@haaaiawd/second-nature 0.2.2 → 0.2.5

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.
Files changed (53) hide show
  1. package/openclaw.plugin.json +1 -1
  2. package/package.json +1 -1
  3. package/runtime/cli/ops/heartbeat-surface.d.ts +20 -0
  4. package/runtime/cli/ops/heartbeat-surface.js +72 -1
  5. package/runtime/cli/ops/ops-router.js +119 -31
  6. package/runtime/connectors/base/contract.d.ts +11 -0
  7. package/runtime/connectors/base/failure-taxonomy.js +45 -26
  8. package/runtime/connectors/base/policy-bound-write-dispatch.d.ts +29 -0
  9. package/runtime/connectors/base/policy-bound-write-dispatch.js +127 -0
  10. package/runtime/connectors/services/connector-cooldown-port.d.ts +22 -0
  11. package/runtime/connectors/services/connector-cooldown-port.js +123 -0
  12. package/runtime/connectors/services/connector-executor-adapter.js +10 -4
  13. package/runtime/connectors/services/credential-route-context.d.ts +3 -2
  14. package/runtime/connectors/services/credential-route-context.js +19 -3
  15. package/runtime/core/second-nature/action/action-closure-recorder.d.ts +4 -0
  16. package/runtime/core/second-nature/action/action-closure-recorder.js +5 -0
  17. package/runtime/core/second-nature/action/action-proposal-builder.js +1 -0
  18. package/runtime/core/second-nature/control-plane/heartbeat-orchestrator.d.ts +2 -0
  19. package/runtime/core/second-nature/control-plane/heartbeat-orchestrator.js +412 -25
  20. package/runtime/core/second-nature/control-plane/real-runtime-spine.d.ts +35 -0
  21. package/runtime/core/second-nature/control-plane/real-runtime-spine.js +42 -0
  22. package/runtime/core/second-nature/guidance/impulse-context-reader.d.ts +44 -0
  23. package/runtime/core/second-nature/guidance/impulse-context-reader.js +84 -0
  24. package/runtime/core/second-nature/guidance/impulse-context-writer.d.ts +39 -0
  25. package/runtime/core/second-nature/guidance/impulse-context-writer.js +70 -0
  26. package/runtime/core/second-nature/perception/judgment-engine.d.ts +2 -0
  27. package/runtime/core/second-nature/perception/judgment-engine.js +11 -1
  28. package/runtime/core/second-nature/perception/perception-builder.d.ts +6 -2
  29. package/runtime/core/second-nature/perception/perception-builder.js +18 -7
  30. package/runtime/core/second-nature/quiet-dream/daily-rhythm-scheduler.d.ts +43 -0
  31. package/runtime/core/second-nature/quiet-dream/daily-rhythm-scheduler.js +162 -0
  32. package/runtime/core/second-nature/quiet-dream/memory-projection-lifecycle.d.ts +2 -2
  33. package/runtime/core/second-nature/quiet-dream/memory-projection-lifecycle.js +27 -44
  34. package/runtime/core/second-nature/quiet-dream/quiet-daily-review-builder.d.ts +3 -0
  35. package/runtime/core/second-nature/quiet-dream/quiet-daily-review-builder.js +4 -0
  36. package/runtime/observability/living-loop-health-gate.d.ts +49 -0
  37. package/runtime/observability/living-loop-health-gate.js +141 -0
  38. package/runtime/observability/loop-status.d.ts +30 -0
  39. package/runtime/observability/loop-status.js +167 -7
  40. package/runtime/observability/services/heartbeat-digest-assembler.d.ts +21 -0
  41. package/runtime/observability/services/heartbeat-digest-assembler.js +44 -0
  42. package/runtime/shared/types/v8-contracts.d.ts +2 -2
  43. package/runtime/storage/db/index.js +60 -6
  44. package/runtime/storage/db/migrations/index.js +4 -0
  45. package/runtime/storage/db/migrations/v8-001-living-perception-loop.js +119 -119
  46. package/runtime/storage/db/migrations/v8-002-perception-contract-alignment.d.ts +12 -0
  47. package/runtime/storage/db/migrations/v8-002-perception-contract-alignment.js +14 -0
  48. package/runtime/storage/db/migrations/v8-003-quiet-closure-refs.d.ts +10 -0
  49. package/runtime/storage/db/migrations/v8-003-quiet-closure-refs.js +12 -0
  50. package/runtime/storage/db/schema/v8-entities.d.ts +874 -0
  51. package/runtime/storage/db/schema/v8-entities.js +62 -1
  52. package/runtime/storage/v8-state-stores.d.ts +41 -2
  53. package/runtime/storage/v8-state-stores.js +206 -2
@@ -0,0 +1,84 @@
1
+ /**
2
+ * ImpulseContextReader — Read agent-facing impulse context artifact from state.
3
+ *
4
+ * Core logic: Retrieve the latest persisted artifact for a given scene/capability
5
+ * combo, with freshness diagnostics and explicit missing-artifact reasons.
6
+ *
7
+ * Design authority:
8
+ * - `.anws/v8/04_SYSTEM_DESIGN/guidance-voice-system.md §1`
9
+ * - `docs/validation/openclaw-plugin-classification.md §5`
10
+ *
11
+ * Dependencies:
12
+ * - `src/storage/v8-state-stores.js` (readImpulseContextArtifact)
13
+ *
14
+ * Boundary:
15
+ * - Does NOT fall back to real-time assembly; returns missing reason when absent.
16
+ * - Does NOT register a fake OpenClaw context-engine.
17
+ */
18
+ import { readImpulseContextArtifact } from "../../../storage/v8-state-stores.js";
19
+ // ───────────────────────────────────────────────────────────────
20
+ // Helpers
21
+ // ───────────────────────────────────────────────────────────────
22
+ function parseConstraints(json) {
23
+ if (!json)
24
+ return [];
25
+ try {
26
+ const parsed = JSON.parse(json);
27
+ return Array.isArray(parsed) ? parsed : [];
28
+ }
29
+ catch {
30
+ return [];
31
+ }
32
+ }
33
+ // ───────────────────────────────────────────────────────────────
34
+ // Public API
35
+ // ───────────────────────────────────────────────────────────────
36
+ export async function readImpulseContext(db, sceneType, capabilityIntent, platformId) {
37
+ const result = await readImpulseContextArtifact(db, sceneType, capabilityIntent, platformId);
38
+ if (result.degraded) {
39
+ return {
40
+ available: false,
41
+ reason: "state_unreadable",
42
+ operatorNextAction: "Check state database connectivity",
43
+ };
44
+ }
45
+ const row = result.row;
46
+ if (!row) {
47
+ return {
48
+ available: false,
49
+ reason: "artifact_not_persisted",
50
+ operatorNextAction: `Run guidance_payload for scene=${sceneType} cap=${capabilityIntent ?? "any"} to generate artifact`,
51
+ };
52
+ }
53
+ const now = Date.now();
54
+ const updatedAt = new Date(row.updatedAt).getTime();
55
+ const freshnessMs = now - updatedAt;
56
+ // Expire artifacts older than 24 hours
57
+ const ONE_DAY_MS = 24 * 60 * 60 * 1000;
58
+ if (freshnessMs > ONE_DAY_MS) {
59
+ return {
60
+ available: false,
61
+ reason: "artifact_expired",
62
+ operatorNextAction: `Re-run guidance_payload for scene=${sceneType} — artifact is stale (${Math.round(freshnessMs / 3600000)}h old)`,
63
+ };
64
+ }
65
+ return {
66
+ available: true,
67
+ artifact: {
68
+ id: row.id,
69
+ sceneType: row.sceneType,
70
+ capabilityIntent: row.capabilityIntent,
71
+ platformId: row.platformId,
72
+ capabilityClass: row.capabilityClass,
73
+ impulseSource: row.impulseSource,
74
+ impulseText: row.impulseText,
75
+ atmosphereText: row.atmosphereText,
76
+ expressionBoundaryConstraints: parseConstraints(row.expressionBoundaryConstraintsJson),
77
+ expressionBoundaryStyle: row.expressionBoundaryStyle,
78
+ freshnessVersion: row.freshnessVersion,
79
+ createdAt: row.createdAt,
80
+ updatedAt: row.updatedAt,
81
+ },
82
+ freshnessMs,
83
+ };
84
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * ImpulseContextWriter — Persist assembled impulse + atmosphere as agent-facing artifact.
3
+ *
4
+ * Core logic: Convert impulse assembly result into a durable artifact that can be
5
+ * read during setup, heartbeat, and platform-scene entry without re-assembly.
6
+ *
7
+ * Design authority:
8
+ * - `.anws/v8/04_SYSTEM_DESIGN/guidance-voice-system.md §1`
9
+ * - `docs/validation/openclaw-plugin-classification.md §5`
10
+ *
11
+ * Dependencies:
12
+ * - `src/storage/v8-state-stores.js` (writeImpulseContextArtifact)
13
+ * - `src/guidance/impulse-assembler.js` (ImpulseAssemblerResult)
14
+ *
15
+ * Boundary:
16
+ * - Does NOT register a fake OpenClaw context-engine.
17
+ * - Does NOT claim delivery or decision capability.
18
+ * - Overwrites existing artifact for same scene/capability combo (upsert behavior).
19
+ */
20
+ import type { StateDatabase } from "../../../storage/db/index.js";
21
+ import type { ImpulseAssemblerResult } from "../../../guidance/impulse-assembler.js";
22
+ import type { SourceRef, DegradedOperationResult } from "../../../shared/types/v8-contracts.js";
23
+ export interface ImpulseContextArtifactInput {
24
+ sceneType: string;
25
+ capabilityIntent?: string;
26
+ platformId?: string;
27
+ impulseResult: ImpulseAssemblerResult;
28
+ atmosphereText?: string;
29
+ expressionBoundaryConstraints: string[];
30
+ expressionBoundaryStyle?: string;
31
+ }
32
+ export interface WriteArtifactResult {
33
+ id: string;
34
+ freshnessVersion: number;
35
+ }
36
+ export declare function writeImpulseContext(db: StateDatabase, input: ImpulseContextArtifactInput, options?: {
37
+ now?: string;
38
+ sourceRefs?: SourceRef[];
39
+ }): Promise<WriteArtifactResult | DegradedOperationResult>;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * ImpulseContextWriter — Persist assembled impulse + atmosphere as agent-facing artifact.
3
+ *
4
+ * Core logic: Convert impulse assembly result into a durable artifact that can be
5
+ * read during setup, heartbeat, and platform-scene entry without re-assembly.
6
+ *
7
+ * Design authority:
8
+ * - `.anws/v8/04_SYSTEM_DESIGN/guidance-voice-system.md §1`
9
+ * - `docs/validation/openclaw-plugin-classification.md §5`
10
+ *
11
+ * Dependencies:
12
+ * - `src/storage/v8-state-stores.js` (writeImpulseContextArtifact)
13
+ * - `src/guidance/impulse-assembler.js` (ImpulseAssemblerResult)
14
+ *
15
+ * Boundary:
16
+ * - Does NOT register a fake OpenClaw context-engine.
17
+ * - Does NOT claim delivery or decision capability.
18
+ * - Overwrites existing artifact for same scene/capability combo (upsert behavior).
19
+ */
20
+ import { writeImpulseContextArtifact } from "../../../storage/v8-state-stores.js";
21
+ // ───────────────────────────────────────────────────────────────
22
+ // Helpers
23
+ // ───────────────────────────────────────────────────────────────
24
+ function buildArtifactId(sceneType, capabilityIntent, platformId) {
25
+ const cap = capabilityIntent ?? "none";
26
+ const plat = platformId ?? "generic";
27
+ return `ica_${sceneType}_${cap}_${plat}`;
28
+ }
29
+ // ───────────────────────────────────────────────────────────────
30
+ // Public API
31
+ // ───────────────────────────────────────────────────────────────
32
+ export async function writeImpulseContext(db, input, options) {
33
+ const now = options?.now ?? new Date().toISOString();
34
+ const id = buildArtifactId(input.sceneType, input.capabilityIntent, input.platformId);
35
+ const sourceRefs = options?.sourceRefs ?? [
36
+ {
37
+ uri: `sn://impulse-context/${id}`,
38
+ family: "projection",
39
+ id,
40
+ redactionClass: "none",
41
+ resolveStatus: "resolvable",
42
+ },
43
+ ];
44
+ const result = await writeImpulseContextArtifact(db, {
45
+ id,
46
+ createdAt: now,
47
+ updatedAt: now,
48
+ sceneType: input.sceneType,
49
+ capabilityIntent: input.capabilityIntent ?? null,
50
+ platformId: input.platformId ?? null,
51
+ capabilityClass: input.impulseResult.capabilityClass ?? null,
52
+ impulseSource: input.impulseResult.source,
53
+ impulseText: input.impulseResult.impulse?.text ?? null,
54
+ atmosphereText: input.atmosphereText ?? null,
55
+ expressionBoundaryConstraintsJson: JSON.stringify(input.expressionBoundaryConstraints),
56
+ expressionBoundaryStyle: input.expressionBoundaryStyle ?? null,
57
+ freshnessVersion: 1,
58
+ sourceRefs,
59
+ redactionClass: "none",
60
+ payloadJson: JSON.stringify({
61
+ impulseKind: input.impulseResult.impulse?.kind ?? null,
62
+ impulseReviewStatus: input.impulseResult.impulse?.reviewStatus ?? null,
63
+ }),
64
+ lifecycleStatus: "active",
65
+ });
66
+ if ("reason" in result) {
67
+ return result;
68
+ }
69
+ return { id, freshnessVersion: 1 };
70
+ }
@@ -23,6 +23,7 @@
23
23
  */
24
24
  import type { StateDatabase } from "../../../storage/db/index.js";
25
25
  import type { SourceRef, DegradedOperationResult, PlatformNeutralActionKind, V8ReasonCode } from "../../../shared/types/v8-contracts.js";
26
+ import type { AcceptedProjection } from "../control-plane/accepted-projection-loader.js";
26
27
  export interface JudgmentVerdictResult {
27
28
  id: string;
28
29
  cycleId: string;
@@ -41,6 +42,7 @@ export interface RunAgentJudgmentResult {
41
42
  }
42
43
  export interface RunAgentJudgmentOptions {
43
44
  now?: string;
45
+ acceptedProjections?: AcceptedProjection[];
44
46
  }
45
47
  export declare function runAgentJudgment(db: StateDatabase, perceptionCardId: string, options?: RunAgentJudgmentOptions): Promise<RunAgentJudgmentResult | DegradedOperationResult>;
46
48
  export interface BatchJudgmentResult {
@@ -153,7 +153,17 @@ export async function runAgentJudgment(db, perceptionCardId, options) {
153
153
  /* ignore */
154
154
  }
155
155
  }
156
- const { actionKind, reason, finalConfidence } = selectVerdict(card.relevance ?? 0.3, card.confidence ?? 0.6, riskPosture, hasSourceRefs, possibleIntents);
156
+ let { actionKind, reason, finalConfidence } = selectVerdict(card.relevance ?? 0.3, card.confidence ?? 0.6, riskPosture, hasSourceRefs, possibleIntents);
157
+ // T-DQ.R.3: Boost verdict when accepted memory projection matches topic
158
+ const acceptedProjections = options?.acceptedProjections ?? [];
159
+ const matchingProjection = acceptedProjections.find((p) => p.topicKey.toLowerCase() === (card.topic ?? "").toLowerCase());
160
+ if (matchingProjection) {
161
+ finalConfidence = Math.min(0.95, finalConfidence + 0.1);
162
+ if (actionKind === "ignore") {
163
+ actionKind = "remember";
164
+ reason = "projection_topic_matched";
165
+ }
166
+ }
157
167
  const verdict = {
158
168
  id: `jud_${perceptionCardId}_${now.replace(/[:.]/g, "")}`,
159
169
  cycleId: card.cycleId,
@@ -37,8 +37,12 @@ export interface PerceptionCardResult {
37
37
  cycleId: string;
38
38
  topic: string;
39
39
  entities: string[];
40
- novelty: "new" | "recurring" | "update";
41
- relevance: number;
40
+ /** Canonical novelty class: new | changed | duplicate | stale */
41
+ noveltyClass: "new" | "changed" | "duplicate" | "stale";
42
+ /** Numeric relevance score in [0, 1] */
43
+ relevanceScore: number;
44
+ /** Derived relevance class: low | medium | high */
45
+ relevanceClass: "low" | "medium" | "high";
42
46
  summary: string;
43
47
  possibleIntents: PlatformNeutralActionKind[];
44
48
  reviewPriority: "low" | "medium" | "high";
@@ -76,12 +76,13 @@ function extractEntities(evidence) {
76
76
  }
77
77
  return [...new Set(entities)];
78
78
  }
79
- function inferNovelty(evidence) {
79
+ function inferNoveltyClass(evidence) {
80
+ // Canonical novelty classification
80
81
  if (evidence.sensitivityHint === "public_technical")
81
- return "recurring";
82
+ return "changed";
82
83
  return "new";
83
84
  }
84
- function inferRelevance(evidence) {
85
+ function inferRelevanceScore(evidence) {
85
86
  if (evidence.sensitivityHint === "sensitive")
86
87
  return 0.9;
87
88
  if (evidence.sensitivityHint === "public_technical")
@@ -90,6 +91,13 @@ function inferRelevance(evidence) {
90
91
  return 0.5;
91
92
  return 0.3;
92
93
  }
94
+ function inferRelevanceClass(score) {
95
+ if (score >= 0.7)
96
+ return "high";
97
+ if (score >= 0.4)
98
+ return "medium";
99
+ return "low";
100
+ }
93
101
  function inferSummary(evidence) {
94
102
  const platform = evidence.platformId;
95
103
  const topic = extractTopic(evidence);
@@ -124,13 +132,15 @@ function inferRiskFlags(evidence) {
124
132
  }
125
133
  function buildCardFromEvidence(evidence, cycleId, now) {
126
134
  const sourceRefs = parseSourceRefs(evidence.sourceRefsJson);
135
+ const relevanceScore = inferRelevanceScore(evidence);
127
136
  return {
128
137
  id: `per_${evidence.id}`,
129
138
  cycleId,
130
139
  topic: extractTopic(evidence),
131
140
  entities: extractEntities(evidence),
132
- novelty: inferNovelty(evidence),
133
- relevance: inferRelevance(evidence),
141
+ noveltyClass: inferNoveltyClass(evidence),
142
+ relevanceScore,
143
+ relevanceClass: inferRelevanceClass(relevanceScore),
134
144
  summary: inferSummary(evidence),
135
145
  possibleIntents: inferPossibleIntents(evidence),
136
146
  reviewPriority: inferReviewPriority(evidence),
@@ -177,8 +187,9 @@ export async function buildPerceptionCards(db, options) {
177
187
  cycleId: card.cycleId,
178
188
  topic: card.topic,
179
189
  entitiesJson: JSON.stringify(card.entities),
180
- novelty: card.novelty,
181
- relevance: card.relevance,
190
+ novelty: card.noveltyClass,
191
+ relevance: card.relevanceScore,
192
+ relevanceClass: card.relevanceClass,
182
193
  summary: card.summary,
183
194
  riskFlagsJson: JSON.stringify(card.riskFlags),
184
195
  confidence: card.confidence,
@@ -0,0 +1,43 @@
1
+ /**
2
+ * DailyRhythmScheduler — Independent Quiet/Dream cadence with absence reasons.
3
+ *
4
+ * Core logic: Check if today's Quiet review is due (closures exist but no review
5
+ * yet), schedule/run it, then check Dream status. Records durable states so
6
+ * loop_status can report exact missing stages even when heartbeat does not
7
+ * select a quiet intent.
8
+ *
9
+ * Design authority:
10
+ * - `.anws/v8/04_SYSTEM_DESIGN/dream-quiet-memory-system.md §4`
11
+ * - `.anws/v8/04_SYSTEM_DESIGN/dream-quiet-memory-system.detail.md §3.1-§3.4`
12
+ *
13
+ * Dependencies:
14
+ * - `src/storage/v8-state-stores.js` (write/read DailyRhythmState)
15
+ * - `src/core/second-nature/quiet-dream/quiet-daily-review-builder.js`
16
+ * - `src/core/second-nature/quiet-dream/dream-scheduler.js`
17
+ *
18
+ * Boundary:
19
+ * - Does not run consolidation; only schedules and records lifecycle.
20
+ * - Does not bypass Dream runner; only records due/completed/blocked.
21
+ */
22
+ import type { StateDatabase } from "../../../storage/db/index.js";
23
+ import type { DegradedOperationResult, V8ReasonCode } from "../../../shared/types/v8-contracts.js";
24
+ export type RhythmStatus = "due" | "completed" | "scheduled" | "skipped" | "blocked" | "not_due";
25
+ export interface DailyRhythmState {
26
+ day: string;
27
+ quietStatus: RhythmStatus;
28
+ dreamStatus: RhythmStatus;
29
+ quietReason?: V8ReasonCode;
30
+ dreamReason?: V8ReasonCode;
31
+ quietCompletedAt?: string;
32
+ dreamCompletedAt?: string;
33
+ }
34
+ export interface CheckDailyRhythmOptions {
35
+ now?: string;
36
+ forceQuiet?: boolean;
37
+ schedulerAvailable?: boolean;
38
+ }
39
+ export type CheckDailyRhythmResult = {
40
+ status: "checked";
41
+ state: DailyRhythmState;
42
+ } | DegradedOperationResult;
43
+ export declare function checkDailyRhythm(db: StateDatabase, options?: CheckDailyRhythmOptions): Promise<CheckDailyRhythmResult>;
@@ -0,0 +1,162 @@
1
+ /**
2
+ * DailyRhythmScheduler — Independent Quiet/Dream cadence with absence reasons.
3
+ *
4
+ * Core logic: Check if today's Quiet review is due (closures exist but no review
5
+ * yet), schedule/run it, then check Dream status. Records durable states so
6
+ * loop_status can report exact missing stages even when heartbeat does not
7
+ * select a quiet intent.
8
+ *
9
+ * Design authority:
10
+ * - `.anws/v8/04_SYSTEM_DESIGN/dream-quiet-memory-system.md §4`
11
+ * - `.anws/v8/04_SYSTEM_DESIGN/dream-quiet-memory-system.detail.md §3.1-§3.4`
12
+ *
13
+ * Dependencies:
14
+ * - `src/storage/v8-state-stores.js` (write/read DailyRhythmState)
15
+ * - `src/core/second-nature/quiet-dream/quiet-daily-review-builder.js`
16
+ * - `src/core/second-nature/quiet-dream/dream-scheduler.js`
17
+ *
18
+ * Boundary:
19
+ * - Does not run consolidation; only schedules and records lifecycle.
20
+ * - Does not bypass Dream runner; only records due/completed/blocked.
21
+ */
22
+ import { writeDailyRhythmState, readDailyRhythmStateByDay, readActionClosuresByDay, } from "../../../storage/v8-state-stores.js";
23
+ import { buildQuietDailyReview } from "./quiet-daily-review-builder.js";
24
+ import { scheduleDreamAfterQuiet } from "./dream-scheduler.js";
25
+ // ───────────────────────────────────────────────────────────────
26
+ // Helpers
27
+ // ───────────────────────────────────────────────────────────────
28
+ function todayString(now) {
29
+ return now.slice(0, 10);
30
+ }
31
+ // ───────────────────────────────────────────────────────────────
32
+ // Public API
33
+ // ───────────────────────────────────────────────────────────────
34
+ export async function checkDailyRhythm(db, options) {
35
+ const now = options?.now ?? new Date().toISOString();
36
+ const day = todayString(now);
37
+ // Read existing rhythm state
38
+ const existing = await readDailyRhythmStateByDay(db, day);
39
+ if (existing.degraded) {
40
+ return existing.degraded;
41
+ }
42
+ const state = existing.row
43
+ ? {
44
+ day: existing.row.day,
45
+ quietStatus: existing.row.quietStatus,
46
+ dreamStatus: existing.row.dreamStatus,
47
+ quietReason: existing.row.quietReason,
48
+ dreamReason: existing.row.dreamReason,
49
+ quietCompletedAt: existing.row.quietCompletedAt ?? undefined,
50
+ dreamCompletedAt: existing.row.dreamCompletedAt ?? undefined,
51
+ }
52
+ : {
53
+ day,
54
+ quietStatus: "not_due",
55
+ dreamStatus: "not_due",
56
+ };
57
+ // Check if closures exist for today
58
+ const closuresRead = await readActionClosuresByDay(db, day);
59
+ if (closuresRead.degraded) {
60
+ return closuresRead.degraded;
61
+ }
62
+ const hasClosures = closuresRead.rows.length > 0;
63
+ // Determine Quiet status
64
+ if (state.quietStatus === "completed" || state.quietStatus === "blocked") {
65
+ // Already handled; don't re-run
66
+ }
67
+ else if (!hasClosures) {
68
+ state.quietStatus = "not_due";
69
+ state.quietReason = "quiet_empty_input";
70
+ }
71
+ else {
72
+ // Closures exist but Quiet not completed → due
73
+ state.quietStatus = "due";
74
+ // Auto-run Quiet if forced or if not yet attempted
75
+ if (options?.forceQuiet || state.quietStatus === "due") {
76
+ const quietResult = await buildQuietDailyReview(db, { day, now });
77
+ if (quietResult.status === "completed") {
78
+ state.quietStatus = "completed";
79
+ state.quietReason = "quiet_completed";
80
+ state.quietCompletedAt = now;
81
+ }
82
+ else if (quietResult.status === "empty") {
83
+ state.quietStatus = "skipped";
84
+ state.quietReason = quietResult.reason ?? "quiet_empty_input";
85
+ }
86
+ else {
87
+ // Degraded
88
+ state.quietStatus = "blocked";
89
+ state.quietReason = quietResult.reason ?? "state_unreadable";
90
+ }
91
+ }
92
+ }
93
+ // Determine Dream status based on Quiet outcome
94
+ if (state.quietStatus === "completed") {
95
+ if (state.dreamStatus === "completed" ||
96
+ state.dreamStatus === "scheduled" ||
97
+ state.dreamStatus === "blocked") {
98
+ // Already handled; do not re-schedule
99
+ }
100
+ else {
101
+ state.dreamStatus = "due";
102
+ state.dreamReason = "dream_scheduled";
103
+ // Schedule Dream
104
+ 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;
112
+ }
113
+ else if (dreamResult.status === "blocked") {
114
+ state.dreamStatus = "blocked";
115
+ state.dreamReason = dreamResult.reason ?? "dream_scheduler_unavailable";
116
+ }
117
+ else {
118
+ state.dreamStatus = "scheduled";
119
+ state.dreamReason = "dream_scheduled";
120
+ }
121
+ }
122
+ }
123
+ else if (state.quietStatus === "not_due") {
124
+ state.dreamStatus = "not_due";
125
+ state.dreamReason = "quiet_empty_input";
126
+ }
127
+ else if (state.quietStatus === "skipped") {
128
+ state.dreamStatus = "blocked";
129
+ state.dreamReason = state.quietReason ?? "quiet_empty_input";
130
+ }
131
+ else {
132
+ // Quiet blocked (degraded) → Dream cannot run
133
+ state.dreamStatus = "blocked";
134
+ state.dreamReason = state.quietReason ?? "dream_blocked_redaction";
135
+ }
136
+ // Persist state
137
+ const writeResult = await writeDailyRhythmState(db, {
138
+ id: `rhythm_${day}`,
139
+ day,
140
+ quietStatus: state.quietStatus,
141
+ dreamStatus: state.dreamStatus,
142
+ quietReason: state.quietReason ?? null,
143
+ dreamReason: state.dreamReason ?? null,
144
+ quietCompletedAt: state.quietCompletedAt ?? null,
145
+ dreamCompletedAt: state.dreamCompletedAt ?? null,
146
+ sourceRefs: [
147
+ {
148
+ uri: `sn://rhythm/${day}`,
149
+ family: "dream_run",
150
+ id: `rhythm_${day}`,
151
+ redactionClass: "none",
152
+ resolveStatus: "resolvable",
153
+ },
154
+ ],
155
+ payloadJson: JSON.stringify({ checkedAt: now, hasClosures: closuresRead.rows.length }),
156
+ updatedAt: now,
157
+ });
158
+ if ("reason" in writeResult) {
159
+ return writeResult;
160
+ }
161
+ return { status: "checked", state };
162
+ }
@@ -32,5 +32,5 @@ export interface AcceptMemoryProjectionOptions {
32
32
  now?: string;
33
33
  }
34
34
  export declare function acceptMemoryProjection(db: StateDatabase, candidateId: string, topicKey: string, memoryText: string, sourceRefs: SourceRef[], options?: AcceptMemoryProjectionOptions): Promise<ProjectionLifecycleResult | DegradedOperationResult>;
35
- export declare function rejectMemoryProjection(db: StateDatabase, projectionId: string, candidateId: string, topicKey: string, sourceRefs: SourceRef[], reason?: V8ReasonCode, options?: AcceptMemoryProjectionOptions): Promise<ProjectionLifecycleResult | DegradedOperationResult>;
36
- export declare function retireMemoryProjection(db: StateDatabase, projectionId: string, candidateId: string, topicKey: string, sourceRefs: SourceRef[], options?: AcceptMemoryProjectionOptions): Promise<ProjectionLifecycleResult | DegradedOperationResult>;
35
+ export declare function rejectMemoryProjection(db: StateDatabase, projectionId: string, _candidateId: string, _topicKey: string, _sourceRefs: SourceRef[], reason?: V8ReasonCode, options?: AcceptMemoryProjectionOptions): Promise<ProjectionLifecycleResult | DegradedOperationResult>;
36
+ export declare function retireMemoryProjection(db: StateDatabase, projectionId: string, _candidateId: string, _topicKey: string, _sourceRefs: SourceRef[], options?: AcceptMemoryProjectionOptions): Promise<ProjectionLifecycleResult | DegradedOperationResult>;
@@ -20,7 +20,7 @@
20
20
  *
21
21
  * Test coverage: tests/unit/dream/memory-projection-lifecycle.test.ts
22
22
  */
23
- import { readMemoryProjectionsByTopic, writeLongTermMemoryProjection, } from "../../../storage/v8-state-stores.js";
23
+ import { readMemoryProjectionsByTopic, writeLongTermMemoryProjection, updateLongTermMemoryProjectionStatus, } from "../../../storage/v8-state-stores.js";
24
24
  // ───────────────────────────────────────────────────────────────
25
25
  // Public API
26
26
  // ───────────────────────────────────────────────────────────────
@@ -44,21 +44,12 @@ export async function acceptMemoryProjection(db, candidateId, topicKey, memoryTe
44
44
  const activeProjection = existing.rows.find((r) => r.status === "active" || r.status === "accepted");
45
45
  let supersedesId;
46
46
  if (activeProjection) {
47
- // Supersede existing active projection
48
- const supersedeResult = await writeLongTermMemoryProjection(db, {
49
- id: activeProjection.id,
50
- createdAt: activeProjection.createdAt,
51
- candidateId: activeProjection.candidateId,
52
- topicKey: activeProjection.topicKey,
53
- status: "superseded",
54
- sourceRefs: parseSourceRefs(activeProjection.sourceRefsJson),
55
- redactionClass: activeProjection.redactionClass,
56
- lifecycleStatus: "superseded",
57
- payloadJson: JSON.stringify({
58
- supersededAt: now,
59
- supersededBy: candidateId,
60
- }),
61
- });
47
+ // Supersede existing active projection — UPDATE instead of INSERT
48
+ const supersedeResult = await updateLongTermMemoryProjectionStatus(db, activeProjection.id, "superseded", JSON.stringify({
49
+ ...parsePayloadJson(activeProjection.payloadJson),
50
+ supersededAt: now,
51
+ supersededBy: candidateId,
52
+ }));
62
53
  if ("reason" in supersedeResult) {
63
54
  return supersedeResult;
64
55
  }
@@ -91,21 +82,12 @@ export async function acceptMemoryProjection(db, candidateId, topicKey, memoryTe
91
82
  supersedesProjectionId: supersedesId,
92
83
  };
93
84
  }
94
- export async function rejectMemoryProjection(db, projectionId, candidateId, topicKey, sourceRefs, reason = "projection_rejected", options) {
85
+ export async function rejectMemoryProjection(db, projectionId, _candidateId, _topicKey, _sourceRefs, reason = "projection_rejected", options) {
95
86
  const now = options?.now ?? new Date().toISOString();
96
- const writeResult = await writeLongTermMemoryProjection(db, {
97
- id: projectionId,
98
- createdAt: now,
99
- candidateId,
100
- topicKey,
101
- status: "rejected",
102
- sourceRefs,
103
- redactionClass: "none",
104
- lifecycleStatus: "rejected",
105
- payloadJson: JSON.stringify({ rejectedAt: now, reason }),
106
- });
107
- if ("reason" in writeResult) {
108
- return writeResult;
87
+ // F5: Use UPDATE instead of INSERT to avoid PK conflict on existing projections
88
+ const updateResult = await updateLongTermMemoryProjectionStatus(db, projectionId, "rejected", JSON.stringify({ rejectedAt: now, reason }));
89
+ if ("reason" in updateResult) {
90
+ return updateResult;
109
91
  }
110
92
  return {
111
93
  projectionId,
@@ -113,21 +95,12 @@ export async function rejectMemoryProjection(db, projectionId, candidateId, topi
113
95
  reason,
114
96
  };
115
97
  }
116
- export async function retireMemoryProjection(db, projectionId, candidateId, topicKey, sourceRefs, options) {
98
+ export async function retireMemoryProjection(db, projectionId, _candidateId, _topicKey, _sourceRefs, options) {
117
99
  const now = options?.now ?? new Date().toISOString();
118
- const writeResult = await writeLongTermMemoryProjection(db, {
119
- id: projectionId,
120
- createdAt: now,
121
- candidateId,
122
- topicKey,
123
- status: "retired",
124
- sourceRefs,
125
- redactionClass: "none",
126
- lifecycleStatus: "retired",
127
- payloadJson: JSON.stringify({ retiredAt: now }),
128
- });
129
- if ("reason" in writeResult) {
130
- return writeResult;
100
+ // F5: Use UPDATE instead of INSERT to avoid PK conflict on existing projections
101
+ const updateResult = await updateLongTermMemoryProjectionStatus(db, projectionId, "retired", JSON.stringify({ retiredAt: now }));
102
+ if ("reason" in updateResult) {
103
+ return updateResult;
131
104
  }
132
105
  return {
133
106
  projectionId,
@@ -149,3 +122,13 @@ function parseSourceRefs(json) {
149
122
  return [];
150
123
  }
151
124
  }
125
+ function parsePayloadJson(json) {
126
+ if (!json)
127
+ return {};
128
+ try {
129
+ return JSON.parse(json);
130
+ }
131
+ catch {
132
+ return {};
133
+ }
134
+ }
@@ -27,7 +27,10 @@ export interface QuietDailyReviewResult {
27
27
  day: string;
28
28
  closureCount: number;
29
29
  memoryCandidateCount: number;
30
+ /** Generic source refs (closure + perception + other) */
30
31
  sourceRefs: SourceRef[];
32
+ /** Explicit closure refs — first-class provenance for reviewed ActionClosureRecords */
33
+ closureRefs: SourceRef[];
31
34
  reviewSummary: string;
32
35
  importanceSignals: string[];
33
36
  createdAt: string;
@@ -68,6 +68,8 @@ export async function buildQuietDailyReview(db, options) {
68
68
  };
69
69
  }
70
70
  const sourceRefs = closures.map(buildSourceRefFromClosure);
71
+ // T-DQ.R.4: first-class closure refs — identical to sourceRefs here, but explicitly typed
72
+ const closureRefs = closures.map(buildSourceRefFromClosure);
71
73
  // Collect memory-review candidates from closure payloads
72
74
  const memoryCandidates = [];
73
75
  for (const closure of closures) {
@@ -96,6 +98,7 @@ export async function buildQuietDailyReview(db, options) {
96
98
  closureCount: closures.length,
97
99
  memoryCandidateCount: memoryCandidates.length,
98
100
  sourceRefs,
101
+ closureRefs,
99
102
  redactionClass: "none",
100
103
  lifecycleStatus: "pending",
101
104
  payloadJson: JSON.stringify({
@@ -115,6 +118,7 @@ export async function buildQuietDailyReview(db, options) {
115
118
  closureCount: closures.length,
116
119
  memoryCandidateCount: memoryCandidates.length,
117
120
  sourceRefs,
121
+ closureRefs,
118
122
  reviewSummary,
119
123
  importanceSignals,
120
124
  createdAt: now,