dsh-memu-memory 1.2.0 → 1.2.2

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 (100) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +19 -8
  3. package/README.zh.md +12 -6
  4. package/docs/contributing.md +16 -3
  5. package/docs/env-and-commands.md +8 -0
  6. package/docs/host-api.md +3 -1
  7. package/docs/memory-semantics.md +21 -2
  8. package/docs/troubleshooting.md +42 -3
  9. package/lib/activity-log.js +22 -5
  10. package/lib/backup.js +24 -2
  11. package/lib/binding-diagnostics.js +3 -3
  12. package/lib/client.js +1610 -117
  13. package/lib/consolidation-worker.js +68 -17
  14. package/lib/curator-evidence.js +131 -0
  15. package/lib/curator-policy.js +81 -0
  16. package/lib/curator.js +1299 -83
  17. package/lib/cycle.js +3 -0
  18. package/lib/embedding-download.js +39 -4
  19. package/lib/engine.js +6 -3
  20. package/lib/global-graph.js +282 -0
  21. package/lib/graph.js +190 -14
  22. package/lib/inbound.js +19 -0
  23. package/lib/index.js +33 -1
  24. package/lib/inject.js +277 -17
  25. package/lib/lexicon.js +1115 -0
  26. package/lib/local-vector-service.js +8 -6
  27. package/lib/managed-runtime.js +33 -2
  28. package/lib/memory.js +585 -76
  29. package/lib/memu.js +2 -2
  30. package/lib/migrate.js +83 -2
  31. package/lib/private-links.js +587 -0
  32. package/lib/private.js +128 -7
  33. package/lib/prompts/global-curator.md +76 -19
  34. package/lib/prompts/private-consolidation.coding.md +4 -1
  35. package/lib/prompts/private-consolidation.md +3 -1
  36. package/lib/prompts/private-consolidation.mixed.md +3 -1
  37. package/lib/prompts/private-consolidation.rp.md +3 -1
  38. package/lib/prompts.js +1 -1
  39. package/lib/rpc.js +622 -13
  40. package/lib/state.js +4 -1
  41. package/lib/text-retrieval.js +661 -0
  42. package/lib/text-similarity.js +124 -0
  43. package/lib/tools.js +230 -16
  44. package/lib/vector-migration.js +28 -3
  45. package/lib/vector-profile.js +24 -0
  46. package/lib/webui/standalone.css +17 -0
  47. package/lib/webui/standalone.js +2 -0
  48. package/package.json +3 -2
  49. package/scripts/acceptance-local.mjs +8 -5
  50. package/scripts/curator-pair-calibrate.mjs +259 -0
  51. package/scripts/lexicon-calibrate.mjs +230 -0
  52. package/scripts/lexicon-dedupe.mjs +318 -0
  53. package/scripts/replay.mjs +573 -0
  54. package/tests/absorb-review.test.mjs +353 -0
  55. package/tests/appendix-b.test.mjs +6 -2
  56. package/tests/appendix-b2.test.mjs +2 -2
  57. package/tests/client-export.test.mjs +520 -0
  58. package/tests/client-render.test.mjs +19 -3
  59. package/tests/consolidation-credential.test.mjs +16 -5
  60. package/tests/consolidation-feed.test.mjs +96 -0
  61. package/tests/consolidation-fork.test.mjs +144 -10
  62. package/tests/consolidation-snapshot.test.mjs +82 -4
  63. package/tests/curator-absorb.test.mjs +443 -0
  64. package/tests/curator-calibrate.test.mjs +173 -0
  65. package/tests/curator-clusters.test.mjs +281 -0
  66. package/tests/curator-commit.test.mjs +324 -0
  67. package/tests/curator-evidence.test.mjs +102 -0
  68. package/tests/curator-link.test.mjs +316 -0
  69. package/tests/curator-pairing.test.mjs +252 -0
  70. package/tests/curator-probe.test.mjs +15 -6
  71. package/tests/curator-review-mode.test.mjs +314 -0
  72. package/tests/disclosure.test.mjs +2 -1
  73. package/tests/fixtures/curator-pairs/labeled.v1.jsonl +90 -0
  74. package/tests/fixtures/lexicon-stub-hooks.mjs +15 -0
  75. package/tests/fixtures/lexicon-stub.mjs +284 -0
  76. package/tests/graph-cascade.test.mjs +223 -0
  77. package/tests/graph.test.mjs +113 -0
  78. package/tests/help-page.test.mjs +415 -0
  79. package/tests/hybrid-recall.test.mjs +32 -2
  80. package/tests/inbound.test.mjs +6 -3
  81. package/tests/inject-guide.test.mjs +28 -0
  82. package/tests/inject.test.mjs +157 -5
  83. package/tests/lexicon-calibrate.test.mjs +48 -0
  84. package/tests/lexicon-dedupe.test.mjs +171 -0
  85. package/tests/lexicon-frequency.test.mjs +179 -0
  86. package/tests/lexicon-inject.test.mjs +410 -0
  87. package/tests/lexicon-perf.test.mjs +436 -0
  88. package/tests/lexicon-ui.test.mjs +547 -0
  89. package/tests/lexicon.test.mjs +922 -0
  90. package/tests/managed-memory.test.mjs +55 -0
  91. package/tests/managed-runtime.test.mjs +32 -0
  92. package/tests/private-links.test.mjs +445 -0
  93. package/tests/prompts.test.mjs +35 -0
  94. package/tests/recall-dedup.test.mjs +446 -0
  95. package/tests/sidebar-beat.test.mjs +5 -1
  96. package/tests/tool-schema.test.mjs +1 -1
  97. package/tests/tools.test.mjs +112 -3
  98. package/tests/vector-migration.test.mjs +58 -1
  99. package/tests/webui-auth.test.mjs +348 -2
  100. package/tests/webui.test.mjs +191 -2
@@ -0,0 +1,415 @@
1
+ // 帮助页与首次安装引导(施工稿 §6.5 包 E)+ 实体孤立分组的渲染断言(§6.4 包 D 第 5 条)。
2
+ // 沿用 client-render.test.mjs 的最小 React 运行时:一个在渲染期 throw 的页面块就是
3
+ // 白屏,必须在构建期失败而不是静默白板。每一页都渲染,因为块只在选中该页时挂载。
4
+ import test from 'node:test'
5
+ import assert from 'node:assert/strict'
6
+ import fs from 'node:fs'
7
+
8
+ const clientSource = fs.readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
9
+
10
+ function makeElement(type, props, ...children) {
11
+ return { type, props: { ...(props || {}), children: children.length <= 1 ? children[0] : children } }
12
+ }
13
+
14
+ let hooks = []
15
+ let cursor = 0
16
+ let effects = []
17
+ let forcedList = []
18
+ let generation = 0
19
+
20
+ const React = {
21
+ createElement: makeElement,
22
+ Fragment: Symbol('Fragment'),
23
+ useState(initial) {
24
+ const index = cursor++
25
+ const generation_ = generation
26
+ if (hooks[index] === undefined) hooks[index] = typeof initial === 'function' ? initial() : initial
27
+ const forced = forcedList.find((entry) => entry.index === index)
28
+ if (forced) hooks[index] = forced.value
29
+ return [hooks[index], (next) => {
30
+ if (generation_ !== generation) return
31
+ hooks[index] = typeof next === 'function' ? next(hooks[index]) : next
32
+ }]
33
+ },
34
+ useCallback: (fn) => fn,
35
+ useMemo: (fn) => fn(),
36
+ useEffect(fn, deps) { effects.push({ fn, deps, index: cursor++ }) },
37
+ useLayoutEffect() {},
38
+ useRef: (value) => ({ current: value }),
39
+ useSyncExternalStore: (_subscribe, getSnapshot) => getSnapshot(),
40
+ }
41
+
42
+ globalThis.window = {
43
+ location: { hash: '', href: 'http://localhost/', search: '' },
44
+ history: { replaceState() {} },
45
+ __ModuleLoader__: {
46
+ load({ factory }) {
47
+ globalThis.__dshMemuHelpExports = factory((name) => {
48
+ if (name === 'react') return React
49
+ throw new Error(`unexpected require: ${name}`)
50
+ })
51
+ },
52
+ },
53
+ addEventListener() {},
54
+ removeEventListener() {},
55
+ }
56
+ globalThis.document = {
57
+ createElement: () => ({ style: {}, attrs: {}, children: [], setAttribute() {}, addEventListener() {}, appendChild(child) { return child }, remove() {}, click() {} }),
58
+ querySelector: () => null,
59
+ addEventListener() {},
60
+ removeEventListener() {},
61
+ head: { appendChild() {} },
62
+ body: { appendChild() {}, style: {} },
63
+ }
64
+
65
+ await import('../lib/client.js')
66
+
67
+ const EMPTY_STATE = {
68
+ engine: { status: 'ready', versions: [], currentVersion: '0.11.0b3', recommendedVersion: '0.11.0b3', installed: true, state: {} },
69
+ settings: { autoInject: 'smart', mustRememberCap: 16, unpinBudgetPerSession: 2 },
70
+ libraries: [],
71
+ archivedLibraries: [],
72
+ memuConfig: { provider: 'openai' },
73
+ summaryConfig: {},
74
+ binding: null,
75
+ privateCount: 0,
76
+ pinnedCount: 0,
77
+ globalCount: 0,
78
+ jobs: [],
79
+ }
80
+
81
+ const DEFAULT_ANSWERS = {
82
+ getState: EMPTY_STATE,
83
+ getSettingsState: EMPTY_STATE,
84
+ getPrompts: { prompts: [] },
85
+ listVersions: { ok: true, versions: [] },
86
+ listBackups: { backups: [] },
87
+ listFolders: { folders: [] },
88
+ listLibraries: { libraries: [] },
89
+ listSessions: { sessions: [] },
90
+ getSidebarState: { binding: null, domain: 'general', folder: null, effective: {}, private: { items: [], active: 0, pinned: 0, total: 0 }, global: { items: [], active: 0, total: 0 }, domains: [] },
91
+ jobStatus: null,
92
+ browseWall: { items: [], total: 0 },
93
+ browseLibrary: { source: 'engine', files: [] },
94
+ getGraph: { nodes: [], edges: [], limit: 200, truncated: false, focusId: null },
95
+ listEntities: { items: [], total: 0 },
96
+ }
97
+
98
+ const registrations = []
99
+ const rpcCalls = []
100
+
101
+ function fakeCtx(answers = {}) {
102
+ const merged = { ...DEFAULT_ANSWERS, ...answers }
103
+ return {
104
+ effect: (fn) => { try { fn() } catch {} },
105
+ locale: { register: () => () => {}, bind: () => (key) => key, get: () => 'zh' },
106
+ connection: {
107
+ rpc: {
108
+ call: async (path, endpoint, payload) => {
109
+ rpcCalls.push({ endpoint, payload })
110
+ return { ok: true, value: merged[endpoint] ?? {} }
111
+ },
112
+ },
113
+ },
114
+ sessions: { list: { subscribe: () => () => {}, getSnapshot: () => ({ byId: {}, ids: [] }) } },
115
+ get: () => undefined,
116
+ slots: {
117
+ inject: (_name, callback) => callback(scope),
118
+ register: (spec, component) => { registrations.push({ slot: spec.name, component }); return () => {} },
119
+ },
120
+ inject: (_deps, callback) => callback({
121
+ effect: (fn) => { try { fn() } catch {} },
122
+ sidebarRightTabs: { register: () => () => {} },
123
+ get: () => undefined,
124
+ }),
125
+ }
126
+ }
127
+
128
+ const scope = {
129
+ effect: (fn) => { try { fn() } catch {} },
130
+ register: (spec, component) => { registrations.push({ slot: spec.name, component }); return () => {} },
131
+ inject: (_deps, callback) => callback(scope),
132
+ }
133
+
134
+ let pageHookIndex = -1
135
+
136
+ function collectPageHook() {
137
+ const zeroes = hooks.map((value, index) => (value === 0 ? index : -1)).filter((index) => index >= 0)
138
+ assert.equal(zeroes.length, 1, 'expected exactly one numeric-zero state (the page index)')
139
+ pageHookIndex = zeroes[0]
140
+ }
141
+
142
+ async function mountAndSettle(component, props, { standalone = false } = {}) {
143
+ hooks = []
144
+ forcedList = []
145
+ generation++
146
+ pageHookIndex = -1
147
+ effects = []
148
+ cursor = 0
149
+ const tree = component({ ...props, ...(standalone ? { standalone: true } : {}) })
150
+ assert.ok(tree, 'component rendered nothing')
151
+ for (const effect of effects) effect.fn()
152
+ await new Promise((resolve) => setTimeout(resolve, 0))
153
+ await new Promise((resolve) => setTimeout(resolve, 0))
154
+ return tree
155
+ }
156
+
157
+ function renderAt(component, props, pageValue, extraForced = []) {
158
+ effects = []
159
+ cursor = 0
160
+ forcedList = [{ index: pageHookIndex, value: pageValue }, ...extraForced]
161
+ const tree = component(props)
162
+ assert.ok(tree, 'component rendered nothing')
163
+ return { tree, effects }
164
+ }
165
+
166
+ function treeText(node, depth = 0) {
167
+ if (node === null || node === undefined || depth > 400) return ''
168
+ if (typeof node === 'string' || typeof node === 'number') return String(node)
169
+ if (Array.isArray(node)) return node.map((child) => treeText(child, depth + 1)).join(' ')
170
+ if (typeof node.type === 'function') return treeText(node.type(node.props), depth + 1)
171
+ return `${node.props?.className || ''} ${treeText(node.props?.children, depth + 1)}`
172
+ }
173
+
174
+ function walkNodes(node, visit, depth = 0) {
175
+ if (node === null || node === undefined || depth > 400) return
176
+ if (Array.isArray(node)) {
177
+ for (const child of node) walkNodes(child, visit, depth + 1)
178
+ return
179
+ }
180
+ if (typeof node !== 'object') return
181
+ if (visit(node, depth) === false) return
182
+ if (typeof node.type === 'function') {
183
+ walkNodes(node.type(node.props), visit, depth + 1)
184
+ return
185
+ }
186
+ walkNodes(node.props?.children, visit, depth + 1)
187
+ }
188
+
189
+ function settingsComponent() {
190
+ const registration = registrations.filter((entry) => entry.slot === 'settings.section').pop()
191
+ assert.ok(registration?.component, 'settings.section was not registered')
192
+ return registration.component
193
+ }
194
+
195
+ test('all thirteen settings pages render, and the nav carries the help entry', async () => {
196
+ const ctx = fakeCtx()
197
+ globalThis.__dshMemuHelpExports.apply(ctx)
198
+ const component = settingsComponent()
199
+ const props = { ctx, t: (key) => key }
200
+
201
+ await mountAndSettle(component, props)
202
+ collectPageHook()
203
+
204
+ const NAV = ['pageOverview', 'pageConnection', 'pageSettings', 'pageFolders', 'pageLibraries', 'pageBrowser', 'pageGlobal', 'pagePrompts', 'pageStats', 'pageMemories', 'pageLogs', 'pageLexicon', 'pageHelp']
205
+ for (let page = 0; page < 13; page++) {
206
+ const { tree } = renderAt(component, props, page)
207
+ const text = treeText(tree)
208
+ assert.match(String(tree.props?.className), /dshm-root/, `page ${page} did not render the shell`)
209
+ assert.ok(NAV.every((key) => text.includes(key)), `page ${page} lost a nav entry (pageHelp included)`)
210
+ assert.equal((text.match(/dshm-nav-ind/g) || []).length, 1, `page ${page} lost or duplicated its rail indicator`)
211
+ assert.match(text, /dshm-page/, `page ${page} lost the content pane`)
212
+ assert.ok(!text.includes('[object Object]'), `page ${page} rendered an object as text`)
213
+ }
214
+ })
215
+
216
+ test('the deep link #/settings/help lands on the help page when standalone', async () => {
217
+ const component = settingsComponent()
218
+ const props = { ctx: fakeCtx(), t: (key) => key }
219
+ window.location.hash = '#/settings/help'
220
+
221
+ await mountAndSettle(component, props, { standalone: true })
222
+ // hooks 已经带着 page=12(初始器读 hash);再渲染一次即命中帮助页。
223
+ forcedList = []
224
+ effects = []
225
+ cursor = 0
226
+ const tree = component({ ...props, standalone: true })
227
+ const text = treeText(tree)
228
+ assert.match(text, /helpQuickStart/, 'the deep link must open the quick start section')
229
+ assert.match(text, /helpFaq/, 'the deep link must open the FAQ section')
230
+ assert.match(text, /helpFeedback/, 'the deep link must open the feedback section')
231
+ window.location.hash = ''
232
+ })
233
+
234
+ test('the help page shows five steps, ten FAQ details and the feedback actions', async () => {
235
+ const component = settingsComponent()
236
+ const props = { ctx: fakeCtx(), t: (key) => key }
237
+ await mountAndSettle(component, props)
238
+ collectPageHook()
239
+
240
+ const { tree } = renderAt(component, props, 12)
241
+ const text = treeText(tree)
242
+ for (const step of [1, 2, 3, 4, 5]) assert.ok(text.includes(`helpStep${step}`), `quick start step ${step} is missing`)
243
+ for (const n of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) {
244
+ assert.ok(text.includes(`helpFaq${n}`), `FAQ item ${n} is missing`)
245
+ assert.ok(text.includes(`helpFaq${n}A`), `FAQ answer ${n} is missing`)
246
+ }
247
+ assert.ok(text.includes('helpQuickStart') && text.includes('helpFaq') && text.includes('helpFeedback'), 'the three help sections are all present')
248
+ assert.ok(text.includes('helpOpenIssue'), 'the issue button is missing')
249
+ assert.ok(text.includes('helpCopyDiagnostics'), 'the copy-diagnostics button is missing')
250
+ assert.ok(text.includes('helpDocs'), 'the docs link is missing')
251
+ // FAQ 用 10 个 <details> 折叠;quick start 的每一步都带「前往 →」跳转按钮
252
+ //(第 2 步是会话侧动作,只给文案,不带跳转 → 4 个按钮)。
253
+ const details = []
254
+ walkNodes(tree, (node) => {
255
+ if (node.type === 'details') details.push(node)
256
+ })
257
+ assert.equal(details.length, 10, `expected ten FAQ details, found ${details.length}`)
258
+ assert.equal((text.match(/helpGoTo/g) || []).length, 4, 'four of the five steps jump, the bind step cannot')
259
+ // 不新增 RPC:复制诊断只序列化 getSettingsState 的既有快照(state 优先,缺了才补拉)。
260
+ const src = clientSource
261
+ assert.ok(src.includes('https://github.com/Tkingxiao/dsh-memu-memory/issues/new'), 'the issue URL is wired')
262
+ assert.ok(src.includes('https://github.com/Tkingxiao/dsh-memu-memory#readme'), 'the docs URL is wired')
263
+ assert.match(src, /const snapshot = state \|\| await call\('getSettingsState', \{\}\)/, 'diagnostics reuse getSettingsState instead of a new RPC')
264
+ assert.match(src, /helpStep1[\s\S]*PAGE\.libraries[\s\S]*helpStep3[\s\S]*PAGE\.settings[\s\S]*helpStep4[\s\S]*PAGE\.memories[\s\S]*helpStep5[\s\S]*PAGE\.lexicon/, 'the steps point at the documented pages')
265
+ assert.match(src, /\{ key: 'helpStep2', to: null \}/, 'the bind step stays a text-only step')
266
+ })
267
+
268
+ test('the detailed help sections and label table render between quick start and FAQ', async () => {
269
+ const component = settingsComponent()
270
+ const props = { ctx: fakeCtx(), t: (key) => key }
271
+ await mountAndSettle(component, props)
272
+ collectPageHook()
273
+
274
+ const { tree } = renderAt(component, props, 12)
275
+ const text = treeText(tree)
276
+ for (const section of ['helpScopes', 'helpTitleCards', 'helpArchiveDelete', 'helpVector', 'helpAutoLink', 'helpLabels', 'helpSidebar']) {
277
+ assert.ok(text.includes(section), `help section ${section} is missing`)
278
+ }
279
+ // 四区详解必须夹在快速开始与 FAQ 之间,反馈块保持最后。
280
+ const order = ['helpQuickStart', 'helpScopes', 'helpTitleCards', 'helpArchiveDelete', 'helpVector',
281
+ 'helpAutoLink', 'helpLabels', 'helpSidebar', 'helpFaq', 'helpFeedback'].map((key) => text.indexOf(key))
282
+ assert.ok(order.every((index) => index >= 0), 'every help block must be present')
283
+ assert.deepEqual(order, [...order].sort((a, b) => a - b), 'help blocks must render in the documented order')
284
+ for (const key of [
285
+ 'helpScopesChain', 'helpScopesPrivate', 'helpScopesGlobal', 'helpScopesWhere',
286
+ 'helpScopesPitfall1', 'helpScopesPitfall2', 'helpScopesPitfall3',
287
+ 'helpTitleCardsBody', 'helpTitleCardsRecall', 'helpTitleCardsRule',
288
+ 'helpArchiveDeleteArchive', 'helpArchiveDeleteDelete', 'helpArchiveDeleteLexicon',
289
+ 'helpArchiveDeleteAbsorb', 'helpArchiveDeleteSessionGone',
290
+ 'helpVectorSetup', 'helpVectorCount', 'helpVectorFallback', 'helpVectorRebuild',
291
+ 'helpAutoLinkRule', 'helpAutoLinkHub', 'helpAutoLinkWorker', 'helpAutoLinkSafety',
292
+ 'helpSidebarAlerts', 'helpSidebarReview', 'helpSidebarRunLog', 'helpSidebarReviewMode',
293
+ 'helpSidebarFirstRun', 'helpSidebarFallback', 'helpSidebarScope',
294
+ ]) assert.ok(text.includes(key), `help body ${key} is missing`)
295
+
296
+ // 标注表固定 2 列 9 行:行上的 data-help-row 携带 i18n key,顺序即规范顺序。
297
+ const tables = []
298
+ const rows = []
299
+ walkNodes(tree, (node) => {
300
+ if (node.props?.['data-help-table'] === 'labels') tables.push(node)
301
+ if (node.props?.['data-help-row']) rows.push(node.props['data-help-row'])
302
+ })
303
+ assert.equal(tables.length, 1, 'exactly one label cheat sheet')
304
+ assert.deepEqual(rows, [
305
+ 'helpLabelSource', 'helpLabelLayer', 'helpLabelOpenLoop', 'helpLabelMarginalized', 'helpLabelDate',
306
+ 'helpLabelAttachment', 'helpLabelVector', 'helpLabelReviewRisk', 'helpLabelLexicon',
307
+ ], 'the label table must carry nine rows in order')
308
+ for (const key of rows) assert.ok(text.includes(key), `label row ${key} is missing its text`)
309
+ for (const key of ['helpLabelSourceMeaning', 'helpLabelLayerMeaning', 'helpLabelOpenLoopMeaning',
310
+ 'helpLabelMarginalizedMeaning', 'helpLabelDateMeaning', 'helpLabelAttachmentMeaning',
311
+ 'helpLabelVectorMeaning', 'helpLabelReviewRiskMeaning', 'helpLabelLexiconMeaning']) {
312
+ assert.ok(text.includes(key), `label meaning ${key} is missing`)
313
+ }
314
+ })
315
+
316
+ test('the first-run card shows only when no library exists yet', async () => {
317
+ // 没有库:卡片出现在 MemU 记忆页顶部,两个按钮分别指向建库页与帮助页。
318
+ const component = settingsComponent()
319
+ const emptyProps = { ctx: fakeCtx(), t: (key) => key }
320
+ await mountAndSettle(component, emptyProps)
321
+ collectPageHook()
322
+ const emptyRun = renderAt(component, emptyProps, 9)
323
+ const emptyText = treeText(emptyRun.tree)
324
+ assert.match(emptyText, /firstRunTitle/, 'the first-run card is missing with zero libraries')
325
+ assert.match(emptyText, /firstRunDesc/)
326
+ assert.match(emptyText, /firstRunGoLibraries/, 'the create-library button is missing')
327
+ assert.match(emptyText, /firstRunGuide/, 'the guide button is missing')
328
+
329
+ // 已有库:卡片消失(不落 localStorage,库存在即退场)。
330
+ const loaded = { ...EMPTY_STATE, libraries: [{ id: 'lib-1', name: 'demo', scopeKey: '/proj/demo', privateCount: 0 }] }
331
+ const loadedProps = { ctx: fakeCtx({ getState: loaded, getSettingsState: loaded }), t: (key) => key }
332
+ await mountAndSettle(component, loadedProps)
333
+ collectPageHook()
334
+ const loadedRun = renderAt(component, loadedProps, 9)
335
+ assert.ok(!treeText(loadedRun.tree).includes('firstRunTitle'), 'the first-run card must disappear once a library exists')
336
+ })
337
+
338
+ // GraphPanel 只在 browserBlock 的 relations 视图挂载,而嵌套组件只在树被展开时
339
+ // 执行:先渲染并展开(初始化面板 hooks + 收集它的 fetch effects),跑完 effects
340
+ // 落定后再渲染展开一次。注意最终树只能展开一次——walkNodes 和 treeText 各展开
341
+ // 一遍会让第二次跑在错位的 hook 游标上,读出空状态。
342
+ function walkAndText(node, sink, depth = 0) {
343
+ if (node === null || node === undefined || depth > 400) return ''
344
+ if (typeof node === 'string' || typeof node === 'number') return String(node)
345
+ if (Array.isArray(node)) return node.map((child) => walkAndText(child, sink, depth + 1)).join(' ')
346
+ if (typeof node.type === 'function') return walkAndText(node.type(node.props), sink, depth + 1)
347
+ if (node.type === 'details' && String(node.props?.className || '').includes('dshm-graph-orphans')) sink.push(node)
348
+ return `${node.props?.className || ''} ${walkAndText(node.props?.children, sink, depth + 1)}`
349
+ }
350
+
351
+ async function relationsTree(component, props, pageHook, viewIndex) {
352
+ const forced = [{ index: pageHook, value: 5 }, { index: viewIndex, value: 'relations' }]
353
+ effects = []
354
+ cursor = 0
355
+ forcedList = forced
356
+ const mounted = component(props)
357
+ assert.ok(mounted, 'relations view rendered nothing')
358
+ treeText(mounted)
359
+ for (const effect of effects) effect.fn()
360
+ await new Promise((resolve) => setTimeout(resolve, 0))
361
+ await new Promise((resolve) => setTimeout(resolve, 0))
362
+ effects = []
363
+ cursor = 0
364
+ forcedList = forced
365
+ const tree = component(props)
366
+ const orphanDetails = []
367
+ const text = walkAndText(tree, orphanDetails)
368
+ return { text, orphanDetails }
369
+ }
370
+
371
+ test('orphan entities collapse into one details block; linked rows stay in the main list', async () => {
372
+ const loaded = { ...EMPTY_STATE, libraries: [{ id: 'lib-1', name: 'demo', scopeKey: '/proj/demo', privateCount: 0 }] }
373
+ const component = settingsComponent()
374
+ const props = { ctx: fakeCtx({
375
+ getState: loaded,
376
+ getSettingsState: loaded,
377
+ listEntities: {
378
+ items: [
379
+ { id: 'ent-linked', name: 'Linked', kind: 'person', aliases: [], linked: true },
380
+ { id: 'ent-ghost', name: 'Ghost', kind: 'person', aliases: [], linked: false },
381
+ ],
382
+ total: 2,
383
+ },
384
+ }), t: (key) => key }
385
+
386
+ await mountAndSettle(component, props)
387
+ collectPageHook()
388
+ const activeIndexes = hooks.map((value, index) => (value === 'active' ? index : -1)).filter((index) => index >= 0)
389
+ assert.equal(activeIndexes.length, 1, 'expected exactly one "active" state (the browser view)')
390
+ const { text, orphanDetails } = await relationsTree(component, props, pageHookIndex, activeIndexes[0])
391
+ assert.equal(orphanDetails.length, 1, 'exactly one collapsed orphan group')
392
+ const summary = treeText(orphanDetails[0])
393
+ assert.match(summary, /orphanEntities/, 'the summary must carry the orphan label')
394
+ assert.match(summary, /orphanPruneHint/, 'the group must note the startup prune')
395
+ assert.match(summary, /entityDelete/, 'the orphan keeps its delete button inside the group')
396
+ assert.match(text, /Linked/, 'the linked entity is rendered by name')
397
+ assert.match(text, /Ghost/, 'the orphan entity is rendered by name')
398
+
399
+ // 没有孤立实体:不渲染折叠区;没有实体:给出 entityEmpty 空态。
400
+ const cleanProps = { ctx: fakeCtx({ getState: loaded, getSettingsState: loaded, listEntities: { items: [{ id: 'ent-ok', name: 'Ok', kind: 'person', aliases: [], linked: true }], total: 1 } }), t: (key) => key }
401
+ await mountAndSettle(component, cleanProps)
402
+ collectPageHook()
403
+ const cleanIndexes = hooks.map((value, index) => (value === 'active' ? index : -1)).filter((index) => index >= 0)
404
+ assert.equal(cleanIndexes.length, 1)
405
+ const clean = await relationsTree(component, cleanProps, pageHookIndex, cleanIndexes[0])
406
+ assert.equal(clean.orphanDetails.length, 0, 'no orphan group may render when every entity is linked')
407
+
408
+ const noneProps = { ctx: fakeCtx({ getState: loaded, getSettingsState: loaded, listEntities: { items: [], total: 0 } }), t: (key) => key }
409
+ await mountAndSettle(component, noneProps)
410
+ collectPageHook()
411
+ const noneIndexes = hooks.map((value, index) => (value === 'active' ? index : -1)).filter((index) => index >= 0)
412
+ assert.equal(noneIndexes.length, 1)
413
+ const none = await relationsTree(component, noneProps, pageHookIndex, noneIndexes[0])
414
+ assert.match(none.text, /entityEmpty/, 'an empty entity list shows the empty state')
415
+ })
@@ -69,9 +69,16 @@ test('eligibility and context are applied before the candidate cap', () => {
69
69
  test('the per-route candidate cap is bounded and deterministic', () => {
70
70
  const entries = {}
71
71
  const local = []
72
+ // Distinct bodies: near-identical candidates would fold into one cluster slot
73
+ // (fact-level dedup), and this test is about the cap, not the folding.
74
+ const alphabet = 'abcdefghijklmnopqrstuvwxyz'
75
+ const token = (n) => alphabet[n % 26] + alphabet[Math.floor(n / 26) % 26]
72
76
  for (let i = 0; i < 60; i += 1) {
73
77
  const id = 'mem-' + String(i).padStart(2, '0')
74
- entries[id] = entry({ title: 'memory ' + i })
78
+ entries[id] = entry({
79
+ title: 'topic ' + token(180 + i),
80
+ content: [token(3 * i), token(3 * i + 1), token(3 * i + 2)].join('of'),
81
+ })
75
82
  local.push({ name: id })
76
83
  }
77
84
  assert.equal(memory.hybridCandidateLimit(1), 12)
@@ -83,8 +90,31 @@ test('the per-route candidate cap is bounded and deterministic', () => {
83
90
  assert.deepEqual(fused.files.map((file) => file.name), local.slice(0, 12).map((file) => file.name).sort((a, b) => a.localeCompare(b)))
84
91
  })
85
92
 
93
+ test('near-duplicate candidates fold into one candidate slot before the cap', () => {
94
+ const entries = {}
95
+ const local = []
96
+ for (let i = 0; i < 12; i += 1) {
97
+ const id = 'mem-dup-' + String(i).padStart(2, '0')
98
+ entries[id] = entry({ title: 'deploy window ' + (i ? 'variant ' + i : ''), content: 'the deploy window is thursday two to four am, grey releases only' })
99
+ local.push({ name: id })
100
+ }
101
+ entries['mem-real'] = entry({ title: 'reimbursement deadline', content: 'expense reports are due before the last working day of each month' })
102
+ const fused = memory.fuseRecallCandidates({ local: [...local, { name: 'mem-real' }], semantic: [], index: indexOf(entries), limit: 12 })
103
+ // The cluster spends one slot instead of twelve, so the real memory enters
104
+ // even though 13 candidates competed for 12 places.
105
+ assert.equal(fused.debug.localCandidates, 2)
106
+ assert.deepEqual(fused.files.map((file) => file.name), ['mem-dup-00', 'mem-real'])
107
+ assert.deepEqual(fused.debug.collapsed, [{ kept: 'mem-dup-00', dropped: local.slice(1).map((file) => file.name) }])
108
+ assert.equal(fused.debug.clusterCount, 1)
109
+ })
110
+
86
111
  test('equal fused scores fall back to the best rank and then the canonical id', () => {
87
- const index = indexOf({ 'mem-a': entry(), 'mem-b': entry() })
112
+ // Distinct texts: two byte-identical memories would fold as one fact before
113
+ // the tie-break this test is about ever gets a chance to run.
114
+ const index = indexOf({
115
+ 'mem-a': entry({ title: 'alpha topic', content: 'alpha body' }),
116
+ 'mem-b': entry({ title: 'beta topic', content: 'beta body' }),
117
+ })
88
118
  const first = memory.fuseRecallCandidates({ local: [{ name: 'mem-a' }], semantic: [{ name: 'mem-b' }], index })
89
119
  const second = memory.fuseRecallCandidates({ local: [{ name: 'mem-a' }], semantic: [{ name: 'mem-b' }], index })
90
120
  assert.deepEqual(first.files.map((file) => file.name), ['mem-a', 'mem-b'])
@@ -85,7 +85,8 @@ test('a library switch during first-step recall discards the whole pre-switch me
85
85
  const marker = 'OLD-LIBRARY-UNIQUE-MARKER-7c1e'
86
86
  await memory.privateRemember({ exec: f.exec, title: '旧库唯一标记', content: marker })
87
87
  const stale = inject.buildAnchor(f.binding, state.getSettings(), f.id)
88
- assert.ok(stale.text.includes(marker), 'the pre-switch anchor must contain the marker for this test to be meaningful')
88
+ assert.ok(stale.text.includes('旧库唯一标记'), 'the pre-switch anchor must carry the title for this test to be meaningful')
89
+ assert.ok(!stale.text.includes(marker), 'title cards do not carry bodies')
89
90
  const other = state.createLibrary({ name: 'other', scopeKey: path.join(home, 'other-lib') })
90
91
  const user = { id: 'first-user', role: 'user', source: { kind: 'user' }, content: [{ type: 'text', text: '旧库唯一标记在哪里' }] }
91
92
  const decision = { kind: 'enter', messages: [user] }
@@ -239,9 +240,11 @@ test('retrieval errors stay fail-open, preserve the batch and use a retry delay'
239
240
  state.updateSettings({ memoryBackend: 'agent' })
240
241
  const current = state.getSessionBinding(f.id)
241
242
  state.setSessionBinding(f.id, { inbound: { ...current.inbound, retryAt: null } })
242
- await saved(f)
243
+ const entry = await saved(f)
243
244
  const out = await continueStep(f, 3)
244
- assert.ok(textOf(out).includes('tools/deploy.ps1'))
245
+ assert.ok(textOf(out).includes(`[${entry.id}]`), 'the retried recall lands as a title card')
246
+ assert.ok(textOf(out).includes('部署脚本'), 'the title is what the block carries')
247
+ assert.ok(!textOf(out).includes('tools/deploy.ps1'), 'the body stays behind memu_private_recall')
245
248
  assert.equal(state.getSessionBinding(f.id).inbound.pending.length, 0)
246
249
  } finally { state.updateSettings({ memoryBackend: 'agent' }) }
247
250
  })
@@ -14,6 +14,7 @@ process.env.DSH_MEMU_HOME = home
14
14
  const state = await import('../lib/state.js')
15
15
  const inject = await import('../lib/inject.js')
16
16
  const prompts = await import('../lib/prompts.js')
17
+ const budget = await import('../lib/budget.js')
17
18
 
18
19
  test.after(() => {
19
20
  try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
@@ -90,6 +91,13 @@ function seedPinned(binding, id, title) {
90
91
  state.savePrivateIndex(binding.libraryId, index)
91
92
  }
92
93
 
94
+ function messageTextOf(message) {
95
+ const content = message?.content
96
+ if (typeof content === 'string') return content
97
+ if (!Array.isArray(content)) return ''
98
+ return content.filter((block) => block?.type === 'text').map((block) => block.text || '').join('\n')
99
+ }
100
+
93
101
  function pluginMessages(messages) {
94
102
  return (messages || []).filter((message) => message?.role === 'user' && message?.source?.kind === 'plugin:' + inject.PLUGIN)
95
103
  }
@@ -362,3 +370,23 @@ test('standard and preset mounts render the same rules body', async () => {
362
370
  assert.equal(textA, textB, 'both paths use the same system-guide body')
363
371
  assert.equal(guideKey(guideMessages(outA.messages)[0]), guideKey(guideMessages(outB.messages)[0]))
364
372
  })
373
+
374
+ test('the guide is included in injectionTotalTokens, not added on top of it', async () => {
375
+ state.updateSettings({ injectionTotalTokens: 900, turnTokens: 800, randomCueChance: 0, habitsInjection: false })
376
+ const sessionId = 'guide-budget'
377
+ const cwd = path.join(home, 'project-guide-budget')
378
+ const binding = state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
379
+ state.setSessionBinding(sessionId, { useLibrary: true, useGlobal: false })
380
+ seedPinned(binding, 'mem-guide-budget', '预算里的规则')
381
+ const index = state.loadPrivateIndex(binding.libraryId)
382
+ index.entries['mem-guide-budget'].content = '这是一条很长的固定记忆,用来占据锚点预算。'.repeat(12)
383
+ state.savePrivateIndex(binding.libraryId, index)
384
+ const harness = mountGuide()
385
+ const turn = makeTurn(sessionId, { cwd, agentPreset: 'coding' })
386
+ const out = await harness.run(turn.payload, turn.decision)
387
+ const guides = guideMessages(out.messages)
388
+ assert.equal(guides.length, 1, 'the guide still lands')
389
+ const used = pluginMessages(out.messages).reduce((sum, message) => sum + budget.estTokens(messageTextOf(message)), 0)
390
+ assert.ok(used <= 900, `guide + anchor must stay inside injectionTotalTokens, got ${used}`)
391
+ assert.ok(used < 900 + 200, 'sanity: the anchor was clipped rather than the guide silently dropped')
392
+ })