顺序遵守裁定:F2 过滤先落地(上一条 425c441),这一条才加计数。
先做计数会数出一个恒为 0 的假指标。
四态定义与归属
noVector vector 为空 回填
unknownSpace 有 vector 但 fp 为空 重嵌(说不出来属空间)
spaceMismatch 有 vector、有 fp ≠ active 重嵌到当前空间
dimensionMismatch 有 vector 但宽度不对 改 provider 配置(F4:与上面分开)
embedFailure 本次调用拿不到向量 ← 不在这里,它是单次事件不是库状态
F3/F4 判据(4 条行为 + 3 条变异)
F3 四态从库派生;skipped 非零(实测 skipped=2,不是恒 0)
F4 dim 与 fp 分开计数,且不互相重复计入
F3 改库/删库后计数跟着变(证明不是内存累加,D4)
F3 skipped 在符号路也计(fusion.go:495 同款:只测向量路等于没测)
F3† 内存累加器实现 ⇒ 「跟随数据库」断言红
F3†symbol 计数只挂在向量路 ⇒ 符号路的 skipped 断言红
F4† 把 dim 并进 spaceMismatch ⇒ 分桶断言红
D4:从 DB 派生,不累加
store.blockStates(activeFp, activeDim) 每次扫行计算,无缓存无累加。
判据用「绕过插件直接 UPDATE / DELETE,计数必须跟着变」证明。
D3/D5:两个消费者,一个来源
- renderIndex() 与 stablemem_stats 都调 blockStates(),不各留一份
- D5 的提示读同一个 unavailable 计数(noVector+unknownSpace+
spaceMismatch+dimensionMismatch),说明「N 条暂不可语义召回,
仍可按精确文本检索」
- D3 判据含无空间/有空间两例:无 active space 时 mismatch 必然为 0
(没有参照物),这条我第一版判据写错了,已按实际语义修正
★ 一处新工具引入的回归(已修)
新增 stablemem_stats 时把工具名硬编码,第二个实例挂载时撞名
"tool 'stablemem_stats' is already registered" —— 与之前 prompt context
名同型:isolate() 不分区工具名。已改为可从 config.statsToolName 覆写,
loader 测试同步传参。
★ 一处「注册了但跑不到」的坑(已修)
registerTools 里我用了 return ctx.tools.register(...),后续第二个
register 永远不可达,但文件读起来像注册了两个。已去掉 return 并注释。
验证
- npm test 143/143(138 + F3/F4 四条 + 变异三条 + D3/D5 两条)
- npm run check 10/10
- 实测数字(5 块构造库):
blockStates('fp-cur',3) = {total:5, active:1, noVector:1,
unknownSpace:1, spaceMismatch:1, dimensionMismatch:1}
recall("缓存", fp-cur) → 返回 1 条, skipped=2
recall("4324", fp-cur) → 返回 2 条, skipped=2
- 干净副本实数见后续消息
145 lines
5.9 KiB
JavaScript
145 lines
5.9 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',
|
|
statsToolName: 'stablemem_stats_alt',
|
|
})
|
|
|
|
// Both awaits are required: commit() embeds (async) before writing, and
|
|
// relying on "no embedder means it happens synchronously" is exactly the
|
|
// fragile assumption this guards against. Promise.all keeps them concurrent,
|
|
// which is what this test is about (two isolated realms writing in parallel).
|
|
await Promise.all([
|
|
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 })
|
|
}
|
|
})
|