@modusensus/dsh-mneme 0.2.7 → 0.2.9
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 +3 -3
- package/lib/config.js +12 -1
- package/lib/dream/decisions.js +3 -3
- package/lib/dream.js +149 -6
- package/lib/index.js +17 -0
- package/lib/service.js +53 -9
- package/lib/store.js +332 -6
- package/package.json +1 -1
- package/src/config.js +12 -1
- package/src/dream/decisions.js +3 -3
- package/src/dream.js +149 -6
- package/src/index.js +17 -0
- package/src/service.js +53 -9
- package/src/store.js +332 -6
- package/test/audit.test.js +92 -0
- package/test/conflict-freeze.test.js +290 -0
- package/test/dream.test.js +126 -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/test/store.test.js +67 -0
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,49 @@ 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);
|
|
103
|
+
|
|
104
|
+
-- conflict_pending: conflicts parked for manual review (conflict freeze mode,
|
|
105
|
+
-- opt-in via config.conflictFreezeEnabled). When enabled, the dream layer does
|
|
106
|
+
-- NOT auto-adjudicate winner/loser — the conflicting pair is parked here until
|
|
107
|
+
-- a human reviews it. resolveConflictPending stamps resolved_at (plus the chosen
|
|
108
|
+
-- winner) so the review action stays auditable. Like the other audit tables this
|
|
109
|
+
-- is bookkeeping: it never triggers write hooks.
|
|
110
|
+
CREATE TABLE IF NOT EXISTS conflict_pending (
|
|
111
|
+
id TEXT PRIMARY KEY,
|
|
112
|
+
run_id TEXT,
|
|
113
|
+
memory_a TEXT NOT NULL,
|
|
114
|
+
memory_b TEXT NOT NULL,
|
|
115
|
+
reason TEXT,
|
|
116
|
+
created_at TEXT NOT NULL,
|
|
117
|
+
resolved_at TEXT,
|
|
118
|
+
resolved_winner TEXT
|
|
119
|
+
);
|
|
120
|
+
CREATE INDEX IF NOT EXISTS idx_conflict_pending_unresolved ON conflict_pending(resolved_at);
|
|
61
121
|
`;
|
|
62
122
|
|
|
63
123
|
const TYPES = new Set(["preference", "project", "decision", "history", "summary"]);
|
|
@@ -116,10 +176,67 @@ function toDreamRun(row) {
|
|
|
116
176
|
outcome: row.outcome ? JSON.parse(row.outcome) : undefined,
|
|
117
177
|
applied: row.applied,
|
|
118
178
|
summary_stored: row.summary_stored === 1,
|
|
119
|
-
receipt: row.receipt
|
|
179
|
+
receipt: row.receipt,
|
|
180
|
+
policy_epoch: row.policy_epoch ?? 0
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function toReceipt(row) {
|
|
185
|
+
if (!row) return undefined;
|
|
186
|
+
return {
|
|
187
|
+
receipt_id: row.receipt_id,
|
|
188
|
+
run_id: row.run_id,
|
|
189
|
+
record_id: row.record_id,
|
|
190
|
+
kind: row.kind,
|
|
191
|
+
input_digest: row.input_digest,
|
|
192
|
+
winner_id: row.winner_id ?? undefined,
|
|
193
|
+
loser_id: row.loser_id ?? undefined,
|
|
194
|
+
keep_source: row.keep_source ?? undefined,
|
|
195
|
+
sources: parseJsonArray(row.sources),
|
|
196
|
+
verdict: row.verdict,
|
|
197
|
+
count_before: row.count_before,
|
|
198
|
+
count_after: row.count_after,
|
|
199
|
+
policy_epoch: row.policy_epoch ?? 0,
|
|
200
|
+
created_at: row.created_at
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function toConflictPending(row) {
|
|
205
|
+
if (!row) return undefined;
|
|
206
|
+
return {
|
|
207
|
+
id: row.id,
|
|
208
|
+
run_id: row.run_id ?? undefined,
|
|
209
|
+
memory_a: row.memory_a,
|
|
210
|
+
memory_b: row.memory_b,
|
|
211
|
+
reason: row.reason ?? undefined,
|
|
212
|
+
created_at: row.created_at,
|
|
213
|
+
resolved_at: row.resolved_at ?? undefined,
|
|
214
|
+
resolved_winner: row.resolved_winner ?? undefined
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function toRecallRun(row) {
|
|
219
|
+
if (!row) return undefined;
|
|
220
|
+
return {
|
|
221
|
+
id: row.id,
|
|
222
|
+
query: row.query,
|
|
223
|
+
mode: row.mode,
|
|
224
|
+
topK: row.top_k,
|
|
225
|
+
threshold: row.threshold,
|
|
226
|
+
candidates: parseJsonArray(row.candidates),
|
|
227
|
+
created_at: row.created_at
|
|
120
228
|
};
|
|
121
229
|
}
|
|
122
230
|
|
|
231
|
+
function parseJsonArray(raw) {
|
|
232
|
+
try {
|
|
233
|
+
const arr = JSON.parse(raw);
|
|
234
|
+
return Array.isArray(arr) ? arr : [];
|
|
235
|
+
} catch {
|
|
236
|
+
return [];
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
123
240
|
export function createStore(path) {
|
|
124
241
|
const db = new DatabaseSync(path);
|
|
125
242
|
db.exec("PRAGMA journal_mode = WAL;");
|
|
@@ -134,6 +251,12 @@ export function createStore(path) {
|
|
|
134
251
|
db.exec("ALTER TABLE memories ADD COLUMN embedding TEXT");
|
|
135
252
|
}
|
|
136
253
|
|
|
254
|
+
// Legacy dream_runs without policy_epoch → backfill with the default epoch.
|
|
255
|
+
const dreamCols = db.prepare("PRAGMA table_info(dream_runs)").all().map((c) => c.name);
|
|
256
|
+
if (!dreamCols.includes("policy_epoch")) {
|
|
257
|
+
db.exec("ALTER TABLE dream_runs ADD COLUMN policy_epoch INTEGER NOT NULL DEFAULT 0");
|
|
258
|
+
}
|
|
259
|
+
|
|
137
260
|
// Per-instance monotonic timestamp guard: consecutive writes within the same
|
|
138
261
|
// millisecond must still produce strictly increasing timestamps (test asserts
|
|
139
262
|
// updated_at != created_at). State lives in the store closure, not module scope.
|
|
@@ -398,16 +521,17 @@ export function createStore(path) {
|
|
|
398
521
|
function saveDreamRun(run) {
|
|
399
522
|
const id = run.id ?? randomUUID();
|
|
400
523
|
const now = nowIso();
|
|
524
|
+
const policyEpoch = Number.isInteger(run.policy_epoch) ? run.policy_epoch : 0;
|
|
401
525
|
db.prepare(
|
|
402
526
|
`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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
527
|
+
input_count, input, decisions, outcome, applied, summary_stored, receipt, policy_epoch)
|
|
528
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
405
529
|
ON CONFLICT(id) DO UPDATE SET
|
|
406
530
|
created_at=excluded.created_at, status=excluded.status, error=excluded.error,
|
|
407
531
|
provider=excluded.provider, model=excluded.model, snapshot_hash=excluded.snapshot_hash,
|
|
408
532
|
input_count=excluded.input_count, input=excluded.input, decisions=excluded.decisions,
|
|
409
533
|
outcome=excluded.outcome, applied=excluded.applied, summary_stored=excluded.summary_stored,
|
|
410
|
-
receipt=excluded.receipt`
|
|
534
|
+
receipt=excluded.receipt, policy_epoch=excluded.policy_epoch`
|
|
411
535
|
).run(
|
|
412
536
|
id,
|
|
413
537
|
run.created_at ?? now,
|
|
@@ -422,7 +546,8 @@ export function createStore(path) {
|
|
|
422
546
|
run.outcome !== undefined ? JSON.stringify(run.outcome) : null,
|
|
423
547
|
run.applied ?? 0,
|
|
424
548
|
run.summary_stored ? 1 : 0,
|
|
425
|
-
run.receipt
|
|
549
|
+
run.receipt,
|
|
550
|
+
policyEpoch
|
|
426
551
|
);
|
|
427
552
|
return getDreamRun(id);
|
|
428
553
|
}
|
|
@@ -440,6 +565,136 @@ export function createStore(path) {
|
|
|
440
565
|
return rows.map(toDreamRun);
|
|
441
566
|
}
|
|
442
567
|
|
|
568
|
+
/**
|
|
569
|
+
* Latest ruling-rule version seen on the audit trail. policy_epoch is a config
|
|
570
|
+
* value stamped onto each run by the caller; reading the newest row's epoch
|
|
571
|
+
* gives the current effective version, falling back to 0 (default) when the
|
|
572
|
+
* trail is empty. Rules upgrades leave older runs with their original epoch,
|
|
573
|
+
* so those decisions can be demoted to historical evidence.
|
|
574
|
+
*/
|
|
575
|
+
function getLatestPolicyEpoch() {
|
|
576
|
+
const row = db.prepare(
|
|
577
|
+
"SELECT policy_epoch FROM dream_runs ORDER BY created_at DESC, id LIMIT 1"
|
|
578
|
+
).get();
|
|
579
|
+
return row ? (row.policy_epoch ?? 0) : 0;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// --- per-record receipt chain --------------------------------------------
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Persist one per-record receipt (a single merge/conflict/update verdict).
|
|
586
|
+
* The run-level dream audit trail answers "did this run happen and with what
|
|
587
|
+
* input"; the receipt chain drills down to each mutable verdict, carrying the
|
|
588
|
+
* input digest (decision basis) plus count_before → count_after idempotency
|
|
589
|
+
* checkpoints so replay drift can be located to the exact record/run. Like
|
|
590
|
+
* the dream trail this is bookkeeping: it never triggers write hooks. Writes
|
|
591
|
+
* are idempotent on receipt id (replay overwrites, never duplicates).
|
|
592
|
+
*/
|
|
593
|
+
function saveReceipt(run) {
|
|
594
|
+
const id = run.receipt_id ?? randomUUID();
|
|
595
|
+
const now = nowIso();
|
|
596
|
+
const policyEpoch = Number.isInteger(run.policy_epoch) ? run.policy_epoch : 0;
|
|
597
|
+
db.prepare(
|
|
598
|
+
`INSERT INTO receipt_chain (receipt_id, run_id, record_id, kind, input_digest,
|
|
599
|
+
winner_id, loser_id, keep_source, sources, verdict, count_before, count_after,
|
|
600
|
+
policy_epoch, created_at)
|
|
601
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
602
|
+
ON CONFLICT(receipt_id) DO UPDATE SET
|
|
603
|
+
run_id=excluded.run_id, record_id=excluded.record_id, kind=excluded.kind,
|
|
604
|
+
input_digest=excluded.input_digest, winner_id=excluded.winner_id,
|
|
605
|
+
loser_id=excluded.loser_id, keep_source=excluded.keep_source,
|
|
606
|
+
sources=excluded.sources, verdict=excluded.verdict,
|
|
607
|
+
count_before=excluded.count_before, count_after=excluded.count_after,
|
|
608
|
+
policy_epoch=excluded.policy_epoch, created_at=excluded.created_at`
|
|
609
|
+
).run(
|
|
610
|
+
id,
|
|
611
|
+
run.run_id,
|
|
612
|
+
run.record_id,
|
|
613
|
+
run.kind,
|
|
614
|
+
run.input_digest,
|
|
615
|
+
run.winner_id ?? null,
|
|
616
|
+
run.loser_id ?? null,
|
|
617
|
+
run.keep_source ?? null,
|
|
618
|
+
JSON.stringify(run.sources ?? []),
|
|
619
|
+
run.verdict,
|
|
620
|
+
run.count_before,
|
|
621
|
+
run.count_after,
|
|
622
|
+
policyEpoch,
|
|
623
|
+
run.created_at ?? now
|
|
624
|
+
);
|
|
625
|
+
return getReceipt(id);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function getReceipt(id) {
|
|
629
|
+
const row = db.prepare("SELECT * FROM receipt_chain WHERE receipt_id = ?").get(id);
|
|
630
|
+
return toReceipt(row);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function listReceipts({ limit = 50, offset = 0, run_id } = {}) {
|
|
634
|
+
const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
|
|
635
|
+
const clauses = [];
|
|
636
|
+
const params = [];
|
|
637
|
+
if (run_id) {
|
|
638
|
+
clauses.push("run_id = ?");
|
|
639
|
+
params.push(run_id);
|
|
640
|
+
}
|
|
641
|
+
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
642
|
+
const rows = db.prepare(
|
|
643
|
+
`SELECT * FROM receipt_chain ${where} ORDER BY created_at DESC, receipt_id LIMIT ? OFFSET ?`
|
|
644
|
+
).all(...params, lim, off);
|
|
645
|
+
return rows.map(toReceipt);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// --- recall-layer audit trail -------------------------------------------
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Persist one recall run (the retrieval scene: query/mode/top-k/threshold +
|
|
652
|
+
* the exact candidate list handed to the caller). Like the dream audit trail
|
|
653
|
+
* this is bookkeeping, so it never triggers write hooks — a notify here would
|
|
654
|
+
* loop back into search itself. Writes are idempotent on run id (replay
|
|
655
|
+
* overwrites, never duplicates), matching saveDreamRun.
|
|
656
|
+
*/
|
|
657
|
+
function saveRecallRun(run) {
|
|
658
|
+
const id = run.id ?? randomUUID();
|
|
659
|
+
db.prepare(
|
|
660
|
+
`INSERT INTO recall_runs (id, query, mode, top_k, threshold, candidates, created_at)
|
|
661
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
662
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
663
|
+
query=excluded.query, mode=excluded.mode, top_k=excluded.top_k,
|
|
664
|
+
threshold=excluded.threshold, candidates=excluded.candidates,
|
|
665
|
+
created_at=excluded.created_at`
|
|
666
|
+
).run(
|
|
667
|
+
id,
|
|
668
|
+
run.query,
|
|
669
|
+
run.mode,
|
|
670
|
+
run.topK ?? null,
|
|
671
|
+
run.threshold ?? null,
|
|
672
|
+
JSON.stringify(run.candidates ?? []),
|
|
673
|
+
run.created_at ?? nowIso()
|
|
674
|
+
);
|
|
675
|
+
return getRecallRun(id);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function getRecallRun(id) {
|
|
679
|
+
const row = db.prepare("SELECT * FROM recall_runs WHERE id = ?").get(id);
|
|
680
|
+
return toRecallRun(row);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function listRecallRuns({ limit = 50, offset = 0, query } = {}) {
|
|
684
|
+
const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
|
|
685
|
+
const clauses = [];
|
|
686
|
+
const params = [];
|
|
687
|
+
if (query) {
|
|
688
|
+
clauses.push("query LIKE ? ESCAPE '\\'");
|
|
689
|
+
params.push(`%${escapeLike(String(query))}%`);
|
|
690
|
+
}
|
|
691
|
+
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
692
|
+
const rows = db.prepare(
|
|
693
|
+
`SELECT * FROM recall_runs ${where} ORDER BY created_at DESC, id LIMIT ? OFFSET ?`
|
|
694
|
+
).all(...params, lim, off);
|
|
695
|
+
return rows.map(toRecallRun);
|
|
696
|
+
}
|
|
697
|
+
|
|
443
698
|
// --- failure memories ----------------------------------------------------
|
|
444
699
|
|
|
445
700
|
/**
|
|
@@ -479,6 +734,66 @@ export function createStore(path) {
|
|
|
479
734
|
return db.prepare("DELETE FROM failure_memories WHERE created_at < ?").run(before).changes;
|
|
480
735
|
}
|
|
481
736
|
|
|
737
|
+
// --- conflict freeze: pending manual review ------------------------------
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Park a detected conflict for human review (conflict freeze mode). The pair
|
|
741
|
+
* order is normalized (sorted by id) so the same two memories are only ever
|
|
742
|
+
* pending once — a re-detection in a later dream run is a no-op, never a
|
|
743
|
+
* duplicate queue entry. Returns the pending row (freshly inserted, or the
|
|
744
|
+
* existing unresolved row when the pair is already pending).
|
|
745
|
+
*/
|
|
746
|
+
function saveConflictPending({ run_id, memory_a, memory_b, reason }) {
|
|
747
|
+
const [a, b] = [memory_a, memory_b].sort();
|
|
748
|
+
const existing = db.prepare(
|
|
749
|
+
"SELECT * FROM conflict_pending WHERE memory_a = ? AND memory_b = ? AND resolved_at IS NULL LIMIT 1"
|
|
750
|
+
).get(a, b);
|
|
751
|
+
if (existing) return toConflictPending(existing);
|
|
752
|
+
const id = randomUUID();
|
|
753
|
+
const now = nowIso();
|
|
754
|
+
db.prepare(
|
|
755
|
+
`INSERT INTO conflict_pending (id, run_id, memory_a, memory_b, reason, created_at)
|
|
756
|
+
VALUES (?, ?, ?, ?, ?, ?)`
|
|
757
|
+
).run(id, run_id ?? null, a, b, reason ?? null, now);
|
|
758
|
+
return toConflictPending(db.prepare("SELECT * FROM conflict_pending WHERE id = ?").get(id));
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* List pending conflicts, newest first. Unresolved rows only by default;
|
|
763
|
+
* pass includeResolved to include resolved ones (audit view).
|
|
764
|
+
*/
|
|
765
|
+
function listConflictPending({ limit = 50, offset = 0, includeResolved = false } = {}) {
|
|
766
|
+
const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
|
|
767
|
+
const clauses = [];
|
|
768
|
+
const params = [];
|
|
769
|
+
if (!includeResolved) clauses.push("resolved_at IS NULL");
|
|
770
|
+
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
771
|
+
const rows = db.prepare(
|
|
772
|
+
`SELECT * FROM conflict_pending ${where} ORDER BY created_at DESC, id LIMIT ? OFFSET ?`
|
|
773
|
+
).all(...params, lim, off);
|
|
774
|
+
return rows.map(toConflictPending);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Mark a pending conflict as reviewed. winner (optional) records which side
|
|
779
|
+
* the human chose, keeping the resolution auditable. Returns the updated row,
|
|
780
|
+
* or undefined for an unknown id.
|
|
781
|
+
*/
|
|
782
|
+
function resolveConflictPending(id, { winner } = {}) {
|
|
783
|
+
const row = db.prepare("SELECT * FROM conflict_pending WHERE id = ?").get(id);
|
|
784
|
+
if (!row) return undefined;
|
|
785
|
+
db.prepare("UPDATE conflict_pending SET resolved_at = ?, resolved_winner = ? WHERE id = ?")
|
|
786
|
+
.run(nowIso(), winner ?? null, id);
|
|
787
|
+
return toConflictPending(db.prepare("SELECT * FROM conflict_pending WHERE id = ?").get(id));
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/** Number of unresolved (awaiting review) pending conflicts. */
|
|
791
|
+
function countConflictPending() {
|
|
792
|
+
return db.prepare(
|
|
793
|
+
"SELECT count(*) AS c FROM conflict_pending WHERE resolved_at IS NULL"
|
|
794
|
+
).get().c;
|
|
795
|
+
}
|
|
796
|
+
|
|
482
797
|
function getFailureStats({ since } = {}) {
|
|
483
798
|
const clause = since ? "WHERE created_at >= ?" : "";
|
|
484
799
|
const params = since ? [since] : [];
|
|
@@ -510,10 +825,21 @@ export function createStore(path) {
|
|
|
510
825
|
saveDreamRun,
|
|
511
826
|
getDreamRun,
|
|
512
827
|
listDreamRuns,
|
|
828
|
+
getLatestPolicyEpoch,
|
|
829
|
+
saveReceipt,
|
|
830
|
+
getReceipt,
|
|
831
|
+
listReceipts,
|
|
832
|
+
saveRecallRun,
|
|
833
|
+
getRecallRun,
|
|
834
|
+
listRecallRuns,
|
|
513
835
|
saveFailure,
|
|
514
836
|
listFailures,
|
|
515
837
|
getFailureStats,
|
|
516
838
|
deleteOldFailures,
|
|
839
|
+
saveConflictPending,
|
|
840
|
+
listConflictPending,
|
|
841
|
+
resolveConflictPending,
|
|
842
|
+
countConflictPending,
|
|
517
843
|
close() {
|
|
518
844
|
db.close();
|
|
519
845
|
}
|
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: "中文" });
|