G2† 补成自动化(你坚持的那条,我认)
你指出 G 组只有三条变异是自动化断言,G2† 只活在我的 commit message 里 ——
手工记录的变异不会随时间复现。已编成 test,并扩到覆盖**每一条**降级路径:
DEGRADATION_PATHS 数据表(6 条,新增第五条时未覆盖则测试即红)
embedder is null / undefined / 无 embedText
★ 无 info() ← 你点名的那条
★ info() 的 fingerprint 为空串
★ info() 的 fingerprint 非字符串
+ 每条都断言 degraded=no_embedder、仍能召回、且零 fetch
+ 额外一条「指纹检查在 embedText 之前」(钉顺序,不只钉结果)
变异实测打红
把 embedText 调用挪到指纹检查之前
not ok 8 - G2†: the fingerprint check runs BEFORE embedText is called
让无指纹分支发一次 fetch
not ok 5 - G2†: "embedder with no info()" ... zero network calls
H 组实现(lib/embed/http.js,单文件,G5 守卫)
- 协议与 HA 逐字段一致:model / modality / side / text;响应两种形状都收
- 指纹 http:<model>:<dim>:<sha256(规范化 endpoint)[:8]>
规范化(hash 之前):去空白 / scheme+host 小写 / 去尾斜杠 / 去默认端口
不做 query 重排与 .. 解析(那些差异通常是真实的)
★ 构造时算一次并复用 —— 因为同一个串必须同时进两张表
- 失败在 adapter 层是硬错误(非 2xx / 无 embedding / 维度不符 / 超限 / 超时),
在召回层被 catch 成 degraded='embed_error',退回 FTS5
- 维度不符绝不截断不填充(block_recall.go:135-138)
票② 两表指纹一致 —— 落在 H 组 schema,未拖到 F
- memory_blocks 加 vector / fingerprint 两列(phase A 的表已发布,
这里用 CREATE TABLE IF NOT EXISTS 的新列,旧库需 ALTER;已记入待办)
- graph_centroid 列名对齐 HA(vector_count / vector / stats / built_at),
并把 is_anomalous 提成独立列(票③口径),不塞进 stats JSON
- putBlock 拒绝「有向量但无指纹」——无标签的向量永远不可安全比较
- putBlock 重提交不带向量时不清空已有向量(与 PutMemoryBlocks 的空值不覆盖同构)
- 新增 saveCentroid / loadCentroid / blockFingerprints
票② 判据
ok - ticket②: block fingerprint and centroid fingerprint are byte-identical
ok - ticket② mutation: a re-derived centroid fingerprint misses the lookup
(加一个尾随空格即查不到,且不抛错 —— 正是必须用断言钉住的原因)
ok - ticket②: a vector without a fingerprint is refused at write time
ok - ticket②: re-committing without a vector must not erase the stored one
H 组变异(8 条,全部实跑打红)
HM1 side 写死 document → not ok 1 - H1
HM2 指纹去掉 endpoint hash → not ok 10/12/13 - F-fp1/fp3/fp4(三条一起红)
HM3 维度不符改为截断 → not ok 5 - H3b
HM4 去掉响应体积上限 → not ok 9 - H6
HM5 保留尾斜杠 → not ok 11 - F-fp2
HM6 非 2xx 当成功 → not ok 4 - H3
HM7 忽略 timeout signal → not ok 8 - H4
HM8 指纹每次 info() 重算 → not ok 15 - F-fp6
一处设计确认:lib/index.js 现在 import './embed/http.js',
G5 仍绿 —— 因为该文件没有 fetch/Authorization 等 transport 标识符。
「可以 import adapter,不可以知道 transport」正是 G5 的设计意图。
验证
- npm test 76/76(56 + H 组 20)
- npm run check 9/9
- 提交前连跑 3 次 test + 3 次 check,6 次全绿
待办(不催,记着)
- 票③ D7 fail-closed(中心表空但有向量行 ⇒ dim 报 null 不报 0)跟 F 组
- phase A 旧库升级需 ALTER TABLE 加两列,随 D 组的 stats 工具一起做
390 lines
15 KiB
JavaScript
390 lines
15 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
|
|
*/
|
|
|
|
/**
|
|
* 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')
|
|
}
|
|
|
|
/**
|
|
* Commit one text block.
|
|
* @param {{ text: string, source?: string, scene?: string, semanticType?: string }} input
|
|
* @returns {{ id: string, created: boolean }}
|
|
*/
|
|
commit(input) {
|
|
return this.store.putBlock(input)
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
const { id, created } = mem.commit({
|
|
text: args.text,
|
|
source: args.source ?? 'tool',
|
|
scene: args.scene ?? '',
|
|
})
|
|
return `${created ? 'Committed' : 'Already present'} block ${id}.`
|
|
},
|
|
}),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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).
|
|
const 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 }
|