dsh-memu-memory 1.1.2 → 1.2.0

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 (199) hide show
  1. package/AGENTS.md +8 -6
  2. package/README.md +50 -5
  3. package/README.zh.md +49 -5
  4. package/docs/active-memory-ab.md +385 -0
  5. package/docs/binding-lifecycle.md +99 -0
  6. package/docs/contributing.md +6 -4
  7. package/docs/emotion-model-spec.md +281 -0
  8. package/docs/env-and-commands.md +13 -0
  9. package/docs/host-api.md +35 -3
  10. package/docs/memory-semantics.md +40 -11
  11. package/docs/recall-benchmark.md +238 -0
  12. package/docs/troubleshooting.md +395 -366
  13. package/lib/active-memory.js +165 -0
  14. package/lib/activity-log.js +190 -0
  15. package/lib/backup.js +1103 -0
  16. package/lib/binding-diagnostics.js +635 -0
  17. package/lib/client.js +728 -58
  18. package/lib/consolidation-ledger.js +347 -0
  19. package/lib/consolidation-receipt.js +284 -0
  20. package/lib/consolidation-snapshot.js +274 -0
  21. package/lib/consolidation-worker.js +115 -0
  22. package/lib/curator.js +81 -37
  23. package/lib/cycle.js +43 -6
  24. package/lib/embedding-catalog.js +763 -0
  25. package/lib/embedding-download.js +407 -0
  26. package/lib/embedding-runtime.js +710 -0
  27. package/lib/engine.js +2 -2
  28. package/lib/index.js +36 -0
  29. package/lib/inject.js +232 -103
  30. package/lib/local-embedding-entry.js +145 -0
  31. package/lib/local-embedding-manager.js +267 -0
  32. package/lib/local-embedding-protocol.js +173 -0
  33. package/lib/local-embedding-worker.js +652 -0
  34. package/lib/local-vector-service.js +283 -0
  35. package/lib/memory.js +599 -57
  36. package/lib/memu-config.js +10 -22
  37. package/lib/memu.js +53 -19
  38. package/lib/mood.js +268 -39
  39. package/lib/paths.js +24 -0
  40. package/lib/preset-scope.js +1 -1
  41. package/lib/private.js +450 -29
  42. package/lib/prompts/private-consolidation.coding.md +1 -0
  43. package/lib/prompts/private-consolidation.md +1 -0
  44. package/lib/prompts/private-consolidation.mixed.md +1 -0
  45. package/lib/prompts/private-consolidation.rp.md +1 -0
  46. package/lib/prompts.js +4 -4
  47. package/lib/remote-summary.js +51 -25
  48. package/lib/rpc.js +54 -5
  49. package/lib/state.js +137 -172
  50. package/lib/tools.js +58 -7
  51. package/lib/vector-migration.js +878 -0
  52. package/lib/vector-profile.js +968 -0
  53. package/package.json +4 -2
  54. package/scripts/acceptance-local.mjs +7 -5
  55. package/scripts/active-memory-ab-live.mjs +655 -0
  56. package/scripts/active-memory-ab.mjs +1302 -0
  57. package/scripts/chain-matrix.mjs +537 -0
  58. package/scripts/check-all.mjs +29 -1
  59. package/scripts/check-doc-drift.mjs +353 -0
  60. package/scripts/diagnose-bindings.mjs +176 -0
  61. package/scripts/local-runtime-install.mjs +142 -0
  62. package/scripts/local-runtime-verify.mjs +56 -0
  63. package/scripts/local-vector-verify.mjs +93 -0
  64. package/scripts/recall-bench.mjs +813 -0
  65. package/scripts/repetition-ab-scenarios.mjs +93 -0
  66. package/scripts/repetition-detector-snapshot.mjs +314 -0
  67. package/scripts/repetition-eval-live.mjs +360 -0
  68. package/scripts/repetition-eval.mjs +702 -0
  69. package/tests/activation.test.mjs +89 -0
  70. package/tests/active-memory-ab.test.mjs +745 -0
  71. package/tests/active-memory.test.mjs +129 -0
  72. package/tests/activity-log.test.mjs +126 -0
  73. package/tests/activity-wiring.test.mjs +103 -0
  74. package/tests/appendix-b.test.mjs +212 -0
  75. package/tests/appendix-b2.test.mjs +166 -0
  76. package/tests/audit-regression.test.mjs +272 -0
  77. package/tests/audit-safety.test.mjs +227 -0
  78. package/tests/backend-compensation.test.mjs +272 -0
  79. package/tests/backup.test.mjs +793 -0
  80. package/tests/binding-diagnostics.test.mjs +720 -0
  81. package/tests/browser.test.mjs +366 -0
  82. package/tests/client-render.test.mjs +892 -0
  83. package/tests/coalesce.test.mjs +113 -0
  84. package/tests/config.test.mjs +159 -0
  85. package/tests/consolidation-credential.test.mjs +320 -0
  86. package/tests/consolidation-fork.test.mjs +357 -0
  87. package/tests/consolidation-ledger.test.mjs +298 -0
  88. package/tests/consolidation-snapshot.test.mjs +321 -0
  89. package/tests/consolidation-value.test.mjs +89 -0
  90. package/tests/context-scope.test.mjs +40 -0
  91. package/tests/cue.test.mjs +44 -0
  92. package/tests/curator-probe.test.mjs +433 -0
  93. package/tests/curator.test.mjs +150 -0
  94. package/tests/cycle.test.mjs +411 -0
  95. package/tests/disclosure.test.mjs +231 -0
  96. package/tests/doc-drift.test.mjs +222 -0
  97. package/tests/embedding-catalog.test.mjs +266 -0
  98. package/tests/embedding-download.test.mjs +391 -0
  99. package/tests/embedding-runtime.test.mjs +389 -0
  100. package/tests/emotion-chain.test.mjs +84 -0
  101. package/tests/emotion-contract.test.mjs +445 -0
  102. package/tests/engine.test.mjs +35 -0
  103. package/tests/fixtures/active-memory-ab/README.md +56 -0
  104. package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
  105. package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
  106. package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
  107. package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
  108. package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
  109. package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
  110. package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
  111. package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
  112. package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
  113. package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
  114. package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
  115. package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
  116. package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
  117. package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
  118. package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
  119. package/tests/fixtures/active-memory-ab/manifest.json +199 -0
  120. package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
  121. package/tests/fixtures/recall-bench/02-exact.json +153 -0
  122. package/tests/fixtures/recall-bench/03-negation.json +173 -0
  123. package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
  124. package/tests/fixtures/recall-bench/05-conflict.json +194 -0
  125. package/tests/fixtures/recall-bench/06-boundary.json +273 -0
  126. package/tests/fixtures/recall-bench/07-secret.json +191 -0
  127. package/tests/fixtures/recall-bench/08-coding.json +164 -0
  128. package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
  129. package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
  130. package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
  131. package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
  132. package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
  133. package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
  134. package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
  135. package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
  136. package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
  137. package/tests/fixtures/repetition-eval/02-en.json +97 -0
  138. package/tests/fixtures/repetition-eval/03-code.json +91 -0
  139. package/tests/fixtures/repetition-eval/04-names.json +116 -0
  140. package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
  141. package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
  142. package/tests/graph-view.test.mjs +187 -0
  143. package/tests/graph.test.mjs +77 -0
  144. package/tests/hook-order.test.mjs +72 -0
  145. package/tests/humanlike.test.mjs +232 -0
  146. package/tests/hybrid-recall.test.mjs +92 -0
  147. package/tests/inbound.test.mjs +393 -0
  148. package/tests/inject-guide.test.mjs +364 -0
  149. package/tests/inject.test.mjs +222 -0
  150. package/tests/injection-budget-settings.test.mjs +35 -0
  151. package/tests/injection-budget.test.mjs +64 -0
  152. package/tests/injection-chain.test.mjs +191 -0
  153. package/tests/loader-register.mjs +3 -0
  154. package/tests/loader.mjs +12 -0
  155. package/tests/local-embedding-entry.test.mjs +95 -0
  156. package/tests/local-embedding-manager.test.mjs +476 -0
  157. package/tests/local-embedding-protocol.test.mjs +95 -0
  158. package/tests/local-embedding-worker.test.mjs +346 -0
  159. package/tests/managed-memory.test.mjs +536 -0
  160. package/tests/managed-runtime.test.mjs +40 -0
  161. package/tests/media.test.mjs +132 -0
  162. package/tests/migration-payload.test.mjs +42 -0
  163. package/tests/mood.test.mjs +109 -0
  164. package/tests/multiprocess.test.mjs +29 -0
  165. package/tests/new-session-gate.test.mjs +361 -0
  166. package/tests/package-shipping.test.mjs +84 -0
  167. package/tests/preset-scope.test.mjs +212 -0
  168. package/tests/prompts.test.mjs +250 -0
  169. package/tests/react-snapshot.test.mjs +23 -0
  170. package/tests/recall-bench.test.mjs +306 -0
  171. package/tests/remote.test.mjs +140 -0
  172. package/tests/rename.test.mjs +30 -0
  173. package/tests/repetition-detector-snapshot.test.mjs +231 -0
  174. package/tests/repetition-eval-live.test.mjs +177 -0
  175. package/tests/repetition-eval.test.mjs +373 -0
  176. package/tests/salient-date.test.mjs +142 -0
  177. package/tests/settings-beat.test.mjs +221 -0
  178. package/tests/short-term.test.mjs +166 -0
  179. package/tests/sidebar-beat.test.mjs +208 -0
  180. package/tests/sidebar-refresh.test.mjs +123 -0
  181. package/tests/sidebar.test.mjs +70 -0
  182. package/tests/snippet.test.mjs +47 -0
  183. package/tests/source-kind.test.mjs +52 -0
  184. package/tests/state.test.mjs +150 -0
  185. package/tests/stats.test.mjs +112 -0
  186. package/tests/storage.test.mjs +125 -0
  187. package/tests/stubs/dsh-llm.js +5 -0
  188. package/tests/stubs/dsh-session.js +7 -0
  189. package/tests/stubs/dsh-tools.js +101 -0
  190. package/tests/tool-schema.test.mjs +73 -0
  191. package/tests/tools.test.mjs +184 -0
  192. package/tests/ui-defaults.test.mjs +57 -0
  193. package/tests/update-fields.test.mjs +138 -0
  194. package/tests/vector-migration.test.mjs +733 -0
  195. package/tests/vector-profile.test.mjs +425 -0
  196. package/tests/visibility-migration.test.mjs +152 -0
  197. package/tests/webui-auth.test.mjs +454 -0
  198. package/tests/webui.test.mjs +164 -0
  199. package/tests/windows-env.test.mjs +89 -0
@@ -0,0 +1,537 @@
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
+ // 加权唤醒要求真实的检查理由:多回合、有结论、有去重后的工具工作。
266
+ // 只有一条消息 + 一个工具调用的旧 fixture 现在会(正确地)落进 low-score。
267
+ const TRANSCRIPT = [
268
+ { seq: 0, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: '帮我把这段对话里值得记的东西整理成记忆,尤其是海边的那个傍晚。' }] } },
269
+ { seq: 1, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: '另外把回滚步骤和失败原因也写清楚。' }] } },
270
+ { seq: 2, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: '最后给我一版可以直接照做的清单。' }] } },
271
+ { seq: 3, type: 'assistant/message', data: { message: { content: [
272
+ { type: 'text', text: '海边的傍晚那一段是这次对话里最值得留下的内容。'.repeat(120) },
273
+ { type: 'tool-call', name: 'read', arguments: '{}' },
274
+ ] } } },
275
+ { seq: 4, type: 'tool/result', data: { message: { isError: false, content: [{ type: 'text', text: '回滚步骤与失败明细'.repeat(160) }] } } },
276
+ { seq: 5, type: 'tool/result', data: { message: { isError: false, content: [{ type: 'text', text: '迁移账本与索引差异'.repeat(160) }] } } },
277
+ { seq: 6, type: 'tool/result', data: { message: { isError: false, content: [{ type: 'text', text: '验收清单与测试输出'.repeat(160) }] } } },
278
+ ]
279
+
280
+ function makeHost({ sessionId, cwd, onFork }) {
281
+ const handlers = []
282
+ const created = []
283
+ let pending = Promise.resolve()
284
+ const ctx = {
285
+ on(name, handler) {
286
+ if (name !== 'session/event') return () => {}
287
+ handlers.push(handler)
288
+ return () => { const at = handlers.indexOf(handler); if (at >= 0) handlers.splice(at, 1) }
289
+ },
290
+ get(name) {
291
+ if (name === 'sessionQuery') {
292
+ return {
293
+ readSession: async (id) => id === sessionId
294
+ ? { events: TRANSCRIPT, session: { id, cwd, agentPreset: 'coding' } }
295
+ : { events: [] },
296
+ }
297
+ }
298
+ if (name === 'agents') {
299
+ return {
300
+ get: (id) => id === sessionId
301
+ ? { status: 'idle', options: { provider: 'chain-matrix', model: 'fixture' } }
302
+ : undefined,
303
+ create: async (spec) => {
304
+ const childSession = { id: spec.sessionId, header: { id: spec.sessionId, cwd, agentPreset: 'coding' } }
305
+ created.push({ id: spec.sessionId, meta: spec.meta, seed: spec.seed })
306
+ return {
307
+ agent: {
308
+ session: childSession,
309
+ followup(message) {
310
+ pending = (async () => {
311
+ try { await onFork({ childId: spec.sessionId, message }) }
312
+ finally {
313
+ for (const handler of [...handlers]) {
314
+ handler(childSession, { type: 'turn/end', seq: 999, data: { turn: 1, reason: { kind: 'completed' } } })
315
+ }
316
+ }
317
+ })()
318
+ },
319
+ whenIdle: () => pending,
320
+ },
321
+ dispose: async () => {},
322
+ }
323
+ },
324
+ }
325
+ }
326
+ return undefined
327
+ },
328
+ }
329
+ return { ctx, created }
330
+ }
331
+
332
+ // ------------------------------------------------------------ 工具面
333
+ const registered = []
334
+ tools.registerTools({
335
+ tools: { register(tool) { registered.push(tool); return () => {} } },
336
+ logger: { warn() {} },
337
+ })
338
+ const rememberTool = registered.find(tool => tool.name === 'memu_private_remember')
339
+ const recallTool = registered.find(tool => tool.name === 'memu_private_recall')
340
+ assert.ok(rememberTool && recallTool, 'the tool surface must register memu_private_remember / memu_private_recall')
341
+
342
+ let lastToolText = ''
343
+ async function writeViaTool(sessionId, cwd, { content, title }) {
344
+ lastToolText = await rememberTool.execute({ content, title }, execFor(sessionId, cwd))
345
+ return lastToolText
346
+ }
347
+
348
+ function markOtherSessionsGone(keepSessionId) {
349
+ for (const binding of state.listSessionBindings()) {
350
+ if (binding.sessionId !== keepSessionId) state.setSessionBinding(binding.sessionId, { sessionGone: true })
351
+ }
352
+ }
353
+
354
+ // ------------------------------------------------------------ 格子
355
+ async function runCell({ backend, trigger }) {
356
+ resetCell()
357
+ const seeded = seedLibrary({ content: null })
358
+ const { sessionId, cwd, libraryId } = seeded
359
+ setBackend(backend)
360
+ if (backend !== 'agent') {
361
+ activate([libraryId])
362
+ bridge.retrieveHook = backend === 'local' ? localVectorRetrieve(libraryId) : remoteVectorRetrieve(libraryId)
363
+ }
364
+ const exec = execFor(sessionId, cwd)
365
+ let forked = 0
366
+
367
+ if (trigger === 'manual') {
368
+ const host = makeHost({
369
+ sessionId, cwd,
370
+ onFork: async ({ childId }) => {
371
+ forked += 1
372
+ await writeViaTool(childId, cwd, { content: MEMORY_TEXT, title: MEMORY_TITLE })
373
+ },
374
+ })
375
+ const outcome = await dispatch(host.ctx, 'generatePrivate', { sessionId })
376
+ assert.equal(outcome.ok, true, 'manual consolidation refused: ' + JSON.stringify(outcome))
377
+ assert.equal(forked, 1, 'the manual trigger must fork exactly once')
378
+ } else if (trigger === 'tool') {
379
+ await writeViaTool(sessionId, cwd, { content: MEMORY_TEXT, title: MEMORY_TITLE })
380
+ } else {
381
+ state.updateSettings({ sleepConsolidation: true, sleepAfterIdleMinutes: 3 })
382
+ markOtherSessionsGone(sessionId)
383
+ const now = Date.now()
384
+ state.setSessionBinding(sessionId, { lastTurnAt: new Date(now - 2 * 60_000).toISOString() })
385
+ const host = makeHost({
386
+ sessionId, cwd,
387
+ onFork: async ({ childId }) => {
388
+ forked += 1
389
+ await writeViaTool(childId, cwd, { content: MEMORY_TEXT, title: MEMORY_TITLE })
390
+ },
391
+ })
392
+ const early = await cycle.runSleepPass(host.ctx, { now })
393
+ assert.equal(early.ran, 0, 'below the 3-minute idle threshold nothing may run')
394
+ state.setSessionBinding(sessionId, { lastTurnAt: new Date(now - 4 * 60_000).toISOString() })
395
+ const due = await cycle.runSleepPass(host.ctx, { now })
396
+ assert.equal(due.ran, 1, 'above the threshold the sweep must consolidate, got ' + JSON.stringify(due))
397
+ assert.equal(forked, 1, 'the idle trigger must fork exactly once')
398
+ }
399
+
400
+ // 1. 写入落在索引里
401
+ const index = state.loadPrivateIndex(libraryId)
402
+ const written = Object.entries(index.entries).find(([, entry]) => String(entry.content || '').includes('黄昏时分'))
403
+ assert.ok(written, 'no memory reached the library index; the tool said: ' + lastToolText)
404
+ const writtenId = written[0]
405
+
406
+ // 2. 语义查询:词法一定召不回,向量腿必须召回
407
+ const semantic = await memory.privateRecall({ exec, query: SEMANTIC_QUERY, topK: 8 })
408
+ const semanticIds = (semantic.files || []).map(file => file.name)
409
+ if (backend === 'agent') {
410
+ assert.deepEqual(semanticIds, [], 'the lexical backend must not invent a hit for a zero-overlap query')
411
+ assert.equal(semantic.recallRoute, 'local')
412
+ } else {
413
+ assert.ok(semanticIds.includes(writtenId), 'the vector leg did not return ' + writtenId + ' (route ' + semantic.recallRoute + ')')
414
+ assert.equal(semantic.recallRoute, 'managed-vector-rrf')
415
+ const retrieves = bridge.calls.filter(call => call.op === 'retrieve')
416
+ assert.ok(retrieves.length >= 1, 'the managed recall never reached the engine boundary')
417
+ assert.equal(retrieves[0].route, backend === 'local' ? 'managed-local' : 'remote-api')
418
+ if (backend === 'local') assert.ok(workerRequests > 0, 'the local route never called the embedding worker over HTTP')
419
+ else assert.equal(workerRequests, 0, 'the remote route must not touch the local worker')
420
+ }
421
+
422
+ // 3. 词法腿仍然工作:融合不能吞掉字面命中
423
+ const lexical = await memory.privateRecall({ exec, query: LEXICAL_QUERY, topK: 8 })
424
+ assert.ok((lexical.files || []).some(file => file.name === writtenId), 'the lexical leg lost a literal hit under ' + backend)
425
+
426
+ // 4. 注入:同一 id 出现在注入结果里
427
+ const injected = await inject.buildRecall(state.getSessionBinding(sessionId), SEMANTIC_QUERY, 1, state.getSettings(), exec, sessionId)
428
+ if (backend === 'agent') {
429
+ assert.deepEqual(injected.ids, [], 'the lexical backend must not inject a semantic-only hit')
430
+ } else {
431
+ assert.ok((injected.ids || []).includes(writtenId), 'injection dropped ' + writtenId + '; errors=' + JSON.stringify(injected.errors))
432
+ assert.ok(injected.text.includes(writtenId), 'the injected text does not name ' + writtenId)
433
+ }
434
+
435
+ // 5. 写入路径必须过引擎边界;受管本地必须在租约里 commit
436
+ const commits = bridge.calls.filter(call => call.op === 'commit')
437
+ if (backend === 'agent') {
438
+ assert.equal(commits.length, 0, 'the lexical backend must never call the engine on a write')
439
+ assert.equal(bridge.calls.filter(call => call.op === 'retrieve').length, 0, 'the lexical backend must never call the engine on a recall')
440
+ } else {
441
+ assert.ok(commits.length >= 1, 'the write never reached the engine boundary')
442
+ if (backend === 'local') {
443
+ assert.equal(commits[0].leased, true, 'a managed-local commit must happen under a write lease')
444
+ }
445
+ }
446
+
447
+ return {
448
+ detail: 'id=' + writtenId
449
+ + ' route=' + semantic.recallRoute
450
+ + ' workerCalls=' + workerRequests
451
+ + ' commits=' + commits.length
452
+ + ' inject=' + (injected.ids || []).length,
453
+ }
454
+ }
455
+
456
+ // ------------------------------------------------------------ 降级格子
457
+ async function runDegradeCell() {
458
+ resetCell()
459
+ const { sessionId, cwd, libraryId } = seedLibrary({})
460
+ setBackend('local')
461
+ activate([libraryId])
462
+ bridge.retrieveHook = localVectorRetrieve(libraryId)
463
+ const exec = execFor(sessionId, cwd)
464
+
465
+ await writeViaTool(sessionId, cwd, { content: MEMORY_TEXT, title: MEMORY_TITLE })
466
+ const index = state.loadPrivateIndex(libraryId)
467
+ const writtenId = Object.keys(index.entries)[0]
468
+ assert.ok(writtenId, 'the fixture memory was not written')
469
+
470
+ bridge.failRetrieve = true
471
+ const degraded = await memory.privateRecall({ exec, query: LEXICAL_QUERY, topK: 8 })
472
+ assert.equal(degraded.recallRoute, 'local-fallback', 'a dead worker must degrade to lexical, not fail the turn')
473
+ assert.ok((degraded.errors || []).some(message => /semantic recall failed/.test(message)), 'the degradation left no error on the result')
474
+ assert.ok((degraded.files || []).some(file => file.name === writtenId), 'the lexical fallback still has to answer')
475
+ assert.match(String(state.getSessionBinding(sessionId).lastRecallError || ''), /unreachable/i, 'lastRecallError must carry the real reason')
476
+
477
+ bridge.failRetrieve = false
478
+ const healthy = await memory.privateRecall({ exec, query: LEXICAL_QUERY, topK: 8 })
479
+ assert.equal(healthy.recallRoute, 'managed-vector-rrf')
480
+ const cleared = state.getSessionBinding(sessionId).lastRecallError
481
+ assert.ok(cleared === undefined || cleared === null, 'a healthy recall must clear lastRecallError, got ' + JSON.stringify(cleared))
482
+
483
+ return { detail: 'degraded route=' + degraded.recallRoute + ' recovered=' + healthy.recallRoute }
484
+ }
485
+
486
+ // ------------------------------------------------------------ 跑矩阵
487
+ const CELLS = [
488
+ { backend: 'agent', trigger: 'manual' },
489
+ { backend: 'agent', trigger: 'tool' },
490
+ { backend: 'agent', trigger: 'idle' },
491
+ { backend: 'local', trigger: 'manual' },
492
+ { backend: 'local', trigger: 'tool' },
493
+ { backend: 'local', trigger: 'idle' },
494
+ { backend: 'api', trigger: 'manual' },
495
+ { backend: 'api', trigger: 'tool' },
496
+ { backend: 'api', trigger: 'idle' },
497
+ ]
498
+
499
+ try {
500
+ for (const cell of CELLS) {
501
+ const id = cell.backend + '/' + cell.trigger
502
+ try {
503
+ const outcome = await runCell(cell)
504
+ results.push({ id, verdict: 'PASS', detail: outcome.detail })
505
+ } catch (error) {
506
+ results.push({ id, verdict: 'FAIL', detail: String(error?.message || error) })
507
+ }
508
+ }
509
+ try {
510
+ const outcome = await runDegradeCell()
511
+ results.push({ id: 'local/degrade', verdict: 'PASS', detail: outcome.detail })
512
+ } catch (error) {
513
+ results.push({ id: 'local/degrade', verdict: 'FAIL', detail: String(error?.message || error) })
514
+ }
515
+ } finally {
516
+ try { await worker.close() } catch {}
517
+ }
518
+
519
+ const failed = results.filter(entry => entry.verdict === 'FAIL')
520
+ console.log('')
521
+ console.log('===== chain matrix =====')
522
+ console.log('data root: ' + home)
523
+ console.log('')
524
+ for (const entry of results) {
525
+ console.log(' [' + entry.verdict + '] ' + entry.id.padEnd(16) + entry.detail)
526
+ }
527
+ if (VERBOSE || failed.length) {
528
+ console.log('')
529
+ console.log('--- engine boundary calls ---')
530
+ for (const call of bridge.calls) console.log(' ' + JSON.stringify(call))
531
+ }
532
+ console.log('')
533
+ console.log('matrix: ' + (results.length - failed.length) + '/' + results.length + ' chains walked, ' + failed.length + ' failed')
534
+ if (failed.length) process.exitCode = 1
535
+
536
+ try { hooks.deregister() } catch {}
537
+ 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 = []
@@ -17,7 +18,16 @@ const check = (label, command, args) => {
17
18
  }
18
19
 
19
20
  for (const dir of ['lib', 'scripts', 'tests']) {
20
- for (const file of fs.readdirSync(path.join(root, dir), { recursive: true })) {
21
+ const abs = path.join(root, dir)
22
+ // 一个目录可以合法缺席:老版本 npm 包不发布 tests/,开发检出里才有。
23
+ // 以前这里直接 readdirSync,产物内 `npm run check` 会 ENOENT 崩在第一步,
24
+ // 而 `npm test` 因为通配符匹配不到文件反而报 0 条测试、exit 0(假绿)。
25
+ // 缺席就 skip 并说明,不当作失败,也不让脚本崩。
26
+ if (!fs.existsSync(abs)) {
27
+ console.log(`skip ${dir}/: directory not present in this install`)
28
+ continue
29
+ }
30
+ for (const file of fs.readdirSync(abs, { recursive: true })) {
21
31
  if (!/\.(js|mjs)$/.test(file)) continue
22
32
  check(`${dir}/${file}`, process.execPath, ['--check', path.join(dir, file)])
23
33
  }
@@ -35,6 +45,24 @@ budget('AGENTS.md', 60000)
35
45
  check('i18n', process.execPath, ['scripts/check-i18n.mjs'])
36
46
  check('package.json', process.execPath, ['-e', 'JSON.parse(require("fs").readFileSync("package.json","utf8"))'])
37
47
 
48
+ // W2 防漂移闸门:RPC 方法数标记、tests 跟踪状态、loader/stub 完整性。
49
+ // 这三项由 scripts/check-doc-drift.mjs 实现,返回 ok / skipped / fail 三态:
50
+ // skipped 只用于「没有 .git 的 npm 安装目录」这类明确不适用场景,不算失败。
51
+ for (const result of runDocDriftChecks(root)) {
52
+ if (result.status === 'ok') {
53
+ console.log(`ok ${result.label}: ${result.summary}`)
54
+ continue
55
+ }
56
+ if (result.status === 'skipped') {
57
+ console.log(`skip ${result.label}: ${result.summary}`)
58
+ for (const detail of result.details || []) console.log(` ${detail}`)
59
+ continue
60
+ }
61
+ failures.push(result.label)
62
+ console.log(`FAIL ${result.label}: ${result.summary}`)
63
+ for (const detail of result.details || []) console.log(` ${detail}`)
64
+ }
65
+
38
66
  if (failures.length > 0) {
39
67
  console.log(`\n${failures.length} check(s) failed: ${failures.join(', ')}`)
40
68
  process.exit(1)