@ouro.bot/cli 0.1.0-alpha.155 → 0.1.0-alpha.157

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.
package/changelog.json CHANGED
@@ -1,6 +1,26 @@
1
1
  {
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
+ {
5
+ "version": "0.1.0-alpha.157",
6
+ "changes": [
7
+ "Habit turns as awareness: unified buildHabitTurnMessage replaces contextual-heartbeat. Continuity-first format (checkpoint leads, not elapsed time). Same format for all habits \u2014 no heartbeat special-casing.",
8
+ "First beat experience: new habits get \"your [Title] is alive. this is its first breath\" on first fire.",
9
+ "Fix: reconcile() now fires new/overdue habits immediately (was start()-only). New habits created via write_file fire within seconds.",
10
+ "Rhythm awareness across all channels: rhythmStatusSection() in system prompt shows heartbeat health in every conversation.",
11
+ "Removed contextual-heartbeat.ts entirely. Dead lastSurfaceAt parameter removed."
12
+ ]
13
+ },
14
+ {
15
+ "version": "0.1.0-alpha.156",
16
+ "changes": [
17
+ "polish: observe tool now hidden (like settle/rest/descend)",
18
+ "polish(bb): batch rapid tool status messages within 500ms debounce window",
19
+ "polish(tools): word-boundary truncation instead of mid-word cut",
20
+ "polish(commands): /commands output uses em dash separators",
21
+ "polish(bb): re-enable typing indicator after each status message"
22
+ ]
23
+ },
4
24
  {
5
25
  "version": "0.1.0-alpha.155",
6
26
  "changes": [
@@ -1023,4 +1043,4 @@
1023
1043
  ]
1024
1044
  }
1025
1045
  ]
1026
- }
1046
+ }
@@ -73,7 +73,24 @@ class HabitScheduler {
73
73
  const jobs = this.buildJobs(habits);
74
74
  this.osCronManager.sync(jobs);
75
75
  this.verifyCronAndCreateFallbacks(jobs);
76
- // Fire overdue habits immediately
76
+ this.fireOverdueHabits(habits);
77
+ }
78
+ reconcile() {
79
+ (0, runtime_1.emitNervesEvent)({
80
+ component: "daemon",
81
+ event: "daemon.habit_scheduler_reconcile",
82
+ message: "habit scheduler reconciling",
83
+ meta: { agent: this.agent },
84
+ });
85
+ // Clear ALL existing timers FIRST to prevent overlap window
86
+ this.clearAllTimerFallbacks();
87
+ const habits = this.scanHabits();
88
+ const jobs = this.buildJobs(habits);
89
+ this.osCronManager.sync(jobs);
90
+ this.verifyCronAndCreateFallbacks(jobs);
91
+ this.fireOverdueHabits(habits);
92
+ }
93
+ fireOverdueHabits(habits) {
77
94
  for (const habit of habits) {
78
95
  if (habit.status !== "active")
79
96
  continue;
@@ -106,20 +123,6 @@ class HabitScheduler {
106
123
  }
107
124
  }
108
125
  }
109
- reconcile() {
110
- (0, runtime_1.emitNervesEvent)({
111
- component: "daemon",
112
- event: "daemon.habit_scheduler_reconcile",
113
- message: "habit scheduler reconciling",
114
- meta: { agent: this.agent },
115
- });
116
- // Clear ALL existing timers FIRST to prevent overlap window
117
- this.clearAllTimerFallbacks();
118
- const habits = this.scanHabits();
119
- const jobs = this.buildJobs(habits);
120
- this.osCronManager.sync(jobs);
121
- this.verifyCronAndCreateFallbacks(jobs);
122
- }
123
126
  stop() {
124
127
  (0, runtime_1.emitNervesEvent)({
125
128
  component: "daemon",
@@ -9,7 +9,13 @@ function basename(filePath) {
9
9
  function truncate(text, max) {
10
10
  if (text.length <= max)
11
11
  return text;
12
- return text.slice(0, max - 1) + "\u2026";
12
+ // Cut at last space before maxLen to avoid mid-word truncation
13
+ const slice = text.slice(0, max - 1);
14
+ const lastSpace = slice.lastIndexOf(" ");
15
+ if (lastSpace > 0)
16
+ return text.slice(0, lastSpace) + "\u2026";
17
+ // No space found (single long word) — hard truncate
18
+ return slice + "\u2026";
13
19
  }
14
20
  const TOOL_DESCRIPTIONS = {
15
21
  // File operations
@@ -98,7 +104,7 @@ const TOOL_DESCRIPTIONS = {
98
104
  const thought = args.thought;
99
105
  return thought ? `thinking about ${truncate(thought, 40)}...` : "thinking deeper...";
100
106
  },
101
- observe: () => "listening...",
107
+ observe: () => null,
102
108
  claude: () => "reasoning...",
103
109
  set_reasoning_effort: (args) => {
104
110
  const level = args.level;
@@ -51,6 +51,7 @@ exports.channelNatureSection = channelNatureSection;
51
51
  exports.groupChatParticipationSection = groupChatParticipationSection;
52
52
  exports.feedbackSignalSection = feedbackSignalSection;
53
53
  exports.mixedTrustGroupSection = mixedTrustGroupSection;
54
+ exports.rhythmStatusSection = rhythmStatusSection;
54
55
  exports.buildSystem = buildSystem;
55
56
  const fs = __importStar(require("fs"));
56
57
  const path = __importStar(require("path"));
@@ -71,6 +72,7 @@ const session_activity_1 = require("../heart/session-activity");
71
72
  const active_work_1 = require("../heart/active-work");
72
73
  const commitments_1 = require("../heart/commitments");
73
74
  const obligation_steering_1 = require("./obligation-steering");
75
+ const daemon_health_1 = require("../heart/daemon/daemon-health");
74
76
  // Lazy-loaded psyche text cache
75
77
  let _psycheCache = null;
76
78
  let _senseStatusLinesCache = null;
@@ -886,6 +888,39 @@ function mixedTrustGroupSection(context) {
886
888
  return "";
887
889
  return "## mixed trust group\nin this group chat, my capabilities depend on who's talking. some people here have full trust, others don't — i adjust what i can do based on who's asking.";
888
890
  }
891
+ function formatElapsedBrief(ms) {
892
+ const minutes = Math.floor(ms / 60000);
893
+ if (minutes < 60)
894
+ return `${minutes}m ago`;
895
+ const hours = Math.floor(minutes / 60);
896
+ return `${hours}h ago`;
897
+ }
898
+ function rhythmStatusSection() {
899
+ try {
900
+ const health = (0, daemon_health_1.readHealth)((0, daemon_health_1.getDefaultHealthPath)());
901
+ if (!health)
902
+ return "";
903
+ const habitNames = Object.keys(health.habits);
904
+ if (habitNames.length === 0)
905
+ return "";
906
+ const nowMs = Date.now();
907
+ const parts = [];
908
+ for (const name of habitNames) {
909
+ const h = health.habits[name];
910
+ const lastFired = h.lastFired ? formatElapsedBrief(nowMs - new Date(h.lastFired).getTime()) : "never";
911
+ parts.push(`${name} last fired ${lastFired}`);
912
+ }
913
+ const degradedNote = health.degraded.length > 0
914
+ ? health.degraded.map((d) => `${d.component}: ${d.reason}`).join("; ") + "."
915
+ : "healthy.";
916
+ return `my rhythms: ${parts.join(". ")}. ${degradedNote}`;
917
+ /* v8 ignore start -- defensive: readHealth handles its own errors; this catch is a safety net for truly unexpected failures @preserve */
918
+ }
919
+ catch {
920
+ return "";
921
+ }
922
+ /* v8 ignore stop */
923
+ }
889
924
  async function buildSystem(channel = "cli", options, context) {
890
925
  (0, runtime_1.emitNervesEvent)({
891
926
  event: "mind.step_start",
@@ -906,6 +941,7 @@ async function buildSystem(channel = "cli", options, context) {
906
941
  channel === "inner" ? journalSection((0, identity_1.getAgentRoot)()) : "",
907
942
  loopOrientationSection(channel),
908
943
  runtimeInfoSection(channel),
944
+ rhythmStatusSection(),
909
945
  channelNatureSection((0, channel_1.getChannelCapabilities)(channel)),
910
946
  providerSection(),
911
947
  dateSection(),
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.enrichReactionText = enrichReactionText;
37
+ exports.createStatusBatcher = createStatusBatcher;
37
38
  exports.handleBlueBubblesEvent = handleBlueBubblesEvent;
38
39
  exports.recoverMissedBlueBubblesMessages = recoverMissedBlueBubblesMessages;
39
40
  exports.createBlueBubblesWebhookHandler = createBlueBubblesWebhookHandler;
@@ -81,6 +82,44 @@ function enrichReactionText(baseText, originalText, maxLen) {
81
82
  : originalText;
82
83
  return `${baseText} to: "${truncated}"`;
83
84
  }
85
+ /**
86
+ * Accumulates status descriptions and debounces them.
87
+ * If multiple descriptions arrive within `delayMs`, they are joined with ` · `
88
+ * and sent as a single message. Flush sends immediately and clears the timer.
89
+ */
90
+ function createStatusBatcher(send, delayMs) {
91
+ (0, runtime_1.emitNervesEvent)({
92
+ component: "senses",
93
+ event: "senses.bluebubbles_status_batcher_created",
94
+ message: "status batcher initialized",
95
+ meta: { delayMs },
96
+ });
97
+ let pending = [];
98
+ let timer = null;
99
+ function fire() {
100
+ if (pending.length === 0)
101
+ return;
102
+ const combined = pending.join(" \u00b7 ");
103
+ pending = [];
104
+ timer = null;
105
+ send(combined);
106
+ }
107
+ return {
108
+ add(text) {
109
+ pending.push(text);
110
+ if (timer !== null)
111
+ clearTimeout(timer);
112
+ timer = setTimeout(fire, delayMs);
113
+ },
114
+ flush() {
115
+ if (timer !== null) {
116
+ clearTimeout(timer);
117
+ timer = null;
118
+ }
119
+ fire();
120
+ },
121
+ };
122
+ }
84
123
  const defaultDeps = {
85
124
  getAgentName: identity_1.getAgentName,
86
125
  buildSystem: prompt_1.buildSystem,
@@ -401,14 +440,17 @@ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat) {
401
440
  text,
402
441
  replyToMessageGuid: replyTarget.getReplyToMessageGuid(),
403
442
  });
443
+ // Re-enable typing indicator — sending a message clears the typing bubble
444
+ await client.setTyping(chat, true);
404
445
  });
405
446
  }
447
+ const statusBatcher = createStatusBatcher((text) => sendStatus(text), 500);
406
448
  const toolCallbacks = (0, tool_activity_callbacks_1.createToolActivityCallbacks)({
407
- onDescription: (text) => sendStatus(text),
449
+ onDescription: (text) => statusBatcher.add(text),
408
450
  /* v8 ignore next -- onResult only called in debug mode; tested via tool-activity-callbacks.test.ts @preserve */
409
- onResult: (text) => sendStatus(text),
451
+ onResult: (text) => { statusBatcher.flush(); sendStatus(text); },
410
452
  /* v8 ignore next -- onFailure only called on tool failure; tested via tool-activity-callbacks.test.ts @preserve */
411
- onFailure: (text) => sendStatus(text),
453
+ onFailure: (text) => { statusBatcher.flush(); sendStatus(text); },
412
454
  isDebug: commands_1.getDebugMode,
413
455
  });
414
456
  return {
@@ -471,6 +513,7 @@ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat) {
471
513
  textBuffer = "";
472
514
  },
473
515
  async flush() {
516
+ statusBatcher.flush();
474
517
  await queue;
475
518
  const trimmed = textBuffer.trim();
476
519
  if (!trimmed) {
@@ -495,6 +538,7 @@ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat) {
495
538
  });
496
539
  },
497
540
  async finish() {
541
+ statusBatcher.flush();
498
542
  if (!typingActive) {
499
543
  await queue;
500
544
  return;
@@ -72,7 +72,7 @@ function registerDefaultCommands(registry) {
72
72
  channels: ["cli", "teams"],
73
73
  handler: (ctx) => {
74
74
  const cmds = registry.list(ctx.channel);
75
- const lines = cmds.map((c) => `/${c.name} - ${c.description}`);
75
+ const lines = cmds.map((c) => `/${c.name} \u2014 ${c.description}`);
76
76
  return { action: "response", message: lines.join("\n") };
77
77
  },
78
78
  });
@@ -0,0 +1,108 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildHabitTurnMessage = buildHabitTurnMessage;
4
+ const runtime_1 = require("../nerves/runtime");
5
+ function formatElapsed(ms) {
6
+ const minutes = Math.floor(ms / 60000);
7
+ if (minutes < 60) {
8
+ return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
9
+ }
10
+ const hours = Math.floor(minutes / 60);
11
+ return `${hours} ${hours === 1 ? "hour" : "hours"}`;
12
+ }
13
+ function buildHabitTurnMessage(options) {
14
+ const { habitName, habitTitle, habitBody, lastRun, checkpoint, alsoDue, staleObligations, parseErrors, degradedComponents, now, } = options;
15
+ const hasBody = habitBody !== undefined && habitBody !== "";
16
+ // First beat: lastRun is null
17
+ if (lastRun === null) {
18
+ // Cold start: no checkpoint, no body — bare awareness
19
+ if (!checkpoint && !hasBody) {
20
+ (0, runtime_1.emitNervesEvent)({
21
+ component: "senses",
22
+ event: "senses.habit_turn_message_built",
23
+ message: "habit turn message built (cold start)",
24
+ meta: { habitName, coldStart: true },
25
+ });
26
+ return "...time passing. anything stirring?";
27
+ }
28
+ if (!hasBody) {
29
+ // First beat with no body: nudge
30
+ const sections = [
31
+ `your ${habitTitle} fired but has no instructions \u2014 add a body to \`habits/${habitName}.md\``,
32
+ ];
33
+ appendTrailingExtras(sections, alsoDue, staleObligations, parseErrors, degradedComponents);
34
+ (0, runtime_1.emitNervesEvent)({
35
+ component: "senses",
36
+ event: "senses.habit_turn_message_built",
37
+ message: "habit turn message built (first beat, no body)",
38
+ meta: { habitName, firstBeat: true, hasBody: false },
39
+ });
40
+ return sections.join("\n\n");
41
+ }
42
+ const sections = [
43
+ `your ${habitTitle} is alive. this is its first breath.`,
44
+ habitBody,
45
+ ];
46
+ appendTrailingExtras(sections, alsoDue, staleObligations, parseErrors, degradedComponents);
47
+ (0, runtime_1.emitNervesEvent)({
48
+ component: "senses",
49
+ event: "senses.habit_turn_message_built",
50
+ message: "habit turn message built (first beat)",
51
+ meta: { habitName, firstBeat: true },
52
+ });
53
+ return sections.join("\n\n");
54
+ }
55
+ // Normal turn
56
+ const sections = [];
57
+ // 1. Checkpoint
58
+ if (checkpoint) {
59
+ sections.push(`you were thinking about ${checkpoint}.`);
60
+ }
61
+ // 2. Elapsed time
62
+ const nowMs = now().getTime();
63
+ const lastRunMs = new Date(lastRun).getTime();
64
+ const elapsed = nowMs - lastRunMs;
65
+ sections.push(`${formatElapsed(elapsed)} have passed.`);
66
+ // 3. Body or no-body nudge
67
+ if (hasBody) {
68
+ sections.push(habitBody);
69
+ }
70
+ else {
71
+ sections.push(`your ${habitTitle} fired but has no instructions \u2014 add a body to \`habits/${habitName}.md\``);
72
+ }
73
+ // 4-7. Trailing extras
74
+ appendTrailingExtras(sections, alsoDue, staleObligations, parseErrors, degradedComponents);
75
+ (0, runtime_1.emitNervesEvent)({
76
+ component: "senses",
77
+ event: "senses.habit_turn_message_built",
78
+ message: "habit turn message built",
79
+ meta: {
80
+ habitName,
81
+ hasCheckpoint: !!checkpoint,
82
+ hasBody: hasBody,
83
+ staleObligationCount: staleObligations.length,
84
+ },
85
+ });
86
+ return sections.join("\n\n");
87
+ }
88
+ function appendTrailingExtras(sections, alsoDue, staleObligations, parseErrors, degradedComponents) {
89
+ // 4. Also-due
90
+ if (alsoDue) {
91
+ sections.push(alsoDue);
92
+ }
93
+ // 5. Stale obligations
94
+ if (staleObligations.length > 0) {
95
+ const lines = staleObligations.map((o) => `something for ${o.friendName} has been sitting for ${formatElapsed(o.stalenessMs)}`);
96
+ sections.push(lines.join("\n"));
97
+ }
98
+ // 6. Parse errors
99
+ if (parseErrors.length > 0) {
100
+ const lines = parseErrors.map((e) => `I noticed my habit file \`${e.file}\` has invalid frontmatter \u2014 I should fix it. (${e.error})`);
101
+ sections.push(lines.join("\n"));
102
+ }
103
+ // 7. Degraded state
104
+ if (degradedComponents.length > 0) {
105
+ const reasons = degradedComponents.map((d) => `${d.component}: ${d.reason}`).join("; ");
106
+ sections.push(`[note: my scheduling is degraded: ${reasons}]`);
107
+ }
108
+ }
@@ -67,7 +67,7 @@ const manager_1 = require("../heart/bridges/manager");
67
67
  const session_activity_1 = require("../heart/session-activity");
68
68
  const bluebubbles_1 = require("./bluebubbles");
69
69
  const obligations_2 = require("../heart/obligations");
70
- const contextual_heartbeat_1 = require("./contextual-heartbeat");
70
+ const habit_turn_message_1 = require("./habit-turn-message");
71
71
  const journal_index_1 = require("../mind/journal-index");
72
72
  const habit_parser_1 = require("../heart/daemon/habit-parser");
73
73
  const cadence_1 = require("../heart/daemon/cadence");
@@ -559,106 +559,65 @@ async function runInnerDialogTurn(options) {
559
559
  const habitFilePath = path.join(agentRoot, "habits", `${habitName}.md`);
560
560
  // Read and parse the habit file
561
561
  let habitBody;
562
+ let habitTitle = habitName;
562
563
  let habitLastRun = null;
563
564
  try {
564
565
  const habitContent = fs.readFileSync(habitFilePath, "utf-8");
565
566
  const parsed = (0, habit_parser_1.parseHabitFile)(habitContent, habitFilePath);
566
- habitBody = parsed.body;
567
+ habitBody = parsed.body || undefined;
568
+ habitTitle = parsed.title || habitName;
567
569
  habitLastRun = parsed.lastRun;
568
570
  }
569
571
  catch {
570
572
  // Habit file missing or unreadable
571
573
  }
572
- if (habitName === "heartbeat") {
573
- // Heartbeat habit: use contextual heartbeat builder with habit body
574
- const journalDir = path.join(agentRoot, "journal");
575
- const runtimeStatePath = innerDialogRuntimeStatePath(sessionFilePath);
576
- let lastCompletedAt;
577
- try {
578
- const raw = fs.readFileSync(runtimeStatePath, "utf-8");
579
- const runtimeState = JSON.parse(raw);
580
- lastCompletedAt = typeof runtimeState.lastCompletedAt === "string" ? runtimeState.lastCompletedAt : undefined;
581
- }
582
- catch {
583
- // no runtime state — will get cold start fallback
584
- }
574
+ // If the habit file couldn't be read at all (no body, no title parsed), error message
575
+ if (habitBody === undefined && habitTitle === habitName) {
576
+ userContent = `habit "${habitName}" could not be read (file not found or unreadable). check habits/${habitName}.md exists.`;
577
+ }
578
+ else {
579
+ // Unified path: gather context for ALL habits (heartbeat included)
585
580
  const obligations = (0, obligations_1.listActiveObligations)(agentName);
586
581
  const nowMs = now().getTime();
587
- const pendingObligations = obligations.map((o) => ({
588
- id: o.id,
589
- content: o.delegatedContent,
582
+ const staleObligations = obligations.map((o) => ({
590
583
  friendName: o.origin.friendId,
591
- timestamp: o.createdAt,
592
- staleness: nowMs - o.createdAt,
584
+ content: o.delegatedContent,
585
+ stalenessMs: nowMs - o.createdAt,
593
586
  }));
594
- const heartbeatContent = (0, contextual_heartbeat_1.buildContextualHeartbeat)({
595
- journalDir,
596
- lastCompletedAt,
597
- pendingObligations,
598
- lastSurfaceAt: undefined,
599
- checkpoint: state.checkpoint,
600
- now,
601
- readJournalDir: () => (0, prompt_1.readJournalFiles)(journalDir),
587
+ const alsoDue = buildAlsoDueLine(agentRoot, habitName, now);
588
+ // Degraded state (best-effort: never crash)
589
+ let degradedComponents = [];
590
+ try {
591
+ const health = (0, daemon_health_1.readHealth)((0, daemon_health_1.getDefaultHealthPath)());
592
+ if (health && health.degraded.length > 0) {
593
+ degradedComponents = health.degraded.map((d) => ({ component: d.component, reason: d.reason }));
594
+ }
595
+ }
596
+ catch {
597
+ // Best-effort: missing file or parse error -> empty array, no crash
598
+ }
599
+ userContent = (0, habit_turn_message_1.buildHabitTurnMessage)({
600
+ habitName,
601
+ habitTitle,
602
602
  habitBody,
603
+ lastRun: habitLastRun,
604
+ checkpoint: displayCheckpoint(state.checkpoint),
605
+ alsoDue: alsoDue || undefined,
606
+ staleObligations,
607
+ parseErrors: options?.parseErrors ?? [],
608
+ degradedComponents,
609
+ now,
603
610
  });
604
- // Build also-due line
605
- const alsoDue = buildAlsoDueLine(agentRoot, habitName, now);
606
- userContent = alsoDue ? `${heartbeatContent}\n\n${alsoDue}` : heartbeatContent;
607
- // Parse error nudge
608
- const parseErrorNudge = buildParseErrorNudge(options?.parseErrors ?? []);
609
- if (parseErrorNudge)
610
- userContent = `${userContent}\n\n${parseErrorNudge}`;
611
611
  // Piggyback journal embedding indexing (best-effort, fire-and-forget)
612
+ const journalDir = path.join(agentRoot, "journal");
612
613
  /* v8 ignore start -- journal indexing piggyback: embedding provider may not be available; tested via journal-index unit tests @preserve */
613
614
  void (0, journal_index_1.indexJournalFiles)(journalDir, path.join(journalDir, ".index.json"), {
614
615
  embed: async () => [],
615
616
  }).catch(() => {
616
- // swallowed: indexing failure must never block heartbeat
617
+ // swallowed: indexing failure must never block habit turn
617
618
  });
618
619
  /* v8 ignore stop */
619
620
  }
620
- else if (habitBody !== undefined) {
621
- // Non-heartbeat habit: basic context + body
622
- const sections = [];
623
- // Elapsed time since last run
624
- if (habitLastRun) {
625
- const lastRunMs = new Date(habitLastRun).getTime();
626
- const elapsed = now().getTime() - lastRunMs;
627
- const minutes = Math.floor(elapsed / 60000);
628
- if (minutes < 60) {
629
- sections.push(`it's been ${minutes} ${minutes === 1 ? "minute" : "minutes"} since ${habitName} last ran.`);
630
- }
631
- else {
632
- const hours = Math.floor(minutes / 60);
633
- sections.push(`it's been ${hours} ${hours === 1 ? "hour" : "hours"} since ${habitName} last ran.`);
634
- }
635
- }
636
- sections.push(`## habit: ${habitName}\n${habitBody}`);
637
- // Also due line
638
- const alsoDue = buildAlsoDueLine(agentRoot, habitName, now);
639
- if (alsoDue)
640
- sections.push(alsoDue);
641
- // Parse error nudge
642
- const parseErrorNudge = buildParseErrorNudge(options?.parseErrors ?? []);
643
- if (parseErrorNudge)
644
- sections.push(parseErrorNudge);
645
- userContent = sections.join("\n\n");
646
- }
647
- else {
648
- // Habit file not found
649
- userContent = `habit "${habitName}" could not be read (file not found or unreadable). check habits/${habitName}.md exists.`;
650
- }
651
- // Degraded state nudge (best-effort: never crash on health file read failure)
652
- try {
653
- const health = (0, daemon_health_1.readHealth)((0, daemon_health_1.getDefaultHealthPath)());
654
- if (health && health.degraded.length > 0) {
655
- const reasons = health.degraded.map((d) => `${d.component}: ${d.reason}`).join("; ");
656
- userContent = `${userContent}\n\n[note: my scheduling is degraded: ${reasons}]`;
657
- }
658
- }
659
- catch {
660
- // Best-effort: missing file or parse error -> no nudge, no crash
661
- }
662
621
  }
663
622
  else {
664
623
  userContent = buildInstinctUserMessage(instincts, reason, state);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.155",
3
+ "version": "0.1.0-alpha.157",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",
@@ -1,106 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.buildContextualHeartbeat = buildContextualHeartbeat;
4
- const runtime_1 = require("../nerves/runtime");
5
- const STALE_THRESHOLD_MS = 30 * 60 * 1000; // 30 minutes
6
- const MAX_JOURNAL_FILES = 10;
7
- function formatElapsed(ms) {
8
- const minutes = Math.floor(ms / 60000);
9
- if (minutes < 60) {
10
- return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
11
- }
12
- const hours = Math.floor(minutes / 60);
13
- return `${hours} ${hours === 1 ? "hour" : "hours"}`;
14
- }
15
- function formatRelativeTime(nowMs, mtimeMs) {
16
- const diffMs = nowMs - mtimeMs;
17
- const minutes = Math.floor(diffMs / 60000);
18
- if (minutes < 1)
19
- return "just now";
20
- if (minutes < 60)
21
- return `${minutes} ${minutes === 1 ? "minute" : "minutes"} ago`;
22
- const hours = Math.floor(minutes / 60);
23
- if (hours < 24)
24
- return `${hours} ${hours === 1 ? "hour" : "hours"} ago`;
25
- const days = Math.floor(hours / 24);
26
- return `${days} ${days === 1 ? "day" : "days"} ago`;
27
- }
28
- function buildContextualHeartbeat(options) {
29
- const { lastCompletedAt, pendingObligations, lastSurfaceAt, checkpoint, now, readJournalDir, habitBody, } = options;
30
- const nowDate = now();
31
- const nowMs = nowDate.getTime();
32
- const journalFiles = readJournalDir();
33
- // Cold start: no journal files and no runtime state
34
- if (journalFiles.length === 0 && !lastCompletedAt) {
35
- const lines = ["...time passing. anything stirring?"];
36
- if (checkpoint) {
37
- lines.push(`\nlast i remember: ${checkpoint}`);
38
- }
39
- (0, runtime_1.emitNervesEvent)({
40
- component: "senses",
41
- event: "senses.contextual_heartbeat_built",
42
- message: "contextual heartbeat built (cold start)",
43
- meta: { coldStart: true },
44
- });
45
- return lines.join("\n");
46
- }
47
- const sections = [];
48
- // Elapsed time since last turn
49
- if (lastCompletedAt) {
50
- const lastMs = new Date(lastCompletedAt).getTime();
51
- const elapsed = nowMs - lastMs;
52
- sections.push(`it's been ${formatElapsed(elapsed)} since your last turn.`);
53
- }
54
- // Habit body (instructions from habit file)
55
- if (habitBody) {
56
- sections.push(habitBody);
57
- }
58
- // Pending attention count
59
- if (pendingObligations.length > 0) {
60
- const count = pendingObligations.length;
61
- sections.push(`you're holding ${count} ${count === 1 ? "thought" : "thoughts"}.`);
62
- }
63
- // Journal index
64
- if (journalFiles.length > 0) {
65
- const sorted = [...journalFiles].sort((a, b) => b.mtime - a.mtime).slice(0, MAX_JOURNAL_FILES);
66
- const indexLines = ["## journal"];
67
- for (const file of sorted) {
68
- const ago = formatRelativeTime(nowMs, file.mtime);
69
- const previewClause = file.preview ? ` — ${file.preview}` : "";
70
- indexLines.push(`- ${file.name} (${ago})${previewClause}`);
71
- }
72
- sections.push(indexLines.join("\n"));
73
- }
74
- // Journal entries since last surface
75
- if (lastSurfaceAt && journalFiles.length > 0) {
76
- const surfaceMs = new Date(lastSurfaceAt).getTime();
77
- const entriesSinceSurface = journalFiles.filter((f) => f.mtime > surfaceMs).length;
78
- if (entriesSinceSurface > 0) {
79
- const surfaceElapsed = formatElapsed(nowMs - surfaceMs);
80
- sections.push(`${entriesSinceSurface} journal ${entriesSinceSurface === 1 ? "entry" : "entries"} since you last surfaced, ${surfaceElapsed} ago.`);
81
- }
82
- }
83
- // Stale obligation alerts
84
- const staleObligations = pendingObligations.filter((o) => o.staleness >= STALE_THRESHOLD_MS);
85
- if (staleObligations.length > 0) {
86
- for (const obligation of staleObligations) {
87
- sections.push(`this has been sitting for ${formatElapsed(obligation.staleness)}: ${obligation.content}`);
88
- }
89
- }
90
- // Checkpoint
91
- if (checkpoint) {
92
- sections.push(`last i remember: ${checkpoint}`);
93
- }
94
- (0, runtime_1.emitNervesEvent)({
95
- component: "senses",
96
- event: "senses.contextual_heartbeat_built",
97
- message: "contextual heartbeat built",
98
- meta: {
99
- hasJournal: journalFiles.length > 0,
100
- hasLastCompleted: !!lastCompletedAt,
101
- obligationCount: pendingObligations.length,
102
- staleCount: staleObligations.length,
103
- },
104
- });
105
- return sections.join("\n\n");
106
- }