@modusensus/dsh-mneme 0.2.6 → 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/src/store.js CHANGED
@@ -26,7 +26,7 @@ CREATE INDEX IF NOT EXISTS idx_memories_importance ON memories(importance);
26
26
  CREATE TABLE IF NOT EXISTS dream_runs (
27
27
  id TEXT PRIMARY KEY,
28
28
  created_at TEXT NOT NULL,
29
- status TEXT NOT NULL, -- ok | failed
29
+ status TEXT NOT NULL, -- ok | noop | degraded | reconcile | failed
30
30
  error TEXT,
31
31
  provider TEXT,
32
32
  model TEXT,
@@ -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,
@@ -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: "中文" });
@@ -288,3 +380,69 @@ test("buildOutcome over actually-committed sub-steps never claims a rolled-back
288
380
  assert.equal(outcome.byId.m2, "merge-archived");
289
381
  assert.equal(outcome.byId.k, "keep");
290
382
  });
383
+
384
+ // ---------------------------------------------------------------- noop & degraded (F-03)
385
+
386
+ test("runDream records status noop when all decisions are keep and summary is empty", async () => {
387
+ const { store, service, dream } = setup();
388
+ const { memory: m } = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" });
389
+ const ctx = mockCtx({
390
+ onConsolidation: () => JSON.stringify([{ action: "keep", ids: [m.id] }]),
391
+ summaryText: ""
392
+ });
393
+ const result = await dream.runDream(ctx, service, {});
394
+ assert.equal(result.ok, false, "no-change + empty summary is not a success");
395
+ assert.equal(result.status, "noop", "explicit noop status");
396
+ assert.equal(result.applied, 0);
397
+ assert.equal(result.summary, false);
398
+
399
+ const run = store.listDreamRuns()[0];
400
+ assert.equal(run.status, "noop", "audit row records noop, never ok");
401
+ assert.equal(run.applied, 0);
402
+ assert.equal(run.summary_stored, false);
403
+ const parsed = parseReceipt(run.receipt);
404
+ assert.equal(parsed.status, "noop", "receipt records noop");
405
+ assert.equal(parsed.applied, 0);
406
+ assert.equal(parsed.summaryStored, false);
407
+ assert.equal(run.outcome.byId[m.id], "keep", "keep disposition still recorded for replay");
408
+ store.close();
409
+ });
410
+
411
+ test("runDream records status ok when all decisions are keep but a summary was stored", async () => {
412
+ const { store, service, dream } = setup();
413
+ const { memory: m } = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" });
414
+ const ctx = mockCtx({ onConsolidation: () => JSON.stringify([{ action: "keep", ids: [m.id] }]) });
415
+ const result = await dream.runDream(ctx, service, {});
416
+ assert.equal(result.status, "ok", "summary refresh is substantive output");
417
+ assert.equal(result.ok, true);
418
+ assert.equal(result.applied, 0);
419
+ assert.equal(result.summary, true);
420
+ const run = store.listDreamRuns()[0];
421
+ assert.equal(run.status, "ok");
422
+ assert.equal(parseReceipt(run.receipt).status, "ok");
423
+ assert.equal(parseReceipt(run.receipt).summaryStored, true);
424
+ store.close();
425
+ });
426
+
427
+ test("runDream records status degraded when changes land but the summary is empty", async () => {
428
+ const { store, service, dream } = setup();
429
+ const { memory: a } = service.saveWithDedupe({ type: "project", title: "旧", content: "A", importance: 3 });
430
+ const { memory: b } = service.saveWithDedupe({ type: "project", title: "新", content: "B", importance: 4 });
431
+ const ctx = mockCtx({
432
+ onConsolidation: () => JSON.stringify([
433
+ { action: "merge", ids: [a.id, b.id], title: "合并", content: "合并内容", importance: 4, keepSource: b.id }
434
+ ]),
435
+ summaryText: ""
436
+ });
437
+ const result = await dream.runDream(ctx, service, {});
438
+ assert.equal(result.status, "degraded", "real changes without a summary are not a clean ok");
439
+ assert.equal(result.ok, true, "consolidation landed so the baseline may advance");
440
+ assert.equal(result.applied, 1);
441
+ assert.equal(result.summary, false, "summary honestly reported missing");
442
+ const run = store.listDreamRuns()[0];
443
+ assert.equal(run.status, "degraded", "audit row records degraded");
444
+ assert.equal(run.summary_stored, false);
445
+ assert.equal(parseReceipt(run.receipt).status, "degraded");
446
+ assert.equal(parseReceipt(run.receipt).summaryStored, false);
447
+ store.close();
448
+ });
@@ -296,6 +296,27 @@ test("failed run does not refresh baseline: next write re-triggers", async () =>
296
296
  store.close();
297
297
  });
298
298
 
299
+ test("noop run (nothing changed) does not refresh baseline: next write re-triggers", async () => {
300
+ const { store, service } = dreamSetup();
301
+ let calls = 0;
302
+ const dream = createDreamScheduler({
303
+ onRun: async () => { calls++; return { ok: false, status: "noop", applied: 0, summary: false }; },
304
+ thresholdCount: 2, thresholdChars: 5000, delayMs: 5,
305
+ logger: { warn: () => {} }
306
+ });
307
+ service.saveWithDedupe({ type: "project", title: "a", content: "x".repeat(10) });
308
+ service.saveWithDedupe({ type: "project", title: "b", content: "y".repeat(10) });
309
+ assert.equal(dream.maybeSchedule(service), true, "scheduled");
310
+ await new Promise((r) => setTimeout(r, 50));
311
+ assert.equal(calls, 1, "run attempted once");
312
+ // a noop is not a success: the baseline stays put so the accumulated writes
313
+ // are still owed and the next write re-schedules instead of being absorbed
314
+ assert.equal(dream.maybeSchedule(service), true, "noop keeps baseline, re-schedules");
315
+ await new Promise((r) => setTimeout(r, 50));
316
+ assert.equal(calls, 2, "retried after noop");
317
+ store.close();
318
+ });
319
+
299
320
  test("throwing run does not refresh baseline and is logged", async () => {
300
321
  const { store, service } = dreamSetup();
301
322
  const warnings = [];