回应四条裁定与一个交叉点。不含实现。
核心结论:草案 A1「recallBlocks 改 async」是我图省事的设计偷懒,
把「要拿向量」和「要打分」混在一个函数里。切分后风险归零:
recallBlocks(store, query, opts) 保持同步,签名行为不变
新增可选 opts.vectorScores = Map<blockId, number>
recallBlocksHybrid(store, embedder, q, opts) 新的 async 包装
实测调用面:生产代码 3 行(lib/index.js:124/140/277),
测试代码 0 行(test/store.test.js 5 处 + scripts/checkpoints.js 4 处
全部继续调同步版)。已实测确认 test/plugin.test.js:119 那个
`contextForStep = () => ''` 的同步桩在 async 化后仍兼容(await 非
Promise 值即其本身),所以那条变异测试继续有效。
建议把 guard 组 A1 改成「recallBlocks 保持同步且实现不变」,
并新增 A3:hybrid 在 embedder 为 null 时返回值与同步版逐字段相同
(含 why 字符串)——切分后最大的新风险是两个入口漂移,A3 钉死它。
四条裁定核对
- §2.1 endpoint 规范化:建议固定四条(去尾斜杠 / host 小写 /
省略默认端口 / 去空白),不做 query 重排与路径归一。
- §2.2 四态:可执行,我草案的三态漏了「向量化失败」,裁定并进来更准。
新增 D7:stats 默认不解析向量 JSON(用 length(vector)),维度从
graph_centroid 取。修正一处我原稿的说漏——HA 为算 Dimensions 必须
逐行 decodeVector(block_recall.go:303/313),所以「维度统计」与
「不解析」互斥,只能靠中心表绕开。
- §2.3 centroid 补 stats 列:已列为第一件事。
- §2.4 失败降级:同意,并建议 mode 三值而非 bool
(no_embedder / embed_error / all_mismatched 对用户意义不同)。
§3 建议把 embedder 焊成单方法窄接口(embedText / info / close),
加 G5 源码 grep 守卫:除 lib/embed/http.js 外不得出现 http/endpoint/
fetch/Bearer 等 provider 专属标识——机器可查,比约定强。
§4 交叉点:建议投完整 phase B,按 §1 切分后 5.5-6.5 人天。
但建议你先确认一件事:是否有真实使用中的 dsh 会话在产生块?
若无,可先投 2.5 人天的中间态(向量写入 + 四态可见,不改召回主路径)。
§6 记录本轮两处自我修正。
参考仓未改动。
HologramAgent-DSH-Memory-Port
把 HologramAgent 的记忆系统移植到 dsh 插件。本仓分两部分:
- 调研报告(已交付)
docs/feasibility.md— 三层记忆移植可行性评估(结论:只能做子集,L2+L3 可移植,L1 抓不住)docs/shadow-context.md— 影子上下文方案核实(结论:路线② preset 可行,路线① 只是看着可行)
- MVP 阶段 A 实现(本文件以下内容)— 1–2 天切片:块能进 dsh、能被召回、能进 prompt,不引入 embedding
dsh-memory(阶段 A)
HologramAgent-style block memory for dsh,
as a dsh plugin. This is phase A of the MVP described in
docs/feasibility.md §7.1 — the smallest slice that proves
the whole chain works:
commit a block → store it durably → recall it → get it into the model's prompt
Deliberately not included: embedding providers, vectors, the L1 eviction flow, media blocks. Each is blocked or out of scope for a 1–2 day slice; see What this slice does not do.
★ Read this before touching injection
dsh has two injection hooks that look interchangeable and are not. Getting this wrong silently corrupts the conversation log.
| hook | receives this turn's input? | durable side effect |
|---|---|---|
ctx.systemPrompt.context({ text }) |
No — AssembleContext is {scope, signal} + a merged-in agent |
appends a runtime-context snapshot |
ctx.on('agent/pre-step') |
Yes — payload carries decision.messages |
every message you return is appended as a durable user/message |
Evidence, verified in the installed harness (not inferred):
dsh-system-prompt/lib/types/index.d.ts:37-44—AssembleContexthas onlyscopeandsignal(agentis added bydsh-agent's declaration merging; still no messages).dsh-agent-loop/lib/index.js:902-934—preStep()claims the inbox, callssystemPrompt.assemble(), then runs theagent/pre-stepwaterfall with those messages.dsh-agent-loop/lib/index.js:1066-1073— the loop then doessession.append('user/message', message, { surfaceOp: 'append' })for each returned message.
Consequences, and how this plugin uses them:
- The input-independent index (a block count, "memory exists, here's the tool")
goes through
systemPrompt.context. It cannot see the current question, so it must not pretend to be a recall result. - Per-turn recall goes through
agent/pre-step, because that is the only hook that sees the input. Recall isprepend: trueso it runs before listeners that may want the final word. - Because pre-step output is durable, injected recall becomes part of the
conversation history. That is acceptable here (it is honest: the model saw it),
but it means this plugin is not a shadow-context mechanism. For that
distinction see
docs/shadow-context.md§1.3.
There is no hook for "the context is about to overflow, please evict
something", and no waterfall that can rewrite a loop-built request
(llm/stream receives a deep-frozen GenerateOptions for loop requests —
dsh-llm/lib/types/index.d.ts:37-42).
Install
# from this repo
npm install # nothing to install; dsh packages are peers
The plugin needs node:sqlite (built into Node ≥ 22.5). On Node 22.23 it works
behind an ExperimentalWarning only — no --experimental-sqlite flag.
Mount it in a dsh composition:
- id: memory
name: dsh-memory
config:
path: /path/to/memory.db # optional; defaults to <cwd>/.dsh-memory/memory.db
topK: 3 # optional; recall hits injected per turn
inject: true # optional; false = tool only, no auto-recall
toolName: stablemem_commit # optional; must be unique per tool layer
Use
The model gets one tool:
stablemem_commit({ text, source?, scene? })
and, once at least one block exists, a constant prompt context line telling it that durable memory is available.
Recall runs automatically each turn: the last user message is used as the query,
FTS5 + exact-symbol pinning selects up to topK blocks, and they are injected as
one attributed user message:
[Memory · dsh-memory] Recalled from durable block memory — these are PAST notes, not the current conversation.
[1] (exact 1.01) 值班室分机号 4324,值班人 老周
Programmatic use (another plugin, or a preset subtree):
ctx.stablemem.commit({ text: 'etcd 集群端口 2379', source: 'ops' })
ctx.stablemem.recall('etcd 端口', 5)
ctx.stablemem.link({ sourceKind: 'block', sourceId: a, targetKind: 'block', targetId: b, type: 'supersedes' })
Schema
Three tables, mirroring HologramAgent's (internal/memory/graph.go:2577, :320,
centroid_store.go:58), restricted to the text modality:
| table | purpose | notes |
|---|---|---|
memory_blocks |
the block payload | text_content is the body; source / scene / semantic_type mirror HA's columns |
memory_block_edges |
structural + semantic edges | unique per (source_kind, source_id, target_kind, target_id, type) |
graph_centroid |
vector-space centering | created empty — phase B writes it; creating it now avoids a migration |
memory_blocks_fts |
FTS5 index over text_content |
unicode61 remove_diacritics 2 |
memory_blocks.id is content-addressed
(blk_ + sha256(text)[:12], ported from distill.BlockID,
internal/memory/distill/blocks.go:99), so re-committing identical text is
idempotent and never blanks existing metadata.
Recall design, and why there are no vectors yet
This slice ports HologramAgent's symbol path, not its vector path.
docs/zh/recall-capability-tiers.md measures four query shapes. The one that
works best in production is 形态 1 (queries carrying an exact token: port, IP,
path, version) — which is pure symbol matching. The one that needs vectors is
形态 3 (pure generic description), and it is the weakest even with vectors
(target block ranked 389/2686). So dropping embeddings here costs the tier that
was already marginal, not the tier that carries the product.
Scoring follows HA exactly where it matters:
- An exact numeric hit is a boolean pin (
1.0 + lexical*0.01), never a weight. Folding it into a weighted sum makes it lose to noise — the regression documented atinternal/memory/fusion.go:43-49. - A matched word is a coverage signal, not an exact hit.
- Single-character symbols are rejected (
abstain.go:115"单字不成符号"); without that gate a query likebilling0yields a bare0that "exactly" matches every block containing a zero. - A matched word prefers blocks where it is not a prefix of a longer word
(
billingshould beatbilling2), loosely mirroringisCrossWordTailMatch(distill/split.go:383).
Two documented gaps, asserted rather than hidden (see checkpoint V2):
| gap | why | when |
|---|---|---|
| 纯泛化描述 queries ("公网地址是什么") | needs vectors | phase B |
| 「库里没有」detection (abstention) | needs either vectors or a real CJK tokenizer | phase B/C |
| CJK segmentation | unicode61 keeps a CJK run as ONE token; compensated by a 2..4-char sliding-window fallback ported from cjkWindows (fusion.go:240) |
phase C |
Three harness constraints this slice discovered
Each was found by a failing test, not by reading types — they are recorded here because they will bite anyone writing a second dsh service plugin.
-
A cordis service must not use JS
#privatefields.ctx.stablememis a proxy, not the instance you constructed, and private fields are bound to the declaring object:Cannot read private member #store from an object whose class did not declare it. Keep service state in ordinary properties. -
isolate()does not partition prompt-context names or tool names. It partitions service lookups. Two instances in one realm still collide onsystemPrompt.context({name})(dsh-system-prompt/lib/index.js:204) andtools.registerunless the names differ. Hence the configurabletoolName, and the prompt-context name derived from the database path (memory:index:<key>), which stays unique and stable. -
ctx.systemPrompt.getContextOrder()is an allocator, not a free constant. Prefer a named slot (WEB_SURFACE) over a magic number; the numeric constants are private to the package.
What this slice does not do
| not done | why | where it lands |
|---|---|---|
| embeddings / vectors | no embedding service in dsh (docs/feasibility.md §5.1) |
phase B |
| evicting L1 context into memory | dsh's compaction is summary-replacement, and CompactionTrigger carries no query (§5.3) |
replaced by shadow-context + preset |
| media blocks | dsh attachment is CAS; HA requires bytes inside the block (§5.4) | not planned |
| cross-session / cross-agent memory | one sqlite file per config.path |
phase D |
| abstention | needs vectors or a real tokenizer | phase B/C |
Verify
npm test # 26 tests: store, recall, plugin wiring, real loader, isolation
npm run check # 8 checkpoints with human-readable evidence (V1–V5 + mutations)
npm run check prints one line of evidence per checkpoint. The mapping to
docs/feasibility.md §7.1:
| checkpoint | proves |
|---|---|
| V1 | node:sqlite opens, WAL applies, 100 writes round-trip |
| V2 | recall quality per capability tier: tiers 1–2 must pass, tiers 3–4 pinned as gaps |
| V3 | systemPrompt.context exposes agent and cannot see this step's messages |
| V3b | agent/pre-step injection lands in the accepted batch with our source.kind |
| V3b† | mutation — silencing recall makes V3b fail (the test can turn red) |
| V4 | tools.register schema + render contract, executed end to end |
| V5 | fiber disposal closes the handle; the file is reopenable |
| A5† | mutation — containment-only recall loses discrimination (why FTS is primary) |
Expectations from a real run
✔ V1 journal=wal, rows=100, mid-batch row=blk_cad13afe6824, file on disk
✔ V2 tier1(exact) 30/30 = 100% · tier2(domain) 20/20 = 100%
· known gaps: 泛化查询→0 hit, 库里没有→0 hit (需向量,phase B)
✔ V3 AssembleContext keys = [agent] — no messages/input field
✔ V3b batch 2 msgs; injected kind=dsh-memory, carries "4324"
✔ V4 1 tool(s) visible; execute → "Committed block blk_0be019e8b0e4."
✔ V5 fiber dispose → handle closed; reopened same file successfully
8/8 checkpoints passed
Ported from HologramAgent
Source is read-only reference (/home/program/TrueAgent); nothing is vendored.
| this repo | HologramAgent |
|---|---|
lib/store.js BlockStore |
memory.GraphDB.PutMemoryBlocks (internal/memory/block.go:203) |
lib/store.js blockId |
distill.BlockID (internal/memory/distill/blocks.go:99) |
lib/recall.js querySymbols |
memory.QuerySymbols (internal/memory/fusion.go:145) |
lib/recall.js scoring / pin |
memory.SymbolScore (fusion.go:284), pin at :427 |
lib/recall.js isNumericSymbol |
fusion.go:316 |
lib/recall.js cjkNeedles |
cjkWindows (fusion.go:240) |
lib/recall.js single-char gate |
AbstainCheck guard (abstain.go:115) |
lib/index.js renderIndex header |
crossModalMarkdown (internal/agent/core/crossmodal.go:256) |
License
MIT