@modusensus/dsh-mneme 0.6.7 → 0.6.9

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 (139) hide show
  1. package/.github/workflows/test.yml +32 -0
  2. package/.release-notes-v0.6.9.md +13 -0
  3. package/CHANGELOG.md +89 -0
  4. package/LICENSE +21 -21
  5. package/README.md +219 -463
  6. package/SECURITY.md +544 -0
  7. package/docs/devlog/2026-08-14-dsh-mneme-dev-log.md +247 -0
  8. package/docs/devlog/2026-08-15-dsh-mneme-audit-stress-dev-log.md +145 -0
  9. package/docs/devlog/2026-08-15-dsh-mneme-pipeline-dev-log.md +56 -0
  10. package/docs/devlog/2026-08-15-dsh-mneme-reflection-dev-log.md +77 -0
  11. package/docs/devlog/2026-08-15-dsh-mneme-review-fixes-dev-log.md +64 -0
  12. package/docs/devlog/2026-08-15-dsh-mneme-semantic-dev-log.md +90 -0
  13. package/dsh-mneme/CHANGELOG.md +248 -0
  14. package/dsh-mneme/LICENSE +21 -0
  15. package/dsh-mneme/README.md +465 -0
  16. package/{cordis.patch.yml → dsh-mneme/cordis.patch.yml} +15 -15
  17. package/dsh-mneme/docs/AGENT_MEMORY_RESEARCH.md +183 -0
  18. package/dsh-mneme/docs/ENTITIES.md +245 -0
  19. package/dsh-mneme/docs/LOCAL_MODEL.md +141 -0
  20. package/dsh-mneme/docs/MIGRATION.md +127 -0
  21. package/dsh-mneme/docs/SEMANTIC.md +256 -0
  22. package/dsh-mneme/docs/SLEEP.md +163 -0
  23. package/{lib → dsh-mneme/lib}/api.js +783 -783
  24. package/{lib → dsh-mneme/lib}/client.js +1757 -1757
  25. package/{src → dsh-mneme/lib}/commands.js +64 -64
  26. package/{lib → dsh-mneme/lib}/config.js +298 -288
  27. package/{lib → dsh-mneme/lib}/dream/clustering.js +118 -118
  28. package/{src → dsh-mneme/lib}/dream/decisions.js +488 -439
  29. package/{lib → dsh-mneme/lib}/dream/sleep.js +561 -554
  30. package/{src → dsh-mneme/lib}/dream/tag-extractor.js +156 -156
  31. package/{lib → dsh-mneme/lib}/dream.js +958 -929
  32. package/{src → dsh-mneme/lib}/embedding.js +154 -154
  33. package/{src → dsh-mneme/lib}/entities/extractor.js +242 -242
  34. package/{src → dsh-mneme/lib}/hot-memory.js +53 -53
  35. package/{lib → dsh-mneme/lib}/index.js +361 -361
  36. package/{src → dsh-mneme/lib}/inject.js +208 -208
  37. package/{lib → dsh-mneme/lib}/local-embedder.js +282 -282
  38. package/{lib → dsh-mneme/lib}/mirror.js +170 -170
  39. package/{lib → dsh-mneme/lib}/parser/tag.js +59 -59
  40. package/{lib → dsh-mneme/lib}/parser/wiki-link.js +38 -38
  41. package/{src → dsh-mneme/lib}/quality-filter.js +123 -123
  42. package/{lib → dsh-mneme/lib}/reranker.js +218 -218
  43. package/{lib → dsh-mneme/lib}/search/adaptive.js +22 -22
  44. package/{lib → dsh-mneme/lib}/search/bm25.js +96 -96
  45. package/{src → dsh-mneme/lib}/search/tag-boost.js +61 -61
  46. package/{src → dsh-mneme/lib}/service.js +1726 -1726
  47. package/{lib → dsh-mneme/lib}/settings.js +172 -172
  48. package/{lib → dsh-mneme/lib}/store.js +2238 -2238
  49. package/{lib → dsh-mneme/lib}/summarize.js +236 -236
  50. package/{lib → dsh-mneme/lib}/tools.js +290 -290
  51. package/{lib → dsh-mneme/lib}/vector-index.js +116 -116
  52. package/dsh-mneme/package-lock.json +1936 -0
  53. package/dsh-mneme/package.json +80 -0
  54. package/{scripts → dsh-mneme/scripts}/benchmark-embed.js +201 -201
  55. package/{scripts → dsh-mneme/scripts}/benchmark-recall.js +133 -133
  56. package/{scripts → dsh-mneme/scripts}/benchmark-rerank.js +166 -166
  57. package/{scripts → dsh-mneme/scripts}/e2e-dsh.js +218 -218
  58. package/{scripts → dsh-mneme/scripts}/stress-dsh.js +255 -255
  59. package/{scripts → dsh-mneme/scripts}/sync-lib.js +52 -52
  60. package/{src → dsh-mneme/src}/api.js +783 -783
  61. package/{lib → dsh-mneme/src}/commands.js +64 -64
  62. package/{src → dsh-mneme/src}/config.js +298 -288
  63. package/{src → dsh-mneme/src}/dream/clustering.js +118 -118
  64. package/{lib → dsh-mneme/src}/dream/decisions.js +488 -439
  65. package/{src → dsh-mneme/src}/dream/sleep.js +561 -554
  66. package/{lib → dsh-mneme/src}/dream/tag-extractor.js +156 -156
  67. package/{src → dsh-mneme/src}/dream.js +958 -929
  68. package/{lib → dsh-mneme/src}/embedding.js +154 -154
  69. package/{lib → dsh-mneme/src}/entities/extractor.js +242 -242
  70. package/{lib → dsh-mneme/src}/hot-memory.js +53 -53
  71. package/{src → dsh-mneme/src}/index.js +361 -361
  72. package/{lib → dsh-mneme/src}/inject.js +208 -208
  73. package/{src → dsh-mneme/src}/local-embedder.js +282 -282
  74. package/{src → dsh-mneme/src}/mirror.js +170 -170
  75. package/{src → dsh-mneme/src}/parser/tag.js +59 -59
  76. package/{src → dsh-mneme/src}/parser/wiki-link.js +38 -38
  77. package/{lib → dsh-mneme/src}/quality-filter.js +123 -123
  78. package/{src → dsh-mneme/src}/reranker.js +218 -218
  79. package/{src → dsh-mneme/src}/search/adaptive.js +22 -22
  80. package/{src → dsh-mneme/src}/search/bm25.js +96 -96
  81. package/{lib → dsh-mneme/src}/search/tag-boost.js +61 -61
  82. package/{lib → dsh-mneme/src}/service.js +1726 -1726
  83. package/{src → dsh-mneme/src}/settings.js +172 -172
  84. package/{src → dsh-mneme/src}/store.js +2238 -2238
  85. package/{src → dsh-mneme/src}/summarize.js +236 -236
  86. package/{src → dsh-mneme/src}/tools.js +290 -290
  87. package/{src → dsh-mneme/src}/vector-index.js +116 -116
  88. package/{test → dsh-mneme/test}/api.test.js +594 -594
  89. package/{test → dsh-mneme/test}/audit.test.js +448 -448
  90. package/{test → dsh-mneme/test}/benchmark.test.js +35 -35
  91. package/{test → dsh-mneme/test}/boundary-v0625.test.js +82 -82
  92. package/{test → dsh-mneme/test}/client.test.js +368 -368
  93. package/{test → dsh-mneme/test}/clustering.test.js +100 -100
  94. package/{test → dsh-mneme/test}/commands.test.js +69 -69
  95. package/{test → dsh-mneme/test}/config.test.js +50 -50
  96. package/{test → dsh-mneme/test}/conflict-freeze.test.js +290 -290
  97. package/{test → dsh-mneme/test}/directory.test.js +134 -134
  98. package/{test → dsh-mneme/test}/dream.test.js +1060 -901
  99. package/{test → dsh-mneme/test}/entities.test.js +522 -522
  100. package/{test → dsh-mneme/test}/epistemic.test.js +298 -298
  101. package/{test → dsh-mneme/test}/fnew-0112.test.js +311 -311
  102. package/{test → dsh-mneme/test}/fnew-03.test.js +422 -422
  103. package/{test → dsh-mneme/test}/graph-api.test.js +175 -175
  104. package/{test → dsh-mneme/test}/helpers/dream-mock.js +82 -82
  105. package/{test → dsh-mneme/test}/hot-memory.test.js +174 -174
  106. package/{test → dsh-mneme/test}/inject.test.js +103 -103
  107. package/{test → dsh-mneme/test}/llm-audit.test.js +279 -279
  108. package/{test → dsh-mneme/test}/local-embedder.test.js +227 -227
  109. package/{test → dsh-mneme/test}/mirror-dirty.test.js +424 -424
  110. package/{test → dsh-mneme/test}/mirror-edit-digest.test.js +187 -187
  111. package/{test → dsh-mneme/test}/mirror-generation.test.js +499 -499
  112. package/{test → dsh-mneme/test}/mirror.test.js +249 -249
  113. package/{test → dsh-mneme/test}/normalize-decisions.test.js +120 -120
  114. package/{test → dsh-mneme/test}/peer-blockers.test.js +190 -190
  115. package/{test → dsh-mneme/test}/policy-epoch.test.js +259 -259
  116. package/{test → dsh-mneme/test}/provenance.test.js +103 -103
  117. package/{test → dsh-mneme/test}/quality-filter.test.js +118 -118
  118. package/{test → dsh-mneme/test}/reasoning-effort.test.js +199 -199
  119. package/{test → dsh-mneme/test}/recall-evals.test.js +235 -235
  120. package/{test → dsh-mneme/test}/recall-layer.test.js +315 -315
  121. package/{test → dsh-mneme/test}/receipt-chain.test.js +451 -451
  122. package/{test → dsh-mneme/test}/reflection.test.js +226 -226
  123. package/{test → dsh-mneme/test}/reranker.test.js +240 -240
  124. package/{test → dsh-mneme/test}/search-fusion.test.js +90 -90
  125. package/{test → dsh-mneme/test}/semantic.test.js +124 -124
  126. package/{test → dsh-mneme/test}/service-search.test.js +199 -199
  127. package/{test → dsh-mneme/test}/service.test.js +435 -435
  128. package/{test → dsh-mneme/test}/settings.test.js +118 -118
  129. package/{test → dsh-mneme/test}/sleep.test.js +365 -365
  130. package/{test → dsh-mneme/test}/store.test.js +436 -436
  131. package/{test → dsh-mneme/test}/stress.test.js +209 -209
  132. package/{test → dsh-mneme/test}/summarize.test.js +191 -191
  133. package/{test → dsh-mneme/test}/tag-boost.test.js +125 -125
  134. package/{test → dsh-mneme/test}/tag.test.js +312 -312
  135. package/{test → dsh-mneme/test}/tools.test.js +285 -285
  136. package/{test → dsh-mneme/test}/vector-index.test.js +221 -221
  137. package/{test → dsh-mneme/test}/wiki-link.test.js +332 -332
  138. package/package.json +18 -40
  139. package//346/250/252/345/271/205.png +0 -0
@@ -1,783 +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
- // --- 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
- }
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
+ }