@ouro.bot/cli 0.1.0-alpha.297 → 0.1.0-alpha.299

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.
@@ -723,6 +723,19 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
723
723
  message: "ouro CLI command invoked",
724
724
  meta: { kind: command.kind },
725
725
  });
726
+ if (command.kind === "bluebubbles.replay") {
727
+ const { replayBlueBubblesMessage, formatBlueBubblesReplayText } = await Promise.resolve().then(() => __importStar(require("../../senses/bluebubbles/replay")));
728
+ const replay = await replayBlueBubblesMessage({
729
+ agentName: command.agent,
730
+ messageGuid: command.messageGuid,
731
+ eventType: command.eventType,
732
+ });
733
+ const text = command.json
734
+ ? JSON.stringify(replay, null, 2)
735
+ : formatBlueBubblesReplayText(replay);
736
+ deps.writeStdout(text);
737
+ return text;
738
+ }
726
739
  if (command.kind === "daemon.up") {
727
740
  // ── dev mode cleanup: delete dev-config.json so the wrapper stops dispatching to dev repo ──
728
741
  /* v8 ignore start -- dev-config cleanup: requires real filesystem state @preserve */
@@ -56,6 +56,7 @@ function usage() {
56
56
  " ouro habit list [--agent <name>]",
57
57
  " ouro habit create [--agent <name>] <name> [--cadence <interval>]",
58
58
  " ouro link <agent> --friend <id> --provider <provider> --external-id <external-id>",
59
+ " ouro bluebubbles replay --agent <name> --message-guid <guid> [--event-type new-message|updated-message] [--json]",
59
60
  " ouro task board [<status>] [--agent <name>]",
60
61
  " ouro task create <title> [--type <type>] [--agent <name>]",
61
62
  " ouro task update <id> <status> [--agent <name>]",
@@ -636,6 +637,49 @@ function parseSetupCommand(args) {
636
637
  throw new Error("setup requires --agent <name>");
637
638
  return { kind: "setup", tool, agent };
638
639
  }
640
+ function parseBlueBubblesCommand(args) {
641
+ const subcommand = args[0];
642
+ if (subcommand !== "replay") {
643
+ throw new Error(`Usage\n${usage()}`);
644
+ }
645
+ let agent;
646
+ let messageGuid;
647
+ let eventType = "new-message";
648
+ let json = false;
649
+ for (let i = 1; i < args.length; i += 1) {
650
+ if (args[i] === "--agent" && args[i + 1]) {
651
+ agent = args[++i];
652
+ continue;
653
+ }
654
+ if (args[i] === "--message-guid" && args[i + 1]) {
655
+ messageGuid = args[++i];
656
+ continue;
657
+ }
658
+ if (args[i] === "--event-type" && args[i + 1]) {
659
+ const candidate = args[++i];
660
+ if (candidate !== "new-message" && candidate !== "updated-message") {
661
+ throw new Error("bluebubbles replay --event-type must be new-message or updated-message");
662
+ }
663
+ eventType = candidate;
664
+ continue;
665
+ }
666
+ if (args[i] === "--json") {
667
+ json = true;
668
+ continue;
669
+ }
670
+ }
671
+ if (!agent)
672
+ throw new Error("bluebubbles replay requires --agent <name>");
673
+ if (!messageGuid)
674
+ throw new Error("bluebubbles replay requires --message-guid <guid>");
675
+ return {
676
+ kind: "bluebubbles.replay",
677
+ agent,
678
+ messageGuid,
679
+ eventType,
680
+ ...(json ? { json: true } : {}),
681
+ };
682
+ }
639
683
  // ── Main dispatch ──
640
684
  function parseOuroCommand(args) {
641
685
  const [head, second] = args;
@@ -758,5 +802,7 @@ function parseOuroCommand(args) {
758
802
  return parseMcpServeCommand(args.slice(1));
759
803
  if (head === "setup")
760
804
  return parseSetupCommand(args.slice(1));
805
+ if (head === "bluebubbles")
806
+ return parseBlueBubblesCommand(args.slice(1));
761
807
  throw new Error(`Unknown command '${args.join(" ")}'.\n${usage()}`);
762
808
  }
@@ -140,6 +140,13 @@ const healthMonitor = new health_monitor_1.HealthMonitor({
140
140
  meta: { message },
141
141
  });
142
142
  },
143
+ /* v8 ignore next 3 -- wiring: delegates to processManager.restartAgent which has full unit tests @preserve */
144
+ onCriticalAgent: (agentName) => {
145
+ try {
146
+ processManager.restartAgent(agentName);
147
+ }
148
+ catch { /* recovery is best-effort */ }
149
+ },
143
150
  });
144
151
  const daemon = new daemon_1.OuroDaemon({
145
152
  socketPath,
@@ -199,6 +206,7 @@ void daemon.start().then(() => {
199
206
  scheduler.watchForChanges();
200
207
  habitSchedulers.push(scheduler);
201
208
  }
209
+ healthMonitor.startPeriodicChecks(60_000);
202
210
  /* v8 ignore start -- startup failure + signal handlers: call process.exit, untestable in vitest @preserve */
203
211
  }).catch(async (err) => {
204
212
  const error = err instanceof Error ? err : new Error(String(err));
@@ -229,6 +237,7 @@ process.on("SIGINT", () => {
229
237
  s.stopWatching();
230
238
  s.stop();
231
239
  }
240
+ healthMonitor.stopPeriodicChecks();
232
241
  setTimeout(() => process.exit(1), 5_000).unref();
233
242
  void daemon.stop().then(() => process.exit(0));
234
243
  });
@@ -239,6 +248,7 @@ process.on("SIGTERM", () => {
239
248
  s.stopWatching();
240
249
  s.stop();
241
250
  }
251
+ healthMonitor.stopPeriodicChecks();
242
252
  setTimeout(() => process.exit(1), 5_000).unref();
243
253
  void daemon.stop().then(() => process.exit(0));
244
254
  });
@@ -7,11 +7,34 @@ class HealthMonitor {
7
7
  scheduler;
8
8
  alertSink;
9
9
  diskUsagePercent;
10
+ onCriticalAgent;
11
+ intervalHandle = null;
10
12
  constructor(options) {
11
13
  this.processManager = options.processManager;
12
14
  this.scheduler = options.scheduler;
13
15
  this.alertSink = options.alertSink ?? (() => undefined);
14
16
  this.diskUsagePercent = options.diskUsagePercent ?? (() => 0);
17
+ this.onCriticalAgent = options.onCriticalAgent ?? (() => undefined);
18
+ }
19
+ startPeriodicChecks(intervalMs) {
20
+ if (this.intervalHandle !== null)
21
+ return;
22
+ (0, runtime_1.emitNervesEvent)({
23
+ level: "info",
24
+ component: "daemon",
25
+ event: "daemon.health_check_scheduled",
26
+ message: "periodic health checks started",
27
+ meta: { intervalMs },
28
+ });
29
+ this.intervalHandle = setInterval(() => {
30
+ void this.runChecks();
31
+ }, intervalMs);
32
+ }
33
+ stopPeriodicChecks() {
34
+ if (this.intervalHandle === null)
35
+ return;
36
+ clearInterval(this.intervalHandle);
37
+ this.intervalHandle = null;
15
38
  }
16
39
  async runChecks() {
17
40
  const results = [];
@@ -23,6 +46,21 @@ class HealthMonitor {
23
46
  status: "critical",
24
47
  message: `non-running agents: ${unhealthy.map((item) => item.name).join(", ")}`,
25
48
  });
49
+ for (const agent of unhealthy) {
50
+ try {
51
+ (0, runtime_1.emitNervesEvent)({
52
+ level: "warn",
53
+ component: "daemon",
54
+ event: "daemon.health_check_recovery_attempted",
55
+ message: "triggering recovery restart for non-running agent",
56
+ meta: { agentName: agent.name, agentStatus: agent.status },
57
+ });
58
+ this.onCriticalAgent(agent.name);
59
+ }
60
+ catch {
61
+ // Recovery is best-effort -- callback errors must not crash runChecks
62
+ }
63
+ }
26
64
  }
27
65
  else {
28
66
  results.push({ name: "agent-processes", status: "ok", message: "all managed agents running" });
@@ -68,6 +68,9 @@ function recordBlueBubblesInbound(agentName, event, source) {
68
68
  const filePath = getBlueBubblesInboundLogPath(agentName, event.chat.sessionKey);
69
69
  try {
70
70
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
71
+ if (event.messageGuid.trim() && readEntries(filePath).some((entry) => entry.messageGuid === event.messageGuid)) {
72
+ return filePath;
73
+ }
71
74
  fs.appendFileSync(filePath, JSON.stringify({
72
75
  recordedAt: new Date(event.timestamp).toISOString(),
73
76
  messageGuid: event.messageGuid,
@@ -656,9 +656,11 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source) {
656
656
  }
657
657
  // Record EARLY to prevent duplicate processing. BB webhooks can retry
658
658
  // before the first turn completes — recording after the turn is too late.
659
- (0, inbound_log_1.recordBlueBubblesInbound)(agentName, event, source);
660
- if (source !== "webhook" && sessionLikelyContainsMessage(event, existing?.messages ?? sessionMessages)) {
661
- (0, inbound_log_1.recordBlueBubblesInbound)(agentName, event, "recovery-bootstrap");
659
+ const inboundSource = source !== "webhook" && sessionLikelyContainsMessage(event, existing?.messages ?? sessionMessages)
660
+ ? "recovery-bootstrap"
661
+ : source;
662
+ (0, inbound_log_1.recordBlueBubblesInbound)(agentName, event, inboundSource);
663
+ if (inboundSource === "recovery-bootstrap") {
662
664
  (0, runtime_1.emitNervesEvent)({
663
665
  component: "senses",
664
666
  event: "senses.bluebubbles_recovery_skip",
@@ -813,9 +815,6 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source) {
813
815
  text: result.gateResult.autoReply,
814
816
  });
815
817
  }
816
- if (event.kind === "message") {
817
- (0, inbound_log_1.recordBlueBubblesInbound)(resolvedDeps.getAgentName(), event, source);
818
- }
819
818
  return {
820
819
  handled: true,
821
820
  notifiedAgent: false,
@@ -824,9 +823,6 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source) {
824
823
  }
825
824
  // Gate allowed — flush the agent's reply
826
825
  await callbacks.flush();
827
- if (event.kind === "message") {
828
- (0, inbound_log_1.recordBlueBubblesInbound)(resolvedDeps.getAgentName(), event, source);
829
- }
830
826
  (0, runtime_1.emitNervesEvent)({
831
827
  component: "senses",
832
828
  event: "senses.bluebubbles_turn_end",
@@ -859,7 +855,28 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source) {
859
855
  async function handleBlueBubblesEvent(payload, deps = {}) {
860
856
  const resolvedDeps = { ...defaultDeps, ...deps };
861
857
  const client = resolvedDeps.createClient();
862
- const normalized = (0, model_1.normalizeBlueBubblesEvent)(payload);
858
+ let normalized;
859
+ try {
860
+ normalized = (0, model_1.normalizeBlueBubblesEvent)(payload);
861
+ }
862
+ catch (error) {
863
+ if (error instanceof model_1.BlueBubblesIgnoredEventError) {
864
+ (0, runtime_1.emitNervesEvent)({
865
+ component: "senses",
866
+ event: "senses.bluebubbles_event_skipped",
867
+ message: "skipped ignorable bluebubbles event",
868
+ meta: {
869
+ eventType: error.eventType,
870
+ },
871
+ });
872
+ return {
873
+ handled: true,
874
+ notifiedAgent: false,
875
+ reason: "ignored",
876
+ };
877
+ }
878
+ throw error;
879
+ }
863
880
  // Pre-repair dedup: if we've already processed this messageGuid, skip the
864
881
  // repair+hydrate path entirely. Applies to BOTH `kind: "message"` AND
865
882
  // `kind: "mutation"` events — BlueBubbles often sends a `new-message`
@@ -1,9 +1,22 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BlueBubblesIgnoredEventError = void 0;
3
4
  exports.normalizeBlueBubblesEvent = normalizeBlueBubblesEvent;
4
5
  const runtime_1 = require("../../nerves/runtime");
5
6
  const render_1 = require("../../heart/attachments/render");
6
7
  const types_1 = require("../../heart/attachments/types");
8
+ const IGNORABLE_GUIDLESS_EVENT_TYPES = new Set([
9
+ "chat-read-status-changed",
10
+ ]);
11
+ class BlueBubblesIgnoredEventError extends Error {
12
+ eventType;
13
+ constructor(eventType, message) {
14
+ super(message);
15
+ this.name = "BlueBubblesIgnoredEventError";
16
+ this.eventType = eventType;
17
+ }
18
+ }
19
+ exports.BlueBubblesIgnoredEventError = BlueBubblesIgnoredEventError;
7
20
  function asRecord(value) {
8
21
  return value && typeof value === "object" && !Array.isArray(value)
9
22
  ? value
@@ -203,6 +216,9 @@ function normalizeBlueBubblesEvent(payload) {
203
216
  message: "ignored bluebubbles payload without guid",
204
217
  meta: { eventType },
205
218
  });
219
+ if (IGNORABLE_GUIDLESS_EVENT_TYPES.has(eventType)) {
220
+ throw new BlueBubblesIgnoredEventError(eventType, `Ignored BlueBubbles event '${eventType}' without data.guid`);
221
+ }
206
222
  throw new Error("BlueBubbles payload is missing data.guid");
207
223
  }
208
224
  const threadOriginatorGuid = readString(data, "threadOriginatorGuid")?.trim() || undefined;
@@ -0,0 +1,129 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.replayBlueBubblesMessage = replayBlueBubblesMessage;
4
+ exports.formatBlueBubblesReplayText = formatBlueBubblesReplayText;
5
+ const render_1 = require("../../heart/attachments/render");
6
+ const types_1 = require("../../heart/attachments/types");
7
+ const identity_1 = require("../../heart/identity");
8
+ const runtime_1 = require("../../nerves/runtime");
9
+ const client_1 = require("./client");
10
+ const model_1 = require("./model");
11
+ function buildReplayHint(probeEventType, event) {
12
+ if (probeEventType === "updated-message"
13
+ && event.kind === "mutation"
14
+ && (event.mutationType === "read" || event.mutationType === "delivery")) {
15
+ return "replay resolved to a state-only mutation; rerun with --event-type new-message to inspect the original message payload.";
16
+ }
17
+ return undefined;
18
+ }
19
+ async function replayBlueBubblesMessage(params, deps = {}) {
20
+ const agentName = params.agentName.trim();
21
+ const messageGuid = params.messageGuid.trim();
22
+ const eventType = params.eventType ?? "new-message";
23
+ if (!agentName) {
24
+ throw new Error("bluebubbles replay requires agentName");
25
+ }
26
+ if (!messageGuid) {
27
+ throw new Error("bluebubbles replay requires messageGuid");
28
+ }
29
+ const setReplayAgentName = deps.setAgentName ?? identity_1.setAgentName;
30
+ const resetReplayIdentity = deps.resetIdentity ?? identity_1.resetIdentity;
31
+ const normalizeEvent = deps.normalizeEvent ?? model_1.normalizeBlueBubblesEvent;
32
+ (0, runtime_1.emitNervesEvent)({
33
+ component: "senses",
34
+ event: "senses.bluebubbles_replay_start",
35
+ message: "starting bluebubbles historical replay",
36
+ meta: {
37
+ agentName,
38
+ messageGuid,
39
+ eventType,
40
+ },
41
+ });
42
+ setReplayAgentName(agentName);
43
+ try {
44
+ const client = deps.createClient ? deps.createClient() : (0, client_1.createBlueBubblesClient)();
45
+ const probe = normalizeEvent({
46
+ type: eventType,
47
+ data: {
48
+ guid: messageGuid,
49
+ hasPayloadData: true,
50
+ },
51
+ });
52
+ const event = await client.repairEvent(probe);
53
+ const attachmentRecords = event.kind === "message"
54
+ ? event.attachments
55
+ .filter((attachment) => typeof attachment.guid === "string" && attachment.guid.trim().length > 0)
56
+ .map((attachment) => (0, types_1.buildBlueBubblesAttachmentRecord)(attachment))
57
+ : [];
58
+ const result = {
59
+ probe: {
60
+ agentName,
61
+ messageGuid,
62
+ eventType,
63
+ },
64
+ event,
65
+ attachmentIds: attachmentRecords.map((attachment) => attachment.id),
66
+ attachmentBlock: (0, render_1.renderAttachmentBlock)(attachmentRecords),
67
+ ...(buildReplayHint(eventType, event) ? { hint: buildReplayHint(eventType, event) } : {}),
68
+ };
69
+ (0, runtime_1.emitNervesEvent)({
70
+ component: "senses",
71
+ event: "senses.bluebubbles_replay_end",
72
+ message: "completed bluebubbles historical replay",
73
+ meta: {
74
+ agentName,
75
+ messageGuid,
76
+ eventType,
77
+ kind: event.kind,
78
+ attachmentCount: attachmentRecords.length,
79
+ },
80
+ });
81
+ return result;
82
+ }
83
+ catch (error) {
84
+ (0, runtime_1.emitNervesEvent)({
85
+ level: "warn",
86
+ component: "senses",
87
+ event: "senses.bluebubbles_replay_error",
88
+ message: "bluebubbles historical replay failed",
89
+ meta: {
90
+ agentName,
91
+ messageGuid,
92
+ eventType,
93
+ reason: error instanceof Error ? error.message : String(error),
94
+ },
95
+ });
96
+ throw error;
97
+ }
98
+ finally {
99
+ resetReplayIdentity();
100
+ }
101
+ }
102
+ function formatBlueBubblesReplayText(result) {
103
+ const lines = [
104
+ `probe: ${result.probe.eventType}`,
105
+ `agent: ${result.probe.agentName}`,
106
+ `message_guid: ${result.probe.messageGuid}`,
107
+ `result_kind: ${result.event.kind}`,
108
+ `session: ${result.event.chat.sessionKey}`,
109
+ ];
110
+ if (result.event.kind === "mutation") {
111
+ lines.push(`mutation_type: ${result.event.mutationType}`);
112
+ }
113
+ if (result.event.kind === "message" && result.event.inputPartsForAgent?.length) {
114
+ lines.push(`input_parts_for_agent: ${result.event.inputPartsForAgent.length}`);
115
+ }
116
+ if (result.event.repairNotice?.trim()) {
117
+ lines.push(`repair_notice: ${result.event.repairNotice.trim()}`);
118
+ }
119
+ if (result.attachmentBlock && !result.event.textForAgent.includes(result.attachmentBlock)) {
120
+ lines.push(result.attachmentBlock);
121
+ }
122
+ lines.push("[text_for_agent]");
123
+ lines.push(result.event.textForAgent || "(empty)");
124
+ if (result.hint) {
125
+ lines.push("[hint]");
126
+ lines.push(result.hint);
127
+ }
128
+ return lines.join("\n");
129
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.297",
3
+ "version": "0.1.0-alpha.299",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",