dsh-memu-memory 1.2.1 → 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 (87) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +12 -5
  3. package/README.zh.md +4 -2
  4. package/docs/contributing.md +16 -3
  5. package/docs/host-api.md +3 -1
  6. package/docs/memory-semantics.md +21 -2
  7. package/docs/troubleshooting.md +42 -4
  8. package/lib/backup.js +24 -2
  9. package/lib/binding-diagnostics.js +2 -2
  10. package/lib/client.js +1361 -146
  11. package/lib/consolidation-worker.js +30 -8
  12. package/lib/curator-evidence.js +131 -0
  13. package/lib/curator-policy.js +81 -0
  14. package/lib/curator.js +1252 -91
  15. package/lib/cycle.js +3 -0
  16. package/lib/embedding-download.js +39 -4
  17. package/lib/engine.js +6 -3
  18. package/lib/global-graph.js +282 -0
  19. package/lib/graph.js +51 -9
  20. package/lib/inbound.js +19 -0
  21. package/lib/inject.js +277 -17
  22. package/lib/lexicon.js +1115 -0
  23. package/lib/local-vector-service.js +8 -6
  24. package/lib/managed-runtime.js +33 -2
  25. package/lib/memory.js +282 -17
  26. package/lib/memu.js +2 -2
  27. package/lib/migrate.js +83 -2
  28. package/lib/private-links.js +587 -0
  29. package/lib/private.js +32 -3
  30. package/lib/prompts/global-curator.md +76 -19
  31. package/lib/prompts/private-consolidation.coding.md +4 -1
  32. package/lib/prompts/private-consolidation.md +3 -1
  33. package/lib/prompts/private-consolidation.mixed.md +3 -1
  34. package/lib/prompts/private-consolidation.rp.md +3 -1
  35. package/lib/prompts.js +1 -1
  36. package/lib/rpc.js +577 -9
  37. package/lib/state.js +4 -1
  38. package/lib/text-retrieval.js +661 -0
  39. package/lib/text-similarity.js +36 -18
  40. package/lib/tools.js +168 -10
  41. package/lib/webui/standalone.css +17 -0
  42. package/lib/webui/standalone.js +2 -0
  43. package/package.json +1 -1
  44. package/scripts/acceptance-local.mjs +8 -5
  45. package/scripts/curator-pair-calibrate.mjs +259 -0
  46. package/scripts/lexicon-calibrate.mjs +230 -0
  47. package/scripts/lexicon-dedupe.mjs +318 -0
  48. package/tests/absorb-review.test.mjs +353 -0
  49. package/tests/appendix-b2.test.mjs +2 -2
  50. package/tests/client-export.test.mjs +520 -0
  51. package/tests/client-render.test.mjs +19 -3
  52. package/tests/consolidation-fork.test.mjs +14 -12
  53. package/tests/consolidation-snapshot.test.mjs +39 -0
  54. package/tests/curator-absorb.test.mjs +443 -0
  55. package/tests/curator-calibrate.test.mjs +173 -0
  56. package/tests/curator-clusters.test.mjs +281 -0
  57. package/tests/curator-commit.test.mjs +52 -5
  58. package/tests/curator-evidence.test.mjs +102 -0
  59. package/tests/curator-link.test.mjs +316 -0
  60. package/tests/curator-pairing.test.mjs +252 -0
  61. package/tests/curator-probe.test.mjs +15 -6
  62. package/tests/curator-review-mode.test.mjs +314 -0
  63. package/tests/disclosure.test.mjs +2 -1
  64. package/tests/fixtures/curator-pairs/labeled.v1.jsonl +90 -0
  65. package/tests/fixtures/lexicon-stub-hooks.mjs +15 -0
  66. package/tests/fixtures/lexicon-stub.mjs +284 -0
  67. package/tests/graph-cascade.test.mjs +35 -2
  68. package/tests/graph.test.mjs +96 -0
  69. package/tests/help-page.test.mjs +415 -0
  70. package/tests/inbound.test.mjs +6 -3
  71. package/tests/inject-guide.test.mjs +28 -0
  72. package/tests/inject.test.mjs +157 -5
  73. package/tests/lexicon-calibrate.test.mjs +48 -0
  74. package/tests/lexicon-dedupe.test.mjs +171 -0
  75. package/tests/lexicon-frequency.test.mjs +179 -0
  76. package/tests/lexicon-inject.test.mjs +410 -0
  77. package/tests/lexicon-perf.test.mjs +436 -0
  78. package/tests/lexicon-ui.test.mjs +547 -0
  79. package/tests/lexicon.test.mjs +922 -0
  80. package/tests/managed-runtime.test.mjs +32 -0
  81. package/tests/private-links.test.mjs +445 -0
  82. package/tests/prompts.test.mjs +35 -0
  83. package/tests/recall-dedup.test.mjs +33 -4
  84. package/tests/tool-schema.test.mjs +1 -1
  85. package/tests/tools.test.mjs +112 -3
  86. package/tests/webui-auth.test.mjs +348 -2
  87. package/tests/webui.test.mjs +188 -0
@@ -70,7 +70,7 @@ test('tip-of-tongue is about reachability, not importance', () => {
70
70
  assert.equal(memory.isTipOfTongue({ layer: 'long_term', confidence: 0.9, strength: 1, createdAt: daysAgo(1) }), false)
71
71
  })
72
72
 
73
- test('injection gives the gist, not the wording, when a memory is half-remembered', async () => {
73
+ test('recall shows the title of a half-remembered memory, never its wording', async () => {
74
74
  const { sessionId, binding, exec } = setup('tongue')
75
75
  const content = '周四晚上在厨房,他一边擦杯子一边说那句关于不要再改需求的话,说完就把杯子放回架子上,转身去阳台抽了一支烟,很久都没有再开口,客厅的电视一直在放同一档节目。'
76
76
  const saved = await memory.privateRemember({ exec, content, title: '厨房里那句话', confidence: 0.3 })
@@ -79,7 +79,7 @@ test('injection gives the gist, not the wording, when a memory is half-remembere
79
79
  state.savePrivateIndex(binding.libraryId, index)
80
80
 
81
81
  const out = await inject.buildRecall(binding, '厨房 那句话', 1, { ...state.getSettings(), privateTopK: 3, relevanceThreshold: 0 }, exec, sessionId)
82
- assert.ok(out.text.includes('只记得大概'), 'the model is told it only remembers the gist')
82
+ assert.ok(out.text.includes('厨房里那句话'), 'the title is the directory entry')
83
83
  assert.ok(!out.text.includes('客厅的电视一直在放同一档节目'), 'the exact wording must not be handed over as if quoted')
84
84
  assert.ok(out.text.includes(saved.id), 'the id and the tool are how the original is still reachable')
85
85
  })
@@ -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