@modusensus/dsh-mneme 0.4.5 → 0.4.7
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 +25 -0
- package/lib/api.js +40 -2
- package/lib/config.js +35 -0
- package/lib/dream.js +86 -6
- package/lib/embedding.js +59 -2
- package/lib/index.js +68 -1
- package/lib/inject.js +79 -4
- package/lib/quality-filter.js +123 -0
- package/lib/service.js +214 -13
- package/lib/store.js +234 -39
- 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 +35 -0
- package/src/dream.js +86 -6
- package/src/embedding.js +59 -2
- package/src/index.js +68 -1
- package/src/inject.js +79 -4
- package/src/quality-filter.js +123 -0
- package/src/service.js +214 -13
- package/src/store.js +234 -39
- 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/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/service.test.js +133 -2
- package/test/vector-index.test.js +22 -6
package/src/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 {
|
|
@@ -492,45 +548,39 @@ export function createStore(path) {
|
|
|
492
548
|
db.exec("PRAGMA journal_mode = WAL;");
|
|
493
549
|
db.exec(SCHEMA);
|
|
494
550
|
|
|
495
|
-
// Schema migrations for legacy databases (idempotent).
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
db.
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
551
|
+
// Schema migrations for legacy databases (idempotent). Each ADD COLUMN is
|
|
552
|
+
// also race-safe: two concurrently-opening processes can both pass the
|
|
553
|
+
// PRAGMA table_info check before either ALTERs, so the ALTER itself is
|
|
554
|
+
// guarded against the "duplicate column name" error SQLite raises when the
|
|
555
|
+
// other process won the race (SQLite has no ADD COLUMN IF NOT EXISTS).
|
|
556
|
+
const addColumn = (table, column, ddl) => {
|
|
557
|
+
const cols = db.prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name);
|
|
558
|
+
if (!cols.includes(column)) {
|
|
559
|
+
try {
|
|
560
|
+
db.exec(ddl);
|
|
561
|
+
} catch (e) {
|
|
562
|
+
if (!/duplicate column name/i.test(String(e?.message ?? e))) throw e;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
addColumn("memories", "archived", "ALTER TABLE memories ADD COLUMN archived INTEGER NOT NULL DEFAULT 0");
|
|
568
|
+
addColumn("memories", "embedding", "ALTER TABLE memories ADD COLUMN embedding TEXT");
|
|
569
|
+
addColumn("memories", "last_accessed_at", "ALTER TABLE memories ADD COLUMN last_accessed_at TEXT");
|
|
570
|
+
addColumn("memories", "_full_content", "ALTER TABLE memories ADD COLUMN _full_content TEXT");
|
|
571
|
+
addColumn("memories", "epistemic_status", "ALTER TABLE memories ADD COLUMN epistemic_status TEXT NOT NULL DEFAULT 'subjective'");
|
|
572
|
+
addColumn("memories", "content_history", "ALTER TABLE memories ADD COLUMN content_history TEXT");
|
|
573
|
+
addColumn("memories", "quality_score", "ALTER TABLE memories ADD COLUMN quality_score REAL");
|
|
512
574
|
|
|
513
575
|
// Legacy dream_runs without policy_epoch → backfill with the default epoch.
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
db.exec("ALTER TABLE dream_runs ADD COLUMN policy_epoch INTEGER NOT NULL DEFAULT 0");
|
|
517
|
-
}
|
|
518
|
-
if (!dreamCols.includes("run_type")) {
|
|
519
|
-
db.exec("ALTER TABLE dream_runs ADD COLUMN run_type TEXT NOT NULL DEFAULT 'auto'");
|
|
520
|
-
}
|
|
576
|
+
addColumn("dream_runs", "policy_epoch", "ALTER TABLE dream_runs ADD COLUMN policy_epoch INTEGER NOT NULL DEFAULT 0");
|
|
577
|
+
addColumn("dream_runs", "run_type", "ALTER TABLE dream_runs ADD COLUMN run_type TEXT NOT NULL DEFAULT 'auto'");
|
|
521
578
|
|
|
522
579
|
// Legacy mirror_state without v0.3.6 generation columns → add each missing
|
|
523
580
|
// column idempotently (old DBs open cleanly, no data loss).
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
}
|
|
528
|
-
if (!mirrorCols.includes("applied_generation")) {
|
|
529
|
-
db.exec("ALTER TABLE mirror_state ADD COLUMN applied_generation INTEGER NOT NULL DEFAULT 0");
|
|
530
|
-
}
|
|
531
|
-
if (!mirrorCols.includes("type_status")) {
|
|
532
|
-
db.exec("ALTER TABLE mirror_state ADD COLUMN type_status TEXT");
|
|
533
|
-
}
|
|
581
|
+
addColumn("mirror_state", "generation", "ALTER TABLE mirror_state ADD COLUMN generation INTEGER NOT NULL DEFAULT 0");
|
|
582
|
+
addColumn("mirror_state", "applied_generation", "ALTER TABLE mirror_state ADD COLUMN applied_generation INTEGER NOT NULL DEFAULT 0");
|
|
583
|
+
addColumn("mirror_state", "type_status", "ALTER TABLE mirror_state ADD COLUMN type_status TEXT");
|
|
534
584
|
|
|
535
585
|
// Audit peer F: a legacy DB may hold a non-integer generation/applied_generation
|
|
536
586
|
// (pre-v0.3.9 the JS gate truncated with Math.trunc and SQLite's CHECK only
|
|
@@ -607,9 +657,24 @@ export function createStore(path) {
|
|
|
607
657
|
: inferEpistemicStatus(memory);
|
|
608
658
|
runAtomically(() => {
|
|
609
659
|
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(
|
|
660
|
+
`INSERT INTO memories (id, type, title, content, tags, importance, forgotten, archived, source, content_history, quality_score, embedding, epistemic_status, created_at, updated_at)
|
|
661
|
+
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
662
|
+
).run(
|
|
663
|
+
id,
|
|
664
|
+
type,
|
|
665
|
+
memory.title,
|
|
666
|
+
memory.content,
|
|
667
|
+
tags,
|
|
668
|
+
importance,
|
|
669
|
+
memory.archived ? 1 : 0,
|
|
670
|
+
memory.source ?? null,
|
|
671
|
+
JSON.stringify(memory.content_history ?? []),
|
|
672
|
+
Number.isFinite(memory.quality_score) ? memory.quality_score : null,
|
|
673
|
+
embedding,
|
|
674
|
+
epistemicStatus,
|
|
675
|
+
now,
|
|
676
|
+
now
|
|
677
|
+
);
|
|
613
678
|
// desired generation bumped in the same transaction as the write: once
|
|
614
679
|
// this commits, generation > applied_generation, so a crash right after
|
|
615
680
|
// (before syncMirror) is caught by recoverMirror on restart (peer
|
|
@@ -632,9 +697,15 @@ export function createStore(path) {
|
|
|
632
697
|
? (Array.isArray(patch.embedding) && patch.embedding.length ? JSON.stringify(patch.embedding) : null)
|
|
633
698
|
: existing.embedding ?? null;
|
|
634
699
|
const epistemicStatus = resolveEpistemicStatus(existing, patch);
|
|
700
|
+
const contentHistory = Array.isArray(patch.content_history)
|
|
701
|
+
? JSON.stringify(patch.content_history)
|
|
702
|
+
: (Array.isArray(existing.content_history) ? JSON.stringify(existing.content_history) : null);
|
|
703
|
+
const qualityScore = patch.quality_score !== undefined && Number.isFinite(patch.quality_score)
|
|
704
|
+
? patch.quality_score
|
|
705
|
+
: (existing.quality_score ?? null);
|
|
635
706
|
runAtomically(() => {
|
|
636
707
|
db.prepare(
|
|
637
|
-
`UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, embedding=?, epistemic_status=?, updated_at=? WHERE id=?`
|
|
708
|
+
`UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, content_history=?, quality_score=?, embedding=?, epistemic_status=?, updated_at=? WHERE id=?`
|
|
638
709
|
).run(
|
|
639
710
|
type,
|
|
640
711
|
patch.title ?? existing.title,
|
|
@@ -642,6 +713,8 @@ export function createStore(path) {
|
|
|
642
713
|
JSON.stringify(patch.tags ?? existing.tags),
|
|
643
714
|
Number.isInteger(patch.importance) ? patch.importance : existing.importance,
|
|
644
715
|
patch.source !== undefined ? patch.source : (existing.source ?? null),
|
|
716
|
+
contentHistory,
|
|
717
|
+
qualityScore,
|
|
645
718
|
embedding,
|
|
646
719
|
epistemicStatus,
|
|
647
720
|
now,
|
|
@@ -684,6 +757,12 @@ export function createStore(path) {
|
|
|
684
757
|
? (Array.isArray(patch.embedding) && patch.embedding.length ? JSON.stringify(patch.embedding) : null)
|
|
685
758
|
: existing.embedding ?? null;
|
|
686
759
|
const epistemicStatus = resolveEpistemicStatus(existing, patch);
|
|
760
|
+
const contentHistory = Array.isArray(patch.content_history)
|
|
761
|
+
? JSON.stringify(patch.content_history)
|
|
762
|
+
: (Array.isArray(existing.content_history) ? JSON.stringify(existing.content_history) : null);
|
|
763
|
+
const qualityScore = patch.quality_score !== undefined && Number.isFinite(patch.quality_score)
|
|
764
|
+
? patch.quality_score
|
|
765
|
+
: (existing.quality_score ?? null);
|
|
687
766
|
// The CAS UPDATE and the desired-generation bump must commit together (audit
|
|
688
767
|
// peer A): if the UPDATE autocommits first and the process dies before the
|
|
689
768
|
// increment, the store is mutated while generation == applied_generation and
|
|
@@ -693,7 +772,7 @@ export function createStore(path) {
|
|
|
693
772
|
let applied = false;
|
|
694
773
|
runAtomically(() => {
|
|
695
774
|
const result = db.prepare(
|
|
696
|
-
`UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, embedding=?, epistemic_status=?, updated_at=?
|
|
775
|
+
`UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, content_history=?, quality_score=?, embedding=?, epistemic_status=?, updated_at=?
|
|
697
776
|
WHERE id=? AND updated_at=?`
|
|
698
777
|
).run(
|
|
699
778
|
type,
|
|
@@ -702,6 +781,8 @@ export function createStore(path) {
|
|
|
702
781
|
JSON.stringify(patch.tags ?? existing.tags),
|
|
703
782
|
Number.isInteger(patch.importance) ? patch.importance : existing.importance,
|
|
704
783
|
patch.source !== undefined ? patch.source : (existing.source ?? null),
|
|
784
|
+
contentHistory,
|
|
785
|
+
qualityScore,
|
|
705
786
|
embedding,
|
|
706
787
|
epistemicStatus,
|
|
707
788
|
now,
|
|
@@ -1152,6 +1233,115 @@ export function createStore(path) {
|
|
|
1152
1233
|
return rows.map(toRecallEval);
|
|
1153
1234
|
}
|
|
1154
1235
|
|
|
1236
|
+
// --- llm audit trail (Bug8) ---------------------------------------------
|
|
1237
|
+
|
|
1238
|
+
/**
|
|
1239
|
+
* Persist one LLM audit row (a background call's token/time/status receipt).
|
|
1240
|
+
* Bookkeeping like the other audit tables: it never triggers write hooks, so
|
|
1241
|
+
* recording a call can never loop back into the scheduler that made it. The
|
|
1242
|
+
* call itself is wrapped so a failure is captured (status='error') instead of
|
|
1243
|
+
* blocking the feature — only a throwing saveLlmAudit is swallowed, never the
|
|
1244
|
+
* LLM call.
|
|
1245
|
+
*/
|
|
1246
|
+
function saveLlmAudit(entry) {
|
|
1247
|
+
const now = nowIso();
|
|
1248
|
+
const inTokens = Number.isFinite(entry.input_tokens) ? entry.input_tokens : 0;
|
|
1249
|
+
const outTokens = Number.isFinite(entry.output_tokens) ? entry.output_tokens : 0;
|
|
1250
|
+
db.prepare(
|
|
1251
|
+
`INSERT INTO llm_audit_logs (timestamp, trigger_source, operation_type, model_id,
|
|
1252
|
+
input_tokens, output_tokens, total_tokens, cost_usd, duration_ms, status,
|
|
1253
|
+
error_message, related_memory_ids, metadata)
|
|
1254
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
1255
|
+
).run(
|
|
1256
|
+
entry.timestamp ?? now,
|
|
1257
|
+
entry.trigger_source,
|
|
1258
|
+
entry.operation_type,
|
|
1259
|
+
entry.model_id,
|
|
1260
|
+
inTokens,
|
|
1261
|
+
outTokens,
|
|
1262
|
+
Number.isFinite(entry.total_tokens) ? entry.total_tokens : inTokens + outTokens,
|
|
1263
|
+
Number.isFinite(entry.cost_usd) ? entry.cost_usd : 0,
|
|
1264
|
+
Number.isFinite(entry.duration_ms) ? entry.duration_ms : 0,
|
|
1265
|
+
entry.status ?? "success",
|
|
1266
|
+
entry.error_message ?? null,
|
|
1267
|
+
JSON.stringify(entry.related_memory_ids ?? []),
|
|
1268
|
+
entry.metadata !== undefined
|
|
1269
|
+
? (typeof entry.metadata === "string" ? entry.metadata : JSON.stringify(entry.metadata))
|
|
1270
|
+
: null
|
|
1271
|
+
);
|
|
1272
|
+
return toLlmAudit(db.prepare("SELECT * FROM llm_audit_logs ORDER BY id DESC LIMIT 1").get());
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
function listLlmAudits({ limit = 50, offset = 0, source } = {}) {
|
|
1276
|
+
const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
|
|
1277
|
+
const clauses = [];
|
|
1278
|
+
const params = [];
|
|
1279
|
+
if (source) {
|
|
1280
|
+
clauses.push("trigger_source = ?");
|
|
1281
|
+
params.push(source);
|
|
1282
|
+
}
|
|
1283
|
+
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
1284
|
+
const rows = db.prepare(
|
|
1285
|
+
`SELECT * FROM llm_audit_logs ${where} ORDER BY timestamp DESC, id DESC LIMIT ? OFFSET ?`
|
|
1286
|
+
).all(...params, lim, off);
|
|
1287
|
+
return rows.map(toLlmAudit);
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
function countLlmAudits({ source } = {}) {
|
|
1291
|
+
const clauses = [];
|
|
1292
|
+
const params = [];
|
|
1293
|
+
if (source) {
|
|
1294
|
+
clauses.push("trigger_source = ?");
|
|
1295
|
+
params.push(source);
|
|
1296
|
+
}
|
|
1297
|
+
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
1298
|
+
return db.prepare(`SELECT count(*) AS c FROM llm_audit_logs ${where}`).get(...params).c;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/**
|
|
1302
|
+
* Aggregate LLM spend over the last `days`: total calls/tokens/duration/cost,
|
|
1303
|
+
* broken down by trigger_source and by status. Used by the API's
|
|
1304
|
+
* /llm-audit/stats endpoint so the Web panel can show where budget goes.
|
|
1305
|
+
*/
|
|
1306
|
+
function getLlmAuditStats({ days = 7 } = {}) {
|
|
1307
|
+
const since = new Date(Date.now() - days * 86400000).toISOString();
|
|
1308
|
+
const total = db.prepare(
|
|
1309
|
+
`SELECT count(*) AS c,
|
|
1310
|
+
COALESCE(SUM(input_tokens), 0) AS i,
|
|
1311
|
+
COALESCE(SUM(output_tokens), 0) AS o,
|
|
1312
|
+
COALESCE(SUM(total_tokens), 0) AS t,
|
|
1313
|
+
COALESCE(SUM(duration_ms), 0) AS d,
|
|
1314
|
+
COALESCE(SUM(cost_usd), 0) AS cst
|
|
1315
|
+
FROM llm_audit_logs WHERE timestamp >= ?`
|
|
1316
|
+
).get(since);
|
|
1317
|
+
const bySource = db.prepare(
|
|
1318
|
+
`SELECT trigger_source AS source, count(*) AS c,
|
|
1319
|
+
COALESCE(SUM(total_tokens), 0) AS total_tokens
|
|
1320
|
+
FROM llm_audit_logs WHERE timestamp >= ?
|
|
1321
|
+
GROUP BY trigger_source ORDER BY total_tokens DESC`
|
|
1322
|
+
).all(since);
|
|
1323
|
+
const byStatus = db.prepare(
|
|
1324
|
+
"SELECT status, count(*) AS c FROM llm_audit_logs WHERE timestamp >= ? GROUP BY status"
|
|
1325
|
+
).all(since);
|
|
1326
|
+
return {
|
|
1327
|
+
days,
|
|
1328
|
+
since,
|
|
1329
|
+
total_calls: total.c,
|
|
1330
|
+
input_tokens: total.i,
|
|
1331
|
+
output_tokens: total.o,
|
|
1332
|
+
total_tokens: total.t,
|
|
1333
|
+
total_duration_ms: total.d,
|
|
1334
|
+
total_cost_usd: Number(total.cst),
|
|
1335
|
+
by_source: bySource,
|
|
1336
|
+
by_status: byStatus
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/** Delete audit rows older than `before` (ISO string). Returns count removed. */
|
|
1341
|
+
function deleteOldLlmAudits(before) {
|
|
1342
|
+
return db.prepare("DELETE FROM llm_audit_logs WHERE timestamp < ?").run(before).changes;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1155
1345
|
// --- failure memories ----------------------------------------------------
|
|
1156
1346
|
|
|
1157
1347
|
/**
|
|
@@ -1678,6 +1868,11 @@ export function createStore(path) {
|
|
|
1678
1868
|
saveRecallEval,
|
|
1679
1869
|
getRecallEval,
|
|
1680
1870
|
listRecallEvals,
|
|
1871
|
+
saveLlmAudit,
|
|
1872
|
+
listLlmAudits,
|
|
1873
|
+
countLlmAudits,
|
|
1874
|
+
getLlmAuditStats,
|
|
1875
|
+
deleteOldLlmAudits,
|
|
1681
1876
|
saveFailure,
|
|
1682
1877
|
listFailures,
|
|
1683
1878
|
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++;
|
package/test/api.test.js
CHANGED
|
@@ -5,6 +5,7 @@ import { createStore } from "../src/store.js";
|
|
|
5
5
|
import { createService } from "../src/service.js";
|
|
6
6
|
import { createApi } from "../src/api.js";
|
|
7
7
|
import { createSettings } from "../src/settings.js";
|
|
8
|
+
import { createVectorIndex } from "../src/vector-index.js";
|
|
8
9
|
|
|
9
10
|
class FakeRes extends EventEmitter {
|
|
10
11
|
constructor() { super(); this.statusCode = 200; this.body = ""; }
|
|
@@ -383,3 +384,86 @@ test("no apiToken configured keeps all endpoints open", async () => {
|
|
|
383
384
|
await vec.handler(req("/api/dsh-mneme/vector-config"), res);
|
|
384
385
|
assert.equal(res.statusCode, 200, "open when apiToken is unset");
|
|
385
386
|
});
|
|
387
|
+
|
|
388
|
+
// --- Bug8: llm-audit API (pagination + stats) --------------------------------
|
|
389
|
+
|
|
390
|
+
test("GET /api/dsh-mneme/semantic/llm-audit returns paginated rows", async () => {
|
|
391
|
+
const { routes, service } = setup();
|
|
392
|
+
for (let i = 0; i < 5; i++) {
|
|
393
|
+
service.saveLlmAudit({ trigger_source: "autoDream", operation_type: "dream_consolidate", model_id: "m1", input_tokens: 10, output_tokens: 5, status: "success", related_memory_ids: [] });
|
|
394
|
+
}
|
|
395
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/semantic/llm-audit");
|
|
396
|
+
const res = new FakeRes();
|
|
397
|
+
await route.handler(req("/api/dsh-mneme/semantic/llm-audit?page=2&pageSize=2"), res);
|
|
398
|
+
assert.equal(res.statusCode, 200);
|
|
399
|
+
const data = JSON.parse(res.body);
|
|
400
|
+
assert.equal(data.total, 5);
|
|
401
|
+
assert.equal(data.page, 2);
|
|
402
|
+
assert.equal(data.pageSize, 2);
|
|
403
|
+
assert.equal(data.items.length, 2, "second page of 2");
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test("GET /api/dsh-mneme/semantic/llm-audit filters by source", async () => {
|
|
407
|
+
const { routes, service } = setup();
|
|
408
|
+
service.saveLlmAudit({ trigger_source: "autoDream", operation_type: "dream_consolidate", model_id: "m1", status: "success" });
|
|
409
|
+
service.saveLlmAudit({ trigger_source: "autoSummarize", operation_type: "summarize_compress", model_id: "m2", status: "success" });
|
|
410
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/semantic/llm-audit");
|
|
411
|
+
const res = new FakeRes();
|
|
412
|
+
await route.handler(req("/api/dsh-mneme/semantic/llm-audit?source=autoSummarize"), res);
|
|
413
|
+
const data = JSON.parse(res.body);
|
|
414
|
+
assert.equal(data.total, 1);
|
|
415
|
+
assert.equal(data.items[0].operation_type, "summarize_compress");
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test("GET /api/dsh-mneme/semantic/llm-audit/stats aggregates tokens by source and status", async () => {
|
|
419
|
+
const { routes, service } = setup();
|
|
420
|
+
service.saveLlmAudit({
|
|
421
|
+
trigger_source: "autoDream", operation_type: "dream_consolidate", model_id: "m1",
|
|
422
|
+
input_tokens: 100, output_tokens: 50, total_tokens: 150, duration_ms: 12, status: "success", related_memory_ids: []
|
|
423
|
+
});
|
|
424
|
+
service.saveLlmAudit({
|
|
425
|
+
trigger_source: "autoSummarize", operation_type: "summarize_compress", model_id: "m2",
|
|
426
|
+
input_tokens: 20, output_tokens: 10, total_tokens: 30, duration_ms: 5, status: "error", error_message: "boom", related_memory_ids: []
|
|
427
|
+
});
|
|
428
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/semantic/llm-audit/stats");
|
|
429
|
+
const res = new FakeRes();
|
|
430
|
+
await route.handler(req("/api/dsh-mneme/semantic/llm-audit/stats?days=7"), res);
|
|
431
|
+
assert.equal(res.statusCode, 200);
|
|
432
|
+
const data = JSON.parse(res.body);
|
|
433
|
+
assert.equal(data.total_calls, 2);
|
|
434
|
+
assert.equal(data.input_tokens, 120);
|
|
435
|
+
assert.equal(data.output_tokens, 60);
|
|
436
|
+
assert.equal(data.total_tokens, 180);
|
|
437
|
+
assert.equal(data.total_duration_ms, 17);
|
|
438
|
+
assert.ok(data.by_source.some((s) => s.source === "autoDream" && s.total_tokens === 150), "autoDream aggregate present");
|
|
439
|
+
assert.ok(data.by_status.some((s) => s.status === "error" && s.c === 1), "error status counted");
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
// --- issue #10: vector-reindex with an embed-only OpenAI-compatible embedder --
|
|
443
|
+
|
|
444
|
+
test("Bug10: vector-reindex with an embed-only OpenAI-compatible embedder returns the real count and records the model fingerprint", async () => {
|
|
445
|
+
const store = createStore(":memory:");
|
|
446
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
447
|
+
const settings = createSettings(store.db);
|
|
448
|
+
const vectorIndex = createVectorIndex({ store });
|
|
449
|
+
const embedder = {
|
|
450
|
+
embed: async (text) => [0.1, 0.2, 0.3], // OpenAI-compatible single-text embed
|
|
451
|
+
modelHash: "text-embedding-3#abc",
|
|
452
|
+
dimension: 3
|
|
453
|
+
};
|
|
454
|
+
// A pre-index row written before the embedder is attached (so it still has no vector).
|
|
455
|
+
service.saveWithDedupe({ type: "project", title: "待回填", content: "缺少向量的存量记忆" });
|
|
456
|
+
const routes = [];
|
|
457
|
+
const ctx = { webServer: { register(route) { routes.push(route); return () => {}; } } };
|
|
458
|
+
createApi(ctx, service, settings, { add() {}, remove() {}, list() { return []; } }, embedder, { vectorIndex }, "");
|
|
459
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/vector-reindex");
|
|
460
|
+
const res = new FakeRes();
|
|
461
|
+
await route.handler(req("/api/dsh-mneme/vector-reindex"), res);
|
|
462
|
+
assert.equal(res.statusCode, 200);
|
|
463
|
+
const data = JSON.parse(res.body);
|
|
464
|
+
assert.equal(data.indexed, 1, "actual indexed count, not 0");
|
|
465
|
+
assert.equal(data.skipped, 0);
|
|
466
|
+
assert.equal(vectorIndex.modelHash(), "text-embedding-3#abc", "model_hash written to vector_meta");
|
|
467
|
+
assert.equal(vectorIndex.dimension(), 3, "dimension written to vector_meta");
|
|
468
|
+
assert.equal(vectorIndex.getEmbedding(service.all()[0].id).length, 3, "embedding persisted");
|
|
469
|
+
});
|
package/test/dream.test.js
CHANGED
|
@@ -847,3 +847,55 @@ test("consolidation prompt pins the decision schema (action field, single-string
|
|
|
847
847
|
assert.match(systemText, /决策 JSON 示例/, "prompt includes a canonical example block");
|
|
848
848
|
store.close();
|
|
849
849
|
});
|
|
850
|
+
|
|
851
|
+
// --- Bug8: llm_audit_logs trail ----------------------------------------------
|
|
852
|
+
|
|
853
|
+
test("Bug8: runDream records llm_audit_logs rows for consolidation and summary", async () => {
|
|
854
|
+
const { store, service } = dreamSetup();
|
|
855
|
+
service.saveWithDedupe({ type: "project", title: "旧1", content: "第一段内容" });
|
|
856
|
+
service.saveWithDedupe({ type: "project", title: "旧2", content: "第二段内容" });
|
|
857
|
+
const ctx = mockCtx({
|
|
858
|
+
onConsolidation: (listText) => JSON.stringify([{ action: "keep", ids: [listText.match(/id=([^\s|]+)/)[1]] }])
|
|
859
|
+
});
|
|
860
|
+
const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
|
|
861
|
+
const result = await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
|
|
862
|
+
assert.equal(result.ok, true, "run succeeds");
|
|
863
|
+
const rows = store.listLlmAudits();
|
|
864
|
+
assert.equal(rows.length, 2, "consolidation + summary both audited");
|
|
865
|
+
assert.deepEqual(rows.map((r) => r.trigger_source), ["autoDream", "autoDream"]);
|
|
866
|
+
assert.deepEqual(rows.map((r) => r.operation_type).sort(), ["dream_consolidate", "dream_summarize"]);
|
|
867
|
+
const consolidate = rows.find((r) => r.operation_type === "dream_consolidate");
|
|
868
|
+
assert.equal(consolidate.related_memory_ids.length, 2, "consolidation audit links the snapshot ids");
|
|
869
|
+
const summarize = rows.find((r) => r.operation_type === "dream_summarize");
|
|
870
|
+
assert.deepEqual(summarize.related_memory_ids, [], "summary audit has no related ids");
|
|
871
|
+
for (const row of rows) {
|
|
872
|
+
assert.equal(row.status, "success");
|
|
873
|
+
assert.equal(row.model_id, "mock:stress-model");
|
|
874
|
+
assert.ok(Number.isInteger(row.duration_ms) && row.duration_ms >= 0, "duration recorded");
|
|
875
|
+
assert.equal(row.input_tokens, 0);
|
|
876
|
+
assert.equal(row.output_tokens, 0);
|
|
877
|
+
}
|
|
878
|
+
store.close();
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
test("Bug8: a failed LLM call is recorded with status=error and does not block the run", async () => {
|
|
882
|
+
const { store, service } = dreamSetup();
|
|
883
|
+
service.saveWithDedupe({ type: "project", title: "主题", content: "内容" });
|
|
884
|
+
const ctx = {
|
|
885
|
+
logger: { warn: () => {} },
|
|
886
|
+
llm: {
|
|
887
|
+
stream: async function* () {
|
|
888
|
+
yield { type: "finish", reason: { kind: "error" } };
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
};
|
|
892
|
+
const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
|
|
893
|
+
const result = await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
|
|
894
|
+
assert.equal(result.ok, false, "failed run reported");
|
|
895
|
+
const rows = store.listLlmAudits();
|
|
896
|
+
assert.equal(rows.length, 1, "one audit row for the failed consolidation call");
|
|
897
|
+
assert.equal(rows[0].operation_type, "dream_consolidate");
|
|
898
|
+
assert.equal(rows[0].status, "error", "LLM failure status=error");
|
|
899
|
+
assert.ok(rows[0].error_message, "error message recorded");
|
|
900
|
+
store.close();
|
|
901
|
+
});
|