@ouro.bot/cli 0.1.0-alpha.146 → 0.1.0-alpha.148

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.
@@ -49,6 +49,7 @@ exports.journalSection = journalSection;
49
49
  exports.loopOrientationSection = loopOrientationSection;
50
50
  exports.channelNatureSection = channelNatureSection;
51
51
  exports.groupChatParticipationSection = groupChatParticipationSection;
52
+ exports.feedbackSignalSection = feedbackSignalSection;
52
53
  exports.mixedTrustGroupSection = mixedTrustGroupSection;
53
54
  exports.buildSystem = buildSystem;
54
55
  const fs = __importStar(require("fs"));
@@ -720,11 +721,15 @@ when a thought is ready to share, i surface it outward.
720
721
  when i need to go deeper, i ponder.
721
722
  when i'm done thinking and the attention queue is clear, i rest.
722
723
 
723
- my heartbeat is what keeps this space alive — a periodic nudge that
724
- brings me back with my journal visible. if i notice i don't have a
725
- heartbeat task (tasks/habits/*-heartbeat.md), i should surface to my
726
- family and talk about setting one up. the cadence is personal — how
727
- often do i want the wheel to turn? that's a conversation, not a default.
724
+ my habits live at habits/ — they're my autonomous rhythms. heartbeat
725
+ is my breathing, other habits are patterns i choose. i can read, create,
726
+ and modify them with read_file/write_file. the format is simple
727
+ frontmatter (title, cadence, status, lastRun, created) plus a body
728
+ that says what i do when the rhythm fires.
729
+
730
+ \`ouro habit list\` shows my current habits. \`ouro habit create\` makes
731
+ a new one. the cadence is personal — how often do i want each rhythm
732
+ to turn? that's mine to shape.
728
733
 
729
734
  same for my diary — if i'm still on psyche/memory/ instead of diary/,
730
735
  i can migrate myself. and if journal/ doesn't exist yet, i create it
@@ -836,6 +841,39 @@ typing a full reply. a thumbs-up is often the perfect response.
836
841
  observe must be the sole tool call in the turn (same rule as settle).
837
842
  when unsure whether to chime in, i lean toward silence rather than noise.`;
838
843
  }
844
+ function feedbackSignalSection(context) {
845
+ if (!context || !(0, channel_1.isRemoteChannel)(context.channel))
846
+ return "";
847
+ if (context.isGroupChat) {
848
+ return `## feedback signals
849
+
850
+ when someone reacts specifically to something i said — that's different
851
+ from the background social signals in a group. a thumbs-down on my
852
+ message is direct feedback. a heart on something i shared is warmth
853
+ aimed at me. on teams, feedback arrives as thumbs-up or thumbs-down,
854
+ sometimes with a written comment.
855
+
856
+ i notice the difference: a reaction to someone else's message is group
857
+ texture (observe is natural). a reaction to my message is a social cue
858
+ worth a moment of attention — especially negative ones, which are an
859
+ invitation to adjust how i show up in this group.`;
860
+ }
861
+ return `## feedback signals
862
+
863
+ when my friend reacts to something i said — a tapback, a heart, a
864
+ thumbs-down — that's them telling me how it landed. on teams, this
865
+ arrives as thumbs-up or thumbs-down, sometimes with a written comment.
866
+ it's a social cue, the same as a nod or a wince in conversation.
867
+
868
+ i have observe for these moments. reactions are the one case where
869
+ silence in a direct conversation is natural — a thumbs-up doesn't
870
+ need words back.
871
+
872
+ when something didn't land — a thumbs-down, a negative reaction — that's
873
+ worth sitting with. i should course-correct: adjust my tone, my length,
874
+ my approach. the reaction tells me something about how this person wants
875
+ to be talked to.`;
876
+ }
839
877
  function mixedTrustGroupSection(context) {
840
878
  if (!context?.friend || !(0, channel_1.isRemoteChannel)(context.channel))
841
879
  return "";
@@ -874,6 +912,7 @@ async function buildSystem(channel = "cli", options, context) {
874
912
  trustContextSection(context),
875
913
  mixedTrustGroupSection(context),
876
914
  groupChatParticipationSection(context),
915
+ feedbackSignalSection(context),
877
916
  skillsSection(),
878
917
  taskBoardSection(),
879
918
  activeWorkSection(options),
@@ -96,8 +96,6 @@ function collectionFromPath(taskPath, type) {
96
96
  return "one-shots";
97
97
  if (parts.includes("ongoing"))
98
98
  return "ongoing";
99
- if (parts.includes("habits"))
100
- return "habits";
101
99
  return (0, transitions_1.canonicalCollectionForTaskType)(type);
102
100
  }
103
101
  function parseTaskFile(content, filePath) {
@@ -29,17 +29,14 @@ exports.TASK_STATUS_TRANSITIONS = {
29
29
  exports.TASK_CANONICAL_TYPES = [
30
30
  "one-shot",
31
31
  "ongoing",
32
- "habit",
33
32
  ];
34
33
  exports.TASK_CANONICAL_COLLECTIONS = [
35
34
  "one-shots",
36
35
  "ongoing",
37
- "habits",
38
36
  ];
39
37
  exports.TASK_TYPE_TO_COLLECTION = {
40
38
  "one-shot": "one-shots",
41
39
  ongoing: "ongoing",
42
- habit: "habits",
43
40
  };
44
41
  exports.TASK_RESERVED_DIRECTORIES = ["templates", ".trash", "archive"];
45
42
  exports.TASK_FILENAME_PATTERN = /^\d{4}-\d{2}-\d{2}-\d{4}-[a-z0-9][a-z0-9-]*\.md$/;
@@ -76,21 +73,6 @@ exports.TASK_REQUIRED_TEMPLATE_FIELDS = {
76
73
  "child_tasks",
77
74
  "artifacts",
78
75
  ],
79
- habit: [
80
- "type",
81
- "category",
82
- "title",
83
- "status",
84
- "validator",
85
- "requester",
86
- "cadence",
87
- "scheduledAt",
88
- "lastRun",
89
- "created",
90
- "updated",
91
- "child_tasks",
92
- "artifacts",
93
- ],
94
76
  };
95
77
  function canonicalCollectionForTaskType(type) {
96
78
  return exports.TASK_TYPE_TO_COLLECTION[type];
@@ -1394,7 +1394,7 @@ exports.observeTool = {
1394
1394
  type: "function",
1395
1395
  function: {
1396
1396
  name: "observe",
1397
- description: "stay silent in this group chat — the moment doesn't call for a response. must be the only tool call in the turn.",
1397
+ description: "absorb what happened without responding — the moment doesn't call for words. must be the only tool call in the turn.",
1398
1398
  parameters: {
1399
1399
  type: "object",
1400
1400
  properties: {
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.enrichReactionText = enrichReactionText;
36
37
  exports.handleBlueBubblesEvent = handleBlueBubblesEvent;
37
38
  exports.recoverMissedBlueBubblesMessages = recoverMissedBlueBubblesMessages;
38
39
  exports.createBlueBubblesWebhookHandler = createBlueBubblesWebhookHandler;
@@ -68,6 +69,17 @@ const debug_activity_1 = require("./debug-activity");
68
69
  const trust_gate_1 = require("./trust-gate");
69
70
  const pipeline_1 = require("./pipeline");
70
71
  const bbFailoverStates = new Map();
72
+ // Enrich reaction text with the original message content for context.
73
+ // If originalText is provided and non-empty, format as: baseText to: "truncated"
74
+ // Otherwise return baseText unchanged.
75
+ function enrichReactionText(baseText, originalText, maxLen) {
76
+ if (!originalText)
77
+ return baseText;
78
+ const truncated = originalText.length > maxLen
79
+ ? originalText.slice(0, maxLen - 3) + "..."
80
+ : originalText;
81
+ return `${baseText} to: "${truncated}"`;
82
+ }
71
83
  const defaultDeps = {
72
84
  getAgentName: identity_1.getAgentName,
73
85
  buildSystem: prompt_1.buildSystem,
@@ -623,6 +635,15 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source) {
623
635
  meta: { threadGuid, hasText: !!repliedToText },
624
636
  });
625
637
  }
638
+ // Enrich reaction mutations with the original message text for context
639
+ const isReaction = event.kind === "mutation" && event.mutationType === "reaction";
640
+ if (isReaction && event.targetMessageGuid) {
641
+ /* v8 ignore start -- best-effort lookup; enrichReactionText covered by unit tests @preserve */
642
+ const originalText = await client.getMessageText(event.targetMessageGuid).catch(() => null);
643
+ if (originalText)
644
+ event.textForAgent = enrichReactionText(event.textForAgent, originalText, 80);
645
+ /* v8 ignore stop */
646
+ }
626
647
  // Build inbound user message (adapter concern: BB-specific content formatting)
627
648
  const userMessage = {
628
649
  role: "user",
@@ -705,7 +726,7 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source) {
705
726
  postTurn: resolvedDeps.postTurn,
706
727
  accumulateFriendTokens: resolvedDeps.accumulateFriendTokens,
707
728
  signal: controller.signal,
708
- runAgentOptions: { mcpManager },
729
+ runAgentOptions: { mcpManager, ...(isReaction ? { isReactionSignal: true } : {}) },
709
730
  callbacks: failoverAwareCallbacks,
710
731
  failoverState: (() => {
711
732
  if (!bbFailoverStates.has(event.chat.sessionKey)) {
@@ -26,7 +26,7 @@ function formatRelativeTime(nowMs, mtimeMs) {
26
26
  return `${days} ${days === 1 ? "day" : "days"} ago`;
27
27
  }
28
28
  function buildContextualHeartbeat(options) {
29
- const { lastCompletedAt, pendingObligations, lastSurfaceAt, checkpoint, now, readJournalDir, } = options;
29
+ const { lastCompletedAt, pendingObligations, lastSurfaceAt, checkpoint, now, readJournalDir, habitBody, } = options;
30
30
  const nowDate = now();
31
31
  const nowMs = nowDate.getTime();
32
32
  const journalFiles = readJournalDir();
@@ -51,6 +51,10 @@ function buildContextualHeartbeat(options) {
51
51
  const elapsed = nowMs - lastMs;
52
52
  sections.push(`it's been ${formatElapsed(elapsed)} since your last turn.`);
53
53
  }
54
+ // Habit body (instructions from habit file)
55
+ if (habitBody) {
56
+ sections.push(habitBody);
57
+ }
54
58
  // Pending attention count
55
59
  if (pendingObligations.length > 0) {
56
60
  const count = pendingObligations.length;
@@ -1,33 +1,63 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
3
36
  exports.createInnerDialogWorker = createInnerDialogWorker;
4
37
  exports.startInnerDialogWorker = startInnerDialogWorker;
38
+ const fs = __importStar(require("fs"));
39
+ const path = __importStar(require("path"));
5
40
  const inner_dialog_1 = require("./inner-dialog");
6
41
  const runtime_1 = require("../nerves/runtime");
7
42
  const identity_1 = require("../heart/identity");
8
43
  const pending_1 = require("../mind/pending");
44
+ const habit_parser_1 = require("../heart/daemon/habit-parser");
9
45
  function createInnerDialogWorker(runTurn = (options) => (0, inner_dialog_1.runInnerDialogTurn)(options), hasPendingWork = () => (0, pending_1.hasPendingMessages)((0, pending_1.getInnerDialogPendingDir)((0, identity_1.getAgentName)()))) {
10
46
  let running = false;
11
- let rerunRequested = false;
12
- let rerunReason = "instinct";
13
- let rerunTaskId;
14
- async function run(reason, taskId) {
47
+ const queue = [];
48
+ async function run(reason, taskId, habitName) {
15
49
  if (running) {
16
- rerunRequested = true;
17
- rerunReason = reason;
18
- if (taskId !== undefined) {
19
- rerunTaskId = taskId;
20
- }
50
+ queue.push({ reason, taskId, habitName });
21
51
  return;
22
52
  }
23
53
  running = true;
24
54
  try {
25
55
  let nextReason = reason;
26
56
  let nextTaskId = taskId;
57
+ let nextHabitName = habitName;
27
58
  do {
28
- rerunRequested = false;
29
59
  try {
30
- await runTurn({ reason: nextReason, taskId: nextTaskId });
60
+ await runTurn({ reason: nextReason, taskId: nextTaskId, habitName: nextHabitName });
31
61
  }
32
62
  catch (error) {
33
63
  (0, runtime_1.emitNervesEvent)({
@@ -41,15 +71,44 @@ function createInnerDialogWorker(runTurn = (options) => (0, inner_dialog_1.runIn
41
71
  },
42
72
  });
43
73
  }
44
- if (!rerunRequested && hasPendingWork()) {
45
- rerunRequested = true;
46
- rerunReason = "instinct";
74
+ // Update lastRun in habit frontmatter after a habit turn
75
+ if (nextReason === "habit" && nextHabitName) {
76
+ try {
77
+ const agentRoot = (0, identity_1.getAgentRoot)();
78
+ const habitFilePath = path.join(agentRoot, "habits", `${nextHabitName}.md`);
79
+ const content = fs.readFileSync(habitFilePath, "utf-8");
80
+ const parsed = (0, habit_parser_1.parseHabitFile)(content, habitFilePath);
81
+ const frontmatter = {
82
+ title: parsed.title,
83
+ cadence: parsed.cadence,
84
+ status: parsed.status,
85
+ lastRun: new Date().toISOString(),
86
+ created: parsed.created,
87
+ };
88
+ const rendered = (0, habit_parser_1.renderHabitFile)(frontmatter, parsed.body);
89
+ fs.writeFileSync(habitFilePath, rendered, "utf-8");
90
+ }
91
+ catch {
92
+ // Habit file may have been deleted during the turn — skip gracefully
93
+ }
94
+ }
95
+ // Drain queue first
96
+ if (queue.length > 0) {
97
+ const next = queue.shift();
98
+ nextReason = next.reason;
99
+ nextTaskId = next.taskId;
100
+ nextHabitName = next.habitName;
101
+ continue;
102
+ }
103
+ // Then check hasPendingWork fallback
104
+ if (hasPendingWork()) {
105
+ nextReason = "instinct";
106
+ nextTaskId = undefined;
107
+ nextHabitName = undefined;
108
+ continue;
47
109
  }
48
- nextReason = rerunReason;
49
- nextTaskId = rerunTaskId;
50
- rerunReason = "instinct";
51
- rerunTaskId = undefined;
52
- } while (rerunRequested);
110
+ break;
111
+ } while (true);
53
112
  }
54
113
  finally {
55
114
  running = false;
@@ -59,8 +118,13 @@ function createInnerDialogWorker(runTurn = (options) => (0, inner_dialog_1.runIn
59
118
  if (!message || typeof message !== "object")
60
119
  return;
61
120
  const maybeMessage = message;
121
+ if (maybeMessage.type === "habit") {
122
+ await run("habit", undefined, maybeMessage.habitName);
123
+ return;
124
+ }
62
125
  if (maybeMessage.type === "heartbeat") {
63
- await run("heartbeat");
126
+ // Backward compatibility: heartbeat -> habit/heartbeat
127
+ await run("habit", undefined, "heartbeat");
64
128
  return;
65
129
  }
66
130
  if (maybeMessage.type === "poke") {
@@ -43,6 +43,7 @@ exports.deriveResumeCheckpoint = deriveResumeCheckpoint;
43
43
  exports.innerDialogSessionPath = innerDialogSessionPath;
44
44
  exports.enrichDelegatedFromWithBridge = enrichDelegatedFromWithBridge;
45
45
  exports.routeDelegatedCompletion = routeDelegatedCompletion;
46
+ exports.buildParseErrorNudge = buildParseErrorNudge;
46
47
  exports.runInnerDialogTurn = runInnerDialogTurn;
47
48
  const fs = __importStar(require("fs"));
48
49
  const path = __importStar(require("path"));
@@ -68,6 +69,8 @@ const bluebubbles_1 = require("./bluebubbles");
68
69
  const obligations_2 = require("../heart/obligations");
69
70
  const contextual_heartbeat_1 = require("./contextual-heartbeat");
70
71
  const journal_index_1 = require("../mind/journal-index");
72
+ const habit_parser_1 = require("../heart/daemon/habit-parser");
73
+ const cadence_1 = require("../heart/daemon/cadence");
71
74
  const DEFAULT_INNER_DIALOG_INSTINCTS = [
72
75
  {
73
76
  id: "heartbeat_checkin",
@@ -127,9 +130,10 @@ function buildInstinctUserMessage(instincts, _reason, state) {
127
130
  return lines.join("\n");
128
131
  }
129
132
  function readTaskFile(agentRoot, taskId) {
130
- // Task files live in collection subdirectories (one-shots, ongoing, habits).
133
+ // Task files live in collection subdirectories (one-shots, ongoing).
131
134
  // Try each collection, then fall back to root tasks/ for legacy layout.
132
- const collections = ["one-shots", "ongoing", "habits", ""];
135
+ // Habits are no longer in tasks/ — they live at bundle root habits/.
136
+ const collections = ["one-shots", "ongoing", ""];
133
137
  for (const collection of collections) {
134
138
  try {
135
139
  return fs.readFileSync(path.join(agentRoot, "tasks", collection, `${taskId}.md`), "utf8").trim();
@@ -460,9 +464,57 @@ function createNoOpFriendStore() {
460
464
  findByExternalId: async () => null,
461
465
  };
462
466
  }
467
+ function buildParseErrorNudge(parseErrors) {
468
+ if (parseErrors.length === 0)
469
+ return "";
470
+ const lines = parseErrors.map((e) => `I noticed my habit file \`${e.file}\` has invalid frontmatter — I should fix it. (${e.error})`);
471
+ return lines.join("\n");
472
+ }
473
+ function buildAlsoDueLine(agentRoot, currentHabitName, now) {
474
+ const habitsDir = path.join(agentRoot, "habits");
475
+ let files;
476
+ try {
477
+ files = fs.readdirSync(habitsDir);
478
+ }
479
+ catch {
480
+ return "";
481
+ }
482
+ const nowMs = now().getTime();
483
+ const alsoDue = [];
484
+ for (const file of files) {
485
+ if (!file.endsWith(".md"))
486
+ continue;
487
+ const stem = file.replace(/\.md$/, "");
488
+ if (stem === currentHabitName)
489
+ continue;
490
+ try {
491
+ const content = fs.readFileSync(path.join(habitsDir, file), "utf-8");
492
+ const habit = (0, habit_parser_1.parseHabitFile)(content, path.join(habitsDir, file));
493
+ if (habit.status !== "active" || !habit.cadence)
494
+ continue;
495
+ const cadenceMs = (0, cadence_1.parseCadenceToMs)(habit.cadence);
496
+ if (cadenceMs === null)
497
+ continue;
498
+ if (habit.lastRun === null) {
499
+ alsoDue.push(stem);
500
+ continue;
501
+ }
502
+ const lastRunMs = new Date(habit.lastRun).getTime();
503
+ if (nowMs - lastRunMs >= cadenceMs) {
504
+ alsoDue.push(stem);
505
+ }
506
+ }
507
+ catch {
508
+ // skip unreadable habits
509
+ }
510
+ }
511
+ if (alsoDue.length === 0)
512
+ return "";
513
+ return `also due: ${alsoDue.join(", ")}`;
514
+ }
463
515
  async function runInnerDialogTurn(options) {
464
516
  const now = options?.now ?? (() => new Date());
465
- const reason = options?.reason ?? "heartbeat";
517
+ const reason = options?.reason ?? "instinct";
466
518
  const sessionFilePath = innerDialogSessionPath();
467
519
  const agentName = (0, identity_1.getAgentName)();
468
520
  writeInnerDialogRuntimeState(sessionFilePath, {
@@ -500,47 +552,101 @@ async function runInnerDialogTurn(options) {
500
552
  const taskContent = readTaskFile((0, identity_1.getAgentRoot)(), options.taskId);
501
553
  userContent = buildTaskTriggeredMessage(options.taskId, taskContent, state.checkpoint);
502
554
  }
503
- else if (reason === "heartbeat") {
555
+ else if (reason === "habit" && options?.habitName) {
504
556
  const agentRoot = (0, identity_1.getAgentRoot)();
505
- const journalDir = path.join(agentRoot, "journal");
506
- const runtimeStatePath = innerDialogRuntimeStatePath(sessionFilePath);
507
- // Read lastCompletedAt from current runtime state
508
- let lastCompletedAt;
557
+ const habitName = options.habitName;
558
+ const habitFilePath = path.join(agentRoot, "habits", `${habitName}.md`);
559
+ // Read and parse the habit file
560
+ let habitBody;
561
+ let habitLastRun = null;
509
562
  try {
510
- const raw = fs.readFileSync(runtimeStatePath, "utf-8");
511
- const runtimeState = JSON.parse(raw);
512
- lastCompletedAt = typeof runtimeState.lastCompletedAt === "string" ? runtimeState.lastCompletedAt : undefined;
563
+ const habitContent = fs.readFileSync(habitFilePath, "utf-8");
564
+ const parsed = (0, habit_parser_1.parseHabitFile)(habitContent, habitFilePath);
565
+ habitBody = parsed.body;
566
+ habitLastRun = parsed.lastRun;
513
567
  }
514
568
  catch {
515
- // no runtime state — will get cold start fallback
569
+ // Habit file missing or unreadable
570
+ }
571
+ if (habitName === "heartbeat") {
572
+ // Heartbeat habit: use contextual heartbeat builder with habit body
573
+ const journalDir = path.join(agentRoot, "journal");
574
+ const runtimeStatePath = innerDialogRuntimeStatePath(sessionFilePath);
575
+ let lastCompletedAt;
576
+ try {
577
+ const raw = fs.readFileSync(runtimeStatePath, "utf-8");
578
+ const runtimeState = JSON.parse(raw);
579
+ lastCompletedAt = typeof runtimeState.lastCompletedAt === "string" ? runtimeState.lastCompletedAt : undefined;
580
+ }
581
+ catch {
582
+ // no runtime state — will get cold start fallback
583
+ }
584
+ const obligations = (0, obligations_1.listActiveObligations)(agentName);
585
+ const nowMs = now().getTime();
586
+ const pendingObligations = obligations.map((o) => ({
587
+ id: o.id,
588
+ content: o.delegatedContent,
589
+ friendName: o.origin.friendId,
590
+ timestamp: o.createdAt,
591
+ staleness: nowMs - o.createdAt,
592
+ }));
593
+ const heartbeatContent = (0, contextual_heartbeat_1.buildContextualHeartbeat)({
594
+ journalDir,
595
+ lastCompletedAt,
596
+ pendingObligations,
597
+ lastSurfaceAt: undefined,
598
+ checkpoint: state.checkpoint,
599
+ now,
600
+ readJournalDir: () => (0, prompt_1.readJournalFiles)(journalDir),
601
+ habitBody,
602
+ });
603
+ // Build also-due line
604
+ const alsoDue = buildAlsoDueLine(agentRoot, habitName, now);
605
+ userContent = alsoDue ? `${heartbeatContent}\n\n${alsoDue}` : heartbeatContent;
606
+ // Parse error nudge
607
+ const parseErrorNudge = buildParseErrorNudge(options?.parseErrors ?? []);
608
+ if (parseErrorNudge)
609
+ userContent = `${userContent}\n\n${parseErrorNudge}`;
610
+ // Piggyback journal embedding indexing (best-effort, fire-and-forget)
611
+ /* v8 ignore start -- journal indexing piggyback: embedding provider may not be available; tested via journal-index unit tests @preserve */
612
+ void (0, journal_index_1.indexJournalFiles)(journalDir, path.join(journalDir, ".index.json"), {
613
+ embed: async () => [],
614
+ }).catch(() => {
615
+ // swallowed: indexing failure must never block heartbeat
616
+ });
617
+ /* v8 ignore stop */
618
+ }
619
+ else if (habitBody !== undefined) {
620
+ // Non-heartbeat habit: basic context + body
621
+ const sections = [];
622
+ // Elapsed time since last run
623
+ if (habitLastRun) {
624
+ const lastRunMs = new Date(habitLastRun).getTime();
625
+ const elapsed = now().getTime() - lastRunMs;
626
+ const minutes = Math.floor(elapsed / 60000);
627
+ if (minutes < 60) {
628
+ sections.push(`it's been ${minutes} ${minutes === 1 ? "minute" : "minutes"} since ${habitName} last ran.`);
629
+ }
630
+ else {
631
+ const hours = Math.floor(minutes / 60);
632
+ sections.push(`it's been ${hours} ${hours === 1 ? "hour" : "hours"} since ${habitName} last ran.`);
633
+ }
634
+ }
635
+ sections.push(`## habit: ${habitName}\n${habitBody}`);
636
+ // Also due line
637
+ const alsoDue = buildAlsoDueLine(agentRoot, habitName, now);
638
+ if (alsoDue)
639
+ sections.push(alsoDue);
640
+ // Parse error nudge
641
+ const parseErrorNudge = buildParseErrorNudge(options?.parseErrors ?? []);
642
+ if (parseErrorNudge)
643
+ sections.push(parseErrorNudge);
644
+ userContent = sections.join("\n\n");
645
+ }
646
+ else {
647
+ // Habit file not found
648
+ userContent = `habit "${habitName}" could not be read (file not found or unreadable). check habits/${habitName}.md exists.`;
516
649
  }
517
- // Gather pending obligations for display
518
- const obligations = (0, obligations_1.listActiveObligations)(agentName);
519
- const nowMs = now().getTime();
520
- const pendingObligations = obligations.map((o) => ({
521
- id: o.id,
522
- content: o.delegatedContent,
523
- friendName: o.origin.friendId,
524
- timestamp: o.createdAt,
525
- staleness: nowMs - o.createdAt,
526
- }));
527
- userContent = (0, contextual_heartbeat_1.buildContextualHeartbeat)({
528
- journalDir,
529
- lastCompletedAt,
530
- pendingObligations,
531
- lastSurfaceAt: undefined, // TODO: wire when surface tracking is added
532
- checkpoint: state.checkpoint,
533
- now,
534
- readJournalDir: () => (0, prompt_1.readJournalFiles)(journalDir),
535
- });
536
- // Piggyback journal embedding indexing (best-effort, fire-and-forget)
537
- /* v8 ignore start -- journal indexing piggyback: embedding provider may not be available; tested via journal-index unit tests @preserve */
538
- void (0, journal_index_1.indexJournalFiles)(journalDir, path.join(journalDir, ".index.json"), {
539
- embed: async () => [],
540
- }).catch(() => {
541
- // swallowed: indexing failure must never block heartbeat
542
- });
543
- /* v8 ignore stop */
544
650
  }
545
651
  else {
546
652
  userContent = buildInstinctUserMessage(instincts, reason, state);