按 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 未改动。
232 lines
7.9 KiB
JavaScript
232 lines
7.9 KiB
JavaScript
/**
|
||
* 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()
|
||
}
|
||
})
|