dsh-memu-memory 1.1.2 → 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/AGENTS.md +8 -6
  2. package/README.md +21 -5
  3. package/README.zh.md +21 -5
  4. package/docs/contributing.md +5 -3
  5. package/docs/env-and-commands.md +13 -0
  6. package/docs/host-api.md +35 -3
  7. package/docs/memory-semantics.md +32 -5
  8. package/docs/troubleshooting.md +36 -7
  9. package/lib/activity-log.js +190 -0
  10. package/lib/backup.js +1103 -0
  11. package/lib/binding-diagnostics.js +630 -0
  12. package/lib/client.js +697 -49
  13. package/lib/curator.js +81 -37
  14. package/lib/cycle.js +3 -0
  15. package/lib/embedding-catalog.js +763 -0
  16. package/lib/embedding-download.js +407 -0
  17. package/lib/embedding-runtime.js +710 -0
  18. package/lib/engine.js +2 -2
  19. package/lib/index.js +36 -0
  20. package/lib/inject.js +131 -30
  21. package/lib/local-embedding-entry.js +145 -0
  22. package/lib/local-embedding-manager.js +249 -0
  23. package/lib/local-embedding-protocol.js +173 -0
  24. package/lib/local-embedding-worker.js +652 -0
  25. package/lib/local-vector-service.js +283 -0
  26. package/lib/memory.js +385 -45
  27. package/lib/memu-config.js +10 -22
  28. package/lib/memu.js +53 -19
  29. package/lib/paths.js +24 -0
  30. package/lib/private.js +139 -13
  31. package/lib/rpc.js +45 -4
  32. package/lib/state.js +109 -161
  33. package/lib/tools.js +9 -5
  34. package/lib/vector-migration.js +878 -0
  35. package/lib/vector-profile.js +941 -0
  36. package/package.json +3 -2
  37. package/scripts/acceptance-local.mjs +5 -4
  38. package/scripts/chain-matrix.mjs +527 -0
  39. package/scripts/check-all.mjs +19 -0
  40. package/scripts/check-doc-drift.mjs +353 -0
  41. package/scripts/diagnose-bindings.mjs +176 -0
  42. package/scripts/local-runtime-install.mjs +142 -0
  43. package/scripts/local-runtime-verify.mjs +56 -0
  44. package/scripts/local-vector-verify.mjs +93 -0
  45. package/scripts/recall-bench.mjs +813 -0
  46. package/scripts/repetition-ab-scenarios.mjs +93 -0
  47. package/scripts/repetition-detector-snapshot.mjs +314 -0
  48. package/scripts/repetition-eval-live.mjs +360 -0
  49. package/scripts/repetition-eval.mjs +702 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-memu-memory",
3
3
  "icon": "./assets/icon.png",
4
- "version": "1.1.2",
4
+ "version": "1.1.3",
5
5
  "description": "面向长篇 RP 与陪伴对话的 DeepSeek Harness 记忆插件:三层记忆、逐轮召回、来源标注、关系图、图片附件与独立 WebUI。",
6
6
  "keywords": [
7
7
  "deepseek-harness",
@@ -59,7 +59,8 @@
59
59
  "test:graph-browser": "node scripts/check-graph-browser.mjs",
60
60
  "check": "node scripts/check-all.mjs",
61
61
  "setup": "node scripts/setup.mjs",
62
- "test": "node --import ./tests/loader-register.mjs --test tests/*.test.mjs"
62
+ "test": "node --import ./tests/loader-register.mjs --test tests/*.test.mjs",
63
+ "test:chain": "node scripts/chain-matrix.mjs"
63
64
  },
64
65
  "engines": {
65
66
  "node": ">=20.9.0",
@@ -231,11 +231,12 @@ await auto('B-2', 'a failing recall is reported and the turn still gets its text
231
231
  const exec = { agent: { session: { header: { id: sessionId, cwd, agentPreset: binding.agentPreset } } } }
232
232
  // The realistic failure: the engine backend is selected but not installed. It is
233
233
  // set through the same RPC the settings page uses, so this is the user-facing path.
234
- await dispatch({}, 'setSettings', { patch: { memoryBackend: 'api' } })
235
- const failed = await inject.buildRecall(binding, 'something to look up', 1, state.getSettings(), exec, sessionId)
236
- await dispatch({}, 'setSettings', { patch: { memoryBackend: 'agent' } })
234
+ await dispatch({}, 'setSettings', { patch: { memoryBackend: 'api', vectorSource: 'local' } })
235
+ const failed = await inject.buildRecall(binding, 'title mem-b2a', 1, state.getSettings(), exec, sessionId)
236
+ await dispatch({}, 'setSettings', { patch: { memoryBackend: 'agent', vectorSource: 'local' } })
237
237
  assert.ok(failed.errors.length > 0, 'the failure is carried out, not swallowed')
238
- assert.match(failed.errors.join(' '), /private recall failed/)
238
+ assert.match(failed.errors.join(' '), /semantic recall failed/)
239
+ assert.match(failed.text, /mem-b2a/, 'lexical fallback still returns the matching memory')
239
240
  assert.equal(typeof failed.text, 'string', 'the caller still receives a string to inject')
240
241
  const healthy = await inject.buildRecall(binding, 'something to look up', 2, state.getSettings(), exec, sessionId)
241
242
  assert.deepEqual(healthy.errors, [], 'a healthy turn reports nothing')
@@ -0,0 +1,527 @@
1
+ #!/usr/bin/env node
2
+ // 链路矩阵:backend × trigger。证明每条链路都走得通,而不只是各层各自可测。
3
+ //
4
+ // node scripts/chain-matrix.mjs [--verbose]
5
+ //
6
+ // backend agent 纯词法(memoryBackend=agent)
7
+ // local 受管本地向量(memoryBackend=api + vectorSource=local)
8
+ // api 远程向量(memoryBackend=api + vectorSource=api)
9
+ // trigger manual 设置页「整理这次对话」→ RPC generatePrivate → 种子 fork
10
+ // tool 模型自主调用 memu_private_remember
11
+ // idle 空闲整理 sweep(sleepAfterIdleMinutes,默认 3 分钟)
12
+ //
13
+ // 每个格子断言同一组事实:写入落在父库索引 → 语义查询召回到它 → 注入文本带上同一 id;
14
+ // 词法查询也必须命中(融合不吞掉词法腿);受管路由必须在租约里 commit。
15
+ //
16
+ // 边界:MemU 引擎(Python/SQLite)被替换成记账桥,因为本机没有引擎安装。所以这里验证的是
17
+ // 插件侧直到引擎调用为止的每一段链路;引擎自身的存储/检索由真机验收负责(docs §14)。
18
+ // 向量那一路走真实 embedding worker(loopback HTTP、真协议、真维度校验),但适配器是确定性
19
+ // 概念编码器——它只证明链路通,不代表任何真实模型的语义质量。质量问题看 recall-bench。
20
+
21
+ import assert from 'node:assert/strict'
22
+ import crypto from 'node:crypto'
23
+ import fs from 'node:fs'
24
+ import os from 'node:os'
25
+ import path from 'node:path'
26
+ import { execFileSync, spawnSync } from 'node:child_process'
27
+ import { registerHooks } from 'node:module'
28
+ import { fileURLToPath } from 'node:url'
29
+
30
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
31
+ const VERBOSE = process.argv.includes('--verbose')
32
+
33
+ // 宿主包只在 DSH 里存在;和 npm test / acceptance 用同一套 loader stub 重跑一次。
34
+ if (!process.env.DSH_MEMU_CHAIN_CHILD) {
35
+ const child = spawnSync(process.execPath, [
36
+ '--import', './tests/loader-register.mjs',
37
+ fileURLToPath(import.meta.url),
38
+ ...process.argv.slice(2),
39
+ ], { cwd: ROOT, stdio: 'inherit', env: { ...process.env, DSH_MEMU_CHAIN_CHILD: '1' } })
40
+ process.exit(typeof child.status === 'number' ? child.status : 1)
41
+ }
42
+
43
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-chain-'))
44
+ process.env.DSH_MEMU_HOME = home
45
+ process.env.DSH_MEMU_MEMORY_HOME = home
46
+
47
+ // ------------------------------------------------------------ 引擎边界替身
48
+ // memory.js / curator.js / rpc.js 都从 './memu.js' 取 commit/retrieve/forget/listFiles。
49
+ // 这里整体换成一个记账桥:调用被记录,retrieve 委托给当前格子的钩子。
50
+ const bridge = {
51
+ calls: [],
52
+ retrieveHook: null,
53
+ failRetrieve: false,
54
+ reset() {
55
+ this.calls.length = 0
56
+ this.retrieveHook = null
57
+ this.failRetrieve = false
58
+ },
59
+ }
60
+ globalThis.__chainMatrixBridge = {
61
+ async commit(args) {
62
+ let leased = null
63
+ try {
64
+ const current = (await import('../lib/vector-profile.js')).activeVectorState()
65
+ leased = Object.keys(current?.writeGuard?.leases || {}).length > 0
66
+ } catch { leased = null }
67
+ bridge.calls.push({ op: 'commit', db: args?.db || null, route: args?.route || null, leased })
68
+ return { ok: true }
69
+ },
70
+ async retrieve(args) {
71
+ bridge.calls.push({ op: 'retrieve', db: args?.db || null, route: args?.route || null })
72
+ if (bridge.failRetrieve) throw new Error('embedding worker unreachable (fixture)')
73
+ if (!bridge.retrieveHook) return { files: [], segments: [], resources: [], raw: null }
74
+ return bridge.retrieveHook(args)
75
+ },
76
+ async forget(args) {
77
+ bridge.calls.push({ op: 'forget', db: args?.db || null })
78
+ return { ok: true }
79
+ },
80
+ async listFiles(args) {
81
+ bridge.calls.push({ op: 'listFiles', db: args?.db || null })
82
+ return { files: [], segments: [], resources: [] }
83
+ },
84
+ }
85
+ const engineSource = [
86
+ 'const b = globalThis.__chainMatrixBridge;',
87
+ 'export const commit = (...a) => b.commit(...a);',
88
+ 'export const retrieve = (...a) => b.retrieve(...a);',
89
+ 'export const forget = (...a) => b.forget(...a);',
90
+ 'export const listFiles = (...a) => b.listFiles(...a);',
91
+ ].join(' ')
92
+ const engineUrl = 'data:text/javascript,' + encodeURIComponent(engineSource)
93
+ const hooks = registerHooks({
94
+ resolve(specifier, context, nextResolve) {
95
+ if (specifier === './memu.js') return { url: engineUrl, shortCircuit: true }
96
+ return nextResolve(specifier, context)
97
+ },
98
+ })
99
+
100
+ const state = await import('../lib/state.js')
101
+ const memory = await import('../lib/memory.js')
102
+ const inject = await import('../lib/inject.js')
103
+ const cycle = await import('../lib/cycle.js')
104
+ const tools = await import('../lib/tools.js')
105
+ const vp = await import('../lib/vector-profile.js')
106
+ const { dispatch } = await import('../lib/rpc.js')
107
+ const { startEmbeddingWorker } = await import('../lib/local-embedding-worker.js')
108
+
109
+ // ------------------------------------------------------------ 确定性“语义”编码器
110
+ // 概念表只服务于断言:记忆和查询共享概念但**不共享任何字符**,词法腿必然召不回,
111
+ // 向量腿必须召回到。这样每个格子都能同时验证“词法仍在工作”和“向量真的接上了”。
112
+ const DIMENSION = 16
113
+ const CONCEPTS = [
114
+ { at: 0, words: ['黄昏', '斜阳', '夕阳', '日落', '傍晚', 'sunset'] },
115
+ { at: 1, words: ['沙滩', '海岸', '海边', '滩涂', 'beach'] },
116
+ { at: 2, words: ['散步', '慢行', '慢走', 'walk'] },
117
+ { at: 3, words: ['数据库', '迁移', '事务', 'database', 'migration'] },
118
+ ]
119
+ function conceptVector(value) {
120
+ const text = String(value || '')
121
+ const vector = new Array(DIMENSION).fill(0)
122
+ let hit = false
123
+ for (const concept of CONCEPTS) {
124
+ for (const word of concept.words) {
125
+ if (text.includes(word)) { vector[concept.at] += 1; hit = true }
126
+ }
127
+ }
128
+ if (!hit) vector[DIMENSION - 1] = 1
129
+ const norm = Math.sqrt(vector.reduce((sum, x) => sum + x * x, 0)) || 1
130
+ return vector.map(x => x / norm)
131
+ }
132
+ const cosine = (a, b) => a.reduce((sum, x, i) => sum + x * b[i], 0) / ((Math.hypot(...a) * Math.hypot(...b)) || 1)
133
+
134
+ // 记忆和查询没有共同字符:字符串层面的词法召回一定是空。
135
+ const MEMORY_TITLE = '海边的黄昏'
136
+ const MEMORY_TEXT = '黄昏时分在沙滩上散步,海风把话吹得很轻。'
137
+ const DISTRACTOR_TEXT = '数据库迁移必须保持事务一致性。'
138
+ const SEMANTIC_QUERY = '斜阳里慢行'
139
+ const LEXICAL_QUERY = '散步'
140
+
141
+ // ------------------------------------------------------------ 真实 embedding worker
142
+ const modelDir = path.join(home, 'fixture-model')
143
+ fs.mkdirSync(modelDir, { recursive: true })
144
+ const token = crypto.randomBytes(32).toString('hex')
145
+ let workerRequests = 0
146
+ const worker = await startEmbeddingWorker({
147
+ modelDir,
148
+ manifest: {
149
+ id: 'chain-matrix-fixture',
150
+ model: 'chain-matrix-fixture@fixture',
151
+ status: 'verified',
152
+ verified: true,
153
+ dimension: DIMENSION,
154
+ maxTokens: 512,
155
+ pooling: 'cls',
156
+ normalization: 'l2',
157
+ },
158
+ token,
159
+ adapter: { encode: async (texts) => texts.map(conceptVector) },
160
+ logger: { info() {}, warn() {} },
161
+ })
162
+ async function embedOverHttp(texts) {
163
+ workerRequests += 1
164
+ const response = await fetch(worker.url, {
165
+ method: 'POST',
166
+ headers: { authorization: 'Bearer ' + token, 'content-type': 'application/json' },
167
+ body: JSON.stringify({ model: worker.model, input: texts }),
168
+ })
169
+ const payload = await response.json()
170
+ if (!response.ok) throw new Error('worker ' + response.status + ': ' + JSON.stringify(payload).slice(0, 200))
171
+ return payload.data.map(row => row.embedding)
172
+ }
173
+
174
+ // ------------------------------------------------------------ 会话/库/向量代际
175
+ let serial = 0
176
+ const results = []
177
+
178
+ function resetCell() {
179
+ fs.rmSync(vp.vectorActiveFile(), { force: true })
180
+ state.updateSettings({
181
+ memoryBackend: 'agent', vectorSource: 'local', relevanceThreshold: 0, privateTopK: 8,
182
+ sleepConsolidation: false, autoCurateEveryHours: 0, autoInject: 'smart',
183
+ })
184
+ state.saveGlobalIndex({ entries: {} })
185
+ bridge.reset()
186
+ workerRequests = 0
187
+ }
188
+
189
+ function activate(libraryIds) {
190
+ const profile = vp.profileFromCatalog('bge-small-zh-v1.5-int8')
191
+ const databases = vp.buildDatabaseSet({ profile, generation: 1, libraryIds })
192
+ fs.mkdirSync(path.join(home, 'libraries'), { recursive: true })
193
+ for (const row of [databases.global, ...Object.values(databases.libraries)]) {
194
+ fs.writeFileSync(vp.resolveVectorPath(row.path), '')
195
+ }
196
+ vp.updateVectorStateSync(current => ({
197
+ ...current, generation: 1, requested: null, pending: null,
198
+ active: { status: 'active', generation: 1, fingerprint: profile.fingerprint, profile, databases },
199
+ }))
200
+ }
201
+
202
+ function seedLibrary({ content = MEMORY_TEXT, title = MEMORY_TITLE } = {}) {
203
+ serial += 1
204
+ const sessionId = 'chain-session-' + serial
205
+ const cwd = path.join(home, 'ws', 'chain-' + serial)
206
+ state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
207
+ state.setSessionBinding(sessionId, { useLibrary: true, useGlobal: false })
208
+ const binding = state.getSessionBinding(sessionId)
209
+ const index = state.loadPrivateIndex(binding.libraryId)
210
+ if (content) {
211
+ index.entries['mem-fixture-' + serial] = {
212
+ track: 'memory', layer: 'long_term', status: 'active',
213
+ title, content, domain: 'coding',
214
+ createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
215
+ }
216
+ state.savePrivateIndex(binding.libraryId, index)
217
+ }
218
+ return { sessionId, cwd, libraryId: binding.libraryId }
219
+ }
220
+
221
+ function setBackend(backend) {
222
+ state.updateSettings({
223
+ memoryBackend: backend === 'agent' ? 'agent' : 'api',
224
+ vectorSource: backend === 'api' ? 'api' : 'local',
225
+ })
226
+ }
227
+
228
+ function execFor(sessionId, cwd) {
229
+ return { agent: { session: { header: { id: sessionId, cwd, agentPreset: 'coding' } } } }
230
+ }
231
+
232
+ // ------------------------------------------------------------ 向量召回钩子
233
+ function localVectorRetrieve(libraryId) {
234
+ return async ({ query, route }) => {
235
+ assert.equal(route, 'managed-local', 'the local route must not fall back to remote')
236
+ const index = state.loadPrivateIndex(libraryId)
237
+ const entries = Object.entries(index.entries).filter(([, entry]) => entry.status !== 'archived')
238
+ const queryVector = (await embedOverHttp([query]))[0]
239
+ const texts = entries.map(([, entry]) => [entry.title, entry.content].filter(Boolean).join(' '))
240
+ const vectors = texts.length ? await embedOverHttp(texts) : []
241
+ const scored = entries
242
+ .map(([id], i) => ({ id, score: cosine(queryVector, vectors[i]) }))
243
+ .filter(row => row.score >= 0.5)
244
+ .sort((a, b) => b.score - a.score)
245
+ return { files: scored.map(row => ({ name: row.id })), segments: [], resources: [], raw: null }
246
+ }
247
+ }
248
+
249
+ // 远程那一路只验证路由与融合:用同一个概念编码器模拟“远端已经理解了查询”。
250
+ function remoteVectorRetrieve(libraryId) {
251
+ return async ({ query, route }) => {
252
+ assert.equal(route, 'remote-api', 'the remote route must be passed explicitly')
253
+ const index = state.loadPrivateIndex(libraryId)
254
+ const queryVector = conceptVector(query)
255
+ const scored = Object.entries(index.entries)
256
+ .filter(([, entry]) => entry.status !== 'archived')
257
+ .map(([id, entry]) => ({ id, score: cosine(queryVector, conceptVector([entry.title, entry.content].join(' '))) }))
258
+ .filter(row => row.score >= 0.5)
259
+ .sort((a, b) => b.score - a.score)
260
+ return { files: scored.map(row => ({ name: row.id })), segments: [], resources: [], raw: null }
261
+ }
262
+ }
263
+
264
+ // ------------------------------------------------------------ 宿主 ctx(fork 宿主)
265
+ const TRANSCRIPT = [
266
+ { seq: 0, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: '帮我把这段对话里值得记的东西整理成记忆,尤其是海边的那个傍晚。' }] } },
267
+ { seq: 1, type: 'assistant/message', data: { message: { content: [{ type: 'tool-call', name: 'read', arguments: '{}' }] } } },
268
+ ]
269
+
270
+ function makeHost({ sessionId, cwd, onFork }) {
271
+ const handlers = []
272
+ const created = []
273
+ let pending = Promise.resolve()
274
+ const ctx = {
275
+ on(name, handler) {
276
+ if (name !== 'session/event') return () => {}
277
+ handlers.push(handler)
278
+ return () => { const at = handlers.indexOf(handler); if (at >= 0) handlers.splice(at, 1) }
279
+ },
280
+ get(name) {
281
+ if (name === 'sessionQuery') {
282
+ return {
283
+ readSession: async (id) => id === sessionId
284
+ ? { events: TRANSCRIPT, session: { id, cwd, agentPreset: 'coding' } }
285
+ : { events: [] },
286
+ }
287
+ }
288
+ if (name === 'agents') {
289
+ return {
290
+ get: (id) => id === sessionId
291
+ ? { status: 'idle', options: { provider: 'chain-matrix', model: 'fixture' } }
292
+ : undefined,
293
+ create: async (spec) => {
294
+ const childSession = { id: spec.sessionId, header: { id: spec.sessionId, cwd, agentPreset: 'coding' } }
295
+ created.push({ id: spec.sessionId, meta: spec.meta, seed: spec.seed })
296
+ return {
297
+ agent: {
298
+ session: childSession,
299
+ followup(message) {
300
+ pending = (async () => {
301
+ try { await onFork({ childId: spec.sessionId, message }) }
302
+ finally {
303
+ for (const handler of [...handlers]) {
304
+ handler(childSession, { type: 'turn/end', seq: 999, data: { turn: 1, reason: { kind: 'completed' } } })
305
+ }
306
+ }
307
+ })()
308
+ },
309
+ whenIdle: () => pending,
310
+ },
311
+ dispose: async () => {},
312
+ }
313
+ },
314
+ }
315
+ }
316
+ return undefined
317
+ },
318
+ }
319
+ return { ctx, created }
320
+ }
321
+
322
+ // ------------------------------------------------------------ 工具面
323
+ const registered = []
324
+ tools.registerTools({
325
+ tools: { register(tool) { registered.push(tool); return () => {} } },
326
+ logger: { warn() {} },
327
+ })
328
+ const rememberTool = registered.find(tool => tool.name === 'memu_private_remember')
329
+ const recallTool = registered.find(tool => tool.name === 'memu_private_recall')
330
+ assert.ok(rememberTool && recallTool, 'the tool surface must register memu_private_remember / memu_private_recall')
331
+
332
+ let lastToolText = ''
333
+ async function writeViaTool(sessionId, cwd, { content, title }) {
334
+ lastToolText = await rememberTool.execute({ content, title }, execFor(sessionId, cwd))
335
+ return lastToolText
336
+ }
337
+
338
+ function markOtherSessionsGone(keepSessionId) {
339
+ for (const binding of state.listSessionBindings()) {
340
+ if (binding.sessionId !== keepSessionId) state.setSessionBinding(binding.sessionId, { sessionGone: true })
341
+ }
342
+ }
343
+
344
+ // ------------------------------------------------------------ 格子
345
+ async function runCell({ backend, trigger }) {
346
+ resetCell()
347
+ const seeded = seedLibrary({ content: null })
348
+ const { sessionId, cwd, libraryId } = seeded
349
+ setBackend(backend)
350
+ if (backend !== 'agent') {
351
+ activate([libraryId])
352
+ bridge.retrieveHook = backend === 'local' ? localVectorRetrieve(libraryId) : remoteVectorRetrieve(libraryId)
353
+ }
354
+ const exec = execFor(sessionId, cwd)
355
+ let forked = 0
356
+
357
+ if (trigger === 'manual') {
358
+ const host = makeHost({
359
+ sessionId, cwd,
360
+ onFork: async ({ childId }) => {
361
+ forked += 1
362
+ await writeViaTool(childId, cwd, { content: MEMORY_TEXT, title: MEMORY_TITLE })
363
+ },
364
+ })
365
+ const outcome = await dispatch(host.ctx, 'generatePrivate', { sessionId })
366
+ assert.equal(outcome.ok, true, 'manual consolidation refused: ' + JSON.stringify(outcome))
367
+ assert.equal(forked, 1, 'the manual trigger must fork exactly once')
368
+ } else if (trigger === 'tool') {
369
+ await writeViaTool(sessionId, cwd, { content: MEMORY_TEXT, title: MEMORY_TITLE })
370
+ } else {
371
+ state.updateSettings({ sleepConsolidation: true, sleepAfterIdleMinutes: 3 })
372
+ markOtherSessionsGone(sessionId)
373
+ const now = Date.now()
374
+ state.setSessionBinding(sessionId, { lastTurnAt: new Date(now - 2 * 60_000).toISOString() })
375
+ const host = makeHost({
376
+ sessionId, cwd,
377
+ onFork: async ({ childId }) => {
378
+ forked += 1
379
+ await writeViaTool(childId, cwd, { content: MEMORY_TEXT, title: MEMORY_TITLE })
380
+ },
381
+ })
382
+ const early = await cycle.runSleepPass(host.ctx, { now })
383
+ assert.equal(early.ran, 0, 'below the 3-minute idle threshold nothing may run')
384
+ state.setSessionBinding(sessionId, { lastTurnAt: new Date(now - 4 * 60_000).toISOString() })
385
+ const due = await cycle.runSleepPass(host.ctx, { now })
386
+ assert.equal(due.ran, 1, 'above the threshold the sweep must consolidate, got ' + JSON.stringify(due))
387
+ assert.equal(forked, 1, 'the idle trigger must fork exactly once')
388
+ }
389
+
390
+ // 1. 写入落在索引里
391
+ const index = state.loadPrivateIndex(libraryId)
392
+ const written = Object.entries(index.entries).find(([, entry]) => String(entry.content || '').includes('黄昏时分'))
393
+ assert.ok(written, 'no memory reached the library index; the tool said: ' + lastToolText)
394
+ const writtenId = written[0]
395
+
396
+ // 2. 语义查询:词法一定召不回,向量腿必须召回
397
+ const semantic = await memory.privateRecall({ exec, query: SEMANTIC_QUERY, topK: 8 })
398
+ const semanticIds = (semantic.files || []).map(file => file.name)
399
+ if (backend === 'agent') {
400
+ assert.deepEqual(semanticIds, [], 'the lexical backend must not invent a hit for a zero-overlap query')
401
+ assert.equal(semantic.recallRoute, 'local')
402
+ } else {
403
+ assert.ok(semanticIds.includes(writtenId), 'the vector leg did not return ' + writtenId + ' (route ' + semantic.recallRoute + ')')
404
+ assert.equal(semantic.recallRoute, 'managed-vector-rrf')
405
+ const retrieves = bridge.calls.filter(call => call.op === 'retrieve')
406
+ assert.ok(retrieves.length >= 1, 'the managed recall never reached the engine boundary')
407
+ assert.equal(retrieves[0].route, backend === 'local' ? 'managed-local' : 'remote-api')
408
+ if (backend === 'local') assert.ok(workerRequests > 0, 'the local route never called the embedding worker over HTTP')
409
+ else assert.equal(workerRequests, 0, 'the remote route must not touch the local worker')
410
+ }
411
+
412
+ // 3. 词法腿仍然工作:融合不能吞掉字面命中
413
+ const lexical = await memory.privateRecall({ exec, query: LEXICAL_QUERY, topK: 8 })
414
+ assert.ok((lexical.files || []).some(file => file.name === writtenId), 'the lexical leg lost a literal hit under ' + backend)
415
+
416
+ // 4. 注入:同一 id 出现在注入结果里
417
+ const injected = await inject.buildRecall(state.getSessionBinding(sessionId), SEMANTIC_QUERY, 1, state.getSettings(), exec, sessionId)
418
+ if (backend === 'agent') {
419
+ assert.deepEqual(injected.ids, [], 'the lexical backend must not inject a semantic-only hit')
420
+ } else {
421
+ assert.ok((injected.ids || []).includes(writtenId), 'injection dropped ' + writtenId + '; errors=' + JSON.stringify(injected.errors))
422
+ assert.ok(injected.text.includes(writtenId), 'the injected text does not name ' + writtenId)
423
+ }
424
+
425
+ // 5. 写入路径必须过引擎边界;受管本地必须在租约里 commit
426
+ const commits = bridge.calls.filter(call => call.op === 'commit')
427
+ if (backend === 'agent') {
428
+ assert.equal(commits.length, 0, 'the lexical backend must never call the engine on a write')
429
+ assert.equal(bridge.calls.filter(call => call.op === 'retrieve').length, 0, 'the lexical backend must never call the engine on a recall')
430
+ } else {
431
+ assert.ok(commits.length >= 1, 'the write never reached the engine boundary')
432
+ if (backend === 'local') {
433
+ assert.equal(commits[0].leased, true, 'a managed-local commit must happen under a write lease')
434
+ }
435
+ }
436
+
437
+ return {
438
+ detail: 'id=' + writtenId
439
+ + ' route=' + semantic.recallRoute
440
+ + ' workerCalls=' + workerRequests
441
+ + ' commits=' + commits.length
442
+ + ' inject=' + (injected.ids || []).length,
443
+ }
444
+ }
445
+
446
+ // ------------------------------------------------------------ 降级格子
447
+ async function runDegradeCell() {
448
+ resetCell()
449
+ const { sessionId, cwd, libraryId } = seedLibrary({})
450
+ setBackend('local')
451
+ activate([libraryId])
452
+ bridge.retrieveHook = localVectorRetrieve(libraryId)
453
+ const exec = execFor(sessionId, cwd)
454
+
455
+ await writeViaTool(sessionId, cwd, { content: MEMORY_TEXT, title: MEMORY_TITLE })
456
+ const index = state.loadPrivateIndex(libraryId)
457
+ const writtenId = Object.keys(index.entries)[0]
458
+ assert.ok(writtenId, 'the fixture memory was not written')
459
+
460
+ bridge.failRetrieve = true
461
+ const degraded = await memory.privateRecall({ exec, query: LEXICAL_QUERY, topK: 8 })
462
+ assert.equal(degraded.recallRoute, 'local-fallback', 'a dead worker must degrade to lexical, not fail the turn')
463
+ assert.ok((degraded.errors || []).some(message => /semantic recall failed/.test(message)), 'the degradation left no error on the result')
464
+ assert.ok((degraded.files || []).some(file => file.name === writtenId), 'the lexical fallback still has to answer')
465
+ assert.match(String(state.getSessionBinding(sessionId).lastRecallError || ''), /unreachable/i, 'lastRecallError must carry the real reason')
466
+
467
+ bridge.failRetrieve = false
468
+ const healthy = await memory.privateRecall({ exec, query: LEXICAL_QUERY, topK: 8 })
469
+ assert.equal(healthy.recallRoute, 'managed-vector-rrf')
470
+ const cleared = state.getSessionBinding(sessionId).lastRecallError
471
+ assert.ok(cleared === undefined || cleared === null, 'a healthy recall must clear lastRecallError, got ' + JSON.stringify(cleared))
472
+
473
+ return { detail: 'degraded route=' + degraded.recallRoute + ' recovered=' + healthy.recallRoute }
474
+ }
475
+
476
+ // ------------------------------------------------------------ 跑矩阵
477
+ const CELLS = [
478
+ { backend: 'agent', trigger: 'manual' },
479
+ { backend: 'agent', trigger: 'tool' },
480
+ { backend: 'agent', trigger: 'idle' },
481
+ { backend: 'local', trigger: 'manual' },
482
+ { backend: 'local', trigger: 'tool' },
483
+ { backend: 'local', trigger: 'idle' },
484
+ { backend: 'api', trigger: 'manual' },
485
+ { backend: 'api', trigger: 'tool' },
486
+ { backend: 'api', trigger: 'idle' },
487
+ ]
488
+
489
+ try {
490
+ for (const cell of CELLS) {
491
+ const id = cell.backend + '/' + cell.trigger
492
+ try {
493
+ const outcome = await runCell(cell)
494
+ results.push({ id, verdict: 'PASS', detail: outcome.detail })
495
+ } catch (error) {
496
+ results.push({ id, verdict: 'FAIL', detail: String(error?.message || error) })
497
+ }
498
+ }
499
+ try {
500
+ const outcome = await runDegradeCell()
501
+ results.push({ id: 'local/degrade', verdict: 'PASS', detail: outcome.detail })
502
+ } catch (error) {
503
+ results.push({ id: 'local/degrade', verdict: 'FAIL', detail: String(error?.message || error) })
504
+ }
505
+ } finally {
506
+ try { await worker.close() } catch {}
507
+ }
508
+
509
+ const failed = results.filter(entry => entry.verdict === 'FAIL')
510
+ console.log('')
511
+ console.log('===== chain matrix =====')
512
+ console.log('data root: ' + home)
513
+ console.log('')
514
+ for (const entry of results) {
515
+ console.log(' [' + entry.verdict + '] ' + entry.id.padEnd(16) + entry.detail)
516
+ }
517
+ if (VERBOSE || failed.length) {
518
+ console.log('')
519
+ console.log('--- engine boundary calls ---')
520
+ for (const call of bridge.calls) console.log(' ' + JSON.stringify(call))
521
+ }
522
+ console.log('')
523
+ console.log('matrix: ' + (results.length - failed.length) + '/' + results.length + ' chains walked, ' + failed.length + ' failed')
524
+ if (failed.length) process.exitCode = 1
525
+
526
+ try { hooks.deregister() } catch {}
527
+ try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
@@ -1,6 +1,7 @@
1
1
  import { execFileSync } from 'node:child_process'
2
2
  import fs from 'node:fs'
3
3
  import path from 'node:path'
4
+ import { runChecks as runDocDriftChecks } from './check-doc-drift.mjs'
4
5
 
5
6
  const root = path.resolve(import.meta.dirname, '..')
6
7
  const failures = []
@@ -35,6 +36,24 @@ budget('AGENTS.md', 60000)
35
36
  check('i18n', process.execPath, ['scripts/check-i18n.mjs'])
36
37
  check('package.json', process.execPath, ['-e', 'JSON.parse(require("fs").readFileSync("package.json","utf8"))'])
37
38
 
39
+ // W2 防漂移闸门:RPC 方法数标记、tests 跟踪状态、loader/stub 完整性。
40
+ // 这三项由 scripts/check-doc-drift.mjs 实现,返回 ok / skipped / fail 三态:
41
+ // skipped 只用于「没有 .git 的 npm 安装目录」这类明确不适用场景,不算失败。
42
+ for (const result of runDocDriftChecks(root)) {
43
+ if (result.status === 'ok') {
44
+ console.log(`ok ${result.label}: ${result.summary}`)
45
+ continue
46
+ }
47
+ if (result.status === 'skipped') {
48
+ console.log(`skip ${result.label}: ${result.summary}`)
49
+ for (const detail of result.details || []) console.log(` ${detail}`)
50
+ continue
51
+ }
52
+ failures.push(result.label)
53
+ console.log(`FAIL ${result.label}: ${result.summary}`)
54
+ for (const detail of result.details || []) console.log(` ${detail}`)
55
+ }
56
+
38
57
  if (failures.length > 0) {
39
58
  console.log(`\n${failures.length} check(s) failed: ${failures.join(', ')}`)
40
59
  process.exit(1)