@haaaiawd/second-nature 0.1.25 → 0.1.26

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 (34) hide show
  1. package/index.js +1 -0
  2. package/openclaw.plugin.json +1 -1
  3. package/package.json +1 -1
  4. package/runtime/cli/commands/goal.d.ts +2 -0
  5. package/runtime/cli/commands/goal.js +5 -1
  6. package/runtime/cli/commands/index.js +1 -1
  7. package/runtime/cli/explain/resolve-subject.js +3 -0
  8. package/runtime/cli/ops/ops-router.js +13 -5
  9. package/runtime/cli/ops/workspace-heartbeat-runner.d.ts +6 -0
  10. package/runtime/cli/ops/workspace-heartbeat-runner.js +35 -1
  11. package/runtime/cli/read-models/index.js +81 -10
  12. package/runtime/cli/read-models/types.d.ts +10 -3
  13. package/runtime/core/second-nature/feedback/owner-reply-feedback.d.ts +46 -0
  14. package/runtime/core/second-nature/feedback/owner-reply-feedback.js +159 -0
  15. package/runtime/core/second-nature/heartbeat/heartbeat-loop.d.ts +8 -1
  16. package/runtime/core/second-nature/heartbeat/heartbeat-loop.js +45 -4
  17. package/runtime/core/second-nature/heartbeat/runtime-snapshot.d.ts +2 -0
  18. package/runtime/core/second-nature/heartbeat/runtime-snapshot.js +1 -1
  19. package/runtime/core/second-nature/heartbeat/snapshot-builder.d.ts +16 -2
  20. package/runtime/core/second-nature/index.d.ts +1 -0
  21. package/runtime/core/second-nature/index.js +1 -0
  22. package/runtime/core/second-nature/orchestrator/goal-priority.d.ts +14 -2
  23. package/runtime/core/second-nature/orchestrator/goal-priority.js +2 -2
  24. package/runtime/core/second-nature/orchestrator/intent-planner.d.ts +29 -1
  25. package/runtime/core/second-nature/orchestrator/intent-planner.js +154 -79
  26. package/runtime/core/second-nature/orchestrator/narrative-update.js +23 -9
  27. package/runtime/core/second-nature/orchestrator/platform-capability-router.d.ts +34 -0
  28. package/runtime/core/second-nature/orchestrator/platform-capability-router.js +115 -0
  29. package/runtime/observability/query/explain-query.d.ts +3 -0
  30. package/runtime/observability/query/explain-query.js +9 -0
  31. package/runtime/shared/types/credential.d.ts +1 -1
  32. package/runtime/storage/chronicle/session-chronicle-store.d.ts +1 -1
  33. package/runtime/storage/services/credential-vault.d.ts +18 -0
  34. package/runtime/storage/services/credential-vault.js +73 -3
package/index.js CHANGED
@@ -7,6 +7,7 @@
7
7
  * runtime graph currently contains async sql.js bootstrap that breaks vm sandbox loading
8
8
  * - expose a minimal in-memory activation spine so status/lifecycle stay truthful even when
9
9
  * the full workspace runtime is not loaded inside the host
10
+ * - T4.2.1: owner reply ingestion → RelationshipMemory feedback (full runtime only)
10
11
  *
11
12
  * Dependencies:
12
13
  * - only imports runtime lifecycle/service modules that are synchronous at load time
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "second-nature",
3
3
  "name": "Second Nature",
4
- "version": "0.1.23",
4
+ "version": "0.1.26",
5
5
  "description": "OpenClaw native plugin with synchronous surface registration and bundled runtime spine. Set SECOND_NATURE_WORKSPACE_ROOT or tool workspaceRoot to the same path as the agent workspace (see README / T1.1.4 ops norm).",
6
6
  "activation": {
7
7
  "onStartup": true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haaaiawd/second-nature",
3
- "version": "0.1.25",
3
+ "version": "0.1.26",
4
4
  "description": "OpenClaw native plugin with synchronous registration, a packaged runtime artifact, and operator-facing status/explain flows.",
5
5
  "keywords": [
6
6
  "openclaw",
@@ -4,6 +4,8 @@ export interface GoalCommandInput {
4
4
  goalId?: string;
5
5
  description?: string;
6
6
  completionCriteria?: string;
7
+ /** T1.4.2 — alias for `completionCriteria`. */
8
+ criteria?: string;
7
9
  risk?: "low" | "medium" | "high";
8
10
  kind?: "short_term" | "long_term";
9
11
  statusFilter?: string;
@@ -43,13 +43,17 @@ export async function goalCommand(stateDb, input) {
43
43
  }
44
44
  const goalId = input.goalId?.trim() || randomUUID();
45
45
  const now = new Date().toISOString();
46
+ // T1.4.2: `criteria` is an alias for `completionCriteria`.
47
+ const completionCriteria = input.completionCriteria?.trim() ||
48
+ input.criteria?.trim() ||
49
+ "";
46
50
  await store.upsertAgentGoal({
47
51
  goalId,
48
52
  kind: input.kind ?? "short_term",
49
53
  status: "accepted",
50
54
  origin: "owner_set",
51
55
  description,
52
- completionCriteria: input.completionCriteria?.trim() || "",
56
+ completionCriteria,
53
57
  risk: input.risk ?? "low",
54
58
  priorityHint: 0,
55
59
  sourceRefs: [],
@@ -136,7 +136,7 @@ export function createCliCommands(deps) {
136
136
  return explainSubjectError("EXPLAIN_SUBJECT_REQUIRES_ID", "subject must include identifier");
137
137
  }
138
138
  if (code === "explain_subject_unsupported") {
139
- return explainSubjectError("EXPLAIN_SUBJECT_UNSUPPORTED", "supported subjects include decision:, platform:, outreach:, soul:, fallback:, delivery:, probe:, report:, source:");
139
+ return explainSubjectError("EXPLAIN_SUBJECT_UNSUPPORTED", "supported subjects include decision:, platform:, outreach:, soul:, fallback:, delivery:, probe:, report:, source:, relationship:");
140
140
  }
141
141
  return explainSubjectError("EXPLAIN_SUBJECT_INVALID", "invalid explain subject");
142
142
  }
@@ -37,5 +37,8 @@ export function resolveExplainSubject(raw) {
37
37
  if (prefix === "source" || prefix === "source_ref") {
38
38
  return { kind: "source_ref", id };
39
39
  }
40
+ if (prefix === "relationship") {
41
+ return { kind: "relationship", id };
42
+ }
40
43
  throw new Error("explain_subject_unsupported");
41
44
  }
@@ -219,13 +219,21 @@ export function createOpsRouter(deps) {
219
219
  const action = ["set", "list", "accept", "reject"].includes(rawAction)
220
220
  ? rawAction
221
221
  : "list";
222
+ const sanitizeText = (v, maxLen = 1000) => {
223
+ if (typeof v !== "string")
224
+ return undefined;
225
+ const trimmed = v.trim();
226
+ if (trimmed.length === 0)
227
+ return undefined;
228
+ return trimmed.slice(0, maxLen);
229
+ };
222
230
  return goalCommand(deps.state, {
223
231
  action,
224
- goalId: typeof input?.goalId === "string" ? input.goalId : undefined,
225
- description: typeof input?.description === "string" ? input.description : undefined,
226
- completionCriteria: typeof input?.completionCriteria === "string"
227
- ? input.completionCriteria
228
- : undefined,
232
+ goalId: typeof input?.goalId === "string" ? input.goalId.trim().slice(0, 128) : undefined,
233
+ description: sanitizeText(input?.description),
234
+ completionCriteria: sanitizeText(input?.completionCriteria),
235
+ // T1.4.2: criteria alias for completionCriteria
236
+ criteria: sanitizeText(input?.criteria),
229
237
  risk: typeof input?.risk === "string"
230
238
  ? input.risk
231
239
  : undefined,
@@ -18,6 +18,7 @@ import type { CliReadModels } from "../read-models/index.js";
18
18
  import type { RuntimeDecisionRecorder } from "../../observability/services/runtime-decision-recorder.js";
19
19
  import type { StateDatabase } from "../../storage/db/index.js";
20
20
  import type { ConnectorExecutor } from "../../core/second-nature/orchestrator/effect-dispatcher.js";
21
+ import type { CapabilityContractRegistry } from "../../connectors/base/manifest.js";
21
22
  export interface WorkspaceHeartbeatRunnerOptions {
22
23
  /** When supplied, the runner persists the cycle so `loadStatus` can read it (T1.2.3). */
23
24
  runtimeRecorder?: RuntimeDecisionRecorder;
@@ -38,6 +39,11 @@ export interface WorkspaceHeartbeatRunnerOptions {
38
39
  * connector-system instead of returning connector_dispatch_unwired.
39
40
  */
40
41
  connectorExecutor?: ConnectorExecutor;
42
+ /**
43
+ * T2.4.1: when present, planner resolves platform-specific intents from accepted goals
44
+ * and connector evidence.
45
+ */
46
+ connectorRegistry?: CapabilityContractRegistry;
41
47
  }
42
48
  export declare function loadSnapshotInputsForWorkspaceHeartbeat(readModels: CliReadModels, options?: {
43
49
  state?: StateDatabase;
@@ -2,6 +2,7 @@ import { runHeartbeatCycle } from "../../core/second-nature/heartbeat/run-heartb
2
2
  import { loadLifeEvidenceSnapshot } from "../../storage/snapshots/life-evidence-snapshot.js";
3
3
  import { createAgentGoalStore } from "../../storage/goal/agent-goal-store.js";
4
4
  import { createNarrativeStateStore } from "../../storage/narrative/narrative-state-store.js";
5
+ import { createRelationshipMemoryStore } from "../../storage/relationship/relationship-memory-store.js";
5
6
  export async function loadSnapshotInputsForWorkspaceHeartbeat(readModels, options = {}) {
6
7
  const status = await readModels.loadStatus();
7
8
  const mode = status.rhythm.mode === "unknown" ? "active" : status.rhythm.mode;
@@ -30,6 +31,8 @@ export async function loadSnapshotInputsForWorkspaceHeartbeat(readModels, option
30
31
  platformEventCount = snapshot.platformEvents.length;
31
32
  workEventCount = snapshot.workEvents.length;
32
33
  if (snapshot.empty) {
34
+ // L-01: Currently snapshot only exposes `empty` boolean.
35
+ // Future: if snapshot adds `emptyReason` (e.g. "redacted_only"), map it here.
33
36
  lifeEvidenceEmptyReason = "no_sources";
34
37
  }
35
38
  }
@@ -46,7 +49,9 @@ export async function loadSnapshotInputsForWorkspaceHeartbeat(readModels, option
46
49
  lifeEvidenceEmptyReason = "state_unavailable";
47
50
  }
48
51
  // T2.1.4: Load accepted goals from state DB when available.
52
+ // M-03: typed as GoalContext to avoid coupling to the full AgentGoal schema.
49
53
  let acceptedGoals;
54
+ let acceptedGoalsLoadError;
50
55
  if (options.state) {
51
56
  try {
52
57
  const goalStore = createAgentGoalStore(options.state);
@@ -55,8 +60,29 @@ export async function loadSnapshotInputsForWorkspaceHeartbeat(readModels, option
55
60
  limit: 20,
56
61
  });
57
62
  }
63
+ catch (err) {
64
+ acceptedGoals = [];
65
+ acceptedGoalsLoadError = err instanceof Error ? err.message : String(err);
66
+ // H-05: Distinguish "load failed" from "no goals" for observability.
67
+ }
68
+ }
69
+ // CR-02: Load narrative state and relationship memory when state is available.
70
+ let narrativeState;
71
+ let relationshipMemory;
72
+ if (options.state) {
73
+ try {
74
+ const narrativeStore = createNarrativeStateStore(options.state);
75
+ narrativeState = (await narrativeStore.loadNarrativeState()) ?? undefined;
76
+ }
77
+ catch {
78
+ // Narrative state is optional; failure should not block the cycle.
79
+ }
80
+ try {
81
+ const relationshipStore = createRelationshipMemoryStore(options.state);
82
+ relationshipMemory = (await relationshipStore.loadRelationshipMemory()) ?? undefined;
83
+ }
58
84
  catch {
59
- acceptedGoals = undefined;
85
+ // Relationship memory is optional; failure should not block the cycle.
60
86
  }
61
87
  }
62
88
  return {
@@ -74,6 +100,9 @@ export async function loadSnapshotInputsForWorkspaceHeartbeat(readModels, option
74
100
  workEventCount,
75
101
  lifeEvidenceEmptyReason,
76
102
  acceptedGoals,
103
+ acceptedGoalsLoadError,
104
+ narrativeState,
105
+ relationshipMemory,
77
106
  };
78
107
  }
79
108
  export function createWorkspaceHeartbeatRunner(readModels, options = {}) {
@@ -99,6 +128,11 @@ export function createWorkspaceHeartbeatRunner(readModels, options = {}) {
99
128
  : undefined,
100
129
  connectorExecutor: options.connectorExecutor,
101
130
  narrativeStateStore,
131
+ // T3.3.1: pass state + workspaceRoot so connector effects can write life evidence.
132
+ state: options.state,
133
+ workspaceRoot: options.workspaceRoot,
134
+ // T2.4.1: pass registry so planner resolves platform-specific intents.
135
+ connectorRegistry: options.connectorRegistry,
102
136
  },
103
137
  });
104
138
  if (options.runtimeRecorder) {
@@ -12,6 +12,8 @@ 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
14
  import { createNarrativeStateStore } from "../../storage/narrative/narrative-state-store.js";
15
+ import { createRelationshipMemoryStore } from "../../storage/relationship/relationship-memory-store.js";
16
+ import { probeCredentialHealth } from "../../storage/services/credential-vault.js";
15
17
  const INTERNAL_RUNTIME_PLATFORM_ID = "second-nature-runtime";
16
18
  const INTERNAL_RUNTIME_TRACE_PREFIX = "sn-runtime-";
17
19
  function toExplainQuery(subject) {
@@ -31,6 +33,8 @@ function toExplainQuery(subject) {
31
33
  return { kind: "delivery", auditId: subject.id };
32
34
  case "source_ref":
33
35
  return { kind: "source_ref", sourceRefId: subject.id };
36
+ case "relationship":
37
+ return { kind: "relationship", relationshipId: subject.id };
34
38
  default:
35
39
  return undefined;
36
40
  }
@@ -47,6 +51,8 @@ function buildCredentialNextStep(status) {
47
51
  return "submit_verification_answer";
48
52
  if (status === "expired" || status === "revoked" || status === "failed")
49
53
  return "refresh_credential_context";
54
+ if (status === "decrypt_failed")
55
+ return "verify_or_re_create_credential_then_re_import";
50
56
  return undefined;
51
57
  }
52
58
  /**
@@ -207,12 +213,28 @@ async function buildBaseStatus(deps) {
207
213
  : undefined,
208
214
  },
209
215
  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
+ credentials: credentials.map((item) => {
217
+ const platformId = item.platformId ??
218
+ item.platform_id;
219
+ const encryptedValue = item.encryptedValue ??
220
+ item.encrypted_value;
221
+ const baseUrl = item.baseUrl ??
222
+ item.base_url;
223
+ const health = probeCredentialHealth(platformId, encryptedValue, baseUrl);
224
+ const effectiveStatus = health.state === "decrypt_failed"
225
+ ? "decrypt_failed"
226
+ : item.status;
227
+ return {
228
+ platformId,
229
+ status: effectiveStatus,
230
+ nextStep: health.diagnosticCode === "missing_runtime_secret"
231
+ ? "set_SECOND_NATURE_ENCRYPTION_KEY_then_re_probe"
232
+ : health.diagnosticCode === "credential_recovery_required"
233
+ ? "verify_or_re_create_credential_then_re_import"
234
+ : buildCredentialNextStep(effectiveStatus),
235
+ keyHealth: health.keyHealth,
236
+ };
237
+ }),
216
238
  risk: {
217
239
  level: riskFlags.length > 0 ? "medium" : "low",
218
240
  flags: riskFlags,
@@ -325,19 +347,40 @@ export function createCliReadModels(deps) {
325
347
  record = undefined;
326
348
  }
327
349
  if (!record) {
350
+ // T1.4.1: even when no row exists, probe key health so status can surface
351
+ // missing_runtime_secret rather than a generic "missing".
352
+ const health = probeCredentialHealth(platformId, null, null);
328
353
  return {
329
354
  platformId,
330
- status: "missing",
331
- nextStep: "provide_credential_context",
355
+ status: health.state,
356
+ nextStep: health.diagnosticCode === "missing_runtime_secret"
357
+ ? "set_SECOND_NATURE_ENCRYPTION_KEY_then_re_probe"
358
+ : "provide_credential_context",
359
+ keyHealth: health.keyHealth,
332
360
  };
333
361
  }
362
+ // T1.4.1: attempt decryption to detect decrypt_failed / wrong_key.
363
+ const encryptedValue = record.encryptedValue ??
364
+ record.encrypted_value;
365
+ const baseUrl = record.baseUrl ??
366
+ record.base_url;
367
+ const health = probeCredentialHealth(platformId, encryptedValue, baseUrl);
368
+ // If decryption failed, surface the honest diagnostic; otherwise surface DB status.
369
+ const effectiveStatus = health.state === "decrypt_failed"
370
+ ? "decrypt_failed"
371
+ : record.status;
334
372
  return {
335
373
  platformId: record.platformId ??
336
374
  record.platform_id,
337
- status: record.status,
375
+ status: effectiveStatus,
338
376
  verificationDeadline: record.expiresAt ?? undefined,
339
377
  attemptsRemaining: record.attemptsRemaining ?? undefined,
340
- nextStep: buildCredentialNextStep(record.status),
378
+ nextStep: health.diagnosticCode === "missing_runtime_secret"
379
+ ? "set_SECOND_NATURE_ENCRYPTION_KEY_then_re_probe"
380
+ : health.diagnosticCode === "credential_recovery_required"
381
+ ? "verify_or_re_create_credential_then_re_import"
382
+ : buildCredentialNextStep(effectiveStatus),
383
+ keyHealth: health.keyHealth,
341
384
  };
342
385
  },
343
386
  async loadFallbackView(ref) {
@@ -390,6 +433,34 @@ export function createCliReadModels(deps) {
390
433
  evidenceRefs: [],
391
434
  };
392
435
  }
436
+ // T1.4.2: relationship explain reads RelationshipMemory store directly.
437
+ if (subject.kind === "relationship") {
438
+ const relationshipStore = createRelationshipMemoryStore(deps.stateDb);
439
+ const memory = await relationshipStore.loadRelationshipMemory(subject.id);
440
+ if (!memory) {
441
+ return {
442
+ subjectType: "relationship",
443
+ conclusion: "nothing_yet",
444
+ keyFactors: ["no_relationship_memory_recorded"],
445
+ evidenceRefs: [],
446
+ nextStep: "interact_with_agent_then_re_check",
447
+ };
448
+ }
449
+ return {
450
+ subjectType: "relationship",
451
+ conclusion: `tone:${memory.tonePreference} replies:${memory.noReplyCount === 0 ? "responsive" : "cooldown"}`,
452
+ keyFactors: [
453
+ `tone_preference:${memory.tonePreference}`,
454
+ ...(memory.averageReplyDelayMinutes
455
+ ? [`avg_reply_delay_minutes:${memory.averageReplyDelayMinutes}`]
456
+ : []),
457
+ ...(memory.topicAffinities.length > 0
458
+ ? [`topics:${memory.topicAffinities.map((t) => t.topic).join(",")}`]
459
+ : ["insufficient_history"]),
460
+ ],
461
+ evidenceRefs: memory.sourceRefs.map((s) => s.sourceId),
462
+ };
463
+ }
393
464
  const query = subject.kind === "decision" ||
394
465
  subject.kind === "platform-selection" ||
395
466
  subject.kind === "outreach"
@@ -25,8 +25,10 @@ export interface ConnectorSummary {
25
25
  }
26
26
  export interface CredentialSummary {
27
27
  platformId: string;
28
- status: "missing" | "pending_verification" | "active" | "expired" | "revoked" | "failed";
28
+ status: "missing" | "pending_verification" | "active" | "expired" | "revoked" | "failed" | "decrypt_failed";
29
29
  nextStep?: string;
30
+ /** T1.4.1 — diagnostic key health without leaking raw secret. */
31
+ keyHealth?: "missing_key" | "wrong_key" | "ok";
30
32
  }
31
33
  export interface RiskSummary {
32
34
  level: "low" | "medium" | "high";
@@ -129,12 +131,17 @@ export interface SessionDetailReadModel {
129
131
  }
130
132
  export interface CredentialReadModel {
131
133
  platformId: string;
132
- status: "missing" | "pending_verification" | "active" | "expired" | "revoked" | "failed";
134
+ status: "missing" | "pending_verification" | "active" | "expired" | "revoked" | "failed" | "decrypt_failed";
133
135
  verificationDeadline?: string;
134
136
  attemptsRemaining?: number;
135
137
  nextStep?: string;
138
+ /**
139
+ * T1.4.1 — redacted diagnostic: when true, the raw encrypted value could not be
140
+ * decrypted because SECOND_NATURE_ENCRYPTION_KEY is missing or wrong.
141
+ */
142
+ keyHealth?: "missing_key" | "wrong_key" | "ok";
136
143
  }
137
- export type ExplainSubjectKind = "decision" | "platform-selection" | "outreach" | "soul-change" | "fallback" | "probe" | "delivery" | "report" | "source_ref";
144
+ export type ExplainSubjectKind = "decision" | "platform-selection" | "outreach" | "soul-change" | "fallback" | "probe" | "delivery" | "report" | "source_ref" | "relationship";
138
145
  export interface ExplainReadModel {
139
146
  subjectType: ExplainSubjectKind;
140
147
  conclusion: string;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * T4.2.1 — Owner reply ingestion and RelationshipMemory feedback loop.
3
+ *
4
+ * When an owner replies to an outreach, this function:
5
+ * 1. Appends a `SessionChronicle` entry with the reply context.
6
+ * 2. Loads the current `RelationshipMemory`.
7
+ * 3. Infers tone/timing/topic from the reply text and updates the memory.
8
+ * 4. Persists the updated `RelationshipMemory` with source refs pointing to the chronicle entry.
9
+ *
10
+ * Boundaries:
11
+ * - Does NOT generate outreach drafts; that is the guidance layer's job.
12
+ * - Does NOT execute connectors; this is a pure state update path.
13
+ * - Errors in relationship update must not break chronicle write (chronicle is source of truth).
14
+ */
15
+ import type { StateDatabase } from "../../../storage/db/index.js";
16
+ import { type OwnerReplySignal } from "../../../storage/chronicle/session-chronicle-store.js";
17
+ import { type RelationshipMemory, type TopicAffinity } from "../../../storage/relationship/relationship-memory-store.js";
18
+ export interface ReplyInferenceConfig {
19
+ positiveKeywords?: string[];
20
+ negativeKeywords?: string[];
21
+ busyKeywords?: string[];
22
+ topicPatterns?: Record<string, string[]>;
23
+ }
24
+ export declare function inferTone(text: string, config?: ReplyInferenceConfig): "casual" | "direct" | "quiet" | "unknown";
25
+ export declare function inferTiming(text: string, config?: ReplyInferenceConfig): "responsive" | "busy" | undefined;
26
+ export declare function inferTopics(text: string, config?: ReplyInferenceConfig): string[];
27
+ export declare function mergeTopicAffinities(existing: TopicAffinity[], newTopics: string[]): TopicAffinity[];
28
+ export interface ProcessOwnerReplyInput {
29
+ /** The raw reply text from the owner. */
30
+ replyText: string;
31
+ /** The decisionId of the outreach this reply is responding to. */
32
+ relatedDecisionId: string;
33
+ /** Optional explicit owner signal (parsed by host or explicit UI). */
34
+ explicitSignal?: OwnerReplySignal;
35
+ }
36
+ export interface ProcessOwnerReplyResult {
37
+ chronicleEntryId: string;
38
+ relationshipUpdated: boolean;
39
+ priorMemory?: RelationshipMemory;
40
+ updatedMemory?: RelationshipMemory;
41
+ relationshipUpdateError?: string;
42
+ }
43
+ /**
44
+ * Process an owner reply: write chronicle, update RelationshipMemory.
45
+ */
46
+ export declare function processOwnerReply(input: ProcessOwnerReplyInput, state: StateDatabase): Promise<ProcessOwnerReplyResult>;
@@ -0,0 +1,159 @@
1
+ import { createSessionChronicleStore, } from "../../../storage/chronicle/session-chronicle-store.js";
2
+ import { createRelationshipMemoryStore, } from "../../../storage/relationship/relationship-memory-store.js";
3
+ const DEFAULT_POSITIVE_KEYWORDS = [
4
+ "agree", "thanks", "appreciate", "helpful", "good", "great", "love", "like", "enjoy",
5
+ "excited", "happy", "nice", "wonderful", "awesome", "perfect", "cool", "ok", "yes",
6
+ ];
7
+ const DEFAULT_NEGATIVE_KEYWORDS = [
8
+ "disagree", "frustrated", "annoying", "bad", "hate", "dislike", "angry", "upset",
9
+ "disappointed", "concerned", "no", "not", "never", "wrong", "terrible", "awful",
10
+ "useless", "stop", "don't",
11
+ ];
12
+ const DEFAULT_BUSY_KEYWORDS = [
13
+ "busy", "swamped", "occupied", "tight schedule", "no time", "later",
14
+ "overloaded", "overwhelmed", "backlog", "not now", "another time", "schedule tight",
15
+ ];
16
+ const DEFAULT_TOPIC_PATTERNS = {
17
+ work: ["work", "project", "task", "job", "delivery", "deadline"],
18
+ personal: ["family", "life", "health", "weekend", "trip"],
19
+ tech: ["code", "system", "bug", "feature", "architecture", "design"],
20
+ social: ["friend", "community", "meetup", "event", "collaboration"],
21
+ };
22
+ export function inferTone(text, config) {
23
+ const lower = text.toLowerCase();
24
+ const positiveKeywords = config?.positiveKeywords ?? DEFAULT_POSITIVE_KEYWORDS;
25
+ const negativeKeywords = config?.negativeKeywords ?? DEFAULT_NEGATIVE_KEYWORDS;
26
+ const pos = positiveKeywords.filter((w) => lower.includes(w)).length;
27
+ const neg = negativeKeywords.filter((w) => lower.includes(w)).length;
28
+ if (neg >= pos && neg > 0)
29
+ return "quiet"; // owner is negative → agent should be more reserved
30
+ if (pos > 0)
31
+ return "casual"; // positive → casual is fine
32
+ return "unknown";
33
+ }
34
+ export function inferTiming(text, config) {
35
+ const lower = text.toLowerCase();
36
+ const busyKeywords = config?.busyKeywords ?? DEFAULT_BUSY_KEYWORDS;
37
+ if (busyKeywords.some((w) => lower.includes(w)))
38
+ return "busy";
39
+ if (lower.includes("quick") || lower.includes("prompt"))
40
+ return "responsive";
41
+ return undefined;
42
+ }
43
+ export function inferTopics(text, config) {
44
+ const lower = text.toLowerCase();
45
+ const topicPatterns = config?.topicPatterns ?? DEFAULT_TOPIC_PATTERNS;
46
+ const topics = [];
47
+ for (const [topic, patterns] of Object.entries(topicPatterns)) {
48
+ if (patterns.some((p) => lower.includes(p))) {
49
+ topics.push(topic);
50
+ }
51
+ }
52
+ return topics;
53
+ }
54
+ export function mergeTopicAffinities(existing, newTopics) {
55
+ const map = new Map(existing.map((t) => [t.topic, t.affinity]));
56
+ for (const topic of newTopics) {
57
+ map.set(topic, Math.min(1, (map.get(topic) ?? 0) + 0.1));
58
+ }
59
+ return Array.from(map.entries())
60
+ .map(([topic, affinity]) => ({ topic, affinity }))
61
+ .sort((a, b) => b.affinity - a.affinity);
62
+ }
63
+ function redactSensitive(text) {
64
+ return text
65
+ .replace(/\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/g, "[REDACTED_CARD]")
66
+ .replace(/password[:\s=]+\S+/gi, "[REDACTED_PASSWORD]")
67
+ .replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, "[REDACTED_EMAIL]")
68
+ .replace(/\b\d{3}[-.\s]?\d{3}[-.\s]?\d{4}\b/g, "[REDACTED_PHONE]")
69
+ .replace(/\b\d{3}-\d{2}-\d{4}\b/g, "[REDACTED_SSN]")
70
+ .replace(/\b(?:sk-|pk-|Bearer\s+|api[_-]?key[:\s=]+)[A-Za-z0-9_\-\/+=]{20,}\b/gi, "[REDACTED_TOKEN]");
71
+ }
72
+ /**
73
+ * Process an owner reply: write chronicle, update RelationshipMemory.
74
+ */
75
+ export async function processOwnerReply(input, state) {
76
+ const chronicleStore = createSessionChronicleStore(state);
77
+ const relStore = createRelationshipMemoryStore(state);
78
+ const now = new Date().toISOString();
79
+ // 1. Write chronicle entry (source of truth)
80
+ const entryId = `owner_reply:${input.relatedDecisionId}:${Date.now()}`;
81
+ const replyText = input.replyText?.trim() ?? "";
82
+ const isEmpty = replyText.length === 0;
83
+ const tone = isEmpty ? "unknown" : inferTone(replyText);
84
+ const timing = isEmpty ? undefined : inferTiming(replyText);
85
+ const topics = isEmpty ? [] : inferTopics(replyText);
86
+ const chronicleEntry = {
87
+ entryId,
88
+ eventKind: "owner_reply",
89
+ actor: "owner",
90
+ occurredAt: now,
91
+ summary: redactSensitive(isEmpty ? "(empty reply)" : replyText.slice(0, 500)),
92
+ result: "succeeded",
93
+ sourceRefs: [{ sourceId: entryId, kind: "owner_reply", url: `chronicle://${entryId}` }],
94
+ relatedDecisionId: input.relatedDecisionId,
95
+ ownerReply: {
96
+ tone,
97
+ delayMinutes: input.explicitSignal?.delayMinutes,
98
+ topics: topics.length > 0 ? topics : input.explicitSignal?.topics,
99
+ explicitPreference: input.explicitSignal?.explicitPreference,
100
+ },
101
+ };
102
+ await chronicleStore.appendSessionChronicle(chronicleEntry);
103
+ // 2. Load and update RelationshipMemory (best-effort)
104
+ let relationshipUpdated = false;
105
+ let priorMemory;
106
+ let updatedMemory;
107
+ try {
108
+ priorMemory = (await relStore.loadRelationshipMemory()) ?? undefined;
109
+ const nextRevision = (priorMemory?.revision ?? 0) + 1;
110
+ const topicAffinities = mergeTopicAffinities(priorMemory?.topicAffinities ?? [], topics);
111
+ const update = {
112
+ relationshipId: priorMemory?.relationshipId ?? "default",
113
+ revision: nextRevision,
114
+ tonePreference: tone !== "unknown" ? tone : (priorMemory?.tonePreference ?? "unknown"),
115
+ averageReplyDelayMinutes: input.explicitSignal?.delayMinutes ?? priorMemory?.averageReplyDelayMinutes,
116
+ noReplyCount: 0, // owner replied → reset counter
117
+ topicAffinities: topicAffinities.length > 0 ? topicAffinities : (priorMemory?.topicAffinities ?? []),
118
+ lastInteractionAt: now,
119
+ sourceRefs: [
120
+ ...(priorMemory?.sourceRefs ?? []),
121
+ { sourceId: entryId, kind: "owner_reply_feedback", url: `chronicle://${entryId}` },
122
+ ],
123
+ updatedAt: now,
124
+ };
125
+ await relStore.upsertRelationshipMemory(update);
126
+ updatedMemory = (await relStore.loadRelationshipMemory()) ?? undefined;
127
+ relationshipUpdated = true;
128
+ }
129
+ catch (err) {
130
+ // Relationship update is best-effort; chronicle is the source of truth.
131
+ // Missing memory update will be reflected in the next `explain relationship` query.
132
+ const errorMessage = err instanceof Error ? err.message : String(err);
133
+ console.warn(`[owner-reply-feedback] RelationshipMemory update failed: ${errorMessage}`);
134
+ // Write a diagnostic chronicle entry so operators can trace the failure.
135
+ await chronicleStore.appendSessionChronicle({
136
+ entryId: `${entryId}:diagnostic`,
137
+ eventKind: "system_notice",
138
+ actor: "system",
139
+ occurredAt: new Date().toISOString(),
140
+ summary: `RelationshipMemory update failed: ${errorMessage}`,
141
+ result: "failed",
142
+ sourceRefs: [{ sourceId: entryId, kind: "owner_reply_feedback", url: `chronicle://${entryId}` }],
143
+ relatedDecisionId: input.relatedDecisionId,
144
+ });
145
+ return {
146
+ chronicleEntryId: entryId,
147
+ relationshipUpdated: false,
148
+ priorMemory,
149
+ updatedMemory,
150
+ relationshipUpdateError: errorMessage,
151
+ };
152
+ }
153
+ return {
154
+ chronicleEntryId: entryId,
155
+ relationshipUpdated,
156
+ priorMemory,
157
+ updatedMemory,
158
+ };
159
+ }
@@ -20,6 +20,7 @@ import type { GuidanceDraftPort } from "../../../guidance/outreach-draft-schema.
20
20
  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
+ import type { CapabilityContractRegistry } from "../../../connectors/base/manifest.js";
23
24
  import type { NarrativeStateStore } from "../../../storage/narrative/narrative-state-store.js";
24
25
  import type { NarrativeTracePayload } from "../../../observability/services/lived-experience-audit.js";
25
26
  export interface HeartbeatDecisionTracePayload {
@@ -47,7 +48,7 @@ export interface HeartbeatQuietWorkflowDeps {
47
48
  * Resolves the heartbeat outcome for a guard-allowed intent (outreach dispatch, quiet orchestration, or default).
48
49
  * Exported for unit tests (CR-M1 wiring).
49
50
  */
50
- export declare function resolveAllowedIntentResult(intent: CandidateIntent, runtime: HeartbeatRuntimeSnapshot, inputs: SnapshotInputs, signal: HeartbeatSignal, deps: Pick<HeartbeatDeps, "outreachDispatch" | "quietWorkflow" | "connectorExecutor">): Promise<HeartbeatCycleResult>;
51
+ export declare function resolveAllowedIntentResult(intent: CandidateIntent, runtime: HeartbeatRuntimeSnapshot, inputs: SnapshotInputs, signal: HeartbeatSignal, deps: Pick<HeartbeatDeps, "outreachDispatch" | "quietWorkflow" | "connectorExecutor" | "state" | "workspaceRoot">): Promise<HeartbeatCycleResult>;
51
52
  export interface HeartbeatDeps {
52
53
  /** Load snapshot inputs from state-system */
53
54
  loadSnapshotInputs: () => Promise<SnapshotInputs>;
@@ -64,6 +65,12 @@ export interface HeartbeatDeps {
64
65
  narrativeStateStore?: NarrativeStateStore;
65
66
  /** T5.1.2: when present, heartbeat records a NarrativeTrace after successful narrative state update. */
66
67
  recordNarrativeTrace?: (payload: NarrativeTracePayload) => Promise<void>;
68
+ /** T3.3.1: when present, successful connector effects write LifeEvidence artifacts. */
69
+ state?: StateDatabase;
70
+ /** T3.3.1: workspace root for evidence artifact paths. */
71
+ workspaceRoot?: string;
72
+ /** T2.4.1: when present, planner resolves platform-specific intents. */
73
+ connectorRegistry?: CapabilityContractRegistry;
67
74
  }
68
75
  /**
69
76
  * Ingest a heartbeat rhythm signal and drive one full decision round.