Files
jianf 114fae84aa 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 未改动。
2026-10-30 09:44:42 +08:00

202 lines
7.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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()
})