jianf eac9f0fca6 docs: phase B 开工前决策建议 —— 异步化回归风险可结构性消除
回应四条裁定与一个交叉点。不含实现。

核心结论:草案 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 记录本轮两处自我修正。

参考仓未改动。
2026-10-30 09:52:06 +08:00

HologramAgent-DSH-Memory-Port

把 HologramAgent 的记忆系统移植到 dsh 插件。本仓分两部分:

  1. 调研报告(已交付)
    • docs/feasibility.md — 三层记忆移植可行性评估(结论:只能做子集,L2+L3 可移植,L1 抓不住)
    • docs/shadow-context.md — 影子上下文方案核实(结论:路线② preset 可行,路线① 只是看着可行)
  2. 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 — AssembleContext has only scope and signal (agent is added by dsh-agent's declaration merging; still no messages).
  • dsh-agent-loop/lib/index.js:902-934 — preStep() claims the inbox, calls systemPrompt.assemble(), then runs the agent/pre-step waterfall with those messages.
  • dsh-agent-loop/lib/index.js:1066-1073 — the loop then does session.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 is prepend: true so 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 at internal/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 like billing0 yields a bare 0 that "exactly" matches every block containing a zero.
  • A matched word prefers blocks where it is not a prefix of a longer word (billing should beat billing2), loosely mirroring isCrossWordTailMatch (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.

  1. A cordis service must not use JS #private fields. ctx.stablemem is 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.

  2. isolate() does not partition prompt-context names or tool names. It partitions service lookups. Two instances in one realm still collide on systemPrompt.context({name}) (dsh-system-prompt/lib/index.js:204) and tools.register unless the names differ. Hence the configurable toolName, and the prompt-context name derived from the database path (memory:index:<key>), which stays unique and stable.

  3. 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

Description
评估 HologramAgent 三层记忆系统移植为 dsh 插件的可行性(调研/评估,不产出实现)
Readme 651 KiB
v0.1.0 Latest
2026-11-05 12:39:06 +08:00
Languages
JavaScript 99.8%
Shell 0.2%