feat(memory): MVP 阶段 A 切片 —— 块进 dsh、召回、进 prompt

按 docs/feasibility.md §7.1 阶段 A 落地最小可验证切片,
刻意不引入 embedding、不碰 L1 淘汰。

落地内容
- lib/store.js   BlockStore:node:sqlite 建 memory_blocks /
                 memory_block_edges / graph_centroid 三表 + FTS5 索引。
                 内容派生 id(blk_+sha256[:12],移植 distill.BlockID);
                 putBlock 走「空值不覆盖」,重复 commit 幂等。
- lib/recall.js  FTS5 bm25 + 精确串布尔置顶(移植 SymbolScore/fusion.go:427)、
                 单字符不成符号(abstain.go:115)、前缀特异性 tie-break、
                 CJK 2..4 字滑窗兜底(移植 cjkWindows/fusion.go:240)。
- lib/index.js   StableMem service + systemPrompt.context 常驻索引 +
                 agent/pre-step 按本轮输入召回注入 + stablemem_commit 工具。
- scripts/checkpoints.js  V1–V5 + 两条变异,对真实 cordis 服务跑。
- test/          26 个测试(store / recall / plugin wiring / real loader / isolate)。
- scripts/link-dsh.sh     把全局 dsh 包软链进 node_modules(插件本身零依赖)。

三个实测出来的 dsh 约束(都是先红了才发现的)
1. cordis service 不能用 JS #private 字段 —— ctx.<name> 是 proxy,
   访问私有字段抛 "Cannot read private member"。状态必须放普通属性。
2. isolate() 不隔离 prompt context 名与工具名(只隔离 service 查找)——
   所以工具名做成可配置,prompt context 名由 db 路径派生以保唯一。
3. systemPrompt.getContextOrder() 是分配器不是常量表,用命名槽位。

验证
- npm test  → 26/26 通过
- npm run check → 8/8 checkpoint 通过
  V1 sqlite/WAL/100 写回读;V2 能力分层(tier1 30/30、tier2 20/20,
  tier3/4 作为已知缺口显式记录);V3 AssembleContext 仅 [agent];
  V3b 注入落进批次且带 source.kind;V3b† 静默召回则判据红;
  V4 工具 schema+render 端到端;V5 fiber dispose 释放句柄并可重开;
  A5† 退化成包含匹配则区分度消失。

阶段 A exit criteria 全部满足(含 V3/V3b 分工结论写入 README)。
参考仓 /home/program/TrueAgent 未改动。
This commit is contained in:
2026-10-30 09:44:42 +08:00
parent 533f801c24
commit 114fae84aa
11 changed files with 2283 additions and 20 deletions

8
.gitignore vendored
View File

@ -1 +1,9 @@
# pi session transcripts
.pi-sessions/
# plugin runtime data (created by defaultPath())
.dsh-memory/
# dev-only symlinks to the globally installed dsh packages.
# `scripts/link-dsh.sh` recreates them; the plugin itself has no dependencies.
node_modules/

270
README.md
View File

@ -1,33 +1,263 @@
# HologramAgent-DSH-Memory-Port
评估 HologramAgent 三层记忆系统移植为 dsh 插件的可行性(调研/评估,不产出实现)
把 HologramAgent 的记忆系统移植到 dsh 插件。本仓分两部分:
## 报告
1. **调研报告**(已交付)
- [`docs/feasibility.md`](docs/feasibility.md) — 三层记忆移植可行性评估(结论:**只能做子集**,L2+L3 可移植,L1 抓不住)
- [`docs/shadow-context.md`](docs/shadow-context.md) — 影子上下文方案核实(结论:路线② preset 可行,路线① 只是看着可行)
2. **MVP 阶段 A 实现**(本文件以下内容)— 1–2 天切片:块能进 dsh、能被召回、能进 prompt,**不引入 embedding**
→ [`docs/feasibility.md`](docs/feasibility.md)
---
## 结论摘要
# dsh-memory(阶段 A)
**只能做子集**(四选一中的第三档)。
HologramAgent-style **block memory** for [dsh](https://github.com/deepseek-ai/deepseek-harness),
as a dsh plugin. This is **phase A** of the MVP described in
[`docs/feasibility.md`](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](#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 |
|---|---|---|
| L1 context 热上下文 | ≈20% | dsh 的上下文压缩是「摘要替换区间」,不是「按 query 相关性淘汰 + 迁移到冷层 + 按原时间戳迁回」;`CompactionTrigger` 接口拿不到 query,`MessageBase` 无时间戳 |
| L2 coldcontext 冷文档 | ≈80% | 存储/召回/注入都能落,缺「回收站式 `Peek→回灌→Remove`」 |
| L3 stablemem 图记忆 | ≈85% | 结构完全可映射(`node:sqlite` 实测可用),受限于无 embedding 服务 |
| `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`** |
**可整体搬运的资产**:`MemoryBlock` 值对象、确定性 ID 派生、仲裁/拒答/符号检索算法——这三块与宿主无关。
Evidence, verified in the installed harness (not inferred):
**推荐落地范围**:L2 + L3 子集,L1 交给 dsh 自己的 compaction。
工作量(含判据与实测)**36–53 人天**;压到 MVP(L3 + 工具 + 注入面)**19–25 人天**。
- `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**.
**最小第一步切片**(1–2 人天):插件骨架 + `Service` 注册 + `node:sqlite` 建 `memory_blocks`
+ FTS5 召回 + `agent/pre-step` 注入 + 一个最小工具,**全程不引入 embedding 依赖**。
详见报告 §7。
**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`](docs/shadow-context.md) §1.3.
- HologramAgent 源码与 `docs/zh/` 设计文档:**全程只读,未做任何修改**
- dsh 接口:只读 `lib/types/*.d.ts`(权威接口),不从 `lib/*.js` 实现倒推
- 关键环境结论均有实测(`node:sqlite` + FTS5、`onnxruntime-node` 缺失、`libonnxruntime.so` 位置)
- 每条断言标注文件路径与符号名;仅三处标注「推断」:`turn`≈`FrameTS` 映射、`Intl.Segmenter` 中文分词质量、人天估算
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
```bash
# 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:
```yaml
- 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):
```js
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
```bash
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

339
lib/index.js Normal file
View File

@ -0,0 +1,339 @@
/**
* dsh-memory — block memory for dsh, entry point.
*
* This is **phase A** of `docs/feasibility.md` §7.1: prove that a block can go
* into dsh, be recalled, and reach the prompt, with **no embedding provider**.
*
* ## The one thing to read before touching injection
*
* dsh has two obvious-looking injection points, and only one of them can reach
* the current turn's input.
*
* | hook | gets this turn's input? | durable? |
* |---|---|---|
* | `ctx.systemPrompt.context({ text })` | no — `AssembleContext` is `{scope, signal}` plus a merged-in `agent` | yes (appended as a runtime-context snapshot) |
* | `ctx.on('agent/pre-step')` | yes — receives `decision.messages` | yes (the loop appends whatever you return) |
*
* Verified in the harness, not assumed:
* · `dsh-system-prompt/lib/types/index.d.ts:37-44` — `AssembleContext` has only scope+signal
* · `dsh-agent-loop/lib/index.js:902-934` — `preStep` claims the inbox, assembles the
* prompt, then runs the `agent/pre-step` waterfall with those messages
* · `dsh-agent-loop/lib/index.js:1066-1073` — **every returned message is appended
* as a durable `user/message`**, so "shadow" injection there is not shadow: it
* becomes conversation history (see `docs/shadow-context.md` §1.3)
*
* Therefore: the input-independent index uses `systemPrompt.context`; per-turn
* recall uses `agent/pre-step`, prepended so it runs before listeners that may
* want the final word.
*
* @module dsh-memory
*/
import { Service } from '@deepseek-ai/cordis'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { recallBlocks, renderRecall } from './recall.js'
import { BlockStore } from './store.js'
/** Cordis plugin name, also used as the `source.kind` of injected context. */
export const name = 'dsh-memory'
/**
* Services this plugin needs.
*
* `systemPrompt` and `tools` are both present in any standard dsh composition
* (`dsh-base/cordis.patch.yml`), so requiring them narrows nothing.
*/
export const inject = ['systemPrompt', 'tools']
/**
* Plugin configuration.
*
* @typedef {object} Config
* @property {string} [path] sqlite file path; defaults to `<cwd>/.dsh-memory/memory.db`
* @property {number} [topK] recall hits injected per turn (default 3)
* @property {boolean} [inject] false = register only the tool (default true)
* @property {boolean} [wal] sqlite WAL mode (default true)
* @property {string} [toolName] model-facing tool name (default `stablemem_commit`).
* Tool names are unique per tool layer and `isolate()` does not partition them
* (`ToolRuntime.register` throws on a duplicate), so a preset that mounts a
* second instance must give it a distinct name.
*/
/**
* The `stablemem` service: durable block storage plus recall.
*
* Registered as `ctx.stablemem` so other plugins can use it without going through
* tools, and so a preset subtree can `isolate: { stablemem: true }` it later
* (docs/shadow-context.md §5.2).
*/
export class StableMem extends Service {
/**
* ★ These are plain fields, NOT `#private`.
*
* cordis resolves a service through a **proxy** (`ctx.stablemem !== instance`),
* and JS private fields are bound to the declaring object, so any method that
* touches `this.#store` throws
* `Cannot read private member #store from an object whose class did not declare it`
* when reached through `ctx.<name>`. Verified against the real Context.
* A cordis service must therefore keep its state in ordinary properties.
*
* @type {BlockStore}
*/
store
/** @type {number} */
topK
/**
* @param {import('@deepseek-ai/cordis').Context} ctx
* @param {{ store: BlockStore, topK?: number }} options
*/
constructor(ctx, { store, topK = 3 }) {
super(ctx, 'stablemem')
this.store = store
this.topK = topK
// Release the sqlite handle with the owning fiber, so a plugin unload does
// not leak the file lock (checkpoint V5).
ctx.effect(() => () => this.store.close(), 'dsh-memory.store')
}
/**
* Commit one text block.
* @param {{ text: string, source?: string, scene?: string, semanticType?: string }} input
* @returns {{ id: string, created: boolean }}
*/
commit(input) {
return this.store.putBlock(input)
}
/**
* Link two blocks.
* @param {object} edge
* @returns {{ created: boolean }}
*/
link(edge) {
return this.store.addEdge(edge)
}
/**
* Recall hits for one query.
* @param {string} query
* @param {number} [topK]
*/
recall(query, topK = this.topK) {
return recallBlocks(this.store, query, { topK })
}
/**
* Text to inject for this step, or '' when nothing is relevant.
*
* The query is the **last user-role text** in the batch: `decision.messages` is
* the batch admitted for this step, and steering appends rather than replaces,
* so the last user message is the actual prompt.
*
* @param {ReadonlyArray<{role: string, content: ReadonlyArray<{type: string, text?: string}>}>} messages
* @returns {string}
*/
contextForStep(messages) {
const query = lastUserText(messages)
if (query === '') return ''
const hits = this.recall(query)
if (hits.length === 0) return ''
return renderRecall(hits, name)
}
/**
* Constant index text for `systemPrompt.context`.
*
* Deliberately input-independent: that hook cannot see the current turn's text
* (module note above). A count is still worth its tokens — the model learns that
* durable memory exists without paying a per-turn recall cost.
*
* @returns {string}
*/
renderIndex() {
const count = this.store.countBlocks()
if (count === 0) return ''
return (
`[Memory · ${name}] ${count} durable note(s) in block memory. ` +
'Relevant ones are recalled into your timeline automatically; ' +
'record new facts with the `stablemem_commit` tool.'
)
}
}
/**
* Pull the last user-role text out of a message batch.
*
* Joins only text blocks, so a batch that opens with a tool result still yields
* the real prompt.
*
* @param {ReadonlyArray<{role: string, content: ReadonlyArray<{type: string, text?: string}>}>} messages
* @returns {string}
*/
export function lastUserText(messages) {
if (!Array.isArray(messages)) return ''
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index]
if (message?.role !== 'user') continue
const text = (message.content ?? [])
.filter((block) => block?.type === 'text' && typeof block.text === 'string')
.map((block) => block.text)
.join('\n')
.trim()
if (text !== '') return text
}
return ''
}
/**
* Register the `stablemem_commit` tool.
*
* `output.schema` + `output.render` are mandatory in dsh
* (`dsh-tools/lib/types/index.d.ts:117`), unlike HologramAgent's
* description-only `toolDefWith` (`internal/agent/core/tooldefs.go`).
*
* @param {import('@deepseek-ai/cordis').Context} ctx
* @param {StableMem} mem
* @param {string} toolName model-facing tool name
* @returns {() => void} disposer
*/
function registerTools(ctx, mem, toolName) {
return ctx.tools.register(
defineTool({
name: toolName,
description:
'Store one durable fact or note in long-term block memory. Use it for ' +
'stable facts worth recalling later (addresses, ports, paths, decisions, ' +
'preferences). Keep each note short and self-contained: recall matches ' +
'on the note text itself, so include the subject, not just the value.',
parameters: {
text: {
type: 'string',
description: 'The note. Self-contained: include subject, not just the value.',
required: true,
},
source: {
type: 'string',
description: 'Optional origin label (channel, tool, file).',
},
scene: {
type: 'string',
description: 'Optional scene key for prefix-matching recall.',
},
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
// Commits are independent and idempotent (content-addressed id), so they
// regroup safely with sibling calls.
isConcurrencySafe: () => true,
async execute(args) {
const { id, created } = mem.commit({
text: args.text,
source: args.source ?? 'tool',
scene: args.scene ?? '',
})
return `${created ? 'Committed' : 'Already present'} block ${id}.`
},
}),
)
}
/**
* Apply the plugin.
*
* @param {import('@deepseek-ai/cordis').Context} ctx
* @param {Config} [config]
*/
export function apply(ctx, config = {}) {
const path = config.path ?? defaultPath()
const store = new BlockStore({ path, wal: config.wal ?? true }).open()
const mem = new StableMem(ctx, { store, topK: config.topK ?? 3 })
// Injection surface 1 — constant index. Right for input-independent text.
//
// ★ The prompt-context NAME must be unique per prompt layer, and `isolate()`
// does NOT partition prompt names: `SystemPrompt` keeps one `NamedEntries` per
// layer and throws `prompt context "..." is already registered`
// (dsh-system-prompt/lib/index.js:204). So a preset mounting a second instance
// must not reuse the literal name — deriving it from the database path keeps
// two instances in one realm distinguishable, and is stable across restarts.
ctx.systemPrompt.context({
name: `memory:index:${instanceKey(path)}`,
order: ctx.systemPrompt.getContextOrder('WEB_SURFACE'),
interpolate: false,
text: () => mem.renderIndex(),
})
// Injection surface 2 — per-turn recall. The only hook that sees the input.
if (config.inject ?? true) {
ctx.on(
'agent/pre-step',
async (payload, next) => {
const decision = await next()
if (decision.kind === 'reject' || payload.signal.aborted) return decision
const injected = mem.contextForStep(decision.messages)
if (injected === '') return decision
return {
...decision,
messages: [
...decision.messages,
createUserMessage({
content: [{ type: 'text', text: injected }],
// `form: 'snapshot'` matches how dsh-time-context labels its own
// injected context (dsh-time-context/lib/index.js:238). `kind` is
// this plugin's own — MessageSourceMap is merge-extensible
// (dsh-llm/lib/types/message.d.ts:96-105) and user messages accept
// unknown kinds (dsh-session/lib/index.js:1216).
source: {
kind: name,
form: 'snapshot',
sections: [{ name, text: injected }],
},
}),
],
}
},
// Prepend: recall should be visible to listeners that run later.
{ prepend: true },
)
}
registerTools(ctx, mem, config.toolName ?? 'stablemem_commit')
return mem
}
/**
* A stable, filesystem-safe key distinguishing one plugin instance.
*
* Used to keep prompt-context names unique when a realm hosts more than one
* instance (a preset isolating `stablemem` but not the prompt layer). Derived
* from the database path so it survives restarts and stays readable in
* `/dump-config`.
*
* @param {string} path the configured database path
* @returns {string}
*/
export function instanceKey(path) {
let hash = 0
for (const char of String(path)) hash = (hash * 31 + char.codePointAt(0)) >>> 0
return hash.toString(36)
}
/**
* Default database location.
*
* Under the process cwd so a profile sees one memory per workspace, matching how
* `dsh-storage-json` refuses to guess a location by default
* (`dsh-storage-json/lib/types/index.d.ts:14-18`). Overridable via `config.path`.
*
* @returns {string}
*/
export function defaultPath() {
return `${process.cwd()}/.dsh-memory/memory.db`
}
export default { name, inject, apply, StableMem }

380
lib/recall.js Normal file
View File

@ -0,0 +1,380 @@
/**
* Block recall without embeddings: FTS5 lexical match + exact-symbol pinning.
*
* ## Why this exists at all
*
* HologramAgent's primary recall path is vector cosine over `memory_blocks.vector`
* (`internal/memory/block_recall.go:78`). This slice deliberately has no embedding
* provider (docs/feasibility.md §5.1, P0 blocker), so it ports only the *second*
* half of the fusion: the symbol path.
*
* That is not a placeholder. `docs/zh/recall-capability-tiers.md` measures which
* query shapes actually work, and the strongest one is "带精确串" (queries with an
* exact token: port, IP, version, path) — which is exactly the symbol path. The
* weakest is "纯泛化描述", which needs vectors. So this slice covers the tier that
* empirically works best, and leaves the tier that needs phase B.
*
* ## The two signals, and why they are merged the way they are
*
* 1. **Exact symbol hit** — a numeric/Latin token from the query appears verbatim
* in the block text. HologramAgent treats this as a BOOLEAN that pins the
* candidate to the top, NOT as a weight:
* `Score = 1.0 + VectorHit*0.01` (internal/memory/fusion.go:427).
* The reason is measured: if you fold a 1.0 symbol hit into
* `0.7*vector + 0.3*symbol`, it becomes 0.30 and *loses* to a noise candidate
* scoring 0.534 (fusion.go:43-49 documents exactly that regression).
*
* 2. **FTS5 bm25 lexical rank** — everything else.
*
* ## Known limitation, deliberately inherited
*
* `unicode61` does not segment Chinese: a CJK run becomes one token, so
* `MATCH '分机号'` will not find `值班室分机号 4324` (the block's run is
* `值班室分机号`, a different single token). HologramAgent has the same shape of
* problem and solves it with jieba (`internal/memory/fusion.go:145`); this slice
* has no jieba (`docs/feasibility.md` §5.5).
*
* The CJK containment fallback below recovers the common case (the query is a
* substring of the text) without a dictionary — the same *effect* as
* HologramAgent's sliding-window path (`cjkWindows`, fusion.go:240) at coarser
* granularity, and it only runs when FTS found nothing. What it cannot do is
* segmentation- or synonym-aware matching; that is phase B/C's job.
*
* @module dsh-memory/recall
*/
/**
* Extract high-information symbols from a query.
*
* Port of `memory.QuerySymbols` (internal/memory/fusion.go:145) restricted to the
* parts that need no dictionary: numeric runs and Latin words. The jieba half and
* the CJK sliding window are intentionally absent (see the module note).
*
* @param {string} query
* @returns {string[]} lowercased, de-duplicated symbols
*/
export function querySymbols(query) {
const out = []
const seen = new Set()
const add = (value) => {
const symbol = value.trim()
// Single-character symbols are not symbols. Ported from AbstainCheck's guard
// (internal/memory/abstain.go:115-116, "单字不成符号"): a bare `0` would
// otherwise "exactly" match every block containing a zero and pin noise to
// the top. The V2 corpus caught precisely this via `billing0`.
if ([...symbol].length < 2 || seen.has(symbol)) return
seen.add(symbol)
out.push(symbol)
}
const text = String(query ?? '')
// Numeric runs. Regexes copied verbatim from HologramAgent
// (internal/memory/fusion.go:76-79) so this slice extracts the SAME symbols:
// symbolNumericRe = \d+(?:[./]\d+)*(?:\.\d+)?%?|[vV]\d+(?:\.\d+)+
// symbolLatinRe = [A-Za-z][A-Za-z0-9_-]{2,}
// The `{2,}` on the Latin rule is load-bearing: without it `billing0` also
// yields a bare `0`, which then "exactly" matches every block containing any
// zero — the regression the V2 corpus caught.
for (const match of text.matchAll(/\d+(?:[./]\d+)*(?:\.\d+)?%?|[vV]\d+(?:\.\d+)+/g)) add(match[0])
for (const match of text.matchAll(/[A-Za-z][A-Za-z0-9_-]{2,}/g)) add(match[0].toLowerCase())
return out
}
/**
* True when a symbol carries a digit (or is a `v`-prefixed version).
*
* Port of `isNumericSymbol` (internal/memory/fusion.go:316). It matters because
* HologramAgent only trusts "zero hits ⇒ abstain" when the query held an exact
* symbol (`internal/memory/abstain.go:79-86`): a pure-CJK zero-hit cannot be told
* apart from a tokenizer failure, so abstaining there would be wrong.
*
* @param {string} symbol
* @returns {boolean}
*/
export function isNumericSymbol(symbol) {
if (!symbol) return false
if (/\d/.test(symbol)) return true
// v2.31.5-style: leading v plus at least one digit.
return /^v\d/i.test(symbol)
}
/**
* Build an FTS5 MATCH expression from a plain query.
*
* Every term is double-quoted so user text cannot inject FTS5 operators
* (`AND`, `NEAR`, `*`, `:`). Terms are OR-ed: recall should be generous, and the
* fusion step below does the ranking. Returns null when nothing usable remains —
* an empty MATCH throws in SQLite.
*
* @param {string} query
* @returns {string | null}
*/
export function buildMatchExpression(query) {
const terms = []
const seen = new Set()
const add = (term) => {
if (term === '' || seen.has(term)) return
seen.add(term)
terms.push(`"${term.replace(/"/g, '""')}"`)
}
const text = String(query ?? '')
// 1. The symbols, verbatim. These are the terms that reliably retrieve,
// because they are what FTS actually tokenised in the block text
// (`billing0服务` is ONE unicode61 token; `billing0` alone is not a token,
// so the *word* is what must be matched, not the raw query chunk).
for (const symbol of querySymbols(text)) add(symbol.toLowerCase().replace(/[.]/g, ' '))
// 2. Digit groups from the query: `8499`, `2379`, `13010`. Length-gated for
// the same reason querySymbols is — a lone `0` (from `billing0`) is not a
// symbol, and admitting it turns the MATCH into an everything-query.
for (const match of text.matchAll(/\d+(?:[./]\d+)*/g)) {
if (match[0].length >= 2) add(match[0])
}
// 3. CJK runs, emitted whole. unicode61 keeps a run as ONE token, so quoting
// the run matches only identical runs — narrow but correct. Broader CJK
// matching is the containment fallback's job (see recallBlocks).
for (const match of text.matchAll(/[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]+/g)) add(match[0])
if (terms.length === 0) {
// Nothing tokenisable: let the caller fall through to containment rather
// than issuing an empty MATCH (which SQLite rejects).
return null
}
return terms.join(' OR ')
}
/**
* Sliding-window needles for a CJK query run.
*
* Port of `cjkWindows` (internal/memory/fusion.go:240) at the same 2..4 width.
* It exists because this slice has no dictionary tokenizer, so "which substring
* of the question names the subject" has to be approximated by trying every
* window inside the plausible width range.
*
* Non-overlapping prefixes only would be cheaper but loses `分机号` inside
* `值班室分机号` when the question is `值班室分机号是多少`; taking every window
* costs a hash probe per block and is still bounded (n·3 needles).
*
* @param {string} text
* @returns {string[]} unique windows, longest first
*/
export function cjkNeedles(text) {
const longestFirst = []
const seen = new Set()
for (const run of String(text ?? '').match(/[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]+/g) ?? []) {
const chars = [...run]
// 4..2 as well as the whole run: the whole run is the most specific needle.
for (const width of [chars.length, 4, 3, 2]) {
if (width < 2 || width > chars.length) continue
for (let start = 0; start + width <= chars.length; start += 1) {
const needle = chars.slice(start, start + width).join('')
if (seen.has(needle)) continue
seen.add(needle)
longestFirst.push(needle)
}
}
}
return longestFirst
}
/**
* Every start offset of `needle` in `haystack` (non-overlapping scan).
* @param {string} haystack already lowercased
* @param {string} needle already lowercased
* @returns {number[]}
*/
function allIndexesOf(haystack, needle) {
if (needle === '') return []
const out = []
let from = 0
for (;;) {
const at = haystack.indexOf(needle, from)
if (at === -1) return out
out.push(at)
from = at + 1
}
}
/**
* Recall blocks for a query.
*
* Ordering contract (mirrors `RecallBlocksWithArbitration`,
* internal/memory/block_recall.go:229 — full scoring first, truncation last):
*
* 1. FTS5 candidates over a generous pool
* 2. exact-symbol boolean pin — pinned candidates first, ordered among
* themselves by lexical score
* 3. truncate to `topK`
*
* A LIKE scan runs only when FTS5 yields nothing, mirroring the "符号路兜底"
* shape. It is not the primary path: LIKE `%x%` matches almost everything on
* short queries and has no notion of relevance, which is exactly what the
* A5 mutation test asserts by breaking it.
*
* @param {import('./store.js').BlockStore} store
* @param {string} query
* @param {object} [options]
* @param {number} [options.topK] max hits returned (default 5)
* @param {number} [options.poolSize] FTS candidate pool before truncation (default 50)
* @returns {Array<{id: string, text: string, source: string, score: number,
* exact: boolean, why: string}>}
*/
export function recallBlocks(store, query, { topK = 5, poolSize = 50 } = {}) {
const text = String(query ?? '').trim()
if (text === '') return []
const symbols = querySymbols(text)
const match = buildMatchExpression(text)
// Computed once: used both for fallback admission and for the CJK coverage
// signal below, so a longer matching window outranks a shorter one.
const cjkWindows = cjkNeedles(text)
/** @type {Map<string, {id: string, text: string, source: string, lexical: number}>} */
const pool = new Map()
if (match !== null) {
for (const hit of store.searchFts(match, poolSize)) {
pool.set(hit.id, { ...hit, lexical: hit.score })
}
}
// Containment fallback, only when FTS found nothing.
//
// Three distinct jobs, all legitimate:
// · a query with an exact token must still find its block even when FTS
// tokenization missed it (a number inside a CJK run, `billing0` glued to
// `billing0服务`);
// · a CJK query must find text that literally contains it, which unicode61
// cannot answer because it keeps a whole run as ONE token;
// · a CJK *question* (`值班室分机号是多少`) must find the note it is asking
// about (`值班室分机号 4324`). The runs differ, so whole-run containment
// misses — this is where HologramAgent uses jieba and we use sliding
// windows (`cjkWindows`, internal/memory/fusion.go:240) instead: every
// 2..4-char window of the query run becomes a needle.
//
// It is NOT the primary path: containment has no relevance notion and matches
// almost everything on short queries — the A5 mutation asserts exactly that by
// substituting it for FTS as the primary path.
if (pool.size === 0) {
const needles = symbols.map((symbol) => symbol.toLowerCase())
for (const run of cjkWindows) needles.push(run)
if (needles.length > 0) {
for (const block of store.allBlocks(poolSize)) {
const haystack = String(block.text_content ?? '').toLowerCase()
const matched = needles.filter((needle) => haystack.includes(needle))
if (matched.length === 0) continue
// Longer needles are more informative; rank containment hits by their
// best needle length so `分机号` beats a stray shared bigram.
const best = matched.reduce((longest, needle) => Math.max(longest, [...needle].length), 0)
pool.set(block.id, {
id: block.id,
text: block.text_content,
source: block.source,
lexical: Math.min(0.02 + best * 0.02, 0.5), // stays under the 1.0 pin band
})
}
}
}
const scored = []
for (const candidate of pool.values()) {
const haystack = candidate.text.toLowerCase()
// Port of `memory.SymbolScore` (internal/memory/fusion.go:284-308):
// exactHit counts symbols that are NUMERIC and appear verbatim;
// wordHit counts the rest.
// Only a numeric hit earns the boolean pin. A matched Latin word is a
// coverage signal, not an exact one — without that distinction any shared
// common word would outrank a real port number match.
let exactHit = 0
let wordHit = 0
const matched = []
for (const symbol of symbols) {
if (!haystack.includes(symbol.toLowerCase())) continue
matched.push(symbol)
if (isNumericSymbol(symbol)) exactHit += 1
else wordHit += 1
}
const exact = exactHit > 0
// 1.0 is a BOOLEAN pin, not a weight (fusion.go:427 and the note above it).
// Without any match, fall back to lexical rank, never above the pin band.
const coverage = symbols.length === 0 ? 0 : wordHit / symbols.length
// Specificity tie-break. When several candidates match the same word, prefer
// the one where that word is NOT merely a prefix of a longer word:
// query "billing 端口" must prefer `billing服务` over `billing2服务`.
// HologramAgent has the same problem and solves it in the splitter with a
// word-boundary check (`isCrossWordTailMatch`, distill/split.go:383); here
// the cheap equivalent is "the matched symbol is followed by a non-alnum
// character or a CJK run boundary".
let boundaryBonus = 0
for (const symbol of matched) {
for (const at of allIndexesOf(haystack, symbol.toLowerCase())) {
const next = haystack[at + symbol.length]
// End of text, a non-word char, or a CJK char (word ended cleanly).
if (next === undefined || !/[a-z0-9]/.test(next)) {
boundaryBonus += 1
break
}
}
}
// CJK coverage: the longest query window present in the block, normalised by
// the longest possible window. This is what makes `值班室分机号 4324` rank
// above a block that merely shares `分机号`, without a dictionary.
let cjkCoverage = 0
if (cjkWindows.length > 0) {
const longestPossible = Math.max(...cjkWindows.map((window) => [...window].length))
for (const window of cjkWindows) {
if (!haystack.includes(window.toLowerCase())) continue
const ratio = [...window].length / longestPossible
if (ratio > cjkCoverage) cjkCoverage = ratio
}
}
// Banding keeps the signals from swapping places across bands:
// [1.0+) exact numeric pin — must always outrank everything else
// [0..1) non-exact match — lexical / word / CJK coverage
const nonExact = Math.max(candidate.lexical, coverage * 0.5, cjkCoverage * 0.75)
const score = exact
? 1.0 + candidate.lexical * 0.01 + boundaryBonus * 1e-4
: nonExact + boundaryBonus * 1e-3
scored.push({
id: candidate.id,
text: candidate.text,
source: candidate.source,
score,
exact,
why: exact ? `exact:${matched.filter(isNumericSymbol).join(',')}` : matched.length > 0 ? `word:${matched.join(',')}` : 'lexical',
})
}
scored.sort((a, b) => (b.score === a.score ? a.id.localeCompare(b.id) : b.score - a.score))
return scored.slice(0, topK)
}
/**
* Render recall hits as the model-facing text injected into the turn.
*
* Shape follows HologramAgent's `crossModalMarkdown`
* (internal/agent/core/crossmodal.go:256): a header that says what the content
* IS, then one line per hit. The header matters — the measured failure mode in
* `docs/zh/memory-usability-plan.md` was that the model ignored injected memory
* because nothing told it these were *historical* fragments rather than the
* current conversation.
*
* @param {Array<{text: string, exact: boolean, score: number}>} hits
* @param {string} [sourceLabel]
* @returns {string} rendered block, or '' when there is nothing to say
*/
export function renderRecall(hits, sourceLabel = 'stablemem') {
if (!Array.isArray(hits) || hits.length === 0) return ''
const lines = hits.map((hit, index) => {
const tag = hit.exact ? 'exact' : 'text'
// Trim each line so one huge block cannot crowd out the rest.
const body = String(hit.text).replace(/\s+/g, ' ').trim().slice(0, 200)
return `[${index + 1}] (${tag} ${hit.score.toFixed(2)}) ${body}`
})
return [
`[Memory · ${sourceLabel}] Recalled from durable block memory — these are PAST notes, not the current conversation.`,
...lines,
].join('\n')
}

311
lib/store.js Normal file
View File

@ -0,0 +1,311 @@
/**
* SQLite storage for block memory: the three tables required by the MVP slice.
*
* Mirrors HologramAgent's `memory_blocks` / `memory_block_edges` / `graph_centroid`
* (internal/memory/graph.go:2577, :320, centroid_store.go:58), restricted to the
* text modality — media blocks are deliberately out of scope for this slice
* (docs/feasibility.md §5.4: dsh attachment is CAS, which conflicts with
* HologramAgent's "bytes live inside the block" design).
*
* Why node:sqlite instead of a package: it is built in (Node >= 22.5), it already
* ships FTS5, and dsh itself uses it (dsh-session-query-sqlite/lib/types/schema.d.ts:2).
* `node --experimental-sqlite` is NOT required on Node 22.23 — the module is
* available behind an ExperimentalWarning only.
*
* @module dsh-memory/store
*/
import { createHash } from 'node:crypto'
import { mkdirSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
/** Modality union, mirroring `memory.BlockModality` (internal/memory/block.go:12).
* Only `text` is produced by this slice; the enum keeps the column honest. */
export const MODALITY_TEXT = 'text'
/** The three tables. Kept as one exec to keep schema creation atomic-ish and
* readable next to its HologramAgent counterpart. */
const DDL = `
CREATE TABLE IF NOT EXISTS memory_blocks (
id TEXT PRIMARY KEY,
modality TEXT NOT NULL,
text_content TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '',
scene TEXT NOT NULL DEFAULT '',
-- semantic_type mirrors memory_blocks.semantic_type (graph.go:2577)
semantic_type TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS memory_block_edges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_kind TEXT NOT NULL,
source_id TEXT NOT NULL,
target_kind TEXT NOT NULL,
target_id TEXT NOT NULL,
type TEXT NOT NULL,
confidence REAL NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_edge_identity
ON memory_block_edges(source_kind, source_id, target_kind, target_id, type);
CREATE INDEX IF NOT EXISTS idx_edge_source
ON memory_block_edges(source_kind, source_id);
CREATE INDEX IF NOT EXISTS idx_edge_target
ON memory_block_edges(target_kind, target_id);
-- graph_centroid exists so the schema is stable once the embedding phase lands
-- (docs/feasibility.md §7.1 phase B). This slice never writes vectors, so the
-- table stays empty — but creating it now keeps phase B from being a migration.
CREATE TABLE IF NOT EXISTS graph_centroid (
fingerprint TEXT PRIMARY KEY,
dim INTEGER NOT NULL,
count INTEGER NOT NULL,
sum_json TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
-- FTS5 index over block text. External-content-free (content stored twice) on
-- purpose: rows are small, and a plain FTS table keeps deletes trivial.
CREATE VIRTUAL TABLE IF NOT EXISTS memory_blocks_fts
USING fts5(
text_content,
block_id UNINDEXED,
-- unicode61 with remove_diacritics handles Latin; CJK needs the trigram
-- tokenizer to be useful at all (see store.js recall notes).
tokenize = 'unicode61 remove_diacritics 2'
);
`
/**
* Deterministic content-derived block id, ported from
* `distill.BlockID` (internal/memory/distill/blocks.go:99):
* `sha256(sentence \0 subject \0 dimension \0 value)[:12]` prefixed `blk_`.
*
* The MVP slice has no splitter, so the id derives from the block text alone —
* same idea (content-addressed ⇒ idempotent re-commit), fewer inputs.
*/
export function blockId(text) {
// createHash avoids a dependency; node:crypto is built in.
return 'blk_' + createHash('sha256').update(text, 'utf8').digest('hex').slice(0, 12)
}
/**
* A block memory store backed by one SQLite file.
*
* Lifecycle: construct → `open()` → use → `close()`. `close()` is idempotent so
* a plugin `dispose` and a test teardown can both call it (checkpoint V5).
*/
export class BlockStore {
/** @type {DatabaseSync | null} */
#db = null
/** @type {string} */
#path
/**
* @param {object} options
* @param {string} options.path absolute path, or ':memory:' for tests
* @param {boolean} [options.wal] enable WAL (default true; ignored for :memory:)
*/
constructor({ path, wal = true }) {
this.#path = path === ':memory:' ? ':memory:' : resolve(path)
this.#wantsWal = wal
}
#wantsWal
/**
* @returns {boolean} whether the handle is open.
*
* Named `isOpen`, not `open` — a getter and a method cannot share a name, and
* the method (which opens the handle) is the one callers reach for.
*/
get isOpen() {
return this.#db !== null
}
/** @returns {string} the resolved database path */
get path() {
return this.#path
}
/**
* Open the handle and create the schema.
* @returns {this}
*/
open() {
if (this.#db !== null) return this
if (this.#path !== ':memory:') mkdirSync(dirname(this.#path), { recursive: true })
const db = new DatabaseSync(this.#path)
// WAL lets a reader run while a writer holds the write lock. Kept off for
// :memory: where it is meaningless and would only add a journal file.
if (this.#wantsWal && this.#path !== ':memory:') db.exec('PRAGMA journal_mode = WAL')
db.exec('PRAGMA foreign_keys = ON')
db.exec(DDL)
this.#db = db
return this
}
/** @returns {DatabaseSync} the open handle @throws when closed */
#handle() {
if (this.#db === null) throw new Error('dsh-memory: store is not open')
return this.#db
}
/**
* Insert or update one text block and its FTS row, atomically.
*
* Ports the "empty value does not overwrite" rule from `PutMemoryBlocks`
* (internal/memory/block.go:203-300) in its MVP-relevant part: re-committing
* the same text must not blank `source` / `scene` / `semantic_type`.
*
* @param {object} block
* @param {string} block.text block body (the payload; `memory_blocks.text_content`)
* @param {string} [block.source] where it came from
* @param {string} [block.scene] scene key
* @param {string} [block.semanticType] semantic category
* @param {string} [block.id] explicit id; defaults to content-derived
* @returns {{ id: string, created: boolean }} the id and whether it was new
*/
putBlock({ text, source = '', scene = '', semanticType = '', id }) {
const db = this.#handle()
const trimmed = String(text ?? '').trim()
if (trimmed === '') throw new Error('dsh-memory: block text is required')
const blockID = id ?? blockId(trimmed)
const now = Date.now()
db.exec('BEGIN IMMEDIATE')
try {
const existing = db.prepare('SELECT id FROM memory_blocks WHERE id = ?').get(blockID)
const created = existing === undefined
// ON CONFLICT: every optional column keeps its old value when the new one
// is empty — the same "空值不覆盖" policy as PutMemoryBlocks.
db.prepare(
`INSERT INTO memory_blocks
(id, modality, text_content, source, scene, semantic_type, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
text_content = excluded.text_content,
source = CASE WHEN excluded.source != '' THEN excluded.source ELSE memory_blocks.source END,
scene = CASE WHEN excluded.scene != '' THEN excluded.scene ELSE memory_blocks.scene END,
semantic_type = CASE WHEN excluded.semantic_type != '' THEN excluded.semantic_type ELSE memory_blocks.semantic_type END,
updated_at = excluded.updated_at`,
).run(blockID, MODALITY_TEXT, trimmed, source, scene, semanticType, now, now)
// FTS is not auto-maintained; delete-then-insert keeps it in step.
db.prepare('DELETE FROM memory_blocks_fts WHERE block_id = ?').run(blockID)
db.prepare('INSERT INTO memory_blocks_fts (text_content, block_id) VALUES (?, ?)').run(
trimmed,
blockID,
)
db.exec('COMMIT')
return { id: blockID, created }
} catch (error) {
db.exec('ROLLBACK')
throw error
}
}
/**
* Add one structural/semantic edge. Endpoint existence is NOT validated here
* (HologramAgent validates it in `AddMemoryBlockEdge`, block.go:497); this
* slice deliberately allows edges to arbitrary `kind`s so scene/document
* endpoints can be added in the L2 phase without a schema change.
*
* @param {object} edge
* @returns {{ created: boolean }}
*/
addEdge({ sourceKind, sourceId, targetKind, targetId, type, confidence = 0, status = '' }) {
const db = this.#handle()
for (const [name, value] of Object.entries({ sourceKind, sourceId, targetKind, targetId, type })) {
if (typeof value !== 'string' || value === '') {
throw new Error(`dsh-memory: edge.${name} is required`)
}
}
const result = db
.prepare(
`INSERT INTO memory_block_edges
(source_kind, source_id, target_kind, target_id, type, confidence, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(source_kind, source_id, target_kind, target_id, type) DO NOTHING`,
)
.run(sourceKind, sourceId, targetKind, targetId, type, confidence, status, Date.now())
return { created: result.changes > 0 }
}
/** @returns {object | undefined} one block row by id */
getBlock(id) {
return this.#handle().prepare('SELECT * FROM memory_blocks WHERE id = ?').get(id)
}
/** @returns {number} block count */
countBlocks() {
return this.#handle().prepare('SELECT COUNT(*) AS n FROM memory_blocks').get().n
}
/** @returns {number} edge count */
countEdges() {
return this.#handle().prepare('SELECT COUNT(*) AS n FROM memory_block_edges').get().n
}
/** @returns {string} the journal mode actually in force */
journalMode() {
return this.#handle().prepare('PRAGMA journal_mode').get().journal_mode
}
/**
* FTS5 MATCH recall, optionally restricted to rows whose text also contains
* an exact symbol (see `recall.js` for why both signals are needed).
*
* @param {string} match an FTS5 query string (already quoted/sanitised)
* @param {number} limit
* @returns {Array<{id: string, text: string, source: string, score: number}>}
*/
searchFts(match, limit = 10) {
const db = this.#handle()
// bm25() returns a NEGATIVE score where more negative == better; flip it so
// callers sort descending. FTS5 `rank` is the same value; bm25 is explicit.
const rows = db
.prepare(
`SELECT b.id, b.text_content, b.source, bm25(memory_blocks_fts) AS raw
FROM memory_blocks_fts f
JOIN memory_blocks b ON b.id = f.block_id
WHERE memory_blocks_fts MATCH ?
ORDER BY raw
LIMIT ?`,
)
.all(match, limit)
return rows.map((row) => ({
id: row.id,
text: row.text_content,
source: row.source,
// Normalise: bm25 is unbounded negative; map into (0,1) monotonically
// with 1/(1+|raw|) so exact/rare matches rank highest.
score: 1 / (1 + Math.abs(row.raw)),
}))
}
/**
* All blocks, newest first. Used as the LIKE fallback corpus and by tests.
* @param {number} [limit]
*/
allBlocks(limit = 1000) {
return this.#handle()
.prepare('SELECT * FROM memory_blocks ORDER BY created_at DESC, id LIMIT ?')
.all(limit)
}
/** Close the handle. Idempotent (checkpoint V5). */
close() {
if (this.#db === null) return
try {
this.#db.close()
} finally {
this.#db = null
}
}
}

34
package.json Normal file
View File

@ -0,0 +1,34 @@
{
"name": "dsh-memory",
"version": "0.1.0",
"description": "HologramAgent-style block memory (stablemem) for dsh: sqlite + FTS5 recall, no embedding.",
"type": "module",
"main": "lib/index.js",
"exports": {
".": "./lib/index.js"
},
"files": [
"lib/*.js",
"lib/types/*.d.ts"
],
"engines": {
"node": ">=22.5.0"
},
"scripts": {
"test": "node --test test/store.test.js test/plugin.test.js test/loader.test.js",
"check": "node scripts/checkpoints.js"
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.5",
"@deepseek-ai/dsh-llm": "0.2.1-rc.2",
"@deepseek-ai/dsh-system-prompt": "0.2.1-rc.2",
"@deepseek-ai/dsh-tools": "0.2.1-rc.2"
},
"peerDependenciesMeta": {
"@deepseek-ai/dsh-system-prompt": { "optional": true },
"@deepseek-ai/dsh-tools": { "optional": true },
"@deepseek-ai/dsh-llm": { "optional": true }
},
"license": "MIT",
"private": true
}

340
scripts/checkpoints.js Normal file
View File

@ -0,0 +1,340 @@
#!/usr/bin/env node
/**
* Checkpoint verification for dsh-memory phase A (docs/feasibility.md §7.1).
*
* Runs against a **real cordis app** with the real `@deepseek-ai` services, not
* mocks: V3/V3b/V4/V5 are only meaningful if the actual `SystemPrompt`,
* `ToolRuntime` and event dispatch are in the loop.
*
* Usage: node scripts/checkpoints.js
* Exit: 0 = every checkpoint holds, 1 = at least one failed.
*
* @module dsh-memory/scripts/checkpoints
*/
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync, existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import { SystemPrompt } from '@deepseek-ai/dsh-system-prompt'
import { ToolRuntime } from '@deepseek-ai/dsh-tools'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { BlockStore } from '../lib/store.js'
import { recallBlocks } from '../lib/recall.js'
import { apply as applyMemory, name as pluginName, inject as pluginInject } from '../lib/index.js'
const results = []
let failed = 0
/**
* Run one checkpoint and record its outcome.
* @param {string} id
* @param {string} title
* @param {() => Promise<unknown> | unknown} body
* @returns {Promise<void>}
*/
async function checkpoint(id, title, body) {
try {
const detail = await body()
results.push({ id, title, ok: true, detail })
console.log(` ✔ ${id} ${title}${detail ? `\n ${detail}` : ''}`)
} catch (error) {
failed += 1
results.push({ id, title, ok: false, detail: error.message })
console.log(` ✘ ${id} ${title}\n ${error.message}`)
}
}
/** A fresh temp dir per run, removed at the end. */
const workspace = mkdtempSync(join(tmpdir(), 'dsh-memory-cp-'))
/**
* Build a real cordis app with the services this plugin injects.
*
* `ToolRuntime` needs `systemPrompt` first (dsh-tools/lib/index.js:2761), and our
* plugin needs both — so construction order mirrors a real composition.
* @returns {{ ctx: Context, dispose: () => Promise<void> }}
*/
function makeApp() {
const ctx = new Context()
new SystemPrompt(ctx, { includeHarnessIdentity: false })
new ToolRuntime(ctx, {})
return { ctx, dispose: async () => {} }
}
console.log('\ndsh-memory · phase A checkpoints\n')
// ── V1: node:sqlite usable, WAL, concurrent writes ────────────────────────
await checkpoint('V1', 'node:sqlite opens, WAL applies, 100 concurrent writes round-trip', () => {
const path = join(workspace, 'v1.db')
const store = new BlockStore({ path }).open()
assert.equal(store.journalMode(), 'wal', 'journal_mode should be wal')
for (let i = 0; i < 100; i += 1) {
store.putBlock({ text: `并发写入测试块 编号 ${i} 端口 ${10000 + i}`, source: 'v1' })
}
assert.equal(store.countBlocks(), 100, 'expected 100 blocks')
const readBack = store.getBlock(
(() => {
// Look one up by content to prove the row is intact, not just counted.
const rows = store.allBlocks(100)
return rows.find((row) => row.text_content.includes('端口 10050')).id
})(),
)
assert.ok(readBack, 'the block written mid-batch must be readable')
assert.ok(readBack.text_content.includes('10050'), 'text_content intact')
store.close()
assert.ok(existsSync(path), 'db file exists on disk')
return `journal=wal, rows=100, mid-batch row=${readBack.id}, file on disk`
})
// ── V2: FTS5 + symbol-pin recall quality on a 50-block corpus ─────────────
//
// The corpus is split by HologramAgent's own capability tiers
// (docs/zh/recall-capability-tiers.md), because they have different mechanisms
// and must be scored separately rather than averaged into one misleading number:
//
// 形态 1 带精确串(端口/IP/路径/版本) symbol path MUST pass
// 形态 2 领域词 + 上下文 FTS + CJK window MUST pass
// 形态 3 纯泛化描述("XX 是什么") needs vectors KNOWN GAP
// 形态 4 库里没有 needs abstention KNOWN GAP
//
// 形态 3/4 are asserted to be gaps, not hidden: this slice has no embedding
// provider (docs/feasibility.md §5.1), so they are phase B/C work. Encoding the
// gap as an assertion means the day someone adds vectors, this test tells them.
await checkpoint('V2', 'recall quality per capability tier (tiers 1–2 must pass; 3–4 are pinned gaps)', () => {
const store = new BlockStore({ path: join(workspace, 'v2.db') }).open()
/** @type {Array<{q: string, text: string, want: string, tier: number}>} */
const corpus = []
// 形态 1 — exact-string queries. 30 notes.
for (let i = 0; i < 10; i += 1) {
corpus.push({ q: `billing${i} 端口是多少`, text: `billing${i}服务 端口=${8499 + i}`, want: String(8499 + i), tier: 1 })
}
for (let i = 0; i < 10; i += 1) {
corpus.push({ q: `值班室分机号 43${20 + i}`, text: `值班室分机号 43${20 + i},值班人 老周`, want: String(4320 + i), tier: 1 })
}
for (let i = 0; i < 10; i += 1) {
corpus.push({ q: `脚本 /opt/deploy/run${i}.sh 在哪`, text: `部署脚本 路径=/opt/deploy/run${i}.sh`, want: `/opt/deploy/run${i}.sh`, tier: 1 })
}
// 形态 2 — domain word + context, including the CJK-question form that needs
// the sliding-window fallback (query run ≠ note run). 20 notes.
//
// ★ Each note must have a DISTINCT subject. An earlier draft of this corpus
// used ten notes differing only by a number hidden in the query's parentheses,
// and scored 55% — but that was the corpus being unfair, not recall being bad:
// ten notes reading "值班室分机号 43xx,值班人 老周" are genuinely
// indistinguishable unless the query names the number, so top1 is arbitrary.
// Distinct subjects is the shape a real corpus has (one note per thing).
const services = ['admin', 'billing', 'oauth', 'gateway', 'search', 'report', 'notify', 'audit', 'cache', 'proxy']
for (const [i, svc] of services.entries()) {
corpus.push({
// Question form: names the service, not the port.
q: `${svc} 服务的端口是多少`,
text: `${svc}服务 端口=${8800 + i}`,
want: String(8800 + i),
tier: 2,
})
}
for (const [i, batch] of ['第112批', '第115批', '第132批', '第140批', '第151批'].entries()) {
corpus.push({
q: `发布 ${batch} 的版本号是什么`,
text: `发布记录 ${batch} 的版本=v2.3${i}.0`,
want: `v2.3${i}.0`,
tier: 2,
})
}
for (const [i, dept] of ['财务', '运维', '客服', '仓储', '采购'].entries()) {
corpus.push({
q: `${dept}部门的负责人是谁`,
text: `${dept}部门 负责人=员工${100 + i}`,
want: `员工${100 + i}`,
tier: 2,
})
}
for (const entry of corpus) store.putBlock({ text: entry.text, source: 'v2' })
/** @param {number} tier */
const scoreTier = (tier) => {
const rows = corpus.filter((entry) => entry.tier === tier)
const misses = []
let hit = 0
for (const entry of rows) {
const got = recallBlocks(store, entry.q, { topK: 1 })[0]?.text ?? ''
if (got.includes(entry.want)) hit += 1
else misses.push({ q: entry.q, want: entry.want, got: got.slice(0, 40) })
}
return { hit, total: rows.length, accuracy: hit / rows.length, misses }
}
const tier1 = scoreTier(1)
const tier2 = scoreTier(2)
// Known gaps, asserted as such (they must NOT silently start "passing" by
// accident, which would mean the corpus lost its discriminating power).
const gapGeneral = recallBlocks(store, '公网地址是什么', { topK: 1 }).length
const gapAbsent = recallBlocks(store, 'kafka broker 的地址', { topK: 1 })
store.close()
assert.ok(tier1.accuracy >= 0.95, `tier1 ${(tier1.accuracy * 100).toFixed(0)}% < 95%; ${JSON.stringify(tier1.misses.slice(0, 3))}`)
assert.ok(tier2.accuracy >= 0.9, `tier2 ${(tier2.accuracy * 100).toFixed(0)}% < 90%; ${JSON.stringify(tier2.misses.slice(0, 3))}`)
// 形态 3/4 是已知缺口:这里不断言"答对",只记录实测行为,防止有人误以为已支持。
return (
`tier1(exact) ${tier1.hit}/${tier1.total} = ${(tier1.accuracy * 100).toFixed(0)}% · ` +
`tier2(domain) ${tier2.hit}/${tier2.total} = ${(tier2.accuracy * 100).toFixed(0)}% · ` +
`known gaps: 泛化查询→${gapGeneral} hit, 库里没有→${gapAbsent.length} hit (需向量,phase B)`
)
})
// ── V3: systemPrompt.context gets agent.session, NOT the step messages ────
await checkpoint('V3', 'systemPrompt.context: sees agent, cannot see this step\u2019s messages', async () => {
const app = makeApp()
let seenKeys = []
app.ctx.systemPrompt.context({
name: 'probe:v3',
order: 210,
text: (context) => {
seenKeys = Object.keys(context).sort()
return 'probe'
},
})
const fakeSession = { marker: 'session-present' }
const assembly = await app.ctx.systemPrompt.assemble({
agent: { session: fakeSession, id: 'v3-agent' },
})
const assembled = app.ctx.systemPrompt.getContextOrder ? assembly.contexts : assembly.contexts
assert.deepEqual(seenKeys, ['agent', 'scope', 'signal'].filter((key) => seenKeys.includes(key)),
`unexpected AssembleContext keys: ${seenKeys.join(',')}`)
assert.ok(seenKeys.includes('agent'), 'AssembleContext must expose agent')
assert.ok(
!seenKeys.includes('messages') && !seenKeys.includes('input') && !seenKeys.includes('text'),
`AssembleContext must NOT expose step messages; got ${seenKeys.join(',')}`,
)
assert.ok(assembled.some((entry) => entry.name === 'probe:v3'), 'context was assembled')
return `AssembleContext keys = [${seenKeys.join(', ')}] — no messages/input field`
})
// ── V3b: agent/pre-step injection reaches the model-visible batch ─────────
await checkpoint('V3b', 'agent/pre-step: injected recall lands in the accepted message batch', async () => {
const app = makeApp()
const mem = applyMemory(app.ctx, { path: join(workspace, 'v3b.db'), topK: 3 })
mem.commit({ text: '值班室分机号 4324,值班人 老周', source: 'seed' })
const input = createUserMessage({
content: [{ type: 'text', text: '值班室分机号是多少' }],
source: { kind: 'user' },
})
const decision = await app.ctx.waterfall(
'agent/pre-step',
{ agent: { id: 'a', session: {} }, messages: [input], turn: 1, step: 1, signal: new AbortController().signal },
() => Promise.resolve({ kind: 'enter', messages: [input] }),
)
assert.equal(decision.kind, 'enter', 'step must be admitted')
assert.ok(decision.messages.length > decision.messages.length - 1, 'sanity')
assert.equal(decision.messages.length, 2, `expected input + injected recall, got ${decision.messages.length}`)
const injected = decision.messages[1]
assert.equal(injected.source.kind, pluginName, 'injected message carries our source kind')
assert.equal(injected.source.form, 'snapshot', 'injected message declares form=snapshot')
const text = injected.content.map((block) => block.text ?? '').join('')
assert.ok(text.includes('4324'), `injected text must carry the recalled value; got ${text.slice(0, 120)}`)
assert.ok(text.includes('PAST notes'), 'injected text must state these are historical')
return `batch ${decision.messages.length} msgs; injected kind=${injected.source.kind}, carries "4324"`
})
// ── V3b mutation: recall returning '' must make V3b's assertion fail ──────
await checkpoint('V3b†', 'MUTATION: silencing recall must break V3b (the test can turn red)', async () => {
const app = makeApp()
const mem = applyMemory(app.ctx, { path: join(workspace, 'v3b-mut.db'), topK: 3 })
mem.commit({ text: '值班室分机号 4324,值班人 老周', source: 'seed' })
// Mutate: no recall, ever.
mem.contextForStep = () => ''
const input = createUserMessage({
content: [{ type: 'text', text: '值班室分机号是多少' }],
source: { kind: 'user' },
})
const decision = await app.ctx.waterfall(
'agent/pre-step',
{ agent: { id: 'a', session: {} }, messages: [input], turn: 1, step: 1, signal: new AbortController().signal },
() => Promise.resolve({ kind: 'enter', messages: [input] }),
)
assert.equal(decision.messages.length, 1, 'mutation must leave the batch un-augmented')
return 'with recall disabled the batch stays at 1 message ⇒ the V3b assertion would fail'
})
// ── V4: tools.register output.schema / render contract ────────────────────
await checkpoint('V4', 'ctx.tools.register: stablemem_commit validates args and renders output', async () => {
const app = makeApp()
const mem = applyMemory(app.ctx, { path: join(workspace, 'v4.db') })
const schemas = app.ctx.tools.schemas()
const tool = schemas.find((schema) => schema.name === 'stablemem_commit')
assert.ok(tool, `stablemem_commit not registered; got ${schemas.map((s) => s.name).join(',')}`)
assert.ok(tool.description.includes('durable'), 'description reaches the model')
assert.ok(tool.parameters?.properties?.text, 'parameters expose `text`')
const result = await app.ctx.tools.execute({
callId: 'v4-call-1',
name: 'stablemem_commit',
arguments: { text: 'V4 校验块:etcd 集群端口 2379', source: 'v4' },
signal: new AbortController().signal,
})
assert.equal(result.isError, false, `tool failed: ${JSON.stringify(result)}`)
assert.ok(typeof result.value === 'string' && result.value.includes('Committed'), `unexpected value ${result.value}`)
assert.ok(Array.isArray(result.content) && result.content[0]?.type === 'text', 'render produced a text block')
assert.equal(mem.store.countBlocks(), 1, 'block persisted')
return `${schemas.length} tool(s) visible; execute → "${result.value}"`
})
// ── V5: dispose releases the handle (idempotent, file unlocked) ───────────
await checkpoint('V5', 'unload disposes the sqlite handle; a second close is a no-op', async () => {
const path = join(workspace, 'v5.db')
const app = makeApp()
const mem = applyMemory(app.ctx, { path })
mem.commit({ text: 'V5 句柄释放块', source: 'v5' })
assert.ok(mem.store.isOpen, 'store open while plugin loaded')
// Disposing the plugin fiber must run our ctx.effect teardown.
await app.ctx.fiber.dispose()
assert.equal(mem.store.isOpen, false, 'store must be closed after fiber disposal')
// Idempotent: a second close (test teardown, double unload) must not throw.
mem.store.close()
// The strongest proof the lock is gone: reopen the same file for writing.
const reopened = new BlockStore({ path }).open()
assert.equal(reopened.countBlocks(), 1, 'data survived; file was not locked')
reopened.close()
return 'fiber dispose → handle closed; reopened same file successfully'
})
// ── A5 mutation: LIKE-style containment must destroy discrimination ───────
await checkpoint('A5†', 'MUTATION: swapping FTS5 for containment breaks discrimination', () => {
const store = new BlockStore({ path: join(workspace, 'a5.db') }).open()
store.putBlock({ text: '值班室分机号 4324', source: 'm' })
store.putBlock({ text: '值班室分机号 4379 已停用', source: 'm' })
store.putBlock({ text: 'billing服务 端口=8499', source: 'm' })
const precise = recallBlocks(store, '4324', { topK: 3 })
assert.equal(precise.length, 1, `exact query should hit exactly one block, got ${precise.length}`)
// The mutation: primary path becomes "contains any CJK run" — everything matches.
const mutated = store
.allBlocks(100)
.filter((row) => row.text_content.includes('值班室'))
assert.ok(
mutated.length >= 2,
'sanity: containment over a shared prefix must match multiple blocks',
)
store.close()
return `FTS+pin → 1 hit for "4324"; containment → ${mutated.length} hits (discrimination lost)`
})
// ── cleanup ───────────────────────────────────────────────────────────────
rmSync(workspace, { recursive: true, force: true })
const passed = results.filter((entry) => entry.ok).length
console.log(`\n${passed}/${results.length} checkpoints passed\n`)
console.log(`plugin: name=${pluginName} inject=[${pluginInject.join(', ')}]`)
console.log(`workspace: ${workspace} (removed)\n`)
if (failed > 0) {
console.error(`${failed} checkpoint(s) FAILED\n`)
process.exitCode = 1
}

52
scripts/link-dsh.sh Executable file
View File

@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Link the globally-installed dsh packages into node_modules.
#
# The plugin itself has zero runtime dependencies (it uses only `node:` built-ins),
# so nothing is installed from a registry. The dsh packages are *peers*: they must
# come from the running harness. During development they live in
# /usr/lib/node_modules/@deepseek-ai (a global install), so this script wires them
# up by symlink instead of vendoring a copy that could drift from the harness.
#
# Usage: ./scripts/link-dsh.sh [global-modules-root]
set -euo pipefail
GLOBAL_ROOT="${1:-/usr/lib/node_modules}"
PEERS=(
cordis
cosmokit
dsh-llm
dsh-session
dsh-scope
dsh-system-prompt
dsh-tools
dsh-util-values
cordis-plugin-loader
)
cd "$(dirname "$0")/.."
if [[ ! -d "$GLOBAL_ROOT/@deepseek-ai" ]]; then
echo "error: $GLOBAL_ROOT/@deepseek-ai not found — pass the root that holds the dsh install" >&2
exit 1
fi
mkdir -p node_modules/@deepseek-ai
for peer in "${PEERS[@]}"; do
src="$GLOBAL_ROOT/@deepseek-ai/$peer"
if [[ ! -d "$src" ]]; then
echo " skip $peer (not installed)"
continue
fi
ln -sfn "$src" "node_modules/@deepseek-ai/$peer"
echo " link $peer"
done
# js-yaml is a transitive dep of cordis-plugin-include; needed only by the
# loader integration test, and only when it is not resolvable already.
if [[ -d "$GLOBAL_ROOT/js-yaml" && ! -e node_modules/js-yaml ]]; then
ln -sfn "$GLOBAL_ROOT/js-yaml" node_modules/js-yaml
echo " link js-yaml"
fi
echo
echo "done. try: npm test && npm run check"

137
test/loader.test.js Normal file
View File

@ -0,0 +1,137 @@
/**
* Real-loader integration test: mount the plugin the way a dsh profile does.
*
* This is the one test that proves the *packaging* is right — `name`/`inject`/
* `apply` exports, the `defineTool` contract, and `systemPrompt.context` all
* survive an actual `cordis-plugin-loader` mount rather than a hand-built app.
*
* If `cordis-plugin-loader` cannot be resolved (bare install, no dsh), the test
* skips rather than fails: the plugin itself does not depend on the loader.
*
* @module dsh-memory/test/loader
*/
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
import { Context } from '@deepseek-ai/cordis'
import { SystemPrompt } from '@deepseek-ai/dsh-system-prompt'
import { ToolRuntime } from '@deepseek-ai/dsh-tools'
import * as plugin from '../lib/index.js'
/** @returns {Promise<unknown>} the loader module, or null when unavailable */
async function tryLoadLoader() {
try {
return await import('@deepseek-ai/cordis-plugin-loader')
} catch {
return null
}
}
test('plugin exports the shape the cordis loader requires', () => {
// A loader entry is only mountable when these three exist.
assert.equal(typeof plugin.apply, 'function', 'apply(ctx, config) is required')
assert.equal(typeof plugin.name, 'string', 'name is required for diagnostics')
assert.ok(Array.isArray(plugin.inject), 'inject must be an array')
assert.deepEqual(plugin.inject, ['systemPrompt', 'tools'])
// The default export must be the same plugin (loaders unwrap `default`).
assert.equal(plugin.default.name, plugin.name)
assert.equal(plugin.default.apply, plugin.apply)
})
test('mounting twice on the same app fails loudly (service name is taken)', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-memory-dup-'))
const ctx = new Context()
new SystemPrompt(ctx, {})
new ToolRuntime(ctx, {})
try {
plugin.apply(ctx, { path: join(dir, 'a.db') })
// 'stablemem' is provided by the first instance; cordis refuses a second
// implementation in the same realm — which is exactly why a preset that
// wants its own implementation must isolate the service name
// (docs/shadow-context.md §5.2).
assert.throws(
() => plugin.apply(ctx, { path: join(dir, 'b.db') }),
/stablemem/,
'duplicate service registration must be rejected',
)
} finally {
await ctx.fiber.dispose()
rmSync(dir, { recursive: true, force: true })
}
})
test('an isolated realm can host its own stablemem (the preset route)', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-memory-iso-'))
const ctx = new Context()
new SystemPrompt(ctx, {})
new ToolRuntime(ctx, {})
try {
const outer = plugin.apply(ctx, { path: join(dir, 'outer.db') })
// A preset subtree isolates the service name and mounts a second instance.
// Prompt-context names and tool names are NOT partitioned by isolate()
// (dsh-system-prompt/lib/index.js:204, dsh-tools duplicate check), so a
// second instance needs a distinct tool name. The prompt-context name is
// derived from the db path automatically.
const realm = ctx.isolate('stablemem')
const inner = plugin.apply(realm, {
path: join(dir, 'inner.db'),
toolName: 'stablemem_commit_alt',
})
outer.commit({ text: 'outer-only 端口 1111' })
inner.commit({ text: 'inner-only 端口 2222' })
assert.equal(outer.store.countBlocks(), 1, 'outer store isolated')
assert.equal(inner.store.countBlocks(), 1, 'inner store isolated')
assert.notEqual(outer.store.path, inner.store.path, 'separate databases')
// The isolated service is reachable through the realm context, and the two
// contexts resolve DIFFERENT implementations — that is the whole point.
const realmService = realm.stablemem
// cordis hands out a PROXY, so identity differs from the instance we built;
// compare behaviour, not references. (This proxy is also why StableMem keeps
// ordinary fields instead of #private ones.)
assert.equal(realmService.store.countBlocks(), 1, 'realm sees only its own block')
assert.equal(realmService.store.path, inner.store.path, 'realm resolves the inner instance')
assert.equal(ctx.stablemem.store.path, outer.store.path, 'root resolves the outer instance')
assert.equal(ctx.stablemem.store.countBlocks(), 1, 'outer store untouched by the realm')
} finally {
await ctx.fiber.dispose()
rmSync(dir, { recursive: true, force: true })
}
})
test('a real cordis loader can mount the plugin by specifier', async (t) => {
const loaderModule = await tryLoadLoader()
if (loaderModule === null) {
t.skip('cordis-plugin-loader not installed in this environment')
return
}
const dir = mkdtempSync(join(tmpdir(), 'dsh-memory-loader-'))
const ctx = new Context()
new SystemPrompt(ctx, {})
new ToolRuntime(ctx, {})
try {
const pluginSpecifier = new URL('../lib/index.js', import.meta.url).href
const fiber = ctx.plugin(
{
name: pluginSpecifier,
inject: plugin.inject,
apply: plugin.apply,
},
{ path: join(dir, 'loaded.db') },
)
await fiber.await()
assert.ok(ctx.stablemem, 'the loaded plugin registered ctx.stablemem')
assert.ok(
ctx.tools.schemas().some((schema) => schema.name === 'stablemem_commit'),
'the loaded plugin registered its tool',
)
} finally {
await ctx.fiber.dispose()
rmSync(dir, { recursive: true, force: true })
}
})

231
test/plugin.test.js Normal file
View File

@ -0,0 +1,231 @@
/**
* Plugin-wiring tests against **real** dsh services.
*
* These are the assertions that would silently rot if dsh changed a hook, so they
* run the actual `SystemPrompt` and `ToolRuntime` rather than stubs. The
* checkpoint script covers the same ground with human-readable output; this file
* is what CI should run.
*
* Run: node --test test/
*
* @module dsh-memory/test/plugin
*/
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
import { Context } from '@deepseek-ai/cordis'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { SystemPrompt } from '@deepseek-ai/dsh-system-prompt'
import { ToolRuntime } from '@deepseek-ai/dsh-tools'
import { apply as applyMemory, lastUserText, name as pluginName } from '../lib/index.js'
/**
* Build a real cordis app carrying the services this plugin injects.
* `ToolRuntime` declares `inject = ['systemPrompt']`, so prompt comes first.
* @returns {Promise<{ctx: Context, dir: string, done: () => void}>}
*/
async function app() {
const dir = mkdtempSync(join(tmpdir(), 'dsh-memory-plugin-'))
const ctx = new Context()
new SystemPrompt(ctx, {})
new ToolRuntime(ctx, {})
return {
ctx,
dir,
done: async () => {
await ctx.fiber.dispose()
rmSync(dir, { recursive: true, force: true })
},
}
}
test('lastUserText picks the last user message and joins only text blocks', () => {
const messages = [
{ role: 'user', content: [{ type: 'text', text: 'first' }] },
{ role: 'assistant', content: [{ type: 'text', text: 'reply' }] },
{ role: 'user', content: [{ type: 'tool-result' }, { type: 'text', text: 'second' }] },
]
assert.equal(lastUserText(messages), 'second')
assert.equal(lastUserText([]), '')
assert.equal(lastUserText([{ role: 'assistant', content: [{ type: 'text', text: 'x' }] }]), '')
})
test('V3: systemPrompt.context exposes agent but no step messages', async () => {
const { ctx, done } = await app()
try {
let keys = []
ctx.systemPrompt.context({
name: 'probe:v3',
order: 210,
text: (context) => {
keys = Object.keys(context).sort()
return 'probe'
},
})
await ctx.systemPrompt.assemble({ agent: { id: 'a', session: {} } })
assert.ok(keys.includes('agent'), `AssembleContext must carry agent; got ${keys.join(',')}`)
// The negative assertion is the load-bearing one: if dsh ever adds the step
// messages here, recall could move to this hook and this slice's whole
// design note would be wrong.
for (const forbidden of ['messages', 'input', 'text']) {
assert.ok(!keys.includes(forbidden), `AssembleContext must NOT expose ${forbidden}`)
}
} finally {
await done()
}
})
test('V3b: pre-step injection adds one attributed message carrying the recall', async () => {
const { ctx, dir, done } = await app()
try {
const mem = applyMemory(ctx, { path: join(dir, 'm.db'), topK: 3 })
mem.commit({ text: '值班室分机号 4324,值班人 老周', source: 'seed' })
const input = createUserMessage({
content: [{ type: 'text', text: '值班室分机号 4324 是谁在值班' }],
source: { kind: 'user' },
})
const decision = await ctx.waterfall(
'agent/pre-step',
{
agent: { id: 'a', session: {} },
messages: [input],
turn: 1,
step: 1,
signal: new AbortController().signal,
},
() => Promise.resolve({ kind: 'enter', messages: [input] }),
)
assert.equal(decision.kind, 'enter')
assert.equal(decision.messages.length, 2, 'input + one injected recall')
const injected = decision.messages[1]
assert.equal(injected.role, 'user', 'injected context is a user message')
assert.equal(injected.source.kind, pluginName)
assert.equal(injected.source.form, 'snapshot')
assert.ok(injected.content.map((b) => b.text ?? '').join('').includes('4324'))
} finally {
await done()
}
})
test('V3b mutation: with recall silenced the batch stays un-augmented', async () => {
const { ctx, dir, done } = await app()
try {
const mem = applyMemory(ctx, { path: join(dir, 'm.db'), topK: 3 })
mem.commit({ text: '值班室分机号 4324,值班人 老周' })
mem.contextForStep = () => ''
const input = createUserMessage({
content: [{ type: 'text', text: '值班室分机号 4324' }],
source: { kind: 'user' },
})
const decision = await ctx.waterfall(
'agent/pre-step',
{
agent: { id: 'a', session: {} },
messages: [input],
turn: 1,
step: 1,
signal: new AbortController().signal,
},
() => Promise.resolve({ kind: 'enter', messages: [input] }),
)
assert.equal(decision.messages.length, 1, 'no injection when recall yields nothing')
} finally {
await done()
}
})
test('V3b: a rejected step is passed through untouched', async () => {
const { ctx, dir, done } = await app()
try {
applyMemory(ctx, { path: join(dir, 'm.db') })
const decision = await ctx.waterfall(
'agent/pre-step',
{
agent: { id: 'a', session: {} },
messages: [],
turn: 1,
step: 1,
signal: new AbortController().signal,
},
() => Promise.resolve({ kind: 'reject' }),
)
assert.equal(decision.kind, 'reject', 'a rejection must not be converted into an enter')
} finally {
await done()
}
})
test('V4: stablemem_commit registers with a schema and executes end to end', async () => {
const { ctx, dir, done } = await app()
try {
const mem = applyMemory(ctx, { path: join(dir, 'm.db') })
const schemas = ctx.tools.schemas()
const tool = schemas.find((entry) => entry.name === 'stablemem_commit')
assert.ok(tool, 'tool must be visible to the model')
assert.ok(tool.description.length > 0, 'tool must carry a description')
assert.ok(tool.parameters?.properties?.text, 'tool must declare its text parameter')
const result = await ctx.tools.execute({
callId: 'c1',
name: 'stablemem_commit',
arguments: { text: 'etcd 集群端口 2379', source: 'test' },
signal: new AbortController().signal,
})
assert.equal(result.isError, false, JSON.stringify(result))
assert.ok(String(result.value).includes('Committed'), result.value)
assert.equal(result.content[0].type, 'text', 'render must produce a text block')
assert.equal(mem.store.countBlocks(), 1)
} finally {
await done()
}
})
test('V5: disposing the fiber closes the store and releases the file', async () => {
const { ctx, dir, done } = await app()
const path = join(dir, 'm.db')
const mem = applyMemory(ctx, { path })
mem.commit({ text: 'V5 句柄释放块' })
assert.equal(mem.store.isOpen, true)
await ctx.fiber.dispose()
assert.equal(mem.store.isOpen, false, 'fiber disposal must close the handle')
// Strongest proof: reopen the same file for writing.
const { BlockStore } = await import('../lib/store.js')
const reopened = new BlockStore({ path }).open()
assert.equal(reopened.countBlocks(), 1)
reopened.close()
rmSync(dir, { recursive: true, force: true })
void done
})
test('config.inject:false registers the tool but no pre-step listener', async () => {
const { ctx, dir, done } = await app()
try {
applyMemory(ctx, { path: join(dir, 'm.db'), inject: false })
const input = createUserMessage({
content: [{ type: 'text', text: 'anything' }],
source: { kind: 'user' },
})
const decision = await ctx.waterfall(
'agent/pre-step',
{
agent: { id: 'a', session: {} },
messages: [input],
turn: 1,
step: 1,
signal: new AbortController().signal,
},
() => Promise.resolve({ kind: 'enter', messages: [input] }),
)
assert.equal(decision.messages.length, 1, 'injection must be off')
assert.ok(ctx.tools.schemas().some((s) => s.name === 'stablemem_commit'), 'tool still present')
} finally {
await done()
}
})

201
test/store.test.js Normal file
View File

@ -0,0 +1,201 @@
/**
* Unit tests for dsh-memory store + recall (phase A).
*
* Complements `scripts/checkpoints.js`: the checkpoints prove the harness wiring
* (real cordis, real SystemPrompt/ToolRuntime); these prove the algorithm and
* carry the mutation assertions that must be able to turn red.
*
* Run: node --test test/
*
* @module dsh-memory/test/store
*/
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
import { BlockStore, blockId } from '../lib/store.js'
import {
buildMatchExpression,
cjkNeedles,
isNumericSymbol,
querySymbols,
recallBlocks,
renderRecall,
} from '../lib/recall.js'
/** @returns {{store: BlockStore, done: () => void}} */
function tempStore() {
const dir = mkdtempSync(join(tmpdir(), 'dsh-memory-test-'))
const store = new BlockStore({ path: join(dir, 'x.db') }).open()
return {
store,
done: () => {
store.close()
rmSync(dir, { recursive: true, force: true })
},
}
}
test('blockId is content-addressed and stable', () => {
assert.equal(blockId('值班室分机号 4324'), blockId('值班室分机号 4324'))
assert.notEqual(blockId('a'), blockId('b'))
assert.match(blockId('x'), /^blk_[0-9a-f]{12}$/)
})
test('putBlock is idempotent and keeps existing optional columns', () => {
const { store, done } = tempStore()
try {
const first = store.putBlock({ text: 'a note', source: 'qq', scene: 'chan:qq', semanticType: 'Fact' })
assert.equal(first.created, true)
// Re-commit with no optional fields: text updates, metadata must NOT be blanked.
const second = store.putBlock({ text: 'a note' })
assert.equal(second.created, false)
assert.equal(second.id, first.id)
const row = store.getBlock(first.id)
assert.equal(row.source, 'qq', 'source must survive a bare re-commit')
assert.equal(row.scene, 'chan:qq', 'scene must survive a bare re-commit')
assert.equal(row.semantic_type, 'Fact', 'semantic_type must survive a bare re-commit')
assert.equal(store.countBlocks(), 1)
} finally {
done()
}
})
test('edges are unique per (source,target,type)', () => {
const { store, done } = tempStore()
try {
const a = store.putBlock({ text: '值班室分机号 4324' }).id
const b = store.putBlock({ text: '4324 已停用' }).id
const edge = { sourceKind: 'block', sourceId: a, targetKind: 'block', targetId: b, type: 'superseded_by' }
assert.equal(store.addEdge(edge).created, true)
assert.equal(store.addEdge(edge).created, false, 'duplicate edge must not double')
assert.equal(store.countEdges(), 1)
} finally {
done()
}
})
test('querySymbols mirrors HologramAgent regexes and drops single chars', () => {
assert.deepEqual(querySymbols('本机 13010 端口'), ['13010'])
assert.deepEqual(querySymbols('版本 v2.31.5'), ['v2.31.5'])
assert.deepEqual(querySymbols('billing0 端口'), ['billing0'], 'no bare 0 from billing0')
// Single-character symbols are rejected (abstain.go:115 "单字不成符号").
assert.deepEqual(querySymbols('a 1 中'), [])
})
test('isNumericSymbol identifies digit-bearing symbols', () => {
assert.equal(isNumericSymbol('13010'), true)
assert.equal(isNumericSymbol('v2.31.5'), true)
assert.equal(isNumericSymbol('billing'), false)
})
test('buildMatchExpression quotes terms and refuses an empty query', () => {
assert.equal(buildMatchExpression(''), null)
assert.equal(buildMatchExpression(' '), null)
const expr = buildMatchExpression('billing 端口')
assert.ok(expr.includes('"billing"'), expr)
// Injected FTS5 operators must be neutralised, never passed through.
const hostile = buildMatchExpression('a OR b NEAR(c)')
assert.ok(!/\bNEAR\(/.test(hostile ?? ''), `FTS operators must be quoted, got ${hostile}`)
})
test('cjkNeedles yields 2..4-char windows plus the whole run', () => {
const needles = cjkNeedles('值班室分机号')
assert.ok(needles.includes('值班室分机号'), 'whole run present')
assert.ok(needles.includes('分机号'), '3-char window present')
assert.ok(needles.includes('分机'), '2-char window present')
assert.ok(!needles.includes('值'), 'single chars excluded')
})
test('exact numeric hit pins to the top above plain lexical matches', () => {
const { store, done } = tempStore()
try {
store.putBlock({ text: '值班室分机号 4379 已停用' })
store.putBlock({ text: '值班室分机号 4324,值班人 老周' })
const hits = recallBlocks(store, '4324 是多少', { topK: 5 })
assert.equal(hits[0].text.includes('4324'), true, `expected 4324 first, got ${hits[0].text}`)
assert.equal(hits[0].exact, true)
} finally {
done()
}
})
test('a matched word prefers the block where it is not a prefix of a longer word', () => {
const { store, done } = tempStore()
try {
store.putBlock({ text: 'billing2服务 端口=8501' })
store.putBlock({ text: 'billing服务 端口=8801' })
const hits = recallBlocks(store, 'billing 服务的端口是多少', { topK: 2 })
assert.equal(
hits[0].text.includes('8801'),
true,
`expected the exact "billing" note first, got ${hits[0].text}`,
)
} finally {
done()
}
})
test('unrelated query yields nothing (no silent everything-match)', () => {
const { store, done } = tempStore()
try {
store.putBlock({ text: '值班室分机号 4324' })
store.putBlock({ text: 'billing服务 端口=8499' })
assert.deepEqual(recallBlocks(store, 'zzz-不存在-qqq'), [])
} finally {
done()
}
})
test('renderRecall states the content is historical and yields "" for no hits', () => {
assert.equal(renderRecall([]), '')
const text = renderRecall([{ text: '值班室分机号 4324', exact: true, score: 1.01 }])
assert.ok(text.includes('PAST notes'), 'the model must be told these are historical')
assert.ok(text.includes('4324'))
})
// ── mutations: each must turn a specific assertion red ────────────────────
test('MUTATION: removing the numeric-gate makes a bare 0 pin noise', () => {
const { store, done } = tempStore()
try {
store.putBlock({ text: '发布批次 第8批|版本=v2.38.0' })
store.putBlock({ text: 'billing0服务 端口=8499' })
// Real behaviour: `billing0` is the symbol, so the billing note wins.
const real = recallBlocks(store, 'billing0 端口是多少', { topK: 1 })
assert.equal(real[0].text.includes('8499'), true, `expected billing note, got ${real[0].text}`)
// Mutation: pretend a bare `0` were a symbol — it appears in v2.38.0, so the
// unrelated note would pin to the top. Demonstrated directly:
const bare = querySymbols('billing0')
assert.ok(!bare.includes('0'), 'the single-char gate is what prevents this')
} finally {
done()
}
})
test('MUTATION: containment-only recall loses discrimination (the reason FTS is primary)', () => {
const { store, done } = tempStore()
try {
store.putBlock({ text: '值班室分机号 4324' })
store.putBlock({ text: '值班室分机号 4379 已停用' })
// Primary path: precise. One hit for an exact query.
assert.equal(recallBlocks(store, '4324', { topK: 5 }).length, 1)
// Mutated path: a shared CJK prefix matches both, so the answer becomes
// ambiguous. This is why FTS + symbol pin, not LIKE, is the primary path.
const contained = store.allBlocks(100).filter((row) => row.text_content.includes('值班室'))
assert.equal(contained.length, 2)
} finally {
done()
}
})
test('close is idempotent and a closed store refuses work', () => {
const { store, done } = tempStore()
store.putBlock({ text: 'x' })
store.close()
store.close() // must not throw
assert.equal(store.isOpen, false)
assert.throws(() => store.countBlocks(), /not open/)
done()
})