@haaaiawd/second-nature 0.2.6 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * PerceptionBuilder — Generate PerceptionCard records from EvidenceItem batches.
3
3
  *
4
- * Core logic: Read pending evidence, deduplicate by content hash, build
4
+ * Core logic: Read pending evidence, deduplicate by externalId/content hash, build
5
5
  * PerceptionCard with topic, entities, novelty, relevance, summary, risk
6
6
  * flags, confidence, and reviewPriority. Rules-only fallback when model
7
7
  * assist is unavailable.
@@ -21,7 +21,7 @@
21
21
  *
22
22
  * Test coverage: tests/unit/perception/perception-builder.test.ts
23
23
  */
24
- import { readEvidenceItemsByStatus, writePerceptionCard, } from "../../../storage/v8-state-stores.js";
24
+ import { readEvidenceItemsByStatus, writePerceptionCard, updateEvidenceItemLifecycleStatus, } from "../../../storage/v8-state-stores.js";
25
25
  // ───────────────────────────────────────────────────────────────
26
26
  // Config
27
27
  // ───────────────────────────────────────────────────────────────
@@ -38,46 +38,64 @@ function parseSourceRefs(json) {
38
38
  return [];
39
39
  }
40
40
  }
41
- function extractTopic(evidence) {
42
- if (evidence.payloadJson) {
43
- try {
44
- const payload = JSON.parse(evidence.payloadJson);
45
- if (payload.topic)
46
- return String(payload.topic);
47
- if (payload.title)
48
- return String(payload.title);
49
- if (payload.subject)
50
- return String(payload.subject);
41
+ function parsePayload(json) {
42
+ if (!json)
43
+ return undefined;
44
+ try {
45
+ const parsed = JSON.parse(json);
46
+ if (parsed && typeof parsed === "object" && "schemaVersion" in parsed) {
47
+ return parsed;
51
48
  }
52
- catch {
53
- /* ignore */
49
+ if (parsed && typeof parsed === "object" && "summary" in parsed) {
50
+ // Legacy payload or direct summary object
51
+ return {
52
+ schemaVersion: 1,
53
+ sourceKind: "unknown",
54
+ platformId: "",
55
+ capabilityId: "",
56
+ summary: String(parsed.summary ?? ""),
57
+ observedAt: new Date().toISOString(),
58
+ summaryProducer: "connector_rules",
59
+ ...parsed,
60
+ };
54
61
  }
55
62
  }
63
+ catch {
64
+ /* ignore */
65
+ }
66
+ return undefined;
67
+ }
68
+ function extractTopic(evidence) {
69
+ const payload = parsePayload(evidence.payloadJson);
70
+ if (payload?.title)
71
+ return String(payload.title);
72
+ if (payload?.summary) {
73
+ const summary = String(payload.summary);
74
+ return summary.length > 60 ? `${summary.slice(0, 60)}…` : summary;
75
+ }
56
76
  return `${evidence.platformId}_observation`;
57
77
  }
58
78
  function extractEntities(evidence) {
59
79
  const entities = [evidence.platformId];
60
- if (evidence.payloadJson) {
61
- try {
62
- const payload = JSON.parse(evidence.payloadJson);
63
- if (payload.entities && Array.isArray(payload.entities)) {
64
- entities.push(...payload.entities.map(String));
65
- }
66
- if (payload.tags && Array.isArray(payload.tags)) {
67
- entities.push(...payload.tags.map(String));
68
- }
69
- if (payload.mentions && Array.isArray(payload.mentions)) {
70
- entities.push(...payload.mentions.map(String));
71
- }
72
- }
73
- catch {
74
- /* ignore */
75
- }
80
+ const payload = parsePayload(evidence.payloadJson);
81
+ if (payload?.entities && Array.isArray(payload.entities)) {
82
+ entities.push(...payload.entities.map(String));
76
83
  }
77
- return [...new Set(entities)];
84
+ if (payload?.tags && Array.isArray(payload.tags)) {
85
+ entities.push(...payload.tags.map(String));
86
+ }
87
+ if (payload?.actor?.displayName) {
88
+ entities.push(payload.actor.displayName);
89
+ }
90
+ return [...new Set(entities.filter((e) => e.length > 0))];
78
91
  }
79
- function inferNoveltyClass(evidence) {
80
- // Canonical novelty classification
92
+ function inferNoveltyClass(evidence, duplicateKey, seenKeys) {
93
+ if (seenKeys.has(duplicateKey)) {
94
+ const firstObserved = seenKeys.get(duplicateKey);
95
+ const current = evidence.observedAt;
96
+ // If same calendar day, treat as duplicate; otherwise stale.
97
+ return firstObserved.slice(0, 10) === current.slice(0, 10) ? "duplicate" : "stale";
98
+ }
81
99
  if (evidence.sensitivityHint === "public_technical")
82
100
  return "changed";
83
101
  return "new";
@@ -99,9 +117,17 @@ function inferRelevanceClass(score) {
99
117
  return "low";
100
118
  }
101
119
  function inferSummary(evidence) {
102
- const platform = evidence.platformId;
103
- const topic = extractTopic(evidence);
104
- return `Observation from ${platform}: ${topic}`;
120
+ const payload = parsePayload(evidence.payloadJson);
121
+ if (payload?.summary && String(payload.summary).trim().length > 0) {
122
+ return { summary: String(payload.summary), contentMissing: false };
123
+ }
124
+ if (payload?.title) {
125
+ return { summary: `Observation from ${evidence.platformId}: ${payload.title}`, contentMissing: false };
126
+ }
127
+ return {
128
+ summary: `Ref-only observation from ${evidence.platformId}: no readable content`,
129
+ contentMissing: true,
130
+ };
105
131
  }
106
132
  function inferPossibleIntents(evidence) {
107
133
  const intents = ["watch"];
@@ -130,25 +156,35 @@ function inferRiskFlags(evidence) {
130
156
  }
131
157
  return flags;
132
158
  }
133
- function buildCardFromEvidence(evidence, cycleId, now) {
159
+ function duplicateKey(evidence) {
160
+ const payload = parsePayload(evidence.payloadJson);
161
+ if (payload?.externalId) {
162
+ return `${evidence.platformId}:${evidence.contentHash}:${payload.externalId}`;
163
+ }
164
+ return `${evidence.platformId}:${evidence.contentHash}`;
165
+ }
166
+ function buildCardFromEvidence(evidence, cycleId, now, seenKeys) {
134
167
  const sourceRefs = parseSourceRefs(evidence.sourceRefsJson);
135
168
  const relevanceScore = inferRelevanceScore(evidence);
169
+ const key = duplicateKey(evidence);
170
+ const { summary, contentMissing } = inferSummary(evidence);
136
171
  return {
137
172
  id: `per_${evidence.id}`,
138
173
  cycleId,
139
174
  topic: extractTopic(evidence),
140
175
  entities: extractEntities(evidence),
141
- noveltyClass: inferNoveltyClass(evidence),
176
+ noveltyClass: inferNoveltyClass(evidence, key, seenKeys),
142
177
  relevanceScore,
143
178
  relevanceClass: inferRelevanceClass(relevanceScore),
144
- summary: inferSummary(evidence),
179
+ summary,
145
180
  possibleIntents: inferPossibleIntents(evidence),
146
181
  reviewPriority: inferReviewPriority(evidence),
147
182
  sensitivityClass: evidence.sensitivityHint || "public_general",
148
183
  riskFlags: inferRiskFlags(evidence),
149
- confidence: 0.6,
184
+ confidence: contentMissing ? 0.3 : 0.6,
150
185
  evidenceRefs: sourceRefs,
151
186
  createdAt: now,
187
+ contentMissing,
152
188
  };
153
189
  }
154
190
  export async function buildPerceptionCards(db, options) {
@@ -169,6 +205,7 @@ export async function buildPerceptionCards(db, options) {
169
205
  const truncated = evidenceItems.length > maxEvidence;
170
206
  const selectedEvidence = evidenceItems.slice(0, maxEvidence);
171
207
  const cards = [];
208
+ const seenKeys = new Map();
172
209
  for (const evidence of selectedEvidence) {
173
210
  const card = buildCardFromEvidence({
174
211
  id: evidence.id,
@@ -178,9 +215,21 @@ export async function buildPerceptionCards(db, options) {
178
215
  sensitivityHint: evidence.sensitivityHint ?? undefined,
179
216
  sourceRefsJson: evidence.sourceRefsJson,
180
217
  payloadJson: evidence.payloadJson,
181
- }, options.cycleId, now);
218
+ }, options.cycleId, now, seenKeys);
182
219
  cards.push(card);
183
- // Write card to state
220
+ // Track first observation timestamp for duplicate/stale classification
221
+ const key = duplicateKey({
222
+ id: evidence.id,
223
+ platformId: evidence.platformId,
224
+ contentHash: evidence.contentHash,
225
+ observedAt: evidence.observedAt,
226
+ sourceRefsJson: evidence.sourceRefsJson,
227
+ payloadJson: evidence.payloadJson,
228
+ });
229
+ if (!seenKeys.has(key)) {
230
+ seenKeys.set(key, evidence.observedAt);
231
+ }
232
+ // Write card to state and advance evidence lifecycle
184
233
  const writeResult = await writePerceptionCard(db, {
185
234
  id: card.id,
186
235
  createdAt: now,
@@ -200,6 +249,7 @@ export async function buildPerceptionCards(db, options) {
200
249
  payloadJson: JSON.stringify({
201
250
  possibleIntents: card.possibleIntents,
202
251
  sensitivityClass: card.sensitivityClass,
252
+ contentMissing: card.contentMissing,
203
253
  }),
204
254
  });
205
255
  if ("reason" in writeResult) {
@@ -209,11 +259,15 @@ export async function buildPerceptionCards(db, options) {
209
259
  reason: writeResult.reason,
210
260
  };
211
261
  }
262
+ await updateEvidenceItemLifecycleStatus(db, evidence.id, "perceived");
212
263
  }
264
+ const hasContentMissing = cards.some((c) => c.contentMissing);
265
+ const allContentMissing = cards.length > 0 && cards.every((c) => c.contentMissing);
266
+ const status = allContentMissing ? "rules_only" : "completed";
213
267
  return {
214
- status: "completed",
268
+ status,
215
269
  cards,
216
270
  truncated,
217
- reason: truncated ? "evidence_batch_truncated" : undefined,
271
+ reason: status === "rules_only" ? "evidence_content_missing" : (truncated ? "evidence_batch_truncated" : undefined),
218
272
  };
219
273
  }
@@ -4,19 +4,22 @@
4
4
  * Core logic: Check if today's Quiet review is due (closures exist but no review
5
5
  * yet), schedule/run it, then check Dream status. Records durable states so
6
6
  * loop_status can report exact missing stages even when heartbeat does not
7
- * select a quiet intent.
7
+ * select a quiet intent. Also executes stale Dream consolidation runs that
8
+ * were left at "scheduled" because no runner picked them up.
8
9
  *
9
10
  * Design authority:
10
11
  * - `.anws/v8/04_SYSTEM_DESIGN/dream-quiet-memory-system.md §4`
11
12
  * - `.anws/v8/04_SYSTEM_DESIGN/dream-quiet-memory-system.detail.md §3.1-§3.4`
12
13
  *
13
14
  * Dependencies:
14
- * - `src/storage/v8-state-stores.js` (write/read DailyRhythmState)
15
+ * - `src/storage/v8-state-stores.js` (write/read DailyRhythmState, readDreamConsolidationRunById, writeDreamConsolidationRun)
15
16
  * - `src/core/second-nature/quiet-dream/quiet-daily-review-builder.js`
16
17
  * - `src/core/second-nature/quiet-dream/dream-scheduler.js`
18
+ * - `src/core/second-nature/quiet-dream/dream-consolidation-runner.js`
17
19
  *
18
20
  * Boundary:
19
- * - Does not run consolidation; only schedules and records lifecycle.
21
+ * - Schedules and records lifecycle; additionally executes stale scheduled runs
22
+ * so `dreamStatus` reaches completed/blocked.
20
23
  * - Does not bypass Dream runner; only records due/completed/blocked.
21
24
  */
22
25
  import type { StateDatabase } from "../../../storage/db/index.js";
@@ -4,30 +4,138 @@
4
4
  * Core logic: Check if today's Quiet review is due (closures exist but no review
5
5
  * yet), schedule/run it, then check Dream status. Records durable states so
6
6
  * loop_status can report exact missing stages even when heartbeat does not
7
- * select a quiet intent.
7
+ * select a quiet intent. Also executes stale Dream consolidation runs that
8
+ * were left at "scheduled" because no runner picked them up.
8
9
  *
9
10
  * Design authority:
10
11
  * - `.anws/v8/04_SYSTEM_DESIGN/dream-quiet-memory-system.md §4`
11
12
  * - `.anws/v8/04_SYSTEM_DESIGN/dream-quiet-memory-system.detail.md §3.1-§3.4`
12
13
  *
13
14
  * Dependencies:
14
- * - `src/storage/v8-state-stores.js` (write/read DailyRhythmState)
15
+ * - `src/storage/v8-state-stores.js` (write/read DailyRhythmState, readDreamConsolidationRunById, writeDreamConsolidationRun)
15
16
  * - `src/core/second-nature/quiet-dream/quiet-daily-review-builder.js`
16
17
  * - `src/core/second-nature/quiet-dream/dream-scheduler.js`
18
+ * - `src/core/second-nature/quiet-dream/dream-consolidation-runner.js`
17
19
  *
18
20
  * Boundary:
19
- * - Does not run consolidation; only schedules and records lifecycle.
21
+ * - Schedules and records lifecycle; additionally executes stale scheduled runs
22
+ * so `dreamStatus` reaches completed/blocked.
20
23
  * - Does not bypass Dream runner; only records due/completed/blocked.
21
24
  */
22
- import { writeDailyRhythmState, readDailyRhythmStateByDay, readActionClosuresByDay, } from "../../../storage/v8-state-stores.js";
25
+ import { writeDailyRhythmState, readDailyRhythmStateByDay, readActionClosuresByDay, readDreamConsolidationRunById, readDreamConsolidationRunsByQuietId, updateDreamConsolidationRunStatus, } from "../../../storage/v8-state-stores.js";
23
26
  import { buildQuietDailyReview } from "./quiet-daily-review-builder.js";
24
27
  import { scheduleDreamAfterQuiet } from "./dream-scheduler.js";
28
+ import { runDreamConsolidation } from "./dream-consolidation-runner.js";
29
+ // ───────────────────────────────────────────────────────────────
30
+ // Config
31
+ // ───────────────────────────────────────────────────────────────
32
+ const DREAM_DEFAULT_INTERVAL_DAYS = 7;
33
+ const STALE_SCHEDULED_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
25
34
  // ───────────────────────────────────────────────────────────────
26
35
  // Helpers
27
36
  // ───────────────────────────────────────────────────────────────
28
37
  function todayString(now) {
29
38
  return now.slice(0, 10);
30
39
  }
40
+ function parsePayloadJson(json) {
41
+ if (!json)
42
+ return {};
43
+ try {
44
+ return JSON.parse(json);
45
+ }
46
+ catch {
47
+ return {};
48
+ }
49
+ }
50
+ function isWithinDays(isoDate, now, days) {
51
+ const then = new Date(isoDate).getTime();
52
+ const current = new Date(now).getTime();
53
+ if (Number.isNaN(then) || Number.isNaN(current))
54
+ return false;
55
+ return current - then < days * 24 * 60 * 60 * 1000;
56
+ }
57
+ function isStaleScheduled(run, now) {
58
+ const created = new Date(run.createdAt).getTime();
59
+ const current = new Date(now).getTime();
60
+ if (Number.isNaN(created) || Number.isNaN(current))
61
+ return false;
62
+ return current - created > STALE_SCHEDULED_THRESHOLD_MS;
63
+ }
64
+ async function loadLatestDreamRunForQuiet(db, quietId) {
65
+ const runsRead = await readDreamConsolidationRunsByQuietId(db, quietId);
66
+ if (runsRead.degraded) {
67
+ return { degraded: runsRead.degraded };
68
+ }
69
+ return { row: runsRead.rows[0] };
70
+ }
71
+ async function executeStaleScheduledDreams(db, state, now) {
72
+ // Look for any scheduled dream runs for today and execute them.
73
+ const quietId = `quiet_${state.day}`;
74
+ const knownRunIds = [];
75
+ // Direct lookup by quiet review id is more reliable than rhythm payload cache.
76
+ const runsRead = await readDreamConsolidationRunsByQuietId(db, quietId);
77
+ if (runsRead.degraded) {
78
+ return runsRead.degraded;
79
+ }
80
+ for (const run of runsRead.rows) {
81
+ knownRunIds.push(run.id);
82
+ }
83
+ // Also read the rhythm payload for any ids that may have been recorded before.
84
+ const rhythmRead = await readDailyRhythmStateByDay(db, state.day);
85
+ if (!rhythmRead.degraded && rhythmRead.row?.payloadJson) {
86
+ const payload = parsePayloadJson(rhythmRead.row.payloadJson);
87
+ if (payload.dreamRunId) {
88
+ knownRunIds.push(String(payload.dreamRunId));
89
+ }
90
+ if (Array.isArray(payload.dreamRunIds)) {
91
+ knownRunIds.push(...payload.dreamRunIds);
92
+ }
93
+ }
94
+ const uniqueRunIds = [...new Set(knownRunIds)];
95
+ if (uniqueRunIds.length === 0) {
96
+ return { completed: false };
97
+ }
98
+ let lastResult = { completed: false };
99
+ for (const runId of uniqueRunIds) {
100
+ const runRead = await readDreamConsolidationRunById(db, runId);
101
+ if (runRead.degraded) {
102
+ return runRead.degraded;
103
+ }
104
+ const run = runRead.row;
105
+ if (!run)
106
+ continue;
107
+ if ((run.status === "scheduled" || run.status === "started") && isStaleScheduled(run, now)) {
108
+ const consolidateResult = await runDreamConsolidation(db, runId, { now });
109
+ if ("status" in consolidateResult && consolidateResult.status !== "degraded") {
110
+ const dreamResult = consolidateResult;
111
+ const finalStatus = dreamResult.status;
112
+ const finalReason = dreamResult.reason ?? undefined;
113
+ const updateResult = await updateDreamConsolidationRunStatus(db, runId, finalStatus, {
114
+ reason: finalReason ?? null,
115
+ lifecycleStatus: finalStatus === "completed" ? "completed" : "archived",
116
+ payloadJson: JSON.stringify({
117
+ ...parsePayloadJson(run.payloadJson),
118
+ consolidatedAt: now,
119
+ candidateCount: dreamResult.candidates.length,
120
+ staleRepairedAt: now,
121
+ }),
122
+ });
123
+ if ("reason" in updateResult) {
124
+ return updateResult;
125
+ }
126
+ lastResult = { completed: true, reason: finalReason ?? "dream_scheduled_stalled" };
127
+ }
128
+ else {
129
+ const degraded = consolidateResult;
130
+ return degraded;
131
+ }
132
+ }
133
+ else if (run.status === "completed") {
134
+ lastResult = { completed: true, reason: run.reason ?? undefined };
135
+ }
136
+ }
137
+ return lastResult;
138
+ }
31
139
  // ───────────────────────────────────────────────────────────────
32
140
  // Public API
33
141
  // ───────────────────────────────────────────────────────────────
@@ -90,33 +198,96 @@ export async function checkDailyRhythm(db, options) {
90
198
  }
91
199
  }
92
200
  }
201
+ // Track scheduled dream run ids across attempts
202
+ const dreamRunIds = [];
93
203
  // Determine Dream status based on Quiet outcome
94
204
  if (state.quietStatus === "completed") {
95
- if (state.dreamStatus === "completed" ||
96
- state.dreamStatus === "scheduled" ||
97
- state.dreamStatus === "blocked") {
205
+ if (state.dreamStatus === "completed") {
206
+ // Already completed; nothing to do
207
+ }
208
+ else if (state.dreamStatus === "scheduled") {
209
+ // Stale scheduled run: try to execute consolidation now
210
+ const staleResult = await executeStaleScheduledDreams(db, state, now);
211
+ if ("status" in staleResult && staleResult.status === "degraded") {
212
+ return staleResult;
213
+ }
214
+ const { completed, reason } = staleResult;
215
+ if (completed) {
216
+ state.dreamStatus = "completed";
217
+ state.dreamReason = reason ?? "dream_completed";
218
+ state.dreamCompletedAt = now;
219
+ }
220
+ else {
221
+ // Still cannot locate/run scheduled dream; remain scheduled
222
+ state.dreamReason = state.dreamReason ?? "dream_scheduled";
223
+ }
224
+ }
225
+ else if (state.dreamStatus === "blocked") {
98
226
  // Already handled; do not re-schedule
99
227
  }
100
228
  else {
101
- state.dreamStatus = "due";
102
- state.dreamReason = "dream_scheduled";
103
- // Schedule Dream
104
229
  const quietId = `quiet_${day}`;
105
- const dreamResult = await scheduleDreamAfterQuiet(db, quietId, {
106
- now,
107
- schedulerAvailable: options?.schedulerAvailable ?? true,
108
- });
109
- if ("reason" in dreamResult) {
110
- state.dreamStatus = "blocked";
111
- state.dreamReason = dreamResult.reason;
230
+ const latestRun = await loadLatestDreamRunForQuiet(db, quietId);
231
+ if (latestRun.degraded) {
232
+ return latestRun.degraded;
112
233
  }
113
- else if (dreamResult.status === "blocked") {
114
- state.dreamStatus = "blocked";
115
- state.dreamReason = dreamResult.reason ?? "dream_scheduler_unavailable";
234
+ // If a completed/blocked run already exists within the 7-day interval, honor it.
235
+ if (latestRun.row &&
236
+ (latestRun.row.status === "completed" || latestRun.row.status === "blocked") &&
237
+ isWithinDays(latestRun.row.createdAt, now, DREAM_DEFAULT_INTERVAL_DAYS)) {
238
+ state.dreamStatus = latestRun.row.status;
239
+ state.dreamReason = latestRun.row.reason ?? "dream_completed";
240
+ if (latestRun.row.status === "completed") {
241
+ state.dreamCompletedAt = now;
242
+ }
116
243
  }
117
244
  else {
118
- state.dreamStatus = "scheduled";
245
+ state.dreamStatus = "due";
119
246
  state.dreamReason = "dream_scheduled";
247
+ // Schedule Dream
248
+ const dreamResult = await scheduleDreamAfterQuiet(db, quietId, {
249
+ now,
250
+ schedulerAvailable: options?.schedulerAvailable ?? true,
251
+ });
252
+ if ("reason" in dreamResult) {
253
+ state.dreamStatus = "blocked";
254
+ state.dreamReason = dreamResult.reason;
255
+ }
256
+ else if (dreamResult.status === "blocked") {
257
+ state.dreamStatus = "blocked";
258
+ state.dreamReason = dreamResult.reason ?? "dream_scheduler_unavailable";
259
+ }
260
+ else {
261
+ state.dreamStatus = "scheduled";
262
+ state.dreamReason = "dream_scheduled";
263
+ dreamRunIds.push(dreamResult.id);
264
+ // Immediately execute the freshly scheduled dream so it does not sit
265
+ // pending forever (T-DQ.R.7).
266
+ const consolidateResult = await runDreamConsolidation(db, dreamResult.id, { now });
267
+ if ("status" in consolidateResult && consolidateResult.status !== "degraded") {
268
+ const dreamOutcome = consolidateResult;
269
+ const updateResult = await updateDreamConsolidationRunStatus(db, dreamResult.id, dreamOutcome.status, {
270
+ reason: dreamOutcome.reason ?? null,
271
+ lifecycleStatus: dreamOutcome.status === "completed" ? "completed" : "archived",
272
+ payloadJson: JSON.stringify({
273
+ consolidatedAt: now,
274
+ candidateCount: dreamOutcome.candidates.length,
275
+ }),
276
+ });
277
+ if ("reason" in updateResult) {
278
+ return updateResult;
279
+ }
280
+ state.dreamStatus = dreamOutcome.status === "completed" ? "completed" : "blocked";
281
+ state.dreamReason = dreamOutcome.reason ?? (dreamOutcome.status === "completed" ? "dream_completed" : "dream_failed");
282
+ if (dreamOutcome.status === "completed") {
283
+ state.dreamCompletedAt = now;
284
+ }
285
+ }
286
+ else {
287
+ const degraded = consolidateResult;
288
+ return degraded;
289
+ }
290
+ }
120
291
  }
121
292
  }
122
293
  }
@@ -134,6 +305,11 @@ export async function checkDailyRhythm(db, options) {
134
305
  state.dreamReason = state.quietReason ?? "dream_blocked_redaction";
135
306
  }
136
307
  // Persist state
308
+ const payload = { checkedAt: now, hasClosures: closuresRead.rows.length };
309
+ if (dreamRunIds.length > 0) {
310
+ payload.dreamRunId = dreamRunIds[0];
311
+ payload.dreamRunIds = dreamRunIds;
312
+ }
137
313
  const writeResult = await writeDailyRhythmState(db, {
138
314
  id: `rhythm_${day}`,
139
315
  day,
@@ -152,7 +328,7 @@ export async function checkDailyRhythm(db, options) {
152
328
  resolveStatus: "resolvable",
153
329
  },
154
330
  ],
155
- payloadJson: JSON.stringify({ checkedAt: now, hasClosures: closuresRead.rows.length }),
331
+ payloadJson: JSON.stringify(payload),
156
332
  updatedAt: now,
157
333
  });
158
334
  if ("reason" in writeResult) {
@@ -1,16 +1,17 @@
1
1
  /**
2
2
  * QuietDailyReviewBuilder — Aggregate daily closures, perceptions, and
3
- * memory-review candidates into a source-backed QuietDailyReview.
3
+ * memory-review candidates into a readable, source-backed QuietDailyReview.
4
4
  *
5
5
  * Core logic: Read ActionClosureRecords by day, collect memory-review
6
- * candidates, build summary, and write QuietDailyReview row.
6
+ * candidates and attached PerceptionCard summaries, build a human-readable
7
+ * review, and write QuietDailyReview row.
7
8
  *
8
9
  * Design authority:
9
10
  * - `.anws/v8/04_SYSTEM_DESIGN/dream-quiet-memory-system.detail.md §3.1`
10
11
  * - `.anws/v8/04_SYSTEM_DESIGN/dream-quiet-memory-system.md §4.2`
11
12
  *
12
13
  * Dependencies:
13
- * - `src/storage/v8-state-stores.js` (readActionClosuresByDay, writeQuietDailyReview)
14
+ * - `src/storage/v8-state-stores.js` (readActionClosuresByDay, readPerceptionCardById, writeQuietDailyReview)
14
15
  * - `src/shared/types/v8-contracts.js` (SourceRef, DegradedOperationResult, V8ReasonCode)
15
16
  *
16
17
  * Boundary:
@@ -22,6 +23,14 @@
22
23
  */
23
24
  import type { StateDatabase } from "../../../storage/db/index.js";
24
25
  import type { SourceRef, DegradedOperationResult, V8ReasonCode } from "../../../shared/types/v8-contracts.js";
26
+ export interface QuietReviewEntry {
27
+ closureId: string;
28
+ platformId?: string;
29
+ actionKind?: string;
30
+ status: string;
31
+ summary?: string;
32
+ reason?: string;
33
+ }
25
34
  export interface QuietDailyReviewResult {
26
35
  id: string;
27
36
  day: string;
@@ -32,9 +41,16 @@ export interface QuietDailyReviewResult {
32
41
  /** Explicit closure refs — first-class provenance for reviewed ActionClosureRecords */
33
42
  closureRefs: SourceRef[];
34
43
  reviewSummary: string;
44
+ /** Human-readable review sections */
45
+ sections: QuietReviewSection[];
35
46
  importanceSignals: string[];
36
47
  createdAt: string;
37
48
  }
49
+ export interface QuietReviewSection {
50
+ kind: "headline" | "completed" | "deferred" | "failed" | "memory_candidates" | "observations";
51
+ title: string;
52
+ lines: string[];
53
+ }
38
54
  export interface BuildQuietDailyReviewOptions {
39
55
  day?: string;
40
56
  now?: string;