@modusensus/dsh-mneme 0.4.5 → 0.4.6

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/store.js CHANGED
@@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS memories (
12
12
  forgotten INTEGER NOT NULL DEFAULT 0,
13
13
  archived INTEGER NOT NULL DEFAULT 0,
14
14
  source TEXT,
15
+ content_history TEXT,
15
16
  embedding TEXT,
16
17
  epistemic_status TEXT NOT NULL DEFAULT 'subjective',
17
18
  last_accessed_at TEXT,
@@ -146,6 +147,31 @@ CREATE TABLE IF NOT EXISTS conflict_pending (
146
147
  );
147
148
  CREATE INDEX IF NOT EXISTS idx_conflict_pending_unresolved ON conflict_pending(resolved_at);
148
149
 
150
+ -- llm_audit_logs: every background LLM call (autoDream consolidation + summary,
151
+ -- autoSummarize compression) is recorded here — tokens in/out, duration, status
152
+ -- and the trigger that caused it (Bug8). Failures are captured as status='error'
153
+ -- and never block the calling feature. retentionDays is enforced by a boot-time
154
+ -- purge (deleteOldLlmAudits). Bookkeeping like the other audit tables: it never
155
+ -- triggers write hooks.
156
+ CREATE TABLE IF NOT EXISTS llm_audit_logs (
157
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
158
+ timestamp TEXT NOT NULL,
159
+ trigger_source TEXT NOT NULL, -- autoDream | autoSummarize | manual ...
160
+ operation_type TEXT NOT NULL, -- dream_consolidate | dream_summarize | summarize_compress ...
161
+ model_id TEXT NOT NULL,
162
+ input_tokens INTEGER NOT NULL DEFAULT 0,
163
+ output_tokens INTEGER NOT NULL DEFAULT 0,
164
+ total_tokens INTEGER NOT NULL DEFAULT 0,
165
+ cost_usd REAL NOT NULL DEFAULT 0,
166
+ duration_ms INTEGER NOT NULL DEFAULT 0,
167
+ status TEXT NOT NULL, -- success | error | skipped
168
+ error_message TEXT,
169
+ related_memory_ids TEXT, -- JSON: ids the call operated on
170
+ metadata TEXT -- JSON: free-form extras
171
+ );
172
+ CREATE INDEX IF NOT EXISTS idx_llm_audit_timestamp ON llm_audit_logs(timestamp);
173
+ CREATE INDEX IF NOT EXISTS idx_llm_audit_source ON llm_audit_logs(trigger_source);
174
+
149
175
  -- entity gene (v0.3.0): named entities mentioned across memories, with
150
176
  -- time-boxed attributes (valid_from → valid_until) and typed relations.
151
177
  -- Attributes follow the snapshot style: saveAttr invalidates the previous
@@ -295,6 +321,8 @@ function toRow(row) {
295
321
  forgotten: row.forgotten === 1,
296
322
  archived: row.archived === 1,
297
323
  source: row.source ?? undefined,
324
+ content_history: parseJsonArray(row.content_history),
325
+ quality_score: row.quality_score !== null && row.quality_score !== undefined ? Number(row.quality_score) : undefined,
298
326
  epistemic_status: row.epistemic_status ?? "subjective",
299
327
  created_at: row.created_at,
300
328
  updated_at: row.updated_at,
@@ -439,6 +467,34 @@ function toRelation(row) {
439
467
  };
440
468
  }
441
469
 
470
+ function toLlmAudit(row) {
471
+ if (!row) return undefined;
472
+ let metadata;
473
+ if (row.metadata != null) {
474
+ try {
475
+ metadata = JSON.parse(row.metadata);
476
+ } catch {
477
+ metadata = row.metadata;
478
+ }
479
+ }
480
+ return {
481
+ id: row.id,
482
+ timestamp: row.timestamp,
483
+ trigger_source: row.trigger_source,
484
+ operation_type: row.operation_type,
485
+ model_id: row.model_id,
486
+ input_tokens: row.input_tokens,
487
+ output_tokens: row.output_tokens,
488
+ total_tokens: row.total_tokens,
489
+ cost_usd: row.cost_usd,
490
+ duration_ms: row.duration_ms,
491
+ status: row.status,
492
+ error_message: row.error_message ?? undefined,
493
+ related_memory_ids: parseJsonArray(row.related_memory_ids),
494
+ metadata
495
+ };
496
+ }
497
+
442
498
  function toMirrorState(row) {
443
499
  if (!row) {
444
500
  return {
@@ -509,6 +565,12 @@ export function createStore(path) {
509
565
  if (!columns.includes("epistemic_status")) {
510
566
  db.exec("ALTER TABLE memories ADD COLUMN epistemic_status TEXT NOT NULL DEFAULT 'subjective'");
511
567
  }
568
+ if (!columns.includes("content_history")) {
569
+ db.exec("ALTER TABLE memories ADD COLUMN content_history TEXT");
570
+ }
571
+ if (!columns.includes("quality_score")) {
572
+ db.exec("ALTER TABLE memories ADD COLUMN quality_score REAL");
573
+ }
512
574
 
513
575
  // Legacy dream_runs without policy_epoch → backfill with the default epoch.
514
576
  const dreamCols = db.prepare("PRAGMA table_info(dream_runs)").all().map((c) => c.name);
@@ -607,9 +669,24 @@ export function createStore(path) {
607
669
  : inferEpistemicStatus(memory);
608
670
  runAtomically(() => {
609
671
  db.prepare(
610
- `INSERT INTO memories (id, type, title, content, tags, importance, forgotten, source, embedding, epistemic_status, created_at, updated_at)
611
- VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?)`
612
- ).run(id, type, memory.title, memory.content, tags, importance, memory.source ?? null, embedding, epistemicStatus, now, now);
672
+ `INSERT INTO memories (id, type, title, content, tags, importance, forgotten, archived, source, content_history, quality_score, embedding, epistemic_status, created_at, updated_at)
673
+ VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?)`
674
+ ).run(
675
+ id,
676
+ type,
677
+ memory.title,
678
+ memory.content,
679
+ tags,
680
+ importance,
681
+ memory.archived ? 1 : 0,
682
+ memory.source ?? null,
683
+ JSON.stringify(memory.content_history ?? []),
684
+ Number.isFinite(memory.quality_score) ? memory.quality_score : null,
685
+ embedding,
686
+ epistemicStatus,
687
+ now,
688
+ now
689
+ );
613
690
  // desired generation bumped in the same transaction as the write: once
614
691
  // this commits, generation > applied_generation, so a crash right after
615
692
  // (before syncMirror) is caught by recoverMirror on restart (peer
@@ -632,9 +709,15 @@ export function createStore(path) {
632
709
  ? (Array.isArray(patch.embedding) && patch.embedding.length ? JSON.stringify(patch.embedding) : null)
633
710
  : existing.embedding ?? null;
634
711
  const epistemicStatus = resolveEpistemicStatus(existing, patch);
712
+ const contentHistory = Array.isArray(patch.content_history)
713
+ ? JSON.stringify(patch.content_history)
714
+ : (Array.isArray(existing.content_history) ? JSON.stringify(existing.content_history) : null);
715
+ const qualityScore = patch.quality_score !== undefined && Number.isFinite(patch.quality_score)
716
+ ? patch.quality_score
717
+ : (existing.quality_score ?? null);
635
718
  runAtomically(() => {
636
719
  db.prepare(
637
- `UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, embedding=?, epistemic_status=?, updated_at=? WHERE id=?`
720
+ `UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, content_history=?, quality_score=?, embedding=?, epistemic_status=?, updated_at=? WHERE id=?`
638
721
  ).run(
639
722
  type,
640
723
  patch.title ?? existing.title,
@@ -642,6 +725,8 @@ export function createStore(path) {
642
725
  JSON.stringify(patch.tags ?? existing.tags),
643
726
  Number.isInteger(patch.importance) ? patch.importance : existing.importance,
644
727
  patch.source !== undefined ? patch.source : (existing.source ?? null),
728
+ contentHistory,
729
+ qualityScore,
645
730
  embedding,
646
731
  epistemicStatus,
647
732
  now,
@@ -684,6 +769,12 @@ export function createStore(path) {
684
769
  ? (Array.isArray(patch.embedding) && patch.embedding.length ? JSON.stringify(patch.embedding) : null)
685
770
  : existing.embedding ?? null;
686
771
  const epistemicStatus = resolveEpistemicStatus(existing, patch);
772
+ const contentHistory = Array.isArray(patch.content_history)
773
+ ? JSON.stringify(patch.content_history)
774
+ : (Array.isArray(existing.content_history) ? JSON.stringify(existing.content_history) : null);
775
+ const qualityScore = patch.quality_score !== undefined && Number.isFinite(patch.quality_score)
776
+ ? patch.quality_score
777
+ : (existing.quality_score ?? null);
687
778
  // The CAS UPDATE and the desired-generation bump must commit together (audit
688
779
  // peer A): if the UPDATE autocommits first and the process dies before the
689
780
  // increment, the store is mutated while generation == applied_generation and
@@ -693,7 +784,7 @@ export function createStore(path) {
693
784
  let applied = false;
694
785
  runAtomically(() => {
695
786
  const result = db.prepare(
696
- `UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, embedding=?, epistemic_status=?, updated_at=?
787
+ `UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, content_history=?, quality_score=?, embedding=?, epistemic_status=?, updated_at=?
697
788
  WHERE id=? AND updated_at=?`
698
789
  ).run(
699
790
  type,
@@ -702,6 +793,8 @@ export function createStore(path) {
702
793
  JSON.stringify(patch.tags ?? existing.tags),
703
794
  Number.isInteger(patch.importance) ? patch.importance : existing.importance,
704
795
  patch.source !== undefined ? patch.source : (existing.source ?? null),
796
+ contentHistory,
797
+ qualityScore,
705
798
  embedding,
706
799
  epistemicStatus,
707
800
  now,
@@ -1152,6 +1245,115 @@ export function createStore(path) {
1152
1245
  return rows.map(toRecallEval);
1153
1246
  }
1154
1247
 
1248
+ // --- llm audit trail (Bug8) ---------------------------------------------
1249
+
1250
+ /**
1251
+ * Persist one LLM audit row (a background call's token/time/status receipt).
1252
+ * Bookkeeping like the other audit tables: it never triggers write hooks, so
1253
+ * recording a call can never loop back into the scheduler that made it. The
1254
+ * call itself is wrapped so a failure is captured (status='error') instead of
1255
+ * blocking the feature — only a throwing saveLlmAudit is swallowed, never the
1256
+ * LLM call.
1257
+ */
1258
+ function saveLlmAudit(entry) {
1259
+ const now = nowIso();
1260
+ const inTokens = Number.isFinite(entry.input_tokens) ? entry.input_tokens : 0;
1261
+ const outTokens = Number.isFinite(entry.output_tokens) ? entry.output_tokens : 0;
1262
+ db.prepare(
1263
+ `INSERT INTO llm_audit_logs (timestamp, trigger_source, operation_type, model_id,
1264
+ input_tokens, output_tokens, total_tokens, cost_usd, duration_ms, status,
1265
+ error_message, related_memory_ids, metadata)
1266
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
1267
+ ).run(
1268
+ entry.timestamp ?? now,
1269
+ entry.trigger_source,
1270
+ entry.operation_type,
1271
+ entry.model_id,
1272
+ inTokens,
1273
+ outTokens,
1274
+ Number.isFinite(entry.total_tokens) ? entry.total_tokens : inTokens + outTokens,
1275
+ Number.isFinite(entry.cost_usd) ? entry.cost_usd : 0,
1276
+ Number.isFinite(entry.duration_ms) ? entry.duration_ms : 0,
1277
+ entry.status ?? "success",
1278
+ entry.error_message ?? null,
1279
+ JSON.stringify(entry.related_memory_ids ?? []),
1280
+ entry.metadata !== undefined
1281
+ ? (typeof entry.metadata === "string" ? entry.metadata : JSON.stringify(entry.metadata))
1282
+ : null
1283
+ );
1284
+ return toLlmAudit(db.prepare("SELECT * FROM llm_audit_logs ORDER BY id DESC LIMIT 1").get());
1285
+ }
1286
+
1287
+ function listLlmAudits({ limit = 50, offset = 0, source } = {}) {
1288
+ const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
1289
+ const clauses = [];
1290
+ const params = [];
1291
+ if (source) {
1292
+ clauses.push("trigger_source = ?");
1293
+ params.push(source);
1294
+ }
1295
+ const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
1296
+ const rows = db.prepare(
1297
+ `SELECT * FROM llm_audit_logs ${where} ORDER BY timestamp DESC, id DESC LIMIT ? OFFSET ?`
1298
+ ).all(...params, lim, off);
1299
+ return rows.map(toLlmAudit);
1300
+ }
1301
+
1302
+ function countLlmAudits({ source } = {}) {
1303
+ const clauses = [];
1304
+ const params = [];
1305
+ if (source) {
1306
+ clauses.push("trigger_source = ?");
1307
+ params.push(source);
1308
+ }
1309
+ const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
1310
+ return db.prepare(`SELECT count(*) AS c FROM llm_audit_logs ${where}`).get(...params).c;
1311
+ }
1312
+
1313
+ /**
1314
+ * Aggregate LLM spend over the last `days`: total calls/tokens/duration/cost,
1315
+ * broken down by trigger_source and by status. Used by the API's
1316
+ * /llm-audit/stats endpoint so the Web panel can show where budget goes.
1317
+ */
1318
+ function getLlmAuditStats({ days = 7 } = {}) {
1319
+ const since = new Date(Date.now() - days * 86400000).toISOString();
1320
+ const total = db.prepare(
1321
+ `SELECT count(*) AS c,
1322
+ COALESCE(SUM(input_tokens), 0) AS i,
1323
+ COALESCE(SUM(output_tokens), 0) AS o,
1324
+ COALESCE(SUM(total_tokens), 0) AS t,
1325
+ COALESCE(SUM(duration_ms), 0) AS d,
1326
+ COALESCE(SUM(cost_usd), 0) AS cst
1327
+ FROM llm_audit_logs WHERE timestamp >= ?`
1328
+ ).get(since);
1329
+ const bySource = db.prepare(
1330
+ `SELECT trigger_source AS source, count(*) AS c,
1331
+ COALESCE(SUM(total_tokens), 0) AS total_tokens
1332
+ FROM llm_audit_logs WHERE timestamp >= ?
1333
+ GROUP BY trigger_source ORDER BY total_tokens DESC`
1334
+ ).all(since);
1335
+ const byStatus = db.prepare(
1336
+ "SELECT status, count(*) AS c FROM llm_audit_logs WHERE timestamp >= ? GROUP BY status"
1337
+ ).all(since);
1338
+ return {
1339
+ days,
1340
+ since,
1341
+ total_calls: total.c,
1342
+ input_tokens: total.i,
1343
+ output_tokens: total.o,
1344
+ total_tokens: total.t,
1345
+ total_duration_ms: total.d,
1346
+ total_cost_usd: Number(total.cst),
1347
+ by_source: bySource,
1348
+ by_status: byStatus
1349
+ };
1350
+ }
1351
+
1352
+ /** Delete audit rows older than `before` (ISO string). Returns count removed. */
1353
+ function deleteOldLlmAudits(before) {
1354
+ return db.prepare("DELETE FROM llm_audit_logs WHERE timestamp < ?").run(before).changes;
1355
+ }
1356
+
1155
1357
  // --- failure memories ----------------------------------------------------
1156
1358
 
1157
1359
  /**
@@ -1678,6 +1880,11 @@ export function createStore(path) {
1678
1880
  saveRecallEval,
1679
1881
  getRecallEval,
1680
1882
  listRecallEvals,
1883
+ saveLlmAudit,
1884
+ listLlmAudits,
1885
+ countLlmAudits,
1886
+ getLlmAuditStats,
1887
+ deleteOldLlmAudits,
1681
1888
  saveFailure,
1682
1889
  listFailures,
1683
1890
  getFailureStats,
package/lib/summarize.js CHANGED
@@ -98,6 +98,12 @@ export function createSummarizer(ctx, service, config) {
98
98
  if (disposed || inFlight.has(session.id)) return;
99
99
  const controller = new AbortController();
100
100
  inFlight.set(session.id, controller);
101
+ // Bug8: audit state for the compression call. null = no audit for this run
102
+ // (disabled, or no LLM call was actually made). The audit row is written in
103
+ // the finally below — once, regardless of which exit path the call took —
104
+ // so a failed/aborted stream still leaves a status='error' trail without
105
+ // ever blocking the summarization itself.
106
+ let audit = null;
101
107
  try {
102
108
  const header = session.requestHeader?.()?.config;
103
109
  // Config override takes priority, then session header, then nothing.
@@ -110,6 +116,18 @@ export function createSummarizer(ctx, service, config) {
110
116
  const messages = collectMessages(session);
111
117
  if (!messages.length) return;
112
118
 
119
+ if (config?.llmAudit?.enabled !== false && typeof service.saveLlmAudit === "function") {
120
+ audit = {
121
+ route,
122
+ timestamp: new Date().toISOString(),
123
+ startedAt: Date.now(),
124
+ inputTokens: 0,
125
+ outputTokens: 0,
126
+ status: "success",
127
+ errorMessage: null
128
+ };
129
+ }
130
+
113
131
  const assembler = new BlockAssembler();
114
132
  let text = "";
115
133
  const options = {
@@ -122,15 +140,35 @@ export function createSummarizer(ctx, service, config) {
122
140
  ],
123
141
  signal: controller.signal
124
142
  };
125
- for await (const chunk of ctx.llm.stream(options)) {
126
- if (STREAM_CHUNK_TYPES.has(chunk.type)) assembler.push(toProtocolChunk(chunk));
127
- if (chunk.type === "text-delta") {
128
- text += chunk.text ?? chunk.delta ?? "";
143
+ try {
144
+ for await (const chunk of ctx.llm.stream(options)) {
145
+ if (STREAM_CHUNK_TYPES.has(chunk.type)) assembler.push(toProtocolChunk(chunk));
146
+ if (chunk.type === "text-delta") {
147
+ text += chunk.text ?? chunk.delta ?? "";
148
+ }
149
+ if (chunk.type === "usage" && audit) {
150
+ const i = chunk.input_tokens ?? chunk.inputTokens ?? chunk.prompt_tokens ?? chunk.promptTokens;
151
+ const o = chunk.output_tokens ?? chunk.outputTokens ?? chunk.completion_tokens ?? chunk.completionTokens;
152
+ if (Number.isFinite(i)) audit.inputTokens = i;
153
+ if (Number.isFinite(o)) audit.outputTokens = o;
154
+ }
155
+ if (chunk.type === "finish") {
156
+ const reasonKind = chunk.reason?.kind ?? chunk.kind;
157
+ if (reasonKind === "error" || reasonKind === "aborted") {
158
+ if (audit) {
159
+ audit.status = "error";
160
+ audit.errorMessage = `llm stream ${reasonKind}`;
161
+ }
162
+ return;
163
+ }
164
+ }
129
165
  }
130
- if (chunk.type === "finish") {
131
- const reasonKind = chunk.reason?.kind ?? chunk.kind;
132
- if (reasonKind === "error" || reasonKind === "aborted") return;
166
+ } catch (error) {
167
+ if (audit) {
168
+ audit.status = "error";
169
+ audit.errorMessage = String(error?.message ?? error);
133
170
  }
171
+ throw error; // caller's catch handles the failure; audit already staged
134
172
  }
135
173
  // Direct delta accumulation is the primary extraction path (it works
136
174
  // for real protocol chunks {index,text} and looser {delta} shapes
@@ -147,6 +185,26 @@ export function createSummarizer(ctx, service, config) {
147
185
  service.saveWithDedupe({ ...entry, source: `session:${session.id}` });
148
186
  }
149
187
  } finally {
188
+ if (audit) {
189
+ try {
190
+ service.saveLlmAudit({
191
+ timestamp: audit.timestamp,
192
+ trigger_source: "autoSummarize",
193
+ operation_type: "summarize_compress",
194
+ model_id: `${audit.route.provider}:${audit.route.model}`,
195
+ input_tokens: audit.inputTokens,
196
+ output_tokens: audit.outputTokens,
197
+ total_tokens: audit.inputTokens + audit.outputTokens,
198
+ cost_usd: 0,
199
+ duration_ms: Date.now() - audit.startedAt,
200
+ status: audit.status,
201
+ error_message: audit.errorMessage,
202
+ related_memory_ids: []
203
+ });
204
+ } catch (auditError) {
205
+ ctx.logger?.warn?.(`dsh-mneme: llm audit write failed: ${String(auditError)}`);
206
+ }
207
+ }
150
208
  inFlight.delete(session.id);
151
209
  }
152
210
  }
@@ -73,13 +73,23 @@ export function createVectorIndex({ store, logger }) {
73
73
 
74
74
  /** Re-embed every row missing an embedding. Returns indexed count. */
75
75
  async rebuildIndex(embedder, { limit = 1000 } = {}) {
76
- if (!embedder || typeof embedder.embedSingle !== "function") return { indexed: 0, skipped: 0 };
76
+ // The loop needs a single-text embedder. Native embedSingle is preferred;
77
+ // embed-only OpenAI-compatible clients (issue #10) are accepted too via
78
+ // their `embed` single-text interface, so /vector-reindex no longer
79
+ // silently returns 0 for them. An embedder exposing neither is ignored.
80
+ let embedOne = null;
81
+ if (embedder && typeof embedder.embedSingle === "function") {
82
+ embedOne = (text) => embedder.embedSingle(text);
83
+ } else if (embedder && typeof embedder.embed === "function") {
84
+ embedOne = (text) => Promise.resolve(embedder.embed(text));
85
+ }
86
+ if (!embedOne) return { indexed: 0, skipped: 0 };
77
87
  const rows = store.needsEmbedding(limit);
78
88
  let indexed = 0;
79
89
  for (const row of rows) {
80
90
  try {
81
91
  const text = [row.title, row.content].filter(Boolean).join("\n");
82
- const vector = await embedder.embedSingle(text);
92
+ const vector = await embedOne(text);
83
93
  if (vector && vector.length) {
84
94
  store.setEmbedding(row.id, vector);
85
95
  indexed++;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@modusensus/dsh-mneme",
3
3
  "description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors, 7 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
4
- "version": "0.4.5",
4
+ "version": "0.4.6",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
package/src/api.js CHANGED
@@ -237,7 +237,9 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
237
237
  const task = viaIndex
238
238
  ? semantic.vectorIndex.rebuildIndex(embedder, { limit })
239
239
  : embedder.reindexMissing ? embedder.reindexMissing(limit) : Promise.resolve({ indexed: 0, skipped: 0, error: "vector-unavailable" });
240
- task.then((result) => {
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) => {
241
243
  sendJson(res, 200, result);
242
244
  }).catch(() => {
243
245
  sendJson(res, 200, { indexed: 0, skipped: 0, error: "vector-failed" });
@@ -268,6 +270,42 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
268
270
  }
269
271
  });
270
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
+
271
309
  // --- health: mirror sync state (F-NEW-03 / v0.3.6) ---
272
310
  // Auth-gated; only returns a sanitized error code (never raw last_error which
273
311
  // may leak paths/token-like strings/internal hosts). On state read failure it
@@ -354,7 +392,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
354
392
  });
355
393
 
356
394
  return {
357
- routes: 9,
395
+ routes: 11,
358
396
  dispose: () => {
359
397
  for (const dispose of disposers) dispose();
360
398
  }
package/src/config.js CHANGED
@@ -82,6 +82,15 @@ export const Config = z.object({
82
82
  vectorSearchThreshold: z.number().min(0).max(1).default(0.65),
83
83
  hybridSearchVectorWeight: z.number().min(0).max(1).default(0.6),
84
84
  hybridSearchKeywordWeight: z.number().min(0).max(1).default(0.4),
85
+ // Lazy auto-backfill of missing embeddings on boot (Bug2): when the vector
86
+ // API is configured and rows still lack an embedding, the index is rebuilt
87
+ // in the background after a short delay, rate-limited in batches. On by
88
+ // default; set false to keep the backfill manual only.
89
+ autoReindexOnBoot: z.boolean().default(true),
90
+ // Semantic-first injection (Bug4): when enabled, injectCandidates with a
91
+ // non-empty query recalls via the vector index first and falls back to the
92
+ // rule-based pick to fill/dedupe. Empty query / no vector → legacy behavior.
93
+ hybridInject: z.boolean().default(true),
85
94
 
86
95
  // --- semantic: rerank layer (v0.2) --------------------------------------
87
96
  // Opt-in by default (item ⑥): the local cross-encoder pulls in onnxruntime
@@ -177,6 +186,32 @@ export const Config = z.object({
177
186
  // never used to influence behavior).
178
187
  trustEpistemicWeighting: z.boolean().default(false),
179
188
 
189
+ // --- memory quality filter (Bug7) ------------------------------------------
190
+ // Heuristic gate on what deserves the injection/recall surface. When enabled,
191
+ // saveWithDedupe scores each new memory after dedupe and before write:
192
+ // score >= degradeThreshold (60) → stored normally
193
+ // archiveThreshold (30) <= score < 60 → quality_score persisted and the
194
+ // injection sort re-ranks by importance * quality_score/100 (degraded)
195
+ // score < 30 → archived + tagged low_quality (still explicitly searchable)
196
+ // Meta-memory markers, near-duplicates and repetitive filler lose points.
197
+ memoryQualityFilter: z.object({
198
+ enabled: z.boolean().default(true),
199
+ archiveThreshold: z.natural().min(1).max(100).default(30),
200
+ degradeThreshold: z.natural().min(1).max(100).default(60),
201
+ minContentLength: z.natural().min(1).max(1000).default(10)
202
+ }).default({}),
203
+
204
+ // --- LLM audit trail (Bug8) ------------------------------------------------
205
+ // Records every background LLM call (autoDream consolidation + summary,
206
+ // autoSummarize compression) into llm_audit_logs: tokens, duration, status
207
+ // and which trigger produced it. Failures are recorded as status=error and
208
+ // never block the feature. retentionDays bounds the table: older rows are
209
+ // purged on boot.
210
+ llmAudit: z.object({
211
+ enabled: z.boolean().default(true),
212
+ retentionDays: z.natural().min(1).max(3650).default(90)
213
+ }).default({}),
214
+
180
215
  // --- recall evaluation: test-result storage (v0.4.6, 方案 B) --------------
181
216
  // Separate retrieval evaluation snapshots from the production recall audit.
182
217
  // When false (default) evaluateRetrieval still computes precision/recall/mrr