@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/README.md +24 -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 +212 -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 +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 +212 -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/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/service.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { TYPE_FILE } from "./mirror.js";
|
|
3
|
+
import { evaluateMemoryQuality } from "./quality-filter.js";
|
|
3
4
|
|
|
4
5
|
const INJECT_TYPES = new Set(["preference", "project", "decision", "summary"]);
|
|
5
6
|
|
|
@@ -9,6 +10,29 @@ const INJECT_TYPES = new Set(["preference", "project", "decision", "summary"]);
|
|
|
9
10
|
// unknown statuses are unscaled (×1). Off by default, so nothing changes.
|
|
10
11
|
const EPISTEMIC_WEIGHTS = { observation: 1.0, inferred: 0.85, subjective: 0.7 };
|
|
11
12
|
|
|
13
|
+
// Bug5: content version history cap (FIFO — the newest 20 versions are kept,
|
|
14
|
+
// older ones dropped). Entries are {content, source, updated_at}; source marks
|
|
15
|
+
// how the version was superseded (auto_merge | human_override | overwrite).
|
|
16
|
+
const CONTENT_HISTORY_MAX = 20;
|
|
17
|
+
|
|
18
|
+
/** Prepend the previous content to a memory's content_history (FIFO capped). */
|
|
19
|
+
function pushContentHistory(existing, source) {
|
|
20
|
+
const history = Array.isArray(existing?.content_history) ? existing.content_history : [];
|
|
21
|
+
return [
|
|
22
|
+
{ content: existing?.content ?? "", source, updated_at: new Date().toISOString() },
|
|
23
|
+
...history
|
|
24
|
+
].slice(0, CONTENT_HISTORY_MAX);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Bug5: same-title merge appends the new content under a timestamped `---`
|
|
28
|
+
* separator instead of overwriting, so a re-noted memory never loses history.
|
|
29
|
+
* The `---` line is compatible with the mirror's readHumanEdits (which strips
|
|
30
|
+
* only the LAST structural `---` when parsing the human-editable file). */
|
|
31
|
+
function appendContent(oldContent, newContent) {
|
|
32
|
+
const ts = new Date().toISOString();
|
|
33
|
+
return `${oldContent}\n\n---\n[${ts}] ${newContent}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
12
36
|
/**
|
|
13
37
|
* Standard retrieval-quality metrics over the ordered candidate ids actually
|
|
14
38
|
* returned vs the ids the evaluator marked relevant (方案 B). Pure + total, so
|
|
@@ -69,6 +93,13 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
69
93
|
// judgment-layer audit trail (dream_runs).
|
|
70
94
|
let recallRecorder = null;
|
|
71
95
|
|
|
96
|
+
// Bug4: semantic recall cache for the injection path. The system-prompt
|
|
97
|
+
// interpolator renders context synchronously, so injectCandidates cannot
|
|
98
|
+
// fire a fresh async embed. The most recent searchMemories recall is cached
|
|
99
|
+
// here (query + ordered candidates) and reused when the injection query
|
|
100
|
+
// matches, giving semantic-first injection without breaking the sync render.
|
|
101
|
+
let lastSemanticRecall = null;
|
|
102
|
+
|
|
72
103
|
// Transaction nesting depth. Inside service.transaction the per-mutation side
|
|
73
104
|
// effects (mirror render, write notify, re-embed) are deferred so a ROLLBACK
|
|
74
105
|
// never leaves the mirror file diverged from the database; transaction()
|
|
@@ -429,6 +460,9 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
429
460
|
});
|
|
430
461
|
} catch { /* recall receipt is best effort */ }
|
|
431
462
|
}
|
|
463
|
+
// Bug4: cache the latest semantic recall so the sync injection path can
|
|
464
|
+
// reuse it when the injection query matches (no async embed available).
|
|
465
|
+
lastSemanticRecall = { query: q, items: result };
|
|
432
466
|
touchRecalled(result);
|
|
433
467
|
return result;
|
|
434
468
|
}
|
|
@@ -570,25 +604,91 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
570
604
|
}
|
|
571
605
|
}
|
|
572
606
|
|
|
607
|
+
/**
|
|
608
|
+
* Embed an arbitrary query text and return its vector (null on failure / no
|
|
609
|
+
* embedder). Used by the injector to prefetch the semantic-first recall
|
|
610
|
+
* vector for the current user message — the system-prompt render is
|
|
611
|
+
* synchronous, so the vector must be cached in advance (Bug4).
|
|
612
|
+
*/
|
|
613
|
+
async function embedQuery(query) {
|
|
614
|
+
const q = String(query ?? "").trim();
|
|
615
|
+
if (!q || !embedder) return null;
|
|
616
|
+
try {
|
|
617
|
+
const embedSingle = typeof embedder.embedSingle === "function"
|
|
618
|
+
? embedder.embedSingle.bind(embedder)
|
|
619
|
+
: embedder.embed.bind(embedder);
|
|
620
|
+
const vector = await embedSingle(q);
|
|
621
|
+
return Array.isArray(vector) && vector.length ? vector : null;
|
|
622
|
+
} catch {
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
573
627
|
/**
|
|
574
628
|
* Save a memory, merging into an existing one when title matches within the same type.
|
|
629
|
+
*
|
|
630
|
+
* Bug5: a same-title merge no longer overwrites — the new content is appended
|
|
631
|
+
* under a timestamped `---` separator (`旧内容\n\n---\n[时间戳] 新内容`) and the
|
|
632
|
+
* previous content is archived into content_history (source: auto_merge, FIFO
|
|
633
|
+
* capped at 20). importance takes the max of both (capped at 5). Callers that
|
|
634
|
+
* truly replace a row (dream summary regeneration) pass `_overwrite: true` to
|
|
635
|
+
* overwrite directly while still archiving the old version (source: overwrite).
|
|
636
|
+
* mergeHumanEdits entry points pass `_humanEdited: true` — same direct
|
|
637
|
+
* overwrite semantics, source: human_override.
|
|
638
|
+
*
|
|
639
|
+
* Bug7 (memory quality filter): when config.memoryQualityFilter.enabled, the
|
|
640
|
+
* memory is scored after dedupe, before write:
|
|
641
|
+
* score >= degradeThreshold → stored normally (score persisted)
|
|
642
|
+
* archiveThreshold <= score < 60 → persisted + ranked degraded
|
|
643
|
+
* score < archiveThreshold → archived + tagged low_quality (still
|
|
644
|
+
* explicitly searchable via includeArchived)
|
|
575
645
|
* @returns {{action: "created"|"merged", memory: object}}
|
|
576
646
|
*/
|
|
577
647
|
function saveWithDedupe(memory) {
|
|
648
|
+
// Bug7: score quality once (after dedupe lookup, before write). Failures
|
|
649
|
+
// inside the evaluator are impossible (pure function), but the write that
|
|
650
|
+
// records the score must never fail the save — wrap defensively.
|
|
651
|
+
const qf = config.memoryQualityFilter;
|
|
652
|
+
let quality = null;
|
|
653
|
+
if (qf?.enabled === true) {
|
|
654
|
+
try {
|
|
655
|
+
const recentContents = store.all().slice(0, 20).map((m) => m.content ?? "");
|
|
656
|
+
quality = evaluateMemoryQuality(memory, {
|
|
657
|
+
minContentLength: qf.minContentLength ?? 10,
|
|
658
|
+
recentContents
|
|
659
|
+
});
|
|
660
|
+
} catch { /* quality scoring is best-effort */ }
|
|
661
|
+
}
|
|
578
662
|
const existing = store
|
|
579
663
|
.list({ type: memory.type, limit: 100 })
|
|
580
664
|
.find((m) => m.title.trim() === String(memory.title).trim());
|
|
581
665
|
if (existing) {
|
|
666
|
+
const newContent = String(memory.content ?? "");
|
|
667
|
+
if (!newContent.trim()) {
|
|
668
|
+
// Nothing to merge: the row stays untouched.
|
|
669
|
+
return { action: "merged", memory: existing };
|
|
670
|
+
}
|
|
671
|
+
const direct = memory._overwrite === true || memory._humanEdited === true;
|
|
672
|
+
const content = direct
|
|
673
|
+
? newContent
|
|
674
|
+
: appendContent(existing.content, newContent);
|
|
675
|
+
const importance = Math.min(5, Math.max(existing.importance, memory.importance ?? existing.importance));
|
|
582
676
|
const merged = store.update(existing.id, {
|
|
583
|
-
content
|
|
584
|
-
importance
|
|
677
|
+
content,
|
|
678
|
+
importance,
|
|
585
679
|
tags: memory.tags ?? existing.tags,
|
|
586
|
-
title: memory.title ?? existing.title
|
|
680
|
+
title: memory.title ?? existing.title,
|
|
681
|
+
content_history: pushContentHistory(existing, direct
|
|
682
|
+
? (memory._humanEdited === true ? "human_override" : "overwrite")
|
|
683
|
+
: "auto_merge"),
|
|
684
|
+
...(quality ? { quality_score: quality.score } : {})
|
|
587
685
|
});
|
|
686
|
+
// Bug7: a degraded/archived result is applied on top of the merged row.
|
|
687
|
+
const result = applyQualityDisposition(merged, quality, qf);
|
|
588
688
|
afterSync("write");
|
|
589
689
|
notifyWrite();
|
|
590
|
-
scheduleEmbed(
|
|
591
|
-
return { action: "merged", memory:
|
|
690
|
+
scheduleEmbed(result);
|
|
691
|
+
return { action: "merged", memory: result };
|
|
592
692
|
}
|
|
593
693
|
const created = store.save({
|
|
594
694
|
type: memory.type,
|
|
@@ -596,13 +696,44 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
596
696
|
content: memory.content,
|
|
597
697
|
tags: memory.tags ?? [],
|
|
598
698
|
importance: memory.importance ?? 3,
|
|
599
|
-
source: memory.source ?? "manual"
|
|
699
|
+
source: memory.source ?? "manual",
|
|
700
|
+
...(quality ? { quality_score: quality.score } : {})
|
|
600
701
|
});
|
|
702
|
+
const result = applyQualityDisposition(created, quality, qf);
|
|
601
703
|
afterSync("write");
|
|
602
704
|
notifyWrite();
|
|
603
|
-
scheduleEmbed(
|
|
604
|
-
scheduleEntityExtraction(
|
|
605
|
-
return { action: "created", memory:
|
|
705
|
+
scheduleEmbed(result);
|
|
706
|
+
scheduleEntityExtraction(result);
|
|
707
|
+
return { action: "created", memory: result };
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Bug7: apply the quality verdict to a freshly written row. Below the archive
|
|
712
|
+
* threshold the memory is archived + tagged low_quality (still searchable
|
|
713
|
+
* explicitly via includeArchived); between archive and degrade thresholds the
|
|
714
|
+
* score is already persisted and only the injection ranking is affected
|
|
715
|
+
* (importance × score/100). Best-effort: a disposition write failure must
|
|
716
|
+
* never fail the save. Returns the (possibly refreshed) memory row so callers
|
|
717
|
+
* see the archived/tagged state, not the pre-disposition snapshot.
|
|
718
|
+
*/
|
|
719
|
+
function applyQualityDisposition(memory, quality, qf) {
|
|
720
|
+
if (!quality || qf?.enabled !== true) return memory;
|
|
721
|
+
const archiveThreshold = qf.archiveThreshold ?? 30;
|
|
722
|
+
// Signal tags (meta / repetitive / duplicate / short_content / low_quality)
|
|
723
|
+
// are merged onto the stored row in every assessed band so the verdict is
|
|
724
|
+
// observable, not just the numeric score. Below the archive threshold the
|
|
725
|
+
// memory is additionally archived (still explicitly searchable).
|
|
726
|
+
const tags = [...new Set([...(memory.tags ?? []), ...(quality.tags ?? [])])];
|
|
727
|
+
if (tags.length === (memory.tags?.length ?? 0) && quality.score >= archiveThreshold) {
|
|
728
|
+
return memory; // no tag drift and not archived → nothing extra to write
|
|
729
|
+
}
|
|
730
|
+
try {
|
|
731
|
+
store.update(memory.id, { tags, quality_score: quality.score });
|
|
732
|
+
if (quality.score < archiveThreshold) store.setArchived(memory.id, true);
|
|
733
|
+
return store.getById(memory.id);
|
|
734
|
+
} catch {
|
|
735
|
+
return memory;
|
|
736
|
+
}
|
|
606
737
|
}
|
|
607
738
|
|
|
608
739
|
/**
|
|
@@ -611,17 +742,71 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
611
742
|
* importance >= threshold. History is never auto-injected. Archived entries
|
|
612
743
|
* are excluded (store.list already filters them by default; the extra
|
|
613
744
|
* !m.archived check is kept as double insurance).
|
|
745
|
+
*
|
|
746
|
+
* Bug4 (hybridInject): when a non-empty `query` is available and a matching
|
|
747
|
+
* semantic recall was cached by the last searchMemories, the vector hits
|
|
748
|
+
* lead the selection (up to maxItems*2 candidates) and the rule-based pick
|
|
749
|
+
* fills + dedupes the remaining slots. Empty query / no cached recall /
|
|
750
|
+
* hybridInject off → pure legacy rule-based selection.
|
|
614
751
|
*/
|
|
615
|
-
function injectCandidates({ maxItems = 5, threshold = 3 } = {}) {
|
|
752
|
+
function injectCandidates({ query = "", maxItems = 5, threshold = 3, queryVector } = {}) {
|
|
753
|
+
const q = String(query ?? "").trim();
|
|
754
|
+
// Bug7: quality-weighted importance in the rule-based tier. Unassessed rows
|
|
755
|
+
// (quality_score null) count as 100 (weight 1), so legacy stores keep their
|
|
756
|
+
// exact summary>preference>importance ordering.
|
|
757
|
+
const qualityWeight = (m) => (m.quality_score != null ? m.quality_score / 100 : 1);
|
|
616
758
|
const items = store.list({ limit: 200, includeForgotten: false })
|
|
617
759
|
.filter((m) => !m.archived && INJECT_TYPES.has(m.type) && !m.forgotten &&
|
|
618
760
|
(m.type === "summary" || m.type === "preference" || m.importance >= threshold))
|
|
619
761
|
.sort((a, b) => {
|
|
620
762
|
const pa = a.type === "summary" ? 0 : a.type === "preference" ? 1 : 2;
|
|
621
763
|
const pb = b.type === "summary" ? 0 : b.type === "preference" ? 1 : 2;
|
|
622
|
-
return pa - pb || b.importance - a.importance;
|
|
764
|
+
return pa - pb || (b.importance * qualityWeight(b)) - (a.importance * qualityWeight(a));
|
|
623
765
|
});
|
|
624
|
-
|
|
766
|
+
let candidates = items;
|
|
767
|
+
if (config.hybridInject !== false && q) {
|
|
768
|
+
// Bug4: semantic-first recall. Vector hits (queryVector, cached by the
|
|
769
|
+
// injector's async prefetch) lead when present; otherwise the last
|
|
770
|
+
// searchMemories recall for the exact same query is reused. Rule-based
|
|
771
|
+
// items fill + dedupe the remaining slots. Empty query / no vector /
|
|
772
|
+
// no cached recall → pure legacy rule-based selection.
|
|
773
|
+
const semanticItems = [];
|
|
774
|
+
if (Array.isArray(queryVector) && queryVector.length && vectorIndex) {
|
|
775
|
+
try {
|
|
776
|
+
const hits = vectorIndex.search(queryVector, { limit: maxItems * 2, threshold: 0 });
|
|
777
|
+
for (const m of hits) {
|
|
778
|
+
if (m && !m.archived && INJECT_TYPES.has(m.type) && !m.forgotten &&
|
|
779
|
+
(m.type === "summary" || m.type === "preference" || m.importance >= threshold)) {
|
|
780
|
+
semanticItems.push(m);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
} catch { /* vector unavailable: fall through to the recall cache */ }
|
|
784
|
+
}
|
|
785
|
+
if (!semanticItems.length && lastSemanticRecall?.query === q && lastSemanticRecall.items?.length) {
|
|
786
|
+
for (const m of lastSemanticRecall.items) {
|
|
787
|
+
if (m && !m.archived && INJECT_TYPES.has(m.type) && !m.forgotten) semanticItems.push(m);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
if (semanticItems.length) {
|
|
791
|
+
const seen = new Set();
|
|
792
|
+
const merged = [];
|
|
793
|
+
const push = (m) => {
|
|
794
|
+
if (seen.has(m.id)) return;
|
|
795
|
+
seen.add(m.id);
|
|
796
|
+
merged.push(m);
|
|
797
|
+
};
|
|
798
|
+
for (const m of semanticItems) {
|
|
799
|
+
push(m);
|
|
800
|
+
if (merged.length >= maxItems * 2) break;
|
|
801
|
+
}
|
|
802
|
+
for (const m of items) {
|
|
803
|
+
if (merged.length >= maxItems * 2) break;
|
|
804
|
+
push(m);
|
|
805
|
+
}
|
|
806
|
+
candidates = merged;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
const selected = candidates.slice(0, maxItems);
|
|
625
810
|
touchRecalled(selected);
|
|
626
811
|
return selected;
|
|
627
812
|
}
|
|
@@ -654,7 +839,15 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
654
839
|
// 人工编辑回灌后触发 re-embed(issue #3 残留修复):向量必须与
|
|
655
840
|
// 新 title/content 一致。scheduleEmbed 为 fire-and-forget,
|
|
656
841
|
// 内部 try/catch 吞错,失败不影响主流程。
|
|
657
|
-
|
|
842
|
+
// Bug5: human edits overwrite directly, but the machine version is
|
|
843
|
+
// archived into content_history (source: human_override) before being
|
|
844
|
+
// replaced, so a manual correction never silently destroys the old value.
|
|
845
|
+
const merged = store.update(edit.id, {
|
|
846
|
+
...patch,
|
|
847
|
+
content_history: patch.content !== undefined && existing.content !== patch.content
|
|
848
|
+
? pushContentHistory(existing, "human_override")
|
|
849
|
+
: existing.content_history
|
|
850
|
+
});
|
|
658
851
|
applied++;
|
|
659
852
|
scheduleEmbed(merged);
|
|
660
853
|
}
|
|
@@ -999,6 +1192,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
999
1192
|
setReranker(rn) { reranker = rn; },
|
|
1000
1193
|
setRecallRecorder(fn) { recallRecorder = fn; },
|
|
1001
1194
|
searchMemories,
|
|
1195
|
+
embedQuery,
|
|
1002
1196
|
evaluateRetrieval,
|
|
1003
1197
|
computeRetrievalMetrics,
|
|
1004
1198
|
// passthroughs used by tools and api layers; mutations keep the mirror in sync
|
|
@@ -1139,6 +1333,13 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
1139
1333
|
saveRecallEval: (r) => store.saveRecallEval(r),
|
|
1140
1334
|
getRecallEval: (id) => store.getRecallEval(id),
|
|
1141
1335
|
listRecallEvals: (opts) => store.listRecallEvals(opts),
|
|
1336
|
+
// LLM audit trail (Bug8): bookkeeping semantics like the recall/dream
|
|
1337
|
+
// passthroughs — a saveLlmAudit write never triggers write hooks.
|
|
1338
|
+
saveLlmAudit: (entry) => store.saveLlmAudit(entry),
|
|
1339
|
+
listLlmAudits: (opts) => store.listLlmAudits(opts),
|
|
1340
|
+
countLlmAudits: (opts) => store.countLlmAudits(opts),
|
|
1341
|
+
getLlmAuditStats: (opts) => store.getLlmAuditStats(opts),
|
|
1342
|
+
deleteOldLlmAudits: (before) => store.deleteOldLlmAudits(before),
|
|
1142
1343
|
// Entity gene (v0.3.0) passthroughs for the autoDream apply path
|
|
1143
1344
|
// (applyDecisions): records supersedes relations after an update and
|
|
1144
1345
|
// migrates entity_attrs on merge. Bookkeeping writes like the audit
|
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 {
|
|
@@ -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(
|
|
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,
|