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,520 @@
1
+ // Package B (spec §6.2): export/import contract + success notices.
2
+ //
3
+ // The export contract is behavioral: exportLibraryNow/importLibraryNow take an
4
+ // options object whose libraryId defaults to the browsed library but must be
5
+ // the id that reaches the exportLibrary payload, the fallback download name
6
+ // and the ZIP upload URL. The notices are the other half: every browser row
7
+ // action answers with its own success message through the page's notice slot
8
+ // while failures keep using setError.
9
+ //
10
+ // Same fake-React harness as tests/client-render.test.mjs, plus two seams the
11
+ // old harness never needed: captured createElement nodes (the file input and
12
+ // the download anchors) and stubbed fetch/URL.createObjectURL.
13
+ import test from 'node:test'
14
+ import assert from 'node:assert/strict'
15
+
16
+ const createdNodes = []
17
+ const fetchCalls = []
18
+ const realFetch = globalThis.fetch
19
+ const realCreateObjectURL = URL.createObjectURL
20
+ const realRevokeObjectURL = URL.revokeObjectURL
21
+
22
+ const registrations = []
23
+
24
+ function makeElement(type, props, ...children) {
25
+ return { type, props: { ...(props || {}), children: children.length <= 1 ? children[0] : children } }
26
+ }
27
+
28
+ let hooks = []
29
+ let cursor = 0
30
+ let effects = []
31
+ let dirty = false
32
+ let forced = null
33
+ let generation = 0
34
+
35
+ const React = {
36
+ createElement: makeElement,
37
+ Fragment: Symbol('Fragment'),
38
+ useState(initial) {
39
+ const index = cursor++
40
+ const generation_ = generation
41
+ if (hooks[index] === undefined) hooks[index] = typeof initial === 'function' ? initial() : initial
42
+ if (forced && forced.index === index) hooks[index] = forced.value
43
+ return [hooks[index], (next) => {
44
+ if (generation_ !== generation) return
45
+ hooks[index] = typeof next === 'function' ? next(hooks[index]) : next
46
+ dirty = true
47
+ }]
48
+ },
49
+ useCallback: (fn) => fn,
50
+ useMemo: (fn) => fn(),
51
+ useEffect(fn, deps) { effects.push({ fn, deps, index: cursor++ }) },
52
+ useLayoutEffect() {},
53
+ useRef: (value) => ({ current: value }),
54
+ useSyncExternalStore: (_subscribe, getSnapshot) => getSnapshot(),
55
+ }
56
+
57
+ globalThis.window = {
58
+ __ModuleLoader__: {
59
+ load({ factory }) {
60
+ globalThis.__dshMemuExports = factory((name) => {
61
+ if (name === 'react') return React
62
+ throw new Error(`unexpected require: ${name}`)
63
+ })
64
+ },
65
+ },
66
+ addEventListener() {},
67
+ removeEventListener() {},
68
+ }
69
+ globalThis.document = {
70
+ createElement: (tag) => {
71
+ const node = {
72
+ tag,
73
+ style: {},
74
+ attrs: {},
75
+ children: [],
76
+ href: '',
77
+ download: '',
78
+ textContent: '',
79
+ setAttribute(name, value) { node.attrs[name] = value },
80
+ addEventListener() {},
81
+ appendChild(child) { node.children.push(child); return child },
82
+ remove() { node.removed = true },
83
+ click() { node.clicked = true },
84
+ }
85
+ createdNodes.push(node)
86
+ return node
87
+ },
88
+ querySelector: () => null,
89
+ addEventListener() {},
90
+ removeEventListener() {},
91
+ head: { appendChild() {} },
92
+ body: { appendChild() {} },
93
+ }
94
+
95
+ // The import ZIP path POSTs to a host route; record instead of hitting nothing.
96
+ globalThis.fetch = async (url, options) => {
97
+ fetchCalls.push({ url: String(url), options })
98
+ return { json: async () => ({ ok: true, value: { imported: 2, counts: {} } }) }
99
+ }
100
+ URL.createObjectURL = () => 'blob:fixture'
101
+ URL.revokeObjectURL = () => {}
102
+
103
+ await import('../lib/client.js')
104
+
105
+ const EMPTY_STATE = {
106
+ engine: { status: 'ready', versions: [], currentVersion: '0.11.0b3', recommendedVersion: '0.11.0b3', installed: true, state: {} },
107
+ settings: { autoInject: 'smart', mustRememberCap: 16, unpinBudgetPerSession: 2 },
108
+ libraries: [{ id: 'lib-1', name: 'demo', scopeKey: '/proj/demo', privateCount: 2 }],
109
+ archivedLibraries: [],
110
+ memuConfig: { provider: 'openai' },
111
+ summaryConfig: {},
112
+ binding: null,
113
+ privateCount: 2,
114
+ pinnedCount: 0,
115
+ globalCount: 0,
116
+ jobs: [],
117
+ }
118
+
119
+ const BROWSE_FILES = [
120
+ { id: 'mem-live', title: 'live entry', content: 'an active body', layer: 'long_term', status: 'active', decay: 0.9 },
121
+ { id: 'mem-gone', title: 'archived entry', content: 'an archived body', layer: 'long_term', status: 'archived', decay: 0.4 },
122
+ ]
123
+
124
+ const WALL_ITEM = {
125
+ id: 'mem-wall', libraryId: 'lib-1', title: 'walled entry', preview: 'a body',
126
+ layer: 'long_term', status: 'active', decay: 0.9,
127
+ }
128
+
129
+ function makeAnswers(over = {}) {
130
+ return {
131
+ getState: EMPTY_STATE,
132
+ getSettingsState: EMPTY_STATE,
133
+ listLibraries: { libraries: EMPTY_STATE.libraries },
134
+ listSessions: { sessions: [] },
135
+ listVersions: { ok: true, versions: [] },
136
+ listBackups: { backups: [] },
137
+ listFolders: { folders: [] },
138
+ getSidebarState: {
139
+ binding: null, domain: 'general', folder: null,
140
+ effective: { useLibrary: false, useGlobal: false, enabled: false },
141
+ private: { items: [], active: 0, pinned: 0, total: 0 },
142
+ global: { items: [], active: 0, total: 0 },
143
+ domains: [],
144
+ },
145
+ jobStatus: null,
146
+ browseLibrary: { source: 'engine', files: structuredClone(BROWSE_FILES) },
147
+ browseWall: { items: [structuredClone(WALL_ITEM)], total: 1, errors: [] },
148
+ getMemory: { id: 'mem-wall', libraryId: 'lib-1', entry: { title: 'walled entry', content: 'a body', layer: 'long_term', status: 'active', confidence: 0.8, tags: [] }, attachments: [] },
149
+ ...over,
150
+ }
151
+ }
152
+
153
+ function fakeCtx({ answers = {}, rpcCalls = [] } = {}) {
154
+ return {
155
+ effect: (fn) => { try { fn() } catch {} },
156
+ locale: { register: () => () => {}, bind: () => (key) => key, get: () => 'zh' },
157
+ connection: {
158
+ rpc: {
159
+ call: async (path, endpoint, payload) => {
160
+ rpcCalls.push({ path, endpoint, payload })
161
+ if (typeof answers[endpoint] === 'function') return answers[endpoint](payload)
162
+ return { ok: true, value: answers[endpoint] ?? {} }
163
+ },
164
+ },
165
+ },
166
+ sessions: { list: { subscribe: () => () => {}, getSnapshot: () => ({ byId: {}, ids: [] }) } },
167
+ get: (name) => (name === 'uiSession'
168
+ ? { adapter: { current: { getSnapshot: () => ({ key: 'probe-session' }) } } }
169
+ : undefined),
170
+ slots: {
171
+ inject: (_name, callback) => callback(scope),
172
+ register: (spec, component) => { registrations.push({ slot: spec.name, id: spec.id, component }); return () => {} },
173
+ },
174
+ inject: (_deps, callback) => callback({
175
+ effect: (fn) => { try { fn() } catch {} },
176
+ sidebarRightTabs: { register: () => () => {} },
177
+ get: () => undefined,
178
+ }),
179
+ }
180
+ }
181
+
182
+ const scope = {
183
+ effect: (fn) => { try { fn() } catch {} },
184
+ register: (spec, component) => { registrations.push({ slot: spec.name, id: spec.id, component }); return () => {} },
185
+ inject: (_deps, callback) => callback(scope),
186
+ }
187
+
188
+ async function renderPage(component, props, pageValue, { remount = false } = {}) {
189
+ if (remount) { hooks = []; generation++ }
190
+ effects = []
191
+ cursor = 0
192
+ forced = pageValue === undefined ? null : { index: pageHookIndex, value: pageValue }
193
+ const tree = component(props)
194
+ assert.ok(tree, 'component rendered nothing')
195
+ return { tree, effects }
196
+ }
197
+
198
+ let pageHookIndex = -1
199
+
200
+ function collectPageHook() {
201
+ const zeroes = hooks.map((value, index) => (value === 0 ? index : -1)).filter((index) => index >= 0)
202
+ assert.equal(zeroes.length, 1, 'expected exactly one numeric-zero state (the page index)')
203
+ pageHookIndex = zeroes[0]
204
+ }
205
+
206
+ function treeText(node, depth = 0) {
207
+ if (node === null || node === undefined || depth > 400) return ''
208
+ if (typeof node === 'string' || typeof node === 'number') return String(node)
209
+ if (Array.isArray(node)) return node.map((child) => treeText(child, depth + 1)).join(' ')
210
+ if (typeof node.type === 'function') return treeText(node.type(node.props), depth + 1)
211
+ return `${node.props?.className || ''} ${treeText(node.props?.children, depth + 1)}`
212
+ }
213
+
214
+ function walkNodes(node, visit, depth = 0) {
215
+ if (node === null || node === undefined || depth > 400) return
216
+ if (Array.isArray(node)) {
217
+ for (const child of node) walkNodes(child, visit, depth + 1)
218
+ return
219
+ }
220
+ if (typeof node !== 'object') return
221
+ if (visit(node, depth) === false) return
222
+ if (typeof node.type === 'function') {
223
+ walkNodes(node.type(node.props), visit, depth + 1)
224
+ return
225
+ }
226
+ walkNodes(node.props?.children, visit, depth + 1)
227
+ }
228
+
229
+ const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
230
+
231
+ // Renders the settings page, waits for the mount fetches, jumps to the browser
232
+ // page (5), selects lib-1 in the picker and (optionally) presses reload so the
233
+ // rows are painted. Returns render/rerender helpers plus the collected calls.
234
+ async function openBrowserPage(t, { reload = true, answers = {} } = {}) {
235
+ const rpcCalls = []
236
+ const ctx = fakeCtx({ answers: makeAnswers(answers), rpcCalls })
237
+ globalThis.__dshMemuExports.apply(ctx)
238
+ const settings = registrations.filter((r) => r.slot === 'settings.section').pop()
239
+ assert.ok(settings?.component, 'settings.section was not registered')
240
+ const props = { ctx, t: (key) => key }
241
+ const first = await renderPage(settings.component, props, undefined, { remount: true })
242
+ collectPageHook()
243
+ const cleanups = []
244
+ t.after(() => { for (const cleanup of cleanups.reverse()) cleanup() })
245
+ for (const effect of first.effects) {
246
+ const cleanup = effect.fn()
247
+ if (typeof cleanup === 'function') cleanups.push(cleanup)
248
+ }
249
+ await settle()
250
+ await settle()
251
+
252
+ let tree = (await renderPage(settings.component, props, 5)).tree
253
+ let picker = []
254
+ walkNodes(tree, (node) => {
255
+ const options = Array.isArray(node.props?.children) ? node.props.children : []
256
+ if (node.type === 'select' && options.some((option) => option?.props?.value === 'lib-1')) picker.push(node)
257
+ })
258
+ assert.equal(picker.length, 1, 'the library picker did not render')
259
+ picker[0].props.onChange({ target: { value: 'lib-1' } })
260
+
261
+ const rerender = async () => {
262
+ const { tree, effects } = await renderPage(settings.component, props, 5)
263
+ const nodes = []
264
+ walkNodes(tree, (node) => { nodes.push(node) })
265
+ return { tree, nodes, text: treeText(tree), effects }
266
+ }
267
+ let view = await rerender()
268
+
269
+ if (reload) {
270
+ const reloadButtons = view.nodes.filter((node) => typeof node.type === 'function' && node.props?.children === 'reload')
271
+ assert.equal(reloadButtons.length, 1, 'the reload button did not render')
272
+ reloadButtons[0].props.onClick()
273
+ await settle()
274
+ await settle()
275
+ view = await rerender()
276
+ }
277
+ const findButtons = (label) => view.nodes.filter((node) => typeof node.type === 'function' && node.props?.children === label)
278
+ return { ctx, rpcCalls, rerender, view, findButtons }
279
+ }
280
+
281
+ test.after(() => {
282
+ globalThis.fetch = realFetch
283
+ URL.createObjectURL = realCreateObjectURL
284
+ URL.revokeObjectURL = realRevokeObjectURL
285
+ })
286
+
287
+ test('the JSON export sends the browsed libraryId and reports exportStarted with the filename', async (t) => {
288
+ createdNodes.length = 0
289
+ const { rpcCalls, rerender, findButtons } = await openBrowserPage(t, {
290
+ reload: false,
291
+ answers: { exportLibrary: { filename: 'lib-1.json', base64: Buffer.from('hello').toString('base64') } },
292
+ })
293
+ const exportButtons = findButtons('export')
294
+ assert.equal(exportButtons.length, 1, 'the JSON export button did not render')
295
+ exportButtons[0].props.onClick()
296
+ await settle()
297
+ await settle()
298
+
299
+ const call = rpcCalls.find((entry) => entry.endpoint === 'exportLibrary')
300
+ assert.ok(call, 'exportLibrary was not called')
301
+ assert.equal(call.payload.libraryId, 'lib-1', 'the payload must carry the selected library id')
302
+ assert.equal(call.payload.includeAttachments, false)
303
+ assert.equal(call.payload.delivery, undefined)
304
+ const anchor = createdNodes.find((node) => node.tag === 'a' && node.clicked)
305
+ assert.ok(anchor, 'the download anchor was created and clicked')
306
+ assert.equal(anchor.href, 'blob:fixture')
307
+ assert.equal(anchor.download, 'lib-1.json', 'the fallback download name uses the served filename')
308
+ const { text } = await rerender()
309
+ assert.ok(text.includes('exportStarted'), 'the export notice did not appear')
310
+ assert.ok(!text.includes('exportImages'), 'the renamed copy key leaked back in')
311
+ })
312
+
313
+ test('the ZIP export passes includeAttachments with delivery download and offers the served URL', async (t) => {
314
+ createdNodes.length = 0
315
+ const { rpcCalls, rerender, findButtons } = await openBrowserPage(t, {
316
+ reload: false,
317
+ answers: { exportLibrary: { filename: 'lib-1-with-attachments.zip', downloadUrl: 'http://host/exports/lib-1-with-attachments.zip' } },
318
+ })
319
+ const zipButtons = findButtons('exportZip')
320
+ assert.equal(zipButtons.length, 1, 'the ZIP export button did not render')
321
+ zipButtons[0].props.onClick()
322
+ await settle()
323
+ await settle()
324
+
325
+ const call = rpcCalls.find((entry) => entry.endpoint === 'exportLibrary')
326
+ assert.equal(call.payload.includeAttachments, true, 'the ZIP button must request attachments')
327
+ assert.equal(call.payload.delivery, 'download')
328
+ assert.equal(call.payload.libraryId, 'lib-1')
329
+ const anchor = createdNodes.find((node) => node.tag === 'a' && node.clicked)
330
+ assert.ok(anchor, 'the downloadUrl branch still downloads through an anchor')
331
+ assert.equal(anchor.href, 'http://host/exports/lib-1-with-attachments.zip')
332
+ const { text } = await rerender()
333
+ assert.ok(text.includes('exportStarted'), 'a served URL export also reports exportStarted')
334
+ })
335
+
336
+ test('the ZIP import posts to the selected library id and lands back in that library', async (t) => {
337
+ createdNodes.length = 0
338
+ fetchCalls.length = 0
339
+ const { rpcCalls, rerender, findButtons } = await openBrowserPage(t, { reload: false })
340
+ const importButtons = findButtons('importJson')
341
+ assert.equal(importButtons.length, 1, 'the import button did not render')
342
+ importButtons[0].props.onClick()
343
+ const input = createdNodes.find((node) => node.tag === 'input' && node.type === 'file')
344
+ assert.ok(input, 'the import file picker was created')
345
+ input.files = [{ name: 'lib-1.zip' }]
346
+ await input.onchange()
347
+ await settle()
348
+ await settle()
349
+
350
+ assert.equal(fetchCalls.length, 1, 'the ZIP import must go through the host route')
351
+ assert.ok(
352
+ fetchCalls[0].url.startsWith('/dsh-memu-memory/import/lib-1.zip'),
353
+ `the ZIP URL must use the selected library id, got ${fetchCalls[0].url}`,
354
+ )
355
+ const { text } = await rerender()
356
+ assert.ok(text.includes('importDone'), 'the import success notice did not appear')
357
+ // After a successful import the browser reloads the same library.
358
+ const browsed = rpcCalls.filter((entry) => entry.endpoint === 'browseLibrary')
359
+ assert.ok(browsed.some((entry) => entry.payload.libraryId === 'lib-1'), 'the browser reloaded the imported library')
360
+ })
361
+
362
+ test('each browser row action answers with its own success notice', async (t) => {
363
+ createdNodes.length = 0
364
+ // A server with state: the row actions mutate the served inventory, so the
365
+ // archived tab really gains/loses rows as the test drives it. Function
366
+ // answers must hand back the full RPC envelope (ok/value) themselves.
367
+ const files = structuredClone(BROWSE_FILES)
368
+ const { rpcCalls, rerender, view, findButtons } = await openBrowserPage(t, {
369
+ reload: true,
370
+ answers: {
371
+ browseLibrary: () => ({ ok: true, value: { source: 'engine', files: structuredClone(files) } }),
372
+ addMemory: async () => ({ ok: true, value: { id: 'mem-added' } }),
373
+ updateMemory: async (payload) => {
374
+ const row = files.find((file) => file.id === payload.id)
375
+ if (row) Object.assign(row, payload.patch)
376
+ return { ok: true, value: {} }
377
+ },
378
+ archiveMemory: async (payload) => {
379
+ const row = files.find((file) => file.id === payload.id)
380
+ if (row) row.status = 'archived'
381
+ return { ok: true, value: {} }
382
+ },
383
+ deleteMemory: async (payload) => {
384
+ const at = files.findIndex((file) => file.id === payload.id)
385
+ if (at >= 0) files.splice(at, 1)
386
+ return { ok: true, value: {} }
387
+ },
388
+ },
389
+ })
390
+
391
+ // addMemory → memorySaved
392
+ const contentBox = view.nodes.find((node) => node.type === 'textarea' && node.props?.placeholder === 'memoryContent')
393
+ assert.ok(contentBox, 'the new-memory form did not render')
394
+ contentBox.props.onChange({ target: { value: 'a fresh fact worth keeping' } })
395
+ let next = await rerender()
396
+ const addButtons = next.nodes.filter((node) => typeof node.type === 'function' && node.props?.children === 'add')
397
+ assert.equal(addButtons.length, 1)
398
+ addButtons[0].props.onClick()
399
+ await settle(); await settle()
400
+ next = await rerender()
401
+ assert.ok(next.text.includes('memorySaved'), 'addMemory did not report memorySaved')
402
+ assert.ok(rpcCalls.some((call) => call.endpoint === 'addMemory' && call.payload.libraryId === 'lib-1'))
403
+
404
+ // saveMemory → memorySaved (the row editor needs a non-empty draft first)
405
+ const titleInput = next.nodes.find((node) => node.type === 'input' && node.props?.placeholder === 'name')
406
+ assert.ok(titleInput, 'the row editor did not render')
407
+ titleInput.props.onChange({ target: { value: 'renamed entry' } })
408
+ next = await rerender()
409
+ const saveButtons = next.nodes.filter((node) => typeof node.type === 'function' && node.props?.children === 'save')
410
+ assert.equal(saveButtons.length, 1, 'the row save button did not render')
411
+ saveButtons[0].props.onClick()
412
+ await settle(); await settle()
413
+ next = await rerender()
414
+ assert.ok(next.text.includes('memorySaved'), 'saveMemory did not report memorySaved')
415
+ assert.ok(rpcCalls.some((call) => call.endpoint === 'updateMemory' && call.payload.id === 'mem-live'))
416
+
417
+ // archiveMemory → memoryArchived (the active row's archive button)
418
+ const archiveButtons = next.nodes.filter((node) => typeof node.type === 'function' && node.props?.children === 'archive')
419
+ assert.equal(archiveButtons.length, 1, 'the active row archive button did not render')
420
+ archiveButtons[0].props.onClick()
421
+ await settle(); await settle()
422
+ next = await rerender()
423
+ assert.ok(next.text.includes('memoryArchived'), 'archiveMemory did not report memoryArchived')
424
+ assert.ok(rpcCalls.some((call) => call.endpoint === 'archiveMemory' && call.payload.id === 'mem-live'))
425
+
426
+ // The archived tab now holds both rows; restore → memoryRestored.
427
+ const archivedTab = next.nodes.filter((node) => node.props?.role === 'tab'
428
+ && String(node.props?.children || '').startsWith('browseArchivedTab'))
429
+ assert.equal(archivedTab.length, 1)
430
+ archivedTab[0].props.onClick()
431
+ next = await rerender()
432
+ const restoreButtons = next.nodes.filter((node) => typeof node.type === 'function' && node.props?.children === 'restore')
433
+ assert.equal(restoreButtons.length, 2, 'both archived rows offer restore')
434
+ restoreButtons[1].props.onClick()
435
+ await settle(); await settle()
436
+ next = await rerender()
437
+ assert.ok(next.text.includes('memoryRestored'), 'restoreMemory did not report memoryRestored')
438
+ assert.ok(rpcCalls.some((call) => call.endpoint === 'updateMemory' && call.payload.id === 'mem-gone' && call.payload.patch.status === 'active'))
439
+
440
+ // deleteMemoryForever → memoryDeleted (two-step confirm on the last archived row)
441
+ const armButtons = next.nodes.filter((node) => typeof node.type === 'function' && node.props?.children === 'deleteForever')
442
+ assert.ok(armButtons.length >= 1, 'the archived delete button did not render')
443
+ armButtons[0].props.onClick()
444
+ next = await rerender()
445
+ const confirmButtons = next.nodes.filter((node) => typeof node.type === 'function' && node.props?.children === 'confirmDelete')
446
+ assert.equal(confirmButtons.length, 1, 'the armed confirm did not render')
447
+ confirmButtons[0].props.onClick()
448
+ await settle(); await settle()
449
+ next = await rerender()
450
+ assert.ok(next.text.includes('memoryDeleted'), 'deleteMemoryForever did not report memoryDeleted')
451
+ assert.ok(rpcCalls.some((call) => call.endpoint === 'deleteMemory' && call.payload.id === 'mem-live'))
452
+ })
453
+
454
+ test('the memory wall reports save, archive and bulk outcomes through onNotice', async (t) => {
455
+ createdNodes.length = 0
456
+ const rpcCalls = []
457
+ const ctx = fakeCtx({ answers: makeAnswers(), rpcCalls })
458
+ globalThis.__dshMemuExports.apply(ctx)
459
+ const settings = registrations.filter((r) => r.slot === 'settings.section').pop()
460
+ const props = { ctx, t: (key) => key }
461
+ const first = await renderPage(settings.component, props, undefined, { remount: true })
462
+ collectPageHook()
463
+ for (const effect of first.effects) effect.fn()
464
+ await settle(); await settle()
465
+
466
+ const rerenderAt = async (page) => {
467
+ const { tree, effects } = await renderPage(settings.component, props, page)
468
+ const nodes = []
469
+ walkNodes(tree, (node) => { nodes.push(node) })
470
+ return { tree, nodes, text: treeText(tree), effects }
471
+ }
472
+ // Jump to the wall page and let its own effects fetch; browseWall is
473
+ // effect-driven, unlike the browser page's handler-driven reload.
474
+ let view = await rerenderAt(9)
475
+ for (const effect of view.effects) effect.fn()
476
+ await settle(); await settle()
477
+ view = await rerenderAt(9)
478
+ const wallItems = view.nodes.filter((node) => node.type === 'button' && String(node.props?.className || '').includes('dshm-wall-item'))
479
+ assert.equal(wallItems.length, 1, 'the wall item did not render')
480
+ wallItems[0].props.onClick()
481
+ await settle(); await settle()
482
+ view = await rerenderAt(9)
483
+
484
+ // The drawer is open and editable: save → memorySaved.
485
+ const saveButtons = view.nodes.filter((node) => typeof node.type === 'function' && node.props?.children === 'save')
486
+ assert.equal(saveButtons.length, 1, 'the drawer save button did not render')
487
+ saveButtons[0].props.onClick()
488
+ await settle(); await settle()
489
+ view = await rerenderAt(9)
490
+ assert.ok(view.text.includes('memorySaved'), 'the wall save did not reach the page notice')
491
+
492
+ // Archive from the drawer → memoryArchived.
493
+ const archiveButtons = view.nodes.filter((node) => typeof node.type === 'function' && node.props?.children === 'archive')
494
+ assert.equal(archiveButtons.length, 1, 'the drawer archive button did not render')
495
+ archiveButtons[0].props.onClick()
496
+ await settle(); await settle()
497
+ view = await rerenderAt(9)
498
+ assert.ok(view.text.includes('memoryArchived'), 'the wall archive did not reach the page notice')
499
+
500
+ // Close the drawer, enter multi-select, select all, bulk archive → bulkDone.
501
+ const closeButtons = view.nodes.filter((node) => typeof node.type === 'function' && node.props?.children === 'close')
502
+ assert.ok(closeButtons.length >= 1)
503
+ closeButtons[closeButtons.length - 1].props.onClick()
504
+ view = await rerenderAt(9)
505
+ const multiButtons = view.nodes.filter((node) => typeof node.type === 'function' && node.props?.children === 'multiSelect')
506
+ assert.equal(multiButtons.length, 1, 'the multi-select entry did not render')
507
+ multiButtons[0].props.onClick()
508
+ view = await rerenderAt(9)
509
+ const selectAll = view.nodes.filter((node) => typeof node.type === 'function' && node.props?.children === 'wallSelectAll')
510
+ assert.equal(selectAll.length, 1)
511
+ selectAll[0].props.onClick()
512
+ view = await rerenderAt(9)
513
+ const bulkArchive = view.nodes.filter((node) => typeof node.type === 'function' && node.props?.children === 'archive (1)')
514
+ assert.equal(bulkArchive.length, 1, 'the bulk archive button did not render')
515
+ bulkArchive[0].props.onClick()
516
+ await settle(); await settle()
517
+ view = await rerenderAt(9)
518
+ assert.ok(view.text.includes('bulkDone'), 'the bulk success notice did not appear')
519
+ assert.ok(rpcCalls.some((call) => call.endpoint === 'updateMemory' && call.payload.id === 'mem-wall' && call.payload.patch.status === 'archived'))
520
+ })
@@ -6,6 +6,7 @@
6
6
  // only runs when its page is selected.
7
7
  import test from 'node:test'
8
8
  import assert from 'node:assert/strict'
9
+ import fs from 'node:fs'
9
10
 
10
11
  const registrations = []
11
12
 
@@ -211,14 +212,14 @@ test('the settings page renders every page instead of a blank panel', async () =
211
212
  await new Promise((resolve) => setTimeout(resolve, 0))
212
213
  await new Promise((resolve) => setTimeout(resolve, 0))
213
214
 
214
- for (let page = 0; page < 11; page++) {
215
+ for (let page = 0; page < 12; page++) {
215
216
  const { tree } = await renderPage(settings.component, { ctx, t: (key) => key }, page)
216
217
  const text = treeText(tree)
217
218
  assert.match(String(tree.props?.className), /dshm-root/, `page ${page} did not render the shell`)
218
219
  // The rail is the nav: every page label has to be there. Counting the shared
219
220
  // dshm-nav-item class instead would also pick up the in-page tab strips (memory
220
221
  // backend, prompt scope, browser view), which use the same widget on purpose.
221
- const NAV = ['pageOverview', 'pageConnection', 'pageSettings', 'pageFolders', 'pageLibraries', 'pageBrowser', 'pageGlobal', 'pagePrompts', 'pageStats', 'pageMemories', 'pageLogs']
222
+ const NAV = ['pageOverview', 'pageConnection', 'pageSettings', 'pageFolders', 'pageLibraries', 'pageBrowser', 'pageGlobal', 'pagePrompts', 'pageStats', 'pageMemories', 'pageLogs', 'pageLexicon']
222
223
  assert.ok(NAV.every((key) => text.includes(key)), `page ${page} lost a nav entry`)
223
224
  assert.equal((text.match(/dshm-nav-ind/g) || []).length, 1, `page ${page} lost or duplicated its rail indicator`)
224
225
  assert.match(text, /dshm-page/, `page ${page} lost the content pane`)
@@ -258,6 +259,21 @@ test('the sidebar tab renders with the two-layer header', async () => {
258
259
  assert.match(text, /dmCoding/, 'domain picker did not render its options')
259
260
  })
260
261
 
262
+ test('the sidebar context-scope card cannot be flex-shrunk and keeps a two-column field grid', () => {
263
+ const src = fs.readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
264
+ assert.match(src, /className: 'dshm-tab-body'/, 'the sidebar body needs its non-shrinking class')
265
+ assert.match(src, /\.dshm-tab-body>\*\{flex-shrink:0\}/, 'sidebar sections must scroll instead of shrinking')
266
+ assert.match(src, /\.dshm-scope\{width:100%;margin:0 0 10px;[^}]*flex-shrink:0/, 'the scope card must span the sidebar content width and not collapse')
267
+ assert.match(src, /\.dshm-scope-form\{[^}]*grid-template-columns:repeat\(2,minmax\(0,1fr\)\)/, 'four scope fields must wrap into two readable columns')
268
+ })
269
+
270
+ test('the sidebar run log keeps five rows and links its header to the WebUI logs page', () => {
271
+ const src = fs.readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
272
+ assert.match(src, /runLog\.slice\(0, 5\)/, 'the sidebar shows the five newest runs, not an unbounded list')
273
+ assert.match(src, /openWebUiPage\('logs'\)/, 'the run-log header must open the WebUI logs page')
274
+ assert.match(src, /runLogOpen/, 'the jump needs a visible label')
275
+ })
276
+
261
277
  // The browser row is the only place a human can see that a memory is marked
262
278
  // "never say this out loud" or "secret" without opening the JSON. The badges are
263
279
  // driven by fields that used to be stored and ignored, so a regression here is
@@ -484,7 +500,7 @@ test('every pick-one-of-these control uses the same tab strip', async () => {
484
500
  await new Promise((resolve) => setTimeout(resolve, 0))
485
501
 
486
502
  const orphans = []
487
- for (let page = 0; page < 11; page++) {
503
+ for (let page = 0; page < 12; page++) {
488
504
  const { tree } = await renderPage(settings.component, { ctx, t: (key) => key }, page)
489
505
  walkNodes(tree, (node) => {
490
506
  if (node.type !== 'button') return
@@ -208,7 +208,12 @@ test('a tool surface without memory tools is unavailable, and nothing is forked'
208
208
  }
209
209
  })
210
210
 
211
- test('a PTC-only tool surface is reported as PTC and not as a generic miss', async () => {
211
+ test('a PTC-only tool surface is not a refusal: the fork proceeds and only an honest receipt failure is reported', async () => {
212
+ // 2026-10-31 口径变更:PTC 会话的 header 只有 run_code(或任意 PTC transport 名),
213
+ // 其余工具走 run_code 程序里的 SDK 绑定 —— header 名单不承载"能不能写"。
214
+ // 所以 PTC 不再产生 unavailable:fork 照常创建,写入能力由 scope 挂载与回执裁决。
215
+ // 这个 fake 的 worker 不写回执,所以结果是一次诚实的 receipt-missing 失败,
216
+ // 而不是"从不尝试"。
212
217
  const sessionId = 'cred-tools-ptc'
213
218
  state.ensureSession(sessionId, { cwd: path.join(home, 'proj-ptc'), agentPreset: 'standard' })
214
219
  const ctx = host({
@@ -216,15 +221,21 @@ test('a PTC-only tool surface is reported as PTC and not as a generic miss', asy
216
221
  events: [
217
222
  { seq: 0, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: '用 PTC 跑一下' }] } },
218
223
  { seq: 1, type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } },
219
- { seq: 2, type: 'tool/ptc-dispatch', data: {} },
224
+ { seq: 2, type: 'tool/ptc-dispatch', data: { name: 'ptc_exec' } },
220
225
  { seq: 3, type: 'request/header', data: { header: { config: { provider: 'p', model: 'm' }, tools: [{ name: 'ptc_exec' }] } } },
221
226
  ],
222
227
  })
223
228
  const result = await requestPrivateConsolidation(ctx, sessionId, { force: true })
229
+ assert.equal(ctx.rec.created.length, 1, 'a PTC surface no longer blocks the fork')
224
230
  assert.equal(result.ok, false)
225
- assert.equal(result.reasonCode, 'worker-tools-ptc-only')
226
- assert.equal(result.ptc, true)
227
- assert.equal(ctx.rec.created.length, 0)
231
+ assert.equal(result.reasonCode, 'receipt-missing', 'the fake worker gave no receipt: an honest run failure, not a refusal')
232
+ // 任务书按 PTC 口径写:点名 run_code transport 与 SDK 调用形态。
233
+ const followup = ctx.rec.followedUp[0]
234
+ const brief = Array.isArray(followup?.content)
235
+ ? followup.content.map((block) => block?.text || '').join('\n')
236
+ : String(followup || '')
237
+ assert.match(brief, /run_code/)
238
+ assert.match(brief, /tools\.memu_private_remember/)
228
239
  })
229
240
 
230
241
  test('a parent that changes its library mid-run voids the credential', async () => {