Files
HologramAgent-DSH-Memory-Port/test/loader.test.js
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

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 })
}
})