@haaaiawd/second-nature 0.1.24 → 0.1.25

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.
@@ -11,6 +11,7 @@ import { queryExplain, } from "../../observability/query/explain-query.js";
11
11
  import { mapOperatorExplainToReadModel } from "./operator-explain-map.js";
12
12
  import { loadOperatorFallbackRow, toOperatorFallbackView, } from "../../storage/fallback/load-operator-fallback.js";
13
13
  import { loadRhythmPolicySnapshot, } from "../../storage/rhythm/rhythm-policy-snapshot.js";
14
+ import { createNarrativeStateStore } from "../../storage/narrative/narrative-state-store.js";
14
15
  const INTERNAL_RUNTIME_PLATFORM_ID = "second-nature-runtime";
15
16
  const INTERNAL_RUNTIME_TRACE_PREFIX = "sn-runtime-";
16
17
  function toExplainQuery(subject) {
@@ -112,6 +113,117 @@ function mapConnectorStatus(attempt) {
112
113
  }
113
114
  return "healthy";
114
115
  }
116
+ /**
117
+ * Derive groundingStatus from confidence and status.
118
+ *
119
+ * Rules (in priority order):
120
+ * 1. blocked: status === "awaiting_sources" OR confidence < 0.4
121
+ * 2. pass: confidence >= 0.7 AND status === "active"
122
+ * 3. degraded: all other cases (0.4 <= confidence < 0.7, or status is insufficient_sources)
123
+ */
124
+ function deriveGroundingStatus(status, confidence) {
125
+ if (status === "awaiting_sources" || confidence < 0.4) {
126
+ return "blocked";
127
+ }
128
+ if (confidence >= 0.7 && status === "active") {
129
+ return "pass";
130
+ }
131
+ return "degraded";
132
+ }
133
+ /**
134
+ * Build the base StatusReadModel that is shared by loadStatus and loadV6Status.
135
+ * Centralising this logic eliminates the DRY violation identified in CR-01.
136
+ */
137
+ async function buildBaseStatus(deps) {
138
+ let recentAttempts = [];
139
+ let recentDecisions = [];
140
+ let credentials = [];
141
+ try {
142
+ recentAttempts = await deps.observabilityDb.db
143
+ .select()
144
+ .from(executionAttempts)
145
+ .orderBy(desc(executionAttempts.startedAt), desc(executionAttempts.finishedAt))
146
+ .limit(50);
147
+ }
148
+ catch {
149
+ recentAttempts = [];
150
+ }
151
+ try {
152
+ recentDecisions = await deps.observabilityDb.db
153
+ .select()
154
+ .from(decisionLedger)
155
+ .orderBy(desc(decisionLedger.createdAt))
156
+ .limit(50);
157
+ }
158
+ catch {
159
+ recentDecisions = [];
160
+ }
161
+ try {
162
+ credentials = await deps.stateDb.db.query.credentialRecords.findMany();
163
+ }
164
+ catch {
165
+ credentials = [];
166
+ }
167
+ const latestRuntimeAttempt = recentAttempts.find((attempt) => attempt.platformId === INTERNAL_RUNTIME_PLATFORM_ID);
168
+ const latestConnectorAttempt = recentAttempts.find((attempt) => attempt.platformId !== INTERNAL_RUNTIME_PLATFORM_ID);
169
+ const latestRuntimeDecision = recentDecisions.find((decision) => decision.traceId.startsWith(INTERNAL_RUNTIME_TRACE_PREFIX));
170
+ const runtimeUpdatedAt = latestRuntimeAttempt?.finishedAt ??
171
+ latestRuntimeAttempt?.startedAt ??
172
+ latestRuntimeDecision?.createdAt ??
173
+ "";
174
+ const quietMode = latestRuntimeDecision?.mode === "quiet" ||
175
+ latestRuntimeDecision?.mode === "maintenance_only" ||
176
+ latestRuntimeDecision?.mode === "paused_for_interrupt"
177
+ ? latestRuntimeDecision.mode
178
+ : "unknown";
179
+ const riskFlags = [
180
+ latestRuntimeAttempt?.failureClass,
181
+ latestConnectorAttempt?.failureClass,
182
+ ].filter((value) => Boolean(value));
183
+ const connectorSummary = latestConnectorAttempt
184
+ ? [
185
+ {
186
+ platformId: latestConnectorAttempt.platformId,
187
+ status: mapConnectorStatus(latestConnectorAttempt),
188
+ channel: latestConnectorAttempt.channel,
189
+ failureClass: latestConnectorAttempt.failureClass ?? undefined,
190
+ },
191
+ ]
192
+ : [];
193
+ return {
194
+ runtime: {
195
+ host: "openclaw-plugin",
196
+ serviceStatus: mapRuntimeStatus(latestRuntimeAttempt),
197
+ updatedAt: runtimeUpdatedAt,
198
+ },
199
+ rhythm: {
200
+ mode: latestRuntimeDecision?.mode ?? "unknown",
201
+ },
202
+ quiet: {
203
+ mode: quietMode,
204
+ lastEvent: latestRuntimeDecision?.traceId,
205
+ interrupted: latestRuntimeDecision?.mode === "paused_for_interrupt"
206
+ ? true
207
+ : undefined,
208
+ },
209
+ connectors: connectorSummary,
210
+ credentials: credentials.map((item) => ({
211
+ platformId: item.platformId ??
212
+ item.platform_id,
213
+ status: item.status,
214
+ nextStep: buildCredentialNextStep(item.status),
215
+ })),
216
+ risk: {
217
+ level: riskFlags.length > 0 ? "medium" : "low",
218
+ flags: riskFlags,
219
+ },
220
+ deliveryPosture: {
221
+ verdict: "none",
222
+ source: "workspace_default_none",
223
+ reasonCode: "delivery_target_none",
224
+ },
225
+ };
226
+ }
115
227
  export function createCliReadModels(deps) {
116
228
  const assetRepository = new AssetRepository(deps.stateDb);
117
229
  const credentialRepository = new CredentialRepository(deps.stateDb);
@@ -124,103 +236,7 @@ export function createCliReadModels(deps) {
124
236
  const auditStore = deps.livedExperienceAuditStore ?? new AppendOnlyAuditStore();
125
237
  return {
126
238
  async loadStatus(_scope) {
127
- let recentAttempts = [];
128
- let recentDecisions = [];
129
- let credentials = [];
130
- try {
131
- recentAttempts = await deps.observabilityDb.db
132
- .select()
133
- .from(executionAttempts)
134
- .orderBy(desc(executionAttempts.startedAt), desc(executionAttempts.finishedAt))
135
- .limit(50);
136
- }
137
- catch {
138
- recentAttempts = [];
139
- }
140
- try {
141
- recentDecisions = await deps.observabilityDb.db
142
- .select()
143
- .from(decisionLedger)
144
- .orderBy(desc(decisionLedger.createdAt))
145
- .limit(50);
146
- }
147
- catch {
148
- recentDecisions = [];
149
- }
150
- try {
151
- credentials = await deps.stateDb.db.query.credentialRecords.findMany();
152
- }
153
- catch {
154
- credentials = [];
155
- }
156
- const latestRuntimeAttempt = recentAttempts.find((attempt) => attempt.platformId === INTERNAL_RUNTIME_PLATFORM_ID);
157
- // CH-15-04 (CH-14-03): latestConnectorAttempt is the most recent execution attempt whose
158
- // platformId is NOT the internal sn-runtime sentinel — i.e. a real connector platform
159
- // (Moltbook, EvoMap, etc.). The `connectors` array in StatusReadModel reflects this single
160
- // most-recent non-runtime attempt, NOT the full connector manifest. An empty array means
161
- // no connector attempt has been recorded yet, not that connectors are misconfigured.
162
- const latestConnectorAttempt = recentAttempts.find((attempt) => attempt.platformId !== INTERNAL_RUNTIME_PLATFORM_ID);
163
- const latestRuntimeDecision = recentDecisions.find((decision) => decision.traceId.startsWith(INTERNAL_RUNTIME_TRACE_PREFIX));
164
- const runtimeUpdatedAt = latestRuntimeAttempt?.finishedAt ??
165
- latestRuntimeAttempt?.startedAt ??
166
- latestRuntimeDecision?.createdAt ??
167
- "";
168
- const quietMode = latestRuntimeDecision?.mode === "quiet" ||
169
- latestRuntimeDecision?.mode === "maintenance_only" ||
170
- latestRuntimeDecision?.mode === "paused_for_interrupt"
171
- ? latestRuntimeDecision.mode
172
- : "unknown";
173
- const riskFlags = [
174
- latestRuntimeAttempt?.failureClass,
175
- latestConnectorAttempt?.failureClass,
176
- ].filter((value) => Boolean(value));
177
- const connectorSummary = latestConnectorAttempt
178
- ? [
179
- {
180
- platformId: latestConnectorAttempt.platformId,
181
- status: mapConnectorStatus(latestConnectorAttempt),
182
- channel: latestConnectorAttempt.channel,
183
- failureClass: latestConnectorAttempt.failureClass ?? undefined,
184
- },
185
- ]
186
- : [];
187
- return {
188
- runtime: {
189
- host: "openclaw-plugin",
190
- serviceStatus: mapRuntimeStatus(latestRuntimeAttempt),
191
- updatedAt: runtimeUpdatedAt,
192
- },
193
- rhythm: {
194
- mode: latestRuntimeDecision?.mode ?? "unknown",
195
- windowId: undefined,
196
- },
197
- quiet: {
198
- mode: quietMode,
199
- lastEvent: latestRuntimeDecision?.traceId,
200
- interrupted: latestRuntimeDecision?.mode === "paused_for_interrupt"
201
- ? true
202
- : undefined,
203
- },
204
- connectors: connectorSummary,
205
- credentials: credentials.map((item) => ({
206
- platformId: item.platformId ??
207
- item.platform_id,
208
- status: item.status,
209
- nextStep: buildCredentialNextStep(item.status),
210
- })),
211
- risk: {
212
- level: riskFlags.length > 0 ? "medium" : "low",
213
- flags: riskFlags,
214
- },
215
- // T1.2.5 (CH-14-04): default delivery posture is workspace_default_none because the
216
- // workspace heartbeat hardcodes `deliveryCapability: { target: "none" }` until a host
217
- // capability probe explicitly sets a valid target.
218
- deliveryPosture: {
219
- verdict: "none",
220
- source: "workspace_default_none",
221
- reasonCode: "delivery_target_none",
222
- },
223
- };
239
+ return buildBaseStatus(deps);
224
240
  },
225
241
  async loadDailyReport(day) {
226
242
  let bundle;
@@ -387,5 +403,220 @@ export function createCliReadModels(deps) {
387
403
  evidenceRefs: bundle.explanation.evidenceRefs,
388
404
  };
389
405
  },
406
+ // T1.2.2 — read recent DreamTrace events from audit store.
407
+ async loadDreamRecent(limit = 5) {
408
+ const events = auditStore.list().filter((e) => e.family === "dream.trace");
409
+ const recent = events
410
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
411
+ .slice(0, limit);
412
+ return {
413
+ runs: recent.map((e) => {
414
+ const p = e.payload;
415
+ return {
416
+ traceId: p.traceId,
417
+ runId: p.runId,
418
+ durationMs: p.durationMs ?? 0,
419
+ inputCounts: p.inputCounts ?? { evidence: 0, chronicle: 0, memoryEntries: 0 },
420
+ fallbackReason: p.fallbackReason,
421
+ lifecycleStatus: p.fallbackReason ? "partial" : "completed",
422
+ insightsCount: 0, // would require deeper payload parsing
423
+ createdAt: e.createdAt,
424
+ };
425
+ }),
426
+ totalRuns: events.length,
427
+ };
428
+ },
429
+ // T1.2.5 — aggregate recent heartbeat, narrative, dream, delivery events into cycles.
430
+ async loadCycleRecent(limit = 5) {
431
+ const events = auditStore.list();
432
+ const decisions = events.filter((e) => e.family === "heartbeat.decision");
433
+ const narratives = events.filter((e) => e.family === "narrative.trace");
434
+ const dreams = events.filter((e) => e.family === "dream.trace");
435
+ const deliveries = events.filter((e) => e.family === "delivery");
436
+ const connectors = events.filter((e) => e.family === "connector.attempt");
437
+ // Group by time buckets (hourly)
438
+ const buckets = new Map();
439
+ for (const e of decisions) {
440
+ const hour = e.createdAt.slice(0, 13);
441
+ const b = buckets.get(hour) ?? { timestamp: `${hour}:00:00Z`, dimensions: [] };
442
+ if (!b.dimensions.includes("decision"))
443
+ b.dimensions.push("decision");
444
+ const p = e.payload;
445
+ if (p.outcome)
446
+ b.decisionOutcome = p.outcome;
447
+ buckets.set(hour, b);
448
+ }
449
+ for (const e of narratives) {
450
+ const hour = e.createdAt.slice(0, 13);
451
+ const b = buckets.get(hour) ?? { timestamp: `${hour}:00:00Z`, dimensions: [] };
452
+ if (!b.dimensions.includes("narrative"))
453
+ b.dimensions.push("narrative");
454
+ const p = e.payload;
455
+ if (p.groundingStatus)
456
+ b.narrativeGrounding = p.groundingStatus;
457
+ buckets.set(hour, b);
458
+ }
459
+ for (const e of dreams) {
460
+ const hour = e.createdAt.slice(0, 13);
461
+ const b = buckets.get(hour) ?? { timestamp: `${hour}:00:00Z`, dimensions: [] };
462
+ if (!b.dimensions.includes("dream"))
463
+ b.dimensions.push("dream");
464
+ const p = e.payload;
465
+ if (p.fallbackReason)
466
+ b.dreamFallback = p.fallbackReason;
467
+ buckets.set(hour, b);
468
+ }
469
+ for (const e of deliveries) {
470
+ const hour = e.createdAt.slice(0, 13);
471
+ const b = buckets.get(hour) ?? { timestamp: `${hour}:00:00Z`, dimensions: [] };
472
+ if (!b.dimensions.includes("delivery"))
473
+ b.dimensions.push("delivery");
474
+ const p = e.payload;
475
+ if (p.status)
476
+ b.deliveryStatus = p.status;
477
+ buckets.set(hour, b);
478
+ }
479
+ for (const e of connectors) {
480
+ const hour = e.createdAt.slice(0, 13);
481
+ const b = buckets.get(hour) ?? { timestamp: `${hour}:00:00Z`, dimensions: [] };
482
+ if (!b.dimensions.includes("connector"))
483
+ b.dimensions.push("connector");
484
+ buckets.set(hour, b);
485
+ }
486
+ const cycles = Array.from(buckets.values())
487
+ .sort((a, b) => b.timestamp.localeCompare(a.timestamp))
488
+ .slice(0, limit);
489
+ return { cycles, totalCycles: buckets.size };
490
+ },
491
+ // T1.2.6 — v6 status aggregate: compose base status + narrative + dream + cycle sections.
492
+ // Each section returns a sentinel status (nothing_yet / has_runs / has_cycles) so operators
493
+ // always get a meaningful non-empty response, never a raw empty object.
494
+ async loadV6Status(scope) {
495
+ // Load NarrativeState asynchronously; audit events are synchronous reads from in-memory store.
496
+ const narrativeStore = createNarrativeStateStore(deps.stateDb);
497
+ let narrativeState;
498
+ try {
499
+ narrativeState = await narrativeStore.loadNarrativeState();
500
+ }
501
+ catch {
502
+ narrativeState = null;
503
+ }
504
+ const allAuditEvents = auditStore.list();
505
+ const dreamSection = allAuditEvents.filter((e) => e.family === "dream.trace");
506
+ const cycleSection = allAuditEvents;
507
+ const baseStatus = await buildBaseStatus(deps);
508
+ // Narrative section
509
+ let narrativeSectionOut;
510
+ if (!narrativeState) {
511
+ narrativeSectionOut = { status: "nothing_yet", focus: "", groundingStatus: "blocked", nextIntent: "", sourceRefCount: 0 };
512
+ }
513
+ else {
514
+ const groundingStatus = deriveGroundingStatus(narrativeState.status, narrativeState.confidence);
515
+ narrativeSectionOut = { status: narrativeState.status, focus: narrativeState.focus, groundingStatus, nextIntent: narrativeState.nextIntent, sourceRefCount: narrativeState.sourceRefs.length };
516
+ }
517
+ // Dream section — degraded when all recorded dream runs have a fallbackReason.
518
+ const dreamEvents = dreamSection;
519
+ let dreamSectionOut;
520
+ if (dreamEvents.length === 0) {
521
+ dreamSectionOut = { status: "nothing_yet", totalRuns: 0, recentRunCount: 0 };
522
+ }
523
+ else {
524
+ const recentDreams = dreamEvents.sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 3);
525
+ const lastFallback = recentDreams.map((e) => e.payload.fallbackReason).find(Boolean);
526
+ const allDegraded = dreamEvents.every((e) => !!e.payload.fallbackReason);
527
+ dreamSectionOut = {
528
+ status: allDegraded ? "degraded" : "has_runs",
529
+ totalRuns: dreamEvents.length,
530
+ recentRunCount: recentDreams.length,
531
+ lastFallbackReason: lastFallback,
532
+ };
533
+ }
534
+ // Cycle section — degraded when buckets exist but cover fewer than 3 dimensions.
535
+ const allEvents = cycleSection;
536
+ const decisionEvents = allEvents.filter((e) => e.family === "heartbeat.decision");
537
+ const narrativeEvents = allEvents.filter((e) => e.family === "narrative.trace");
538
+ const dreamEventsForCycle = allEvents.filter((e) => e.family === "dream.trace");
539
+ const deliveryEvents = allEvents.filter((e) => e.family === "delivery");
540
+ const connectorEvents = allEvents.filter((e) => e.family === "connector.attempt");
541
+ const hourBuckets = new Set();
542
+ const dimensionSet = new Set();
543
+ for (const e of decisionEvents) {
544
+ hourBuckets.add(e.createdAt.slice(0, 13));
545
+ dimensionSet.add("decision");
546
+ }
547
+ for (const e of narrativeEvents) {
548
+ hourBuckets.add(e.createdAt.slice(0, 13));
549
+ dimensionSet.add("narrative");
550
+ }
551
+ for (const e of dreamEventsForCycle) {
552
+ hourBuckets.add(e.createdAt.slice(0, 13));
553
+ dimensionSet.add("dream");
554
+ }
555
+ for (const e of deliveryEvents) {
556
+ hourBuckets.add(e.createdAt.slice(0, 13));
557
+ dimensionSet.add("delivery");
558
+ }
559
+ for (const e of connectorEvents) {
560
+ hourBuckets.add(e.createdAt.slice(0, 13));
561
+ dimensionSet.add("connector");
562
+ }
563
+ let cycleSectionOut;
564
+ if (hourBuckets.size === 0) {
565
+ cycleSectionOut = { status: "nothing_yet", totalCycles: 0, recentCycleCount: 0, dimensions: [] };
566
+ }
567
+ else if (dimensionSet.size < 3) {
568
+ cycleSectionOut = { status: "degraded", totalCycles: hourBuckets.size, recentCycleCount: Math.min(hourBuckets.size, 5), dimensions: Array.from(dimensionSet) };
569
+ }
570
+ else {
571
+ cycleSectionOut = { status: "has_cycles", totalCycles: hourBuckets.size, recentCycleCount: Math.min(hourBuckets.size, 5), dimensions: Array.from(dimensionSet) };
572
+ }
573
+ void scope; // scope param reserved for future scoping — not used in v6 aggregate yet
574
+ return { ...baseStatus, narrative: narrativeSectionOut, dream: dreamSectionOut, cycles: cycleSectionOut };
575
+ },
576
+ // T1.2.1 — read current NarrativeState and map to NarrativeReadModel.
577
+ // Returns `nothing_yet` status when no data exists — honest empty, not an error.
578
+ async loadNarrative(narrativeId) {
579
+ const narrativeStore = createNarrativeStateStore(deps.stateDb);
580
+ let state;
581
+ try {
582
+ state = await narrativeStore.loadNarrativeState(narrativeId);
583
+ }
584
+ catch {
585
+ state = null;
586
+ }
587
+ if (!state) {
588
+ return {
589
+ narrativeId: narrativeId ?? "default",
590
+ revision: 0,
591
+ focus: "",
592
+ progress: [],
593
+ nextIntent: "",
594
+ confidence: 0,
595
+ sourceRefs: [],
596
+ unsupportedClaims: [],
597
+ groundingStatus: "blocked",
598
+ status: "nothing_yet",
599
+ updatedAt: "",
600
+ };
601
+ }
602
+ const groundingStatus = deriveGroundingStatus(state.status, state.confidence);
603
+ return {
604
+ narrativeId: state.narrativeId,
605
+ revision: state.revision,
606
+ focus: state.focus,
607
+ progress: state.progress,
608
+ nextIntent: state.nextIntent,
609
+ confidence: state.confidence,
610
+ sourceRefs: state.sourceRefs.map((r) => ({
611
+ sourceId: r.sourceId,
612
+ kind: r.kind,
613
+ url: r.url,
614
+ })),
615
+ unsupportedClaims: state.unsupportedClaims,
616
+ groundingStatus,
617
+ status: state.status,
618
+ updatedAt: state.updatedAt,
619
+ };
620
+ },
390
621
  };
391
622
  }
@@ -73,6 +73,38 @@ export interface StatusReadModel {
73
73
  */
74
74
  deliveryPosture?: DeliveryPosture;
75
75
  }
76
+ /**
77
+ * T1.2.6 — v6 status aggregate: extends StatusReadModel with narrative, dream recent,
78
+ * cycle recent, and per-section `nothing_yet` / `awaiting_sources` / `degraded` sentinels
79
+ * so operators always get a meaningful view even when individual data sources are empty.
80
+ */
81
+ export interface StatusV6NarrativeSection {
82
+ status: "active" | "insufficient_sources" | "awaiting_sources" | "nothing_yet";
83
+ focus: string;
84
+ groundingStatus: "pass" | "degraded" | "blocked";
85
+ nextIntent: string;
86
+ sourceRefCount: number;
87
+ }
88
+ export interface StatusV6DreamSection {
89
+ status: "has_runs" | "degraded" | "nothing_yet";
90
+ totalRuns: number;
91
+ recentRunCount: number;
92
+ lastFallbackReason?: string;
93
+ }
94
+ export interface StatusV6CycleSection {
95
+ status: "has_cycles" | "degraded" | "nothing_yet";
96
+ totalCycles: number;
97
+ recentCycleCount: number;
98
+ dimensions: string[];
99
+ }
100
+ export interface StatusV6ReadModel extends StatusReadModel {
101
+ /** v6 narrative section; status is nothing_yet when no NarrativeState row exists. */
102
+ narrative: StatusV6NarrativeSection;
103
+ /** v6 dream recent section; status is nothing_yet when no DreamTrace events exist. */
104
+ dream: StatusV6DreamSection;
105
+ /** v6 cycle recent section; status is nothing_yet when no cycle events exist. */
106
+ cycles: StatusV6CycleSection;
107
+ }
76
108
  export interface DailyReportReadModel {
77
109
  day: string;
78
110
  summary: string;
@@ -127,3 +159,51 @@ export interface AuditSummaryReadModel {
127
159
  totalEvents: number;
128
160
  events: AuditEventSummaryEntry[];
129
161
  }
162
+ /** T1.2.2 — recent Dream run summary for operator `dream:recent` command. */
163
+ export interface DreamRecentReadModel {
164
+ runs: Array<{
165
+ traceId: string;
166
+ runId: string;
167
+ durationMs: number;
168
+ inputCounts: {
169
+ evidence: number;
170
+ chronicle: number;
171
+ memoryEntries: number;
172
+ };
173
+ fallbackReason?: string;
174
+ lifecycleStatus: string;
175
+ insightsCount: number;
176
+ createdAt: string;
177
+ }>;
178
+ totalRuns: number;
179
+ }
180
+ /** T1.2.1 — NarrativeState read model for operator `narrative` command. */
181
+ export interface NarrativeReadModel {
182
+ narrativeId: string;
183
+ revision: number;
184
+ focus: string;
185
+ progress: string[];
186
+ nextIntent: string;
187
+ confidence: number;
188
+ sourceRefs: Array<{
189
+ sourceId: string;
190
+ kind: string;
191
+ url?: string;
192
+ }>;
193
+ unsupportedClaims: string[];
194
+ groundingStatus: "pass" | "degraded" | "blocked";
195
+ status: "active" | "insufficient_sources" | "awaiting_sources" | "nothing_yet";
196
+ updatedAt: string;
197
+ }
198
+ /** T1.2.5 — recent cycle summary aggregating heartbeat, narrative, dream, delivery. */
199
+ export interface CycleRecentReadModel {
200
+ cycles: Array<{
201
+ timestamp: string;
202
+ dimensions: Array<"decision" | "narrative" | "dream" | "delivery" | "connector">;
203
+ decisionOutcome?: string;
204
+ narrativeGrounding?: string;
205
+ dreamFallback?: string;
206
+ deliveryStatus?: string;
207
+ }>;
208
+ totalCycles: number;
209
+ }
@@ -21,6 +21,7 @@ import type { StateDatabase } from "../../../storage/db/index.js";
21
21
  import { type OpenClawDeliveryPort } from "../outreach/dispatch-user-outreach.js";
22
22
  import type { ConnectorExecutor } from "../../../connectors/base/contract.js";
23
23
  import type { NarrativeStateStore } from "../../../storage/narrative/narrative-state-store.js";
24
+ import type { NarrativeTracePayload } from "../../../observability/services/lived-experience-audit.js";
24
25
  export interface HeartbeatDecisionTracePayload {
25
26
  scope: RuntimeScope;
26
27
  status: HeartbeatCycleStatus;
@@ -61,6 +62,8 @@ export interface HeartbeatDeps {
61
62
  connectorExecutor?: ConnectorExecutor;
62
63
  /** T2.1.5: when present, heartbeat writes a source-backed NarrativeState revision after each cycle. */
63
64
  narrativeStateStore?: NarrativeStateStore;
65
+ /** T5.1.2: when present, heartbeat records a NarrativeTrace after successful narrative state update. */
66
+ recordNarrativeTrace?: (payload: NarrativeTracePayload) => Promise<void>;
64
67
  }
65
68
  /**
66
69
  * Ingest a heartbeat rhythm signal and drive one full decision round.
@@ -91,7 +91,7 @@ export async function resolveAllowedIntentResult(intent, runtime, inputs, signal
91
91
  * is never blocked by a store failure. Store failures are optionally traced
92
92
  * via recordDecisionTrace so operators can monitor store health.
93
93
  */
94
- async function maybeUpdateNarrativeState(result, selectedIntent, runtime, store, recordTrace, signal) {
94
+ async function maybeUpdateNarrativeState(result, selectedIntent, runtime, store, recordTrace, signal, recordNarrativeTrace) {
95
95
  if (!store)
96
96
  return;
97
97
  try {
@@ -103,6 +103,33 @@ async function maybeUpdateNarrativeState(result, selectedIntent, runtime, store,
103
103
  priorNarrative: prior,
104
104
  });
105
105
  await store.updateNarrativeState(update);
106
+ // T5.1.2: record NarrativeTrace on successful state update
107
+ if (recordNarrativeTrace) {
108
+ try {
109
+ await recordNarrativeTrace({
110
+ traceId: `narrative_trace:${crypto.randomUUID()}`,
111
+ narrativeId: update.narrativeId,
112
+ revision: update.revision,
113
+ updateSource: "heartbeat",
114
+ sourceRefs: update.sourceRefs.map((r) => ({
115
+ id: r.sourceId,
116
+ kind: r.kind,
117
+ uri: r.url,
118
+ })),
119
+ unsupportedClaims: update.unsupportedClaims,
120
+ groundingStatus: update.unsupportedClaims.length > 0
121
+ ? "degraded"
122
+ : update.status === "insufficient_sources"
123
+ ? "blocked"
124
+ : "pass",
125
+ goalInfluenceRefs: selectedIntent?.goalInfluenceRefs ?? [],
126
+ createdAt: update.updatedAt,
127
+ });
128
+ }
129
+ catch {
130
+ // trace emission must not block the cycle
131
+ }
132
+ }
106
133
  }
107
134
  catch {
108
135
  // degrade silently; narrative update is best-effort
@@ -178,7 +205,7 @@ export async function ingestRhythmSignal(signal, deps) {
178
205
  ? { ...resolved, reasons: evaluation.reasons }
179
206
  : resolved;
180
207
  await emitTrace(result);
181
- await maybeUpdateNarrativeState(result, intent, runtime, deps.narrativeStateStore, deps.recordDecisionTrace, signal);
208
+ await maybeUpdateNarrativeState(result, intent, runtime, deps.narrativeStateStore, deps.recordDecisionTrace, signal, deps.recordNarrativeTrace);
182
209
  return result;
183
210
  }
184
211
  if (evaluation.verdict === "defer") {
@@ -196,7 +223,7 @@ export async function ingestRhythmSignal(signal, deps) {
196
223
  reasons: ["silent_no_candidates"],
197
224
  };
198
225
  await emitTrace(result);
199
- await maybeUpdateNarrativeState(result, undefined, runtime, deps.narrativeStateStore, deps.recordDecisionTrace, signal);
226
+ await maybeUpdateNarrativeState(result, undefined, runtime, deps.narrativeStateStore, deps.recordDecisionTrace, signal, deps.recordNarrativeTrace);
200
227
  return result;
201
228
  }
202
229
  if (!anyAllow && anyDefer && !anyDeny) {
@@ -206,7 +233,7 @@ export async function ingestRhythmSignal(signal, deps) {
206
233
  reasons: denyReasons.length > 0 ? denyReasons : ["all_candidates_deferred"],
207
234
  };
208
235
  await emitTrace(result);
209
- await maybeUpdateNarrativeState(result, undefined, runtime, deps.narrativeStateStore, deps.recordDecisionTrace, signal);
236
+ await maybeUpdateNarrativeState(result, undefined, runtime, deps.narrativeStateStore, deps.recordDecisionTrace, signal, deps.recordNarrativeTrace);
210
237
  return result;
211
238
  }
212
239
  if (!anyAllow && denyReasons.length > 0) {
@@ -216,7 +243,7 @@ export async function ingestRhythmSignal(signal, deps) {
216
243
  reasons: denyReasons,
217
244
  };
218
245
  await emitTrace(result);
219
- await maybeUpdateNarrativeState(result, undefined, runtime, deps.narrativeStateStore, deps.recordDecisionTrace, signal);
246
+ await maybeUpdateNarrativeState(result, undefined, runtime, deps.narrativeStateStore, deps.recordDecisionTrace, signal, deps.recordNarrativeTrace);
220
247
  return result;
221
248
  }
222
249
  const result = {
@@ -225,7 +252,7 @@ export async function ingestRhythmSignal(signal, deps) {
225
252
  reasons: ["no_allow_verdict"],
226
253
  };
227
254
  await emitTrace(result);
228
- await maybeUpdateNarrativeState(result, undefined, runtime, deps.narrativeStateStore, deps.recordDecisionTrace, signal);
255
+ await maybeUpdateNarrativeState(result, undefined, runtime, deps.narrativeStateStore, deps.recordDecisionTrace, signal, deps.recordNarrativeTrace);
229
256
  return result;
230
257
  }
231
258
  /**
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * `applyGoalPriority` adjusts candidate intent priorities based on accepted AgentGoals.
5
5
  * Priority order: user_task > accepted_goal > rhythm.
6
- * Only goals with status === "accepted" and origin !== "agent_proposed" are considered.
6
+ * Only goals with status === "accepted" are considered.
7
+ * Agent-proposed goals are included ONLY if policy-accepted (acceptedBy === "policy_allowlist").
7
8
  * All other statuses (proposal / rejected / completed / paused) are implicitly excluded.
8
9
  */
9
10
  import type { CandidateIntent } from "../types.js";