@modusensus/dsh-mneme 0.4.7 → 0.5.0

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.
package/lib/api.js CHANGED
@@ -1,400 +1,516 @@
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("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
- // --- health: mirror sync state (F-NEW-03 / v0.3.6) ---
310
- // Auth-gated; only returns a sanitized error code (never raw last_error which
311
- // may leak paths/token-like strings/internal hosts). On state read failure it
312
- // reports unknown/degraded (fail-closed) instead of a false dirty=false.
313
- register({
314
- kind: "exact",
315
- path: "/api/dsh-mneme/health",
316
- handler(req, res) {
317
- if (!requireAuth(req, res, apiToken)) return;
318
- let state = null;
319
- try {
320
- state = service.getMirrorHealth?.() ?? null;
321
- } catch {
322
- // read failure is itself a health signal: do not report a false clean
323
- sendJson(res, 200, { mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null } });
324
- return;
325
- }
326
- if (!state) {
327
- sendJson(res, 200, { mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null } });
328
- return;
329
- }
330
- // Real read failure surfaces as dirty === null (peer blocker 5): report
331
- // unknown explicitly instead of collapsing into a false "ok"/"degraded".
332
- if (state.dirty === null) {
333
- sendJson(res, 200, {
334
- mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null }
335
- });
336
- return;
337
- }
338
- // Sanitized: boolean dirty + coarse status only; error string is mapped to
339
- // a bounded code, never echoed verbatim.
340
- let code = null;
341
- if (state.last_error) {
342
- const e = String(state.last_error);
343
- code = /enospc|no space/i.test(e) ? "no-space" : /permission|eacces/i.test(e) ? "permission" : "sync-failed";
344
- }
345
- sendJson(res, 200, {
346
- mirror: {
347
- dirty: state.dirty === true,
348
- status: state.dirty === true ? "degraded" : (code ? "degraded" : "ok"),
349
- last_error: code,
350
- last_attempt: state.last_attempt ?? null,
351
- success_at: state.success_at ?? null
352
- }
353
- });
354
- }
355
- });
356
-
357
- // --- custom commands ---
358
- register({
359
- kind: "exact",
360
- path: "/api/dsh-mneme/commands",
361
- handler(req, res) {
362
- try {
363
- if (req.method === "POST") {
364
- if (!requireAuth(req, res, apiToken)) return;
365
- return readBody(req).then((text) => {
366
- const body = parseBody(text);
367
- try {
368
- const command = commands.add({
369
- name: body.name,
370
- description: body.description,
371
- instruction: body.instruction
372
- });
373
- sendJson(res, 200, { command });
374
- } catch (error) {
375
- sendJson(res, 400, { error: error.message });
376
- }
377
- });
378
- }
379
- if (req.method === "DELETE") {
380
- if (!requireAuth(req, res, apiToken)) return;
381
- const url = new URL(req.url, "http://localhost");
382
- const id = url.searchParams.get("id");
383
- const removed = id ? commands.remove(id) : false;
384
- sendJson(res, 200, { removed });
385
- return;
386
- }
387
- sendJson(res, 200, { commands: commands.list() });
388
- } catch {
389
- sendJson(res, 500, { error: "internal" });
390
- }
391
- }
392
- });
393
-
394
- return {
395
- routes: 11,
396
- dispose: () => {
397
- for (const dispose of disposers) dispose();
398
- }
399
- };
400
- }
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
+ // --- health: mirror sync state (F-NEW-03 / v0.3.6) ---
426
+ // Auth-gated; only returns a sanitized error code (never raw last_error which
427
+ // may leak paths/token-like strings/internal hosts). On state read failure it
428
+ // reports unknown/degraded (fail-closed) instead of a false dirty=false.
429
+ register({
430
+ kind: "exact",
431
+ path: "/api/dsh-mneme/health",
432
+ handler(req, res) {
433
+ if (!requireAuth(req, res, apiToken)) return;
434
+ let state = null;
435
+ try {
436
+ state = service.getMirrorHealth?.() ?? null;
437
+ } catch {
438
+ // read failure is itself a health signal: do not report a false clean
439
+ sendJson(res, 200, { mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null } });
440
+ return;
441
+ }
442
+ if (!state) {
443
+ sendJson(res, 200, { mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null } });
444
+ return;
445
+ }
446
+ // Real read failure surfaces as dirty === null (peer blocker 5): report
447
+ // unknown explicitly instead of collapsing into a false "ok"/"degraded".
448
+ if (state.dirty === null) {
449
+ sendJson(res, 200, {
450
+ mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null }
451
+ });
452
+ return;
453
+ }
454
+ // Sanitized: boolean dirty + coarse status only; error string is mapped to
455
+ // a bounded code, never echoed verbatim.
456
+ let code = null;
457
+ if (state.last_error) {
458
+ const e = String(state.last_error);
459
+ code = /enospc|no space/i.test(e) ? "no-space" : /permission|eacces/i.test(e) ? "permission" : "sync-failed";
460
+ }
461
+ sendJson(res, 200, {
462
+ mirror: {
463
+ dirty: state.dirty === true,
464
+ status: state.dirty === true ? "degraded" : (code ? "degraded" : "ok"),
465
+ last_error: code,
466
+ last_attempt: state.last_attempt ?? null,
467
+ success_at: state.success_at ?? null
468
+ }
469
+ });
470
+ }
471
+ });
472
+
473
+ // --- custom commands ---
474
+ register({
475
+ kind: "exact",
476
+ path: "/api/dsh-mneme/commands",
477
+ handler(req, res) {
478
+ try {
479
+ if (req.method === "POST") {
480
+ if (!requireAuth(req, res, apiToken)) return;
481
+ return readBody(req).then((text) => {
482
+ const body = parseBody(text);
483
+ try {
484
+ const command = commands.add({
485
+ name: body.name,
486
+ description: body.description,
487
+ instruction: body.instruction
488
+ });
489
+ sendJson(res, 200, { command });
490
+ } catch (error) {
491
+ sendJson(res, 400, { error: error.message });
492
+ }
493
+ });
494
+ }
495
+ if (req.method === "DELETE") {
496
+ if (!requireAuth(req, res, apiToken)) return;
497
+ const url = new URL(req.url, "http://localhost");
498
+ const id = url.searchParams.get("id");
499
+ const removed = id ? commands.remove(id) : false;
500
+ sendJson(res, 200, { removed });
501
+ return;
502
+ }
503
+ sendJson(res, 200, { commands: commands.list() });
504
+ } catch {
505
+ sendJson(res, 500, { error: "internal" });
506
+ }
507
+ }
508
+ });
509
+
510
+ return {
511
+ routes: 11,
512
+ dispose: () => {
513
+ for (const dispose of disposers) dispose();
514
+ }
515
+ };
516
+ }