The HTTP protocol carries a `side` field — `query` or `document`, HA's
`PurposeQuery`/`PurposeDocument` (`http_embedder.go:105` / `:109`). The port made
it a construction-time setting of the adapter (default `query`) and then **no
call site ever supplied it**: both the write path (`lib/index.js`) and the recall
path (`lib/recall.js`) took the default.
So every stored block was encoded as if it were a question. Nothing fails when
that happens — both sides produce unit vectors of the declared width — which is
exactly why it survived: the corruption is only visible in retrieval quality,
and retrieval quality was already the known-unmeasured part.
## Why two instances and not a per-call argument
`side` is a property of what you are encoding, so a per-call parameter is the
more natural shape. It is not the shape used here, for one reason: the adapter
travels `side` verbatim in the request body from a closure, and giving it a
second, per-call way to receive the same field would leave two sources of truth
for one wire value. Creating two adapters over the same endpoint/model/dimension
— identical fingerprint, differing only in `side` — keeps the wire contract as it
was and puts the distinction where it belongs: in assembly.
`lib/embed/http.js` is therefore **unchanged**.
An injected `config.embedder` (tests, a future local adapter) has no second
instance, so the write path falls back to it (`documentEmbedder ?? embedder`).
That is the previous behaviour, not a second code path — and it is pinned.
## Criterion
`test/phase-b-v6-sides.test.js` starts a real HTTP server that records the `side`
of every request, then drives the **production assembly** (`apply()` with
`config.embed`):
commit → request body side === 'document'
recall → request body side === 'query'
The assertion is on the field that actually went out on the wire, not on an
internal variable. A second test pins the injection fallback: a single injected
embedder must still serve both paths, or every existing test and every
single-sided adapter breaks.
Mutation M-V6 — pointing the write path back at `this.embedder`:
not ok 1 - V6: storing a block encodes it on the document side, asking encodes the query side
error: V6: a block being STORED must be encoded on the document side. ...
+ actual - expected
+ 'query'
- 'document'
## What this does NOT establish
That the provider actually encodes the two sides differently. `side` is a
request field; what the endpoint does with it is the endpoint's business, and
this repo has no model whose asymmetry is measurable. What is now true is that
the distinction reaches the wire instead of being silently defaulted away.
Gates: npm test 292/292 (0 skipped), npm run check 12/12. Frozen faces untouched.
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.
★ 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:
useris the kind for all human prompts, local and remote. A browser prompt arrives under a different source key (user-rpc) but the same kind:So filtering on// dsh-api-session-controller/lib/types/types.d.ts:395-400 'user-rpc': { kind: 'user'; rpcId: SessionRequestId; clientTimeZone?: string; principal?: Principal }kind(not on source key, and not on "has no extra fields") cannot lock out web users.G6bpins this.- Injected context always declares its own kind. Examples verified as
literal declarations in the installed harness:
kind source runtime-contextdsh-agent-loop/lib/types/runtime-context.d.ts:13skill-catalogdsh-tool-skill/lib/types/index.d.ts:29skill-invocationdsh-skill/lib/types/index.d.ts:131scheduledsh-schedule/lib/types/runtime.d.ts:5agent-messagedsh-subagent/lib/types/continuation-messages.d.ts:37subagent-settleddsh-subagent/lib/types/continuation-messages.d.ts:38agent-instructionsdsh-agent-instructions/lib/types/state.d.ts:27time-contextdsh-time-context/lib/types/index.d.ts:14tool-goal,tool-jobsdsh-tool-goal/…/index.d.ts:12,dsh-tool-jobs/…/index.d.ts:16user-approvaldsh-user-approval/lib/types/index.d.ts:14plan-mode,ptc-modedsh-plan-mode/…/index.d.ts:31,dsh-tools/lib/types/ptc.d.ts:14…and more see the G6table
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":
V3bbuilt 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
The plugin brings no dependency of its own: everything it imports at runtime is a
@deepseek-ai/* peer that ships with the dsh install on the machine.
npm install does not deliver those peers (three of the four are declared
optional), so on its own it leaves a tree that cannot even load the plugin — link
them first, per
Prerequisite: link the harness packages first.
# from this repo
npm install
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):
await ctx.stablemem.commit({ text: 'etcd 集群端口 2379', source: 'ops' })
await ctx.stablemem.recall('etcd 端口', { topK: 5 }) // both are async since phase B
await ctx.stablemem.recall('etcd 端口', { topK: 5, signal }) // pass the turn's signal
ctx.stablemem.link({ sourceKind: 'block', sourceId: a, targetKind: 'block', targetId: b, type: 'supersedes' })
recall(query, 5)used to work and silently stopped meaning anything when the second parameter became an options object — a number is not{topK}, so the default was used. Bothcommitandrecallareasyncnow; omittingawaitoncommitstill writes today only because the no-embedder path has noawaitbeforeputBlock, which is exactly the fragile assumption W6 guards.
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
degradedis 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 against
test/fixtures/golden-phase-a.json, comparing id, order, score (exact, not
approximate), the exact flag, and the why string across every corpus block
× query × topK case in that fixture. A tolerance-based comparison would let a
scoring change through, which is the one thing this group exists to stop.
The fixture itself defines how many cases exist — read the file, not this sentence. Counting them here would reintroduce exactly the unguarded number the rest of this section deliberately avoids.
A trap this group found: making
Svc.recall()async silently disarmed a regression mutation —await-ing was missing,undefined.lengthisundefined, andundefined < undefinedisfalse, so an assertion comparing hit counts passed while measuring nothing. Both that test and checkpoint V3c nowawait. If a future change makes anything else async, grep for.lengthon 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:
- 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 brokenlib/then crashed the whole file, so the report was "load failed" instead of "you added a disallowed dependency". They now live intest/phase-b-g-static.test.js, which imports nothing fromlib/and still names the offender whenlib/cannot load at all. - Comment-aware scanning or none. G5's first version flagged
"no endpoint"in a comment and"edge endpoint"instore.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.fingerprintandgraph_centroid.fingerprintmust 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()stampsspace().fingerprint, andsaveSpaceCentroid()takes no fingerprint argument at all — so a caller cannot supply a second spelling. The set assertion isSELECT DISTINCT fingerprint FROM memory_blocks≡ the centroid keys. Mutations verified: prefixing the centroid key, and deriving it from the block text, each turnticket②-strongred.
Ticket ③ boundary. A space with embedded blocks but no centroid row reports
dim: null, never0— zero reads like a real answer.loadCentroid()returnsundefinedfor 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 fromlength(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 reaches embedText, where it is fused with the adapter's own timeout; aborting stores the block with no vector |
otherwise a cancelled turn holds the call for the adapter's full timeout, and isConcurrencySafe siblings queue behind it. W5 only covers the pipeline — the adapter side is W9's job |
| 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,
isErroris 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.★ W5 initially passed while cancellation was completely broken. It used a hand-written slow embedder that registered its own
abortlistener, so it tested the pipeline against a double. The real adapter'sembedText(text)took one parameter, soembedText(text, signal)silently dropped the signal: a cancelled turn ran the full timeout and then stored the vector anyway — with 99 tests green. W9 now uses a real HTTP server and the realcreateHttpEmbedder, and pins both halves of the contract:
test asserts mutation W9 a caller abort interrupts a real request in < 500 ms make embedTextsingle-parameter and drop the fuse ⇒ redW9b the adapter's own timeout still fires when a caller signal is present drop the timeout from the fuse ⇒ red W9c end-to-end: aborted tool call still writes the block, vector: '',code: 'ABORTED'as W9 Note the mutation had to be clean: replacing only the signature left a dangling
signalreference that threw aReferenceError, which W9 read as "rejected quickly" and passed. A mutation that fails for the wrong reason is not evidence.
Read-path cancellation (W10) and visible degradation (W11)
W10 — the write path was fixed in W9; the read path had the same defect and
nobody had looked. recallBlocksHybrid called embedText(text) with one
argument, and neither StableMem.recall() nor contextForStep() took a signal —
even though the agent/pre-step payload carries one
(dsh-agent/lib/types/runtime-types.d.ts:304-310). Measured on the real adapter
with a hanging server: 2004 ms for an abort issued at 50 ms; at the shipped
30 s timeout, every pre-step could block for 30 s with cancellation having no
effect.
The signal now flows agent/pre-step payload.signal → contextForStep → recall → recallBlocksHybrid → embedText, and W10 measures the fix through the real hook
chain (not by calling the seam directly): 54 ms.
| test | asserts | mutation |
|---|---|---|
| W10 | aborting a pre-step returns in < 500 ms, degrade reported | drop options.signal at embedText ⇒ red, 30001 ms |
| W10b | the same at the seam, so a hook refactor cannot hide a regression | as above |
| W11 | a degraded recall puts a readable notice in the rendered text | remove the notice line ⇒ red |
| W11d | degraded and zero hits still injects the notice (no entries) | restore either short-circuit ⇒ W11d red |
| W11e | default config (no embedder) injects nothing on ordinary turns | make no_embedder speak again ⇒ W11e red |
| W12 | aborted and embed_error carry different labels and different wording |
collapse the two labels ⇒ red |
| W11f | no mode promises "matches below" when there is nothing below; every mode points at the entries when there are | drop the aborted split ⇒ red; drop the default: split ⇒ red |
| W11b | a healthy recall renders no notice | — |
| W11c | end-to-end: aborted pre-step text still carries the hit and the notice | remove the notice line ⇒ red |
W11e — a standing fact must not be repeated every turn. W11d made
"degraded + zero hits" speak up, which is right for a per-turn event and wrong
for a standing configuration fact. Without an embedder is the shipping default,
and renderIndex() already tells the model every turn that block memory exists.
Measured before the fix, phase-A default config, ten ordinary turns
("今天天气怎么样", "你好", "谢谢", …):
10 injected, 2130 chars, mean 213 chars/turn
The cost is not really the tokens — it is that a constant, entry-less memory block teaches the model to skip the section, defeating the purpose of W11 for the degradations that matter. W11d and W11e are mutual guards: a per-turn event speaks, a standing state stays silent.
Mutation discipline (learned four times over). A mutation must modify a file
under lib/. A test that builds its own stand-in function, patches a local arrow,
or otherwise asserts on something it just wrote is a self-test: it cannot fail
when lib/ regresses. Two such tests (formerly W11d†a/W11d†b) were green
under both real mutations while W11d itself went red — they have been deleted.
The surviving guards (W11d†, W11d†ctx, W11e†) apply the mutation to a
temporary copy of lib/, import that copy, and assert the behaviour flips.
If a mutation anchor no longer matches they fail loudly rather than quietly
passing, so a refactor cannot silently disarm them.
The first version of these guards patched
lib/in place and restored it. That raced every other test file:node --testruns files in parallel processes, so another suite could import the module inside the mutated window. It produced roughly a 1-in-30 spurious failure whose symptom looked like a flaky assertion rather than a harness bug — the same class of "sometimes green" that is worst to debug. Mutating shared state is never safe to run concurrently with the code under test, so the mutation now happens on a copy. Verified with 30 consecutive full runs: 0 failures.
Why the notice exists at all:
hits.degradedis non-enumerable (group A needs the golden comparison undisturbed), so it is invisible toJSON.stringify,Object.keys, and every serialised log. A test asserting that property proves nothing about what the model receives. The rendered text is the only channel, so "degradation is visible" has to be asserted on the text.
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 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
Prerequisite: link the harness packages first
Nothing here is fetched as this plugin's own dependency. The @deepseek-ai/*
packages that lib/ imports at runtime are peers: they belong to the dsh
install already on the machine, and they are not vendored here on purpose
(a copy would drift from the harness this plugin is supposed to run inside).
npm install does not resolve them either — cordis is the only one of the four
that is non-optional, so npm fetches that one (plus its own transitive deps) and
leaves the other three missing.
A fresh clone therefore needs one setup step before the suite can run:
bash scripts/link-dsh.sh # symlinks the installed dsh packages into node_modules
Skip it and npm test and npm run check both fail with four files
reporting ERR_MODULE_NOT_FOUND: Cannot find package '@deepseek-ai/…' —
loader, plugin, phase-b-g and phase-b-h (the suites that mount a real
dsh app). Those four die at import, so the run reports a handful of tests rather
than the full suite. That is the expected symptom of a missing setup step, not a
broken tree; run the script and both commands go green.
The script takes an optional argument for a non-default install root:
bash scripts/link-dsh.sh /path/to/global/node_modules
Run
npm test # the full suite
npm run check # human-readable evidence, one line per checkpoint
No test or checkpoint counts are stated here on purpose. A number in a README is an unguarded claim: it drifts every time someone adds a test, and a stale count reads as precise while being wrong. Guarding it cost more than the number was worth (a per-run source scan of the suite). The tables below name what is verified;
npm test/npm run checkare the authority on how many.
npm run check prints one line of evidence per checkpoint. The mapping to
docs/feasibility.md §7.1:
Writing or changing a criterion? Read
docs/dev-notes-mutation-criteria.mdfirst. It records the three ways a mutation test silently stops working — self-tests that never touchlib/, hardcoded enumeration lists, and mutating shared state under a parallel test runner — all of which were hit and independently reproduced during this phase.
| 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
<all checkpoints passed>
plugin: name=dsh-memory inject=[systemPrompt, tools]
workspace: /tmp/dsh-memory-cp-9IOx5k (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