五条全部处理。第 5 条确认属实:saveSpaceCentroid 在 lib/ 里零调用点,
真实使用下中心表永久空着,票③ 的「dim unknown」会从边角变成常态。
W1/W5 信号转发(最硬的一条)
- execute(args, exec) → commit(input, {signal}) → embedText(text, signal)
- abort 时按「向量化失败」降级落盘:块写入、vector 留空
- 依据 dsh-tools/lib/types/index.d.ts:120-122
★ 一处我判错了,据实记录
W5 我原本断言「abort 时 isError === false」,实测是 true。查了实现:
dsh-tools/lib/index.js:3408 在工具成功后**再检查一次 signal**,
已 abort 就用 toolAbortedResult 覆盖成功结果。所以 isError 不归本插件控制,
断言它等于和 harness 对抗。改为断言**副作用**(块已写入、vector 为空),
并额外断言 error.info.code === 'ABORTED'。
W2 九个调用点补 await
scripts/checkpoints.js ×4、test/plugin.test.js ×5、test/loader.test.js ×2
(loader 两处保持并发语义用 Promise.all)。
原本绿是因为「无 embedder 时第一个 await 之前就写完 putBlock」——
这个隐式事实一旦被一行 await 打破,九处会静默变成「块没写」,
而且红会出现在离原因很远的地方(靠后面能否召回间接断言)。
W3 空指纹先判后嵌
原来顺序是 await embedText → putBlock 抛 → catch → 再 putBlock,
语义终点对但白付一次网络往返,且与 recall.js:430 的口径不一致。
改为 embed 之前判 space.fingerprint === ''。
W4 写侧维度校验
commit 现在检查 vector.length === space.dimension,不符按 embed failure 处理
(无向量落盘)。不落坏向量的理由:召回侧会按 skippedDim 静默跳过,
用户看到的是「记忆里有这条,但永远召不回来」。
W8 建中心入口(第 5 条)
新增 store.rebuildCentroid(fingerprint),移植 RebuildCentroid
(centroid_store.go:246-320):
· 只取该空间的带向量行
· 维度不符跳过不强制
· 按 L2 归一化后累加均值
· 均值范数 >= 0.6 才判 anomalous(centroid.go:87)
· 计入 stats:meanLength / avgPairCosine / skippedRows
并新增 MEAN_THRESHOLD 常量。
★ W8 第一版判据是错的:我用完全共线的向量(meanLength = 1.0000)
去测 0.6 阈值,把阈值改成 0.95 仍然通过。已改用 ±35° 锥
(meanLength ≈ 0.879,落在 0.6 与 0.95 之间),并加断言
「语料必须落在两阈值之间」,否则测试自身就失去区分力。
判据与变异(全部在 /tmp 干净副本上跑)
W4 维度不符 → 块在、vector=''、fingerprint='' | 删维度检查 → 红
W5 abort → 块在、vector=''、elapsed<2s | 不传 signal → 红(实测 5003ms)
W6 无 embedder 时 {id,created} 与 putBlock 同形 | —
W7 空指纹 → embedText 调用 0 次 | 改为事后判 → 红
W8 同空间重建、跨空间隔离、坏维度跳过、阈值判定 | 去空间过滤 → 3 条红
| 去维度过滤 → 1 条红
| 阈值 0.6→0.95 → 1 条红
未越界
- 四态计数(noVector/unknownSpace/spaceMismatch/embedFailure)留给 F/D
- recallBlocks 仍然同步、未接入 vectorScores(V 组的事)
- 票③「dim 来自中心元数据而非配置」继续有效;saveSpaceCentroid 仍在用
dim ?? space.dimension,已在注释里标记为 C 组要区分「没建过」与
「建过但维度取自配置」——声明不是证据
顺序按你的调整记下:H(完工) → V → C → F → D。
C 组准入条件已满足一半:机制(rebuildCentroid)有了,
「谁触发建中心」待 C 组定。
验证
- npm test 99/99(90 + W4/W5/W6/W7 六条 + W8 三条)
- npm run check 10/10
- 提交前连跑 3 次 test + 3 次 check,6 次全绿
495 lines
19 KiB
JavaScript
495 lines
19 KiB
JavaScript
/**
|
|
* dsh-memory — block memory for dsh, entry point.
|
|
*
|
|
* This is **phase A** of `docs/feasibility.md` §7.1: prove that a block can go
|
|
* into dsh, be recalled, and reach the prompt, with **no embedding provider**.
|
|
*
|
|
* ## The one thing to read before touching injection
|
|
*
|
|
* dsh has two obvious-looking injection points, and only one of them can reach
|
|
* the current turn's input.
|
|
*
|
|
* | hook | gets this turn's input? | durable? |
|
|
* |---|---|---|
|
|
* | `ctx.systemPrompt.context({ text })` | no — `AssembleContext` is `{scope, signal}` plus a merged-in `agent` | yes (appended as a runtime-context snapshot) |
|
|
* | `ctx.on('agent/pre-step')` | yes — receives `decision.messages` | yes (the loop appends whatever you return) |
|
|
*
|
|
* Verified in the harness, not assumed:
|
|
* · `dsh-system-prompt/lib/types/index.d.ts:37-44` — `AssembleContext` has only scope+signal
|
|
* · `dsh-agent-loop/lib/index.js:902-934` — `preStep` claims the inbox, assembles the
|
|
* prompt, then runs the `agent/pre-step` waterfall with those messages
|
|
* · `dsh-agent-loop/lib/index.js:1066-1073` — **every returned message is appended
|
|
* as a durable `user/message`**, so "shadow" injection there is not shadow: it
|
|
* becomes conversation history (see `docs/shadow-context.md` §1.3)
|
|
*
|
|
* Therefore: the input-independent index uses `systemPrompt.context`; per-turn
|
|
* recall uses `agent/pre-step`, prepended so it runs before listeners that may
|
|
* want the final word.
|
|
*
|
|
* @module dsh-memory
|
|
*/
|
|
|
|
import { Service } from '@deepseek-ai/cordis'
|
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
import { createHttpEmbedder } from './embed/http.js'
|
|
import { recallBlocksHybrid, renderRecall } from './recall.js'
|
|
import { BlockStore } from './store.js'
|
|
|
|
/** Cordis plugin name, also used as the `source.kind` of injected context. */
|
|
export const name = 'dsh-memory'
|
|
|
|
/**
|
|
* Services this plugin needs.
|
|
*
|
|
* `systemPrompt` and `tools` are both present in any standard dsh composition
|
|
* (`dsh-base/cordis.patch.yml`), so requiring them narrows nothing.
|
|
*/
|
|
export const inject = ['systemPrompt', 'tools']
|
|
|
|
/**
|
|
* Plugin configuration.
|
|
*
|
|
* @typedef {object} Config
|
|
* @property {string} [path] sqlite file path; defaults to `<cwd>/.dsh-memory/memory.db`
|
|
* @property {number} [topK] recall hits injected per turn (default 3)
|
|
* @property {boolean} [inject] false = register only the tool (default true)
|
|
* @property {boolean} [wal] sqlite WAL mode (default true)
|
|
* @property {string} [toolName] model-facing tool name (default `stablemem_commit`).
|
|
* Tool names are unique per tool layer and `isolate()` does not partition them
|
|
* (`ToolRuntime.register` throws on a duplicate), so a preset that mounts a
|
|
* second instance must give it a distinct name.
|
|
* @property {object} [embed] vector-space configuration. Omit to run without
|
|
* vectors (phase A behaviour, symbol path only).
|
|
* @property {string} [embed.endpoint] embedding endpoint
|
|
* @property {number} [embed.dimension] vector width; required when `endpoint` is set
|
|
* @property {string} [embed.model] model name forwarded in the request body
|
|
* @property {string} [embed.apiKey] bearer credential
|
|
* @property {string} [embed.fingerprint] explicit space identity (overrides derivation)
|
|
* @property {number} [embed.timeoutMs] request timeout
|
|
* @property {object} [embedder] a pre-built Embedder, bypassing `embed`. Used by
|
|
* tests and by any adapter that is not the bundled HTTP one.
|
|
*/
|
|
|
|
/**
|
|
* The `stablemem` service: durable block storage plus recall.
|
|
*
|
|
* Registered as `ctx.stablemem` so other plugins can use it without going through
|
|
* tools, and so a preset subtree can `isolate: { stablemem: true }` it later
|
|
* (docs/shadow-context.md §5.2).
|
|
*/
|
|
export class StableMem extends Service {
|
|
/**
|
|
* ★ These are plain fields, NOT `#private`.
|
|
*
|
|
* cordis resolves a service through a **proxy** (`ctx.stablemem !== instance`),
|
|
* and JS private fields are bound to the declaring object, so any method that
|
|
* touches `this.#store` throws
|
|
* `Cannot read private member #store from an object whose class did not declare it`
|
|
* when reached through `ctx.<name>`. Verified against the real Context.
|
|
* A cordis service must therefore keep its state in ordinary properties.
|
|
*
|
|
* @type {BlockStore}
|
|
*/
|
|
store
|
|
/** @type {number} */
|
|
topK
|
|
/**
|
|
* Vector capability, or null. Phase A has none; group H supplies the adapter.
|
|
* Kept as a plain property (not `#private`) — cordis hands out a proxy, and
|
|
* private fields do not survive it.
|
|
* @type {import('./recall.js').Embedder | null}
|
|
*/
|
|
embedder
|
|
|
|
/**
|
|
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
* @param {{ store: BlockStore, topK?: number,
|
|
* embedder?: import('./recall.js').Embedder | null }} options
|
|
*/
|
|
constructor(ctx, { store, topK = 3, embedder = null }) {
|
|
super(ctx, 'stablemem')
|
|
this.store = store
|
|
this.topK = topK
|
|
this.embedder = embedder
|
|
// Release the sqlite handle with the owning fiber, so a plugin unload does
|
|
// not leak the file lock (checkpoint V5).
|
|
ctx.effect(() => () => this.store.close(), 'dsh-memory.store')
|
|
}
|
|
|
|
/**
|
|
* The configured vector space, or null when running symbol-only.
|
|
*
|
|
* ★ This single object is the ONLY source of `fingerprint`. Both write paths
|
|
* (block rows and centroid rows) take the string from here rather than
|
|
* deriving it themselves: HA looks the centroid up by fingerprint
|
|
* (`block_recall.go:92`), so two independently-derived spellings would make
|
|
* that lookup miss with no error — centering silently never applies.
|
|
*
|
|
* `info()` is synchronous and memoised in the adapter, so the string is
|
|
* computed once per process and reused forever.
|
|
*
|
|
* @returns {{fingerprint: string, dimension: number} | null}
|
|
*/
|
|
space() {
|
|
return this.embedder === null ? null : this.embedder.info()
|
|
}
|
|
|
|
/**
|
|
* Commit one text block.
|
|
*
|
|
* When a vector space is configured the block is embedded and stamped with
|
|
* `space().fingerprint`. When it is not — or the embedder fails — the block is
|
|
* still written, with an empty vector. Storing an invented vector would make it
|
|
* participate in scoring and always rank last, which is a silently wrong
|
|
* memory; an empty one honestly says "not embedded yet".
|
|
*
|
|
* @param {{ text: string, source?: string, scene?: string, semanticType?: string,
|
|
* vector?: number[], fingerprint?: string }} input
|
|
* @returns {Promise<{ id: string, created: boolean, embedded: boolean }>}
|
|
*/
|
|
async commit(input, options = {}) {
|
|
// An explicit vector+fingerprint pair (tests, migrations, or a caller that
|
|
// already embedded) is written verbatim.
|
|
if (Array.isArray(input.vector) && input.vector.length > 0) {
|
|
const written = this.store.putBlock(input)
|
|
return { ...written, embedded: true }
|
|
}
|
|
|
|
const space = this.space()
|
|
// No space configured, or the space cannot name itself: store the block with
|
|
// no vector. Checking the fingerprint BEFORE calling embedText avoids paying
|
|
// for a round-trip whose result could never be labelled with a space.
|
|
if (space === null || typeof space.fingerprint !== 'string' || space.fingerprint === '') {
|
|
return { ...this.store.putBlock(input), embedded: false }
|
|
}
|
|
|
|
let vector
|
|
try {
|
|
// `options.signal` is the tool call's cancellation. Forwarding it is
|
|
// required by the tool contract (dsh-tools/lib/types/index.d.ts:120-122)
|
|
// and it bounds latency: without it the adapter would hold the call for up
|
|
// to its own timeout while the user has already cancelled the turn.
|
|
vector = await this.embedder.embedText(input.text, options.signal)
|
|
} catch {
|
|
// Cancelled, or the vector side is down: the block still lands with no
|
|
// vector (decision §0.4). An abort must not turn into "nothing was written".
|
|
return { ...this.store.putBlock(input), embedded: false }
|
|
}
|
|
|
|
// Write-side dimension check. Recall already skips mismatched vectors
|
|
// (recall.js), so storing one would produce a block that exists, looks fine,
|
|
// and can never be recalled — the exact silent failure group F exists to
|
|
// eliminate. Treat it as an embed failure instead of storing a bad vector.
|
|
if (!Array.isArray(vector) || vector.length !== space.dimension) {
|
|
return { ...this.store.putBlock(input), embedded: false }
|
|
}
|
|
|
|
const written = this.store.putBlock({
|
|
...input,
|
|
vector,
|
|
// ★ From the space object, never re-derived and never from input.
|
|
fingerprint: space.fingerprint,
|
|
})
|
|
return { ...written, embedded: true }
|
|
}
|
|
|
|
/**
|
|
* Persist the centroid for the configured space.
|
|
*
|
|
* Takes NO fingerprint argument on purpose: the caller cannot supply a
|
|
* different spelling than the one {@link commit} stamped on the blocks.
|
|
*
|
|
* @param {{ dim?: number, vectorCount: number, mean: number[],
|
|
* isAnomalous: boolean, stats?: object }} centroid
|
|
* @returns {{ fingerprint: string }}
|
|
*/
|
|
saveSpaceCentroid(centroid) {
|
|
const space = this.space()
|
|
if (space === null) throw new Error('dsh-memory: no vector space configured')
|
|
this.store.saveCentroid({
|
|
fingerprint: space.fingerprint,
|
|
dim: centroid.dim ?? space.dimension,
|
|
vectorCount: centroid.vectorCount,
|
|
mean: centroid.mean,
|
|
isAnomalous: centroid.isAnomalous,
|
|
stats: centroid.stats ?? {},
|
|
})
|
|
return { fingerprint: space.fingerprint }
|
|
}
|
|
|
|
/**
|
|
* Link two blocks.
|
|
* @param {object} edge
|
|
* @returns {{ created: boolean }}
|
|
*/
|
|
link(edge) {
|
|
return this.store.addEdge(edge)
|
|
}
|
|
|
|
/**
|
|
* Recall hits for one query.
|
|
* @param {string} query
|
|
* @param {number} [topK]
|
|
*/
|
|
async recall(query, topK = this.topK) {
|
|
// Group A ships the async seam only: with no embedder configured this is
|
|
// exactly `recallBlocks` (pinned by the golden fixture in test/phase-b-a).
|
|
return recallBlocksHybrid(this.store, this.embedder, query, { topK })
|
|
}
|
|
|
|
/**
|
|
* Text to inject for this step, or '' when nothing is relevant.
|
|
*
|
|
* The query is the **last user-role text** in the batch: `decision.messages` is
|
|
* the batch admitted for this step, and steering appends rather than replaces,
|
|
* so the last user message is the actual prompt.
|
|
*
|
|
* @param {ReadonlyArray<{role: string, content: ReadonlyArray<{type: string, text?: string}>}>} messages
|
|
* @returns {string}
|
|
*/
|
|
async contextForStep(messages) {
|
|
const query = lastUserText(messages)
|
|
if (query === '') return ''
|
|
const hits = await this.recall(query)
|
|
if (hits.length === 0) return ''
|
|
return renderRecall(hits, name)
|
|
}
|
|
|
|
/**
|
|
* Constant index text for `systemPrompt.context`.
|
|
*
|
|
* Deliberately input-independent: that hook cannot see the current turn's text
|
|
* (module note above). A count is still worth its tokens — the model learns that
|
|
* durable memory exists without paying a per-turn recall cost.
|
|
*
|
|
* @returns {string}
|
|
*/
|
|
renderIndex() {
|
|
const count = this.store.countBlocks()
|
|
if (count === 0) return ''
|
|
return (
|
|
`[Memory · ${name}] ${count} durable note(s) in block memory. ` +
|
|
'Relevant ones are recalled into your timeline automatically; ' +
|
|
'record new facts with the `stablemem_commit` tool.'
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Source kinds that carry a HUMAN prompt.
|
|
*
|
|
* Everything else a plugin injects is user-ROLE but not user-SPEECH:
|
|
* `runtime-context` (dsh-agent-loop's working-directory/sandbox snapshot),
|
|
* `skill-catalog` / `skill-invocation` (dsh-tool-skill), `agent-instructions`,
|
|
* `time-context`, `goal`, and any snapshot-style contribution.
|
|
*
|
|
* ★ Why this allowlist exists (regression, found in a real profile run):
|
|
* A real step batch is not `[question]` — it is
|
|
*
|
|
* [ user question ][ runtime-context snapshot ][ skill catalog reminder ]
|
|
*
|
|
* and the injected ones are `role: 'user'` too. Picking "the last user-role
|
|
* message" therefore returned the SKILL CATALOG, so every real turn used a list
|
|
* of skill names as its recall query and matched nothing. Auto-recall was 100%
|
|
* dead in production while all unit checkpoints were green, because the tests
|
|
* built 2-message batches.
|
|
*/
|
|
const HUMAN_PROMPT_KINDS = new Set(['user'])
|
|
|
|
/**
|
|
* Pull the human prompt out of a step's message batch.
|
|
*
|
|
* Scans backwards (the newest human turn wins) but filters by
|
|
* `source.kind` — see {@link HUMAN_PROMPT_KINDS} for why role alone is wrong.
|
|
* Unknown kinds are treated as injected: a plugin's own snapshot must never
|
|
* become the recall query, and a genuine human message always carries
|
|
* `kind: 'user'` (`dsh-llm/lib/types/message.d.ts`, `MessageSourceMap.user`).
|
|
*
|
|
* @param {ReadonlyArray<{role: string, source?: {kind?: string},
|
|
* content: ReadonlyArray<{type: string, text?: string}>}>} messages
|
|
* @returns {string} the question text, or '' when the batch has no human prompt
|
|
*/
|
|
export function lastUserText(messages) {
|
|
if (!Array.isArray(messages)) return ''
|
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
const message = messages[index]
|
|
if (message?.role !== 'user') continue
|
|
const kind = message.source?.kind
|
|
if (typeof kind !== 'string' || !HUMAN_PROMPT_KINDS.has(kind)) continue
|
|
const text = (message.content ?? [])
|
|
.filter((block) => block?.type === 'text' && typeof block.text === 'string')
|
|
.map((block) => block.text)
|
|
.join('\n')
|
|
.trim()
|
|
if (text !== '') return text
|
|
}
|
|
return ''
|
|
}
|
|
|
|
/**
|
|
* Register the `stablemem_commit` tool.
|
|
*
|
|
* `output.schema` + `output.render` are mandatory in dsh
|
|
* (`dsh-tools/lib/types/index.d.ts:117`), unlike HologramAgent's
|
|
* description-only `toolDefWith` (`internal/agent/core/tooldefs.go`).
|
|
*
|
|
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
* @param {StableMem} mem
|
|
* @param {string} toolName model-facing tool name
|
|
* @returns {() => void} disposer
|
|
*/
|
|
function registerTools(ctx, mem, toolName) {
|
|
return ctx.tools.register(
|
|
defineTool({
|
|
name: toolName,
|
|
description:
|
|
'Store one durable fact or note in long-term block memory. Use it for ' +
|
|
'stable facts worth recalling later (addresses, ports, paths, decisions, ' +
|
|
'preferences). Keep each note short and self-contained: recall matches ' +
|
|
'on the note text itself, so include the subject, not just the value.',
|
|
parameters: {
|
|
text: {
|
|
type: 'string',
|
|
description: 'The note. Self-contained: include subject, not just the value.',
|
|
required: true,
|
|
},
|
|
source: {
|
|
type: 'string',
|
|
description: 'Optional origin label (channel, tool, file).',
|
|
},
|
|
scene: {
|
|
type: 'string',
|
|
description: 'Optional scene key for prefix-matching recall.',
|
|
},
|
|
},
|
|
output: {
|
|
schema: { type: 'string' },
|
|
render: (_args, value) => [{ type: 'text', text: value }],
|
|
},
|
|
// Commits are independent and idempotent (content-addressed id), so they
|
|
// regroup safely with sibling calls.
|
|
isConcurrencySafe: () => true,
|
|
async execute(args, exec) {
|
|
const { id, created, embedded } = await mem.commit(
|
|
{
|
|
text: args.text,
|
|
source: args.source ?? 'tool',
|
|
scene: args.scene ?? '',
|
|
},
|
|
// Forward the call's cancellation into the embedding request.
|
|
{ signal: exec.signal },
|
|
)
|
|
const suffix = embedded ? '' : ' (stored without a vector)'
|
|
return `${created ? 'Committed' : 'Already present'} block ${id}${suffix}.`
|
|
},
|
|
}),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Apply the plugin.
|
|
*
|
|
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
* @param {Config} [config]
|
|
*/
|
|
export function apply(ctx, config = {}) {
|
|
const path = config.path ?? defaultPath()
|
|
const store = new BlockStore({ path, wal: config.wal ?? true }).open()
|
|
// The adapter lives in exactly one file (lib/embed/http.js) so swapping the
|
|
// transport never touches recall, scoring, or storage (guarded by G5).
|
|
//
|
|
// `config.embedder` accepts an already-built Embedder. It exists so tests can
|
|
// inject a failing/slow/nameless provider without standing up an HTTP server —
|
|
// and so a future local-ONNX adapter can be mounted without touching this file.
|
|
const embedder =
|
|
config.embedder ??
|
|
(config.embed?.endpoint !== undefined ? createHttpEmbedder(config.embed) : null)
|
|
const mem = new StableMem(ctx, { store, topK: config.topK ?? 3, embedder })
|
|
|
|
// Injection surface 1 — constant index. Right for input-independent text.
|
|
//
|
|
// ★ The prompt-context NAME must be unique per prompt layer, and `isolate()`
|
|
// does NOT partition prompt names: `SystemPrompt` keeps one `NamedEntries` per
|
|
// layer and throws `prompt context "..." is already registered`
|
|
// (dsh-system-prompt/lib/index.js:204). So a preset mounting a second instance
|
|
// must not reuse the literal name — deriving it from the database path keeps
|
|
// two instances in one realm distinguishable, and is stable across restarts.
|
|
ctx.systemPrompt.context({
|
|
name: `memory:index:${instanceKey(path)}`,
|
|
order: ctx.systemPrompt.getContextOrder('WEB_SURFACE'),
|
|
interpolate: false,
|
|
text: () => mem.renderIndex(),
|
|
})
|
|
|
|
// Injection surface 2 — per-turn recall. The only hook that sees the input.
|
|
if (config.inject ?? true) {
|
|
ctx.on(
|
|
'agent/pre-step',
|
|
async (payload, next) => {
|
|
const decision = await next()
|
|
if (decision.kind === 'reject' || payload.signal.aborted) return decision
|
|
const injected = await mem.contextForStep(decision.messages)
|
|
if (injected === '') return decision
|
|
return {
|
|
...decision,
|
|
messages: [
|
|
...decision.messages,
|
|
createUserMessage({
|
|
content: [{ type: 'text', text: injected }],
|
|
// `form: 'snapshot'` matches how dsh-time-context labels its own
|
|
// injected context (dsh-time-context/lib/index.js:238). `kind` is
|
|
// this plugin's own — MessageSourceMap is merge-extensible
|
|
// (dsh-llm/lib/types/message.d.ts:96-105) and user messages accept
|
|
// unknown kinds (dsh-session/lib/index.js:1216).
|
|
source: {
|
|
kind: name,
|
|
form: 'snapshot',
|
|
sections: [{ name, text: injected }],
|
|
},
|
|
}),
|
|
],
|
|
}
|
|
},
|
|
// Prepend: recall should be visible to listeners that run later.
|
|
{ prepend: true },
|
|
)
|
|
}
|
|
|
|
registerTools(ctx, mem, config.toolName ?? 'stablemem_commit')
|
|
|
|
return mem
|
|
}
|
|
|
|
/**
|
|
* A stable, filesystem-safe key distinguishing one plugin instance.
|
|
*
|
|
* Used to keep prompt-context names unique when a realm hosts more than one
|
|
* instance (a preset isolating `stablemem` but not the prompt layer). Derived
|
|
* from the database path so it survives restarts and stays readable in
|
|
* `/dump-config`.
|
|
*
|
|
* @param {string} path the configured database path
|
|
* @returns {string}
|
|
*/
|
|
export function instanceKey(path) {
|
|
let hash = 0
|
|
for (const char of String(path)) hash = (hash * 31 + char.codePointAt(0)) >>> 0
|
|
return hash.toString(36)
|
|
}
|
|
|
|
/**
|
|
* Default database location.
|
|
*
|
|
* Under the process cwd so a profile sees one memory per workspace, matching how
|
|
* `dsh-storage-json` refuses to guess a location by default
|
|
* (`dsh-storage-json/lib/types/index.d.ts:14-18`). Overridable via `config.path`.
|
|
*
|
|
* @returns {string}
|
|
*/
|
|
export function defaultPath() {
|
|
return `${process.cwd()}/.dsh-memory/memory.db`
|
|
}
|
|
|
|
export default { name, inject, apply, StableMem }
|