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