@haaaiawd/second-nature 0.1.51 → 0.2.0

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 (71) hide show
  1. package/openclaw.plugin.json +29 -29
  2. package/package.json +55 -55
  3. package/runtime/cli/commands/index.js +326 -325
  4. package/runtime/cli/ops/heartbeat-surface.d.ts +84 -84
  5. package/runtime/cli/ops/heartbeat-surface.js +100 -100
  6. package/runtime/cli/ops/ops-router.js +1555 -1482
  7. package/runtime/cli/ops/workspace-heartbeat-runner.d.ts +85 -85
  8. package/runtime/cli/ops/workspace-heartbeat-runner.js +242 -242
  9. package/runtime/connectors/base/contract.d.ts +111 -111
  10. package/runtime/connectors/base/failure-taxonomy.d.ts +13 -13
  11. package/runtime/connectors/base/failure-taxonomy.js +186 -186
  12. package/runtime/connectors/base/map-life-evidence.js +137 -137
  13. package/runtime/connectors/base/policy-layer.js +202 -202
  14. package/runtime/connectors/evidence-normalizer.d.ts +45 -0
  15. package/runtime/connectors/evidence-normalizer.js +115 -0
  16. package/runtime/connectors/manifest/manifest-schema.d.ts +152 -152
  17. package/runtime/connectors/manifest/manifest-schema.js +54 -54
  18. package/runtime/connectors/services/connector-executor-adapter.d.ts +20 -20
  19. package/runtime/connectors/services/connector-executor-adapter.js +645 -645
  20. package/runtime/core/second-nature/action/action-closure-recorder.d.ts +70 -0
  21. package/runtime/core/second-nature/action/action-closure-recorder.js +184 -0
  22. package/runtime/core/second-nature/action/action-proposal-builder.d.ts +70 -0
  23. package/runtime/core/second-nature/action/action-proposal-builder.js +217 -0
  24. package/runtime/core/second-nature/action/autonomy-policy-evaluator.d.ts +43 -0
  25. package/runtime/core/second-nature/action/autonomy-policy-evaluator.js +213 -0
  26. package/runtime/core/second-nature/action/policy-bound-dispatch.d.ts +69 -0
  27. package/runtime/core/second-nature/action/policy-bound-dispatch.js +112 -0
  28. package/runtime/core/second-nature/body/tool-affordance/affordance-side-effect.d.ts +49 -0
  29. package/runtime/core/second-nature/body/tool-affordance/affordance-side-effect.js +100 -0
  30. package/runtime/core/second-nature/control-plane/accepted-projection-loader.d.ts +45 -0
  31. package/runtime/core/second-nature/control-plane/accepted-projection-loader.js +85 -0
  32. package/runtime/core/second-nature/control-plane/heartbeat-orchestrator.d.ts +38 -0
  33. package/runtime/core/second-nature/control-plane/heartbeat-orchestrator.js +165 -0
  34. package/runtime/core/second-nature/guidance/guidance-proposal-consumer.d.ts +51 -0
  35. package/runtime/core/second-nature/guidance/guidance-proposal-consumer.js +113 -0
  36. package/runtime/core/second-nature/heartbeat/goal-lifecycle-policy.d.ts +24 -24
  37. package/runtime/core/second-nature/heartbeat/goal-lifecycle-policy.js +61 -61
  38. package/runtime/core/second-nature/heartbeat/heartbeat-loop.d.ts +97 -97
  39. package/runtime/core/second-nature/heartbeat/heartbeat-loop.js +397 -397
  40. package/runtime/core/second-nature/orchestrator/platform-capability-router.js +149 -149
  41. package/runtime/core/second-nature/perception/judgment-engine.d.ts +53 -0
  42. package/runtime/core/second-nature/perception/judgment-engine.js +239 -0
  43. package/runtime/core/second-nature/perception/perception-builder.d.ts +62 -0
  44. package/runtime/core/second-nature/perception/perception-builder.js +208 -0
  45. package/runtime/core/second-nature/perception/sensitivity-classifier.d.ts +37 -0
  46. package/runtime/core/second-nature/perception/sensitivity-classifier.js +87 -0
  47. package/runtime/core/second-nature/quiet-dream/dream-consolidation-runner.d.ts +44 -0
  48. package/runtime/core/second-nature/quiet-dream/dream-consolidation-runner.js +180 -0
  49. package/runtime/core/second-nature/quiet-dream/dream-scheduler.d.ts +36 -0
  50. package/runtime/core/second-nature/quiet-dream/dream-scheduler.js +105 -0
  51. package/runtime/core/second-nature/quiet-dream/memory-projection-lifecycle.d.ts +36 -0
  52. package/runtime/core/second-nature/quiet-dream/memory-projection-lifecycle.js +151 -0
  53. package/runtime/core/second-nature/quiet-dream/quiet-daily-review-builder.d.ts +46 -0
  54. package/runtime/core/second-nature/quiet-dream/quiet-daily-review-builder.js +123 -0
  55. package/runtime/observability/causal-loop-health.d.ts +44 -0
  56. package/runtime/observability/causal-loop-health.js +118 -0
  57. package/runtime/observability/diagnostic-redaction.d.ts +43 -0
  58. package/runtime/observability/diagnostic-redaction.js +114 -0
  59. package/runtime/observability/loop-stage-event-sink.d.ts +43 -0
  60. package/runtime/observability/loop-stage-event-sink.js +148 -0
  61. package/runtime/observability/loop-status.d.ts +46 -0
  62. package/runtime/observability/loop-status.js +85 -0
  63. package/runtime/shared/types/index.js +3 -0
  64. package/runtime/shared/types/v8-contracts.d.ts +86 -0
  65. package/runtime/shared/types/v8-contracts.js +84 -0
  66. package/runtime/storage/db/schema/index.d.ts +1 -0
  67. package/runtime/storage/db/schema/index.js +1 -0
  68. package/runtime/storage/db/schema/v8-entities.d.ts +1973 -0
  69. package/runtime/storage/db/schema/v8-entities.js +160 -0
  70. package/runtime/storage/v8-state-stores.d.ts +147 -0
  71. package/runtime/storage/v8-state-stores.js +491 -0
@@ -1,397 +1,397 @@
1
- import { buildContinuitySnapshot, } from "./snapshot-builder.js";
2
- import { buildHeartbeatRuntimeSnapshot, } from "./runtime-snapshot.js";
3
- import { planCandidateIntents } from "../orchestrator/intent-planner.js";
4
- import { applyGoalPriority } from "../orchestrator/goal-priority.js";
5
- import { evaluateHardGuards } from "../orchestrator/guard-layer.js";
6
- import { dispatchUserOutreachIntent, } from "../outreach/dispatch-user-outreach.js";
7
- import { buildJudgeOutreachInputFromSnapshot } from "../outreach/judge-input-from-snapshot.js";
8
- import { runSourceBackedQuiet } from "../quiet/run-source-backed-quiet.js";
9
- import { toCapabilityIntent } from "../orchestrator/effect-dispatcher.js";
10
- import { updateNarrativeAfterEffect } from "../orchestrator/narrative-update.js";
11
- import { mapLifeEvidence } from "../../../connectors/base/map-life-evidence.js";
12
- import { appendLifeEvidence } from "../../../storage/life-evidence/append-life-evidence.js";
13
- /**
14
- * Resolves the heartbeat outcome for a guard-allowed intent (outreach dispatch, quiet orchestration, or default).
15
- * Exported for unit tests (CR-M1 wiring).
16
- */
17
- export async function resolveAllowedIntentResult(intent, runtime, inputs, signal, deps) {
18
- const day = typeof signal.payload.timestamp === "string"
19
- ? signal.payload.timestamp.slice(0, 10)
20
- : "1970-01-01";
21
- if (intent.effectClass === "user_outreach" && deps.outreachDispatch) {
22
- return dispatchUserOutreachIntent({
23
- candidate: intent,
24
- snapshot: runtime,
25
- judgeInput: buildJudgeOutreachInputFromSnapshot(intent, runtime, inputs),
26
- guidance: deps.outreachDispatch.guidance,
27
- delivery: deps.outreachDispatch.delivery,
28
- state: deps.outreachDispatch.state,
29
- });
30
- }
31
- if (deps.quietWorkflow &&
32
- (intent.kind === "quiet" ||
33
- (intent.kind === "reflection" &&
34
- intent.effectClass === "narrative_reflection"))) {
35
- const quietRun = await runSourceBackedQuiet({
36
- candidate: intent,
37
- runtime,
38
- day,
39
- userInterestSnapshot: inputs.userInterestSnapshot,
40
- workspaceRoot: deps.quietWorkflow.workspaceRoot,
41
- // v7 T-V7C.C.3: pass Dream schedule port so Quiet completion triggers Dream.
42
- dreamSchedulePort: deps.quietWorkflow.dreamSchedulePort,
43
- });
44
- return quietRun.result;
45
- }
46
- // T2.2.3 (CH-14-02/03 / CH-15-01): all intent_selected results must carry at least one
47
- // machine-readable reason so operators can distinguish between effect classes:
48
- // - maintenance / no_effect → "internal_tick" (no external side-effects)
49
- // - connector_action without dispatch wired → "connector_dispatch_unwired"
50
- // - external_platform_action / memory_curation → not generated by intent-planner today;
51
- // if a future path produces them, they will reach the fallback [] branch below and
52
- // should have dedicated reason codes added (e.g. "external_platform_action_unwired").
53
- // - other (outreach / quiet) → caught by the early-return branches above
54
- const noExternalEffect = intent.effectClass === "maintenance" ||
55
- intent.effectClass === "no_effect" ||
56
- intent.kind === "maintenance";
57
- const connectorUnwired = intent.effectClass === "connector_action";
58
- if (connectorUnwired && deps.connectorExecutor) {
59
- if (!intent.platformId || intent.platformId === "unknown") {
60
- return {
61
- scope: "rhythm",
62
- status: "intent_selected",
63
- selectedIntentId: intent.id,
64
- decisionId: `decision:${intent.id}:${Date.now()}`,
65
- reasons: ["connector_dispatch_unavailable"],
66
- };
67
- }
68
- const decisionId = `decision:${intent.id}:${Date.now()}`;
69
- // T-V7C.C.4: inject identity from EmbodiedContext into connector request (readable, no credential)
70
- const platformHandle = runtime.identity?.platformHandles.find((h) => h.platformId === intent.platformId)?.handle;
71
- const result = await deps.connectorExecutor.executeEffect({
72
- platformId: intent.platformId,
73
- intent: toCapabilityIntent(intent),
74
- payload: {},
75
- decisionId,
76
- intentId: intent.id,
77
- idempotencyKey: `idem:${intent.id}:${Date.now()}`,
78
- identity: platformHandle || runtime.identity?.canonicalName
79
- ? {
80
- platformHandle,
81
- canonicalName: runtime.identity?.canonicalName,
82
- }
83
- : undefined,
84
- });
85
- // T3.3.1: on success, map connector result to life evidence and append.
86
- // On failure or empty result, no evidence is fabricated — attempt audit
87
- // is already recorded by the connector policy layer telemetry.
88
- if (result.status === "success" &&
89
- deps.state &&
90
- deps.workspaceRoot) {
91
- try {
92
- const candidate = mapLifeEvidence({
93
- platformId: intent.platformId,
94
- intent: toCapabilityIntent(intent),
95
- result,
96
- observedAt: new Date().toISOString(),
97
- });
98
- if (candidate) {
99
- await appendLifeEvidence(deps.state, deps.workspaceRoot, candidate);
100
- }
101
- }
102
- catch (err) {
103
- // Evidence append must not break the heartbeat cycle.
104
- // Missing evidence will be reflected in the next snapshot load.
105
- const errorMessage = err instanceof Error ? err.message : String(err);
106
- console.warn(`[heartbeat] evidence append failed for ${intent.platformId ?? "unknown"}: ${errorMessage}`);
107
- }
108
- }
109
- // v7 T-V7C.C.2: record ToolExperience for all connector attempts in heartbeat.
110
- if (deps.experienceWriter) {
111
- try {
112
- await deps.experienceWriter.recordExperience({
113
- connectorId: intent.platformId,
114
- capabilityId: toCapabilityIntent(intent),
115
- result,
116
- triggerSource: "heartbeat",
117
- });
118
- }
119
- catch (err) {
120
- const errorMessage = err instanceof Error ? err.message : String(err);
121
- console.warn(`[heartbeat] ToolExperience record failed for ${intent.platformId ?? "unknown"}: ${errorMessage}`);
122
- }
123
- }
124
- // v7 T-BTS.C.5: update circuit breaker state after connector execution.
125
- if (deps.circuitBreakerManager && intent.platformId && intent.capabilityIntent) {
126
- try {
127
- if (result.status === "success") {
128
- await deps.circuitBreakerManager.evaluateSuccess(intent.platformId, intent.capabilityIntent);
129
- }
130
- else {
131
- await deps.circuitBreakerManager.evaluateFailure(intent.platformId, intent.capabilityIntent);
132
- }
133
- }
134
- catch (err) {
135
- const errorMessage = err instanceof Error ? err.message : String(err);
136
- console.warn(`[heartbeat] CircuitBreaker update failed for ${intent.platformId ?? "unknown"}: ${errorMessage}`);
137
- }
138
- }
139
- const base = {
140
- scope: "rhythm",
141
- status: "intent_selected",
142
- selectedIntentId: intent.id,
143
- decisionId,
144
- reasons: result.status === "success"
145
- ? ["connector_effect_executed"]
146
- : result.status === "retryable_failure"
147
- ? ["connector_retryable_failure", result.failureClass ?? "unknown"]
148
- : ["connector_terminal_failure", result.failureClass ?? "unknown"],
149
- };
150
- return base;
151
- }
152
- const reasons = noExternalEffect
153
- ? ["internal_tick"]
154
- : connectorUnwired
155
- ? ["connector_dispatch_unwired"]
156
- : [];
157
- return {
158
- scope: "rhythm",
159
- status: "intent_selected",
160
- selectedIntentId: intent.id,
161
- reasons,
162
- };
163
- }
164
- /**
165
- * T2.1.5: after the cycle result is known, write a narrative revision when
166
- * a NarrativeStateStore is wired. Errors are swallowed so the cycle result
167
- * is never blocked by a store failure. Store failures are optionally traced
168
- * via recordDecisionTrace so operators can monitor store health.
169
- */
170
- async function maybeUpdateNarrativeState(result, selectedIntent, runtime, store, recordTrace, signal, recordNarrativeTrace) {
171
- if (!store)
172
- return;
173
- try {
174
- const prior = await store.loadNarrativeState();
175
- const update = updateNarrativeAfterEffect({
176
- result,
177
- selectedIntent,
178
- lifeEvidence: runtime.lifeEvidence,
179
- priorNarrative: prior,
180
- });
181
- await store.updateNarrativeState(update);
182
- // T5.1.2: record NarrativeTrace on successful state update
183
- if (recordNarrativeTrace) {
184
- try {
185
- await recordNarrativeTrace({
186
- traceId: `narrative_trace:${crypto.randomUUID()}`,
187
- narrativeId: update.narrativeId,
188
- revision: update.revision,
189
- updateSource: "heartbeat",
190
- sourceRefs: update.sourceRefs.map((r) => ({
191
- id: r.sourceId,
192
- kind: r.kind,
193
- uri: r.url,
194
- })),
195
- unsupportedClaims: update.unsupportedClaims,
196
- groundingStatus: update.unsupportedClaims.length > 0
197
- ? "degraded"
198
- : update.status === "insufficient_sources"
199
- ? "blocked"
200
- : "pass",
201
- goalInfluenceRefs: selectedIntent?.goalInfluenceRefs ?? [],
202
- createdAt: update.updatedAt,
203
- });
204
- }
205
- catch {
206
- // trace emission must not block the cycle
207
- }
208
- }
209
- }
210
- catch {
211
- // degrade silently; narrative update is best-effort
212
- if (recordTrace && signal) {
213
- try {
214
- await recordTrace({
215
- scope: result.scope,
216
- status: result.status,
217
- reasons: ["narrative_update_failed"],
218
- selectedIntentId: selectedIntent?.id,
219
- rhythmWindowId: runtime.rhythmWindow.windowId,
220
- allowedIntentKinds: [...runtime.rhythmWindow.allowedIntentKinds],
221
- candidateCount: 0,
222
- lifeEvidenceEmpty: runtime.lifeEvidence.evidenceRefs.length === 0 &&
223
- runtime.lifeEvidence.platformEventCount === 0 &&
224
- runtime.lifeEvidence.workEventCount === 0,
225
- trigger: signal.trigger,
226
- });
227
- }
228
- catch {
229
- // trace emission must also not block the cycle
230
- }
231
- }
232
- }
233
- }
234
- /**
235
- * Ingest a heartbeat rhythm signal and drive one full decision round.
236
- */
237
- export async function ingestRhythmSignal(signal, deps) {
238
- const inputs = await deps.loadSnapshotInputs();
239
- const snapshot = buildContinuitySnapshot(inputs);
240
- const timestamp = signal.payload.timestamp;
241
- const runtime = buildHeartbeatRuntimeSnapshot(timestamp, inputs, snapshot);
242
- // v7 T-CP.C.3: evaluate goal lifecycle transitions before candidate planning.
243
- let goalTransitions = [];
244
- if (deps.goalLifecyclePolicy && inputs.acceptedGoals) {
245
- try {
246
- const policyResult = deps.goalLifecyclePolicy.evaluate(inputs.acceptedGoals);
247
- goalTransitions = policyResult.transitionRequests;
248
- }
249
- catch (err) {
250
- const msg = err instanceof Error ? err.message : String(err);
251
- console.warn(`[heartbeat] Goal lifecycle evaluation failed: ${msg}`);
252
- }
253
- }
254
- const rawCandidates = planCandidateIntents(runtime, {
255
- acceptedGoals: inputs.acceptedGoals,
256
- connectorRegistry: deps.connectorRegistry,
257
- narrativeState: runtime.narrativeState,
258
- relationshipMemory: runtime.relationshipMemory,
259
- });
260
- // v7 T-CP.C.3: when no active goals and no connector candidates, use idle curiosity.
261
- let allCandidates = rawCandidates;
262
- if (deps.idleCuriosityPolicy && runtime.affordanceMap) {
263
- const hasActiveGoals = (inputs.acceptedGoals ?? []).some((g) => g.status === "accepted");
264
- const hasConnectorCandidates = rawCandidates.some((c) => c.effectClass === "connector_action" || c.effectClass === "external_platform_action");
265
- if (!hasActiveGoals && !hasConnectorCandidates) {
266
- try {
267
- const idleResult = deps.idleCuriosityPolicy.select(runtime.affordanceMap, []);
268
- if (idleResult.candidate) {
269
- const idleIntent = {
270
- id: `intent-idle-${idleResult.candidate.platformId}-${idleResult.candidate.capabilityId}`,
271
- kind: "exploration",
272
- priority: 30,
273
- source: "tick",
274
- platformId: idleResult.candidate.platformId,
275
- summary: `idle curiosity: ${idleResult.candidate.intent}`,
276
- effectClass: "connector_action",
277
- capabilityIntent: idleResult.candidate.capabilityId,
278
- sourceRefs: [
279
- {
280
- id: "idle_curiosity",
281
- kind: "workspace_artifact",
282
- uri: `idle://${idleResult.candidate.platformId}`,
283
- },
284
- ],
285
- idempotencyKey: `idle:${idleResult.candidate.platformId}:${idleResult.candidate.capabilityId}`,
286
- goalInfluenceRefs: [],
287
- };
288
- allCandidates = [...rawCandidates, idleIntent];
289
- }
290
- }
291
- catch (err) {
292
- const msg = err instanceof Error ? err.message : String(err);
293
- console.warn(`[heartbeat] Idle curiosity selection failed: ${msg}`);
294
- }
295
- }
296
- }
297
- const { candidates } = applyGoalPriority(allCandidates, inputs.acceptedGoals);
298
- const emitTrace = async (result) => {
299
- if (!deps.recordDecisionTrace)
300
- return;
301
- const traceReasons = [...result.reasons];
302
- if (goalTransitions.length > 0) {
303
- traceReasons.push(`goal_transitions:${goalTransitions.length}`);
304
- }
305
- await deps.recordDecisionTrace({
306
- scope: result.scope,
307
- status: result.status,
308
- reasons: traceReasons,
309
- selectedIntentId: result.selectedIntentId,
310
- rhythmWindowId: runtime.rhythmWindow.windowId,
311
- allowedIntentKinds: [...runtime.rhythmWindow.allowedIntentKinds],
312
- candidateCount: candidates.length,
313
- lifeEvidenceEmpty: runtime.lifeEvidence.evidenceRefs.length === 0 &&
314
- runtime.lifeEvidence.platformEventCount === 0 &&
315
- runtime.lifeEvidence.workEventCount === 0,
316
- trigger: signal.trigger,
317
- });
318
- };
319
- let hasCandidates = false;
320
- let anyAllow = false;
321
- let anyDefer = false;
322
- let anyDeny = false;
323
- const denyReasons = [];
324
- for (const intent of candidates) {
325
- hasCandidates = true;
326
- const evaluation = evaluateHardGuards(intent, runtime);
327
- if (evaluation.verdict === "allow") {
328
- anyAllow = true;
329
- const base = {
330
- scope: "rhythm",
331
- status: "intent_selected",
332
- selectedIntentId: intent.id,
333
- reasons: evaluation.reasons,
334
- };
335
- const resolved = await resolveAllowedIntentResult(intent, runtime, inputs, signal, deps);
336
- const result = resolved.status === "intent_selected" &&
337
- resolved.reasons.length === 0 &&
338
- evaluation.reasons.length > 0
339
- ? { ...resolved, reasons: evaluation.reasons }
340
- : resolved;
341
- await emitTrace(result);
342
- await maybeUpdateNarrativeState(result, intent, runtime, deps.narrativeStateStore, deps.recordDecisionTrace, signal, deps.recordNarrativeTrace);
343
- return result;
344
- }
345
- if (evaluation.verdict === "defer") {
346
- anyDefer = true;
347
- denyReasons.push(`${intent.id}:${evaluation.verdict}(${evaluation.reasons.join(",")})`);
348
- continue;
349
- }
350
- anyDeny = true;
351
- denyReasons.push(`${intent.id}:${evaluation.verdict}(${evaluation.reasons.join(",")})`);
352
- }
353
- if (!hasCandidates) {
354
- const result = {
355
- scope: "rhythm",
356
- status: "heartbeat_ok",
357
- reasons: ["silent_no_candidates"],
358
- };
359
- await emitTrace(result);
360
- await maybeUpdateNarrativeState(result, undefined, runtime, deps.narrativeStateStore, deps.recordDecisionTrace, signal, deps.recordNarrativeTrace);
361
- return result;
362
- }
363
- if (!anyAllow && anyDefer && !anyDeny) {
364
- const result = {
365
- scope: "rhythm",
366
- status: "deferred",
367
- reasons: denyReasons.length > 0 ? denyReasons : ["all_candidates_deferred"],
368
- };
369
- await emitTrace(result);
370
- await maybeUpdateNarrativeState(result, undefined, runtime, deps.narrativeStateStore, deps.recordDecisionTrace, signal, deps.recordNarrativeTrace);
371
- return result;
372
- }
373
- if (!anyAllow && denyReasons.length > 0) {
374
- const result = {
375
- scope: "rhythm",
376
- status: "denied",
377
- reasons: denyReasons,
378
- };
379
- await emitTrace(result);
380
- await maybeUpdateNarrativeState(result, undefined, runtime, deps.narrativeStateStore, deps.recordDecisionTrace, signal, deps.recordNarrativeTrace);
381
- return result;
382
- }
383
- const result = {
384
- scope: "rhythm",
385
- status: "heartbeat_ok",
386
- reasons: ["no_allow_verdict"],
387
- };
388
- await emitTrace(result);
389
- await maybeUpdateNarrativeState(result, undefined, runtime, deps.narrativeStateStore, deps.recordDecisionTrace, signal, deps.recordNarrativeTrace);
390
- return result;
391
- }
392
- /**
393
- * Build a snapshot directly from inputs (for testing or when state-system is unavailable).
394
- */
395
- export function buildSnapshotFromInputs(inputs) {
396
- return buildContinuitySnapshot(inputs);
397
- }
1
+ import { buildContinuitySnapshot, } from "./snapshot-builder.js";
2
+ import { buildHeartbeatRuntimeSnapshot, } from "./runtime-snapshot.js";
3
+ import { planCandidateIntents } from "../orchestrator/intent-planner.js";
4
+ import { applyGoalPriority } from "../orchestrator/goal-priority.js";
5
+ import { evaluateHardGuards } from "../orchestrator/guard-layer.js";
6
+ import { dispatchUserOutreachIntent, } from "../outreach/dispatch-user-outreach.js";
7
+ import { buildJudgeOutreachInputFromSnapshot } from "../outreach/judge-input-from-snapshot.js";
8
+ import { runSourceBackedQuiet } from "../quiet/run-source-backed-quiet.js";
9
+ import { toCapabilityIntent } from "../orchestrator/effect-dispatcher.js";
10
+ import { updateNarrativeAfterEffect } from "../orchestrator/narrative-update.js";
11
+ import { mapLifeEvidence } from "../../../connectors/base/map-life-evidence.js";
12
+ import { appendLifeEvidence } from "../../../storage/life-evidence/append-life-evidence.js";
13
+ /**
14
+ * Resolves the heartbeat outcome for a guard-allowed intent (outreach dispatch, quiet orchestration, or default).
15
+ * Exported for unit tests (CR-M1 wiring).
16
+ */
17
+ export async function resolveAllowedIntentResult(intent, runtime, inputs, signal, deps) {
18
+ const day = typeof signal.payload.timestamp === "string"
19
+ ? signal.payload.timestamp.slice(0, 10)
20
+ : "1970-01-01";
21
+ if (intent.effectClass === "user_outreach" && deps.outreachDispatch) {
22
+ return dispatchUserOutreachIntent({
23
+ candidate: intent,
24
+ snapshot: runtime,
25
+ judgeInput: buildJudgeOutreachInputFromSnapshot(intent, runtime, inputs),
26
+ guidance: deps.outreachDispatch.guidance,
27
+ delivery: deps.outreachDispatch.delivery,
28
+ state: deps.outreachDispatch.state,
29
+ });
30
+ }
31
+ if (deps.quietWorkflow &&
32
+ (intent.kind === "quiet" ||
33
+ (intent.kind === "reflection" &&
34
+ intent.effectClass === "narrative_reflection"))) {
35
+ const quietRun = await runSourceBackedQuiet({
36
+ candidate: intent,
37
+ runtime,
38
+ day,
39
+ userInterestSnapshot: inputs.userInterestSnapshot,
40
+ workspaceRoot: deps.quietWorkflow.workspaceRoot,
41
+ // v7 T-V7C.C.3: pass Dream schedule port so Quiet completion triggers Dream.
42
+ dreamSchedulePort: deps.quietWorkflow.dreamSchedulePort,
43
+ });
44
+ return quietRun.result;
45
+ }
46
+ // T2.2.3 (CH-14-02/03 / CH-15-01): all intent_selected results must carry at least one
47
+ // machine-readable reason so operators can distinguish between effect classes:
48
+ // - maintenance / no_effect → "internal_tick" (no external side-effects)
49
+ // - connector_action without dispatch wired → "connector_dispatch_unwired"
50
+ // - external_platform_action / memory_curation → not generated by intent-planner today;
51
+ // if a future path produces them, they will reach the fallback [] branch below and
52
+ // should have dedicated reason codes added (e.g. "external_platform_action_unwired").
53
+ // - other (outreach / quiet) → caught by the early-return branches above
54
+ const noExternalEffect = intent.effectClass === "maintenance" ||
55
+ intent.effectClass === "no_effect" ||
56
+ intent.kind === "maintenance";
57
+ const connectorUnwired = intent.effectClass === "connector_action";
58
+ if (connectorUnwired && deps.connectorExecutor) {
59
+ if (!intent.platformId || intent.platformId === "unknown") {
60
+ return {
61
+ scope: "rhythm",
62
+ status: "intent_selected",
63
+ selectedIntentId: intent.id,
64
+ decisionId: `decision:${intent.id}:${Date.now()}`,
65
+ reasons: ["connector_dispatch_unavailable"],
66
+ };
67
+ }
68
+ const decisionId = `decision:${intent.id}:${Date.now()}`;
69
+ // T-V7C.C.4: inject identity from EmbodiedContext into connector request (readable, no credential)
70
+ const platformHandle = runtime.identity?.platformHandles.find((h) => h.platformId === intent.platformId)?.handle;
71
+ const result = await deps.connectorExecutor.executeEffect({
72
+ platformId: intent.platformId,
73
+ intent: toCapabilityIntent(intent),
74
+ payload: {},
75
+ decisionId,
76
+ intentId: intent.id,
77
+ idempotencyKey: `idem:${intent.id}:${Date.now()}`,
78
+ identity: platformHandle || runtime.identity?.canonicalName
79
+ ? {
80
+ platformHandle,
81
+ canonicalName: runtime.identity?.canonicalName,
82
+ }
83
+ : undefined,
84
+ });
85
+ // T3.3.1: on success, map connector result to life evidence and append.
86
+ // On failure or empty result, no evidence is fabricated — attempt audit
87
+ // is already recorded by the connector policy layer telemetry.
88
+ if (result.status === "success" &&
89
+ deps.state &&
90
+ deps.workspaceRoot) {
91
+ try {
92
+ const candidate = mapLifeEvidence({
93
+ platformId: intent.platformId,
94
+ intent: toCapabilityIntent(intent),
95
+ result,
96
+ observedAt: new Date().toISOString(),
97
+ });
98
+ if (candidate) {
99
+ await appendLifeEvidence(deps.state, deps.workspaceRoot, candidate);
100
+ }
101
+ }
102
+ catch (err) {
103
+ // Evidence append must not break the heartbeat cycle.
104
+ // Missing evidence will be reflected in the next snapshot load.
105
+ const errorMessage = err instanceof Error ? err.message : String(err);
106
+ console.warn(`[heartbeat] evidence append failed for ${intent.platformId ?? "unknown"}: ${errorMessage}`);
107
+ }
108
+ }
109
+ // v7 T-V7C.C.2: record ToolExperience for all connector attempts in heartbeat.
110
+ if (deps.experienceWriter) {
111
+ try {
112
+ await deps.experienceWriter.recordExperience({
113
+ connectorId: intent.platformId,
114
+ capabilityId: toCapabilityIntent(intent),
115
+ result,
116
+ triggerSource: "heartbeat",
117
+ });
118
+ }
119
+ catch (err) {
120
+ const errorMessage = err instanceof Error ? err.message : String(err);
121
+ console.warn(`[heartbeat] ToolExperience record failed for ${intent.platformId ?? "unknown"}: ${errorMessage}`);
122
+ }
123
+ }
124
+ // v7 T-BTS.C.5: update circuit breaker state after connector execution.
125
+ if (deps.circuitBreakerManager && intent.platformId && intent.capabilityIntent) {
126
+ try {
127
+ if (result.status === "success") {
128
+ await deps.circuitBreakerManager.evaluateSuccess(intent.platformId, intent.capabilityIntent);
129
+ }
130
+ else {
131
+ await deps.circuitBreakerManager.evaluateFailure(intent.platformId, intent.capabilityIntent);
132
+ }
133
+ }
134
+ catch (err) {
135
+ const errorMessage = err instanceof Error ? err.message : String(err);
136
+ console.warn(`[heartbeat] CircuitBreaker update failed for ${intent.platformId ?? "unknown"}: ${errorMessage}`);
137
+ }
138
+ }
139
+ const base = {
140
+ scope: "rhythm",
141
+ status: "intent_selected",
142
+ selectedIntentId: intent.id,
143
+ decisionId,
144
+ reasons: result.status === "success"
145
+ ? ["connector_effect_executed"]
146
+ : result.status === "retryable_failure"
147
+ ? ["connector_retryable_failure", result.failureClass ?? "unknown"]
148
+ : ["connector_terminal_failure", result.failureClass ?? "unknown"],
149
+ };
150
+ return base;
151
+ }
152
+ const reasons = noExternalEffect
153
+ ? ["internal_tick"]
154
+ : connectorUnwired
155
+ ? ["connector_dispatch_unwired"]
156
+ : [];
157
+ return {
158
+ scope: "rhythm",
159
+ status: "intent_selected",
160
+ selectedIntentId: intent.id,
161
+ reasons,
162
+ };
163
+ }
164
+ /**
165
+ * T2.1.5: after the cycle result is known, write a narrative revision when
166
+ * a NarrativeStateStore is wired. Errors are swallowed so the cycle result
167
+ * is never blocked by a store failure. Store failures are optionally traced
168
+ * via recordDecisionTrace so operators can monitor store health.
169
+ */
170
+ async function maybeUpdateNarrativeState(result, selectedIntent, runtime, store, recordTrace, signal, recordNarrativeTrace) {
171
+ if (!store)
172
+ return;
173
+ try {
174
+ const prior = await store.loadNarrativeState();
175
+ const update = updateNarrativeAfterEffect({
176
+ result,
177
+ selectedIntent,
178
+ lifeEvidence: runtime.lifeEvidence,
179
+ priorNarrative: prior,
180
+ });
181
+ await store.updateNarrativeState(update);
182
+ // T5.1.2: record NarrativeTrace on successful state update
183
+ if (recordNarrativeTrace) {
184
+ try {
185
+ await recordNarrativeTrace({
186
+ traceId: `narrative_trace:${crypto.randomUUID()}`,
187
+ narrativeId: update.narrativeId,
188
+ revision: update.revision,
189
+ updateSource: "heartbeat",
190
+ sourceRefs: update.sourceRefs.map((r) => ({
191
+ id: r.sourceId,
192
+ kind: r.kind,
193
+ uri: r.url,
194
+ })),
195
+ unsupportedClaims: update.unsupportedClaims,
196
+ groundingStatus: update.unsupportedClaims.length > 0
197
+ ? "degraded"
198
+ : update.status === "insufficient_sources"
199
+ ? "blocked"
200
+ : "pass",
201
+ goalInfluenceRefs: selectedIntent?.goalInfluenceRefs ?? [],
202
+ createdAt: update.updatedAt,
203
+ });
204
+ }
205
+ catch {
206
+ // trace emission must not block the cycle
207
+ }
208
+ }
209
+ }
210
+ catch {
211
+ // degrade silently; narrative update is best-effort
212
+ if (recordTrace && signal) {
213
+ try {
214
+ await recordTrace({
215
+ scope: result.scope,
216
+ status: result.status,
217
+ reasons: ["narrative_update_failed"],
218
+ selectedIntentId: selectedIntent?.id,
219
+ rhythmWindowId: runtime.rhythmWindow.windowId,
220
+ allowedIntentKinds: [...runtime.rhythmWindow.allowedIntentKinds],
221
+ candidateCount: 0,
222
+ lifeEvidenceEmpty: runtime.lifeEvidence.evidenceRefs.length === 0 &&
223
+ runtime.lifeEvidence.platformEventCount === 0 &&
224
+ runtime.lifeEvidence.workEventCount === 0,
225
+ trigger: signal.trigger,
226
+ });
227
+ }
228
+ catch {
229
+ // trace emission must also not block the cycle
230
+ }
231
+ }
232
+ }
233
+ }
234
+ /**
235
+ * Ingest a heartbeat rhythm signal and drive one full decision round.
236
+ */
237
+ export async function ingestRhythmSignal(signal, deps) {
238
+ const inputs = await deps.loadSnapshotInputs();
239
+ const snapshot = buildContinuitySnapshot(inputs);
240
+ const timestamp = signal.payload.timestamp;
241
+ const runtime = buildHeartbeatRuntimeSnapshot(timestamp, inputs, snapshot);
242
+ // v7 T-CP.C.3: evaluate goal lifecycle transitions before candidate planning.
243
+ let goalTransitions = [];
244
+ if (deps.goalLifecyclePolicy && inputs.acceptedGoals) {
245
+ try {
246
+ const policyResult = deps.goalLifecyclePolicy.evaluate(inputs.acceptedGoals);
247
+ goalTransitions = policyResult.transitionRequests;
248
+ }
249
+ catch (err) {
250
+ const msg = err instanceof Error ? err.message : String(err);
251
+ console.warn(`[heartbeat] Goal lifecycle evaluation failed: ${msg}`);
252
+ }
253
+ }
254
+ const rawCandidates = planCandidateIntents(runtime, {
255
+ acceptedGoals: inputs.acceptedGoals,
256
+ connectorRegistry: deps.connectorRegistry,
257
+ narrativeState: runtime.narrativeState,
258
+ relationshipMemory: runtime.relationshipMemory,
259
+ });
260
+ // v7 T-CP.C.3: when no active goals and no connector candidates, use idle curiosity.
261
+ let allCandidates = rawCandidates;
262
+ if (deps.idleCuriosityPolicy && runtime.affordanceMap) {
263
+ const hasActiveGoals = (inputs.acceptedGoals ?? []).some((g) => g.status === "accepted");
264
+ const hasConnectorCandidates = rawCandidates.some((c) => c.effectClass === "connector_action" || c.effectClass === "external_platform_action");
265
+ if (!hasActiveGoals && !hasConnectorCandidates) {
266
+ try {
267
+ const idleResult = deps.idleCuriosityPolicy.select(runtime.affordanceMap, []);
268
+ if (idleResult.candidate) {
269
+ const idleIntent = {
270
+ id: `intent-idle-${idleResult.candidate.platformId}-${idleResult.candidate.capabilityId}`,
271
+ kind: "exploration",
272
+ priority: 30,
273
+ source: "tick",
274
+ platformId: idleResult.candidate.platformId,
275
+ summary: `idle curiosity: ${idleResult.candidate.intent}`,
276
+ effectClass: "connector_action",
277
+ capabilityIntent: idleResult.candidate.capabilityId,
278
+ sourceRefs: [
279
+ {
280
+ id: "idle_curiosity",
281
+ kind: "workspace_artifact",
282
+ uri: `idle://${idleResult.candidate.platformId}`,
283
+ },
284
+ ],
285
+ idempotencyKey: `idle:${idleResult.candidate.platformId}:${idleResult.candidate.capabilityId}`,
286
+ goalInfluenceRefs: [],
287
+ };
288
+ allCandidates = [...rawCandidates, idleIntent];
289
+ }
290
+ }
291
+ catch (err) {
292
+ const msg = err instanceof Error ? err.message : String(err);
293
+ console.warn(`[heartbeat] Idle curiosity selection failed: ${msg}`);
294
+ }
295
+ }
296
+ }
297
+ const { candidates } = applyGoalPriority(allCandidates, inputs.acceptedGoals);
298
+ const emitTrace = async (result) => {
299
+ if (!deps.recordDecisionTrace)
300
+ return;
301
+ const traceReasons = [...result.reasons];
302
+ if (goalTransitions.length > 0) {
303
+ traceReasons.push(`goal_transitions:${goalTransitions.length}`);
304
+ }
305
+ await deps.recordDecisionTrace({
306
+ scope: result.scope,
307
+ status: result.status,
308
+ reasons: traceReasons,
309
+ selectedIntentId: result.selectedIntentId,
310
+ rhythmWindowId: runtime.rhythmWindow.windowId,
311
+ allowedIntentKinds: [...runtime.rhythmWindow.allowedIntentKinds],
312
+ candidateCount: candidates.length,
313
+ lifeEvidenceEmpty: runtime.lifeEvidence.evidenceRefs.length === 0 &&
314
+ runtime.lifeEvidence.platformEventCount === 0 &&
315
+ runtime.lifeEvidence.workEventCount === 0,
316
+ trigger: signal.trigger,
317
+ });
318
+ };
319
+ let hasCandidates = false;
320
+ let anyAllow = false;
321
+ let anyDefer = false;
322
+ let anyDeny = false;
323
+ const denyReasons = [];
324
+ for (const intent of candidates) {
325
+ hasCandidates = true;
326
+ const evaluation = evaluateHardGuards(intent, runtime);
327
+ if (evaluation.verdict === "allow") {
328
+ anyAllow = true;
329
+ const base = {
330
+ scope: "rhythm",
331
+ status: "intent_selected",
332
+ selectedIntentId: intent.id,
333
+ reasons: evaluation.reasons,
334
+ };
335
+ const resolved = await resolveAllowedIntentResult(intent, runtime, inputs, signal, deps);
336
+ const result = resolved.status === "intent_selected" &&
337
+ resolved.reasons.length === 0 &&
338
+ evaluation.reasons.length > 0
339
+ ? { ...resolved, reasons: evaluation.reasons }
340
+ : resolved;
341
+ await emitTrace(result);
342
+ await maybeUpdateNarrativeState(result, intent, runtime, deps.narrativeStateStore, deps.recordDecisionTrace, signal, deps.recordNarrativeTrace);
343
+ return result;
344
+ }
345
+ if (evaluation.verdict === "defer") {
346
+ anyDefer = true;
347
+ denyReasons.push(`${intent.id}:${evaluation.verdict}(${evaluation.reasons.join(",")})`);
348
+ continue;
349
+ }
350
+ anyDeny = true;
351
+ denyReasons.push(`${intent.id}:${evaluation.verdict}(${evaluation.reasons.join(",")})`);
352
+ }
353
+ if (!hasCandidates) {
354
+ const result = {
355
+ scope: "rhythm",
356
+ status: "heartbeat_ok",
357
+ reasons: ["silent_no_candidates"],
358
+ };
359
+ await emitTrace(result);
360
+ await maybeUpdateNarrativeState(result, undefined, runtime, deps.narrativeStateStore, deps.recordDecisionTrace, signal, deps.recordNarrativeTrace);
361
+ return result;
362
+ }
363
+ if (!anyAllow && anyDefer && !anyDeny) {
364
+ const result = {
365
+ scope: "rhythm",
366
+ status: "deferred",
367
+ reasons: denyReasons.length > 0 ? denyReasons : ["all_candidates_deferred"],
368
+ };
369
+ await emitTrace(result);
370
+ await maybeUpdateNarrativeState(result, undefined, runtime, deps.narrativeStateStore, deps.recordDecisionTrace, signal, deps.recordNarrativeTrace);
371
+ return result;
372
+ }
373
+ if (!anyAllow && denyReasons.length > 0) {
374
+ const result = {
375
+ scope: "rhythm",
376
+ status: "denied",
377
+ reasons: denyReasons,
378
+ };
379
+ await emitTrace(result);
380
+ await maybeUpdateNarrativeState(result, undefined, runtime, deps.narrativeStateStore, deps.recordDecisionTrace, signal, deps.recordNarrativeTrace);
381
+ return result;
382
+ }
383
+ const result = {
384
+ scope: "rhythm",
385
+ status: "heartbeat_ok",
386
+ reasons: ["no_allow_verdict"],
387
+ };
388
+ await emitTrace(result);
389
+ await maybeUpdateNarrativeState(result, undefined, runtime, deps.narrativeStateStore, deps.recordDecisionTrace, signal, deps.recordNarrativeTrace);
390
+ return result;
391
+ }
392
+ /**
393
+ * Build a snapshot directly from inputs (for testing or when state-system is unavailable).
394
+ */
395
+ export function buildSnapshotFromInputs(inputs) {
396
+ return buildContinuitySnapshot(inputs);
397
+ }