@modusensus/dsh-mneme 0.4.4 → 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/README.md +27 -0
- package/lib/api.js +40 -2
- package/lib/config.js +53 -0
- package/lib/dream/decisions.js +43 -2
- package/lib/dream.js +86 -6
- package/lib/embedding.js +59 -2
- package/lib/index.js +68 -1
- package/lib/inject.js +84 -4
- package/lib/quality-filter.js +123 -0
- package/lib/service.js +355 -14
- package/lib/store.js +366 -5
- package/lib/summarize.js +65 -7
- package/lib/vector-index.js +12 -2
- package/package.json +1 -1
- package/src/api.js +40 -2
- package/src/config.js +53 -0
- package/src/dream/decisions.js +43 -2
- package/src/dream.js +86 -6
- package/src/embedding.js +59 -2
- package/src/index.js +68 -1
- package/src/inject.js +84 -4
- package/src/quality-filter.js +123 -0
- package/src/service.js +355 -14
- package/src/store.js +366 -5
- package/src/summarize.js +65 -7
- package/src/vector-index.js +12 -2
- package/test/api.test.js +84 -0
- package/test/dream.test.js +52 -0
- package/test/epistemic.test.js +298 -0
- package/test/inject.test.js +21 -0
- package/test/llm-audit.test.js +279 -0
- package/test/mirror-edit-digest.test.js +3 -1
- package/test/quality-filter.test.js +118 -0
- package/test/recall-evals.test.js +235 -0
- package/test/service.test.js +133 -2
- package/test/vector-index.test.js +22 -6
package/src/store.js
CHANGED
|
@@ -12,7 +12,9 @@ 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,
|
|
17
|
+
epistemic_status TEXT NOT NULL DEFAULT 'subjective',
|
|
16
18
|
last_accessed_at TEXT,
|
|
17
19
|
_full_content TEXT,
|
|
18
20
|
created_at TEXT NOT NULL,
|
|
@@ -61,6 +63,29 @@ CREATE TABLE IF NOT EXISTS recall_runs (
|
|
|
61
63
|
CREATE INDEX IF NOT EXISTS idx_recall_runs_created ON recall_runs(created_at);
|
|
62
64
|
CREATE INDEX IF NOT EXISTS idx_recall_runs_query ON recall_runs(query);
|
|
63
65
|
|
|
66
|
+
-- recall_evals: retrieval evaluation/test snapshots, kept SEPARATE from the
|
|
67
|
+
-- recall_runs production audit so test runs never inflate the production trail.
|
|
68
|
+
-- One row per evaluateRetrieval call that opted into persistence
|
|
69
|
+
-- (config.evalPersistTestResults): the query, the expected ids the operator
|
|
70
|
+
-- marked relevant, the actual ids retrieval returned, and the computed
|
|
71
|
+
-- metrics (precision/recall/mrr). recall_run_id optionally links to the
|
|
72
|
+
-- recall_runs audit row that captured the same retrieval scene (null when the
|
|
73
|
+
-- eval did not also record a run). Bookkeeping like the other audit tables: it
|
|
74
|
+
-- never triggers write hooks.
|
|
75
|
+
CREATE TABLE IF NOT EXISTS recall_evals (
|
|
76
|
+
id TEXT PRIMARY KEY,
|
|
77
|
+
recall_run_id TEXT, -- FK → recall_runs.id (optional linkage)
|
|
78
|
+
query TEXT NOT NULL,
|
|
79
|
+
expected_ids TEXT NOT NULL, -- JSON: relevant ids expected by the evaluator
|
|
80
|
+
actual_ids TEXT NOT NULL, -- JSON: ids actually retrieved
|
|
81
|
+
metrics TEXT NOT NULL, -- JSON: { precision, recall, mrr, hit_count }
|
|
82
|
+
eval_type TEXT NOT NULL DEFAULT 'manual',
|
|
83
|
+
created_at TEXT NOT NULL,
|
|
84
|
+
FOREIGN KEY (recall_run_id) REFERENCES recall_runs(id)
|
|
85
|
+
);
|
|
86
|
+
CREATE INDEX IF NOT EXISTS idx_recall_evals_created ON recall_evals(created_at);
|
|
87
|
+
CREATE INDEX IF NOT EXISTS idx_recall_evals_run ON recall_evals(recall_run_id);
|
|
88
|
+
|
|
64
89
|
-- failure_memories: records user corrections / reflection failures. Captures
|
|
65
90
|
-- what a memory was (actual) vs what the user changed it to (expected)
|
|
66
91
|
-- so later reflection passes can mine recurring correction patterns.
|
|
@@ -122,6 +147,31 @@ CREATE TABLE IF NOT EXISTS conflict_pending (
|
|
|
122
147
|
);
|
|
123
148
|
CREATE INDEX IF NOT EXISTS idx_conflict_pending_unresolved ON conflict_pending(resolved_at);
|
|
124
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
|
+
|
|
125
175
|
-- entity gene (v0.3.0): named entities mentioned across memories, with
|
|
126
176
|
-- time-boxed attributes (valid_from → valid_until) and typed relations.
|
|
127
177
|
-- Attributes follow the snapshot style: saveAttr invalidates the previous
|
|
@@ -191,6 +241,48 @@ CREATE TABLE IF NOT EXISTS mirror_state (
|
|
|
191
241
|
|
|
192
242
|
const TYPES = new Set(["preference", "project", "decision", "history", "summary", "pattern"]);
|
|
193
243
|
|
|
244
|
+
// Epistemic status: what kind of evidence a memory rests on. Defaults to
|
|
245
|
+
// 'subjective' so legacy rows (and rows without any signal) stay compatible.
|
|
246
|
+
const EPISTEMIC_STATUSES = new Set(["observation", "subjective", "inferred"]);
|
|
247
|
+
// Rule-based inference markers, checked in priority order (observation >
|
|
248
|
+
// inferred > subjective). The default fallback is 'subjective'.
|
|
249
|
+
const OBSERVATION_RE = /实测|观察到|观测|测得|测量|结果表明|数据显示|实验|统计|结果/;
|
|
250
|
+
const INFERRED_RE = /推断|推测出|推导|推论|由此可|据此|综上|意味着|所以|因此/;
|
|
251
|
+
const SUBJECTIVE_RE = /我推测|我猜|我觉得|我感觉|可能|大概|也许|认为|猜想|似乎|猜测|感觉/;
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Heuristically infer a memory's epistemic status from its content (and the
|
|
255
|
+
* AI-generated types). summary/pattern entries are always 'inferred' (derived
|
|
256
|
+
* from other memories); otherwise content markers decide. Pure rule-based, so
|
|
257
|
+
* it never throws and always returns a value in EPISTEMIC_STATUSES.
|
|
258
|
+
*/
|
|
259
|
+
function inferEpistemicStatus(memory) {
|
|
260
|
+
if (memory.type === "summary" || memory.type === "pattern") return "inferred";
|
|
261
|
+
const text = `${memory.title ?? ""} ${memory.content ?? ""}`;
|
|
262
|
+
if (OBSERVATION_RE.test(text)) return "observation";
|
|
263
|
+
if (INFERRED_RE.test(text)) return "inferred";
|
|
264
|
+
if (SUBJECTIVE_RE.test(text)) return "subjective";
|
|
265
|
+
return "subjective";
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Resolve a requested epistemic_status: explicit valid value wins, otherwise
|
|
269
|
+
* re-infer from (possibly updated) content. Never returns an invalid value. */
|
|
270
|
+
function resolveEpistemicStatus(memory, patch) {
|
|
271
|
+
if (patch?.epistemic_status !== undefined) {
|
|
272
|
+
return EPISTEMIC_STATUSES.has(patch.epistemic_status) ? patch.epistemic_status : "subjective";
|
|
273
|
+
}
|
|
274
|
+
// Re-infer whenever any signal that feeds the heuristic changed: content
|
|
275
|
+
// (marker words), title (marker words), or type (summary/pattern are always
|
|
276
|
+
// inferred). Otherwise keep the stored status.
|
|
277
|
+
const changed = ["content", "title", "type"].some(
|
|
278
|
+
(k) => patch?.[k] !== undefined && patch[k] !== memory?.[k]
|
|
279
|
+
);
|
|
280
|
+
if (changed) {
|
|
281
|
+
return inferEpistemicStatus({ ...memory, ...patch });
|
|
282
|
+
}
|
|
283
|
+
return memory?.epistemic_status ?? "subjective";
|
|
284
|
+
}
|
|
285
|
+
|
|
194
286
|
// Per-type mirror sync receipts (peer blocker 4): a type is either committed
|
|
195
287
|
// (file written + fence applied), failed (last sync round errored for it), or
|
|
196
288
|
// pending (still owed a write).
|
|
@@ -229,6 +321,9 @@ function toRow(row) {
|
|
|
229
321
|
forgotten: row.forgotten === 1,
|
|
230
322
|
archived: row.archived === 1,
|
|
231
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,
|
|
326
|
+
epistemic_status: row.epistemic_status ?? "subjective",
|
|
232
327
|
created_at: row.created_at,
|
|
233
328
|
updated_at: row.updated_at,
|
|
234
329
|
last_accessed_at: row.last_accessed_at ?? undefined,
|
|
@@ -305,6 +400,24 @@ function toRecallRun(row) {
|
|
|
305
400
|
};
|
|
306
401
|
}
|
|
307
402
|
|
|
403
|
+
function toRecallEval(row) {
|
|
404
|
+
if (!row) return undefined;
|
|
405
|
+
let metrics;
|
|
406
|
+
if (row.metrics != null) {
|
|
407
|
+
try { metrics = JSON.parse(row.metrics); } catch { metrics = undefined; }
|
|
408
|
+
}
|
|
409
|
+
return {
|
|
410
|
+
id: row.id,
|
|
411
|
+
recall_run_id: row.recall_run_id ?? undefined,
|
|
412
|
+
query: row.query,
|
|
413
|
+
expected_ids: parseJsonArray(row.expected_ids),
|
|
414
|
+
actual_ids: parseJsonArray(row.actual_ids),
|
|
415
|
+
metrics,
|
|
416
|
+
eval_type: row.eval_type,
|
|
417
|
+
created_at: row.created_at
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
308
421
|
function toEntity(row) {
|
|
309
422
|
if (!row) return undefined;
|
|
310
423
|
return {
|
|
@@ -354,6 +467,34 @@ function toRelation(row) {
|
|
|
354
467
|
};
|
|
355
468
|
}
|
|
356
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
|
+
|
|
357
498
|
function toMirrorState(row) {
|
|
358
499
|
if (!row) {
|
|
359
500
|
return {
|
|
@@ -421,6 +562,15 @@ export function createStore(path) {
|
|
|
421
562
|
if (!columns.includes("_full_content")) {
|
|
422
563
|
db.exec("ALTER TABLE memories ADD COLUMN _full_content TEXT");
|
|
423
564
|
}
|
|
565
|
+
if (!columns.includes("epistemic_status")) {
|
|
566
|
+
db.exec("ALTER TABLE memories ADD COLUMN epistemic_status TEXT NOT NULL DEFAULT 'subjective'");
|
|
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
|
+
}
|
|
424
574
|
|
|
425
575
|
// Legacy dream_runs without policy_epoch → backfill with the default epoch.
|
|
426
576
|
const dreamCols = db.prepare("PRAGMA table_info(dream_runs)").all().map((c) => c.name);
|
|
@@ -512,11 +662,31 @@ export function createStore(path) {
|
|
|
512
662
|
const embedding = Array.isArray(memory.embedding) && memory.embedding.length
|
|
513
663
|
? JSON.stringify(memory.embedding)
|
|
514
664
|
: null;
|
|
665
|
+
// Explicit valid status wins; otherwise infer from content/type. Falls back
|
|
666
|
+
// to 'subjective' (the column default) so legacy callers never break.
|
|
667
|
+
const epistemicStatus = EPISTEMIC_STATUSES.has(memory.epistemic_status)
|
|
668
|
+
? memory.epistemic_status
|
|
669
|
+
: inferEpistemicStatus(memory);
|
|
515
670
|
runAtomically(() => {
|
|
516
671
|
db.prepare(
|
|
517
|
-
`INSERT INTO memories (id, type, title, content, tags, importance, forgotten, source, embedding, created_at, updated_at)
|
|
518
|
-
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)`
|
|
519
|
-
).run(
|
|
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
|
+
);
|
|
520
690
|
// desired generation bumped in the same transaction as the write: once
|
|
521
691
|
// this commits, generation > applied_generation, so a crash right after
|
|
522
692
|
// (before syncMirror) is caught by recoverMirror on restart (peer
|
|
@@ -538,9 +708,16 @@ export function createStore(path) {
|
|
|
538
708
|
const embedding = patch.embedding !== undefined
|
|
539
709
|
? (Array.isArray(patch.embedding) && patch.embedding.length ? JSON.stringify(patch.embedding) : null)
|
|
540
710
|
: existing.embedding ?? null;
|
|
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);
|
|
541
718
|
runAtomically(() => {
|
|
542
719
|
db.prepare(
|
|
543
|
-
`UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, embedding=?, 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=?`
|
|
544
721
|
).run(
|
|
545
722
|
type,
|
|
546
723
|
patch.title ?? existing.title,
|
|
@@ -548,7 +725,10 @@ export function createStore(path) {
|
|
|
548
725
|
JSON.stringify(patch.tags ?? existing.tags),
|
|
549
726
|
Number.isInteger(patch.importance) ? patch.importance : existing.importance,
|
|
550
727
|
patch.source !== undefined ? patch.source : (existing.source ?? null),
|
|
728
|
+
contentHistory,
|
|
729
|
+
qualityScore,
|
|
551
730
|
embedding,
|
|
731
|
+
epistemicStatus,
|
|
552
732
|
now,
|
|
553
733
|
id
|
|
554
734
|
);
|
|
@@ -588,6 +768,13 @@ export function createStore(path) {
|
|
|
588
768
|
const embedding = patch.embedding !== undefined
|
|
589
769
|
? (Array.isArray(patch.embedding) && patch.embedding.length ? JSON.stringify(patch.embedding) : null)
|
|
590
770
|
: existing.embedding ?? null;
|
|
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);
|
|
591
778
|
// The CAS UPDATE and the desired-generation bump must commit together (audit
|
|
592
779
|
// peer A): if the UPDATE autocommits first and the process dies before the
|
|
593
780
|
// increment, the store is mutated while generation == applied_generation and
|
|
@@ -597,7 +784,7 @@ export function createStore(path) {
|
|
|
597
784
|
let applied = false;
|
|
598
785
|
runAtomically(() => {
|
|
599
786
|
const result = db.prepare(
|
|
600
|
-
`UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, embedding=?, updated_at=?
|
|
787
|
+
`UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, content_history=?, quality_score=?, embedding=?, epistemic_status=?, updated_at=?
|
|
601
788
|
WHERE id=? AND updated_at=?`
|
|
602
789
|
).run(
|
|
603
790
|
type,
|
|
@@ -606,7 +793,10 @@ export function createStore(path) {
|
|
|
606
793
|
JSON.stringify(patch.tags ?? existing.tags),
|
|
607
794
|
Number.isInteger(patch.importance) ? patch.importance : existing.importance,
|
|
608
795
|
patch.source !== undefined ? patch.source : (existing.source ?? null),
|
|
796
|
+
contentHistory,
|
|
797
|
+
qualityScore,
|
|
609
798
|
embedding,
|
|
799
|
+
epistemicStatus,
|
|
610
800
|
now,
|
|
611
801
|
id,
|
|
612
802
|
expectedUpdatedAt
|
|
@@ -1001,6 +1191,169 @@ export function createStore(path) {
|
|
|
1001
1191
|
return rows.map(toRecallRun);
|
|
1002
1192
|
}
|
|
1003
1193
|
|
|
1194
|
+
// --- recall evaluation trail (方案 B: separate from the production audit) -
|
|
1195
|
+
|
|
1196
|
+
/**
|
|
1197
|
+
* Persist one retrieval-evaluation snapshot into recall_evals — the test/eval
|
|
1198
|
+
* sibling of recall_runs, deliberately stored apart so eval snapshots never
|
|
1199
|
+
* inflate the production recall audit. Like the other audit tables this is
|
|
1200
|
+
* bookkeeping: it never triggers write hooks. Writes are idempotent on id
|
|
1201
|
+
* (replay overwrites, never duplicates), matching saveRecallRun. recall_run_id
|
|
1202
|
+
* optionally links the eval to the recall_runs row that captured the same
|
|
1203
|
+
* retrieval scene (FK-referenced, null when no run was recorded).
|
|
1204
|
+
*/
|
|
1205
|
+
function saveRecallEval(evalRow) {
|
|
1206
|
+
const id = evalRow.id ?? randomUUID();
|
|
1207
|
+
db.prepare(
|
|
1208
|
+
`INSERT INTO recall_evals (id, recall_run_id, query, expected_ids, actual_ids, metrics, eval_type, created_at)
|
|
1209
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1210
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
1211
|
+
recall_run_id=excluded.recall_run_id, query=excluded.query,
|
|
1212
|
+
expected_ids=excluded.expected_ids, actual_ids=excluded.actual_ids,
|
|
1213
|
+
metrics=excluded.metrics, eval_type=excluded.eval_type,
|
|
1214
|
+
created_at=excluded.created_at`
|
|
1215
|
+
).run(
|
|
1216
|
+
id,
|
|
1217
|
+
evalRow.recall_run_id ?? null,
|
|
1218
|
+
evalRow.query,
|
|
1219
|
+
JSON.stringify(evalRow.expected_ids ?? []),
|
|
1220
|
+
JSON.stringify(evalRow.actual_ids ?? []),
|
|
1221
|
+
JSON.stringify(evalRow.metrics ?? {}),
|
|
1222
|
+
evalRow.eval_type ?? "manual",
|
|
1223
|
+
evalRow.created_at ?? nowIso()
|
|
1224
|
+
);
|
|
1225
|
+
return getRecallEval(id);
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
function getRecallEval(id) {
|
|
1229
|
+
const row = db.prepare("SELECT * FROM recall_evals WHERE id = ?").get(id);
|
|
1230
|
+
return toRecallEval(row);
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
function listRecallEvals({ limit = 50, offset = 0, query } = {}) {
|
|
1234
|
+
const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
|
|
1235
|
+
const clauses = [];
|
|
1236
|
+
const params = [];
|
|
1237
|
+
if (query) {
|
|
1238
|
+
clauses.push("query LIKE ? ESCAPE '\\'");
|
|
1239
|
+
params.push(`%${escapeLike(String(query))}%`);
|
|
1240
|
+
}
|
|
1241
|
+
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
1242
|
+
const rows = db.prepare(
|
|
1243
|
+
`SELECT * FROM recall_evals ${where} ORDER BY created_at DESC, id LIMIT ? OFFSET ?`
|
|
1244
|
+
).all(...params, lim, off);
|
|
1245
|
+
return rows.map(toRecallEval);
|
|
1246
|
+
}
|
|
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
|
+
|
|
1004
1357
|
// --- failure memories ----------------------------------------------------
|
|
1005
1358
|
|
|
1006
1359
|
/**
|
|
@@ -1524,6 +1877,14 @@ export function createStore(path) {
|
|
|
1524
1877
|
saveRecallRun,
|
|
1525
1878
|
getRecallRun,
|
|
1526
1879
|
listRecallRuns,
|
|
1880
|
+
saveRecallEval,
|
|
1881
|
+
getRecallEval,
|
|
1882
|
+
listRecallEvals,
|
|
1883
|
+
saveLlmAudit,
|
|
1884
|
+
listLlmAudits,
|
|
1885
|
+
countLlmAudits,
|
|
1886
|
+
getLlmAuditStats,
|
|
1887
|
+
deleteOldLlmAudits,
|
|
1527
1888
|
saveFailure,
|
|
1528
1889
|
listFailures,
|
|
1529
1890
|
getFailureStats,
|
package/src/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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
}
|
package/src/vector-index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
92
|
+
const vector = await embedOne(text);
|
|
83
93
|
if (vector && vector.length) {
|
|
84
94
|
store.setEmbedding(row.id, vector);
|
|
85
95
|
indexed++;
|