@ouro.bot/cli 0.1.0-alpha.138 → 0.1.0-alpha.139

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,16 @@
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.139",
6
+ "changes": [
7
+ "Daemon-internal heartbeat timer: self-correcting setTimeout loop reads cadence from heartbeat task file, computes delay from lastCompletedAt, fires IPC {type: heartbeat}. One timer per managed agent.",
8
+ "Heartbeat fires immediately when agent is overdue (elapsed > cadence) or has never run (no runtime state).",
9
+ "Contextual heartbeat message: journal index (up to 10 files with recency and preview), elapsed time since last turn, pending attention count, journal entries since last surface, stale obligation alerts (>30 min).",
10
+ "Bare instinct prompt now cold-start fallback only; resumed heartbeat sessions get contextual message.",
11
+ "Journal embedding indexing piggybacked during heartbeat turns (best-effort, fire-and-forget)."
12
+ ]
13
+ },
4
14
  {
5
15
  "version": "0.1.0-alpha.138",
6
16
  "changes": [
@@ -47,6 +47,7 @@ const sense_manager_1 = require("./sense-manager");
47
47
  const agent_discovery_1 = require("./agent-discovery");
48
48
  const identity_1 = require("../identity");
49
49
  const runtime_mode_1 = require("./runtime-mode");
50
+ const heartbeat_timer_1 = require("./heartbeat-timer");
50
51
  function parseSocketPath(argv) {
51
52
  const socketIndex = argv.indexOf("--socket");
52
53
  if (socketIndex >= 0) {
@@ -115,7 +116,27 @@ const daemon = new daemon_1.OuroDaemon({
115
116
  router,
116
117
  mode,
117
118
  });
118
- void daemon.start().catch(async () => {
119
+ const heartbeatTimers = [];
120
+ /* v8 ignore start -- heartbeat wiring: lambdas delegate to processManager/fs; tested via HeartbeatTimer unit tests @preserve */
121
+ void daemon.start().then(() => {
122
+ const bundlesRoot = (0, identity_1.getAgentBundlesRoot)();
123
+ for (const agent of managedAgents) {
124
+ const bundleRoot = path.join(bundlesRoot, `${agent}.ouro`);
125
+ const timer = new heartbeat_timer_1.HeartbeatTimer({
126
+ agent,
127
+ sendToAgent: (a, msg) => processManager.sendToAgent(a, msg),
128
+ deps: {
129
+ readFileSync: (p, enc) => fs.readFileSync(p, enc),
130
+ readdirSync: (p) => fs.readdirSync(p).map((e) => (typeof e === "string" ? e : e)),
131
+ heartbeatTaskDir: path.join(bundleRoot, "tasks", "habits"),
132
+ runtimeStatePath: path.join(bundleRoot, "state", "sessions", "self", "inner", "runtime.json"),
133
+ },
134
+ });
135
+ timer.start();
136
+ heartbeatTimers.push(timer);
137
+ }
138
+ }).catch(async () => {
139
+ /* v8 ignore stop */
119
140
  (0, runtime_1.emitNervesEvent)({
120
141
  level: "error",
121
142
  component: "daemon",
@@ -127,8 +148,12 @@ void daemon.start().catch(async () => {
127
148
  process.exit(1);
128
149
  });
129
150
  process.on("SIGINT", () => {
151
+ for (const timer of heartbeatTimers)
152
+ timer.stop();
130
153
  void daemon.stop().then(() => process.exit(0));
131
154
  });
132
155
  process.on("SIGTERM", () => {
156
+ for (const timer of heartbeatTimers)
157
+ timer.stop();
133
158
  void daemon.stop().then(() => process.exit(0));
134
159
  });
@@ -0,0 +1,131 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HeartbeatTimer = exports.DEFAULT_CADENCE_MS = void 0;
4
+ exports.parseCadenceMs = parseCadenceMs;
5
+ const runtime_1 = require("../../nerves/runtime");
6
+ const parser_1 = require("../../repertoire/tasks/parser");
7
+ exports.DEFAULT_CADENCE_MS = 30 * 60 * 1000; // 30 minutes
8
+ function parseCadenceMs(raw) {
9
+ if (typeof raw !== "string")
10
+ return null;
11
+ const value = raw.trim();
12
+ if (!value)
13
+ return null;
14
+ const match = /^(\d+)(m|h|d)$/.exec(value);
15
+ if (!match)
16
+ return null;
17
+ const interval = Number.parseInt(match[1], 10);
18
+ if (!Number.isFinite(interval) || interval <= 0)
19
+ return null;
20
+ const unit = match[2];
21
+ if (unit === "m")
22
+ return interval * 60 * 1000;
23
+ if (unit === "h")
24
+ return interval * 60 * 60 * 1000;
25
+ return interval * 24 * 60 * 60 * 1000;
26
+ }
27
+ class HeartbeatTimer {
28
+ agent;
29
+ sendToAgent;
30
+ readFileSync;
31
+ readdirSync;
32
+ heartbeatTaskDir;
33
+ runtimeStatePath;
34
+ now;
35
+ pendingTimer = null;
36
+ constructor(options) {
37
+ this.agent = options.agent;
38
+ this.sendToAgent = options.sendToAgent;
39
+ this.readFileSync = options.deps.readFileSync;
40
+ this.readdirSync = options.deps.readdirSync;
41
+ this.heartbeatTaskDir = options.deps.heartbeatTaskDir;
42
+ this.runtimeStatePath = options.deps.runtimeStatePath;
43
+ this.now = options.deps.now ?? (() => Date.now());
44
+ }
45
+ start() {
46
+ this.scheduleNext();
47
+ }
48
+ stop() {
49
+ if (this.pendingTimer !== null) {
50
+ clearTimeout(this.pendingTimer);
51
+ this.pendingTimer = null;
52
+ }
53
+ }
54
+ scheduleNext() {
55
+ const cadenceMs = this.readCadence();
56
+ const lastCompletedAt = this.readLastCompletedAt();
57
+ const nowMs = this.now();
58
+ let delay;
59
+ if (lastCompletedAt === null) {
60
+ // Never run before — fire immediately
61
+ delay = 0;
62
+ }
63
+ else {
64
+ const elapsed = nowMs - lastCompletedAt;
65
+ delay = Math.max(0, cadenceMs - elapsed);
66
+ }
67
+ this.pendingTimer = setTimeout(() => {
68
+ this.fire();
69
+ }, delay);
70
+ }
71
+ fire() {
72
+ this.pendingTimer = null;
73
+ (0, runtime_1.emitNervesEvent)({
74
+ component: "daemon",
75
+ event: "daemon.heartbeat_fire",
76
+ message: "heartbeat timer fired",
77
+ meta: { agent: this.agent },
78
+ });
79
+ this.sendToAgent(this.agent, { type: "heartbeat" });
80
+ this.scheduleNext();
81
+ }
82
+ readCadence() {
83
+ // Scan habits dir for *-heartbeat.md
84
+ let files;
85
+ try {
86
+ files = this.readdirSync(this.heartbeatTaskDir);
87
+ }
88
+ catch {
89
+ return exports.DEFAULT_CADENCE_MS;
90
+ }
91
+ const heartbeatFile = files.find((f) => f.endsWith("-heartbeat.md"));
92
+ if (!heartbeatFile)
93
+ return exports.DEFAULT_CADENCE_MS;
94
+ try {
95
+ const content = this.readFileSync(`${this.heartbeatTaskDir}/${heartbeatFile}`, "utf-8");
96
+ const cadence = this.extractCadenceFromTaskFile(content);
97
+ return cadence ?? exports.DEFAULT_CADENCE_MS;
98
+ }
99
+ catch {
100
+ return exports.DEFAULT_CADENCE_MS;
101
+ }
102
+ }
103
+ extractCadenceFromTaskFile(content) {
104
+ // Parse frontmatter from task file to get cadence value
105
+ const lines = content.split(/\r?\n/);
106
+ if (lines[0]?.trim() !== "---")
107
+ return null;
108
+ const closing = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
109
+ if (closing === -1)
110
+ return null;
111
+ const rawFrontmatter = lines.slice(1, closing).join("\n");
112
+ const frontmatter = (0, parser_1.parseFrontmatter)(rawFrontmatter);
113
+ return parseCadenceMs(frontmatter.cadence);
114
+ }
115
+ readLastCompletedAt() {
116
+ try {
117
+ const raw = this.readFileSync(this.runtimeStatePath, "utf-8");
118
+ const state = JSON.parse(raw);
119
+ if (typeof state.lastCompletedAt !== "string")
120
+ return null;
121
+ const ms = new Date(state.lastCompletedAt).getTime();
122
+ if (Number.isNaN(ms))
123
+ return null;
124
+ return ms;
125
+ }
126
+ catch {
127
+ return null;
128
+ }
129
+ }
130
+ }
131
+ exports.HeartbeatTimer = HeartbeatTimer;
@@ -44,6 +44,7 @@ exports.commitmentsSection = commitmentsSection;
44
44
  exports.delegationHintSection = delegationHintSection;
45
45
  exports.contextSection = contextSection;
46
46
  exports.metacognitiveFramingSection = metacognitiveFramingSection;
47
+ exports.readJournalFiles = readJournalFiles;
47
48
  exports.journalSection = journalSection;
48
49
  exports.loopOrientationSection = loopOrientationSection;
49
50
  exports.channelNatureSection = channelNatureSection;
@@ -0,0 +1,102 @@
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, } = 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
+ // Pending attention count
55
+ if (pendingObligations.length > 0) {
56
+ const count = pendingObligations.length;
57
+ sections.push(`you're holding ${count} ${count === 1 ? "thought" : "thoughts"}.`);
58
+ }
59
+ // Journal index
60
+ if (journalFiles.length > 0) {
61
+ const sorted = [...journalFiles].sort((a, b) => b.mtime - a.mtime).slice(0, MAX_JOURNAL_FILES);
62
+ const indexLines = ["## journal"];
63
+ for (const file of sorted) {
64
+ const ago = formatRelativeTime(nowMs, file.mtime);
65
+ const previewClause = file.preview ? ` — ${file.preview}` : "";
66
+ indexLines.push(`- ${file.name} (${ago})${previewClause}`);
67
+ }
68
+ sections.push(indexLines.join("\n"));
69
+ }
70
+ // Journal entries since last surface
71
+ if (lastSurfaceAt && journalFiles.length > 0) {
72
+ const surfaceMs = new Date(lastSurfaceAt).getTime();
73
+ const entriesSinceSurface = journalFiles.filter((f) => f.mtime > surfaceMs).length;
74
+ if (entriesSinceSurface > 0) {
75
+ const surfaceElapsed = formatElapsed(nowMs - surfaceMs);
76
+ sections.push(`${entriesSinceSurface} journal ${entriesSinceSurface === 1 ? "entry" : "entries"} since you last surfaced, ${surfaceElapsed} ago.`);
77
+ }
78
+ }
79
+ // Stale obligation alerts
80
+ const staleObligations = pendingObligations.filter((o) => o.staleness >= STALE_THRESHOLD_MS);
81
+ if (staleObligations.length > 0) {
82
+ for (const obligation of staleObligations) {
83
+ sections.push(`this has been sitting for ${formatElapsed(obligation.staleness)}: ${obligation.content}`);
84
+ }
85
+ }
86
+ // Checkpoint
87
+ if (checkpoint) {
88
+ sections.push(`last i remember: ${checkpoint}`);
89
+ }
90
+ (0, runtime_1.emitNervesEvent)({
91
+ component: "senses",
92
+ event: "senses.contextual_heartbeat_built",
93
+ message: "contextual heartbeat built",
94
+ meta: {
95
+ hasJournal: journalFiles.length > 0,
96
+ hasLastCompleted: !!lastCompletedAt,
97
+ obligationCount: pendingObligations.length,
98
+ staleCount: staleObligations.length,
99
+ },
100
+ });
101
+ return sections.join("\n\n");
102
+ }
@@ -66,6 +66,8 @@ const manager_1 = require("../heart/bridges/manager");
66
66
  const session_activity_1 = require("../heart/session-activity");
67
67
  const bluebubbles_1 = require("./bluebubbles");
68
68
  const obligations_2 = require("../heart/obligations");
69
+ const contextual_heartbeat_1 = require("./contextual-heartbeat");
70
+ const journal_index_1 = require("../mind/journal-index");
69
71
  const DEFAULT_INNER_DIALOG_INSTINCTS = [
70
72
  {
71
73
  id: "heartbeat_checkin",
@@ -483,6 +485,48 @@ async function runInnerDialogTurn(options) {
483
485
  const taskContent = readTaskFile((0, identity_1.getAgentRoot)(), options.taskId);
484
486
  userContent = buildTaskTriggeredMessage(options.taskId, taskContent, state.checkpoint);
485
487
  }
488
+ else if (reason === "heartbeat") {
489
+ const agentRoot = (0, identity_1.getAgentRoot)();
490
+ const journalDir = path.join(agentRoot, "journal");
491
+ const runtimeStatePath = innerDialogRuntimeStatePath(sessionFilePath);
492
+ // Read lastCompletedAt from current runtime state
493
+ let lastCompletedAt;
494
+ try {
495
+ const raw = fs.readFileSync(runtimeStatePath, "utf-8");
496
+ const runtimeState = JSON.parse(raw);
497
+ lastCompletedAt = typeof runtimeState.lastCompletedAt === "string" ? runtimeState.lastCompletedAt : undefined;
498
+ }
499
+ catch {
500
+ // no runtime state — will get cold start fallback
501
+ }
502
+ // Gather pending obligations for display
503
+ const obligations = (0, obligations_1.listActiveObligations)(agentName);
504
+ const nowMs = now().getTime();
505
+ const pendingObligations = obligations.map((o) => ({
506
+ id: o.id,
507
+ content: o.delegatedContent,
508
+ friendName: o.origin.friendId,
509
+ timestamp: o.createdAt,
510
+ staleness: nowMs - o.createdAt,
511
+ }));
512
+ userContent = (0, contextual_heartbeat_1.buildContextualHeartbeat)({
513
+ journalDir,
514
+ lastCompletedAt,
515
+ pendingObligations,
516
+ lastSurfaceAt: undefined, // TODO: wire when surface tracking is added
517
+ checkpoint: state.checkpoint,
518
+ now,
519
+ readJournalDir: () => (0, prompt_1.readJournalFiles)(journalDir),
520
+ });
521
+ // Piggyback journal embedding indexing (best-effort, fire-and-forget)
522
+ /* v8 ignore start -- journal indexing piggyback: embedding provider may not be available; tested via journal-index unit tests @preserve */
523
+ void (0, journal_index_1.indexJournalFiles)(journalDir, path.join(journalDir, ".index.json"), {
524
+ embed: async () => [],
525
+ }).catch(() => {
526
+ // swallowed: indexing failure must never block heartbeat
527
+ });
528
+ /* v8 ignore stop */
529
+ }
486
530
  else {
487
531
  userContent = buildInstinctUserMessage(instincts, reason, state);
488
532
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.138",
3
+ "version": "0.1.0-alpha.139",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",