@modusensus/dsh-mneme 0.2.7 → 0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/config.js +4 -0
- package/lib/dream/decisions.js +3 -3
- package/lib/dream.js +89 -0
- package/lib/index.js +17 -0
- package/lib/service.js +47 -9
- package/lib/store.js +236 -6
- package/package.json +1 -1
- package/src/config.js +4 -0
- package/src/dream/decisions.js +3 -3
- package/src/dream.js +89 -0
- package/src/index.js +17 -0
- package/src/service.js +47 -9
- package/src/store.js +236 -6
- package/test/audit.test.js +92 -0
- package/test/policy-epoch.test.js +259 -0
- package/test/recall-layer.test.js +314 -0
- package/test/receipt-chain.test.js +451 -0
package/src/dream.js
CHANGED
|
@@ -100,6 +100,78 @@ export function buildOutcome(decisions) {
|
|
|
100
100
|
return { byId };
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Content-addressed digest of the memories a verdict was decided against
|
|
105
|
+
* (id + title + content + importance), sorted by id so identical inputs always
|
|
106
|
+
* hash the same. This is the per-record "判定依据" fingerprint: a receipt whose
|
|
107
|
+
* digest cannot be reproduced from the involved memories is a bare claim, and a
|
|
108
|
+
* digest match with a divergent outcome pinpoints drift to the exact record.
|
|
109
|
+
*/
|
|
110
|
+
export function hashDecisionInput(memories) {
|
|
111
|
+
const canon = (memories ?? [])
|
|
112
|
+
.map((m) => [m.id, m.title, m.content, m.importance])
|
|
113
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
|
114
|
+
.map((p) => p.map((x) => String(x ?? "")).join(""))
|
|
115
|
+
.join("");
|
|
116
|
+
return createHash("sha256").update(canon).digest("hex");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Build the per-record receipts for a run's actually-committed mutable verdicts
|
|
121
|
+
* (merge/conflict/update) — one row per verdict in the receipt_chain. Inputs
|
|
122
|
+
* are drawn from the run snapshot (what the LLM actually arbitrated against),
|
|
123
|
+
* and the idempotency counters count_before → count_after come from the
|
|
124
|
+
* committed sub-step, so replaying the same decision must reproduce the same
|
|
125
|
+
* numbers. verdict starts "live"; a later policy_epoch upgrade will batch-mark
|
|
126
|
+
* older verdicts "historical" (a receipt_chain rewrite driven by the store's
|
|
127
|
+
* getLatestPolicyEpoch — out of scope for this pass), while "revoked" is
|
|
128
|
+
* reserved for verdicts later overturned by an explicit human decision.
|
|
129
|
+
*/
|
|
130
|
+
function buildRecordReceipts({ runId, committed, snapshot, policyEpoch }) {
|
|
131
|
+
const at = (id) => snapshot?.get?.(id);
|
|
132
|
+
const receipts = [];
|
|
133
|
+
for (const c of committed ?? []) {
|
|
134
|
+
const base = {
|
|
135
|
+
run_id: runId,
|
|
136
|
+
verdict: "live",
|
|
137
|
+
count_before: c.count_before,
|
|
138
|
+
count_after: c.count_after,
|
|
139
|
+
policy_epoch: policyEpoch,
|
|
140
|
+
created_at: new Date().toISOString()
|
|
141
|
+
};
|
|
142
|
+
if (c.action === "merge") {
|
|
143
|
+
receipts.push({
|
|
144
|
+
...base,
|
|
145
|
+
receipt_id: randomUUID(),
|
|
146
|
+
record_id: c.keepSource,
|
|
147
|
+
kind: "merge",
|
|
148
|
+
input_digest: hashDecisionInput((c.ids ?? []).map(at).filter(Boolean)),
|
|
149
|
+
keep_source: c.keepSource,
|
|
150
|
+
sources: c.ids
|
|
151
|
+
});
|
|
152
|
+
} else if (c.action === "conflict") {
|
|
153
|
+
receipts.push({
|
|
154
|
+
...base,
|
|
155
|
+
receipt_id: randomUUID(),
|
|
156
|
+
record_id: c.winner,
|
|
157
|
+
kind: "conflict",
|
|
158
|
+
input_digest: hashDecisionInput([at(c.winner), at(c.loser)].filter(Boolean)),
|
|
159
|
+
winner_id: c.winner,
|
|
160
|
+
loser_id: c.loser
|
|
161
|
+
});
|
|
162
|
+
} else if (c.action === "update") {
|
|
163
|
+
receipts.push({
|
|
164
|
+
...base,
|
|
165
|
+
receipt_id: randomUUID(),
|
|
166
|
+
record_id: c.ids[0],
|
|
167
|
+
kind: "update",
|
|
168
|
+
input_digest: hashDecisionInput([at(c.ids[0])].filter(Boolean))
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return receipts;
|
|
173
|
+
}
|
|
174
|
+
|
|
103
175
|
/**
|
|
104
176
|
* Consume an LLM stream and return the accumulated text. Direct text-delta
|
|
105
177
|
* accumulation covers both the real protocol ({type:"text-delta", index, text})
|
|
@@ -304,6 +376,10 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
304
376
|
model: route?.model,
|
|
305
377
|
snapshot_hash: snapshotHash,
|
|
306
378
|
input_count: snapshot.size,
|
|
379
|
+
// 裁决规则版本号:config.policyEpoch(默认 0)。规则升级后该行保留
|
|
380
|
+
// 当时的 epoch,旧裁决据此降级为历史证据(store 层 getLatestPolicyEpoch
|
|
381
|
+
// 只负责读取当前生效版本,写入由这里完成)。
|
|
382
|
+
policy_epoch: config.policyEpoch ?? 0,
|
|
307
383
|
// Full input snapshot (canonical fields) so the exact arbitration
|
|
308
384
|
// input can be rebuilt offline from the audit row alone — the
|
|
309
385
|
// digest + decisions + outcome triple makes silent errors locatable
|
|
@@ -420,6 +496,19 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
420
496
|
// a target changed during the LLM call is skipped and reported as a
|
|
421
497
|
// conflict instead of being overwritten (item ①).
|
|
422
498
|
const { applied, conflicts, failures, committed } = applyDecisions(decisions, service, logger, snapshot);
|
|
499
|
+
// Per-record receipt chain: one row per actually-committed merge/conflict/
|
|
500
|
+
// update verdict, stamped with the decision-basis digest + idempotency
|
|
501
|
+
// counters (count_before → count_after). Written here, before the run audit
|
|
502
|
+
// row, so the verdict trail always precedes the run trail it belongs to.
|
|
503
|
+
// Bookkeeping: a write failure is logged and swallowed — it must never
|
|
504
|
+
// block the consolidation flow.
|
|
505
|
+
try {
|
|
506
|
+
for (const r of buildRecordReceipts({ runId, committed, snapshot, policyEpoch: config.policyEpoch ?? 0 })) {
|
|
507
|
+
service.saveReceipt(r);
|
|
508
|
+
}
|
|
509
|
+
} catch (error) {
|
|
510
|
+
logger?.warn?.(`dsh-mneme dream: failed to write per-record receipt: ${String(error)}`);
|
|
511
|
+
}
|
|
423
512
|
// Attach the pre-update snapshot to the audit copy of each update decision
|
|
424
513
|
// so the recorded row shows the before/after delta, not just the target.
|
|
425
514
|
const auditDecisions = decisions.map((d) =>
|
package/src/index.js
CHANGED
|
@@ -44,6 +44,23 @@ export const apply = (ctx, config) => {
|
|
|
44
44
|
const mirror = createMirror(memoryDir);
|
|
45
45
|
const service = createService({ store, mirror, config: cfg });
|
|
46
46
|
|
|
47
|
+
// Recall-layer receipt: when searchMemories runs with recordRecall=true, the
|
|
48
|
+
// retrieval scene (query/mode/topK/threshold + candidates) is persisted to
|
|
49
|
+
// recall_runs for audit/replay — the sibling of the dream_runs judgment trail.
|
|
50
|
+
// Best-effort: a failed recall write must never break the search.
|
|
51
|
+
service.setRecallRecorder((recall) => {
|
|
52
|
+
try {
|
|
53
|
+
store.saveRecallRun({
|
|
54
|
+
query: recall.query,
|
|
55
|
+
mode: recall.mode,
|
|
56
|
+
topK: recall.topK,
|
|
57
|
+
threshold: recall.threshold ?? null,
|
|
58
|
+
candidates: recall.candidates ?? [],
|
|
59
|
+
created_at: recall.createdAt
|
|
60
|
+
});
|
|
61
|
+
} catch { /* non-fatal: recall recording is bookkeeping */ }
|
|
62
|
+
});
|
|
63
|
+
|
|
47
64
|
// User-configurable settings (profile, rules) and custom commands share the
|
|
48
65
|
// same SQLite file but live in dedicated tables, isolated from memories.
|
|
49
66
|
const settings = createSettings(store.db);
|
package/src/service.js
CHANGED
|
@@ -16,6 +16,13 @@ export function createService({ store, mirror, config, onWrite }) {
|
|
|
16
16
|
let vectorIndex = null;
|
|
17
17
|
let reranker = null;
|
|
18
18
|
|
|
19
|
+
// Optional recall recorder, installed via setRecallRecorder after creation.
|
|
20
|
+
// When searchMemories is called with recordRecall=true it receives the
|
|
21
|
+
// actual merged recall scene (candidates + scores + source + threshold) so
|
|
22
|
+
// the retrieval layer can be audited/replayed — the sibling of the dream
|
|
23
|
+
// judgment-layer audit trail (dream_runs).
|
|
24
|
+
let recallRecorder = null;
|
|
25
|
+
|
|
19
26
|
// Transaction nesting depth. Inside service.transaction the per-mutation side
|
|
20
27
|
// effects (mirror render, write notify, re-embed) are deferred so a ROLLBACK
|
|
21
28
|
// never leaves the mirror file diverged from the database; transaction()
|
|
@@ -43,7 +50,7 @@ export function createService({ store, mirror, config, onWrite }) {
|
|
|
43
50
|
const out = [];
|
|
44
51
|
for (const s of scored) {
|
|
45
52
|
const c = byId.get(s.id);
|
|
46
|
-
if (c) { out.push({ ...c, score: s.score }); if (out.length >= topK) break; }
|
|
53
|
+
if (c) { out.push({ ...c, score: s.score, source: "rerank" }); if (out.length >= topK) break; }
|
|
47
54
|
}
|
|
48
55
|
return out.length ? out : candidates.slice(0, topK);
|
|
49
56
|
} catch {
|
|
@@ -78,15 +85,16 @@ export function createService({ store, mirror, config, onWrite }) {
|
|
|
78
85
|
return base * (0.5 + (row.importance ?? 3) / 10);
|
|
79
86
|
}
|
|
80
87
|
|
|
81
|
-
async function searchMemories(query, { mode = "auto", topK = 20, threshold, useRerank = true } = {}) {
|
|
88
|
+
async function searchMemories(query, { mode = "auto", topK = 20, threshold, useRerank = true, recordRecall = false } = {}) {
|
|
82
89
|
const q = String(query ?? "").trim();
|
|
83
90
|
if (!q) return [];
|
|
84
91
|
const lim = topK > 0 ? topK : 20;
|
|
85
92
|
|
|
86
93
|
// Keyword results, decorated with a score so they can be weight-blended
|
|
87
|
-
// with vector results and reported uniformly.
|
|
94
|
+
// with vector results and reported uniformly. source tracks where each
|
|
95
|
+
// candidate came from for the recall layer receipt.
|
|
88
96
|
const rawKeyword = store.search(q, { limit: lim });
|
|
89
|
-
const keyword = rawKeyword.map((m) => ({ ...m, score: scoreKeyword(m, q) }));
|
|
97
|
+
const keyword = rawKeyword.map((m) => ({ ...m, score: scoreKeyword(m, q), source: "keyword" }));
|
|
90
98
|
const wantVector = mode === "vector" || mode === "hybrid" || (mode === "auto" && !!embedder);
|
|
91
99
|
let vector = [];
|
|
92
100
|
if (wantVector && embedder) {
|
|
@@ -100,7 +108,7 @@ export function createService({ store, mirror, config, onWrite }) {
|
|
|
100
108
|
const hits = vectorIndex
|
|
101
109
|
? vectorIndex.search(qv, { limit: lim * 2, threshold: threshold ?? 0 })
|
|
102
110
|
: store.searchVector(qv, { limit: lim * 2, threshold: threshold ?? 0 });
|
|
103
|
-
vector = hits.map((m) => ({ ...m, vector: true }));
|
|
111
|
+
vector = hits.map((m) => ({ ...m, vector: true, source: "vector" }));
|
|
104
112
|
}
|
|
105
113
|
} catch { /* vector unavailable: keep keyword results */ }
|
|
106
114
|
}
|
|
@@ -147,10 +155,34 @@ export function createService({ store, mirror, config, onWrite }) {
|
|
|
147
155
|
}
|
|
148
156
|
|
|
149
157
|
merged = merged.slice(0, lim);
|
|
150
|
-
|
|
151
|
-
|
|
158
|
+
const result = useRerank && reranker && merged.length
|
|
159
|
+
? await rerankCandidates(q, merged, lim)
|
|
160
|
+
: merged;
|
|
161
|
+
|
|
162
|
+
// Recall layer receipt: with recordRecall on, hand the actual merged
|
|
163
|
+
// candidate list (id/title/content/score/source) to the injected recorder
|
|
164
|
+
// before returning, making the retrieval scene replayable — the sibling of
|
|
165
|
+
// the dream judgment-layer audit trail. Recorder failures must never break
|
|
166
|
+
// the search itself.
|
|
167
|
+
if (recordRecall && recallRecorder) {
|
|
168
|
+
try {
|
|
169
|
+
recallRecorder({
|
|
170
|
+
query: q,
|
|
171
|
+
mode,
|
|
172
|
+
topK: lim,
|
|
173
|
+
threshold: threshold ?? null,
|
|
174
|
+
candidates: result.map((m) => ({
|
|
175
|
+
id: m.id,
|
|
176
|
+
title: m.title,
|
|
177
|
+
content: m.content,
|
|
178
|
+
score: m.score ?? null,
|
|
179
|
+
source: m.source ?? "keyword"
|
|
180
|
+
})),
|
|
181
|
+
createdAt: new Date().toISOString()
|
|
182
|
+
});
|
|
183
|
+
} catch { /* recall receipt is best effort */ }
|
|
152
184
|
}
|
|
153
|
-
return
|
|
185
|
+
return result;
|
|
154
186
|
}
|
|
155
187
|
|
|
156
188
|
/**
|
|
@@ -355,6 +387,7 @@ export function createService({ store, mirror, config, onWrite }) {
|
|
|
355
387
|
setEmbedder(emb) { embedder = emb; },
|
|
356
388
|
setVectorIndex(vi) { vectorIndex = vi; },
|
|
357
389
|
setReranker(rn) { reranker = rn; },
|
|
390
|
+
setRecallRecorder(fn) { recallRecorder = fn; },
|
|
358
391
|
searchMemories,
|
|
359
392
|
// passthroughs used by tools and api layers; mutations keep the mirror in sync
|
|
360
393
|
search: (q, o) => store.search(q, o),
|
|
@@ -441,6 +474,11 @@ export function createService({ store, mirror, config, onWrite }) {
|
|
|
441
474
|
// dream scheduler that just recorded the run.
|
|
442
475
|
saveDreamRun: (run) => store.saveDreamRun(run),
|
|
443
476
|
getDreamRun: (id) => store.getDreamRun(id),
|
|
444
|
-
listDreamRuns: (opts) => store.listDreamRuns(opts)
|
|
477
|
+
listDreamRuns: (opts) => store.listDreamRuns(opts),
|
|
478
|
+
// Per-record receipt chain (same bookkeeping semantics as saveDreamRun: an
|
|
479
|
+
// audit write, never a write-hook-triggering memory mutation).
|
|
480
|
+
saveReceipt: (r) => store.saveReceipt(r),
|
|
481
|
+
getReceipt: (id) => store.getReceipt(id),
|
|
482
|
+
listReceipts: (opts) => store.listReceipts(opts)
|
|
445
483
|
};
|
|
446
484
|
}
|
package/src/store.js
CHANGED
|
@@ -37,10 +37,27 @@ CREATE TABLE IF NOT EXISTS dream_runs (
|
|
|
37
37
|
outcome TEXT, -- JSON: { byId: {id: action} }
|
|
38
38
|
applied INTEGER NOT NULL DEFAULT 0,
|
|
39
39
|
summary_stored INTEGER NOT NULL DEFAULT 0,
|
|
40
|
-
receipt TEXT NOT NULL
|
|
40
|
+
receipt TEXT NOT NULL,
|
|
41
|
+
policy_epoch INTEGER NOT NULL DEFAULT 0 -- 裁决规则版本:规则升级后旧裁决降级为历史证据
|
|
41
42
|
);
|
|
42
43
|
CREATE INDEX IF NOT EXISTS idx_dream_runs_created ON dream_runs(created_at);
|
|
43
44
|
|
|
45
|
+
-- recall_runs: recall-layer receipt. One row per retrieval scene — the query,
|
|
46
|
+
-- mode, top-k, threshold and the exact candidate list (id/title/content/score/
|
|
47
|
+
-- source) that was returned — so retrieval behavior can be audited and
|
|
48
|
+
-- replayed after the fact. Sibling of the dream judgment-layer audit trail.
|
|
49
|
+
CREATE TABLE IF NOT EXISTS recall_runs (
|
|
50
|
+
id TEXT PRIMARY KEY,
|
|
51
|
+
query TEXT NOT NULL,
|
|
52
|
+
mode TEXT NOT NULL,
|
|
53
|
+
top_k INTEGER,
|
|
54
|
+
threshold REAL,
|
|
55
|
+
candidates TEXT NOT NULL, -- JSON: 召回候选数组(含 id/title/content/score/source)
|
|
56
|
+
created_at TEXT NOT NULL
|
|
57
|
+
);
|
|
58
|
+
CREATE INDEX IF NOT EXISTS idx_recall_runs_created ON recall_runs(created_at);
|
|
59
|
+
CREATE INDEX IF NOT EXISTS idx_recall_runs_query ON recall_runs(query);
|
|
60
|
+
|
|
44
61
|
-- failure_memories: records user corrections / reflection failures. Captures
|
|
45
62
|
-- what a memory was (actual) vs what the user changed it to (expected)
|
|
46
63
|
-- so later reflection passes can mine recurring correction patterns.
|
|
@@ -58,6 +75,31 @@ CREATE TABLE IF NOT EXISTS failure_memories (
|
|
|
58
75
|
);
|
|
59
76
|
CREATE INDEX IF NOT EXISTS idx_failure_memories_created ON failure_memories(created_at);
|
|
60
77
|
CREATE INDEX IF NOT EXISTS idx_failure_memories_type ON failure_memories(failure_type);
|
|
78
|
+
|
|
79
|
+
-- receipt_chain: per-record receipt chain. One row per mutable verdict
|
|
80
|
+
-- (merge/conflict/update), carrying the input digest (the basis of the
|
|
81
|
+
-- decision, content-addressed) and the idempotency check counters
|
|
82
|
+
-- count_before → count_after. Replaying the same decision must reproduce the
|
|
83
|
+
-- same result; a digest match with a divergent outcome pinpoints drift to the
|
|
84
|
+
-- specific record/run. Sibling of the run-level dream audit trail.
|
|
85
|
+
CREATE TABLE IF NOT EXISTS receipt_chain (
|
|
86
|
+
receipt_id TEXT PRIMARY KEY,
|
|
87
|
+
run_id TEXT NOT NULL,
|
|
88
|
+
record_id TEXT NOT NULL,
|
|
89
|
+
kind TEXT NOT NULL, -- merge | conflict | update
|
|
90
|
+
input_digest TEXT NOT NULL,
|
|
91
|
+
winner_id TEXT,
|
|
92
|
+
loser_id TEXT,
|
|
93
|
+
keep_source TEXT,
|
|
94
|
+
sources TEXT, -- JSON: merge 全部参与 id 数组
|
|
95
|
+
verdict TEXT NOT NULL, -- live | revoked | historical
|
|
96
|
+
count_before INTEGER NOT NULL,
|
|
97
|
+
count_after INTEGER NOT NULL,
|
|
98
|
+
policy_epoch INTEGER NOT NULL DEFAULT 0,
|
|
99
|
+
created_at TEXT NOT NULL
|
|
100
|
+
);
|
|
101
|
+
CREATE INDEX IF NOT EXISTS idx_receipt_chain_record ON receipt_chain(record_id);
|
|
102
|
+
CREATE INDEX IF NOT EXISTS idx_receipt_chain_run ON receipt_chain(run_id);
|
|
61
103
|
`;
|
|
62
104
|
|
|
63
105
|
const TYPES = new Set(["preference", "project", "decision", "history", "summary"]);
|
|
@@ -116,10 +158,53 @@ function toDreamRun(row) {
|
|
|
116
158
|
outcome: row.outcome ? JSON.parse(row.outcome) : undefined,
|
|
117
159
|
applied: row.applied,
|
|
118
160
|
summary_stored: row.summary_stored === 1,
|
|
119
|
-
receipt: row.receipt
|
|
161
|
+
receipt: row.receipt,
|
|
162
|
+
policy_epoch: row.policy_epoch ?? 0
|
|
120
163
|
};
|
|
121
164
|
}
|
|
122
165
|
|
|
166
|
+
function toReceipt(row) {
|
|
167
|
+
if (!row) return undefined;
|
|
168
|
+
return {
|
|
169
|
+
receipt_id: row.receipt_id,
|
|
170
|
+
run_id: row.run_id,
|
|
171
|
+
record_id: row.record_id,
|
|
172
|
+
kind: row.kind,
|
|
173
|
+
input_digest: row.input_digest,
|
|
174
|
+
winner_id: row.winner_id ?? undefined,
|
|
175
|
+
loser_id: row.loser_id ?? undefined,
|
|
176
|
+
keep_source: row.keep_source ?? undefined,
|
|
177
|
+
sources: parseJsonArray(row.sources),
|
|
178
|
+
verdict: row.verdict,
|
|
179
|
+
count_before: row.count_before,
|
|
180
|
+
count_after: row.count_after,
|
|
181
|
+
policy_epoch: row.policy_epoch ?? 0,
|
|
182
|
+
created_at: row.created_at
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function toRecallRun(row) {
|
|
187
|
+
if (!row) return undefined;
|
|
188
|
+
return {
|
|
189
|
+
id: row.id,
|
|
190
|
+
query: row.query,
|
|
191
|
+
mode: row.mode,
|
|
192
|
+
topK: row.top_k,
|
|
193
|
+
threshold: row.threshold,
|
|
194
|
+
candidates: parseJsonArray(row.candidates),
|
|
195
|
+
created_at: row.created_at
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function parseJsonArray(raw) {
|
|
200
|
+
try {
|
|
201
|
+
const arr = JSON.parse(raw);
|
|
202
|
+
return Array.isArray(arr) ? arr : [];
|
|
203
|
+
} catch {
|
|
204
|
+
return [];
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
123
208
|
export function createStore(path) {
|
|
124
209
|
const db = new DatabaseSync(path);
|
|
125
210
|
db.exec("PRAGMA journal_mode = WAL;");
|
|
@@ -134,6 +219,12 @@ export function createStore(path) {
|
|
|
134
219
|
db.exec("ALTER TABLE memories ADD COLUMN embedding TEXT");
|
|
135
220
|
}
|
|
136
221
|
|
|
222
|
+
// Legacy dream_runs without policy_epoch → backfill with the default epoch.
|
|
223
|
+
const dreamCols = db.prepare("PRAGMA table_info(dream_runs)").all().map((c) => c.name);
|
|
224
|
+
if (!dreamCols.includes("policy_epoch")) {
|
|
225
|
+
db.exec("ALTER TABLE dream_runs ADD COLUMN policy_epoch INTEGER NOT NULL DEFAULT 0");
|
|
226
|
+
}
|
|
227
|
+
|
|
137
228
|
// Per-instance monotonic timestamp guard: consecutive writes within the same
|
|
138
229
|
// millisecond must still produce strictly increasing timestamps (test asserts
|
|
139
230
|
// updated_at != created_at). State lives in the store closure, not module scope.
|
|
@@ -398,16 +489,17 @@ export function createStore(path) {
|
|
|
398
489
|
function saveDreamRun(run) {
|
|
399
490
|
const id = run.id ?? randomUUID();
|
|
400
491
|
const now = nowIso();
|
|
492
|
+
const policyEpoch = Number.isInteger(run.policy_epoch) ? run.policy_epoch : 0;
|
|
401
493
|
db.prepare(
|
|
402
494
|
`INSERT INTO dream_runs (id, created_at, status, error, provider, model, snapshot_hash,
|
|
403
|
-
input_count, input, decisions, outcome, applied, summary_stored, receipt)
|
|
404
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
495
|
+
input_count, input, decisions, outcome, applied, summary_stored, receipt, policy_epoch)
|
|
496
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
405
497
|
ON CONFLICT(id) DO UPDATE SET
|
|
406
498
|
created_at=excluded.created_at, status=excluded.status, error=excluded.error,
|
|
407
499
|
provider=excluded.provider, model=excluded.model, snapshot_hash=excluded.snapshot_hash,
|
|
408
500
|
input_count=excluded.input_count, input=excluded.input, decisions=excluded.decisions,
|
|
409
501
|
outcome=excluded.outcome, applied=excluded.applied, summary_stored=excluded.summary_stored,
|
|
410
|
-
receipt=excluded.receipt`
|
|
502
|
+
receipt=excluded.receipt, policy_epoch=excluded.policy_epoch`
|
|
411
503
|
).run(
|
|
412
504
|
id,
|
|
413
505
|
run.created_at ?? now,
|
|
@@ -422,7 +514,8 @@ export function createStore(path) {
|
|
|
422
514
|
run.outcome !== undefined ? JSON.stringify(run.outcome) : null,
|
|
423
515
|
run.applied ?? 0,
|
|
424
516
|
run.summary_stored ? 1 : 0,
|
|
425
|
-
run.receipt
|
|
517
|
+
run.receipt,
|
|
518
|
+
policyEpoch
|
|
426
519
|
);
|
|
427
520
|
return getDreamRun(id);
|
|
428
521
|
}
|
|
@@ -440,6 +533,136 @@ export function createStore(path) {
|
|
|
440
533
|
return rows.map(toDreamRun);
|
|
441
534
|
}
|
|
442
535
|
|
|
536
|
+
/**
|
|
537
|
+
* Latest ruling-rule version seen on the audit trail. policy_epoch is a config
|
|
538
|
+
* value stamped onto each run by the caller; reading the newest row's epoch
|
|
539
|
+
* gives the current effective version, falling back to 0 (default) when the
|
|
540
|
+
* trail is empty. Rules upgrades leave older runs with their original epoch,
|
|
541
|
+
* so those decisions can be demoted to historical evidence.
|
|
542
|
+
*/
|
|
543
|
+
function getLatestPolicyEpoch() {
|
|
544
|
+
const row = db.prepare(
|
|
545
|
+
"SELECT policy_epoch FROM dream_runs ORDER BY created_at DESC, id LIMIT 1"
|
|
546
|
+
).get();
|
|
547
|
+
return row ? (row.policy_epoch ?? 0) : 0;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// --- per-record receipt chain --------------------------------------------
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Persist one per-record receipt (a single merge/conflict/update verdict).
|
|
554
|
+
* The run-level dream audit trail answers "did this run happen and with what
|
|
555
|
+
* input"; the receipt chain drills down to each mutable verdict, carrying the
|
|
556
|
+
* input digest (decision basis) plus count_before → count_after idempotency
|
|
557
|
+
* checkpoints so replay drift can be located to the exact record/run. Like
|
|
558
|
+
* the dream trail this is bookkeeping: it never triggers write hooks. Writes
|
|
559
|
+
* are idempotent on receipt id (replay overwrites, never duplicates).
|
|
560
|
+
*/
|
|
561
|
+
function saveReceipt(run) {
|
|
562
|
+
const id = run.receipt_id ?? randomUUID();
|
|
563
|
+
const now = nowIso();
|
|
564
|
+
const policyEpoch = Number.isInteger(run.policy_epoch) ? run.policy_epoch : 0;
|
|
565
|
+
db.prepare(
|
|
566
|
+
`INSERT INTO receipt_chain (receipt_id, run_id, record_id, kind, input_digest,
|
|
567
|
+
winner_id, loser_id, keep_source, sources, verdict, count_before, count_after,
|
|
568
|
+
policy_epoch, created_at)
|
|
569
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
570
|
+
ON CONFLICT(receipt_id) DO UPDATE SET
|
|
571
|
+
run_id=excluded.run_id, record_id=excluded.record_id, kind=excluded.kind,
|
|
572
|
+
input_digest=excluded.input_digest, winner_id=excluded.winner_id,
|
|
573
|
+
loser_id=excluded.loser_id, keep_source=excluded.keep_source,
|
|
574
|
+
sources=excluded.sources, verdict=excluded.verdict,
|
|
575
|
+
count_before=excluded.count_before, count_after=excluded.count_after,
|
|
576
|
+
policy_epoch=excluded.policy_epoch, created_at=excluded.created_at`
|
|
577
|
+
).run(
|
|
578
|
+
id,
|
|
579
|
+
run.run_id,
|
|
580
|
+
run.record_id,
|
|
581
|
+
run.kind,
|
|
582
|
+
run.input_digest,
|
|
583
|
+
run.winner_id ?? null,
|
|
584
|
+
run.loser_id ?? null,
|
|
585
|
+
run.keep_source ?? null,
|
|
586
|
+
JSON.stringify(run.sources ?? []),
|
|
587
|
+
run.verdict,
|
|
588
|
+
run.count_before,
|
|
589
|
+
run.count_after,
|
|
590
|
+
policyEpoch,
|
|
591
|
+
run.created_at ?? now
|
|
592
|
+
);
|
|
593
|
+
return getReceipt(id);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function getReceipt(id) {
|
|
597
|
+
const row = db.prepare("SELECT * FROM receipt_chain WHERE receipt_id = ?").get(id);
|
|
598
|
+
return toReceipt(row);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function listReceipts({ limit = 50, offset = 0, run_id } = {}) {
|
|
602
|
+
const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
|
|
603
|
+
const clauses = [];
|
|
604
|
+
const params = [];
|
|
605
|
+
if (run_id) {
|
|
606
|
+
clauses.push("run_id = ?");
|
|
607
|
+
params.push(run_id);
|
|
608
|
+
}
|
|
609
|
+
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
610
|
+
const rows = db.prepare(
|
|
611
|
+
`SELECT * FROM receipt_chain ${where} ORDER BY created_at DESC, receipt_id LIMIT ? OFFSET ?`
|
|
612
|
+
).all(...params, lim, off);
|
|
613
|
+
return rows.map(toReceipt);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// --- recall-layer audit trail -------------------------------------------
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Persist one recall run (the retrieval scene: query/mode/top-k/threshold +
|
|
620
|
+
* the exact candidate list handed to the caller). Like the dream audit trail
|
|
621
|
+
* this is bookkeeping, so it never triggers write hooks — a notify here would
|
|
622
|
+
* loop back into search itself. Writes are idempotent on run id (replay
|
|
623
|
+
* overwrites, never duplicates), matching saveDreamRun.
|
|
624
|
+
*/
|
|
625
|
+
function saveRecallRun(run) {
|
|
626
|
+
const id = run.id ?? randomUUID();
|
|
627
|
+
db.prepare(
|
|
628
|
+
`INSERT INTO recall_runs (id, query, mode, top_k, threshold, candidates, created_at)
|
|
629
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
630
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
631
|
+
query=excluded.query, mode=excluded.mode, top_k=excluded.top_k,
|
|
632
|
+
threshold=excluded.threshold, candidates=excluded.candidates,
|
|
633
|
+
created_at=excluded.created_at`
|
|
634
|
+
).run(
|
|
635
|
+
id,
|
|
636
|
+
run.query,
|
|
637
|
+
run.mode,
|
|
638
|
+
run.topK ?? null,
|
|
639
|
+
run.threshold ?? null,
|
|
640
|
+
JSON.stringify(run.candidates ?? []),
|
|
641
|
+
run.created_at ?? nowIso()
|
|
642
|
+
);
|
|
643
|
+
return getRecallRun(id);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function getRecallRun(id) {
|
|
647
|
+
const row = db.prepare("SELECT * FROM recall_runs WHERE id = ?").get(id);
|
|
648
|
+
return toRecallRun(row);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function listRecallRuns({ limit = 50, offset = 0, query } = {}) {
|
|
652
|
+
const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
|
|
653
|
+
const clauses = [];
|
|
654
|
+
const params = [];
|
|
655
|
+
if (query) {
|
|
656
|
+
clauses.push("query LIKE ? ESCAPE '\\'");
|
|
657
|
+
params.push(`%${escapeLike(String(query))}%`);
|
|
658
|
+
}
|
|
659
|
+
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
660
|
+
const rows = db.prepare(
|
|
661
|
+
`SELECT * FROM recall_runs ${where} ORDER BY created_at DESC, id LIMIT ? OFFSET ?`
|
|
662
|
+
).all(...params, lim, off);
|
|
663
|
+
return rows.map(toRecallRun);
|
|
664
|
+
}
|
|
665
|
+
|
|
443
666
|
// --- failure memories ----------------------------------------------------
|
|
444
667
|
|
|
445
668
|
/**
|
|
@@ -510,6 +733,13 @@ export function createStore(path) {
|
|
|
510
733
|
saveDreamRun,
|
|
511
734
|
getDreamRun,
|
|
512
735
|
listDreamRuns,
|
|
736
|
+
getLatestPolicyEpoch,
|
|
737
|
+
saveReceipt,
|
|
738
|
+
getReceipt,
|
|
739
|
+
listReceipts,
|
|
740
|
+
saveRecallRun,
|
|
741
|
+
getRecallRun,
|
|
742
|
+
listRecallRuns,
|
|
513
743
|
saveFailure,
|
|
514
744
|
listFailures,
|
|
515
745
|
getFailureStats,
|
package/test/audit.test.js
CHANGED
|
@@ -6,6 +6,7 @@ import { join } from "node:path";
|
|
|
6
6
|
import {
|
|
7
7
|
createDreamScheduler,
|
|
8
8
|
hashSnapshot,
|
|
9
|
+
hashDecisionInput,
|
|
9
10
|
buildReceipt,
|
|
10
11
|
parseReceipt,
|
|
11
12
|
buildOutcome
|
|
@@ -104,6 +105,97 @@ test("successful runDream writes an audit row with receipt, decisions and outcom
|
|
|
104
105
|
store.close();
|
|
105
106
|
});
|
|
106
107
|
|
|
108
|
+
// ---------------------------------------------------------------- per-record receipt chain
|
|
109
|
+
|
|
110
|
+
test("hashDecisionInput is deterministic, order-independent and content-addressed", () => {
|
|
111
|
+
const mk = (id, title, content = "c") => ({ id, title, content, importance: 3 });
|
|
112
|
+
const a = mk("a", "t1");
|
|
113
|
+
const b = mk("b", "t2");
|
|
114
|
+
assert.equal(hashDecisionInput([a, b]), hashDecisionInput([b, a]), "order independent");
|
|
115
|
+
assert.equal(hashDecisionInput([a, b]), hashDecisionInput([{ ...a }, { ...b }]), "clone independent");
|
|
116
|
+
assert.notEqual(hashDecisionInput([a, b]), hashDecisionInput([a, { ...b, content: "changed" }]), "content change flips digest");
|
|
117
|
+
assert.match(hashDecisionInput([]), /^[0-9a-f]{64}$/, "empty input hashes stably");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("runDream writes one per-record receipt per committed merge/conflict/update", async () => {
|
|
121
|
+
const { store, service, dream } = setup();
|
|
122
|
+
const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
|
|
123
|
+
const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
|
|
124
|
+
const { memory: w } = service.saveWithDedupe({ type: "decision", title: "截止", content: "8月20日", importance: 4 });
|
|
125
|
+
const { memory: l } = service.saveWithDedupe({ type: "decision", title: "截止旧", content: "8月15日", importance: 4 });
|
|
126
|
+
const { memory: u } = service.saveWithDedupe({ type: "preference", title: "语言", content: "喜欢 Python" });
|
|
127
|
+
const ctx = mockCtx({
|
|
128
|
+
onConsolidation: () => JSON.stringify([
|
|
129
|
+
{ action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "插件总览", content: "合并内容", importance: 4 },
|
|
130
|
+
{ action: "conflict", winner: w.id, loser: l.id, reason: "更新" },
|
|
131
|
+
{ action: "update", ids: [u.id], content: "喜欢 Rust" }
|
|
132
|
+
])
|
|
133
|
+
});
|
|
134
|
+
// Backdate the update target so the update age guard (minAgeHours) sees a
|
|
135
|
+
// settled record rather than a sub-second-old one — store.nowIso() can drift
|
|
136
|
+
// a few ms ahead of the wall clock when seeds land in the same millisecond.
|
|
137
|
+
store.db.prepare("UPDATE memories SET created_at = ? WHERE id = ?")
|
|
138
|
+
.run(new Date(Date.now() - 3600000).toISOString(), u.id);
|
|
139
|
+
const result = await dream.runDream(ctx, service, { reflectionUpdateMinAgeHours: 0, policyEpoch: 3 });
|
|
140
|
+
assert.equal(result.status, "ok");
|
|
141
|
+
|
|
142
|
+
const receipts = store.listReceipts({ run_id: result.runId });
|
|
143
|
+
assert.equal(receipts.length, 3, "one receipt per mutable verdict, none for keep/archive");
|
|
144
|
+
|
|
145
|
+
const byKind = Object.fromEntries(receipts.map((r) => [r.kind, r]));
|
|
146
|
+
assert.deepEqual(Object.keys(byKind).sort(), ["conflict", "merge", "update"]);
|
|
147
|
+
|
|
148
|
+
const merge = byKind.merge;
|
|
149
|
+
assert.equal(merge.run_id, result.runId);
|
|
150
|
+
assert.equal(merge.record_id, b.id, "merge receipt keyed on keepSource");
|
|
151
|
+
assert.equal(merge.keep_source, b.id);
|
|
152
|
+
assert.deepEqual(merge.sources, [a.id, b.id], "merge sources = full id array");
|
|
153
|
+
assert.equal(merge.count_before, 2);
|
|
154
|
+
assert.equal(merge.count_after, 1);
|
|
155
|
+
assert.equal(merge.verdict, "live");
|
|
156
|
+
assert.equal(merge.policy_epoch, 3, "policy epoch stamped from config");
|
|
157
|
+
assert.equal(merge.input_digest, hashDecisionInput([a, b]), "digest over the pre-apply basis memories");
|
|
158
|
+
assert.match(merge.created_at, /^2\d{3}-/, "created_at is an ISO timestamp");
|
|
159
|
+
|
|
160
|
+
const conflict = byKind.conflict;
|
|
161
|
+
assert.equal(conflict.record_id, w.id, "conflict receipt keyed on winner");
|
|
162
|
+
assert.equal(conflict.winner_id, w.id);
|
|
163
|
+
assert.equal(conflict.loser_id, l.id);
|
|
164
|
+
assert.equal(conflict.keep_source, undefined, "no keep_source on a conflict");
|
|
165
|
+
assert.equal(conflict.count_before, 2);
|
|
166
|
+
assert.equal(conflict.count_after, 1);
|
|
167
|
+
assert.equal(conflict.input_digest, hashDecisionInput([w, l]), "digest over winner+loser");
|
|
168
|
+
|
|
169
|
+
const update = byKind.update;
|
|
170
|
+
assert.equal(update.record_id, u.id);
|
|
171
|
+
assert.equal(update.winner_id, undefined, "no winner/loser on an update");
|
|
172
|
+
assert.equal(update.count_before, 1);
|
|
173
|
+
assert.equal(update.count_after, 1);
|
|
174
|
+
assert.equal(update.input_digest, hashDecisionInput([u]), "digest over the pre-update target");
|
|
175
|
+
store.close();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("a throwing per-record receipt writer never breaks the run (bookkeeping fails safe)", async () => {
|
|
179
|
+
const { store, service, dream } = setup();
|
|
180
|
+
const { memory: a } = service.saveWithDedupe({ type: "project", title: "甲", content: "A", importance: 3 });
|
|
181
|
+
const { memory: b } = service.saveWithDedupe({ type: "project", title: "乙", content: "B", importance: 4 });
|
|
182
|
+
service.saveReceipt = () => { throw new Error("receipt store boom"); };
|
|
183
|
+
const ctx = mockCtx({
|
|
184
|
+
onConsolidation: () => JSON.stringify([
|
|
185
|
+
{ action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "甲乙", content: "合并", importance: 4 }
|
|
186
|
+
])
|
|
187
|
+
});
|
|
188
|
+
const result = await dream.runDream(ctx, service, {});
|
|
189
|
+
assert.equal(result.ok, true, "consolidation unaffected by receipt failure");
|
|
190
|
+
assert.equal(result.applied, 1);
|
|
191
|
+
const run = store.listDreamRuns()[0];
|
|
192
|
+
assert.equal(run.status, "ok", "audit row still written");
|
|
193
|
+
assert.equal(store.getById(b.id).title, "甲乙", "merge still applied");
|
|
194
|
+
assert.equal(store.getById(a.id).archived, true, "merge source still archived");
|
|
195
|
+
assert.equal(store.listReceipts().length, 0, "no receipts persisted");
|
|
196
|
+
store.close();
|
|
197
|
+
});
|
|
198
|
+
|
|
107
199
|
test("failed runDream records status failed with the error", async () => {
|
|
108
200
|
const { store, service, dream } = setup();
|
|
109
201
|
service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" });
|