Files
HologramAgent-DSH-Memory-Port/README.md
jianf c6595d2cf0 feat(memory-b): H 收尾 W1–W8 —— 信号转发、写侧校验、建中心入口
五条全部处理。第 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 次全绿
2026-10-30 10:35:41 +08:00

31 KiB
Raw Blame History

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.

★ A real batch is not just the question

decision.messages is the batch admitted for the step, and in a real dsh profile the human question is followed by other user-role messages:

[ user question ][ runtime-context snapshot ][ skill catalog reminder ]

All three carry role: 'user'. So "use the last user message as the recall query" returns the skill catalog, and every real turn recalls nothing.

This plugin's first release had exactly that bug. It was found by running the plugin in a real headless profile, not by the test suite — the unit tests built 2-message batches, so they never saw it (all checkpoints were green while auto-recall was 100% dead).

lastUserText therefore filters by source.kind, not by role:

Allowed (the recall query may come from these):

kind what
user every human prompt, including remote/browser ones

Everything else is injected. The rule is an allowlist over source.kind, so an unlisted — including a future — kind fails closed.

Two facts make that strict allowlist safe:

  1. user is the kind for all human prompts, local and remote. A browser prompt arrives under a different source key (user-rpc) but the same kind:
    // dsh-api-session-controller/lib/types/types.d.ts:395-400
    'user-rpc': { kind: 'user'; rpcId: SessionRequestId;
                  clientTimeZone?: string; principal?: Principal }
    
    So filtering on kind (not on source key, and not on "has no extra fields") cannot lock out web users. G6b pins this.
  2. Injected context always declares its own kind. Examples verified as literal declarations in the installed harness:
    kind source
    runtime-context dsh-agent-loop/lib/types/runtime-context.d.ts:13
    skill-catalog dsh-tool-skill/lib/types/index.d.ts:29
    skill-invocation dsh-skill/lib/types/index.d.ts:131
    schedule dsh-schedule/lib/types/runtime.d.ts:5
    agent-message dsh-subagent/lib/types/continuation-messages.d.ts:37
    subagent-settled dsh-subagent/lib/types/continuation-messages.d.ts:38
    agent-instructions dsh-agent-instructions/lib/types/state.d.ts:27
    time-context dsh-time-context/lib/types/index.d.ts:14
    tool-goal, tool-jobs dsh-tool-goal/…/index.d.ts:12, dsh-tool-jobs/…/index.d.ts:16
    user-approval dsh-user-approval/lib/types/index.d.ts:14
    plan-mode, ptc-mode dsh-plan-mode/…/index.d.ts:31, dsh-tools/lib/types/ptc.d.ts:14
    …and more see the G6 table

Scope of the test table — read this before trusting it. G6 covers every user-role injected kind declared in the currently installed harness (23 of them). It is not a claim of exhaustiveness: the full MessageSourceMap surface is larger and grows with each release. That is fine, because the allowlist fails closed, so an unlisted kind is safe by construction. What G6 actually buys is: a rename in the harness breaks a test here rather than silently killing recall in production.

G7 separately pins that a message with no source at all yields ''. Reachability, stated precisely: currently unreachable — assertMessageEventShape requires a non-empty source.kind and runs on both the append path (dsh-session/lib/index.js:1089) and the seed/restore path (:1165). It is therefore defense in depth against a future format change, not a live bug.

Measured effect (4 seeded notes, three batch shapes):

batch shape old rule (last role=user) fixed rule (last human kind)
2-part (the old test shape) 1 hit 1 hit
3-part (+runtime-context) 0 hits 1 hit
4-part (+runtime-context +skill-catalog) 0 hits 1 hit
total 1 / 3 3 / 3

Guarded by these tests in test/plugin.test.js:

test what it pins
regression: extraction the question, not the injection, is returned
regression: end-to-end a real 3-part batch actually recalls (this was 0 hits)
regression mutation reintroducing "last role=user" drops hits to 0
G6 all 23 user-role injected kinds in the installed harness are rejected (parameterised over literal declarations)
G6 mutation "role is user ⇒ accept" must fail on all 23
G6b a user-rpc prompt (kind: 'user' + rpcId/principal) is accepted — the allowlist does not lock out remote users
G7 a message with no source yields ''
G7 mutation dropping the guard admits it, so the guard is load-bearing

★ The lesson: a 2-message batch is a fake batch

The bug above shipped with every checkpoint green. The reason is narrower than "tests were missing":

V3b built its batch as [question] — or [question, injected]. A real dsh step appends injected context after the question, so the very shape that made the bug invisible was the shape the test chose.

Hence a rule for anything added from here on: any test that exercises agent/pre-step must build its batch through realBatch(), which always appends at least a runtime-context and a skill-catalog message after the question. A batch of one human message proves the hook fires; it does not prove the hook reads the right thing. V3b is retained (it still pins injection attribution) but is flagged V3b⚠ as a known blind spot.

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' })

Phase B · group A — the async seam

recallBlocks stays synchronous and unmodified; vectors get a separate async wrapper beside it:

recallBlocks(store, query, opts)                  // sync, unchanged, no embedder
recallBlocksHybrid(store, embedder, query, opts)  // async wrapper

Why the split rather than "make recallBlocks async": every existing caller keeps calling a sync function, so the 9 test call sites and the whole phase-A surface need no edit, and the golden fixture proves the sync path did not move.

With no embedder (or a failing one) the wrapper is exactly recallBlocks, and it reports why it degraded on the result array itself:

const hits = await recallBlocksHybrid(store, null, q)
hits.degraded        // 'no_embedder' | 'embed_error' | 'vector_ready_unused'

degraded is non-enumerable, so it does not appear in JSON.stringify, Object.keys, or deep-equality against phase-A output. It never throws — a dead embedding service must not take recall down (§0.4).

'vector_ready_unused' is deliberate naming: group A computes the query vector but does not feed it into scoring yet, so calling that state 'hybrid' would be a lie that outlives its reason. Group V renames it when vectors actually participate.

Because degraded is non-enumerable it is invisible to any serialization. That makes it fine for tests and useless for the "degradation must be visible" requirement — group D therefore renders counters into the prompt text, not onto an object property.

Guarded by test/phase-b-a.test.js (6 tests) against test/fixtures/golden-phase-a.json: 12 blocks × 15 queries × topK 1/3/5 = 45 cases, comparing id, order, score (exact, not approximate), exact flag, and the why string. A tolerance-based comparison would let a scoring change through, which is the one thing this group exists to stop.

A trap this group found: making Svc.recall() async silently disarmed a regression mutation — await-ing was missing, undefined.length is undefined, and undefined < undefined is false, so an assertion comparing hit counts passed while measuring nothing. Both that test and checkpoint V3c now await. If a future change makes anything else async, grep for .length on a value that may have become a Promise.

Phase B · group G — guards

test/phase-b-g.test.js (behavioural) and test/phase-b-g-static.test.js (source-text) pin the properties that keep vectors from damaging phase A:

# guard mutation that must turn it red
G2 no embedder ⇒ zero network calls (global fetch replaced with a tripwire) add a fetch to the no-embedder branch
G2b lib/ imports only node: builtins + dsh peers add onnxruntime-node
G3 stablemem_commit still writes when the embedder throws; symbol recall still finds it make commit await the embedder and reject
G4 only the text modality; no media columns exist add a data BLOB column
G5 transport identifiers (fetch(, Authorization, Bearer, http://, model_dir, api_key) may appear only under lib/embed/ put an endpoint literal in recall.js
G2† every early-degradation path of recallBlocksHybrid is network-free (6 paths, data-driven) add a fetch to any one of them
G2†b the fingerprint check runs before embedText move the call earlier

Two things this group taught us, both about guards rather than about vectors:

  1. A source-scanning guard must not import the code it scans. G2b/G5 first lived beside the behavioural guards, which import lib/index.js. A broken lib/ then crashed the whole file, so the report was "load failed" instead of "you added a disallowed dependency". They now live in test/phase-b-g-static.test.js, which imports nothing from lib/ and still names the offender when lib/ cannot load at all.
  2. Comment-aware scanning or none. G5's first version flagged "no endpoint" in a comment and "edge endpoint" in store.js. A guard that cries wolf gets disabled, which is worse than not having it. It now strips comments before scanning.

Phase B · group H — HTTP embedding adapter

lib/embed/http.js is the only file allowed to know a transport (enforced by G5). Protocol is byte-compatible with HologramAgent's http provider (internal/memory/vector/http_embedder.go:72-87), so an existing HA sidecar works without a shim:

// request
{ "model": "…", "modality": "text", "side": "query", "text": "…" }
// response — either shape accepted
{ "embedding": [ … ] }        { "data": [ { "embedding": [ … ] } ] }

Enable it:

- id: memory
  name: dsh-memory
  config:
    embed:
      endpoint: http://127.0.0.1:18999/embed
      dimension: 2048
      model: jina-v5
      # apiKey / fingerprint / timeoutMs optional

Omit embed entirely to run symbol-only, exactly as phase A did.

Fingerprint: http:<model>:<dim>:<sha256(normalised endpoint)[:8]>

HA derives http:<model>:<dim> (http_embedder.go:99) — model and dimension only. That is unsafe once the endpoint is configurable: two deployments of the same model name at the same dimension produce the same fingerprint but different vectors, so their blocks compare silently and wrongly. The endpoint hash closes that. It is computed once at construction and reused, because the same string must reach both tables:

Ticket ② constraint. memory_blocks.fingerprint and graph_centroid.fingerprint must be byte-identical for the same space. HA looks the centroid up by fingerprint (block_recall.go:92), so two spellings make the lookup miss with no error — centering just never applies and scores quietly degrade.

This is enforced structurally, not by discipline: StableMem.space() is the only source of the string, commit() stamps space().fingerprint, and saveSpaceCentroid() takes no fingerprint argument at all — so a caller cannot supply a second spelling. The set assertion is SELECT DISTINCT fingerprint FROM memory_blocks ≡ the centroid keys. Mutations verified: prefixing the centroid key, and deriving it from the block text, each turn ticket②-strong red.

Ticket ③ boundary. A space with embedded blocks but no centroid row reports dim: null, never 0 — zero reads like a real answer. loadCentroid() returns undefined for a missing row rather than throwing: HA treats a missing or under-sampled centroid as normal degradation (centroid_store.go:144-184), not a recall error. The dimension comes from centroid metadata, never from length(vector). The four-state counters belong to groups F/D.

Endpoint normalisation (before hashing, so cosmetic differences don't each cause a full re-embed): trim, lowercase scheme+host, drop trailing /, drop an explicit default port. It deliberately does not reorder query strings or resolve ..: those differences are usually real.

Write path

commit() embeds before writing, which adds four obligations the synchronous path did not have. Each has a one-line mutation that turns its test red:

obligation behaviour why
W4 dimension a vector whose length ≠ space().dimension is treated as an embed failure — the block lands with vector: '' recall already skips mismatched vectors, so storing one yields a block that exists, looks fine, and can never be retrieved
W5 cancellation exec.signal is forwarded into embedText; aborting stores the block with no vector otherwise a cancelled turn holds the call for the adapter's full 30 s timeout, and isConcurrencySafe siblings queue behind it
W6 result shape with no embedder, commit() returns the same {id, created} as putBlock and the block is immediately FTS-recallable nine call sites relied on "no embedder means it happens synchronously" — true today, silently false the moment an await is added
W7 empty fingerprint checked before embedding, so an unnamed space costs zero round-trips the previous order embedded first and threw afterwards, paying for a result it could never label

On W5, isError is not the assertion: after a body returns, the tool registry re-checks the signal and replaces a success with its canonical abort result (dsh-tools/lib/index.js:3408). What this plugin controls is the side effect — the block is written — so that is what the test asserts.

Building the centroid (W8)

rebuildCentroid(fingerprint) is the entry point that had no caller: without it blocks carried vectors while graph_centroid stayed empty forever, loadCentroid always missed, and centering silently never applied.

Ported from HA's RebuildCentroid (centroid_store.go:246-320): only rows from that space, a mismatched dimension is skipped rather than coerced, the mean is accumulated over L2-normalised vectors, and the mean's norm decides whether to centre at all (MEAN_THRESHOLD = 0.6, centroid.go:87).

Who triggers a rebuild is still an open C-group decision (post-commit ratio vs lazy pre-step check vs an explicit tool). What exists now is the mechanism plus a test corpus that sits between 0.6 and 0.95 — collinear vectors give mean length 1.0 and would pass any threshold, which is exactly how the first version of that test failed to distinguish the mutation.

Failures are hard at the adapter and soft at the recall layer:

layer behaviour
adapter non-2xx / no embedding array / wrong dimension / oversized body / timeout ⇒ throws
recallBlocksHybrid catches, sets degraded: 'embed_error', answers from FTS5

A wrong dimension is never truncated or padded — a silently mis-sized vector produces a plausible-looking but meaningless score (block_recall.go:135-138).

Schema migration

PRAGMA user_version gates the upgrade (currently 0 → 1).

Phase A shipped only CREATE TABLE IF NOT EXISTS, which is a silent no-op on an existing table. A database created by the phase-A build therefore opened without error, recalled fine through FTS, and then threw on the first commit:

PUTBLOCK THREW: table memory_blocks has no column named vector

The memory store became read-only with no diagnostic at load time — a worse failure shape than a loud error, because nothing looks wrong until someone tries to write.

The migration probes pragma_table_info instead of guessing from the version number (a version-0 database created by a crashed phase-B build may already have the columns), then ALTER TABLE ... ADD COLUMN with DEFAULT '' — the same "not embedded yet" sentinel putBlock uses.

graph_centroid needs care: phase A created it with different columns, and the DDL has already run by the time the migration sees the table, so CREATE TABLE IF NOT EXISTS was a no-op. Dropping the old table without rebuilding produces the narrowest failure in the codebase — the store opens cleanly, stamps user_version = 1, accepts putBlock, and throws no such table: graph_centroid only on the first saveCentroid. The rebuild therefore happens inside the migration (DDL_CENTROID).

A populated old centroid table is renamed to graph_centroid_phase_a_<timestamp> rather than dropped (data loss) or treated as fatal (the whole database would refuse to open, for a shape the plugin could not produce by normal use). Startup continues, the old rows stay inspectable with sqlite3.

test what it pins mutation that must turn it red
MIG1 phase-A memory_blocks gains both columns; legacy block readable, FTS-matching, and writable comment out ADD COLUMN vector
MIG2 a fresh database starts at version 1 skip migrate()
MIG3 phase-A database can saveCentroid + loadCentroid after migration drop the centroid rebuild
MIG3b a populated phase-A centroid is renamed aside, not fatal, not lost revert to throw

Verified end to end: a real phase-A database opens, reports user_version = 1, accepts a commit, and its legacy block is still recallable.


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          # 99 tests: store, recall, wiring, loader, isolation, regression, G6/G6b/G7, phase-B A + G + H
npm run check     # 10 checkpoints with human-readable evidence (V1–V5 + V3c + MIG3 + 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⚠ known blind spot: V3b's batch is 2 messages, so it cannot detect the real-batch bug. The regression tests in test/plugin.test.js cover it — see above
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)
MIG3 a phase-A database opens, migrates both tables, and stays fully writable (blocks and centroid)

Plus three regression tests for the real-batch bug described above (test/plugin.test.js): extraction, end-to-end 3-part batch, and a mutation that reintroduces the old rule and asserts the hit count falls to zero.

Expectations from a real run

Pasted from an actual npm run check (not hand-maintained — this section drifted twice by being transcribed by hand):

dsh-memory · phase A checkpoints

  ✔ V1  node:sqlite opens, WAL applies, 100 concurrent writes round-trip
        journal=wal, rows=100, mid-batch row=blk_cad13afe6824, file on disk
  ✔ V2  recall quality per capability tier (tiers 1–2 must pass; 3–4 are pinned gaps)
        tier1(exact) 30/30 = 100% · tier2(domain) 20/20 = 100% · known gaps: 泛化查询→0 hit, 库里没有→0 hit (需向量,phase B)
  ✔ V3  systemPrompt.context: sees agent, cannot see this step’s messages
        AssembleContext keys = [agent] — no messages/input field
  ✔ V3b  agent/pre-step: injected recall lands in the accepted message batch
        batch 2 msgs; injected kind=dsh-memory, carries "4324"
  ✔ V3b†  MUTATION: silencing recall must break V3b (the test can turn red)
        with recall disabled the batch stays at 1 message ⇒ the V3b assertion would fail
  ✔ V3c  realistic 3-part batch: recall uses the question, not the injected context
        batch 4 msgs, hit "4324" · old-rule hits=0 vs fixed hits=1
  ✔ V4  ctx.tools.register: stablemem_commit validates args and renders output
        1 tool(s) visible; execute → "Committed block blk_0be019e8b0e4 (stored without a vector)."
  ✔ V5  unload disposes the sqlite handle; a second close is a no-op
        fiber dispose → handle closed; reopened same file successfully
  ✔ A5†  MUTATION: swapping FTS5 for containment breaks discrimination
        FTS+pin → 1 hit for "4324"; containment → 2 hits (discrimination lost)
  ✔ MIG3  phase-A database opens, migrates, and stays fully writable
        uv=1, blocks=10 cols, centroid=7 cols, legacy block readable + FTS-matching

10/10 checkpoints passed

plugin: name=dsh-memory inject=[systemPrompt, tools]
workspace: /tmp/dsh-memory-cp-kRn1UI (removed)

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