五条全部处理。第 5 条确认属实:saveSpaceCentroid 在 lib/ 里零调用点,
真实使用下中心表永久空着,票③ 的「dim unknown」会从边角变成常态。
W1/W5 信号转发(最硬的一条)
- execute(args, exec) → commit(input, {signal}) → embedText(text, signal)
- abort 时按「向量化失败」降级落盘:块写入、vector 留空
- 依据 dsh-tools/lib/types/index.d.ts:120-122
★ 一处我判错了,据实记录
W5 我原本断言「abort 时 isError === false」,实测是 true。查了实现:
dsh-tools/lib/index.js:3408 在工具成功后**再检查一次 signal**,
已 abort 就用 toolAbortedResult 覆盖成功结果。所以 isError 不归本插件控制,
断言它等于和 harness 对抗。改为断言**副作用**(块已写入、vector 为空),
并额外断言 error.info.code === 'ABORTED'。
W2 九个调用点补 await
scripts/checkpoints.js ×4、test/plugin.test.js ×5、test/loader.test.js ×2
(loader 两处保持并发语义用 Promise.all)。
原本绿是因为「无 embedder 时第一个 await 之前就写完 putBlock」——
这个隐式事实一旦被一行 await 打破,九处会静默变成「块没写」,
而且红会出现在离原因很远的地方(靠后面能否召回间接断言)。
W3 空指纹先判后嵌
原来顺序是 await embedText → putBlock 抛 → catch → 再 putBlock,
语义终点对但白付一次网络往返,且与 recall.js:430 的口径不一致。
改为 embed 之前判 space.fingerprint === ''。
W4 写侧维度校验
commit 现在检查 vector.length === space.dimension,不符按 embed failure 处理
(无向量落盘)。不落坏向量的理由:召回侧会按 skippedDim 静默跳过,
用户看到的是「记忆里有这条,但永远召不回来」。
W8 建中心入口(第 5 条)
新增 store.rebuildCentroid(fingerprint),移植 RebuildCentroid
(centroid_store.go:246-320):
· 只取该空间的带向量行
· 维度不符跳过不强制
· 按 L2 归一化后累加均值
· 均值范数 >= 0.6 才判 anomalous(centroid.go:87)
· 计入 stats:meanLength / avgPairCosine / skippedRows
并新增 MEAN_THRESHOLD 常量。
★ W8 第一版判据是错的:我用完全共线的向量(meanLength = 1.0000)
去测 0.6 阈值,把阈值改成 0.95 仍然通过。已改用 ±35° 锥
(meanLength ≈ 0.879,落在 0.6 与 0.95 之间),并加断言
「语料必须落在两阈值之间」,否则测试自身就失去区分力。
判据与变异(全部在 /tmp 干净副本上跑)
W4 维度不符 → 块在、vector=''、fingerprint='' | 删维度检查 → 红
W5 abort → 块在、vector=''、elapsed<2s | 不传 signal → 红(实测 5003ms)
W6 无 embedder 时 {id,created} 与 putBlock 同形 | —
W7 空指纹 → embedText 调用 0 次 | 改为事后判 → 红
W8 同空间重建、跨空间隔离、坏维度跳过、阈值判定 | 去空间过滤 → 3 条红
| 去维度过滤 → 1 条红
| 阈值 0.6→0.95 → 1 条红
未越界
- 四态计数(noVector/unknownSpace/spaceMismatch/embedFailure)留给 F/D
- recallBlocks 仍然同步、未接入 vectorScores(V 组的事)
- 票③「dim 来自中心元数据而非配置」继续有效;saveSpaceCentroid 仍在用
dim ?? space.dimension,已在注释里标记为 C 组要区分「没建过」与
「建过但维度取自配置」——声明不是证据
顺序按你的调整记下:H(完工) → V → C → F → D。
C 组准入条件已满足一半:机制(rebuildCentroid)有了,
「谁触发建中心」待 C 组定。
验证
- npm test 99/99(90 + W4/W5/W6/W7 六条 + W8 三条)
- npm run check 10/10
- 提交前连跑 3 次 test + 3 次 check,6 次全绿
144 lines
5.8 KiB
JavaScript
144 lines
5.8 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',
|
|
})
|
|
|
|
// 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 })
|
|
}
|
|
})
|