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
package/lib/rpc.js CHANGED
@@ -11,7 +11,7 @@ import { commit } from './memu.js'
11
11
  import { privateList, privateUpdate, privateRemember, privateRecall, privateArchive, privateDelete, globalList, globalRecall, domainOf, decayStrength, appliesToDomain, importMemories, resolveMemoryReview, visibleToSession, isMemoryEligible } from './memory.js'
12
12
  import { normalizeMemoryContext } from './context.js'
13
13
  import { requestPrivateConsolidation } from './private.js'
14
- import { startGlobalCuration, getCurationJob, cancelCurationJob, listCurationJobs } from './curator.js'
14
+ import { startGlobalCuration, getCurationJob, cancelCurationJob, listCurationJobs, undoGlobalAbsorb } from './curator.js'
15
15
  import { maybeMigrateLibraries } from './migrate.js'
16
16
  import { getPromptStates, savePromptOverride, resetPrompt, resetAllPrompts } from './prompts.js'
17
17
  import { statsView } from './stats.js'
@@ -21,6 +21,7 @@ import { webUiHtml, WEBUI_PATH, standaloneHtml, standaloneAsset, SETTINGS_PATH }
21
21
  import { getGraph, listEntities, createEntity, renameEntity, deleteEntity, linkEntries, unlinkEntry, getRelationship, updateRelationship, graphExport, restoreGraphData } from './graph.js'
22
22
  import { getAttachment, attachmentThumbnail, attachmentPreview, exportMediaBundle, importMediaBundle, streamMediaExport, importMediaStream } from './media.js'
23
23
  import { listEvents, logStats, clearEvents } from './activity-log.js'
24
+ import { warn } from './paths.js'
24
25
 
25
26
  const ENDPOINT = '/dsh-memu-memory'
26
27
 
@@ -54,6 +55,292 @@ function requireSessionId(payload) {
54
55
  return id.trim()
55
56
  }
56
57
 
58
+ // Browser-facing library routes are authorized at the HTTP boundary. Direct
59
+ // dispatch callers intentionally keep the historical API and do not pass an
60
+ // authorization context. A session id is only trusted when its existing host
61
+ // binding names the exact target library; requests without a session id must
62
+ // go through the host's standalone/transfer callback.
63
+ //
64
+ // The map is the complete audit of library-addressed endpoints (§10 P0): every
65
+ // entry's dispatch consumes `payload.libraryId` — or, for updateMemory and
66
+ // archiveMemory, falls back to the same persisted session binding that
67
+ // `routeLibraryId` resolves — so the target authorized here is exactly the
68
+ // target dispatch works on. Endpoints that are session- or host-scoped
69
+ // (getState, getSidebarState, initSession, setLibrary, setFolderPolicy, engine
70
+ // and settings management, …) are not library-addressed: their credential is
71
+ // the host's own requestRejection plus, for session-scoped reads, the session
72
+ // id the host itself provided.
73
+ const LIBRARY_ROUTE_OPERATIONS = new Map([
74
+ ['renameLibrary', 'rename'], ['archiveLibrary', 'archive'], ['deleteLibrary', 'delete'], ['restoreLibrary', 'restore'],
75
+ ['browseLibrary', 'read'], ['getMemory', 'read'], ['getStats', 'read'], ['addMemory', 'write'], ['updateMemory', 'write'],
76
+ ['importMemories', 'import'], ['exportLibrary', 'export'], ['getAttachment', 'attachment.read'],
77
+ ['browseWall', 'read'], ['getGraph', 'read'], ['listEntities', 'read'], ['createEntity', 'write'], ['renameEntity', 'write'],
78
+ ['deleteEntity', 'write'], ['linkEntries', 'write'], ['unlinkEntry', 'write'], ['getRelationship', 'read'], ['updateRelationship', 'write'],
79
+ ['listReviews', 'read'], ['resolveReview', 'write'], ['lexiconList', 'read'], ['lexiconSearch', 'read'], ['lexiconAdd', 'write'],
80
+ ['lexiconUpdate', 'write'], ['lexiconDelete', 'write'], ['lexiconArchive', 'write'], ['lexiconRestore', 'write'],
81
+ ['archiveMemory', 'archive'], ['deleteMemory', 'delete'],
82
+ ])
83
+
84
+ function routeLibraryId(endpoint, payload) {
85
+ const explicit = typeof payload?.libraryId === 'string' ? payload.libraryId.trim() : ''
86
+ if (explicit) return explicit
87
+ // These endpoints deliberately support session-scoped operation without an
88
+ // explicit libraryId. Resolve the target only from the persisted binding.
89
+ if (payload?.sessionId !== undefined) {
90
+ if (typeof payload.sessionId !== 'string' || !payload.sessionId.trim()) return null
91
+ return getSessionBinding(payload.sessionId.trim())?.libraryId || null
92
+ }
93
+ return null
94
+ }
95
+
96
+ let capabilityAbsentWarned = false
97
+
98
+ // One capability gate for both routes: absent callback falls back to the HTTP
99
+ // boundary (requestRejection) and warns once; a present callback is strict true.
100
+ // Keeping one helper makes the session-first / capability-second order identical
101
+ // in the global queue and the library routes.
102
+ async function authorizeWithCapability(connection, req, { libraryId, operation, label }) {
103
+ if (typeof connection?.authorizeLibrary !== 'function') {
104
+ if (!capabilityAbsentWarned) {
105
+ capabilityAbsentWarned = true
106
+ try { warn(`dsh-memu-memory: host does not implement connection.authorizeLibrary; ${label} falls back to the HTTP boundary (requestRejection)`) } catch {}
107
+ }
108
+ return true
109
+ }
110
+ try {
111
+ return (await connection.authorizeLibrary(req, { libraryId, operation })) === true
112
+ } catch {
113
+ return false
114
+ }
115
+ }
116
+
117
+ // Global review queue (listReviews/resolveReview without an explicit libraryId)
118
+ // has no private-library target, so it cannot be authorized as a library route.
119
+ // Embedded requests keep the session-level semantics: the queue is visible to
120
+ // conversations, and a write (resolveReview) additionally demands the session
121
+ // exists and is still bound. In both scopes a session id is only a credential
122
+ // while its persisted binding names a library — an empty or dangling id is
123
+ // never one. Standalone requests are the management plane and fail closed on
124
+ // the host's `review.manage` grant: a missing callback (current host) falls back
125
+ // to the HTTP boundary, while a present callback must return strict true.
126
+ async function authorizeGlobalReviewRoute(connection, req, endpoint, payload) {
127
+ if (payload?.sessionId !== undefined) {
128
+ if (typeof payload.sessionId !== 'string' || !payload.sessionId.trim()) return false
129
+ return Boolean(getSessionBinding(payload.sessionId.trim())?.libraryId)
130
+ }
131
+ return authorizeWithCapability(connection, req, { libraryId: null, operation: 'review.manage', label: 'global review queue' })
132
+ }
133
+
134
+ async function authorizeLibraryRoute(connection, req, endpoint, payload, libraryId = routeLibraryId(endpoint, payload)) {
135
+ // Review endpoints are dual-scope: without an explicit libraryId they address
136
+ // the global curator queue, which follows its own management model.
137
+ if ((endpoint === 'listReviews' || endpoint === 'resolveReview') && !String(payload?.libraryId || '').trim()) {
138
+ return authorizeGlobalReviewRoute(connection, req, endpoint, payload)
139
+ }
140
+ if (!LIBRARY_ROUTE_OPERATIONS.has(endpoint)) return true
141
+ // Session-bound requests are checked first: a valid binding must name the exact
142
+ // target library, and a present-but-unresolvable session is a bad credential
143
+ // (never a reason to fall back to the host callback). Without a session, the
144
+ // capability decides — including the explicit no-library aggregate read.
145
+ if (payload?.sessionId !== undefined) {
146
+ if (typeof payload.sessionId !== 'string' || !payload.sessionId.trim()) return false
147
+ if (!libraryId) return false
148
+ const binding = getSessionBinding(payload.sessionId.trim())
149
+ return Boolean(binding?.libraryId && binding.libraryId === libraryId)
150
+ }
151
+ return authorizeWithCapability(connection, req, {
152
+ libraryId,
153
+ operation: LIBRARY_ROUTE_OPERATIONS.get(endpoint),
154
+ label: 'library-level authorization',
155
+ })
156
+ }
157
+
158
+ function rejectLibraryRoute(res) {
159
+ if (res.headersSent) return
160
+ // JSON 信封而非裸文本:standalone fetch 客户端会 response.json(),裸 "forbidden"
161
+ // 抛 SyntaxError(Unexpected token),用户只看到白屏而不是可读错误。
162
+ res.writeHead(403, { 'Content-Type': 'application/json' })
163
+ res.end(JSON.stringify({ ok: false, error: { code: 'dsh-memu-memory/forbidden', message: 'forbidden', details: {} } }))
164
+ }
165
+
166
+ // 词典核心(lib/lexicon.js)按需加载:路由注册与既有 86 个
167
+ // case 不能被一个尚未落地的模块卡死,集成落地后这里可以改回静态 import。
168
+ let lexiconModulePromise = null
169
+ function lexiconApi() {
170
+ if (!lexiconModulePromise) {
171
+ lexiconModulePromise = import('./lexicon.js').catch((error) => {
172
+ lexiconModulePromise = null
173
+ throw error
174
+ })
175
+ }
176
+ return lexiconModulePromise
177
+ }
178
+
179
+ // 全局图(lib/global-graph.js,F1 包):resolveReview 的 unlink 动作按需加载。
180
+ // 与词典核心同样的按需模式——F1 未落地时路由注册不被卡死,unlink 返回可读错误。
181
+ let globalGraphModulePromise = null
182
+ function globalGraphApi() {
183
+ if (!globalGraphModulePromise) {
184
+ globalGraphModulePromise = import('./global-graph.js').then((mod) => {
185
+ if (typeof mod.removeGlobalLink !== 'function') throw new Error('removeGlobalLink not exported yet')
186
+ return mod
187
+ }).catch((error) => {
188
+ globalGraphModulePromise = null
189
+ throw error
190
+ })
191
+ }
192
+ return globalGraphModulePromise
193
+ }
194
+
195
+ // 词典 RPC 的库映射(§4.1):显式 libraryId 必须是已存在的库——不信任客户端任意
196
+ // id——否则回落到会话绑定,与 case 'listReviews' 的入口模式一致。
197
+ function lexiconLibraryId(payload) {
198
+ const id = String(payload?.libraryId || '').trim()
199
+ if (id) {
200
+ if (!getLibrary(id)) throw new Error('library not found')
201
+ if (payload?.sessionId !== undefined) {
202
+ const sessionId = requireSessionId(payload)
203
+ const binding = getSessionBinding(sessionId)
204
+ if (binding?.libraryId !== id) throw new Error('session is not authorized for this memory library')
205
+ }
206
+ return id
207
+ }
208
+ const binding = getSessionBinding(requireSessionId(payload))
209
+ if (!binding?.libraryId || !getLibrary(binding.libraryId)) throw new Error('session is not bound to a memory library')
210
+ return binding.libraryId
211
+ }
212
+
213
+ function lexiconTermView(e) {
214
+ return {
215
+ id: e.id,
216
+ term: e.term,
217
+ kind: e.kind || 'term',
218
+ meaning: String(e.meaning || '').slice(0, 2000),
219
+ usage: String(e.usage || '').slice(0, 800),
220
+ aliases: Array.isArray(e.aliases) ? e.aliases.slice(0, 20) : [],
221
+ examples: Array.isArray(e.examples) ? e.examples.slice(0, 8) : [],
222
+ context: e.context || {},
223
+ knownBy: Array.isArray(e.knownBy) ? e.knownBy : [],
224
+ visibility: e.visibility || 'shared',
225
+ sensitivity: e.sensitivity || 'normal',
226
+ strength: typeof e.strength === 'number' ? e.strength : null,
227
+ confidence: typeof e.confidence === 'number' ? e.confidence : null,
228
+ status: e.status || 'active',
229
+ source: e.source || 'user',
230
+ evidence: Array.isArray(e.evidence) ? e.evidence.slice(0, 6) : [],
231
+ sourceMemoryId: e.sourceMemoryId || null,
232
+ hitCounts: e.hitCounts && typeof e.hitCounts === 'object' ? e.hitCounts : { candidate: 0, injection: 0, query: 0 },
233
+ lastInjectedAt: e.lastInjectedAt || null,
234
+ createdAt: e.createdAt || null,
235
+ updatedAt: e.updatedAt || null,
236
+ reviewedAt: e.reviewedAt || null,
237
+ reviewedBy: e.reviewedBy || null,
238
+ }
239
+ }
240
+
241
+ // 导入合并(§4.4)。冲突判定用核心层自己的 normalizeLexeme(与存储同源,零漂移):
242
+ // 先在本侧把包内词面对到既有词条,active 且 sensitive 的既有词条永不覆盖——跳过并
243
+ // 在结果里报告 skipped。导入以用户来源写入,所以其余词条直接应用字段(§2.1 起不再
244
+ // 产生 pending revision);真正需要守的是墓碑:rejected 是内部防重放墓碑,导入既不
245
+ // 恢复背包里的 rejected 行,也不复活库里已有的 rejected 词条。legacy pending 按启动
246
+ // 迁移的同一口径收口(新提案 → active、敏感 → archived、revision 草稿 → archived)。
247
+ const LEXICON_KINDS = ['slang', 'meme', 'trigger', 'term']
248
+ // 导入包里可能出现的状态:pending/rejected 是 legacy/墓碑,只用于识别并按下面的口径
249
+ // 收口;公开生命周期只有 active / archived。
250
+ const LEXICON_STATUSES = ['pending', 'active', 'rejected', 'archived']
251
+ const LEXICON_PUBLIC_STATUSES = ['active', 'archived']
252
+ // 字段长度策略与核心 lib/lexicon.js 的直接写入完全统一(§10 P2:同一把尺):
253
+ // meaning/usage/aliases/examples 超长一律按核心的 clip 落地,导入分支不再做自己的
254
+ // 长度拒绝;唯一例外是 term 本身——term 是唯一键,截断会破坏唯一性,所以超长或
255
+ // 规范化后为空的 term 由核心 upsert 抛 typed error(LEXICON_INVALID_TERM),该条
256
+ // 进 failed。
257
+
258
+ async function mergeLexiconImport(libraryId, member) {
259
+ if (!member || typeof member !== 'object' || Array.isArray(member)) throw new Error('lexicon member must be an object')
260
+ if (!Array.isArray(member.terms)) throw new Error('lexicon member has no "terms" array')
261
+ if (member.terms.length > 5000) throw new Error('lexicon member has too many terms')
262
+ const lexicon = await lexiconApi()
263
+ const normalize = typeof lexicon.normalizeLexeme === 'function' ? lexicon.normalizeLexeme : (value) => String(value ?? '').normalize('NFKC').toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, '')
264
+ const data = lexicon.loadLexicon(libraryId)
265
+ const byKey = new Map()
266
+ for (const [termId, e] of Object.entries(data.terms || {})) {
267
+ for (const key of [...new Set([String(e?.normalizedTerm || ''), ...((Array.isArray(e?.normalizedAliases) ? e.normalizedAliases : []).map((a) => String(a)))])].filter(Boolean)) {
268
+ if (!byKey.has(key)) byKey.set(key, { termId, entry: e })
269
+ }
270
+ }
271
+ const report = { version: 1, applied: 0, added: 0, updated: 0, revisions: 0, skipped: [], failed: [] }
272
+ for (const raw of member.terms) {
273
+ const label = typeof raw?.term === 'string' ? raw.term : ''
274
+ try {
275
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('term entry must be an object')
276
+ const term = String(raw.term || '').trim()
277
+ const meaning = String(raw.meaning || '').trim()
278
+ if (!term) throw new Error('term is required')
279
+ if (!meaning) throw new Error('meaning is required')
280
+ // 长度不再在本侧拒绝:clip 与 reject 都以核心 upsert 的策略为准(见上方注释)。
281
+ // term 超长/规范化为空由 upsert 抛 LEXICON_INVALID_TERM,整条进 failed。
282
+ const kind = raw.kind == null || raw.kind === '' ? 'term' : String(raw.kind)
283
+ if (!LEXICON_KINDS.includes(kind)) throw new Error(`unknown lexicon kind: ${kind}`)
284
+ const aliases = raw.aliases == null ? undefined : raw.aliases
285
+ const examples = raw.examples == null ? undefined : raw.examples
286
+ const keys = [...new Set([String(raw.normalizedTerm || ''), normalize(term), ...((Array.isArray(raw.aliases) ? raw.aliases : []).map(normalize))])].filter(Boolean)
287
+ const target = keys.map((key) => byKey.get(key)).find(Boolean) || null
288
+ const status = raw.status == null || raw.status === '' ? 'active' : String(raw.status)
289
+ if (!LEXICON_STATUSES.includes(status)) throw new Error(`unknown lexicon status: ${status}`)
290
+ // 墓碑(§2.1):rejected 是内部防重放墓碑,导入是恢复路径也不能把它洗回生命周期;
291
+ // 库里已有同词墓碑时同样跳过——用户来源的 upsert 会复活它并清掉拒绝指纹。
292
+ if (status === 'rejected') {
293
+ report.skipped.push({ term: label || term, reason: 'rejected terms are internal tombstones and are not restored by import' })
294
+ continue
295
+ }
296
+ const targetStatus = target ? (target.entry?.status || 'active') : null
297
+ if (targetStatus === 'rejected') {
298
+ report.skipped.push({ term: label || term, termId: target.termId, reason: 'a rejected tombstone is never resurrected by import' })
299
+ continue
300
+ }
301
+ if (targetStatus === 'active' && target.entry?.sensitivity === 'sensitive') {
302
+ report.skipped.push({ term: label || term, termId: target.termId, reason: 'an active sensitive term is never overwritten by import' })
303
+ continue
304
+ }
305
+ // legacy pending 与 migrateLegacyLexiconStatuses 同一口径(§2.1):新提案 → active
306
+ // (敏感 → archived),revision 草稿 → archived。新写只有公开两态。
307
+ const sensitive = raw.sensitivity === 'sensitive' || (kind === 'trigger' && raw.sensitivity !== 'normal')
308
+ const effectiveStatus = status === 'pending' ? (raw.revisionOf || sensitive ? 'archived' : 'active') : status
309
+ // 收口成 archived 的 legacy 行如果撞上同词的 active 正条,user upsert 会把正条改写
310
+ // 并归档(撞线语义)——迁移会保留正条,这里也跳过草稿,不让它覆盖正条。
311
+ if (status === 'pending' && effectiveStatus === 'archived' && targetStatus === 'active') {
312
+ report.skipped.push({ term: label || term, termId: target.termId, reason: 'legacy pending draft skipped: its active target must not be overwritten by import' })
313
+ continue
314
+ }
315
+ const out = await lexicon.upsertLexiconTerm({
316
+ libraryId,
317
+ term,
318
+ kind,
319
+ meaning,
320
+ usage: raw.usage ?? undefined,
321
+ aliases,
322
+ examples,
323
+ context: raw.context ?? undefined,
324
+ knownBy: raw.knownBy ?? undefined,
325
+ visibility: raw.visibility ?? undefined,
326
+ sensitivity: raw.sensitivity ?? undefined,
327
+ confidence: raw.confidence ?? undefined,
328
+ evidence: raw.evidence ?? undefined,
329
+ source: 'user',
330
+ status: effectiveStatus,
331
+ })
332
+ report.applied += 1
333
+ if (out?.created === false) report.updated += 1
334
+ else report.added += 1
335
+ // revisions 在当前生命周期恒为 0(§2.1 不再生成 pending revision);字段保留只为报告
336
+ // 形状稳定,旧客户端继续拿到一个数字。
337
+ } catch (error) {
338
+ report.failed.push({ term: label, reason: String(error?.message || error).slice(0, 300) })
339
+ }
340
+ }
341
+ return report
342
+ }
343
+
57
344
  // List one library's memories. Retrieval needs the MemU engine, but the sidecar
58
345
  // index holds the same rows, so an engine that is still installing (or broken)
59
346
  // degrades to the index instead of failing the browser.
@@ -601,6 +888,8 @@ async function dispatch(ctx, endpoint, payload = {}) {
601
888
  'shortTermTtlHours',
602
889
  'sidebarSettings', 'visionCaptionEnabled',
603
890
  'inboundToolNames',
891
+ // 全局提炼的审核模式(spec §6.1):恢复默认 = 回到 auto,不保留全审状态。
892
+ 'curatorReviewMode',
604
893
  ]) patch[key] = DEFAULT_SETTINGS[key]
605
894
  return updateSettings(patch)
606
895
  }
@@ -618,6 +907,8 @@ async function dispatch(ctx, endpoint, payload = {}) {
618
907
  'memoryMode', 'memoryBackend', 'habitsInjection', 'purgeArchivedAfterDays',
619
908
  'shortTermTtlHours',
620
909
  'sidebarSettings', 'visionCaptionEnabled', 'inboundToolNames',
910
+ // 全局提炼的审核模式(spec §6.1):不进 allowlist 的 patch 会被静默丢弃。
911
+ 'curatorReviewMode',
621
912
  ])
622
913
  const clean = {}
623
914
  for (const [k, v] of Object.entries(patch)) if (allowed.has(k)) clean[k] = v
@@ -832,6 +1123,7 @@ async function dispatch(ctx, endpoint, payload = {}) {
832
1123
  // The payload may be the parsed export (memories array) or the raw file text.
833
1124
  let memories = payload?.memories
834
1125
  let graphData = payload?.graph || null
1126
+ let lexiconData = payload?.lexicon && typeof payload.lexicon === 'object' && !Array.isArray(payload.lexicon) ? payload.lexicon : null
835
1127
  let missingAttachments = 0
836
1128
  if (payload?.archiveBase64) {
837
1129
  const archive = importMediaBundle(id, Buffer.from(String(payload.archiveBase64), 'base64'))
@@ -843,7 +1135,11 @@ async function dispatch(ctx, endpoint, payload = {}) {
843
1135
  let parsed = null
844
1136
  try { parsed = JSON.parse(memories) } catch { throw new Error('that file is not valid JSON') }
845
1137
  if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
846
- if (Array.isArray(parsed.memories)) { memories = parsed.memories; graphData = parsed.graph || null }
1138
+ if (Array.isArray(parsed.memories)) {
1139
+ memories = parsed.memories
1140
+ graphData = parsed.graph || null
1141
+ lexiconData = lexiconData || (parsed.lexicon && typeof parsed.lexicon === 'object' && !Array.isArray(parsed.lexicon) ? parsed.lexicon : null)
1142
+ }
847
1143
  else throw new Error('this JSON has no "memories" array')
848
1144
  } else if (Array.isArray(parsed)) memories = parsed
849
1145
  else throw new Error('this JSON has no "memories" array')
@@ -851,19 +1147,35 @@ async function dispatch(ctx, endpoint, payload = {}) {
851
1147
  if (!Array.isArray(memories)) throw new Error('memories must be an array')
852
1148
  const result = await importMemories({ libraryId: id, memories })
853
1149
  if (graphData && result.idMap) restoreGraphData(id, graphData, result.idMap)
854
- return { libraryId: id, ...result, missingAttachments }
1150
+ // 词典是版本化 member(§4.4):包里带了才合并恢复;term 冲突(规范化后同词)
1151
+ // 更新既有词条,active+sensitive 一律跳过并上报,超长字段按核心 clip 落地、
1152
+ // term 本身越界/归一化为空才进 failed。
1153
+ const lexiconReport = lexiconData ? await mergeLexiconImport(id, lexiconData) : null
1154
+ const value = { libraryId: id, ...result, missingAttachments }
1155
+ return lexiconReport ? { ...value, lexicon: lexiconReport } : value
855
1156
  }
856
1157
  case 'exportLibrary': {
857
1158
  const id = String(payload?.libraryId || '')
858
1159
  const lib = getLibrary(id)
859
1160
  if (!lib) throw new Error('library not found')
860
1161
  const index = loadPrivateIndex(id)
1162
+ // §4.4:导出包带上版本化词典 member。词典核心暂时不可用(未落地/文件损坏)
1163
+ // 时导出不能整个失败——报 unavailable,让导入端知道这份包没有词典。
1164
+ let lexiconMember = { version: 1, terms: [], unavailable: null }
1165
+ try {
1166
+ const lexicon = await lexiconApi()
1167
+ const data = lexicon.loadLexicon(id)
1168
+ lexiconMember = { version: 1, terms: Object.entries(data.terms || {}).map(([termId, e]) => ({ id: termId, ...e })) }
1169
+ } catch (error) {
1170
+ lexiconMember.unavailable = String(error?.message || error).slice(0, 300)
1171
+ }
861
1172
  const exported = {
862
1173
  format: 'dsh-memu-memory-library@1',
863
1174
  exportedAt: new Date().toISOString(),
864
1175
  library: { id: lib.id, name: lib.name, scopeKey: lib.scopeKey || null, domain: lib.domain || null },
865
1176
  memories: Object.entries(index.entries || {}).map(([memId, e]) => ({ id: memId, ...e })),
866
1177
  graph: graphExport(id),
1178
+ lexicon: lexiconMember,
867
1179
  }
868
1180
  if (payload?.includeAttachments === true) {
869
1181
  if (payload?.delivery === 'download') return { filename: `${id}.zip`, downloadUrl: `/dsh-memu-memory/export/${id}.zip` }
@@ -890,6 +1202,15 @@ async function dispatch(ctx, endpoint, payload = {}) {
890
1202
  expiresAt: entry.expiresAt || null, openLoop: entry.openLoop === true,
891
1203
  marginalized: entry.marginalized === true, decay: Math.min(1, decayStrength(entry)),
892
1204
  tags: Array.isArray(entry.tags) ? entry.tags : [], attachmentCount: entry.attachments?.length || 0,
1205
+ // Global entries carry F1/F2 metadata; the wall drawer reads it from the
1206
+ // same bounded item payload instead of a second full-body request.
1207
+ ...(lib ? {} : {
1208
+ links: Array.isArray(entry.links) ? entry.links : [],
1209
+ absorbedFrom: Array.isArray(entry.absorbedFrom) ? entry.absorbedFrom : [],
1210
+ absorbedAt: entry.absorbedAt || null,
1211
+ representativeId: entry.representativeId || null,
1212
+ absorbOpId: entry.absorbOpId || null,
1213
+ }),
893
1214
  // The first usable image becomes the card cover; the drawer requests
894
1215
  // the full gallery from getMemory. Secret entries come back hidden
895
1216
  // (no url), so the wall never fetches their pixels.
@@ -900,7 +1221,13 @@ async function dispatch(ctx, endpoint, payload = {}) {
900
1221
  for (const library of libraries) {
901
1222
  try { collect(loadPrivateIndex(library.id), library) } catch { errors.push({ libraryId: library.id, error: 'memory index unavailable' }) }
902
1223
  }
903
- if (!libraryId) { try { collect(loadGlobalIndex(), null) } catch { errors.push({ libraryId: null, error: 'global index unavailable' }) } }
1224
+ // Global memory is shared across libraries and the wall always exposes a
1225
+ // global scope. Include it even when a single private library was selected:
1226
+ // the single-library authorization covers that library's private rows, and
1227
+ // global rows are not library-private data. Without this, the standalone
1228
+ // single-library fallback (hosts that deny the all-libraries read) shows an
1229
+ // empty global scope.
1230
+ try { collect(loadGlobalIndex(), null) } catch { errors.push({ libraryId: null, error: 'global index unavailable' }) }
904
1231
  items.sort((a, b) => String(b.updatedAt || b.createdAt || '').localeCompare(String(a.updatedAt || a.createdAt || '')))
905
1232
  const limit = Math.max(1, Math.min(1000, Number(payload?.limit) || 300))
906
1233
  return { items: items.slice(0, limit), total: items.length, libraries, errors }
@@ -945,6 +1272,13 @@ async function dispatch(ctx, endpoint, payload = {}) {
945
1272
  source: f.entry?.source || 'inferred',
946
1273
  status: f.entry?.status || 'active',
947
1274
  content: String(f.content || '').slice(0, 600),
1275
+ // F1/F2 metadata: the global page renders link/absorb tags and the undo
1276
+ // button from these fields; without them every tag is silently absent.
1277
+ links: Array.isArray(f.entry?.links) ? f.entry.links : [],
1278
+ absorbedFrom: Array.isArray(f.entry?.absorbedFrom) ? f.entry.absorbedFrom : [],
1279
+ absorbedAt: f.entry?.absorbedAt || null,
1280
+ representativeId: f.entry?.representativeId || null,
1281
+ absorbOpId: f.entry?.absorbOpId || null,
948
1282
  })),
949
1283
  }
950
1284
  } catch (error) {
@@ -957,6 +1291,11 @@ async function dispatch(ctx, endpoint, payload = {}) {
957
1291
  confidence: e.confidence ?? null, status: e.status || 'active', content: String(e.content || '').slice(0, 600),
958
1292
  evidence: e.evidence || [], layer: e.layer || 'long_term', decay: decayStrength(e),
959
1293
  createdAt: e.createdAt || null, updatedAt: e.updatedAt || null, source: e.source || 'inferred',
1294
+ links: Array.isArray(e.links) ? e.links : [],
1295
+ absorbedFrom: Array.isArray(e.absorbedFrom) ? e.absorbedFrom : [],
1296
+ absorbedAt: e.absorbedAt || null,
1297
+ representativeId: e.representativeId || null,
1298
+ absorbOpId: e.absorbOpId || null,
960
1299
  })),
961
1300
  }
962
1301
  }
@@ -985,18 +1324,221 @@ async function dispatch(ctx, endpoint, payload = {}) {
985
1324
  confidence: e.confidence ?? null,
986
1325
  reviewReason: e.reviewReason || '',
987
1326
  conflictingWith: e.conflictingWith || null,
1327
+ // 包 F2(spec §6.7):风险分与 pendingAbsorb 随队列下发,供 UI 打
1328
+ // 「集合吸收」Tag、列出来源标题并按风险排序;旧条目按缺省读取。
1329
+ reviewRisk: e.reviewRisk ?? null,
1330
+ reviewRiskReasons: Array.isArray(e.reviewRiskReasons) ? e.reviewRiskReasons : [],
1331
+ pendingAbsorb: e.pendingAbsorb || null,
988
1332
  evidence: (e.evidence || []).slice(0, 6),
989
1333
  updatedAt: e.updatedAt || null,
990
1334
  })),
991
1335
  }
992
1336
  }
993
1337
  case 'resolveReview': {
1338
+ // 包 F2(spec §6.7):unlink / undo-absorb 是两个全局元数据动作,不是内容复核
1339
+ // ——都不新增路由,仍走 resolveReview 已在的全局复核授权路径;但它们只作用于
1340
+ // 全局队列,带 libraryId 的调用是私有复核语义错位,直接报错。
1341
+ const reviewAction = String(payload?.action || '')
1342
+ if (reviewAction === 'unlink' || reviewAction === 'undo-absorb') {
1343
+ if (String(payload?.libraryId || '').trim()) {
1344
+ throw new Error(`${reviewAction} is a global review action; pass no libraryId`)
1345
+ }
1346
+ if (reviewAction === 'unlink') {
1347
+ // lib/global-graph.js(F1 包)的 removeGlobalLink,按需加载——与词典核心
1348
+ // 同一模式;模块缺席或未导出时抛固定错误(spec §6.7 RPC 约定)。
1349
+ try {
1350
+ const globalGraph = await globalGraphApi()
1351
+ return globalGraph.removeGlobalLink({
1352
+ linkId: payload?.linkId, fromId: payload?.fromId, toId: payload?.toId, kind: payload?.kind,
1353
+ })
1354
+ } catch (error) {
1355
+ if (globalGraphModulePromise) throw error
1356
+ throw new Error('link removal lands with F1')
1357
+ }
1358
+ }
1359
+ return undoGlobalAbsorb({ id: String(payload?.id || '') })
1360
+ }
994
1361
  return resolveMemoryReview({
995
1362
  libraryId: payload?.libraryId ? String(payload.libraryId) : null,
996
- id: String(payload?.id || ''), action: String(payload?.action || ''),
1363
+ id: String(payload?.id || ''), action: reviewAction,
997
1364
  resolution: payload?.resolution || 'replace', reason: payload?.reason || '',
998
1365
  content: payload?.content, title: payload?.title,
1366
+ sessionId: payload?.sessionId ? String(payload.sessionId) : null,
1367
+ })
1368
+ }
1369
+ // —— 词典:每本私有库一份;入口先由显式 id(须存在)或会话映射到库 ——
1370
+ case 'lexiconList': {
1371
+ const libraryId = lexiconLibraryId(payload)
1372
+ const lexicon = await lexiconApi()
1373
+ const data = lexicon.loadLexicon(libraryId)
1374
+ // 公开两态(§2.1):pending/rejected 是内部状态与墓碑,列表永不返回。status 只接受
1375
+ // active / archived——未知值明确报错,别静默当成“全都要”把墓碑带出去。
1376
+ const requestedStatus = String(payload?.status || '').trim()
1377
+ if (requestedStatus && !LEXICON_PUBLIC_STATUSES.includes(requestedStatus)) {
1378
+ throw new Error(`unknown lexicon status: ${requestedStatus}; expected active or archived`)
1379
+ }
1380
+ // 默认 active;includeArchived 只把默认视图扩大成 active + archived,显式 status 优先。
1381
+ const includeArchived = payload?.includeArchived === true
1382
+ const status = requestedStatus || (includeArchived ? '' : 'active')
1383
+ const kind = String(payload?.kind || '')
1384
+ const sensitivity = String(payload?.sensitivity || '')
1385
+ const q = String(payload?.q || '').trim().toLowerCase()
1386
+ const rows = Object.entries(data.terms || {})
1387
+ .map(([termId, e]) => ({ ...e, id: termId, status: e?.status || 'active' }))
1388
+ .filter((e) => LEXICON_PUBLIC_STATUSES.includes(e.status))
1389
+ // 页签计数(§2.4)在 kind / 搜索 / 状态筛选之前算好:标签数字不随筛选跳动。
1390
+ const statusCounts = { active: 0, archived: 0 }
1391
+ for (const e of rows) statusCounts[e.status] += 1
1392
+ const selected = rows
1393
+ .filter((e) => !status || e.status === status)
1394
+ .filter((e) => !sensitivity || e.sensitivity === sensitivity)
1395
+ .filter((e) => !q || [e.term, e.meaning, e.usage, ...(Array.isArray(e.aliases) ? e.aliases : [])].join('\n').toLowerCase().includes(q))
1396
+ // 总览计数行只跟随 kind 以外的过滤:kind 标签上的数字才是"点了会变"的实时数。
1397
+ const counts = { slang: 0, meme: 0, trigger: 0, term: 0 }
1398
+ for (const e of selected) if (counts[e.kind] !== undefined) counts[e.kind] += 1
1399
+ // updatedAt 倒序;同一时间戳按 id 兜底——分页与多端渲染的顺序必须可复现。
1400
+ const filtered = selected.filter((e) => !kind || e.kind === kind)
1401
+ .sort((a, b) => String(b.updatedAt || b.createdAt || '').localeCompare(String(a.updatedAt || a.createdAt || ''))
1402
+ || String(a.id).localeCompare(String(b.id)))
1403
+ const pageSize = Math.max(1, Math.min(200, Number(payload?.pageSize) || 50))
1404
+ const total = filtered.length
1405
+ const pages = Math.max(1, Math.ceil(total / pageSize))
1406
+ const page = Math.min(pages, Math.max(1, Number(payload?.page) || 1))
1407
+ return {
1408
+ libraryId,
1409
+ version: data.version ?? null,
1410
+ items: filtered.slice((page - 1) * pageSize, page * pageSize).map(lexiconTermView),
1411
+ total,
1412
+ page,
1413
+ pages,
1414
+ pageSize,
1415
+ counts,
1416
+ statusCounts,
1417
+ }
1418
+ }
1419
+ case 'lexiconSearch': {
1420
+ const explicit = String(payload?.libraryId || '').trim()
1421
+ const libraryId = lexiconLibraryId(payload)
1422
+ const query = String(payload?.q ?? payload?.query ?? '').trim()
1423
+ if (!query) throw new Error('q is required')
1424
+ // 会话路径带着该会话的 memoryContext 做可见过滤;设置页显式选库时是管理视角,
1425
+ // 与 browseLibrary 一样不按场景收窄。归档词条只有管理面显式 includeArchived 才查;
1426
+ // pending/rejected 由核心永远排除。
1427
+ const binding = explicit ? null : getSessionBinding(requireSessionId(payload))
1428
+ const lexicon = await lexiconApi()
1429
+ const hits = await lexicon.searchLexicon(
1430
+ { libraryId, query, memoryContext: binding?.memoryContext || {} },
1431
+ { includeArchived: payload?.includeArchived === true },
1432
+ )
1433
+ return {
1434
+ libraryId,
1435
+ items: (Array.isArray(hits) ? hits : []).map((hit) => {
1436
+ const e = hit?.entry && typeof hit.entry === 'object' ? hit.entry : hit
1437
+ return {
1438
+ id: hit?.id || hit?.termId || null,
1439
+ term: e?.term,
1440
+ kind: e?.kind || 'term',
1441
+ meaning: String(e?.meaning || '').slice(0, 2000),
1442
+ usage: String(e?.usage || '').slice(0, 800),
1443
+ aliases: Array.isArray(e?.aliases) ? e.aliases.slice(0, 20) : [],
1444
+ sensitivity: e?.sensitivity || 'normal',
1445
+ status: e?.status || 'active',
1446
+ strength: typeof e?.strength === 'number' ? e.strength : null,
1447
+ evidence: Array.isArray(e?.evidence) ? e.evidence.slice(0, 6) : [],
1448
+ sourceMemoryId: e?.sourceMemoryId || null,
1449
+ }
1450
+ }),
1451
+ }
1452
+ }
1453
+ case 'lexiconAdd': {
1454
+ const libraryId = lexiconLibraryId(payload)
1455
+ const term = String(payload?.term || '').trim()
1456
+ const kind = String(payload?.kind || '').trim()
1457
+ const meaning = String(payload?.meaning || '').trim()
1458
+ if (!term) throw new Error('term is required')
1459
+ if (!kind) throw new Error('kind is required')
1460
+ if (!meaning) throw new Error('meaning is required')
1461
+ const lexicon = await lexiconApi()
1462
+ const result = await lexicon.upsertLexiconTerm({
1463
+ libraryId,
1464
+ term,
1465
+ kind,
1466
+ meaning,
1467
+ usage: payload?.usage,
1468
+ aliases: Array.isArray(payload?.aliases) ? payload.aliases : undefined,
1469
+ examples: Array.isArray(payload?.examples) ? payload.examples : undefined,
1470
+ context: payload?.context,
1471
+ knownBy: payload?.knownBy,
1472
+ visibility: payload?.visibility,
1473
+ sensitivity: payload?.sensitivity,
1474
+ confidence: payload?.confidence,
1475
+ evidence: payload?.evidence,
1476
+ // 面板/管理入口是"用户"来源,直接落 active;敏感字段校验在核心层。
1477
+ source: 'user',
1478
+ status: 'active',
1479
+ })
1480
+ return { libraryId, ...result }
1481
+ }
1482
+ case 'lexiconUpdate': {
1483
+ const libraryId = lexiconLibraryId(payload)
1484
+ const termId = String(payload?.termId || '').trim()
1485
+ if (!termId) throw new Error('termId is required')
1486
+ const patch = payload?.patch && typeof payload.patch === 'object' && !Array.isArray(payload.patch) ? payload.patch : {}
1487
+ // 只开放内容与观感字段;生命周期(归档/恢复)走 lexiconArchive / lexiconRestore,
1488
+ // 来源与类型不在这里改。
1489
+ // 与 updateMemory 同一规矩:名单外的键明确报错,不静默丢弃。
1490
+ const allowed = ['meaning', 'usage', 'aliases', 'examples', 'sensitivity', 'strength', 'visibility', 'context', 'knownBy']
1491
+ const unknown = Object.keys(patch).filter((key) => !allowed.includes(key))
1492
+ if (unknown.length) throw new Error(`unknown lexicon field: ${unknown.join(', ')}; editable: ${allowed.join(', ')}`)
1493
+ const lexicon = await lexiconApi()
1494
+ const data = lexicon.loadLexicon(libraryId)
1495
+ const existing = (data.terms || {})[termId]
1496
+ if (!existing) throw new Error('unknown lexicon term')
1497
+ // 用户来源直接应用字段(含敏感字段);同规范词的撞线语义由核心层管。
1498
+ const result = await lexicon.upsertLexiconTerm({
1499
+ libraryId,
1500
+ term: existing.term,
1501
+ kind: existing.kind,
1502
+ meaning: patch.meaning ?? existing.meaning,
1503
+ usage: patch.usage ?? existing.usage,
1504
+ aliases: patch.aliases ?? existing.aliases,
1505
+ examples: patch.examples ?? existing.examples,
1506
+ context: patch.context ?? existing.context,
1507
+ knownBy: patch.knownBy ?? existing.knownBy,
1508
+ visibility: patch.visibility ?? existing.visibility,
1509
+ sensitivity: patch.sensitivity ?? existing.sensitivity,
1510
+ strength: patch.strength ?? existing.strength,
1511
+ confidence: existing.confidence,
1512
+ evidence: existing.evidence,
1513
+ source: 'user',
999
1514
  })
1515
+ return { libraryId, termId, ...result }
1516
+ }
1517
+ case 'lexiconDelete': {
1518
+ const libraryId = lexiconLibraryId(payload)
1519
+ const termId = String(payload?.termId || '').trim()
1520
+ if (!termId) throw new Error('termId is required')
1521
+ const lexicon = await lexiconApi()
1522
+ const result = await lexicon.deleteLexiconTerm({ libraryId, termId })
1523
+ return { libraryId, ...result }
1524
+ }
1525
+ // 归档 / 恢复(§2.3):公开生命周期的两个用户动作,只承认 active↔archived;
1526
+ // legacy pending 由迁移收口,rejected 是墓碑(核心 typed 拒绝)。
1527
+ case 'lexiconArchive': {
1528
+ const libraryId = lexiconLibraryId(payload)
1529
+ const termId = String(payload?.termId || '').trim()
1530
+ if (!termId) throw new Error('termId is required')
1531
+ const lexicon = await lexiconApi()
1532
+ const result = await lexicon.archiveLexiconTerm({ libraryId, termId })
1533
+ return { libraryId, ...result }
1534
+ }
1535
+ case 'lexiconRestore': {
1536
+ const libraryId = lexiconLibraryId(payload)
1537
+ const termId = String(payload?.termId || '').trim()
1538
+ if (!termId) throw new Error('termId is required')
1539
+ const lexicon = await lexiconApi()
1540
+ const result = await lexicon.restoreLexiconTerm({ libraryId, termId })
1541
+ return { libraryId, ...result }
1000
1542
  }
1001
1543
  case 'generatePrivate': {
1002
1544
  const sessionId = requireSessionId(payload)
@@ -1214,10 +1756,22 @@ export function registerRpc(ctx) {
1214
1756
  res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
1215
1757
  return
1216
1758
  }
1217
- const pathname = new URL(req.url || '/', 'http://dsh.internal').pathname
1759
+ const requestUrl = new URL(req.url || '/', 'http://dsh.internal')
1760
+ const pathname = requestUrl.pathname
1218
1761
  .replace(/^\/dsh-memu(?=\/|$)/, ENDPOINT)
1762
+ // Entry inventory (§10 P0 audit): dispatch is reachable from exactly
1763
+ // three places, each behind authorizeLibraryRoute — the export/import
1764
+ // URL routes below (authorized before any body or media read), the
1765
+ // GET attachment route (same), and the generic POST gate after the
1766
+ // envelope parse. Every other GET here serves static UI bytes or 405s;
1767
+ // no handler code path reaches dispatch without passing one gate.
1219
1768
  const exportMatch = /^\/dsh-memu-memory\/export\/([A-Za-z0-9._-]+)\.zip$/.exec(pathname)
1220
1769
  if (req.method === 'GET' && exportMatch) {
1770
+ const sessionId = requestUrl.searchParams.has('sessionId') ? requestUrl.searchParams.get('sessionId') : undefined
1771
+ if (!await authorizeLibraryRoute(c.connection, req, 'exportLibrary', { libraryId: exportMatch[1], ...(sessionId !== undefined ? { sessionId } : {}) })) {
1772
+ rejectLibraryRoute(res)
1773
+ return
1774
+ }
1221
1775
  try {
1222
1776
  const payload = await dispatch(ctx, 'exportLibrary', { libraryId: exportMatch[1] })
1223
1777
  await streamMediaExport(exportMatch[1], payload, res)
@@ -1226,6 +1780,11 @@ export function registerRpc(ctx) {
1226
1780
  }
1227
1781
  const importMatch = /^\/dsh-memu-memory\/import\/([A-Za-z0-9._-]+)\.zip$/.exec(pathname)
1228
1782
  if (req.method === 'POST' && importMatch) {
1783
+ const sessionId = requestUrl.searchParams.has('sessionId') ? requestUrl.searchParams.get('sessionId') : undefined
1784
+ if (!await authorizeLibraryRoute(c.connection, req, 'importMemories', { libraryId: importMatch[1], ...(sessionId !== undefined ? { sessionId } : {}) })) {
1785
+ rejectLibraryRoute(res)
1786
+ return
1787
+ }
1229
1788
  try {
1230
1789
  const bundle = await importMediaStream(importMatch[1], req)
1231
1790
  const value = await dispatch(ctx, 'importMemories', { libraryId: importMatch[1], memories: bundle.memories, graph: bundle.graph })
@@ -1251,9 +1810,13 @@ export function registerRpc(ctx) {
1251
1810
  }
1252
1811
  const attachment = /^\/dsh-memu-memory\/attachment\/([A-Za-z0-9._-]+)\/([a-f0-9]{64})$/.exec(pathname)
1253
1812
  if (req.method === 'GET' && attachment) {
1813
+ const sessionId = requestUrl.searchParams.has('sessionId') ? requestUrl.searchParams.get('sessionId') : undefined
1814
+ if (!await authorizeLibraryRoute(c.connection, req, 'getAttachment', { libraryId: attachment[1], ...(sessionId !== undefined ? { sessionId } : {}) })) {
1815
+ rejectLibraryRoute(res)
1816
+ return
1817
+ }
1254
1818
  try {
1255
- const url = new URL(req.url, 'http://dsh.internal')
1256
- const thumbnail = await attachmentThumbnail({ libraryId: attachment[1], sha256: attachment[2], sessionId: url.searchParams.get('sessionId') })
1819
+ const thumbnail = await attachmentThumbnail({ libraryId: attachment[1], sha256: attachment[2], sessionId: sessionId ?? null })
1257
1820
  if (!thumbnail) { res.writeHead(404, { 'Cache-Control': 'no-store' }); res.end('attachment missing'); return }
1258
1821
  res.writeHead(200, { 'Content-Type': thumbnail.mime, 'Cache-Control': 'private, no-store', 'X-Content-Type-Options': 'nosniff' })
1259
1822
  res.end(thumbnail.data)
@@ -1323,8 +1886,13 @@ export function registerRpc(ctx) {
1323
1886
  },
1324
1887
  }
1325
1888
  } else {
1889
+ const payload = envelope.payload || {}
1890
+ if (!await authorizeLibraryRoute(c.connection, req, endpoint, payload)) {
1891
+ rejectLibraryRoute(res)
1892
+ return
1893
+ }
1326
1894
  try {
1327
- result = { ok: true, value: await dispatch(ctx, endpoint, envelope.payload || {}) }
1895
+ result = { ok: true, value: await dispatch(ctx, endpoint, payload) }
1328
1896
  } catch (error) {
1329
1897
  result = {
1330
1898
  ok: false,