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
@@ -39,22 +39,25 @@ test('all v1 tools register', () => {
39
39
  const names = tools.map((t) => t.name)
40
40
  for (const name of [
41
41
  'memu_private_link',
42
+ 'memu_private_link_suggest',
42
43
  'memu_private_remember',
43
44
  'memu_private_recall',
44
45
  'memu_private_list',
45
46
  'memu_private_update',
46
47
  'memu_private_archive',
47
48
  'memu_private_unpin',
49
+ 'memu_lexicon_search',
50
+ 'memu_lexicon_propose',
48
51
  'memu_global_recall',
49
52
  'memu_global_list',
50
53
  'memu_status',
51
54
  ]) {
52
55
  assert.ok(names.includes(name), `missing ${name}`)
53
56
  }
54
- // Exactly ten, not "at least these nine": a schema the host DSL refuses used to
57
+ // Exactly thirteen, not "at least these twelve": a schema the host DSL refuses used to
55
58
  // abort registerTools() before any register() ran, so counting is the assertion
56
59
  // that actually catches a total registration failure.
57
- assert.equal(names.length, 10, `expected 10 tools, got ${names.length}: ${names.join(', ')}`)
60
+ assert.equal(names.length, 13, `expected 13 tools, got ${names.length}: ${names.join(', ')}`)
58
61
  dispose()
59
62
  })
60
63
 
@@ -65,7 +68,113 @@ test('status works without an engine', async () => {
65
68
  assert.match(status, /library:/)
66
69
  // The registration count is the visible signal for a refused schema: without it
67
70
  // a host that rejects the tool and a plugin that never had it look identical.
68
- assert.match(status, /tools: 10\/10 registered/)
71
+ assert.match(status, /tools: 13\/13 registered/)
72
+ dispose()
73
+ })
74
+
75
+ test('title descriptions explain the permanent index rule', () => {
76
+ const { tools, dispose } = fakeCtx()
77
+ const remember = tools.find((t) => t.name === 'memu_private_remember')
78
+ const update = tools.find((t) => t.name === 'memu_private_update')
79
+ for (const tool of [remember, update]) {
80
+ const title = tool.parameters.title.description
81
+ assert.match(title, /8-40/, `${tool.name} title carries the 8-40 rule`)
82
+ assert.match(title, /name the concrete fact|title is a permanent index|anchor shows only titles/, `${tool.name} title says it is an index`)
83
+ assert.match(title, /empty label/, `${tool.name} title forbids empty labels`)
84
+ }
85
+ assert.match(remember.parameters.title.description, /WITHOUT the body/, 'the anchor shows titles without bodies')
86
+ const link = tools.find((t) => t.name === 'memu_private_link')
87
+ assert.match(link.description, /memu_private_link_suggest/, 'link is named as the adjudication tool for candidates')
88
+ assert.match(link.parameters.entity.description, /reused/, 'a named entity can be reused instead of duplicated')
89
+ const search = tools.find((t) => t.name === 'memu_lexicon_search')
90
+ assert.match(search.description, /ACTIVE/)
91
+ assert.match(search.description, /archived entries are excluded/)
92
+ const propose = tools.find((t) => t.name === 'memu_lexicon_propose')
93
+ assert.match(propose.description, /ACTIVE immediately/)
94
+ assert.doesNotMatch(propose.description, /stored as pending|needs human review before/, 'a model proposal is no longer pending')
95
+ assert.match(propose.parameters.term.description, /index key/)
96
+ assert.match(propose.parameters.meaning.description, /explanation/)
97
+ assert.match(tools.find((t) => t.name === 'memu_private_link_suggest').parameters.ids.description, /Required/)
98
+ dispose()
99
+ })
100
+
101
+ test('lexicon propose writes an active entry immediately; search only sees active terms', async () => {
102
+ const { tools, dispose } = fakeCtx()
103
+ const cwd = path.join(home, 'project-lex')
104
+ const execLex = { agent: { session: { header: { id: 'tools-lex', cwd, agentPreset: 'coding' } } } }
105
+ const binding = state.ensureSession('tools-lex', { cwd, agentPreset: 'coding' })
106
+ const lexicon = await import('../lib/lexicon.js')
107
+ const propose = tools.find((t) => t.name === 'memu_lexicon_propose')
108
+ const search = tools.find((t) => t.name === 'memu_lexicon_search')
109
+
110
+ const written = await propose.execute({ term: '糖刀', kind: 'slang', meaning: '又甜又扎人的说话方式。' }, execLex)
111
+ assert.match(written, /status=active/)
112
+ assert.doesNotMatch(written, /pending|待审/)
113
+ const storedPair = Object.entries(lexicon.loadLexicon(binding.libraryId).terms).find(([, entry]) => entry.term === '糖刀')
114
+ const stored = storedPair[1]
115
+ assert.equal(stored.status, 'active', 'model-proposal lands as active, no review step')
116
+ assert.equal(stored.source, 'model-proposal')
117
+
118
+ // Writing the same normalized term again updates the active entry, it does not fork it.
119
+ const again = await propose.execute({ term: '糖刀', kind: 'slang', meaning: '又甜又扎人的说话方式。', usage: '撒娇时用。' }, execLex)
120
+ assert.match(again, /已更新现有生效词条/)
121
+ assert.equal(Object.values(lexicon.loadLexicon(binding.libraryId).terms).filter((entry) => entry.term === '糖刀').length, 1)
122
+
123
+ const hit = await search.execute({ query: '糖刀' }, execLex)
124
+ assert.match(hit, /糖刀/)
125
+
126
+ // Archived terms leave both the search surface and injection.
127
+ lexicon.archiveLexiconTerm({ libraryId: binding.libraryId, termId: storedPair[0] })
128
+ const archived = await search.execute({ query: '糖刀' }, execLex)
129
+ assert.match(archived, /没有命中/)
130
+ // Writing the archived term again is not the model's call: it reports the entry was not changed.
131
+ const notOwned = await propose.execute({ term: '糖刀', kind: 'slang', meaning: '再次写入。' }, execLex)
132
+ assert.match(notOwned, /未改动/)
133
+ assert.match(notOwned, /已归档或归用户管理/)
134
+ assert.equal(Object.values(lexicon.loadLexicon(binding.libraryId).terms).find((entry) => entry.term === '糖刀').status, 'archived')
135
+
136
+ const refused = await propose.execute({ term: '别离开我', kind: 'trigger', meaning: '离别场景的触发词。' }, execLex)
137
+ assert.match(refused, /sensitive trigger/, 'a sensitive trigger stays user-managed')
138
+ dispose()
139
+ })
140
+
141
+ test('link suggest applies high-confidence links and returns only uncertain candidates', async () => {
142
+ const { tools, dispose } = fakeCtx()
143
+ const cwd = path.join(home, 'project-links')
144
+ const execLinks = { agent: { session: { header: { id: 'tools-links', cwd, agentPreset: 'rp' } } } }
145
+ const binding = state.ensureSession('tools-links', { cwd, agentPreset: 'rp' })
146
+ state.savePrivateIndex(binding.libraryId, { version: 1, entries: {
147
+ 'mem-a': { title: '猫娘yuike', content: 'yuike 是一只住在窗台的猫娘,喜欢晒太阳。', status: 'active', visibility: 'shared', layer: 'long_term' },
148
+ 'mem-b': { title: 'yuike名字的由来', content: '这个名字来自小时候养过的一只白猫。', status: 'active', visibility: 'shared', layer: 'long_term' },
149
+ } })
150
+ const suggest = tools.find((t) => t.name === 'memu_private_link_suggest')
151
+ assert.equal(suggest.isConcurrencySafe(), false, 'suggest writes links, so it is not a concurrent read')
152
+
153
+ const unbound = await suggest.execute({ ids: ['mem-a'] }, {
154
+ agent: { session: { header: { id: 'tools-links-unbound', cwd: path.join(home, 'project-links-unbound') } } },
155
+ })
156
+ assert.match(unbound, /没有绑定记忆库/)
157
+ const empty = await suggest.execute({ ids: [] }, execLinks)
158
+ assert.match(empty, /1-20/)
159
+
160
+ const payload = JSON.parse(await suggest.execute({ ids: ['mem-a'] }, execLinks))
161
+ assert.deepEqual(Object.keys(payload).sort(), ['autoLinked', 'candidates'], 'the tool answers with the two-field contract only')
162
+ assert.deepEqual(payload.candidates, [], 'the yuike pair is high confidence, so nothing is handed over')
163
+ assert.equal(payload.autoLinked.length, 3, 'relation link plus one hub link per memory')
164
+ for (const edge of payload.autoLinked) {
165
+ assert.deepEqual(Object.keys(edge).sort(), ['from', 'kind', 'to'], 'no bodies or notes in the compact payload')
166
+ }
167
+ assert.ok(payload.autoLinked.some((edge) => edge.from === 'mem-a' && edge.to === 'mem-b' && edge.kind === 'relation'))
168
+ assert.ok(payload.autoLinked.some((edge) => edge.from === 'mem-a' && edge.kind === 'person'))
169
+ // The auto source survives into the graph the UI reads.
170
+ const graph = await import('../lib/graph.js')
171
+ const view = graph.getGraph({ libraryId: binding.libraryId, focusId: 'mem-a', depth: 1 })
172
+ assert.ok(view.edges.some((edge) => edge.from === 'mem-a' && edge.to === 'mem-b' && edge.source === 'auto'))
173
+
174
+ // Running it again is idempotent: no new links, same empty candidate list.
175
+ const rerun = JSON.parse(await suggest.execute({ ids: ['mem-a', 'mem-b'] }, execLinks))
176
+ assert.deepEqual(rerun.autoLinked, [])
177
+ assert.deepEqual(rerun.candidates, [])
69
178
  dispose()
70
179
  })
71
180
 
@@ -25,12 +25,25 @@ const clientJs = standaloneAsset('/dsh-memu-memory/ui-assets/client.js')
25
25
  const iconPng = standaloneAsset('/dsh-memu-memory/ui-assets/icon.png')
26
26
 
27
27
  const routeSpecs = []
28
- const auth = { decision: undefined, calls: 0 }
28
+ // callbackMode: 'present' | 'throw' | 'absent' — 'absent' models a host that
29
+ // does not implement authorizeLibrary at all (typeof check must fail).
30
+ const auth = { decision: undefined, calls: 0, libraryDecision: true, libraryCalls: 0, libraryRequests: [], callbackMode: 'present' }
29
31
  registerRpc({
30
32
  inject: (_deps, build) => build({
31
33
  effect: (fn) => { const dispose = fn(); return typeof dispose === 'function' ? dispose : () => {} },
32
34
  webServer: { register: (spec) => { routeSpecs.push(spec); return () => {} } },
33
- connection: { requestRejection: () => { auth.calls += 1; return auth.decision } },
35
+ connection: {
36
+ requestRejection: () => { auth.calls += 1; return auth.decision },
37
+ get authorizeLibrary() {
38
+ if (auth.callbackMode === 'absent') return undefined
39
+ return async (req, details) => {
40
+ auth.libraryCalls += 1
41
+ auth.libraryRequests.push({ req, ...details })
42
+ if (auth.callbackMode === 'throw') throw new Error('host authorization unavailable')
43
+ return auth.libraryDecision
44
+ }
45
+ },
46
+ },
34
47
  logger: {},
35
48
  }),
36
49
  })
@@ -243,6 +256,53 @@ test('GET-side RPC reads: 401/403 return before the private index is read', asyn
243
256
  }
244
257
  })
245
258
 
259
+ test('library authorization: session A cannot address library B, and standalone callback fails closed', async () => {
260
+ const other = state.createLibrary({ name: 'auth-other', cwd: path.join(home, 'auth-other') })
261
+ state.setSessionBinding('auth-session-other', { libraryId: other.id, useLibrary: true, scopeKey: other.scopeKey })
262
+ for (const prefix of prefixes) {
263
+ auth.callbackMode = 'present'
264
+ auth.libraryDecision = true
265
+ auth.libraryCalls = 0
266
+ auth.libraryRequests.length = 0
267
+ const cross = await send(prefix.route, prefix.base + '/getMemory', {
268
+ method: 'POST',
269
+ body: envelope('getMemory', { libraryId: other.id, sessionId, id: 'mem-auth-sentinel' }),
270
+ })
271
+ assert.equal(cross.res.status, 403)
272
+ assert.equal(cross.res.text, JSON.stringify({ ok: false, error: { code: 'dsh-memu-memory/forbidden', message: 'forbidden', details: {} } }))
273
+ assert.equal(cross.pulls.length, 1, 'JSON body may be parsed before route authorization')
274
+ assert.equal(auth.libraryCalls, 0, 'bound sessions must not fall back to host callback')
275
+ assert.deepEqual(cross.reads, [])
276
+ assert.deepEqual(cross.writes, [])
277
+
278
+ auth.libraryDecision = false
279
+ auth.libraryCalls = 0
280
+ const denied = await send(prefix.route, prefix.base + '/getMemory', {
281
+ method: 'POST',
282
+ body: envelope('getMemory', { libraryId: other.id }),
283
+ })
284
+ assert.equal(denied.res.status, 403)
285
+ assert.equal(auth.libraryCalls, 1)
286
+ assert.equal(denied.reads.length, 0)
287
+ assert.equal(denied.writes.length, 0)
288
+
289
+ auth.libraryDecision = true
290
+ auth.callbackMode = 'throw'
291
+ auth.libraryCalls = 0
292
+ const thrown = await send(prefix.route, prefix.base + '/getMemory', {
293
+ method: 'POST', poison: false,
294
+ body: envelope('getMemory', { libraryId: other.id }),
295
+ })
296
+ assert.equal(thrown.res.status, 403)
297
+ assert.equal(auth.libraryCalls, 1)
298
+ assert.equal(thrown.reads.length, 0)
299
+ assert.equal(thrown.writes.length, 0)
300
+
301
+ auth.callbackMode = 'present'
302
+ auth.libraryDecision = true
303
+ }
304
+ })
305
+
246
306
  test('attachment thumbnails: 401/403 precede authorization and attachment-pool reads', async () => {
247
307
  const attachmentLibrary = state.createLibrary({ name: 'auth-attachment', cwd: path.join(home, 'auth-attachment') })
248
308
  const attachmentSession = 'auth-attachment-session'
@@ -452,3 +512,289 @@ test('unsupported methods still authenticate first: 401/403 before 405', async (
452
512
  assert.equal((await send(prefix.route, prefix.base + '/listLibraries', { method: 'GET' })).res.status, 405)
453
513
  }
454
514
  })
515
+
516
+ // §10 P0: one representative per audited operation category. The table drives
517
+ // every variant, so a new endpoint added to LIBRARY_ROUTE_OPERATIONS with the
518
+ // wrong operation (or none) fails here instead of shipping unverified.
519
+ const MATRIX_SHA = 'a'.repeat(64)
520
+ const matrixRows = [
521
+ { endpoint: 'getMemory', operation: 'read', payload: (lib) => ({ libraryId: lib.id, id: 'mem-matrix-scratch' }) },
522
+ { endpoint: 'browseWall', operation: 'read', payload: (lib) => ({ libraryId: lib.id }) },
523
+ { endpoint: 'getStats', operation: 'read', payload: (lib) => ({ libraryId: lib.id }) },
524
+ { endpoint: 'addMemory', operation: 'write', payload: (lib) => ({ libraryId: lib.id, content: 'matrix write body' }) },
525
+ { endpoint: 'updateMemory', operation: 'write', payload: (lib) => ({ libraryId: lib.id, id: 'mem-matrix-scratch', patch: { title: 'matrix retitled' } }) },
526
+ { endpoint: 'archiveMemory', operation: 'archive', payload: (lib) => ({ libraryId: lib.id, id: 'mem-matrix-scratch' }) },
527
+ { endpoint: 'deleteMemory', operation: 'delete', payload: (lib) => ({ libraryId: lib.id, id: 'mem-matrix-scratch' }) },
528
+ { endpoint: 'renameLibrary', operation: 'rename', payload: (lib) => ({ libraryId: lib.id, name: 'matrix rename' }) },
529
+ { endpoint: 'restoreLibrary', operation: 'restore', payload: (lib) => ({ libraryId: lib.id }) },
530
+ { endpoint: 'importMemories', operation: 'import', payload: (lib) => ({ libraryId: lib.id, memories: [] }) },
531
+ { endpoint: 'exportLibrary', operation: 'export', payload: (lib) => ({ libraryId: lib.id }) },
532
+ { endpoint: 'getAttachment', operation: 'attachment.read', payload: (lib) => ({ libraryId: lib.id, sha256: MATRIX_SHA }) },
533
+ { endpoint: 'listReviews', operation: 'read', payload: (lib) => ({ libraryId: lib.id }) },
534
+ { endpoint: 'resolveReview', operation: 'write', payload: (lib) => ({ libraryId: lib.id, id: 'mem-matrix-scratch', action: 'reject' }) },
535
+ { endpoint: 'lexiconList', operation: 'read', payload: (lib) => ({ libraryId: lib.id }) },
536
+ { endpoint: 'lexiconAdd', operation: 'write', payload: (lib) => ({ libraryId: lib.id, term: 'matrix term', kind: 'term', meaning: 'matrix meaning' }) },
537
+ // §2.3:lexiconReview 删除,lifecycle 变成 archive / restore 两个普通 write;
538
+ // 授权模型不动(会话绑定优先、standalone 走 host callback)。
539
+ { endpoint: 'lexiconArchive', operation: 'write', payload: (lib) => ({ libraryId: lib.id, termId: 'lex-matrix-scratch' }) },
540
+ { endpoint: 'lexiconRestore', operation: 'write', payload: (lib) => ({ libraryId: lib.id, termId: 'lex-matrix-scratch' }) },
541
+ ]
542
+
543
+ function seedMatrixScratch() {
544
+ state.updatePrivateIndex(library.id, (index) => {
545
+ index.entries['mem-matrix-scratch'] = {
546
+ title: 'matrix scratch', content: 'matrix scratch body', status: 'active', layer: 'long_term',
547
+ visibility: 'shared', disclosure: 'can_tell', source: 'user_said',
548
+ createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
549
+ }
550
+ })
551
+ state.flushState()
552
+ }
553
+
554
+ test('operation matrix: every library route denies A→B, allows A→A, and gates standalone on the host callback', async () => {
555
+ const libB = state.createLibrary({ name: 'auth-matrix-b', cwd: path.join(home, 'auth-matrix-b') })
556
+ for (const prefix of prefixes) {
557
+ seedMatrixScratch()
558
+ for (const row of matrixRows) {
559
+ const post = (payload) => send(prefix.route, prefix.base + '/' + row.endpoint, { method: 'POST', body: envelope(row.endpoint, payload) })
560
+
561
+ // Session A → library B: 403, the host callback is never consulted, and
562
+ // the body (already parsed) is the only thing touched before the denial.
563
+ auth.callbackMode = 'present'
564
+ auth.libraryDecision = true
565
+ auth.libraryCalls = 0
566
+ const cross = await post({ ...row.payload(libB), sessionId })
567
+ assert.equal(cross.res.status, 403, `${prefix.name} ${row.endpoint}: session A must not address library B`)
568
+ assert.equal(cross.res.text, JSON.stringify({ ok: false, error: { code: 'dsh-memu-memory/forbidden', message: 'forbidden', details: {} } }))
569
+ assert.equal(cross.pulls.length, 1, 'route authorization happens after the envelope parse')
570
+ assert.equal(auth.libraryCalls, 0, `${row.endpoint}: a bound session must not fall back to the host callback`)
571
+
572
+ // Session A → library A: past the route gate. Business errors, when the
573
+ // payload is not worth making valid, still come back inside a 200.
574
+ const own = await post({ ...row.payload(library), sessionId })
575
+ assert.equal(own.res.status, 200, `${prefix.name} ${row.endpoint}: session A on its own library must pass the route gate`)
576
+ assert.equal(auth.libraryCalls, 0)
577
+
578
+ // Standalone (no session): exactly one authorizeLibrary call that carries
579
+ // the audited operation and the target library; only strict true passes.
580
+ auth.libraryRequests.length = 0
581
+ auth.libraryDecision = true
582
+ auth.libraryCalls = 0
583
+ const granted = await post(row.payload(libB))
584
+ assert.equal(granted.res.status, 200, `${prefix.name} ${row.endpoint}: host callback true must grant the standalone request`)
585
+ assert.equal(auth.libraryCalls, 1)
586
+ const request = auth.libraryRequests.at(-1)
587
+ assert.equal(request.libraryId, libB.id, `${row.endpoint}: callback must see the target library`)
588
+ assert.equal(request.operation, row.operation, `${row.endpoint}: callback must see the audited operation`)
589
+
590
+ auth.libraryDecision = false
591
+ auth.libraryCalls = 0
592
+ const denied = await post(row.payload(libB))
593
+ assert.equal(denied.res.status, 403, `${prefix.name} ${row.endpoint}: callback false must deny`)
594
+ assert.equal(auth.libraryCalls, 1)
595
+
596
+ auth.callbackMode = 'throw'
597
+ auth.libraryDecision = true
598
+ auth.libraryCalls = 0
599
+ const thrown = await post(row.payload(libB))
600
+ assert.equal(thrown.res.status, 403, `${prefix.name} ${row.endpoint}: a thrown callback must deny`)
601
+
602
+ auth.callbackMode = 'absent'
603
+ auth.libraryCalls = 0
604
+ const absent = await post(row.payload(libB))
605
+ assert.equal(absent.res.status, 200, `${prefix.name} ${row.endpoint}: a host without the callback falls back to the HTTP boundary (2026-11-01 flip)`)
606
+ assert.equal(auth.libraryCalls, 0)
607
+ auth.callbackMode = 'present'
608
+ }
609
+ }
610
+ })
611
+
612
+ test('removed lexiconReview is no longer a library route and does not dispatch', async () => {
613
+ for (const prefix of prefixes) {
614
+ auth.callbackMode = 'present'
615
+ auth.libraryDecision = true
616
+ auth.libraryCalls = 0
617
+ const res = await send(prefix.route, prefix.base + '/lexiconReview', {
618
+ method: 'POST',
619
+ body: envelope('lexiconReview', { libraryId: library.id, termId: 'lex-matrix-scratch', action: 'approve' }),
620
+ })
621
+ assert.equal(res.res.status, 200, 'an unknown method is answered as a business error, not routed')
622
+ assert.equal(auth.libraryCalls, 0, 'lexiconReview must not be authorized as a library route anymore')
623
+ const json = JSON.parse(res.res.text)
624
+ assert.equal(json.result.ok, false)
625
+ assert.match(json.result.error.message, /unknown endpoint/)
626
+ }
627
+ // 401/403 仍然先于方法存在性:被拒的 review 请求连 dispatch 都到不了。
628
+ for (const prefix of prefixes) {
629
+ for (const code of [401, 403]) {
630
+ assertRejected(await send(prefix.route, prefix.base + '/lexiconReview', {
631
+ method: 'POST', poison: true, rejection: code,
632
+ body: envelope('lexiconReview', { libraryId: library.id, termId: 'lex-matrix-scratch', action: 'approve' }),
633
+ }), code)
634
+ }
635
+ }
636
+ })
637
+
638
+ test('session-bound resolution: write/archive without an explicit libraryId resolve from the persisted binding', async () => {
639
+ const rows = [
640
+ { endpoint: 'updateMemory', payload: (session) => ({ sessionId: session, id: 'mem-matrix-scratch', patch: { title: 'fallback retitled' } }) },
641
+ { endpoint: 'archiveMemory', payload: (session) => ({ sessionId: session, id: 'mem-matrix-scratch' }) },
642
+ ]
643
+ for (const prefix of prefixes) {
644
+ seedMatrixScratch()
645
+ for (const row of rows) {
646
+ const post = (payload) => send(prefix.route, prefix.base + '/' + row.endpoint, { method: 'POST', body: envelope(row.endpoint, payload) })
647
+ auth.callbackMode = 'present'
648
+ auth.libraryDecision = true
649
+ auth.libraryCalls = 0
650
+ const own = await post(row.payload(sessionId))
651
+ assert.equal(own.res.status, 200, `${prefix.name} ${row.endpoint}: the bound session resolves its own library`)
652
+ assert.equal(auth.libraryCalls, 0)
653
+ for (const bad of ['', 'ghost-session', 42]) {
654
+ const denied = await post(row.payload(bad))
655
+ assert.equal(denied.res.status, 403, `${prefix.name} ${row.endpoint}: sessionId=${JSON.stringify(bad)} must not resolve to a target`)
656
+ assert.equal(denied.res.text, JSON.stringify({ ok: false, error: { code: 'dsh-memu-memory/forbidden', message: 'forbidden', details: {} } }))
657
+ }
658
+ }
659
+ }
660
+ })
661
+
662
+ test('global review queue: embedded sessions may read, writes need a live binding, standalone needs review.manage', async () => {
663
+ const seedGlobalReview = () => {
664
+ state.updateGlobalIndex((index) => {
665
+ index.entries['mem-global-review'] = {
666
+ title: 'global review', content: 'global pending body', status: 'pending_review', layer: 'long_term',
667
+ domain: 'general', visibility: 'shared', disclosure: 'can_tell', source: 'inferred',
668
+ reviewReason: 'matrix conflict', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
669
+ }
670
+ })
671
+ state.flushState()
672
+ }
673
+ const post = (prefix, endpoint, payload) => send(prefix.route, prefix.base + '/' + endpoint, { method: 'POST', body: envelope(endpoint, payload) })
674
+
675
+ for (const prefix of prefixes) {
676
+ seedGlobalReview()
677
+ auth.callbackMode = 'present'
678
+ auth.libraryDecision = true
679
+ auth.libraryCalls = 0
680
+
681
+ // Embedded read: a well-formed session reads the global queue without any
682
+ // host callback — the queue is conversation-visible by design.
683
+ const read = await post(prefix, 'listReviews', { sessionId })
684
+ assert.equal(read.res.status, 200)
685
+ const readJson = JSON.parse(read.res.text)
686
+ assert.equal(readJson.result.ok, true)
687
+ assert.equal(readJson.result.value.scope, 'global')
688
+ assert.ok(readJson.result.value.items.some((item) => item.id === 'mem-global-review'))
689
+ assert.equal(auth.libraryCalls, 0, 'embedded reads never consult the host callback')
690
+
691
+ // Embedded write: only a session whose binding still exists may resolve.
692
+ const resolved = await post(prefix, 'resolveReview', { sessionId, id: 'mem-global-review', action: 'reject', resolution: 'replace' })
693
+ assert.equal(resolved.res.status, 200)
694
+ assert.equal(JSON.parse(resolved.res.text).result.ok, true)
695
+ assert.equal(state.loadGlobalIndex().entries['mem-global-review'].status, 'archived')
696
+
697
+ for (const bad of ['', 'ghost-session']) {
698
+ seedGlobalReview()
699
+ auth.libraryCalls = 0
700
+ const denied = await post(prefix, 'resolveReview', { sessionId: bad, id: 'mem-global-review', action: 'reject' })
701
+ assert.equal(denied.res.status, 403, `${prefix.name} resolveReview with sessionId=${JSON.stringify(bad)} must fail closed`)
702
+ assert.equal(state.loadGlobalIndex().entries['mem-global-review'].status, 'pending_review', 'a denied review write must not touch the queue')
703
+ const readDenied = await post(prefix, 'listReviews', { sessionId: bad })
704
+ assert.equal(readDenied.res.status, 403, `${prefix.name} listReviews with sessionId=${JSON.stringify(bad)} must fail closed`)
705
+ }
706
+
707
+ // Standalone management plane: the global queue authorizes with
708
+ // libraryId null and the dedicated review.manage operation, and a host
709
+ // that does not implement it fails closed.
710
+ const standalone = async (endpoint, payload) => {
711
+ seedGlobalReview()
712
+ auth.callbackMode = 'present'
713
+ auth.libraryDecision = true
714
+ auth.libraryCalls = 0
715
+ auth.libraryRequests.length = 0
716
+ const granted = await post(prefix, endpoint, payload)
717
+ assert.equal(granted.res.status, 200, `${prefix.name} ${endpoint}: review.manage true must grant the standalone request`)
718
+ assert.equal(auth.libraryCalls, 1)
719
+ const request = auth.libraryRequests.at(-1)
720
+ assert.equal(request.libraryId, null, 'the global queue authorizes with libraryId null')
721
+ assert.equal(request.operation, 'review.manage')
722
+
723
+ auth.libraryDecision = false
724
+ auth.libraryCalls = 0
725
+ assert.equal((await post(prefix, endpoint, payload)).res.status, 403, `${prefix.name} ${endpoint}: review.manage false must deny`)
726
+ assert.equal(auth.libraryCalls, 1)
727
+
728
+ auth.callbackMode = 'throw'
729
+ auth.libraryDecision = true
730
+ assert.equal((await post(prefix, endpoint, payload)).res.status, 403, `${prefix.name} ${endpoint}: a thrown callback must deny`)
731
+
732
+ auth.callbackMode = 'absent'
733
+ assert.equal((await post(prefix, endpoint, payload)).res.status, 200, `${prefix.name} ${endpoint}: a host without the callback falls back to the HTTP boundary (2026-11-01 flip)`)
734
+ auth.callbackMode = 'present'
735
+ }
736
+ await standalone('listReviews', {})
737
+ await standalone('resolveReview', { id: 'mem-global-review', action: 'reject' })
738
+
739
+ // The private branch keeps its library checks: a bound session cannot
740
+ // address another library's queue, and a granted standalone private read is
741
+ // a plain library read, not review.manage.
742
+ const privateLib = state.createLibrary({ name: 'auth-review-private', cwd: path.join(home, 'auth-review-private') })
743
+ auth.libraryDecision = true
744
+ assert.equal((await post(prefix, 'listReviews', { libraryId: privateLib.id, sessionId })).res.status, 403)
745
+ assert.equal((await post(prefix, 'resolveReview', { libraryId: privateLib.id, sessionId, id: 'mem-global-review', action: 'reject' })).res.status, 403)
746
+ auth.libraryRequests.length = 0
747
+ auth.libraryCalls = 0
748
+ const standalonePrivate = await post(prefix, 'listReviews', { libraryId: privateLib.id })
749
+ assert.equal(standalonePrivate.res.status, 200)
750
+ assert.equal(auth.libraryRequests.at(-1).operation, 'read', 'a private-queue read is a plain library read')
751
+ }
752
+ })
753
+
754
+
755
+ // ---- 2026-11-01 现场(Unexpected token "forbidden" / Invalid RPC response: 403)----
756
+ // 无 libraryId 的 browseWall("全部记忆库"视图)曾被硬拒 403:capability 缺席的宿主
757
+ // 上整个记忆墙挂掉。契约:无 libraryId 走同一 capability 模型——缺席回退放行、实现
758
+ // 了回调的宿主由 callback 决定;403 响应必须是 JSON 信封(standalone fetch 会
759
+ // response.json(),裸文本抛 SyntaxError)。
760
+
761
+ test('browseWall all-libraries view: absent capability allows, present callback decides, envelope on 403', async () => {
762
+ state.updateSettings({ memoryBackend: 'agent', vectorSource: 'local' })
763
+ const lib = state.createLibrary({ name: 'auth-wall-all', cwd: path.join(home, 'auth-wall-all') })
764
+ state.updatePrivateIndex(lib.id, (index) => {
765
+ index.entries['mem-wall-all'] = { title: 'wall all body', content: 'wall all body', status: 'active', layer: 'long_term', visibility: 'shared', disclosure: 'can_tell', source: 'user_said', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }
766
+ })
767
+ state.flushState()
768
+
769
+ for (const prefix of prefixes) {
770
+ // capability 缺席(当前版本宿主):跨库视图放行——记忆墙的核心视图不能 403。
771
+ auth.libraryCalls = 0
772
+ const allowed = await send(prefix.route, prefix.base + '/browseWall', { method: 'POST', body: envelope('browseWall', { libraryId: '' }) })
773
+ assert.equal(allowed.res.status, 200, `${prefix.name} browseWall(''): absent capability falls back to the HTTP boundary`)
774
+ const payload = JSON.parse(allowed.res.text)
775
+ assert.ok(payload.result.value.items.length >= 1, 'the wall returns entries across libraries')
776
+
777
+ // 实现了 capability 的宿主:多库枚举由回调决定(deny → 403 JSON 信封)。
778
+ auth.libraryDecision = false
779
+ auth.libraryCalls = 0
780
+ const denied = await send(prefix.route, prefix.base + '/browseWall', { method: 'POST', body: envelope('browseWall', { libraryId: '' }) })
781
+ assert.equal(denied.res.status, 403, 'a host that implements the capability may deny the multi-library read')
782
+ assert.equal(auth.libraryCalls, 1, 'present capability is consulted for the multi-library read')
783
+
784
+ // 403 响应体必须是 JSON 信封:standalone fetch 客户端 response.json() 之后再读
785
+ // error.message——裸 "forbidden" 在客户端抛 SyntaxError(现场事故)。
786
+ assert.equal(denied.res.headers['content-type'] || denied.res.headers['Content-Type'], 'application/json')
787
+ const parsed = JSON.parse(denied.res.text)
788
+ assert.equal(parsed.ok, false)
789
+ assert.equal(parsed.error.code, 'dsh-memu-memory/forbidden')
790
+ assert.equal(typeof parsed.error.message, 'string')
791
+ auth.libraryDecision = true
792
+ }
793
+ })
794
+
795
+ // standalone fetch 包装器:403 信封透传 error.message,而不是只报状态码
796
+ // (现场:Unexpected token "forbidden" → Invalid RPC response: 403,都看不到为什么)。
797
+ test('standalone rpc wrapper surfaces the server error message instead of the status code', () => {
798
+ const client = fs.readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
799
+ assert.match(client, /envelope\.error\?\.message \|\| \('Invalid RPC response: ' \+ response\.status\)/)
800
+ })