@modusensus/dsh-mneme 0.6.6 → 0.6.8

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 (117) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +463 -457
  3. package/cordis.patch.yml +15 -15
  4. package/lib/api.js +783 -693
  5. package/lib/client.js +1757 -1656
  6. package/lib/commands.js +64 -64
  7. package/lib/config.js +288 -288
  8. package/lib/dream/clustering.js +118 -118
  9. package/lib/dream/decisions.js +439 -439
  10. package/lib/dream/sleep.js +561 -554
  11. package/lib/dream/tag-extractor.js +156 -156
  12. package/lib/dream.js +935 -929
  13. package/lib/embedding.js +154 -154
  14. package/lib/entities/extractor.js +242 -242
  15. package/lib/hot-memory.js +53 -53
  16. package/lib/index.js +361 -361
  17. package/lib/inject.js +208 -208
  18. package/lib/local-embedder.js +282 -282
  19. package/lib/mirror.js +170 -170
  20. package/lib/parser/tag.js +59 -59
  21. package/lib/parser/wiki-link.js +38 -38
  22. package/lib/quality-filter.js +123 -123
  23. package/lib/reranker.js +218 -218
  24. package/lib/search/adaptive.js +22 -22
  25. package/lib/search/bm25.js +96 -96
  26. package/lib/search/tag-boost.js +61 -61
  27. package/lib/service.js +1726 -1726
  28. package/lib/settings.js +172 -142
  29. package/lib/store.js +2238 -2238
  30. package/lib/summarize.js +236 -236
  31. package/lib/tools.js +290 -290
  32. package/lib/vector-index.js +116 -116
  33. package/package.json +80 -75
  34. package/scripts/benchmark-embed.js +201 -201
  35. package/scripts/benchmark-recall.js +133 -133
  36. package/scripts/benchmark-rerank.js +166 -166
  37. package/scripts/e2e-dsh.js +218 -218
  38. package/scripts/stress-dsh.js +255 -255
  39. package/scripts/sync-lib.js +52 -52
  40. package/src/api.js +783 -693
  41. package/src/commands.js +64 -64
  42. package/src/config.js +288 -288
  43. package/src/dream/clustering.js +118 -118
  44. package/src/dream/decisions.js +439 -439
  45. package/src/dream/sleep.js +561 -554
  46. package/src/dream/tag-extractor.js +156 -156
  47. package/src/dream.js +935 -929
  48. package/src/embedding.js +154 -154
  49. package/src/entities/extractor.js +242 -242
  50. package/src/hot-memory.js +53 -53
  51. package/src/index.js +361 -361
  52. package/src/inject.js +208 -208
  53. package/src/local-embedder.js +282 -282
  54. package/src/mirror.js +170 -170
  55. package/src/parser/tag.js +59 -59
  56. package/src/parser/wiki-link.js +38 -38
  57. package/src/quality-filter.js +123 -123
  58. package/src/reranker.js +218 -218
  59. package/src/search/adaptive.js +22 -22
  60. package/src/search/bm25.js +96 -96
  61. package/src/search/tag-boost.js +61 -61
  62. package/src/service.js +1726 -1726
  63. package/src/settings.js +172 -142
  64. package/src/store.js +2238 -2238
  65. package/src/summarize.js +236 -236
  66. package/src/tools.js +290 -290
  67. package/src/vector-index.js +116 -116
  68. package/test/api.test.js +594 -549
  69. package/test/audit.test.js +448 -448
  70. package/test/benchmark.test.js +35 -35
  71. package/test/boundary-v0625.test.js +82 -82
  72. package/test/client.test.js +368 -368
  73. package/test/clustering.test.js +100 -100
  74. package/test/commands.test.js +69 -69
  75. package/test/config.test.js +50 -50
  76. package/test/conflict-freeze.test.js +290 -290
  77. package/test/directory.test.js +134 -134
  78. package/test/dream.test.js +903 -901
  79. package/test/entities.test.js +522 -522
  80. package/test/epistemic.test.js +298 -298
  81. package/test/fnew-0112.test.js +311 -311
  82. package/test/fnew-03.test.js +422 -422
  83. package/test/graph-api.test.js +175 -175
  84. package/test/helpers/dream-mock.js +82 -82
  85. package/test/hot-memory.test.js +174 -174
  86. package/test/inject.test.js +103 -103
  87. package/test/llm-audit.test.js +279 -279
  88. package/test/local-embedder.test.js +227 -227
  89. package/test/mirror-dirty.test.js +424 -424
  90. package/test/mirror-edit-digest.test.js +187 -187
  91. package/test/mirror-generation.test.js +499 -499
  92. package/test/mirror.test.js +249 -249
  93. package/test/normalize-decisions.test.js +120 -120
  94. package/test/peer-blockers.test.js +190 -190
  95. package/test/policy-epoch.test.js +259 -259
  96. package/test/provenance.test.js +103 -103
  97. package/test/quality-filter.test.js +118 -118
  98. package/test/reasoning-effort.test.js +199 -199
  99. package/test/recall-evals.test.js +235 -235
  100. package/test/recall-layer.test.js +315 -315
  101. package/test/receipt-chain.test.js +451 -451
  102. package/test/reflection.test.js +226 -226
  103. package/test/reranker.test.js +240 -240
  104. package/test/search-fusion.test.js +90 -90
  105. package/test/semantic.test.js +124 -124
  106. package/test/service-search.test.js +199 -199
  107. package/test/service.test.js +435 -435
  108. package/test/settings.test.js +118 -101
  109. package/test/sleep.test.js +365 -365
  110. package/test/store.test.js +436 -436
  111. package/test/stress.test.js +209 -209
  112. package/test/summarize.test.js +191 -191
  113. package/test/tag-boost.test.js +125 -125
  114. package/test/tag.test.js +312 -312
  115. package/test/tools.test.js +285 -285
  116. package/test/vector-index.test.js +221 -221
  117. package/test/wiki-link.test.js +332 -332
package/lib/api.js CHANGED
@@ -1,693 +1,783 @@
1
- import { URL } from "node:url";
2
- import { timingSafeEqual } from "node:crypto";
3
-
4
- function sendJson(res, status, payload) {
5
- res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
6
- res.end(JSON.stringify(payload));
7
- }
8
-
9
- /**
10
- * Mask an API key for client display: keep a recognizable prefix and suffix,
11
- * hide the middle. Empty keys stay empty; short keys are fully hidden.
12
- * The mask only exists in the API layer — storage keeps the real key.
13
- */
14
- function maskApiKey(key) {
15
- if (!key) return "";
16
- if (key.length <= 8) return "***";
17
- return `${key.slice(0, 3)}***${key.slice(-4)}`;
18
- }
19
-
20
- /** True when the request carries the configured apiToken (or no token is set). */
21
- function isAuthorized(req, apiToken) {
22
- if (!apiToken) return true;
23
- const raw = req.headers?.authorization ?? req.headers?.["x-dsh-mneme-token"] ?? "";
24
- const token = raw.startsWith("Bearer ") ? raw.slice(7).trim() : raw.trim();
25
- if (token === "" || token.length !== apiToken.length) return false;
26
- // Constant-time comparison: avoid leaking the token via timing when the API
27
- // is exposed beyond loopback.
28
- return timingSafeEqual(Buffer.from(token), Buffer.from(apiToken));
29
- }
30
-
31
- /**
32
- * Reject a request with 401 when auth is enabled and the token is missing or
33
- * wrong. Returns true when the request may proceed.
34
- */
35
- function requireAuth(req, res, apiToken) {
36
- if (isAuthorized(req, apiToken)) return true;
37
- sendJson(res, 401, { error: "unauthorized" });
38
- return false;
39
- }
40
-
41
- /** Collect the request body as text (tolerant of empty/invalid bodies). */
42
- function readBody(req) {
43
- return new Promise((resolve) => {
44
- let body = "";
45
- req.on("data", (chunk) => { body += chunk; });
46
- req.on("end", () => resolve(body));
47
- req.on("error", () => resolve(""));
48
- });
49
- }
50
-
51
- function parseBody(text) {
52
- try {
53
- return JSON.parse(text || "{}");
54
- } catch {
55
- return {};
56
- }
57
- }
58
-
59
- export function createApi(ctx, service, settings, commands, embedder, semantic = null, apiToken = "") {
60
- const disposers = [];
61
-
62
- // Ensure the service has an embedder when the API layer was handed one
63
- // (tests wire the embedder through the API instead of index.js). Without
64
- // this, /api/dsh-mneme/search would silently degrade to keyword-only.
65
- if (embedder && typeof service.setEmbedder === "function") {
66
- service.setEmbedder(embedder);
67
- }
68
-
69
- const register = (route) => {
70
- disposers.push(ctx.webServer.register(route));
71
- };
72
-
73
- // /api/dsh-mneme prefix fallback → 404 JSON for unknown sub-paths
74
- register({
75
- kind: "prefix",
76
- path: "/api/dsh-mneme",
77
- handler(req, res) {
78
- sendJson(res, 404, { error: "not-found" });
79
- }
80
- });
81
-
82
- register({
83
- kind: "exact",
84
- path: "/api/dsh-mneme/list",
85
- handler(req, res) {
86
- try {
87
- const url = new URL(req.url, "http://localhost");
88
- const type = url.searchParams.get("type") ?? undefined;
89
- const limit = Number(url.searchParams.get("limit") ?? 50);
90
- const offset = Number(url.searchParams.get("offset") ?? 0);
91
- const items = service.toApiList(service.list({ type, limit, offset }));
92
- sendJson(res, 200, { items, total: service.count(type) });
93
- } catch {
94
- sendJson(res, 500, { error: "internal" });
95
- }
96
- }
97
- });
98
-
99
- register({
100
- kind: "exact",
101
- path: "/api/dsh-mneme/search",
102
- handler(req, res) {
103
- try {
104
- const url = new URL(req.url, "http://localhost");
105
- const q = url.searchParams.get("q") ?? "";
106
- const limit = Number(url.searchParams.get("topK") ?? url.searchParams.get("limit") ?? 20);
107
- // mode selects the recall strategy (defaults to auto):
108
- // auto (default) keyword first, vector fills remaining slots
109
- // hybrid vector first, keyword fills remaining slots; scores of
110
- // memories hit by both sides are weight-blended
111
- // vector vector only, falls back to keyword when the vector path
112
- // is unavailable (no embedder or a throwing one)
113
- // keyword literal text only; never queries the embedder
114
- // rerank=false disables the cross-encoder reorder for this request;
115
- // the response `mode` field reports which path actually produced rows.
116
- const mode = url.searchParams.get("mode") ?? "auto";
117
- const rerank = url.searchParams.get("rerank") !== "false";
118
- const query = q.trim();
119
- if (!query) {
120
- sendJson(res, 200, { items: [], mode: "keyword" });
121
- return;
122
- }
123
- // Route through the unified semantic pipeline; any vector/rerank
124
- // failure degrades to keyword results inside searchMemories. The
125
- // returned promise lets the test double await the async search.
126
- return Promise.resolve(
127
- service.searchMemories(query, { mode, topK: limit, useRerank: rerank })
128
- ).then((rows) => {
129
- // mode reflects what actually happened: rows marked `vector` came
130
- // through the semantic path, everything else is keyword fallback.
131
- const used = rows.some((m) => m.vector === true) ? "vector" : "keyword";
132
- sendJson(res, 200, { items: service.toApiList(rows), mode: used });
133
- }).catch(() => {
134
- sendJson(res, 200, { items: service.toApiList(service.search(query, { limit })), mode: "keyword" });
135
- });
136
- } catch {
137
- sendJson(res, 500, { error: "internal" });
138
- }
139
- }
140
- });
141
-
142
- // --- user profile ---
143
- register({
144
- kind: "exact",
145
- path: "/api/dsh-mneme/profile",
146
- handler(req, res) {
147
- try {
148
- if (req.method === "PUT" || req.method === "POST") {
149
- if (!requireAuth(req, res, apiToken)) return;
150
- return readBody(req).then((text) => {
151
- const body = parseBody(text);
152
- settings.setProfile(typeof body.profile === "string" ? body.profile : "");
153
- sendJson(res, 200, { profile: settings.getProfile() });
154
- });
155
- }
156
- sendJson(res, 200, { profile: settings.getProfile() });
157
- } catch {
158
- sendJson(res, 500, { error: "internal" });
159
- }
160
- }
161
- });
162
-
163
- // --- rules ---
164
- register({
165
- kind: "exact",
166
- path: "/api/dsh-mneme/rules",
167
- handler(req, res) {
168
- try {
169
- if (req.method === "PUT" || req.method === "POST") {
170
- if (!requireAuth(req, res, apiToken)) return;
171
- return readBody(req).then((text) => {
172
- const body = parseBody(text);
173
- settings.setRules(Array.isArray(body.rules) ? body.rules : []);
174
- sendJson(res, 200, { rules: settings.getRules() });
175
- });
176
- }
177
- sendJson(res, 200, { rules: settings.getRules() });
178
- } catch {
179
- sendJson(res, 500, { error: "internal" });
180
- }
181
- }
182
- });
183
-
184
- // --- vector search config ---
185
- register({
186
- kind: "exact",
187
- path: "/api/dsh-mneme/vector-config",
188
- handler(req, res) {
189
- try {
190
- // Secret-bearing endpoint: fully protected when apiToken is set.
191
- if (!requireAuth(req, res, apiToken)) return;
192
- if (req.method === "PUT" || req.method === "POST") {
193
- return readBody(req).then((text) => {
194
- const body = parseBody(text);
195
- // An empty apiKey, or one that already looks masked (round-trips
196
- // through maskApiKey unchanged), means "keep the existing key".
197
- // Only a fresh, unmasked key is treated as a real replacement.
198
- const prev = settings.getVectorConfig();
199
- const incoming = typeof body.apiKey === "string" ? body.apiKey.trim() : "";
200
- const isMaskedOrEmpty = incoming === "" || maskApiKey(incoming) === incoming;
201
- const key = isMaskedOrEmpty
202
- ? (prev?.apiKey ?? "")
203
- : incoming;
204
- const cfg = settings.setVectorConfig({
205
- enabled: body.enabled,
206
- baseUrl: body.baseUrl,
207
- apiKey: key,
208
- model: body.model
209
- });
210
- sendJson(res, 200, { config: { ...cfg, apiKey: maskApiKey(cfg.apiKey) } });
211
- });
212
- }
213
- const cfg = settings.getVectorConfig() ?? { enabled: false, baseUrl: "", apiKey: "", model: "" };
214
- sendJson(res, 200, { config: { ...cfg, apiKey: maskApiKey(cfg.apiKey) } });
215
- } catch {
216
- sendJson(res, 500, { error: "internal" });
217
- }
218
- }
219
- });
220
-
221
- // --- vector re-index (backfill embeddings for rows missing them) ---
222
- register({
223
- kind: "exact",
224
- path: "/api/dsh-mneme/vector-reindex",
225
- handler(req, res) {
226
- try {
227
- if (!requireAuth(req, res, apiToken)) return;
228
- if (!embedder) {
229
- sendJson(res, 200, { indexed: 0, skipped: 0, error: "vector-unavailable" });
230
- return;
231
- }
232
- const url = new URL(req.url, "http://localhost");
233
- const limit = Number(url.searchParams.get("limit") ?? 100);
234
- // Unified re-index entry: works for both the legacy OpenAI embedder and
235
- // the new local/ollama backends (which have no reindexMissing method).
236
- const viaIndex = semantic?.vectorIndex && semantic?.vectorIndex.rebuildIndex;
237
- const task = viaIndex
238
- ? semantic.vectorIndex.rebuildIndex(embedder, { limit })
239
- : embedder.reindexMissing ? embedder.reindexMissing(limit) : Promise.resolve({ indexed: 0, skipped: 0, error: "vector-unavailable" });
240
- // Return the chain so awaiting callers (tests/health checks) observe the
241
- // finished response rather than racing the async backfill.
242
- return task.then((result) => {
243
- sendJson(res, 200, result);
244
- }).catch(() => {
245
- sendJson(res, 200, { indexed: 0, skipped: 0, error: "vector-failed" });
246
- });
247
- } catch {
248
- sendJson(res, 500, { error: "internal" });
249
- }
250
- }
251
- });
252
-
253
- // --- semantic pipeline status (model, index, reranker) ---
254
- register({
255
- kind: "exact",
256
- path: "/api/dsh-mneme/semantic",
257
- handler(req, res) {
258
- try {
259
- const stats = semantic?.vectorIndex?.getStats?.() ?? null;
260
- sendJson(res, 200, {
261
- embedProvider: embedder ? (embedder.constructor?.name ?? "unknown") : null,
262
- modelHash: embedder?.modelHash ?? null,
263
- dimension: embedder?.dimension ?? null,
264
- reranker: semantic?.reranker ? "ready" : null,
265
- index: stats
266
- });
267
- } catch {
268
- sendJson(res, 500, { error: "internal" });
269
- }
270
- }
271
- });
272
-
273
- // --- LLM audit trail (Bug8): paginated read + aggregate stats ---
274
- // Read-only endpoints, so like list/search/semantic they stay open even when
275
- // apiToken is set. The stats aggregate budget by source over the last N days.
276
- register({
277
- kind: "exact",
278
- path: "/api/dsh-mneme/semantic/llm-audit",
279
- handler(req, res) {
280
- try {
281
- const url = new URL(req.url, "http://localhost");
282
- const page = Math.max(1, Number(url.searchParams.get("page") ?? 1) || 1);
283
- const pageSize = Math.min(200, Math.max(1, Number(url.searchParams.get("pageSize") ?? 50) || 50));
284
- const source = url.searchParams.get("source") ?? undefined;
285
- const items = service.listLlmAudits?.({ limit: pageSize, offset: (page - 1) * pageSize, source }) ?? [];
286
- const total = service.countLlmAudits?.({ source }) ?? items.length;
287
- sendJson(res, 200, { items, total, page, pageSize });
288
- } catch {
289
- sendJson(res, 500, { error: "internal" });
290
- }
291
- }
292
- });
293
-
294
- register({
295
- kind: "exact",
296
- path: "/api/dsh-mneme/semantic/llm-audit/stats",
297
- handler(req, res) {
298
- try {
299
- const url = new URL(req.url, "http://localhost");
300
- const days = Math.max(1, Math.min(365, Number(url.searchParams.get("days") ?? 7) || 7));
301
- const stats = service.getLlmAuditStats?.({ days }) ?? null;
302
- sendJson(res, 200, stats ?? { error: "unavailable" });
303
- } catch {
304
- sendJson(res, 500, { error: "internal" });
305
- }
306
- }
307
- });
308
-
309
- // --- ego graph: 1-2 hop neighborhood of one entity (graph panel P1) ---
310
- // Read-only like list/search/semantic, so it stays open when apiToken is set.
311
- // BFS from the root entity over entity_relations (both directions; the
312
- // idx_relations_from/to indexes keep a 2-hop walk in the tens of ms even
313
- // for a few thousand nodes). `distance` on each node is the hop count from
314
- // the root so the UI can shade the frontier. The API is graph-traversal
315
- // only — nodes carry no attr payload; hover summaries come from
316
- // /semantic/graph/entity-attrs.
317
- register({
318
- kind: "exact",
319
- path: "/api/dsh-mneme/semantic/graph/ego",
320
- handler(req, res) {
321
- try {
322
- const url = new URL(req.url, "http://localhost");
323
- const name = (url.searchParams.get("entity") ?? "").trim();
324
- if (!name) {
325
- sendJson(res, 400, { error: "missing-entity" });
326
- return;
327
- }
328
- const root = service.findEntityByName?.(name);
329
- if (!root) {
330
- sendJson(res, 404, { error: "entity-not-found" });
331
- return;
332
- }
333
- const depth = Math.max(1, Math.min(2, Number(url.searchParams.get("depth") ?? 1) || 1));
334
- const limit = Math.max(1, Math.min(100, Number(url.searchParams.get("limit") ?? 40) || 40));
335
-
336
- const nodes = new Map([[root.id, { ...root, distance: 0 }]]);
337
- let frontier = [root.id];
338
- for (let d = 1; d <= depth && nodes.size < limit; d++) {
339
- const next = [];
340
- for (const id of frontier) {
341
- for (const rel of service.getRelations?.(id) ?? []) {
342
- const other = rel.from_entity === id ? rel.to_entity : rel.from_entity;
343
- if (nodes.has(other) || nodes.size >= limit) continue;
344
- const entity = service.findEntityById?.(other);
345
- if (!entity) continue;
346
- nodes.set(other, { ...entity, distance: d });
347
- next.push(other);
348
- }
349
- }
350
- frontier = next;
351
- }
352
-
353
- // Collect every relation whose endpoints both survived the limit cut;
354
- // each edge is visited twice (once per endpoint) so dedupe by id.
355
- const edgeMap = new Map();
356
- for (const id of nodes.keys()) {
357
- for (const rel of service.getRelations?.(id) ?? []) {
358
- if (nodes.has(rel.from_entity) && nodes.has(rel.to_entity)) {
359
- edgeMap.set(rel.id, rel);
360
- }
361
- }
362
- }
363
-
364
- sendJson(res, 200, {
365
- root: { id: root.id, name: root.name, type: root.type ?? null, mention_count: root.mention_count ?? 1 },
366
- nodes: [...nodes.values()].map((n) => ({
367
- id: n.id,
368
- name: n.name,
369
- type: n.type ?? null,
370
- mention_count: n.mention_count ?? 1,
371
- distance: n.distance
372
- })),
373
- edges: [...edgeMap.values()].map((e) => ({
374
- id: e.id,
375
- from: e.from_entity,
376
- to: e.to_entity,
377
- relation_type: e.relation_type,
378
- memory_id: e.memory_id ?? null,
379
- created_at: e.created_at
380
- }))
381
- });
382
- } catch {
383
- sendJson(res, 500, { error: "internal" });
384
- }
385
- }
386
- });
387
-
388
- // --- entity attrs: current valid attrs for one entity (graph hover panel) ---
389
- // Read-only; mirrors getCurrentAttrs (valid_until IS NULL). Also used as the
390
- // graph panel's fallback list when the ego graph is too sparse to draw.
391
- register({
392
- kind: "exact",
393
- path: "/api/dsh-mneme/semantic/graph/entity-attrs",
394
- handler(req, res) {
395
- try {
396
- const url = new URL(req.url, "http://localhost");
397
- const name = (url.searchParams.get("entity") ?? "").trim();
398
- if (!name) {
399
- sendJson(res, 400, { error: "missing-entity" });
400
- return;
401
- }
402
- const entity = service.findEntityByName?.(name);
403
- if (!entity) {
404
- sendJson(res, 404, { error: "entity-not-found" });
405
- return;
406
- }
407
- const attrs = service.getCurrentAttrs?.(entity.id) ?? [];
408
- sendJson(res, 200, {
409
- entity: { id: entity.id, name: entity.name, type: entity.type ?? null, mention_count: entity.mention_count ?? 1 },
410
- attrs: Array.isArray(attrs)
411
- ? attrs.map((a) => ({
412
- key: a.attr_key,
413
- value: a.attr_value,
414
- confidence: a.confidence ?? null,
415
- valid_from: a.valid_from ?? null
416
- }))
417
- : []
418
- });
419
- } catch {
420
- sendJson(res, 500, { error: "internal" });
421
- }
422
- }
423
- });
424
-
425
- // --- wiki-link back links (v0.6.1) --------------------------------------
426
- // Read-only like the graph endpoints, so it stays open when apiToken is set.
427
- // GET /api/dsh-mneme/wikilinks/backlinks?id=<memoryId> → memories whose
428
- // content carries a [[wiki-link]] resolving to the given memory.
429
- register({
430
- kind: "exact",
431
- path: "/api/dsh-mneme/wikilinks/backlinks",
432
- handler(req, res) {
433
- try {
434
- const url = new URL(req.url, "http://localhost");
435
- const id = (url.searchParams.get("id") ?? "").trim();
436
- if (!id) {
437
- sendJson(res, 400, { error: "missing-id" });
438
- return;
439
- }
440
- const memory = service.getById?.(id) ?? null;
441
- const backlinks = (service.getBacklinks?.(id) ?? []).map(({ source, relation }) => ({
442
- id: source.id,
443
- title: source.title,
444
- type: source.type,
445
- created_at: relation.created_at
446
- }));
447
- sendJson(res, 200, {
448
- memoryId: id,
449
- memory: memory ? { id: memory.id, title: memory.title, type: memory.type } : null,
450
- backlinks
451
- });
452
- } catch {
453
- sendJson(res, 500, { error: "internal" });
454
- }
455
- }
456
- });
457
-
458
- // --- wiki-link forward links (v0.6.1) -----------------------------------
459
- // GET /api/dsh-mneme/wikilinks/forward?id=<memoryId> → memories the given
460
- // memory explicitly links to. Unresolved target titles surface with id:null.
461
- register({
462
- kind: "exact",
463
- path: "/api/dsh-mneme/wikilinks/forward",
464
- handler(req, res) {
465
- try {
466
- const url = new URL(req.url, "http://localhost");
467
- const id = (url.searchParams.get("id") ?? "").trim();
468
- if (!id) {
469
- sendJson(res, 400, { error: "missing-id" });
470
- return;
471
- }
472
- const memory = service.getById?.(id) ?? null;
473
- const links = (service.getForwardLinks?.(id) ?? []).map(({ target, relation }) => ({
474
- id: target?.id ?? null,
475
- title: target?.title ?? relation.to_entity,
476
- type: target?.type ?? null,
477
- created_at: relation.created_at
478
- }));
479
- sendJson(res, 200, {
480
- memoryId: id,
481
- memory: memory ? { id: memory.id, title: memory.title, type: memory.type } : null,
482
- links
483
- });
484
- } catch {
485
- sendJson(res, 500, { error: "internal" });
486
- }
487
- }
488
- });
489
-
490
- // --- wiki-link resolve (v0.6.1) -----------------------------------------
491
- // GET /api/dsh-mneme/wikilinks/resolve?title=<title> → case-insensitive exact
492
- // title match against the memories table (the resolution used when writing
493
- // links_to relations). 404 when no memory matches.
494
- register({
495
- kind: "exact",
496
- path: "/api/dsh-mneme/wikilinks/resolve",
497
- handler(req, res) {
498
- try {
499
- const url = new URL(req.url, "http://localhost");
500
- const title = (url.searchParams.get("title") ?? "").trim();
501
- if (!title) {
502
- sendJson(res, 400, { error: "missing-title" });
503
- return;
504
- }
505
- const memory = service.resolveWikiLink?.(title) ?? null;
506
- if (!memory) {
507
- sendJson(res, 404, { error: "memory-not-found", title });
508
- return;
509
- }
510
- // Note: no `source` field — it may carry file paths/internal host info
511
- // and this endpoint is read-only without auth when apiToken is set.
512
- sendJson(res, 200, {
513
- title,
514
- memory: {
515
- id: memory.id,
516
- title: memory.title,
517
- type: memory.type
518
- }
519
- });
520
- } catch {
521
- sendJson(res, 500, { error: "internal" });
522
- }
523
- }
524
- });
525
-
526
- // --- health: mirror sync state (F-NEW-03 / v0.3.6) ---
527
- // Auth-gated; only returns a sanitized error code (never raw last_error which
528
- // may leak paths/token-like strings/internal hosts). On state read failure it
529
- // reports unknown/degraded (fail-closed) instead of a false dirty=false.
530
- register({
531
- kind: "exact",
532
- path: "/api/dsh-mneme/health",
533
- handler(req, res) {
534
- if (!requireAuth(req, res, apiToken)) return;
535
- let state = null;
536
- try {
537
- state = service.getMirrorHealth?.() ?? null;
538
- } catch {
539
- // read failure is itself a health signal: do not report a false clean
540
- sendJson(res, 200, { mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null } });
541
- return;
542
- }
543
- if (!state) {
544
- sendJson(res, 200, { mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null } });
545
- return;
546
- }
547
- // Real read failure surfaces as dirty === null (peer blocker 5): report
548
- // unknown explicitly instead of collapsing into a false "ok"/"degraded".
549
- if (state.dirty === null) {
550
- sendJson(res, 200, {
551
- mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null }
552
- });
553
- return;
554
- }
555
- // Sanitized: boolean dirty + coarse status only; error string is mapped to
556
- // a bounded code, never echoed verbatim.
557
- let code = null;
558
- if (state.last_error) {
559
- const e = String(state.last_error);
560
- code = /enospc|no space/i.test(e) ? "no-space" : /permission|eacces/i.test(e) ? "permission" : "sync-failed";
561
- }
562
- sendJson(res, 200, {
563
- mirror: {
564
- dirty: state.dirty === true,
565
- status: state.dirty === true ? "degraded" : (code ? "degraded" : "ok"),
566
- last_error: code,
567
- last_attempt: state.last_attempt ?? null,
568
- success_at: state.success_at ?? null
569
- }
570
- });
571
- }
572
- });
573
-
574
- // --- custom commands ---
575
- register({
576
- kind: "exact",
577
- path: "/api/dsh-mneme/commands",
578
- handler(req, res) {
579
- try {
580
- if (req.method === "POST") {
581
- if (!requireAuth(req, res, apiToken)) return;
582
- return readBody(req).then((text) => {
583
- const body = parseBody(text);
584
- try {
585
- const command = commands.add({
586
- name: body.name,
587
- description: body.description,
588
- instruction: body.instruction
589
- });
590
- sendJson(res, 200, { command });
591
- } catch (error) {
592
- sendJson(res, 400, { error: error.message });
593
- }
594
- });
595
- }
596
- if (req.method === "DELETE") {
597
- if (!requireAuth(req, res, apiToken)) return;
598
- const url = new URL(req.url, "http://localhost");
599
- const id = url.searchParams.get("id");
600
- const removed = id ? commands.remove(id) : false;
601
- sendJson(res, 200, { removed });
602
- return;
603
- }
604
- sendJson(res, 200, { commands: commands.list() });
605
- } catch {
606
- sendJson(res, 500, { error: "internal" });
607
- }
608
- }
609
- });
610
-
611
- // --- memory tags (v0.6.2) ------------------------------------------------
612
- // GET /api/dsh-mneme/memory/tags?id=<memoryId> → live entity_attrs-backed
613
- // tag set for one memory plus the manualTagEnabled gate (so the panel
614
- // can hide tag editing when the manual path is off). Read-only, stays
615
- // open when apiToken is set (like list/search/semantic).
616
- // POST /api/dsh-mneme/memory/tags { id, tags } overwrite the live tag set
617
- // via service.setMemoryTags (manualTagEnabled gate); 409 when the gate
618
- // is closed. Auth-gated like the other write endpoints.
619
- register({
620
- kind: "exact",
621
- path: "/api/dsh-mneme/memory/tags",
622
- handler(req, res) {
623
- try {
624
- if (req.method === "POST" || req.method === "PUT") {
625
- if (!requireAuth(req, res, apiToken)) return;
626
- return readBody(req).then((text) => {
627
- const body = parseBody(text);
628
- const id = typeof body.id === "string" ? body.id.trim() : "";
629
- if (!id) {
630
- sendJson(res, 400, { error: "missing-id" });
631
- return;
632
- }
633
- const memory = service.getById?.(id) ?? null;
634
- if (!memory) {
635
- sendJson(res, 404, { error: "memory-not-found" });
636
- return;
637
- }
638
- const result = service.setMemoryTags(id, Array.isArray(body.tags) ? body.tags : []);
639
- if (result?.ok === false) {
640
- sendJson(res, 409, { error: result.error || "tags-disabled" });
641
- return;
642
- }
643
- sendJson(res, 200, { ok: true, memoryId: id, tags: result?.tags ?? [] });
644
- });
645
- }
646
- const url = new URL(req.url, "http://localhost");
647
- const id = (url.searchParams.get("id") ?? "").trim();
648
- if (!id) {
649
- sendJson(res, 400, { error: "missing-id" });
650
- return;
651
- }
652
- const memory = service.getById?.(id) ?? null;
653
- if (!memory) {
654
- sendJson(res, 404, { error: "memory-not-found" });
655
- return;
656
- }
657
- const tags = service.getMemoryTags?.(id) ?? [];
658
- sendJson(res, 200, {
659
- memoryId: id,
660
- tags: Array.isArray(tags) ? tags : [],
661
- manualTagEnabled: service.manualTagEnabled?.() ?? true
662
- });
663
- } catch {
664
- sendJson(res, 500, { error: "internal" });
665
- }
666
- }
667
- });
668
-
669
- // --- directory view (v0.6.3) ---------------------------------------------
670
- // GET /api/dsh-mneme/directory memories grouped by tag as
671
- // { groups: [{ tag, memories: [...] }], untagged: [...] }. Live-only
672
- // (forgotten/archived/session-disposed excluded), groups tag-sorted, members
673
- // importance+updated DESC. Read-only, stays open when apiToken is set.
674
- register({
675
- kind: "exact",
676
- path: "/api/dsh-mneme/directory",
677
- handler(req, res) {
678
- try {
679
- const dir = service.getDirectory?.() ?? { groups: [], untagged: [] };
680
- sendJson(res, 200, dir);
681
- } catch {
682
- sendJson(res, 500, { error: "internal" });
683
- }
684
- }
685
- });
686
-
687
- return {
688
- routes: 19,
689
- dispose: () => {
690
- for (const dispose of disposers) dispose();
691
- }
692
- };
693
- }
1
+ import { URL } from "node:url";
2
+ import { timingSafeEqual } from "node:crypto";
3
+
4
+ function sendJson(res, status, payload) {
5
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
6
+ res.end(JSON.stringify(payload));
7
+ }
8
+
9
+ /**
10
+ * Mask an API key for client display: keep a recognizable prefix and suffix,
11
+ * hide the middle. Empty keys stay empty; short keys are fully hidden.
12
+ * The mask only exists in the API layer — storage keeps the real key.
13
+ */
14
+ function maskApiKey(key) {
15
+ if (!key) return "";
16
+ if (key.length <= 8) return "***";
17
+ return `${key.slice(0, 3)}***${key.slice(-4)}`;
18
+ }
19
+
20
+ /** True when the request carries the configured apiToken (or no token is set). */
21
+ function isAuthorized(req, apiToken) {
22
+ if (!apiToken) return true;
23
+ const raw = req.headers?.authorization ?? req.headers?.["x-dsh-mneme-token"] ?? "";
24
+ const token = raw.startsWith("Bearer ") ? raw.slice(7).trim() : raw.trim();
25
+ if (token === "" || token.length !== apiToken.length) return false;
26
+ // Constant-time comparison: avoid leaking the token via timing when the API
27
+ // is exposed beyond loopback.
28
+ return timingSafeEqual(Buffer.from(token), Buffer.from(apiToken));
29
+ }
30
+
31
+ /**
32
+ * Reject a request with 401 when auth is enabled and the token is missing or
33
+ * wrong. Returns true when the request may proceed.
34
+ */
35
+ function requireAuth(req, res, apiToken) {
36
+ if (isAuthorized(req, apiToken)) return true;
37
+ sendJson(res, 401, { error: "unauthorized" });
38
+ return false;
39
+ }
40
+
41
+ /** Collect the request body as text (tolerant of empty/invalid bodies). */
42
+ function readBody(req) {
43
+ return new Promise((resolve) => {
44
+ let body = "";
45
+ req.on("data", (chunk) => { body += chunk; });
46
+ req.on("end", () => resolve(body));
47
+ req.on("error", () => resolve(""));
48
+ });
49
+ }
50
+
51
+ function parseBody(text) {
52
+ try {
53
+ return JSON.parse(text || "{}");
54
+ } catch {
55
+ return {};
56
+ }
57
+ }
58
+
59
+ export function createApi(ctx, service, settings, commands, embedder, semantic = null, apiToken = "") {
60
+ const disposers = [];
61
+
62
+ // Ensure the service has an embedder when the API layer was handed one
63
+ // (tests wire the embedder through the API instead of index.js). Without
64
+ // this, /api/dsh-mneme/search would silently degrade to keyword-only.
65
+ if (embedder && typeof service.setEmbedder === "function") {
66
+ service.setEmbedder(embedder);
67
+ }
68
+
69
+ const register = (route) => {
70
+ disposers.push(ctx.webServer.register(route));
71
+ };
72
+
73
+ // /api/dsh-mneme prefix fallback → 404 JSON for unknown sub-paths
74
+ register({
75
+ kind: "prefix",
76
+ path: "/api/dsh-mneme",
77
+ handler(req, res) {
78
+ sendJson(res, 404, { error: "not-found" });
79
+ }
80
+ });
81
+
82
+ register({
83
+ kind: "exact",
84
+ path: "/api/dsh-mneme/list",
85
+ handler(req, res) {
86
+ try {
87
+ const url = new URL(req.url, "http://localhost");
88
+ const type = url.searchParams.get("type") ?? undefined;
89
+ const limit = Number(url.searchParams.get("limit") ?? 50);
90
+ const offset = Number(url.searchParams.get("offset") ?? 0);
91
+ const items = service.toApiList(service.list({ type, limit, offset }));
92
+ sendJson(res, 200, { items, total: service.count(type) });
93
+ } catch {
94
+ sendJson(res, 500, { error: "internal" });
95
+ }
96
+ }
97
+ });
98
+
99
+ register({
100
+ kind: "exact",
101
+ path: "/api/dsh-mneme/search",
102
+ handler(req, res) {
103
+ try {
104
+ const url = new URL(req.url, "http://localhost");
105
+ const q = url.searchParams.get("q") ?? "";
106
+ const limit = Number(url.searchParams.get("topK") ?? url.searchParams.get("limit") ?? 20);
107
+ // mode selects the recall strategy (defaults to auto):
108
+ // auto (default) keyword first, vector fills remaining slots
109
+ // hybrid vector first, keyword fills remaining slots; scores of
110
+ // memories hit by both sides are weight-blended
111
+ // vector vector only, falls back to keyword when the vector path
112
+ // is unavailable (no embedder or a throwing one)
113
+ // keyword literal text only; never queries the embedder
114
+ // rerank=false disables the cross-encoder reorder for this request;
115
+ // the response `mode` field reports which path actually produced rows.
116
+ const mode = url.searchParams.get("mode") ?? "auto";
117
+ const rerank = url.searchParams.get("rerank") !== "false";
118
+ const query = q.trim();
119
+ if (!query) {
120
+ sendJson(res, 200, { items: [], mode: "keyword" });
121
+ return;
122
+ }
123
+ // Route through the unified semantic pipeline; any vector/rerank
124
+ // failure degrades to keyword results inside searchMemories. The
125
+ // returned promise lets the test double await the async search.
126
+ return Promise.resolve(
127
+ service.searchMemories(query, { mode, topK: limit, useRerank: rerank })
128
+ ).then((rows) => {
129
+ // mode reflects what actually happened: rows marked `vector` came
130
+ // through the semantic path, everything else is keyword fallback.
131
+ const used = rows.some((m) => m.vector === true) ? "vector" : "keyword";
132
+ sendJson(res, 200, { items: service.toApiList(rows), mode: used });
133
+ }).catch(() => {
134
+ sendJson(res, 200, { items: service.toApiList(service.search(query, { limit })), mode: "keyword" });
135
+ });
136
+ } catch {
137
+ sendJson(res, 500, { error: "internal" });
138
+ }
139
+ }
140
+ });
141
+
142
+ // --- user profile ---
143
+ register({
144
+ kind: "exact",
145
+ path: "/api/dsh-mneme/profile",
146
+ handler(req, res) {
147
+ try {
148
+ if (req.method === "PUT" || req.method === "POST") {
149
+ if (!requireAuth(req, res, apiToken)) return;
150
+ return readBody(req).then((text) => {
151
+ const body = parseBody(text);
152
+ settings.setProfile(typeof body.profile === "string" ? body.profile : "");
153
+ sendJson(res, 200, { profile: settings.getProfile() });
154
+ });
155
+ }
156
+ sendJson(res, 200, { profile: settings.getProfile() });
157
+ } catch {
158
+ sendJson(res, 500, { error: "internal" });
159
+ }
160
+ }
161
+ });
162
+
163
+ // --- rules ---
164
+ register({
165
+ kind: "exact",
166
+ path: "/api/dsh-mneme/rules",
167
+ handler(req, res) {
168
+ try {
169
+ if (req.method === "PUT" || req.method === "POST") {
170
+ if (!requireAuth(req, res, apiToken)) return;
171
+ return readBody(req).then((text) => {
172
+ const body = parseBody(text);
173
+ settings.setRules(Array.isArray(body.rules) ? body.rules : []);
174
+ sendJson(res, 200, { rules: settings.getRules() });
175
+ });
176
+ }
177
+ sendJson(res, 200, { rules: settings.getRules() });
178
+ } catch {
179
+ sendJson(res, 500, { error: "internal" });
180
+ }
181
+ }
182
+ });
183
+
184
+ // --- vector search config ---
185
+ register({
186
+ kind: "exact",
187
+ path: "/api/dsh-mneme/vector-config",
188
+ handler(req, res) {
189
+ try {
190
+ // Secret-bearing endpoint: fully protected when apiToken is set.
191
+ if (!requireAuth(req, res, apiToken)) return;
192
+ if (req.method === "PUT" || req.method === "POST") {
193
+ return readBody(req).then((text) => {
194
+ const body = parseBody(text);
195
+ // An empty apiKey, or one that already looks masked (round-trips
196
+ // through maskApiKey unchanged), means "keep the existing key".
197
+ // Only a fresh, unmasked key is treated as a real replacement.
198
+ const prev = settings.getVectorConfig();
199
+ const incoming = typeof body.apiKey === "string" ? body.apiKey.trim() : "";
200
+ const isMaskedOrEmpty = incoming === "" || maskApiKey(incoming) === incoming;
201
+ const key = isMaskedOrEmpty
202
+ ? (prev?.apiKey ?? "")
203
+ : incoming;
204
+ const cfg = settings.setVectorConfig({
205
+ enabled: body.enabled,
206
+ baseUrl: body.baseUrl,
207
+ apiKey: key,
208
+ model: body.model
209
+ });
210
+ sendJson(res, 200, { config: { ...cfg, apiKey: maskApiKey(cfg.apiKey) } });
211
+ });
212
+ }
213
+ const cfg = settings.getVectorConfig() ?? { enabled: false, baseUrl: "", apiKey: "", model: "" };
214
+ sendJson(res, 200, { config: { ...cfg, apiKey: maskApiKey(cfg.apiKey) } });
215
+ } catch {
216
+ sendJson(res, 500, { error: "internal" });
217
+ }
218
+ }
219
+ });
220
+
221
+ // --- app config read/write (partial: pass only the fields to change) ---
222
+ register({
223
+ kind: "exact",
224
+ path: "/api/dsh-mneme/config",
225
+ handler: async (req, res) => {
226
+ try {
227
+ if (!requireAuth(req, res, apiToken)) return;
228
+ if (req.method === "PUT" || req.method === "POST") {
229
+ const body = parseBody(await readBody(req));
230
+ const patch = {};
231
+ const setIfBoolean = (key) => {
232
+ if (Object.prototype.hasOwnProperty.call(body, key)) {
233
+ if (typeof body[key] !== "boolean") {
234
+ sendJson(res, 400, { error: `${key} must be a boolean` });
235
+ return false;
236
+ }
237
+ patch[key] = body[key];
238
+ }
239
+ return true;
240
+ };
241
+ if (!setIfBoolean("autoTagEnabled") || !setIfBoolean("manualTagEnabled")) return;
242
+ if (Object.keys(patch).length === 0) {
243
+ sendJson(res, 400, { error: "no valid config field provided" });
244
+ return;
245
+ }
246
+ const config = settings.setAutoTagConfig(patch);
247
+ sendJson(res, 200, { config });
248
+ return;
249
+ }
250
+ sendJson(res, 200, { config: settings.getAutoTagConfig() });
251
+ } catch {
252
+ sendJson(res, 500, { error: "internal" });
253
+ }
254
+ }
255
+ });
256
+
257
+ // --- delete a memory by exact id or best query match (mirrors memory_delete tool) ---
258
+ register({
259
+ kind: "exact",
260
+ path: "/api/dsh-mneme/memories",
261
+ handler: async (req, res) => {
262
+ try {
263
+ if (!requireAuth(req, res, apiToken)) return;
264
+ if (req.method !== "DELETE") {
265
+ sendJson(res, 405, { error: "method not allowed" });
266
+ return;
267
+ }
268
+ const body = parseBody(await readBody(req));
269
+ const hasId = Object.prototype.hasOwnProperty.call(body, "id");
270
+ const hasQuery = Object.prototype.hasOwnProperty.call(body, "query");
271
+ if ((hasId && hasQuery) || (!hasId && !hasQuery)) {
272
+ sendJson(res, 400, { error: "provide exactly one of id or query" });
273
+ return;
274
+ }
275
+ if (hasId) {
276
+ if (typeof body.id !== "string" || !body.id.trim()) {
277
+ sendJson(res, 400, { error: "invalid id" });
278
+ return;
279
+ }
280
+ const mem = service.getById(body.id);
281
+ if (!mem) {
282
+ sendJson(res, 200, { deleted: false });
283
+ return;
284
+ }
285
+ service.remove(body.id);
286
+ sendJson(res, 200, { deleted: true, id: body.id });
287
+ return;
288
+ }
289
+ if (typeof body.query !== "string" || !body.query.trim()) {
290
+ sendJson(res, 400, { error: "invalid query" });
291
+ return;
292
+ }
293
+ const results = await service.searchMemories(body.query, { mode: "auto", topK: 1, useRerank: true });
294
+ if (!Array.isArray(results) || results.length === 0) {
295
+ sendJson(res, 200, { deleted: false });
296
+ return;
297
+ }
298
+ const mem = results[0];
299
+ if (!mem || !mem.id) {
300
+ sendJson(res, 200, { deleted: false });
301
+ return;
302
+ }
303
+ service.remove(mem.id);
304
+ sendJson(res, 200, { deleted: true, id: mem.id });
305
+ } catch {
306
+ sendJson(res, 500, { error: "internal" });
307
+ }
308
+ }
309
+ });
310
+
311
+ // --- vector re-index (backfill embeddings for rows missing them) ---
312
+ register({
313
+ kind: "exact",
314
+ path: "/api/dsh-mneme/vector-reindex",
315
+ handler(req, res) {
316
+ try {
317
+ if (!requireAuth(req, res, apiToken)) return;
318
+ if (!embedder) {
319
+ sendJson(res, 200, { indexed: 0, skipped: 0, error: "vector-unavailable" });
320
+ return;
321
+ }
322
+ const url = new URL(req.url, "http://localhost");
323
+ const limit = Number(url.searchParams.get("limit") ?? 100);
324
+ // Unified re-index entry: works for both the legacy OpenAI embedder and
325
+ // the new local/ollama backends (which have no reindexMissing method).
326
+ const viaIndex = semantic?.vectorIndex && semantic?.vectorIndex.rebuildIndex;
327
+ const task = viaIndex
328
+ ? semantic.vectorIndex.rebuildIndex(embedder, { limit })
329
+ : embedder.reindexMissing ? embedder.reindexMissing(limit) : Promise.resolve({ indexed: 0, skipped: 0, error: "vector-unavailable" });
330
+ // Return the chain so awaiting callers (tests/health checks) observe the
331
+ // finished response rather than racing the async backfill.
332
+ return task.then((result) => {
333
+ sendJson(res, 200, result);
334
+ }).catch(() => {
335
+ sendJson(res, 200, { indexed: 0, skipped: 0, error: "vector-failed" });
336
+ });
337
+ } catch {
338
+ sendJson(res, 500, { error: "internal" });
339
+ }
340
+ }
341
+ });
342
+
343
+ // --- semantic pipeline status (model, index, reranker) ---
344
+ register({
345
+ kind: "exact",
346
+ path: "/api/dsh-mneme/semantic",
347
+ handler(req, res) {
348
+ try {
349
+ const stats = semantic?.vectorIndex?.getStats?.() ?? null;
350
+ sendJson(res, 200, {
351
+ embedProvider: embedder ? (embedder.constructor?.name ?? "unknown") : null,
352
+ modelHash: embedder?.modelHash ?? null,
353
+ dimension: embedder?.dimension ?? null,
354
+ reranker: semantic?.reranker ? "ready" : null,
355
+ index: stats
356
+ });
357
+ } catch {
358
+ sendJson(res, 500, { error: "internal" });
359
+ }
360
+ }
361
+ });
362
+
363
+ // --- LLM audit trail (Bug8): paginated read + aggregate stats ---
364
+ // Read-only endpoints, so like list/search/semantic they stay open even when
365
+ // apiToken is set. The stats aggregate budget by source over the last N days.
366
+ register({
367
+ kind: "exact",
368
+ path: "/api/dsh-mneme/semantic/llm-audit",
369
+ handler(req, res) {
370
+ try {
371
+ const url = new URL(req.url, "http://localhost");
372
+ const page = Math.max(1, Number(url.searchParams.get("page") ?? 1) || 1);
373
+ const pageSize = Math.min(200, Math.max(1, Number(url.searchParams.get("pageSize") ?? 50) || 50));
374
+ const source = url.searchParams.get("source") ?? undefined;
375
+ const items = service.listLlmAudits?.({ limit: pageSize, offset: (page - 1) * pageSize, source }) ?? [];
376
+ const total = service.countLlmAudits?.({ source }) ?? items.length;
377
+ sendJson(res, 200, { items, total, page, pageSize });
378
+ } catch {
379
+ sendJson(res, 500, { error: "internal" });
380
+ }
381
+ }
382
+ });
383
+
384
+ register({
385
+ kind: "exact",
386
+ path: "/api/dsh-mneme/semantic/llm-audit/stats",
387
+ handler(req, res) {
388
+ try {
389
+ const url = new URL(req.url, "http://localhost");
390
+ const days = Math.max(1, Math.min(365, Number(url.searchParams.get("days") ?? 7) || 7));
391
+ const stats = service.getLlmAuditStats?.({ days }) ?? null;
392
+ sendJson(res, 200, stats ?? { error: "unavailable" });
393
+ } catch {
394
+ sendJson(res, 500, { error: "internal" });
395
+ }
396
+ }
397
+ });
398
+
399
+ // --- ego graph: 1-2 hop neighborhood of one entity (graph panel P1) ---
400
+ // Read-only like list/search/semantic, so it stays open when apiToken is set.
401
+ // BFS from the root entity over entity_relations (both directions; the
402
+ // idx_relations_from/to indexes keep a 2-hop walk in the tens of ms even
403
+ // for a few thousand nodes). `distance` on each node is the hop count from
404
+ // the root so the UI can shade the frontier. The API is graph-traversal
405
+ // only — nodes carry no attr payload; hover summaries come from
406
+ // /semantic/graph/entity-attrs.
407
+ register({
408
+ kind: "exact",
409
+ path: "/api/dsh-mneme/semantic/graph/ego",
410
+ handler(req, res) {
411
+ try {
412
+ const url = new URL(req.url, "http://localhost");
413
+ const name = (url.searchParams.get("entity") ?? "").trim();
414
+ if (!name) {
415
+ sendJson(res, 400, { error: "missing-entity" });
416
+ return;
417
+ }
418
+ const root = service.findEntityByName?.(name);
419
+ if (!root) {
420
+ sendJson(res, 404, { error: "entity-not-found" });
421
+ return;
422
+ }
423
+ const depth = Math.max(1, Math.min(2, Number(url.searchParams.get("depth") ?? 1) || 1));
424
+ const limit = Math.max(1, Math.min(100, Number(url.searchParams.get("limit") ?? 40) || 40));
425
+
426
+ const nodes = new Map([[root.id, { ...root, distance: 0 }]]);
427
+ let frontier = [root.id];
428
+ for (let d = 1; d <= depth && nodes.size < limit; d++) {
429
+ const next = [];
430
+ for (const id of frontier) {
431
+ for (const rel of service.getRelations?.(id) ?? []) {
432
+ const other = rel.from_entity === id ? rel.to_entity : rel.from_entity;
433
+ if (nodes.has(other) || nodes.size >= limit) continue;
434
+ const entity = service.findEntityById?.(other);
435
+ if (!entity) continue;
436
+ nodes.set(other, { ...entity, distance: d });
437
+ next.push(other);
438
+ }
439
+ }
440
+ frontier = next;
441
+ }
442
+
443
+ // Collect every relation whose endpoints both survived the limit cut;
444
+ // each edge is visited twice (once per endpoint) so dedupe by id.
445
+ const edgeMap = new Map();
446
+ for (const id of nodes.keys()) {
447
+ for (const rel of service.getRelations?.(id) ?? []) {
448
+ if (nodes.has(rel.from_entity) && nodes.has(rel.to_entity)) {
449
+ edgeMap.set(rel.id, rel);
450
+ }
451
+ }
452
+ }
453
+
454
+ sendJson(res, 200, {
455
+ root: { id: root.id, name: root.name, type: root.type ?? null, mention_count: root.mention_count ?? 1 },
456
+ nodes: [...nodes.values()].map((n) => ({
457
+ id: n.id,
458
+ name: n.name,
459
+ type: n.type ?? null,
460
+ mention_count: n.mention_count ?? 1,
461
+ distance: n.distance
462
+ })),
463
+ edges: [...edgeMap.values()].map((e) => ({
464
+ id: e.id,
465
+ from: e.from_entity,
466
+ to: e.to_entity,
467
+ relation_type: e.relation_type,
468
+ memory_id: e.memory_id ?? null,
469
+ created_at: e.created_at
470
+ }))
471
+ });
472
+ } catch {
473
+ sendJson(res, 500, { error: "internal" });
474
+ }
475
+ }
476
+ });
477
+
478
+ // --- entity attrs: current valid attrs for one entity (graph hover panel) ---
479
+ // Read-only; mirrors getCurrentAttrs (valid_until IS NULL). Also used as the
480
+ // graph panel's fallback list when the ego graph is too sparse to draw.
481
+ register({
482
+ kind: "exact",
483
+ path: "/api/dsh-mneme/semantic/graph/entity-attrs",
484
+ handler(req, res) {
485
+ try {
486
+ const url = new URL(req.url, "http://localhost");
487
+ const name = (url.searchParams.get("entity") ?? "").trim();
488
+ if (!name) {
489
+ sendJson(res, 400, { error: "missing-entity" });
490
+ return;
491
+ }
492
+ const entity = service.findEntityByName?.(name);
493
+ if (!entity) {
494
+ sendJson(res, 404, { error: "entity-not-found" });
495
+ return;
496
+ }
497
+ const attrs = service.getCurrentAttrs?.(entity.id) ?? [];
498
+ sendJson(res, 200, {
499
+ entity: { id: entity.id, name: entity.name, type: entity.type ?? null, mention_count: entity.mention_count ?? 1 },
500
+ attrs: Array.isArray(attrs)
501
+ ? attrs.map((a) => ({
502
+ key: a.attr_key,
503
+ value: a.attr_value,
504
+ confidence: a.confidence ?? null,
505
+ valid_from: a.valid_from ?? null
506
+ }))
507
+ : []
508
+ });
509
+ } catch {
510
+ sendJson(res, 500, { error: "internal" });
511
+ }
512
+ }
513
+ });
514
+
515
+ // --- wiki-link back links (v0.6.1) --------------------------------------
516
+ // Read-only like the graph endpoints, so it stays open when apiToken is set.
517
+ // GET /api/dsh-mneme/wikilinks/backlinks?id=<memoryId> → memories whose
518
+ // content carries a [[wiki-link]] resolving to the given memory.
519
+ register({
520
+ kind: "exact",
521
+ path: "/api/dsh-mneme/wikilinks/backlinks",
522
+ handler(req, res) {
523
+ try {
524
+ const url = new URL(req.url, "http://localhost");
525
+ const id = (url.searchParams.get("id") ?? "").trim();
526
+ if (!id) {
527
+ sendJson(res, 400, { error: "missing-id" });
528
+ return;
529
+ }
530
+ const memory = service.getById?.(id) ?? null;
531
+ const backlinks = (service.getBacklinks?.(id) ?? []).map(({ source, relation }) => ({
532
+ id: source.id,
533
+ title: source.title,
534
+ type: source.type,
535
+ created_at: relation.created_at
536
+ }));
537
+ sendJson(res, 200, {
538
+ memoryId: id,
539
+ memory: memory ? { id: memory.id, title: memory.title, type: memory.type } : null,
540
+ backlinks
541
+ });
542
+ } catch {
543
+ sendJson(res, 500, { error: "internal" });
544
+ }
545
+ }
546
+ });
547
+
548
+ // --- wiki-link forward links (v0.6.1) -----------------------------------
549
+ // GET /api/dsh-mneme/wikilinks/forward?id=<memoryId> memories the given
550
+ // memory explicitly links to. Unresolved target titles surface with id:null.
551
+ register({
552
+ kind: "exact",
553
+ path: "/api/dsh-mneme/wikilinks/forward",
554
+ handler(req, res) {
555
+ try {
556
+ const url = new URL(req.url, "http://localhost");
557
+ const id = (url.searchParams.get("id") ?? "").trim();
558
+ if (!id) {
559
+ sendJson(res, 400, { error: "missing-id" });
560
+ return;
561
+ }
562
+ const memory = service.getById?.(id) ?? null;
563
+ const links = (service.getForwardLinks?.(id) ?? []).map(({ target, relation }) => ({
564
+ id: target?.id ?? null,
565
+ title: target?.title ?? relation.to_entity,
566
+ type: target?.type ?? null,
567
+ created_at: relation.created_at
568
+ }));
569
+ sendJson(res, 200, {
570
+ memoryId: id,
571
+ memory: memory ? { id: memory.id, title: memory.title, type: memory.type } : null,
572
+ links
573
+ });
574
+ } catch {
575
+ sendJson(res, 500, { error: "internal" });
576
+ }
577
+ }
578
+ });
579
+
580
+ // --- wiki-link resolve (v0.6.1) -----------------------------------------
581
+ // GET /api/dsh-mneme/wikilinks/resolve?title=<title> case-insensitive exact
582
+ // title match against the memories table (the resolution used when writing
583
+ // links_to relations). 404 when no memory matches.
584
+ register({
585
+ kind: "exact",
586
+ path: "/api/dsh-mneme/wikilinks/resolve",
587
+ handler(req, res) {
588
+ try {
589
+ const url = new URL(req.url, "http://localhost");
590
+ const title = (url.searchParams.get("title") ?? "").trim();
591
+ if (!title) {
592
+ sendJson(res, 400, { error: "missing-title" });
593
+ return;
594
+ }
595
+ const memory = service.resolveWikiLink?.(title) ?? null;
596
+ if (!memory) {
597
+ sendJson(res, 404, { error: "memory-not-found", title });
598
+ return;
599
+ }
600
+ // Note: no `source` field it may carry file paths/internal host info
601
+ // and this endpoint is read-only without auth when apiToken is set.
602
+ sendJson(res, 200, {
603
+ title,
604
+ memory: {
605
+ id: memory.id,
606
+ title: memory.title,
607
+ type: memory.type
608
+ }
609
+ });
610
+ } catch {
611
+ sendJson(res, 500, { error: "internal" });
612
+ }
613
+ }
614
+ });
615
+
616
+ // --- health: mirror sync state (F-NEW-03 / v0.3.6) ---
617
+ // Auth-gated; only returns a sanitized error code (never raw last_error which
618
+ // may leak paths/token-like strings/internal hosts). On state read failure it
619
+ // reports unknown/degraded (fail-closed) instead of a false dirty=false.
620
+ register({
621
+ kind: "exact",
622
+ path: "/api/dsh-mneme/health",
623
+ handler(req, res) {
624
+ if (!requireAuth(req, res, apiToken)) return;
625
+ let state = null;
626
+ try {
627
+ state = service.getMirrorHealth?.() ?? null;
628
+ } catch {
629
+ // read failure is itself a health signal: do not report a false clean
630
+ sendJson(res, 200, { mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null } });
631
+ return;
632
+ }
633
+ if (!state) {
634
+ sendJson(res, 200, { mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null } });
635
+ return;
636
+ }
637
+ // Real read failure surfaces as dirty === null (peer blocker 5): report
638
+ // unknown explicitly instead of collapsing into a false "ok"/"degraded".
639
+ if (state.dirty === null) {
640
+ sendJson(res, 200, {
641
+ mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null }
642
+ });
643
+ return;
644
+ }
645
+ // Sanitized: boolean dirty + coarse status only; error string is mapped to
646
+ // a bounded code, never echoed verbatim.
647
+ let code = null;
648
+ if (state.last_error) {
649
+ const e = String(state.last_error);
650
+ code = /enospc|no space/i.test(e) ? "no-space" : /permission|eacces/i.test(e) ? "permission" : "sync-failed";
651
+ }
652
+ sendJson(res, 200, {
653
+ mirror: {
654
+ dirty: state.dirty === true,
655
+ status: state.dirty === true ? "degraded" : (code ? "degraded" : "ok"),
656
+ last_error: code,
657
+ last_attempt: state.last_attempt ?? null,
658
+ success_at: state.success_at ?? null
659
+ }
660
+ });
661
+ }
662
+ });
663
+
664
+ // --- custom commands ---
665
+ register({
666
+ kind: "exact",
667
+ path: "/api/dsh-mneme/commands",
668
+ handler(req, res) {
669
+ try {
670
+ if (req.method === "POST") {
671
+ if (!requireAuth(req, res, apiToken)) return;
672
+ return readBody(req).then((text) => {
673
+ const body = parseBody(text);
674
+ try {
675
+ const command = commands.add({
676
+ name: body.name,
677
+ description: body.description,
678
+ instruction: body.instruction
679
+ });
680
+ sendJson(res, 200, { command });
681
+ } catch (error) {
682
+ sendJson(res, 400, { error: error.message });
683
+ }
684
+ });
685
+ }
686
+ if (req.method === "DELETE") {
687
+ if (!requireAuth(req, res, apiToken)) return;
688
+ const url = new URL(req.url, "http://localhost");
689
+ const id = url.searchParams.get("id");
690
+ const removed = id ? commands.remove(id) : false;
691
+ sendJson(res, 200, { removed });
692
+ return;
693
+ }
694
+ sendJson(res, 200, { commands: commands.list() });
695
+ } catch {
696
+ sendJson(res, 500, { error: "internal" });
697
+ }
698
+ }
699
+ });
700
+
701
+ // --- memory tags (v0.6.2) ------------------------------------------------
702
+ // GET /api/dsh-mneme/memory/tags?id=<memoryId> → live entity_attrs-backed
703
+ // tag set for one memory plus the manualTagEnabled gate (so the panel
704
+ // can hide tag editing when the manual path is off). Read-only, stays
705
+ // open when apiToken is set (like list/search/semantic).
706
+ // POST /api/dsh-mneme/memory/tags { id, tags } → overwrite the live tag set
707
+ // via service.setMemoryTags (manualTagEnabled gate); 409 when the gate
708
+ // is closed. Auth-gated like the other write endpoints.
709
+ register({
710
+ kind: "exact",
711
+ path: "/api/dsh-mneme/memory/tags",
712
+ handler(req, res) {
713
+ try {
714
+ if (req.method === "POST" || req.method === "PUT") {
715
+ if (!requireAuth(req, res, apiToken)) return;
716
+ return readBody(req).then((text) => {
717
+ const body = parseBody(text);
718
+ const id = typeof body.id === "string" ? body.id.trim() : "";
719
+ if (!id) {
720
+ sendJson(res, 400, { error: "missing-id" });
721
+ return;
722
+ }
723
+ const memory = service.getById?.(id) ?? null;
724
+ if (!memory) {
725
+ sendJson(res, 404, { error: "memory-not-found" });
726
+ return;
727
+ }
728
+ const result = service.setMemoryTags(id, Array.isArray(body.tags) ? body.tags : []);
729
+ if (result?.ok === false) {
730
+ sendJson(res, 409, { error: result.error || "tags-disabled" });
731
+ return;
732
+ }
733
+ sendJson(res, 200, { ok: true, memoryId: id, tags: result?.tags ?? [] });
734
+ });
735
+ }
736
+ const url = new URL(req.url, "http://localhost");
737
+ const id = (url.searchParams.get("id") ?? "").trim();
738
+ if (!id) {
739
+ sendJson(res, 400, { error: "missing-id" });
740
+ return;
741
+ }
742
+ const memory = service.getById?.(id) ?? null;
743
+ if (!memory) {
744
+ sendJson(res, 404, { error: "memory-not-found" });
745
+ return;
746
+ }
747
+ const tags = service.getMemoryTags?.(id) ?? [];
748
+ sendJson(res, 200, {
749
+ memoryId: id,
750
+ tags: Array.isArray(tags) ? tags : [],
751
+ manualTagEnabled: service.manualTagEnabled?.() ?? true
752
+ });
753
+ } catch {
754
+ sendJson(res, 500, { error: "internal" });
755
+ }
756
+ }
757
+ });
758
+
759
+ // --- directory view (v0.6.3) ---------------------------------------------
760
+ // GET /api/dsh-mneme/directory → memories grouped by tag as
761
+ // { groups: [{ tag, memories: [...] }], untagged: [...] }. Live-only
762
+ // (forgotten/archived/session-disposed excluded), groups tag-sorted, members
763
+ // importance+updated DESC. Read-only, stays open when apiToken is set.
764
+ register({
765
+ kind: "exact",
766
+ path: "/api/dsh-mneme/directory",
767
+ handler(req, res) {
768
+ try {
769
+ const dir = service.getDirectory?.() ?? { groups: [], untagged: [] };
770
+ sendJson(res, 200, dir);
771
+ } catch {
772
+ sendJson(res, 500, { error: "internal" });
773
+ }
774
+ }
775
+ });
776
+
777
+ return {
778
+ routes: 19,
779
+ dispose: () => {
780
+ for (const dispose of disposers) dispose();
781
+ }
782
+ };
783
+ }