@ouro.bot/cli 0.1.0-alpha.954 → 0.1.0-alpha.955

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,12 @@
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.955",
6
+ "changes": [
7
+ "iMessage no longer loses a reply that fails to send. When Slugger's reply times out or otherwise fails at BlueBubbles, your message stays pending; about ten minutes later a recovery pass checks the chat, and if the reply did not arrive after all, Slugger answers again. The message is marked handled only once a reply goes through. After three failed sends it is given up on with an error in the logs, so one stuck message cannot use up the day's recovery budget. If Slugger had already said something partway through that turn, the message is not retried, so you never see that part twice."
8
+ ]
9
+ },
4
10
  {
5
11
  "version": "0.1.0-alpha.954",
6
12
  "changes": [
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.954",
2
+ "runtimeVersion": "0.1.0-alpha.955",
3
3
  "bundleSchemaVersion": 3,
4
4
  "lastUpdated": "2026-09-03T00:00:00.000Z"
5
5
  }
@@ -1,7 +1,7 @@
1
1
  <?xml version="1.0"?>
2
2
  <Container version="2">
3
3
  <Name>ouro-butler</Name>
4
- <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.954</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.955</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -106,6 +106,11 @@ const BLUEBUBBLES_VISIBLE_OUTBOUND_STATUSES = new Set([
106
106
  "local-visible",
107
107
  "delivered",
108
108
  ]);
109
+ // A reply whose final send keeps failing stays pending for recovery this many
110
+ // times, then gets a terminal receipt so one message cannot drain the budget.
111
+ const BLUEBUBBLES_REPLY_DELIVERY_MAX_ATTEMPTS = 3;
112
+ // A timed-out send may still land, so wait before checking the chat and retrying.
113
+ const BLUEBUBBLES_REPLY_RETRY_DELAY_MS = 10 * 60_000;
109
114
  exports.BLUEBUBBLES_SEMANTIC_GENERATION_RETENTION_MS = 15 * 60_000;
110
115
  function pruneBlueBubblesSemanticObservationGenerations(nowMs = Date.now()) {
111
116
  let pruned = 0;
@@ -1622,8 +1627,12 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, opt
1622
1627
  const turnStage = createBlueBubblesTurnStage(event.chat.sessionKey);
1623
1628
  let finalTransportInvoked = false;
1624
1629
  let finalTransportAccepted = false;
1630
+ let spokeBeforeFinalTransport = false;
1625
1631
  const outboundRuntimeConfig = (0, config_1.getBlueBubblesConfig)();
1626
- const callbacks = createBlueBubblesCallbacks(client, event.chat, replyTarget, event.chat.isGroup, () => (0, active_turns_1.noteBlueBubblesActiveTurnVisibleActivity)(agentName, liveTurnId), {
1632
+ const callbacks = createBlueBubblesCallbacks(client, event.chat, replyTarget, event.chat.isGroup, () => {
1633
+ spokeBeforeFinalTransport = true;
1634
+ (0, active_turns_1.noteBlueBubblesActiveTurnVisibleActivity)(agentName, liveTurnId);
1635
+ }, {
1627
1636
  admitOutbound: async () => (!options.lifecycleSignal?.aborted
1628
1637
  && await (0, latest_turn_1.awaitDeliveryAdmission)(options.latestTurnCapability)
1629
1638
  && !options.lifecycleSignal?.aborted),
@@ -1976,6 +1985,18 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, opt
1976
1985
  reason: error instanceof Error ? error.message : String(error),
1977
1986
  },
1978
1987
  });
1988
+ if (spokeBeforeFinalTransport) {
1989
+ // The friend already saw part of this turn; a retry would repeat it.
1990
+ const failed = (0, outbound_state_1.readBlueBubblesOutboundRecordByIdempotencyKey)((0, identity_1.getAgentRoot)(), blueBubblesInboundReplyIdempotencyKey(options.currentIngressEvidence.captureKeyHash));
1991
+ if (failed) {
1992
+ (0, outbound_state_1.markBlueBubblesOutboundPendingManualVerification)({
1993
+ agentRoot: (0, identity_1.getAgentRoot)(),
1994
+ recordId: failed.recordId,
1995
+ decidedAt: new Date().toISOString(),
1996
+ reason: "mid-turn speech was visible before the final send failed",
1997
+ });
1998
+ }
1999
+ }
1979
2000
  return {
1980
2001
  handled: true,
1981
2002
  notifiedAgent: false,
@@ -2406,6 +2427,18 @@ function writeCapturedBlueBubblesHandled(agentName, capture, outcome, detailCode
2406
2427
  function blueBubblesInboundReplyIdempotencyKey(captureKeyHash) {
2407
2428
  return `bluebubbles-inbound-reply:${captureKeyHash}`;
2408
2429
  }
2430
+ async function blueBubblesFailedReplyIsVisible(deps, chat, record) {
2431
+ const since = Date.parse(record.createdAt);
2432
+ const recent = await deps.createClient().listRecentMessages?.({
2433
+ chatGuid: chat.chatGuid,
2434
+ limit: 25,
2435
+ }) ?? [];
2436
+ return recent.some((message) => (message.kind === "message"
2437
+ && message.fromMe
2438
+ && message.timestamp >= since
2439
+ && message.chat.chatGuid === chat.chatGuid
2440
+ && (0, outbound_state_1.blueBubblesOutboundTextMatches)(record, message.text)));
2441
+ }
2409
2442
  function blueBubblesRecoveredOutboundOutcome(record) {
2410
2443
  return BLUEBUBBLES_VISIBLE_OUTBOUND_STATUSES.has(record.status)
2411
2444
  ? "message_completed"
@@ -2520,7 +2553,13 @@ async function handleCapturedBlueBubblesSemanticEvent(captured, resolvedDeps, so
2520
2553
  return result;
2521
2554
  }
2522
2555
  const durableOutbound = (0, outbound_state_1.readBlueBubblesOutboundRecordByIdempotencyKey)((0, identity_1.getAgentRoot)(), blueBubblesInboundReplyIdempotencyKey(captured.capture.keyHash));
2523
- if (durableOutbound) {
2556
+ const retryFailedReply = durableOutbound?.status === "failed"
2557
+ && (durableOutbound.failedAttempts ?? 0) < BLUEBUBBLES_REPLY_DELIVERY_MAX_ATTEMPTS;
2558
+ if (retryFailedReply
2559
+ && Date.now() < Date.parse(durableOutbound.failedAt ?? durableOutbound.updatedAt) + BLUEBUBBLES_REPLY_RETRY_DELAY_MS) {
2560
+ return { handled: true, notifiedAgent: false, kind: captured.normalized.kind, reason: "delivery_failed" };
2561
+ }
2562
+ if (durableOutbound && !retryFailedReply) {
2524
2563
  const outcome = blueBubblesRecoveredOutboundOutcome(durableOutbound);
2525
2564
  (0, runtime_1.emitNervesEvent)({
2526
2565
  level: "warn",
@@ -2627,11 +2666,74 @@ async function handleCapturedBlueBubblesSemanticEvent(captured, resolvedDeps, so
2627
2666
  orientationConversationKind: canonicalEvent.chat.isGroup ? "group" : "one_to_one",
2628
2667
  publishAcceptedReceipt,
2629
2668
  };
2669
+ let failedReplyVisible = false;
2670
+ if (retryFailedReply) {
2671
+ try {
2672
+ failedReplyVisible = await blueBubblesFailedReplyIsVisible(resolvedDeps, canonicalEvent.chat, durableOutbound);
2673
+ }
2674
+ catch (error) {
2675
+ // Without the chat history a retry could reply twice; try again later.
2676
+ (0, runtime_1.emitNervesEvent)({
2677
+ level: "warn",
2678
+ component: "senses",
2679
+ event: "senses.bluebubbles_reply_retry_deferred",
2680
+ message: "deferred a bluebubbles reply retry because recent chat messages could not be read",
2681
+ meta: {
2682
+ messageGuid: captured.normalized.messageGuid,
2683
+ reason: error instanceof Error ? error.message : String(error),
2684
+ },
2685
+ });
2686
+ return { handled: true, notifiedAgent: false, kind: captured.normalized.kind, reason: "delivery_failed" };
2687
+ }
2688
+ }
2689
+ if (failedReplyVisible) {
2690
+ // The send that timed out reached the chat after all; do not reply twice.
2691
+ (0, runtime_1.emitNervesEvent)({
2692
+ level: "warn",
2693
+ component: "senses",
2694
+ event: "senses.bluebubbles_inbound_reply_recovery_suppressed",
2695
+ message: "suppressed bluebubbles reply retry because a reply is already in the chat",
2696
+ meta: { messageGuid: captured.normalized.messageGuid, recordId: durableOutbound.recordId },
2697
+ });
2698
+ publishTerminalReceipt("message_completed", "reply_visible_after_failed_send");
2699
+ semanticHandlingCompleted = true;
2700
+ return { handled: true, notifiedAgent: false, kind: captured.normalized.kind, reason: "already_processed" };
2701
+ }
2630
2702
  result = await handleBlueBubblesNormalizedEvent(canonicalEvent, resolvedDeps, source, capturedOptions);
2631
2703
  // A budget or storm-breaker block defers the message; it was never seen.
2632
2704
  // Leave it pending so a later recovery pass retries it once the block lifts.
2633
2705
  if (result.reason === "autonomy_budget_blocked")
2634
2706
  return result;
2707
+ if (result.reason === "delivery_failed") {
2708
+ // The reply never reached the chat. Leave the message pending so a later
2709
+ // recovery pass sends it, until the per-message attempt cap is reached.
2710
+ let failed = (0, outbound_state_1.readBlueBubblesOutboundRecordByIdempotencyKey)((0, identity_1.getAgentRoot)(), blueBubblesInboundReplyIdempotencyKey(captured.capture.keyHash));
2711
+ if (failed?.status === "failed") {
2712
+ if (retryFailedReply && failed.failedAttempts === durableOutbound.failedAttempts) {
2713
+ // The retry failed before it reached the transport; count it anyway.
2714
+ failed = (0, outbound_state_1.markBlueBubblesOutboundFailed)({
2715
+ agentRoot: (0, identity_1.getAgentRoot)(),
2716
+ recordId: failed.recordId,
2717
+ failedAt: new Date().toISOString(),
2718
+ reason: "retry failed before the final transport",
2719
+ });
2720
+ }
2721
+ /* v8 ignore next -- a failed record re-read after a send always carries its count @preserve */
2722
+ const failedAttempts = failed.failedAttempts ?? 0;
2723
+ if (failedAttempts < BLUEBUBBLES_REPLY_DELIVERY_MAX_ATTEMPTS)
2724
+ return result;
2725
+ (0, runtime_1.emitNervesEvent)({
2726
+ level: "error",
2727
+ component: "senses",
2728
+ event: "senses.bluebubbles_reply_delivery_abandoned",
2729
+ message: "gave up sending a bluebubbles reply after repeated transport failures",
2730
+ meta: { messageGuid: captured.normalized.messageGuid, failedAttempts },
2731
+ });
2732
+ publishTerminalReceipt("message_observed", "reply_delivery_retries_exhausted");
2733
+ semanticHandlingCompleted = true;
2734
+ return result;
2735
+ }
2736
+ }
2635
2737
  handledOutcome = result.notifiedAgent ? "message_completed" : "message_observed";
2636
2738
  }
2637
2739
  if (result.notifiedAgent) {
@@ -37,6 +37,7 @@ exports.BLUEBUBBLES_OUTBOUND_STATE_POLICY_VERSION = void 0;
37
37
  exports.blueBubblesOutboundRecordPath = blueBubblesOutboundRecordPath;
38
38
  exports.buildBlueBubblesRouteProof = buildBlueBubblesRouteProof;
39
39
  exports.buildBlueBubblesAttachmentProof = buildBlueBubblesAttachmentProof;
40
+ exports.blueBubblesOutboundTextMatches = blueBubblesOutboundTextMatches;
40
41
  exports.readBlueBubblesOutboundRecord = readBlueBubblesOutboundRecord;
41
42
  exports.readBlueBubblesOutboundRecordByIdempotencyKey = readBlueBubblesOutboundRecordByIdempotencyKey;
42
43
  exports.reserveBlueBubblesOutbound = reserveBlueBubblesOutbound;
@@ -139,6 +140,10 @@ function buildRecord(input) {
139
140
  contentStored: false,
140
141
  };
141
142
  }
143
+ /** True when `text` is the reply this record last tried to send. */
144
+ function blueBubblesOutboundTextMatches(record, text) {
145
+ return hash(normalizeText(text)) === record.textHash;
146
+ }
142
147
  function writeJsonAtomic(filePath, value) {
143
148
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
144
149
  const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
@@ -240,6 +245,26 @@ function reserveBlueBubblesOutbound(input) {
240
245
  const existing = readBlueBubblesOutboundRecord(input.agentRoot, record.recordId);
241
246
  if (!existing)
242
247
  throw new Error(`bluebubbles outbound reservation is unreadable: ${record.recordId}`);
248
+ if (existing.status === "failed"
249
+ && existing.routeProof.routeHash === record.routeProof.routeHash
250
+ && existing.attachmentProof.identityHash === record.attachmentProof.identityHash) {
251
+ // A failed send to the same chat is retryable, possibly with new text:
252
+ // reserve again, keeping the first attempt's time and the failure history.
253
+ const retry = writeBlueBubblesOutboundRecord(input.agentRoot, {
254
+ ...record,
255
+ createdAt: existing.createdAt,
256
+ ...(existing.failedAttempts ? { failedAttempts: existing.failedAttempts } : {}),
257
+ ...(existing.failedAt ? { failedAt: existing.failedAt } : {}),
258
+ ...(existing.failureReason ? { failureReason: existing.failureReason } : {}),
259
+ });
260
+ (0, runtime_1.emitNervesEvent)({
261
+ component: "senses",
262
+ event: "senses.bluebubbles_outbound_reserved",
263
+ message: "reserved bluebubbles outbound idempotency key again after a failed send",
264
+ meta: { recordId: retry.recordId, textLength: retry.textLength, failedAttempts: retry.failedAttempts ?? 0 },
265
+ });
266
+ return { status: "reserved", record: retry };
267
+ }
243
268
  if (identityMatches(existing, record)) {
244
269
  emitDuplicate(existing);
245
270
  return { status: "duplicate", record: existing };
@@ -295,9 +320,11 @@ function markBlueBubblesOutboundDelivered(input) {
295
320
  });
296
321
  }
297
322
  function markBlueBubblesOutboundFailed(input) {
323
+ const failedAttempts = (readBlueBubblesOutboundRecord(input.agentRoot, input.recordId)?.failedAttempts ?? 0) + 1;
298
324
  return updateStatus(input.agentRoot, input.recordId, "failed", input.failedAt, {
299
325
  failedAt: input.failedAt,
300
326
  failureReason: input.reason.slice(0, 500),
327
+ failedAttempts,
301
328
  });
302
329
  }
303
330
  function markBlueBubblesOutboundPendingManualVerification(input) {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.954",
3
+ "version": "0.1.0-alpha.955",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@ouro.bot/cli",
9
- "version": "0.1.0-alpha.954",
9
+ "version": "0.1.0-alpha.955",
10
10
  "dependencies": {
11
11
  "@anthropic-ai/sdk": "^0.78.0",
12
12
  "@azure/identity": "^4.13.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.954",
3
+ "version": "0.1.0-alpha.955",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },