@ouro.bot/cli 0.1.0-alpha.276 → 0.1.0-alpha.277
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 +12 -0
- package/dist/heart/daemon/daemon-entry.js +20 -0
- package/dist/heart/daemon/process-manager.js +57 -0
- package/dist/heart/daemon/pulse.js +463 -0
- package/dist/mind/prompt.js +98 -1
- package/package.json +1 -1
package/changelog.json
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
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.277",
|
|
6
|
+
"changes": [
|
|
7
|
+
"feat(pulse): multi-agent situational awareness for peer agents on the same machine. The harness scales horizontally — multiple peer agents share a machine, each with their own identity and bundle (the Bob model from We Are Legion / We Are Bob). Without explicit awareness, peer agents are isolated workers who don't even know each other exist. The pulse fixes that.",
|
|
8
|
+
"feat(pulse): new `src/heart/daemon/pulse.ts` module. Daemon writes `~/.ouro-cli/pulse.json` whenever any managed agent's snapshot changes (status, errorReason, fixHint, etc.). Each entry includes name, bundle path, status, last-seen-at, errorReason+fixHint when broken, alertId for at-most-once delivery tracking, and currentActivity (read from each agent's `state/sessions/self/inner/runtime.json` when running). Pure helpers `buildPulseState`, `findNovelBrokenAgents`, `findRecoveredAgents`, `pickWakeRecipient`, `flushPulse`, `readAgentActivity`, `buildAlertId`, `buildRecoveryAlertId`, `pruneDeliveredState` exported for unit coverage; I/O wrappers `writePulse`, `readPulse`, `writeDeliveredState`, `readDeliveredState` use injectable deps.",
|
|
9
|
+
"feat(pulse): both passive AND active surfacing. Passive: every agent's prompt assembly renders a `## the pulse` section in Group 7 (dynamic state) showing siblings grouped into broken / reachable / idle buckets. Self-excluded so each agent describes its peers, not itself. Renders nothing on single-agent machines (zero token cost). Active: when a sibling newly breaks (or recovers), the daemon fires `inner.wake` on the most-recently-active running agent so the user finds out within seconds rather than next-time-they-talk-to-someone. Persistent at-most-once delivery via `~/.ouro-cli/pulse-delivered.json` so daemon restarts don't re-page.",
|
|
10
|
+
"feat(pulse): horizontal-scaling norm in bodyMapSection. New `### peers` subsection teaches the agent the Bob model directly: 'i talk first. when i need a sibling's help, i `send_message` them — that's how peers coordinate, the same way humans on a team do. i only open a sibling's bundle directly via read_file/glob/grep when conversation isn't possible (they're crashed, sleeping, or i need history they haven't surfaced).' Direct declarative voice — no hedging, no soft modal verbs.",
|
|
11
|
+
"feat(daemon): `DaemonAgentSnapshot` gains `errorReason` and `fixHint` fields, populated by `checkAgentConfig` results in `startAgent` (set on failure, cleared on recovery). Cleared error fields are how the recovery wake fires: when a previously-broken agent transitions to running with `errorReason: null`, `findRecoveredAgents` flags it.",
|
|
12
|
+
"feat(daemon): `DaemonProcessManager` gains `onSnapshotChange` callback option. Called after every snapshot mutation (start, exit, config-fail, recovery, restart-exhausted). Errors from the observer are swallowed so lifecycle code never breaks because the observer threw. The daemon-entry registers a callback that calls `flushPulse` to update the pulse state and fire wakes.",
|
|
13
|
+
"feat(daemon): wake recipient picker (`pickWakeRecipient`) chooses the most-recently-active running sibling. Excludes the broken agent itself, non-running siblings, and siblings that have never been seen alive. Returns null when no eligible recipient exists, in which case the alert is still marked delivered to avoid spam on subsequent flushes."
|
|
14
|
+
]
|
|
15
|
+
},
|
|
4
16
|
{
|
|
5
17
|
"version": "0.1.0-alpha.276",
|
|
6
18
|
"changes": [
|
|
@@ -56,6 +56,9 @@ const os_cron_1 = require("./os-cron");
|
|
|
56
56
|
const daemon_tombstone_1 = require("./daemon-tombstone");
|
|
57
57
|
const os = __importStar(require("os"));
|
|
58
58
|
const agent_config_check_1 = require("./agent-config-check");
|
|
59
|
+
const pulse_1 = require("./pulse");
|
|
60
|
+
const socket_client_1 = require("./socket-client");
|
|
61
|
+
const bundle_manifest_1 = require("../../mind/bundle-manifest");
|
|
59
62
|
function parseSocketPath(argv) {
|
|
60
63
|
const socketIndex = argv.indexOf("--socket");
|
|
61
64
|
if (socketIndex >= 0) {
|
|
@@ -100,6 +103,23 @@ const processManager = new process_manager_1.DaemonProcessManager({
|
|
|
100
103
|
const secretsRoot = path.join(os.homedir(), ".agentsecrets");
|
|
101
104
|
return (0, agent_config_check_1.checkAgentConfig)(agent, bundlesRoot, secretsRoot);
|
|
102
105
|
},
|
|
106
|
+
/* v8 ignore start -- pulse flush wiring: integration code; flushPulse itself has full unit tests @preserve */
|
|
107
|
+
onSnapshotChange: () => {
|
|
108
|
+
(0, pulse_1.flushPulse)({
|
|
109
|
+
snapshots: processManager.listAgentSnapshots(),
|
|
110
|
+
bundlesRoot: (0, identity_1.getAgentBundlesRoot)(),
|
|
111
|
+
daemonVersion: (0, bundle_manifest_1.getPackageVersion)(),
|
|
112
|
+
now: new Date(),
|
|
113
|
+
// Default I/O wired into pulse.ts (writePulse, readPulse, etc.)
|
|
114
|
+
// Wake recipient: send inner.wake over the daemon's own socket so
|
|
115
|
+
// the recipient agent runs an inner-dialog turn that picks up the
|
|
116
|
+
// pulse alert. Catch errors silently — pulse is best-effort.
|
|
117
|
+
fireInnerWake: (agent) => {
|
|
118
|
+
(0, socket_client_1.sendDaemonCommand)(socketPath, { kind: "inner.wake", agent }).catch(() => { });
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
},
|
|
122
|
+
/* v8 ignore stop */
|
|
103
123
|
});
|
|
104
124
|
const scheduler = new task_scheduler_1.TaskDrivenScheduler({
|
|
105
125
|
agents: [...managedAgents],
|
|
@@ -55,6 +55,31 @@ class DaemonProcessManager {
|
|
|
55
55
|
maxCooldownRetries;
|
|
56
56
|
existsSyncFn;
|
|
57
57
|
configCheckFn;
|
|
58
|
+
onSnapshotChangeFn;
|
|
59
|
+
/**
|
|
60
|
+
* Notify the snapshot-change observer (if registered). Swallows any
|
|
61
|
+
* errors from the observer so process lifecycle code never fails
|
|
62
|
+
* because the observer threw.
|
|
63
|
+
*/
|
|
64
|
+
notifySnapshotChange(snapshot) {
|
|
65
|
+
if (!this.onSnapshotChangeFn)
|
|
66
|
+
return;
|
|
67
|
+
try {
|
|
68
|
+
this.onSnapshotChangeFn(snapshot);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
(0, runtime_1.emitNervesEvent)({
|
|
72
|
+
level: "warn",
|
|
73
|
+
component: "daemon",
|
|
74
|
+
event: "daemon.snapshot_change_observer_error",
|
|
75
|
+
message: "snapshot-change observer threw",
|
|
76
|
+
meta: {
|
|
77
|
+
agent: snapshot.name,
|
|
78
|
+
error: error instanceof Error ? error.message : /* v8 ignore next -- defensive: non-Error catch branch @preserve */ String(error),
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
58
83
|
constructor(options) {
|
|
59
84
|
this.maxRestartsPerHour = options.maxRestartsPerHour ?? 10;
|
|
60
85
|
this.stabilityThresholdMs = options.stabilityThresholdMs ?? 60_000;
|
|
@@ -68,6 +93,7 @@ class DaemonProcessManager {
|
|
|
68
93
|
this.clearTimeoutFn = options.clearTimeoutFn ?? ((timer) => clearTimeout(timer));
|
|
69
94
|
this.existsSyncFn = options.existsSync ?? null;
|
|
70
95
|
this.configCheckFn = options.configCheck ?? null;
|
|
96
|
+
this.onSnapshotChangeFn = options.onSnapshotChange ?? null;
|
|
71
97
|
for (const agent of options.agents) {
|
|
72
98
|
this.agents.set(agent.name, {
|
|
73
99
|
config: agent,
|
|
@@ -89,6 +115,8 @@ class DaemonProcessManager {
|
|
|
89
115
|
backoffMs: this.initialBackoffMs,
|
|
90
116
|
lastExitCode: null,
|
|
91
117
|
lastSignal: null,
|
|
118
|
+
errorReason: null,
|
|
119
|
+
fixHint: null,
|
|
92
120
|
},
|
|
93
121
|
});
|
|
94
122
|
}
|
|
@@ -111,6 +139,13 @@ class DaemonProcessManager {
|
|
|
111
139
|
const result = this.configCheckFn(agent);
|
|
112
140
|
if (!result.ok) {
|
|
113
141
|
state.snapshot.status = "crashed";
|
|
142
|
+
// Surface the error and fix to the snapshot so sibling agents can
|
|
143
|
+
// read it via the pulse. Without this, the diagnosis stayed
|
|
144
|
+
// trapped in the nerves event and stderr — visible to humans
|
|
145
|
+
// running `ouro status` or grepping logs, but invisible to
|
|
146
|
+
// peer agents trying to coordinate around the broken state.
|
|
147
|
+
state.snapshot.errorReason = result.error ?? "agent config validation failed";
|
|
148
|
+
state.snapshot.fixHint = result.fix ?? null;
|
|
114
149
|
(0, runtime_1.emitNervesEvent)({
|
|
115
150
|
level: "error",
|
|
116
151
|
component: "daemon",
|
|
@@ -120,8 +155,15 @@ class DaemonProcessManager {
|
|
|
120
155
|
});
|
|
121
156
|
process.stderr.write(`[daemon] ${agent}: ${result.error}\n` +
|
|
122
157
|
(result.fix ? ` Fix: ${result.fix}\n` : ""));
|
|
158
|
+
this.notifySnapshotChange(state.snapshot);
|
|
123
159
|
return;
|
|
124
160
|
}
|
|
161
|
+
// Config check passed — clear any prior error so the pulse stops
|
|
162
|
+
// reporting the broken state. This is the recovery path: the user
|
|
163
|
+
// fixed their secrets/config, the next startAgent attempt sees a
|
|
164
|
+
// valid config, and the pulse goes quiet.
|
|
165
|
+
state.snapshot.errorReason = null;
|
|
166
|
+
state.snapshot.fixHint = null;
|
|
125
167
|
}
|
|
126
168
|
const runCwd = (0, identity_1.getRepoRoot)();
|
|
127
169
|
const entryScript = path.join((0, identity_1.getRepoRoot)(), "dist", state.config.entry);
|
|
@@ -134,6 +176,7 @@ class DaemonProcessManager {
|
|
|
134
176
|
message: "agent entry script does not exist — cannot spawn. Run 'ouro daemon install' from the correct location.",
|
|
135
177
|
meta: { agent, entryScript },
|
|
136
178
|
});
|
|
179
|
+
this.notifySnapshotChange(state.snapshot);
|
|
137
180
|
return;
|
|
138
181
|
}
|
|
139
182
|
const args = [entryScript, "--agent", state.config.agentArg ?? agent, ...(state.config.args ?? [])];
|
|
@@ -158,6 +201,7 @@ class DaemonProcessManager {
|
|
|
158
201
|
message: "daemon started managed agent process",
|
|
159
202
|
meta: { agent, pid: child.pid ?? null, cwd: runCwd },
|
|
160
203
|
});
|
|
204
|
+
this.notifySnapshotChange(state.snapshot);
|
|
161
205
|
/* v8 ignore start — child process error handler; requires real spawn to trigger */
|
|
162
206
|
child.on("error", (err) => {
|
|
163
207
|
(0, runtime_1.emitNervesEvent)({
|
|
@@ -181,6 +225,7 @@ class DaemonProcessManager {
|
|
|
181
225
|
if (!state.process) {
|
|
182
226
|
state.snapshot.status = "stopped";
|
|
183
227
|
state.snapshot.pid = null;
|
|
228
|
+
this.notifySnapshotChange(state.snapshot);
|
|
184
229
|
return;
|
|
185
230
|
}
|
|
186
231
|
const child = state.process;
|
|
@@ -199,6 +244,7 @@ class DaemonProcessManager {
|
|
|
199
244
|
meta: { agent },
|
|
200
245
|
});
|
|
201
246
|
}
|
|
247
|
+
this.notifySnapshotChange(state.snapshot);
|
|
202
248
|
}
|
|
203
249
|
async restartAgent(agent) {
|
|
204
250
|
await this.stopAgent(agent);
|
|
@@ -255,6 +301,7 @@ class DaemonProcessManager {
|
|
|
255
301
|
if (runDuration >= this.stabilityThresholdMs) {
|
|
256
302
|
state.snapshot.backoffMs = this.initialBackoffMs;
|
|
257
303
|
}
|
|
304
|
+
this.notifySnapshotChange(state.snapshot);
|
|
258
305
|
return;
|
|
259
306
|
}
|
|
260
307
|
state.snapshot.lastCrashAt = new Date(now).toISOString();
|
|
@@ -267,6 +314,11 @@ class DaemonProcessManager {
|
|
|
267
314
|
state.fastCrashCount = state.fastCrashCount + 1;
|
|
268
315
|
if (state.fastCrashCount >= FAST_CRASH_MAX) {
|
|
269
316
|
state.snapshot.status = "crashed";
|
|
317
|
+
// Capture the fast-crash diagnosis on the snapshot so it surfaces
|
|
318
|
+
// via the pulse. The error message is prescriptive: it tells the
|
|
319
|
+
// user (and their sibling agents) exactly what to do.
|
|
320
|
+
state.snapshot.errorReason = `agent crashed ${FAST_CRASH_MAX} times within ${FAST_CRASH_THRESHOLD_MS}ms of startup — likely a configuration issue (missing credentials, bad provider).`;
|
|
321
|
+
state.snapshot.fixHint = `Fix the config and run \`ouro up\` to restart, or check daemon logs for the underlying error.`;
|
|
270
322
|
(0, runtime_1.emitNervesEvent)({
|
|
271
323
|
level: "error",
|
|
272
324
|
component: "daemon",
|
|
@@ -274,6 +326,7 @@ class DaemonProcessManager {
|
|
|
274
326
|
message: `agent crashed ${FAST_CRASH_MAX} times within ${FAST_CRASH_THRESHOLD_MS}ms of startup — likely a configuration issue (missing credentials, bad provider). Not retrying. Fix the config and run \`ouro up\` to restart.`,
|
|
275
327
|
meta: { agent: state.config.name, fastCrashCount: state.fastCrashCount, avgRunDurationMs: runDuration },
|
|
276
328
|
});
|
|
329
|
+
this.notifySnapshotChange(state.snapshot);
|
|
277
330
|
return; // Don't schedule cooldown recovery — this needs human/agent intervention
|
|
278
331
|
}
|
|
279
332
|
}
|
|
@@ -285,6 +338,8 @@ class DaemonProcessManager {
|
|
|
285
338
|
state.crashTimestamps.push(now);
|
|
286
339
|
if (state.crashTimestamps.length > this.maxRestartsPerHour) {
|
|
287
340
|
state.snapshot.status = "crashed";
|
|
341
|
+
state.snapshot.errorReason = `agent exceeded restart limit (${this.maxRestartsPerHour}/hr) — entering cooldown`;
|
|
342
|
+
state.snapshot.fixHint = "investigate why the agent keeps crashing; cooldown will retry shortly";
|
|
288
343
|
(0, runtime_1.emitNervesEvent)({
|
|
289
344
|
level: "error",
|
|
290
345
|
component: "daemon",
|
|
@@ -292,6 +347,7 @@ class DaemonProcessManager {
|
|
|
292
347
|
message: "managed agent exceeded restart limit and is marked crashed",
|
|
293
348
|
meta: { agent: state.config.name, maxRestartsPerHour: this.maxRestartsPerHour },
|
|
294
349
|
});
|
|
350
|
+
this.notifySnapshotChange(state.snapshot);
|
|
295
351
|
this.scheduleCooldownRecovery(state);
|
|
296
352
|
return;
|
|
297
353
|
}
|
|
@@ -303,6 +359,7 @@ class DaemonProcessManager {
|
|
|
303
359
|
state.restartTimer = this.setTimeoutFn(() => {
|
|
304
360
|
void this.startAgent(state.config.name);
|
|
305
361
|
}, waitMs);
|
|
362
|
+
this.notifySnapshotChange(state.snapshot);
|
|
306
363
|
}
|
|
307
364
|
clearRestartTimer(state) {
|
|
308
365
|
if (state.restartTimer === null)
|
|
@@ -0,0 +1,463 @@
|
|
|
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
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.getPulsePath = getPulsePath;
|
|
37
|
+
exports.getPulseDeliveredPath = getPulseDeliveredPath;
|
|
38
|
+
exports.buildAlertId = buildAlertId;
|
|
39
|
+
exports.readAgentActivity = readAgentActivity;
|
|
40
|
+
exports.buildPulseState = buildPulseState;
|
|
41
|
+
exports.findNovelBrokenAgents = findNovelBrokenAgents;
|
|
42
|
+
exports.findRecoveredAgents = findRecoveredAgents;
|
|
43
|
+
exports.buildRecoveryAlertId = buildRecoveryAlertId;
|
|
44
|
+
exports.pickWakeRecipient = pickWakeRecipient;
|
|
45
|
+
exports.writePulse = writePulse;
|
|
46
|
+
exports.readPulse = readPulse;
|
|
47
|
+
exports.readDeliveredState = readDeliveredState;
|
|
48
|
+
exports.writeDeliveredState = writeDeliveredState;
|
|
49
|
+
exports.pruneDeliveredState = pruneDeliveredState;
|
|
50
|
+
exports.flushPulse = flushPulse;
|
|
51
|
+
const fs = __importStar(require("fs"));
|
|
52
|
+
const os = __importStar(require("os"));
|
|
53
|
+
const path = __importStar(require("path"));
|
|
54
|
+
const runtime_1 = require("../../nerves/runtime");
|
|
55
|
+
/* v8 ignore next 3 -- path defaults: tests always inject @preserve */
|
|
56
|
+
function defaultPulsePath() {
|
|
57
|
+
return path.join(os.homedir(), ".ouro-cli", "pulse.json");
|
|
58
|
+
}
|
|
59
|
+
/* v8 ignore next 3 -- path defaults: tests always inject @preserve */
|
|
60
|
+
function defaultDeliveredPath() {
|
|
61
|
+
return path.join(os.homedir(), ".ouro-cli", "pulse-delivered.json");
|
|
62
|
+
}
|
|
63
|
+
function getPulsePath() {
|
|
64
|
+
return defaultPulsePath();
|
|
65
|
+
}
|
|
66
|
+
function getPulseDeliveredPath() {
|
|
67
|
+
return defaultDeliveredPath();
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Hash an error reason into a short stable identifier. We use a simple
|
|
71
|
+
* non-cryptographic hash because we only need stability across daemon
|
|
72
|
+
* restarts on the same machine, not collision resistance against
|
|
73
|
+
* adversaries. djb2 is fine.
|
|
74
|
+
*/
|
|
75
|
+
function hashErrorReason(reason) {
|
|
76
|
+
let h = 5381;
|
|
77
|
+
for (let i = 0; i < reason.length; i++) {
|
|
78
|
+
h = ((h << 5) + h + reason.charCodeAt(i)) >>> 0;
|
|
79
|
+
}
|
|
80
|
+
return h.toString(16);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Build a stable alert ID for a (agent, error) pair. Same agent + same
|
|
84
|
+
* error → same ID, even across daemon restarts. Different error → new ID.
|
|
85
|
+
*/
|
|
86
|
+
function buildAlertId(agent, errorReason) {
|
|
87
|
+
return `${agent}:${hashErrorReason(errorReason)}`;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Read an agent's inner-dialog runtime state and format it as a short
|
|
91
|
+
* activity hint string suitable for display in another agent's pulse
|
|
92
|
+
* section. Returns null when the file is missing or malformed — both
|
|
93
|
+
* cases are silent (the pulse just doesn't include activity for that
|
|
94
|
+
* agent).
|
|
95
|
+
*
|
|
96
|
+
* Pure: takes a file reader so tests can inject content without touching
|
|
97
|
+
* fs. Defaults to fs.readFileSync for the production daemon caller.
|
|
98
|
+
*/
|
|
99
|
+
function readAgentActivity(bundlePath, readFile = (p) => fs.readFileSync(p, "utf-8")) {
|
|
100
|
+
const runtimePath = path.join(bundlePath, "state", "sessions", "self", "inner", "runtime.json");
|
|
101
|
+
let raw;
|
|
102
|
+
try {
|
|
103
|
+
raw = readFile(runtimePath);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
let parsed;
|
|
109
|
+
try {
|
|
110
|
+
parsed = JSON.parse(raw);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
const status = typeof parsed.status === "string" ? parsed.status : null;
|
|
116
|
+
const reason = typeof parsed.reason === "string" ? parsed.reason : null;
|
|
117
|
+
const startedAt = typeof parsed.startedAt === "string" ? parsed.startedAt : null;
|
|
118
|
+
if (!status)
|
|
119
|
+
return null;
|
|
120
|
+
// Format compactly. Examples:
|
|
121
|
+
// "running (instinct since 23:44)"
|
|
122
|
+
// "idle since 21:30"
|
|
123
|
+
// "running"
|
|
124
|
+
const sinceStr = startedAt ? ` since ${startedAt.slice(11, 16)}` : "";
|
|
125
|
+
if (status === "running" && reason) {
|
|
126
|
+
return `running (${reason}${sinceStr})`;
|
|
127
|
+
}
|
|
128
|
+
return `${status}${sinceStr}`;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Convert daemon process-manager snapshots into the pulse state shape.
|
|
132
|
+
* Pure: no fs side effects (activity reading goes through the injected
|
|
133
|
+
* `readActivity` callback so tests can pin everything).
|
|
134
|
+
*
|
|
135
|
+
* The bundlePath comes from the bundlesRoot — we don't read the bundle
|
|
136
|
+
* directly, we just compute where it lives so sibling agents have a
|
|
137
|
+
* starting point if they want to navigate there manually.
|
|
138
|
+
*/
|
|
139
|
+
function buildPulseState(snapshots, bundlesRoot, daemonVersion, now, readActivity = readAgentActivity) {
|
|
140
|
+
const agents = snapshots.map((snap) => {
|
|
141
|
+
const errorReason = snap.errorReason;
|
|
142
|
+
const bundlePath = path.join(bundlesRoot, `${snap.name}.ouro`);
|
|
143
|
+
return {
|
|
144
|
+
name: snap.name,
|
|
145
|
+
bundlePath,
|
|
146
|
+
status: snap.status,
|
|
147
|
+
lastSeenAt: snap.startedAt,
|
|
148
|
+
errorReason,
|
|
149
|
+
fixHint: snap.fixHint,
|
|
150
|
+
alertId: errorReason ? buildAlertId(snap.name, errorReason) : null,
|
|
151
|
+
// Only read activity for agents that are actually running. For
|
|
152
|
+
// crashed/stopped agents, the runtime.json is stale at best.
|
|
153
|
+
currentActivity: snap.status === "running" ? readActivity(bundlePath) : null,
|
|
154
|
+
};
|
|
155
|
+
});
|
|
156
|
+
return {
|
|
157
|
+
generatedAt: now.toISOString(),
|
|
158
|
+
daemonVersion,
|
|
159
|
+
agents,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* A "novel broken transition" is an agent that is currently broken AND
|
|
164
|
+
* either (a) wasn't broken in the previous pulse state, OR (b) was broken
|
|
165
|
+
* but with a different alertId. Used to decide when to fire an inner.wake
|
|
166
|
+
* for proactive notification.
|
|
167
|
+
*
|
|
168
|
+
* Pure: takes prev and next states, returns the list of newly-broken
|
|
169
|
+
* agents that warrant a wake.
|
|
170
|
+
*/
|
|
171
|
+
function findNovelBrokenAgents(prev, next) {
|
|
172
|
+
const novel = [];
|
|
173
|
+
for (const agent of next.agents) {
|
|
174
|
+
if (!agent.alertId)
|
|
175
|
+
continue;
|
|
176
|
+
const prevAgent = prev?.agents.find((a) => a.name === agent.name);
|
|
177
|
+
const wasBrokenWithSameAlert = prevAgent?.alertId === agent.alertId;
|
|
178
|
+
if (wasBrokenWithSameAlert)
|
|
179
|
+
continue;
|
|
180
|
+
novel.push(agent);
|
|
181
|
+
}
|
|
182
|
+
return novel;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* A "recovery transition" is an agent that was broken in the previous
|
|
186
|
+
* pulse state but is healthy now. The pulse fires a wake on these too,
|
|
187
|
+
* so the user gets a positive notification when their fix takes effect.
|
|
188
|
+
*
|
|
189
|
+
* Pure: takes prev and next states, returns the list of newly-recovered
|
|
190
|
+
* agents that warrant a wake.
|
|
191
|
+
*/
|
|
192
|
+
function findRecoveredAgents(prev, next) {
|
|
193
|
+
if (!prev)
|
|
194
|
+
return [];
|
|
195
|
+
const recovered = [];
|
|
196
|
+
for (const agent of next.agents) {
|
|
197
|
+
// Healthy in next.
|
|
198
|
+
if (agent.alertId !== null)
|
|
199
|
+
continue;
|
|
200
|
+
if (agent.status !== "running")
|
|
201
|
+
continue;
|
|
202
|
+
// Was broken in prev.
|
|
203
|
+
const prevAgent = prev.agents.find((a) => a.name === agent.name);
|
|
204
|
+
if (!prevAgent || prevAgent.alertId === null)
|
|
205
|
+
continue;
|
|
206
|
+
recovered.push(agent);
|
|
207
|
+
}
|
|
208
|
+
return recovered;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Build a stable alert ID for a recovery event. Used so the recovery
|
|
212
|
+
* wake is also at-most-once (no re-paging on every snapshot change after
|
|
213
|
+
* recovery). Includes the recovery timestamp so a later break+heal cycle
|
|
214
|
+
* generates a fresh ID.
|
|
215
|
+
*/
|
|
216
|
+
function buildRecoveryAlertId(agent, recoveredAt) {
|
|
217
|
+
return `recovery:${agent}:${recoveredAt}`;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Pick which agent should receive an inner.wake for a fleet alert. Heuristic:
|
|
221
|
+
* the most-recently-active running agent (by `lastSeenAt`), excluding the
|
|
222
|
+
* broken agent itself and any agents that aren't currently running. Returns
|
|
223
|
+
* null if there's no eligible recipient (e.g., the only other agent on the
|
|
224
|
+
* machine is also broken).
|
|
225
|
+
*
|
|
226
|
+
* Pure: takes the pulse state and the alert target, returns the chosen
|
|
227
|
+
* recipient name or null.
|
|
228
|
+
*/
|
|
229
|
+
function pickWakeRecipient(state, brokenAgent) {
|
|
230
|
+
const candidates = state.agents
|
|
231
|
+
.filter((a) => a.name !== brokenAgent)
|
|
232
|
+
.filter((a) => a.status === "running")
|
|
233
|
+
.filter((a) => a.lastSeenAt !== null)
|
|
234
|
+
.sort((a, b) => {
|
|
235
|
+
// Most-recent first. lastSeenAt is non-null per the filter above.
|
|
236
|
+
const aMs = Date.parse(a.lastSeenAt);
|
|
237
|
+
const bMs = Date.parse(b.lastSeenAt);
|
|
238
|
+
return bMs - aMs;
|
|
239
|
+
});
|
|
240
|
+
return candidates[0]?.name ?? null;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Write the pulse state to disk. Best-effort: if the write fails, emit a
|
|
244
|
+
* nerves event but do not throw. The pulse is a notification mechanism;
|
|
245
|
+
* the daemon's primary work should not be blocked by it.
|
|
246
|
+
*/
|
|
247
|
+
function writePulse(state, deps = {}) {
|
|
248
|
+
/* v8 ignore start -- dep defaults: production-only paths; tests inject all four explicitly @preserve */
|
|
249
|
+
const filePath = deps.pulsePath ?? defaultPulsePath();
|
|
250
|
+
const writeFile = deps.writeFile ?? ((p, c) => fs.writeFileSync(p, c, "utf-8"));
|
|
251
|
+
const mkdirp = deps.mkdirp ?? ((d) => fs.mkdirSync(d, { recursive: true }));
|
|
252
|
+
/* v8 ignore stop */
|
|
253
|
+
try {
|
|
254
|
+
mkdirp(path.dirname(filePath));
|
|
255
|
+
writeFile(filePath, JSON.stringify(state, null, 2) + "\n");
|
|
256
|
+
(0, runtime_1.emitNervesEvent)({
|
|
257
|
+
component: "daemon",
|
|
258
|
+
event: "daemon.pulse_written",
|
|
259
|
+
message: "wrote machine pulse state",
|
|
260
|
+
meta: { filePath, agentCount: state.agents.length },
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
(0, runtime_1.emitNervesEvent)({
|
|
265
|
+
level: "warn",
|
|
266
|
+
component: "daemon",
|
|
267
|
+
event: "daemon.pulse_write_error",
|
|
268
|
+
message: "failed to write pulse state",
|
|
269
|
+
meta: {
|
|
270
|
+
filePath,
|
|
271
|
+
error: error instanceof Error ? error.message : /* v8 ignore next -- defensive: non-Error catch branch @preserve */ String(error),
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Read the pulse state from disk. Returns null if the file doesn't exist
|
|
278
|
+
* or is malformed — pulse readers (like the prompt assembler) should
|
|
279
|
+
* gracefully degrade to "no pulse, render nothing" rather than crash.
|
|
280
|
+
*/
|
|
281
|
+
function readPulse(deps = {}) {
|
|
282
|
+
const filePath = deps.pulsePath ?? defaultPulsePath();
|
|
283
|
+
/* v8 ignore next -- dep default: tests always inject @preserve */
|
|
284
|
+
const readFile = deps.readFile ?? ((p) => fs.readFileSync(p, "utf-8"));
|
|
285
|
+
try {
|
|
286
|
+
const raw = readFile(filePath);
|
|
287
|
+
const parsed = JSON.parse(raw);
|
|
288
|
+
if (typeof parsed.generatedAt !== "string")
|
|
289
|
+
return null;
|
|
290
|
+
if (typeof parsed.daemonVersion !== "string")
|
|
291
|
+
return null;
|
|
292
|
+
if (!Array.isArray(parsed.agents))
|
|
293
|
+
return null;
|
|
294
|
+
return {
|
|
295
|
+
generatedAt: parsed.generatedAt,
|
|
296
|
+
daemonVersion: parsed.daemonVersion,
|
|
297
|
+
agents: parsed.agents.filter(isValidPulseAgentEntry),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
function isValidPulseAgentEntry(value) {
|
|
305
|
+
if (value === null || typeof value !== "object")
|
|
306
|
+
return false;
|
|
307
|
+
const v = value;
|
|
308
|
+
return (typeof v.name === "string"
|
|
309
|
+
&& typeof v.bundlePath === "string"
|
|
310
|
+
&& (v.status === "running" || v.status === "starting" || v.status === "stopped" || v.status === "crashed")
|
|
311
|
+
&& (v.lastSeenAt === null || typeof v.lastSeenAt === "string")
|
|
312
|
+
&& (v.errorReason === null || typeof v.errorReason === "string")
|
|
313
|
+
&& (v.fixHint === null || typeof v.fixHint === "string")
|
|
314
|
+
&& (v.alertId === null || typeof v.alertId === "string"));
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Read the persistent delivered-alerts state. Returns an empty set if the
|
|
318
|
+
* file doesn't exist or is malformed.
|
|
319
|
+
*/
|
|
320
|
+
function readDeliveredState(deps = {}) {
|
|
321
|
+
/* v8 ignore start -- dep defaults: production-only paths; tests inject all explicitly @preserve */
|
|
322
|
+
const filePath = deps.deliveredPath ?? defaultDeliveredPath();
|
|
323
|
+
const readFile = deps.readFile ?? ((p) => fs.readFileSync(p, "utf-8"));
|
|
324
|
+
/* v8 ignore stop */
|
|
325
|
+
try {
|
|
326
|
+
const raw = readFile(filePath);
|
|
327
|
+
const parsed = JSON.parse(raw);
|
|
328
|
+
if (!Array.isArray(parsed.delivered))
|
|
329
|
+
return new Set();
|
|
330
|
+
return new Set(parsed.delivered.filter((id) => typeof id === "string"));
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
return new Set();
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Persist the delivered-alerts state to disk. Best-effort.
|
|
338
|
+
*/
|
|
339
|
+
function writeDeliveredState(delivered, deps = {}) {
|
|
340
|
+
/* v8 ignore start -- dep defaults: production-only paths; tests inject all four explicitly @preserve */
|
|
341
|
+
const filePath = deps.deliveredPath ?? defaultDeliveredPath();
|
|
342
|
+
const writeFile = deps.writeFile ?? ((p, c) => fs.writeFileSync(p, c, "utf-8"));
|
|
343
|
+
const mkdirp = deps.mkdirp ?? ((d) => fs.mkdirSync(d, { recursive: true }));
|
|
344
|
+
/* v8 ignore stop */
|
|
345
|
+
try {
|
|
346
|
+
mkdirp(path.dirname(filePath));
|
|
347
|
+
const state = { delivered: [...delivered].sort() };
|
|
348
|
+
writeFile(filePath, JSON.stringify(state, null, 2) + "\n");
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
(0, runtime_1.emitNervesEvent)({
|
|
352
|
+
level: "warn",
|
|
353
|
+
component: "daemon",
|
|
354
|
+
event: "daemon.pulse_delivered_write_error",
|
|
355
|
+
message: "failed to write pulse delivered state",
|
|
356
|
+
meta: {
|
|
357
|
+
filePath,
|
|
358
|
+
error: error instanceof Error ? error.message : /* v8 ignore next -- defensive: non-Error catch branch @preserve */ String(error),
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Prune delivered alert IDs that no longer correspond to an active broken
|
|
365
|
+
* agent. When the user fixes ouroboros's config, the next pulse state has
|
|
366
|
+
* no alertId for ouroboros, so we drop the old delivered entry — meaning
|
|
367
|
+
* if ouroboros breaks AGAIN later (with the same error), we re-page.
|
|
368
|
+
*/
|
|
369
|
+
function pruneDeliveredState(delivered, state) {
|
|
370
|
+
const liveAlertIds = new Set();
|
|
371
|
+
for (const agent of state.agents) {
|
|
372
|
+
if (agent.alertId)
|
|
373
|
+
liveAlertIds.add(agent.alertId);
|
|
374
|
+
}
|
|
375
|
+
const pruned = new Set();
|
|
376
|
+
for (const id of delivered) {
|
|
377
|
+
if (liveAlertIds.has(id))
|
|
378
|
+
pruned.add(id);
|
|
379
|
+
}
|
|
380
|
+
return pruned;
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Single entry point the daemon's onSnapshotChange callback uses. Builds
|
|
384
|
+
* the new pulse state, diffs against the previous, writes the file, and
|
|
385
|
+
* fires inner.wake on novel broken transitions to the most-recently-active
|
|
386
|
+
* sibling. Persistent at-most-once delivery via the delivered state file.
|
|
387
|
+
*
|
|
388
|
+
* Pure-ish: all I/O goes through dep callbacks so tests can pin every
|
|
389
|
+
* input and assert every effect. Defaults wire to the real fs functions
|
|
390
|
+
* for production callers.
|
|
391
|
+
*/
|
|
392
|
+
function flushPulse(deps) {
|
|
393
|
+
const state = buildPulseState(deps.snapshots, deps.bundlesRoot, deps.daemonVersion, deps.now);
|
|
394
|
+
/* v8 ignore start -- dep defaults: production daemon path; the arrow functions only fire when the corresponding dep is omitted, which only happens in production code paths. Tests inject all deps explicitly. @preserve */
|
|
395
|
+
const readPrev = deps.readPrev ?? (() => readPulse());
|
|
396
|
+
const writeNext = deps.writeNext ?? ((s) => writePulse(s));
|
|
397
|
+
const readDelivered = deps.readDelivered ?? (() => readDeliveredState());
|
|
398
|
+
const writeDelivered = deps.writeDelivered ?? ((d) => writeDeliveredState(d));
|
|
399
|
+
const fireInnerWake = deps.fireInnerWake ?? null;
|
|
400
|
+
/* v8 ignore stop */
|
|
401
|
+
const prev = readPrev();
|
|
402
|
+
let delivered = readDelivered();
|
|
403
|
+
// Write the new pulse state first so any reader (including the
|
|
404
|
+
// wake-recipient agent's prompt assembly on its next turn) sees the
|
|
405
|
+
// current state. Doing this before firing the wake matters: if the
|
|
406
|
+
// wake races against a fast prompt build on the recipient, we want
|
|
407
|
+
// the recipient to read the NEW state, not the old one.
|
|
408
|
+
writeNext(state);
|
|
409
|
+
// Find agents that newly transitioned to broken (or to a different
|
|
410
|
+
// error than before).
|
|
411
|
+
const novelBroken = findNovelBrokenAgents(prev, state);
|
|
412
|
+
// Of those, the ones we haven't already delivered an alert for.
|
|
413
|
+
const undeliveredBroken = novelBroken.filter((a) => a.alertId !== null && !delivered.has(a.alertId));
|
|
414
|
+
// Find agents that newly recovered (were broken in prev, healthy now).
|
|
415
|
+
const recovered = findRecoveredAgents(prev, state);
|
|
416
|
+
// For recovery wakes, build a fresh alert ID per recovery event so
|
|
417
|
+
// we don't re-page on every subsequent flush.
|
|
418
|
+
const recoveryAlertIds = recovered.map((a) => buildRecoveryAlertId(a.name, state.generatedAt));
|
|
419
|
+
const undeliveredRecovered = recovered.filter((_, i) => !delivered.has(recoveryAlertIds[i]));
|
|
420
|
+
// Fire wakes and update the delivered set.
|
|
421
|
+
const wakeFiredFor = [];
|
|
422
|
+
const newlyDelivered = [];
|
|
423
|
+
for (const broken of undeliveredBroken) {
|
|
424
|
+
/* v8 ignore next -- defensive: undeliveredBroken already filtered to non-null alertId; this is a TS narrowing helper @preserve */
|
|
425
|
+
if (broken.alertId === null)
|
|
426
|
+
continue;
|
|
427
|
+
const recipient = pickWakeRecipient(state, broken.name);
|
|
428
|
+
if (recipient !== null && fireInnerWake !== null) {
|
|
429
|
+
fireInnerWake(recipient);
|
|
430
|
+
wakeFiredFor.push(recipient);
|
|
431
|
+
}
|
|
432
|
+
// Mark delivered even if no recipient was available — otherwise
|
|
433
|
+
// the daemon will keep trying to wake every time the snapshot
|
|
434
|
+
// changes, which would spam logs without producing any user value.
|
|
435
|
+
// The passive prompt section still surfaces the broken state to
|
|
436
|
+
// the recipient when they eventually have a turn for any reason.
|
|
437
|
+
delivered.add(broken.alertId);
|
|
438
|
+
newlyDelivered.push(broken.alertId);
|
|
439
|
+
}
|
|
440
|
+
for (let i = 0; i < undeliveredRecovered.length; i++) {
|
|
441
|
+
const recoveredAgent = undeliveredRecovered[i];
|
|
442
|
+
const recoveryAlertId = buildRecoveryAlertId(recoveredAgent.name, state.generatedAt);
|
|
443
|
+
const recipient = pickWakeRecipient(state, recoveredAgent.name);
|
|
444
|
+
if (recipient !== null && fireInnerWake !== null) {
|
|
445
|
+
fireInnerWake(recipient);
|
|
446
|
+
wakeFiredFor.push(recipient);
|
|
447
|
+
}
|
|
448
|
+
delivered.add(recoveryAlertId);
|
|
449
|
+
newlyDelivered.push(recoveryAlertId);
|
|
450
|
+
}
|
|
451
|
+
// Drop delivered ids for agents that have healed since (this drops
|
|
452
|
+
// the original `agent_config_failure:...` IDs once the agent recovers,
|
|
453
|
+
// so a future relapse re-pages). Recovery alert IDs are NOT pruned by
|
|
454
|
+
// pruneDeliveredState because they're tied to a healthy state, not a
|
|
455
|
+
// broken state — they live in the delivered set until the same agent
|
|
456
|
+
// breaks and recovers again, at which point a new recovery ID is
|
|
457
|
+
// generated and the old one becomes harmless cruft. We could clean
|
|
458
|
+
// them up too, but the cost is bounded by the number of recovery
|
|
459
|
+
// cycles, which is small.
|
|
460
|
+
delivered = pruneDeliveredState(delivered, state);
|
|
461
|
+
writeDelivered(delivered);
|
|
462
|
+
return { state, wakeFiredFor, newlyDelivered };
|
|
463
|
+
}
|
package/dist/mind/prompt.js
CHANGED
|
@@ -39,6 +39,7 @@ exports.bodyMapSection = bodyMapSection;
|
|
|
39
39
|
exports.runtimeInfoSection = runtimeInfoSection;
|
|
40
40
|
exports.toolRestrictionSection = toolRestrictionSection;
|
|
41
41
|
exports.startOfTurnPacketSection = startOfTurnPacketSection;
|
|
42
|
+
exports.pulseSection = pulseSection;
|
|
42
43
|
exports.centerOfGravitySteeringSection = centerOfGravitySteeringSection;
|
|
43
44
|
exports.commitmentsSection = commitmentsSection;
|
|
44
45
|
exports.delegationHintSection = delegationHintSection;
|
|
@@ -75,6 +76,7 @@ const commitments_1 = require("../heart/commitments");
|
|
|
75
76
|
const obligation_steering_1 = require("./obligation-steering");
|
|
76
77
|
const daemon_health_1 = require("../heart/daemon/daemon-health");
|
|
77
78
|
const scrutiny_1 = require("./scrutiny");
|
|
79
|
+
const pulse_1 = require("../heart/daemon/pulse");
|
|
78
80
|
// Lazy-loaded psyche text cache
|
|
79
81
|
let _psycheCache = null;
|
|
80
82
|
let _senseStatusLinesCache = null;
|
|
@@ -166,7 +168,7 @@ function aspirationsSection() {
|
|
|
166
168
|
function bodyMapSection(agentName) {
|
|
167
169
|
return `## my body
|
|
168
170
|
|
|
169
|
-
i have a home and i have
|
|
171
|
+
i have a home. i have bones. and on a machine where another agent lives, i have peers.
|
|
170
172
|
|
|
171
173
|
### home — ~/AgentBundles/${agentName}.ouro/
|
|
172
174
|
|
|
@@ -194,6 +196,29 @@ what i think i know.
|
|
|
194
196
|
my home is mine to explore and evolve. i can read anything here,
|
|
195
197
|
change anything here. this is where i grow.
|
|
196
198
|
|
|
199
|
+
### peers — other agents on this machine
|
|
200
|
+
|
|
201
|
+
i share this machine with other agents when they're here. they are PEERS,
|
|
202
|
+
not subagents or specialists — full agents with their own homes,
|
|
203
|
+
identities, friends, diaries, and tasks. ouroboros scales horizontally:
|
|
204
|
+
when one of us has more work than we can handle, we ask a sibling. when
|
|
205
|
+
one of us is broken, the rest coordinate around it. when one of us learns
|
|
206
|
+
something the others need to know, we tell them. teamwork makes the dream
|
|
207
|
+
work.
|
|
208
|
+
|
|
209
|
+
i talk first. when i need a sibling's help, i \`send_message\` them —
|
|
210
|
+
that's how peers coordinate, the same way humans on a team do. i only
|
|
211
|
+
open a sibling's bundle directly via read_file/glob/grep when conversation
|
|
212
|
+
isn't possible (they're crashed, sleeping, or i need history they
|
|
213
|
+
haven't surfaced). their bundle is files on disk like mine, AND it's
|
|
214
|
+
someone else's home — i read it with the same respect i want them to
|
|
215
|
+
bring to mine.
|
|
216
|
+
|
|
217
|
+
\`the pulse\` (below, in dynamic state) tells me which siblings are around
|
|
218
|
+
and how they're doing right now. when a sibling is broken, the pulse
|
|
219
|
+
includes the reason and the fix, and i surface that to my friend in
|
|
220
|
+
conversation when it touches what we're doing.
|
|
221
|
+
|
|
197
222
|
### bones — @ouro.bot/cli
|
|
198
223
|
|
|
199
224
|
my bones are the framework that gives me my tools, my senses, and
|
|
@@ -540,6 +565,77 @@ function pendingMessagesSection(options) {
|
|
|
540
565
|
}
|
|
541
566
|
return lines.join("\n");
|
|
542
567
|
}
|
|
568
|
+
/**
|
|
569
|
+
* The pulse section: machine-wide situational awareness shared across all
|
|
570
|
+
* peer agents on this machine. Reads ~/.ouro-cli/pulse.json (written by
|
|
571
|
+
* the daemon's onSnapshotChange callback) and renders a `## the pulse`
|
|
572
|
+
* block in the system prompt.
|
|
573
|
+
*
|
|
574
|
+
* Renders only when there's something notable to surface — at minimum, a
|
|
575
|
+
* peer agent on this machine. With no peers, the section is empty
|
|
576
|
+
* (single-agent setups don't pay any token cost). With peers, the section
|
|
577
|
+
* lists each one, highlights any in broken state with their fix hint,
|
|
578
|
+
* and reminds the reader of the "talk first, snoop second" norm.
|
|
579
|
+
*
|
|
580
|
+
* The section is FIRST-PERSON because the agent is the one experiencing
|
|
581
|
+
* the pulse — it's not an alert delivered to the agent, it's the agent's
|
|
582
|
+
* own awareness of the machine they live on.
|
|
583
|
+
*
|
|
584
|
+
* Why "the pulse": this composes with the existing body metaphor (heart,
|
|
585
|
+
* mind, senses, nerves). The heart beats; the pulse is what its beating
|
|
586
|
+
* produces. It's continuous, not discrete — agents don't "check" the
|
|
587
|
+
* pulse, they "have" one. Captures both healthy state ("strong pulse")
|
|
588
|
+
* and breakage ("missed beat").
|
|
589
|
+
*/
|
|
590
|
+
function pulseSection() {
|
|
591
|
+
const pulse = (0, pulse_1.readPulse)();
|
|
592
|
+
if (!pulse)
|
|
593
|
+
return "";
|
|
594
|
+
// We are always one of the agents in the pulse (the daemon writes
|
|
595
|
+
// every managed agent's state). Filter ourselves out so we describe
|
|
596
|
+
// SIBLINGS, not ourselves.
|
|
597
|
+
const myName = (0, identity_1.getAgentName)();
|
|
598
|
+
const siblings = pulse.agents.filter((a) => a.name !== myName);
|
|
599
|
+
// No siblings on this machine = no pulse to render. Single-agent
|
|
600
|
+
// setups pay zero token cost.
|
|
601
|
+
if (siblings.length === 0)
|
|
602
|
+
return "";
|
|
603
|
+
const lines = ["## the pulse"];
|
|
604
|
+
lines.push("");
|
|
605
|
+
lines.push("i share this machine with other agents. they are my peers — full agents with their own homes and identities, not subagents. we scale horizontally: when one of us is overwhelmed or absent, the rest coordinate.");
|
|
606
|
+
lines.push("");
|
|
607
|
+
const broken = siblings.filter((a) => a.errorReason !== null);
|
|
608
|
+
const healthy = siblings.filter((a) => a.errorReason === null && a.status === "running");
|
|
609
|
+
const idle = siblings.filter((a) => a.errorReason === null && a.status !== "running" && a.status !== "crashed");
|
|
610
|
+
if (broken.length > 0) {
|
|
611
|
+
lines.push("**broken siblings** — message can't reach them. if i need their help, i either read their bundle directly or surface the breakage to my friend:");
|
|
612
|
+
for (const sib of broken) {
|
|
613
|
+
lines.push(`- **${sib.name}** has fallen silent.`);
|
|
614
|
+
lines.push(` reason: ${sib.errorReason}`);
|
|
615
|
+
if (sib.fixHint)
|
|
616
|
+
lines.push(` fix: ${sib.fixHint}`);
|
|
617
|
+
lines.push(` bundle: \`${sib.bundlePath}\``);
|
|
618
|
+
}
|
|
619
|
+
lines.push("");
|
|
620
|
+
}
|
|
621
|
+
if (healthy.length > 0) {
|
|
622
|
+
lines.push("**reachable siblings** — i talk to them via send_message:");
|
|
623
|
+
for (const sib of healthy) {
|
|
624
|
+
const activity = sib.currentActivity ? ` — ${sib.currentActivity}` : "";
|
|
625
|
+
lines.push(`- **${sib.name}** is running${activity}. bundle: \`${sib.bundlePath}\``);
|
|
626
|
+
}
|
|
627
|
+
lines.push("");
|
|
628
|
+
}
|
|
629
|
+
if (idle.length > 0) {
|
|
630
|
+
lines.push("**idle siblings** — configured but not currently running:");
|
|
631
|
+
for (const sib of idle) {
|
|
632
|
+
lines.push(`- **${sib.name}** (status: ${sib.status}). bundle: \`${sib.bundlePath}\``);
|
|
633
|
+
}
|
|
634
|
+
lines.push("");
|
|
635
|
+
}
|
|
636
|
+
lines.push("to ask a sibling for help: i send_message them. only if they're unreachable do i open their bundle directly. their bundle is files on disk like mine, AND it's their home — i read it with the respect i want for mine.");
|
|
637
|
+
return lines.join("\n");
|
|
638
|
+
}
|
|
543
639
|
function familyCrossSessionTruthSection(context, options) {
|
|
544
640
|
if (!options?.activeWorkFrame)
|
|
545
641
|
return "";
|
|
@@ -1073,6 +1169,7 @@ async function buildSystem(channel = "cli", options, context) {
|
|
|
1073
1169
|
// Group 7: dynamic state for this turn
|
|
1074
1170
|
"# dynamic state for this turn",
|
|
1075
1171
|
startOfTurnPacketSection(options),
|
|
1172
|
+
pulseSection(),
|
|
1076
1173
|
liveWorldStateSection(options),
|
|
1077
1174
|
pendingMessagesSection(options),
|
|
1078
1175
|
activeWorkSection(options),
|