@ouro.bot/cli 0.1.0-alpha.485 → 0.1.0-alpha.488

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.
@@ -37,6 +37,7 @@ exports.enrichReactionText = enrichReactionText;
37
37
  exports.createStatusBatcher = createStatusBatcher;
38
38
  exports.handleBlueBubblesEvent = handleBlueBubblesEvent;
39
39
  exports.catchUpMissedBlueBubblesMessages = catchUpMissedBlueBubblesMessages;
40
+ exports.recoverCapturedBlueBubblesInboundMessages = recoverCapturedBlueBubblesInboundMessages;
40
41
  exports.recoverMissedBlueBubblesMessages = recoverMissedBlueBubblesMessages;
41
42
  exports.createBlueBubblesWebhookHandler = createBlueBubblesWebhookHandler;
42
43
  exports.sendProactiveBlueBubblesMessageToSession = sendProactiveBlueBubblesMessageToSession;
@@ -66,6 +67,7 @@ const model_1 = require("./model");
66
67
  const client_1 = require("./client");
67
68
  const inbound_log_1 = require("./inbound-log");
68
69
  const mutation_log_1 = require("./mutation-log");
70
+ const processed_log_1 = require("./processed-log");
69
71
  const runtime_state_1 = require("./runtime-state");
70
72
  const session_cleanup_1 = require("./session-cleanup");
71
73
  const tool_activity_callbacks_1 = require("../../heart/tool-activity-callbacks");
@@ -73,6 +75,7 @@ const commands_1 = require("../commands");
73
75
  const trust_gate_1 = require("../trust-gate");
74
76
  const pipeline_1 = require("../pipeline");
75
77
  const bbFailoverStates = new Map();
78
+ const bbInFlightMessageGuids = new Set();
76
79
  // Enrich reaction text with the original message content for context.
77
80
  // If originalText is provided and non-empty, format as: baseText to: "truncated"
78
81
  // Otherwise return baseText unchanged.
@@ -122,6 +125,26 @@ function createStatusBatcher(send, delayMs) {
122
125
  },
123
126
  };
124
127
  }
128
+ function blueBubblesMessageKey(sessionKey, messageGuid) {
129
+ return `${sessionKey}:${messageGuid.trim()}`;
130
+ }
131
+ function isBlueBubblesMessageInFlight(sessionKey, messageGuid) {
132
+ if (!messageGuid.trim())
133
+ return false;
134
+ return bbInFlightMessageGuids.has(blueBubblesMessageKey(sessionKey, messageGuid));
135
+ }
136
+ function beginBlueBubblesMessageInFlight(sessionKey, messageGuid) {
137
+ if (!messageGuid.trim())
138
+ return true;
139
+ const key = blueBubblesMessageKey(sessionKey, messageGuid);
140
+ if (bbInFlightMessageGuids.has(key))
141
+ return false;
142
+ bbInFlightMessageGuids.add(key);
143
+ return true;
144
+ }
145
+ function endBlueBubblesMessageInFlight(sessionKey, messageGuid) {
146
+ bbInFlightMessageGuids.delete(blueBubblesMessageKey(sessionKey, messageGuid));
147
+ }
125
148
  const defaultDeps = {
126
149
  getAgentName: identity_1.getAgentName,
127
150
  buildSystem: prompt_1.buildSystem,
@@ -487,6 +510,15 @@ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat) {
487
510
  },
488
511
  onReasoningChunk(_text) { },
489
512
  onToolStart(name, _args) {
513
+ if (name === "observe") {
514
+ (0, runtime_1.emitNervesEvent)({
515
+ component: "senses",
516
+ event: "senses.bluebubbles_tool_start",
517
+ message: "bluebubbles tool execution started",
518
+ meta: { name },
519
+ });
520
+ return;
521
+ }
490
522
  // Tool activity is a reply commitment — start typing if not already
491
523
  if (!typingActive)
492
524
  startTypingNow();
@@ -499,7 +531,9 @@ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat) {
499
531
  });
500
532
  },
501
533
  onToolEnd(name, summary, success) {
502
- toolCallbacks.onToolEnd(name, summary, success);
534
+ if (name !== "observe") {
535
+ toolCallbacks.onToolEnd(name, summary, success);
536
+ }
503
537
  (0, runtime_1.emitNervesEvent)({
504
538
  component: "senses",
505
539
  event: "senses.bluebubbles_tool_end",
@@ -575,6 +609,7 @@ function isWebhookPasswordValid(url, expectedPassword) {
575
609
  }
576
610
  async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source) {
577
611
  const client = resolvedDeps.createClient();
612
+ const agentName = resolvedDeps.getAgentName();
578
613
  if (event.fromMe) {
579
614
  (0, runtime_1.emitNervesEvent)({
580
615
  component: "senses",
@@ -617,252 +652,284 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source) {
617
652
  });
618
653
  return { handled: true, notifiedAgent: false, kind: event.kind, reason: "mutation_state_only" };
619
654
  }
620
- // ── Adapter setup: friend, session, content, callbacks ──────────
621
- const store = resolvedDeps.createFriendStore();
622
- const resolver = resolvedDeps.createFriendResolver(store, resolveFriendParams(event));
623
- const baseContext = await resolver.resolve();
624
- const context = { ...baseContext, isGroupChat: event.chat.isGroup };
625
- const replyTarget = createReplyTargetController(event);
626
- const friendId = context.friend.id;
627
- const sessPath = resolvedDeps.sessionPath(friendId, "bluebubbles", event.chat.sessionKey);
628
- try {
629
- (0, session_cleanup_1.findObsoleteBlueBubblesThreadSessions)(sessPath);
630
- }
631
- catch (error) {
632
- (0, runtime_1.emitNervesEvent)({
633
- level: "warn",
634
- component: "senses",
635
- event: "senses.bluebubbles_thread_lane_cleanup_error",
636
- message: "failed to inspect obsolete bluebubbles thread-lane sessions",
637
- meta: {
638
- sessionPath: sessPath,
639
- reason: error instanceof Error ? error.message : String(error),
640
- },
641
- });
642
- }
643
- return (0, turn_coordinator_1.withSharedTurnLock)("bluebubbles", sessPath, async () => {
644
- // Pre-load session inside the turn lock so same-chat deliveries cannot race on stale trunk state.
645
- const existing = resolvedDeps.loadSession(sessPath);
646
- const mcpManager = await (0, mcp_manager_1.getSharedMcpManager)() ?? undefined;
647
- const sessionMessages = existing?.messages && existing.messages.length > 0
648
- ? existing.messages
649
- : [{ role: "system", content: (0, prompt_1.flattenSystemPrompt)(await resolvedDeps.buildSystem("bluebubbles", {}, context)) }];
650
- if (event.kind === "message") {
651
- const agentName = resolvedDeps.getAgentName();
652
- if ((0, inbound_log_1.hasRecordedBlueBubblesInbound)(agentName, event.chat.sessionKey, event.messageGuid)) {
653
- (0, runtime_1.emitNervesEvent)({
654
- component: "senses",
655
- event: "senses.bluebubbles_recovery_skip",
656
- message: "skipped bluebubbles message already recorded as handled",
657
- meta: {
658
- messageGuid: event.messageGuid,
659
- sessionKey: event.chat.sessionKey,
660
- source,
661
- },
662
- });
663
- return { handled: true, notifiedAgent: false, kind: event.kind, reason: "already_processed" };
664
- }
665
- // Record EARLY to prevent duplicate processing. BB webhooks can retry
666
- // before the first turn completes — recording after the turn is too late.
667
- const inboundSource = source !== "webhook" && sessionLikelyContainsMessage(event, existing?.messages ?? sessionMessages)
668
- ? "recovery-bootstrap"
669
- : source;
670
- (0, inbound_log_1.recordBlueBubblesInbound)(agentName, event, inboundSource);
671
- if (inboundSource === "recovery-bootstrap") {
672
- (0, runtime_1.emitNervesEvent)({
673
- component: "senses",
674
- event: "senses.bluebubbles_recovery_skip",
675
- message: "skipped bluebubbles recovery because the session already contains the message text",
676
- meta: {
677
- messageGuid: event.messageGuid,
678
- sessionKey: event.chat.sessionKey,
679
- source,
680
- },
681
- });
682
- return { handled: true, notifiedAgent: false, kind: event.kind, reason: "already_processed" };
683
- }
684
- }
685
- if (event.kind === "message" && event.chat.isGroup) {
686
- await (0, group_context_1.upsertGroupContextParticipants)({
687
- store,
688
- participants: (event.chat.participantHandles ?? []).map((externalId) => ({
689
- provider: "imessage-handle",
690
- externalId,
691
- })),
692
- groupExternalId: resolveGroupExternalId(event),
655
+ let ownsInFlightMessage = false;
656
+ if (event.kind === "message") {
657
+ if ((0, processed_log_1.hasProcessedBlueBubblesMessage)(agentName, event.chat.sessionKey, event.messageGuid)) {
658
+ (0, runtime_1.emitNervesEvent)({
659
+ component: "senses",
660
+ event: "senses.bluebubbles_recovery_skip",
661
+ message: "skipped bluebubbles message already marked as handled",
662
+ meta: {
663
+ messageGuid: event.messageGuid,
664
+ sessionKey: event.chat.sessionKey,
665
+ source,
666
+ dedupeReason: "processed",
667
+ },
693
668
  });
669
+ return { handled: true, notifiedAgent: false, kind: event.kind, reason: "already_processed" };
694
670
  }
695
- // Fetch the text of the message being replied to (if this is a threaded reply)
696
- const threadGuid = event.kind === "message" ? event.threadOriginatorGuid?.trim() : undefined;
697
- let repliedToText = null;
698
- if (threadGuid) {
699
- repliedToText = await client.getMessageText(threadGuid).catch(/* v8 ignore next */ () => null);
671
+ if (!beginBlueBubblesMessageInFlight(event.chat.sessionKey, event.messageGuid)) {
700
672
  (0, runtime_1.emitNervesEvent)({
701
673
  component: "senses",
702
- event: "senses.bluebubbles_reply_context",
703
- message: repliedToText ? "fetched replied-to message text" : "could not fetch replied-to message text",
704
- meta: { threadGuid, hasText: !!repliedToText },
674
+ event: "senses.bluebubbles_recovery_skip",
675
+ message: "skipped bluebubbles message already in flight",
676
+ meta: {
677
+ messageGuid: event.messageGuid,
678
+ sessionKey: event.chat.sessionKey,
679
+ source,
680
+ dedupeReason: "in_flight",
681
+ },
705
682
  });
683
+ return { handled: true, notifiedAgent: false, kind: event.kind, reason: "already_processed" };
706
684
  }
707
- // Enrich reaction mutations with the original message text for context
708
- const isReaction = event.kind === "mutation" && event.mutationType === "reaction";
709
- if (isReaction && event.targetMessageGuid) {
710
- /* v8 ignore start -- best-effort lookup; enrichReactionText covered by unit tests @preserve */
711
- const originalText = await client.getMessageText(event.targetMessageGuid).catch(() => null);
712
- if (originalText)
713
- event.textForAgent = enrichReactionText(event.textForAgent, originalText, 80);
714
- /* v8 ignore stop */
715
- }
716
- // Build inbound user message (adapter concern: BB-specific content formatting)
717
- const userMessage = {
718
- role: "user",
719
- content: buildInboundContent(event, existing?.messages ?? sessionMessages, repliedToText),
720
- };
721
- const callbacks = createBlueBubblesCallbacks(client, event.chat, replyTarget, event.chat.isGroup);
722
- const controller = new AbortController();
723
- // BB-specific tool context wrappers
724
- const summarize = (0, core_1.createSummarize)("human");
725
- const bbCapabilities = (0, channel_1.getChannelCapabilities)("bluebubbles");
726
- const pendingDir = (0, pending_1.getPendingDir)(resolvedDeps.getAgentName(), friendId, "bluebubbles", event.chat.sessionKey);
727
- // ── Compute trust gate context for group/acquaintance rules ─────
728
- const groupHasFamilyMember = await checkGroupHasFamilyMember(store, event);
729
- const hasExistingGroupWithFamily = event.chat.isGroup
730
- ? false
731
- : await checkHasExistingGroupWithFamily(store, context.friend);
732
- // ── Call shared pipeline ──────────────────────────────────────────
733
- // Buffer terminal errors so failover can suppress them.
734
- // If failover produces a message, the buffered error is skipped.
735
- // If failover doesn't fire, the buffered error is replayed.
736
- let bufferedTerminalError = null;
737
- /* v8 ignore start -- failover-aware error buffering @preserve */
738
- const failoverAwareCallbacks = {
739
- ...callbacks,
740
- onError(error, severity) {
741
- if (severity === "terminal") {
742
- bufferedTerminalError = error;
743
- return;
744
- }
745
- callbacks.onError(error, severity);
746
- },
747
- };
748
- /* v8 ignore stop */
685
+ ownsInFlightMessage = true;
686
+ }
687
+ try {
688
+ // ── Adapter setup: friend, session, content, callbacks ──────────
689
+ const store = resolvedDeps.createFriendStore();
690
+ const resolver = resolvedDeps.createFriendResolver(store, resolveFriendParams(event));
691
+ const baseContext = await resolver.resolve();
692
+ const context = { ...baseContext, isGroupChat: event.chat.isGroup };
693
+ const replyTarget = createReplyTargetController(event);
694
+ const friendId = context.friend.id;
695
+ const sessPath = resolvedDeps.sessionPath(friendId, "bluebubbles", event.chat.sessionKey);
749
696
  try {
750
- const result = await (0, pipeline_1.handleInboundTurn)({
751
- channel: "bluebubbles",
752
- sessionKey: event.chat.sessionKey,
753
- capabilities: bbCapabilities,
754
- messages: [userMessage],
755
- continuityIngressTexts: getBlueBubblesContinuityIngressTexts(event),
756
- friendResolver: { resolve: () => Promise.resolve(context) },
757
- sessionLoader: {
758
- loadOrCreate: () => Promise.resolve({
759
- messages: sessionMessages,
760
- sessionPath: sessPath,
761
- state: existing?.state,
762
- events: existing?.events,
763
- }),
697
+ (0, session_cleanup_1.findObsoleteBlueBubblesThreadSessions)(sessPath);
698
+ }
699
+ catch (error) {
700
+ (0, runtime_1.emitNervesEvent)({
701
+ level: "warn",
702
+ component: "senses",
703
+ event: "senses.bluebubbles_thread_lane_cleanup_error",
704
+ message: "failed to inspect obsolete bluebubbles thread-lane sessions",
705
+ meta: {
706
+ sessionPath: sessPath,
707
+ reason: error instanceof Error ? error.message : String(error),
764
708
  },
765
- pendingDir,
766
- friendStore: store,
767
- provider: "imessage-handle",
768
- externalId: event.sender.externalId || event.sender.rawId,
769
- isGroupChat: event.chat.isGroup,
770
- groupHasFamilyMember,
771
- hasExistingGroupWithFamily,
772
- enforceTrustGate: trust_gate_1.enforceTrustGate,
773
- drainPending: pending_1.drainPending,
774
- drainDeferredReturns: (deferredFriendId) => (0, pending_1.drainDeferredReturns)(resolvedDeps.getAgentName(), deferredFriendId),
775
- runAgent: (msgs, cb, channel, sig, opts) => resolvedDeps.runAgent(msgs, cb, channel, sig, {
776
- ...opts,
777
- toolContext: {
778
- /* v8 ignore next -- default no-op signin; pipeline provides the real one @preserve */
779
- signin: async () => undefined,
780
- ...opts?.toolContext,
781
- summarize,
782
- bluebubblesReplyTarget: {
783
- setSelection: (selection) => replyTarget.setSelection(selection),
709
+ });
710
+ }
711
+ return await (0, turn_coordinator_1.withSharedTurnLock)("bluebubbles", sessPath, async () => {
712
+ // Pre-load session inside the turn lock so same-chat deliveries cannot race on stale trunk state.
713
+ const existing = resolvedDeps.loadSession(sessPath);
714
+ const mcpManager = await (0, mcp_manager_1.getSharedMcpManager)() ?? undefined;
715
+ const sessionMessages = existing?.messages && existing.messages.length > 0
716
+ ? existing.messages
717
+ : [{ role: "system", content: (0, prompt_1.flattenSystemPrompt)(await resolvedDeps.buildSystem("bluebubbles", {}, context)) }];
718
+ if (event.kind === "message") {
719
+ // Record EARLY for audit and crash recovery. This is capture truth, not
720
+ // a claim that the agent turn completed successfully.
721
+ const inboundSource = source !== "webhook" && sessionLikelyContainsMessage(event, existing?.messages ?? sessionMessages)
722
+ ? "recovery-bootstrap"
723
+ : source;
724
+ (0, inbound_log_1.recordBlueBubblesInbound)(agentName, event, inboundSource);
725
+ if (inboundSource === "recovery-bootstrap") {
726
+ (0, runtime_1.emitNervesEvent)({
727
+ component: "senses",
728
+ event: "senses.bluebubbles_recovery_skip",
729
+ message: "skipped bluebubbles recovery because the session already contains the message text",
730
+ meta: {
731
+ messageGuid: event.messageGuid,
732
+ sessionKey: event.chat.sessionKey,
733
+ source,
784
734
  },
785
- codingFeedback: {
786
- send: async (message) => {
787
- await client.sendText({
788
- chat: event.chat,
789
- text: message,
790
- replyToMessageGuid: replyTarget.getReplyToMessageGuid(),
791
- });
735
+ });
736
+ (0, processed_log_1.recordProcessedBlueBubblesMessage)(agentName, event, inboundSource, "session-bootstrap");
737
+ return { handled: true, notifiedAgent: false, kind: event.kind, reason: "already_processed" };
738
+ }
739
+ }
740
+ if (event.kind === "message" && event.chat.isGroup) {
741
+ await (0, group_context_1.upsertGroupContextParticipants)({
742
+ store,
743
+ participants: (event.chat.participantHandles ?? []).map((externalId) => ({
744
+ provider: "imessage-handle",
745
+ externalId,
746
+ })),
747
+ groupExternalId: resolveGroupExternalId(event),
748
+ });
749
+ }
750
+ // Fetch the text of the message being replied to (if this is a threaded reply)
751
+ const threadGuid = event.kind === "message" ? event.threadOriginatorGuid?.trim() : undefined;
752
+ let repliedToText = null;
753
+ if (threadGuid) {
754
+ repliedToText = await client.getMessageText(threadGuid).catch(/* v8 ignore next */ () => null);
755
+ (0, runtime_1.emitNervesEvent)({
756
+ component: "senses",
757
+ event: "senses.bluebubbles_reply_context",
758
+ message: repliedToText ? "fetched replied-to message text" : "could not fetch replied-to message text",
759
+ meta: { threadGuid, hasText: !!repliedToText },
760
+ });
761
+ }
762
+ // Enrich reaction mutations with the original message text for context
763
+ const isReaction = event.kind === "mutation" && event.mutationType === "reaction";
764
+ if (isReaction && event.targetMessageGuid) {
765
+ /* v8 ignore start -- best-effort lookup; enrichReactionText covered by unit tests @preserve */
766
+ const originalText = await client.getMessageText(event.targetMessageGuid).catch(() => null);
767
+ if (originalText)
768
+ event.textForAgent = enrichReactionText(event.textForAgent, originalText, 80);
769
+ /* v8 ignore stop */
770
+ }
771
+ // Build inbound user message (adapter concern: BB-specific content formatting)
772
+ const userMessage = {
773
+ role: "user",
774
+ content: buildInboundContent(event, existing?.messages ?? sessionMessages, repliedToText),
775
+ };
776
+ const callbacks = createBlueBubblesCallbacks(client, event.chat, replyTarget, event.chat.isGroup);
777
+ const controller = new AbortController();
778
+ // BB-specific tool context wrappers
779
+ const summarize = (0, core_1.createSummarize)("human");
780
+ const bbCapabilities = (0, channel_1.getChannelCapabilities)("bluebubbles");
781
+ const pendingDir = (0, pending_1.getPendingDir)(resolvedDeps.getAgentName(), friendId, "bluebubbles", event.chat.sessionKey);
782
+ // ── Compute trust gate context for group/acquaintance rules ─────
783
+ const groupHasFamilyMember = await checkGroupHasFamilyMember(store, event);
784
+ const hasExistingGroupWithFamily = event.chat.isGroup
785
+ ? false
786
+ : await checkHasExistingGroupWithFamily(store, context.friend);
787
+ // ── Call shared pipeline ──────────────────────────────────────────
788
+ // Buffer terminal errors so failover can suppress them.
789
+ // If failover produces a message, the buffered error is skipped.
790
+ // If failover doesn't fire, the buffered error is replayed.
791
+ let bufferedTerminalError = null;
792
+ /* v8 ignore start -- failover-aware error buffering @preserve */
793
+ const failoverAwareCallbacks = {
794
+ ...callbacks,
795
+ onError(error, severity) {
796
+ if (severity === "terminal") {
797
+ bufferedTerminalError = error;
798
+ return;
799
+ }
800
+ callbacks.onError(error, severity);
801
+ },
802
+ };
803
+ /* v8 ignore stop */
804
+ try {
805
+ const result = await (0, pipeline_1.handleInboundTurn)({
806
+ channel: "bluebubbles",
807
+ sessionKey: event.chat.sessionKey,
808
+ capabilities: bbCapabilities,
809
+ messages: [userMessage],
810
+ continuityIngressTexts: getBlueBubblesContinuityIngressTexts(event),
811
+ friendResolver: { resolve: () => Promise.resolve(context) },
812
+ sessionLoader: {
813
+ loadOrCreate: () => Promise.resolve({
814
+ messages: sessionMessages,
815
+ sessionPath: sessPath,
816
+ state: existing?.state,
817
+ events: existing?.events,
818
+ }),
819
+ },
820
+ pendingDir,
821
+ friendStore: store,
822
+ provider: "imessage-handle",
823
+ externalId: event.sender.externalId || event.sender.rawId,
824
+ isGroupChat: event.chat.isGroup,
825
+ groupHasFamilyMember,
826
+ hasExistingGroupWithFamily,
827
+ enforceTrustGate: trust_gate_1.enforceTrustGate,
828
+ drainPending: pending_1.drainPending,
829
+ drainDeferredReturns: (deferredFriendId) => (0, pending_1.drainDeferredReturns)(resolvedDeps.getAgentName(), deferredFriendId),
830
+ runAgent: (msgs, cb, channel, sig, opts) => resolvedDeps.runAgent(msgs, cb, channel, sig, {
831
+ ...opts,
832
+ toolContext: {
833
+ /* v8 ignore next -- default no-op signin; pipeline provides the real one @preserve */
834
+ signin: async () => undefined,
835
+ ...opts?.toolContext,
836
+ summarize,
837
+ bluebubblesReplyTarget: {
838
+ setSelection: (selection) => replyTarget.setSelection(selection),
839
+ },
840
+ codingFeedback: {
841
+ send: async (message) => {
842
+ await client.sendText({
843
+ chat: event.chat,
844
+ text: message,
845
+ replyToMessageGuid: replyTarget.getReplyToMessageGuid(),
846
+ });
847
+ },
792
848
  },
793
849
  },
850
+ }),
851
+ postTurn: (turnMessages, sessionPathArg, usage, hooks, state) => {
852
+ const prepared = resolvedDeps.postTurnTrim(turnMessages, usage, hooks);
853
+ resolvedDeps.deferPostTurnPersist(sessionPathArg, prepared, usage, state);
794
854
  },
795
- }),
796
- postTurn: (turnMessages, sessionPathArg, usage, hooks, state) => {
797
- const prepared = resolvedDeps.postTurnTrim(turnMessages, usage, hooks);
798
- resolvedDeps.deferPostTurnPersist(sessionPathArg, prepared, usage, state);
799
- },
800
- accumulateFriendTokens: resolvedDeps.accumulateFriendTokens,
801
- signal: controller.signal,
802
- runAgentOptions: { mcpManager, ...(isReaction ? { isReactionSignal: true } : {}) },
803
- callbacks: failoverAwareCallbacks,
804
- failoverState: (() => {
805
- if (!bbFailoverStates.has(event.chat.sessionKey)) {
806
- bbFailoverStates.set(event.chat.sessionKey, { pending: null });
855
+ accumulateFriendTokens: resolvedDeps.accumulateFriendTokens,
856
+ signal: controller.signal,
857
+ runAgentOptions: { mcpManager, ...(isReaction ? { isReactionSignal: true } : {}) },
858
+ callbacks: failoverAwareCallbacks,
859
+ failoverState: (() => {
860
+ if (!bbFailoverStates.has(event.chat.sessionKey)) {
861
+ bbFailoverStates.set(event.chat.sessionKey, { pending: null });
862
+ }
863
+ return bbFailoverStates.get(event.chat.sessionKey);
864
+ })(),
865
+ });
866
+ /* v8 ignore start -- failover display + error replay @preserve */
867
+ if (result.failoverMessage) {
868
+ // Failover handled it — show the failover message, skip the buffered error
869
+ await client.sendText({ chat: event.chat, text: result.failoverMessage });
870
+ }
871
+ else if (bufferedTerminalError) {
872
+ // No failover — replay the buffered terminal error
873
+ callbacks.onError(bufferedTerminalError, "terminal");
874
+ }
875
+ /* v8 ignore stop */
876
+ // ── Handle gate result ────────────────────────────────────────
877
+ if (!result.gateResult.allowed) {
878
+ // Send auto-reply via BB API if the gate provides one
879
+ if ("autoReply" in result.gateResult && result.gateResult.autoReply) {
880
+ await client.sendText({
881
+ chat: event.chat,
882
+ text: result.gateResult.autoReply,
883
+ });
807
884
  }
808
- return bbFailoverStates.get(event.chat.sessionKey);
809
- })(),
810
- });
811
- /* v8 ignore start -- failover display + error replay @preserve */
812
- if (result.failoverMessage) {
813
- // Failover handled it — show the failover message, skip the buffered error
814
- await client.sendText({ chat: event.chat, text: result.failoverMessage });
815
- }
816
- else if (bufferedTerminalError) {
817
- // No failover — replay the buffered terminal error
818
- callbacks.onError(bufferedTerminalError, "terminal");
819
- }
820
- /* v8 ignore stop */
821
- // ── Handle gate result ────────────────────────────────────────
822
- if (!result.gateResult.allowed) {
823
- // Send auto-reply via BB API if the gate provides one
824
- if ("autoReply" in result.gateResult && result.gateResult.autoReply) {
825
- await client.sendText({
826
- chat: event.chat,
827
- text: result.gateResult.autoReply,
828
- });
885
+ if (event.kind === "message") {
886
+ (0, processed_log_1.recordProcessedBlueBubblesMessage)(agentName, event, source, "trust-gated");
887
+ }
888
+ return {
889
+ handled: true,
890
+ notifiedAgent: false,
891
+ kind: event.kind,
892
+ };
829
893
  }
894
+ // Gate allowed — flush the agent's reply
895
+ await callbacks.flush();
896
+ if (event.kind === "message") {
897
+ (0, processed_log_1.recordProcessedBlueBubblesMessage)(agentName, event, source, "turn-complete");
898
+ }
899
+ (0, runtime_1.emitNervesEvent)({
900
+ component: "senses",
901
+ event: "senses.bluebubbles_turn_end",
902
+ message: "bluebubbles event handled",
903
+ meta: {
904
+ messageGuid: event.messageGuid,
905
+ kind: event.kind,
906
+ sessionKey: event.chat.sessionKey,
907
+ },
908
+ });
830
909
  return {
831
910
  handled: true,
832
- notifiedAgent: false,
911
+ notifiedAgent: true,
833
912
  kind: event.kind,
834
913
  };
835
914
  }
836
- // Gate allowed — flush the agent's reply
837
- await callbacks.flush();
838
- (0, runtime_1.emitNervesEvent)({
839
- component: "senses",
840
- event: "senses.bluebubbles_turn_end",
841
- message: "bluebubbles event handled",
842
- meta: {
843
- messageGuid: event.messageGuid,
844
- kind: event.kind,
845
- sessionKey: event.chat.sessionKey,
846
- },
847
- });
848
- return {
849
- handled: true,
850
- notifiedAgent: true,
851
- kind: event.kind,
852
- };
853
- }
854
- finally {
855
- // If a terminal error was buffered and never replayed (e.g., handleInboundTurn threw),
856
- // replay it now so the user still sees the error.
857
- /* v8 ignore start -- error replay on throw: tested via BB error test @preserve */
858
- if (bufferedTerminalError) {
859
- callbacks.onError(bufferedTerminalError, "terminal");
860
- bufferedTerminalError = null;
915
+ finally {
916
+ // If a terminal error was buffered and never replayed (e.g., handleInboundTurn threw),
917
+ // replay it now so the user still sees the error.
918
+ /* v8 ignore start -- error replay on throw: tested via BB error test @preserve */
919
+ if (bufferedTerminalError) {
920
+ callbacks.onError(bufferedTerminalError, "terminal");
921
+ bufferedTerminalError = null;
922
+ }
923
+ /* v8 ignore stop */
924
+ await callbacks.finish();
861
925
  }
862
- /* v8 ignore stop */
863
- await callbacks.finish();
926
+ });
927
+ }
928
+ finally {
929
+ if (ownsInFlightMessage && event.kind === "message") {
930
+ endBlueBubblesMessageInFlight(event.chat.sessionKey, event.messageGuid);
864
931
  }
865
- });
932
+ }
866
933
  }
867
934
  async function handleBlueBubblesEvent(payload, deps = {}) {
868
935
  const resolvedDeps = { ...defaultDeps, ...deps };
@@ -908,8 +975,15 @@ async function handleBlueBubblesEvent(payload, deps = {}) {
908
975
  // the downstream `already_processed` path fires its observability events
909
976
  // and the caller sees a consistent return shape.
910
977
  const agentName = resolvedDeps.getAgentName();
978
+ // normalizeBlueBubblesEvent rejects guidless payloads, so duplicate handling
979
+ // only needs to discriminate between known processed, in-flight, or new.
980
+ const duplicateReason = (0, processed_log_1.hasProcessedBlueBubblesMessage)(agentName, normalized.chat.sessionKey, normalized.messageGuid)
981
+ ? "processed"
982
+ : isBlueBubblesMessageInFlight(normalized.chat.sessionKey, normalized.messageGuid)
983
+ ? "in_flight"
984
+ : null;
911
985
  if (normalized.messageGuid
912
- && (0, inbound_log_1.hasRecordedBlueBubblesInbound)(agentName, normalized.chat.sessionKey, normalized.messageGuid)) {
986
+ && duplicateReason) {
913
987
  (0, runtime_1.emitNervesEvent)({
914
988
  level: "warn",
915
989
  component: "senses",
@@ -920,6 +994,7 @@ async function handleBlueBubblesEvent(payload, deps = {}) {
920
994
  sessionKey: normalized.chat.sessionKey,
921
995
  eventType: normalized.eventType,
922
996
  normalizedKind: normalized.kind,
997
+ dedupeReason: duplicateReason,
923
998
  },
924
999
  });
925
1000
  return handleBlueBubblesNormalizedEvent(normalized, resolvedDeps, "webhook");
@@ -929,7 +1004,7 @@ async function handleBlueBubblesEvent(payload, deps = {}) {
929
1004
  }
930
1005
  function countPendingRecoveryCandidates(agentName) {
931
1006
  return (0, mutation_log_1.listBlueBubblesRecoveryCandidates)(agentName)
932
- .filter((entry) => !(0, inbound_log_1.hasRecordedBlueBubblesInbound)(agentName, entry.sessionKey, entry.messageGuid))
1007
+ .filter((entry) => !(0, processed_log_1.hasProcessedBlueBubblesMessage)(agentName, entry.sessionKey, entry.messageGuid))
933
1008
  .length;
934
1009
  }
935
1010
  function parseTimestampMs(value) {
@@ -959,16 +1034,23 @@ async function syncBlueBubblesRuntime(deps = {}) {
959
1034
  const previousState = (0, runtime_state_1.readBlueBubblesRuntimeState)(agentName);
960
1035
  try {
961
1036
  await client.checkHealth();
1037
+ const captured = await recoverCapturedBlueBubblesInboundMessages(resolvedDeps);
962
1038
  const recovery = await recoverMissedBlueBubblesMessages(resolvedDeps);
963
1039
  const catchUp = await catchUpMissedBlueBubblesMessages(resolvedDeps, previousState);
964
- const failed = recovery.failed + catchUp.failed;
965
- const recovered = recovery.recovered + catchUp.recovered;
1040
+ const failed = captured.failed + recovery.failed + catchUp.failed;
1041
+ const recovered = captured.recovered + recovery.recovered + catchUp.recovered;
1042
+ // upstreamStatus reflects whether BlueBubbles itself is healthy and we
1043
+ // have unprocessed work (pendingRecoveryCount). Per-cycle recovery
1044
+ // failures are noted in `detail` for transparency but do NOT flip the
1045
+ // status to error: a single permanently-unrecoverable message would
1046
+ // otherwise stick the sense in "error" forever, contradicting `ouro
1047
+ // doctor` which only checks upstream reachability.
966
1048
  (0, runtime_state_1.writeBlueBubblesRuntimeState)(agentName, {
967
- upstreamStatus: recovery.pending > 0 || failed > 0 ? "error" : "ok",
968
- detail: failed > 0
969
- ? `recovery failures: ${failed}`
970
- : recovery.pending > 0
971
- ? `pending recovery: ${recovery.pending}`
1049
+ upstreamStatus: recovery.pending > 0 ? "error" : "ok",
1050
+ detail: recovery.pending > 0
1051
+ ? `pending recovery: ${recovery.pending}`
1052
+ : failed > 0
1053
+ ? `${failed} message(s) unrecoverable this cycle; upstream ok`
972
1054
  : catchUp.recovered > 0
973
1055
  ? formatRecoveredCount(catchUp.recovered)
974
1056
  : "upstream reachable",
@@ -1066,7 +1148,8 @@ async function catchUpMissedBlueBubblesMessages(deps = {}, previousState) {
1066
1148
  result.inspected++;
1067
1149
  if (event.fromMe
1068
1150
  || event.timestamp < catchUpSince
1069
- || (0, inbound_log_1.hasRecordedBlueBubblesInbound)(agentName, event.chat.sessionKey, event.messageGuid)) {
1151
+ || (0, processed_log_1.hasProcessedBlueBubblesMessage)(agentName, event.chat.sessionKey, event.messageGuid)
1152
+ || isBlueBubblesMessageInFlight(event.chat.sessionKey, event.messageGuid)) {
1070
1153
  result.skipped++;
1071
1154
  continue;
1072
1155
  }
@@ -1109,13 +1192,99 @@ async function catchUpMissedBlueBubblesMessages(deps = {}, previousState) {
1109
1192
  }
1110
1193
  return result;
1111
1194
  }
1195
+ function inboundEntryToRecoveryEvent(entry) {
1196
+ const chatIdentifier = entry.chatIdentifier ?? extractChatIdentifierFromSessionKey(entry.sessionKey) ?? "unknown";
1197
+ const normalizedSessionKey = normalizeBlueBubblesSessionKey(entry.sessionKey);
1198
+ const chatGuid = entry.chatGuid ?? (normalizedSessionKey.startsWith("chat:") ? normalizedSessionKey.slice("chat:".length) : undefined);
1199
+ const isGroup = normalizedSessionKey.includes("+;");
1200
+ return {
1201
+ kind: "message",
1202
+ eventType: "new-message",
1203
+ messageGuid: entry.messageGuid,
1204
+ timestamp: parseTimestampMs(entry.recordedAt) ?? Date.now(),
1205
+ fromMe: false,
1206
+ sender: {
1207
+ provider: "imessage-handle",
1208
+ externalId: chatIdentifier,
1209
+ rawId: chatIdentifier,
1210
+ displayName: chatIdentifier,
1211
+ },
1212
+ chat: {
1213
+ chatGuid,
1214
+ chatIdentifier,
1215
+ isGroup,
1216
+ sessionKey: normalizedSessionKey,
1217
+ sendTarget: chatGuid
1218
+ ? { kind: "chat_guid", value: chatGuid }
1219
+ : { kind: "chat_identifier", value: chatIdentifier },
1220
+ participantHandles: [],
1221
+ },
1222
+ text: entry.textForAgent,
1223
+ textForAgent: entry.textForAgent,
1224
+ attachments: [],
1225
+ hasPayloadData: false,
1226
+ requiresRepair: true,
1227
+ };
1228
+ }
1229
+ async function recoverCapturedBlueBubblesInboundMessages(deps = {}) {
1230
+ const resolvedDeps = { ...defaultDeps, ...deps };
1231
+ const agentName = resolvedDeps.getAgentName();
1232
+ const client = resolvedDeps.createClient();
1233
+ const result = { recovered: 0, skipped: 0, failed: 0 };
1234
+ const seenMessageGuids = new Set();
1235
+ const candidates = (0, inbound_log_1.listRecordedBlueBubblesInbound)(agentName)
1236
+ .filter((entry) => {
1237
+ if (seenMessageGuids.has(entry.messageGuid))
1238
+ return false;
1239
+ seenMessageGuids.add(entry.messageGuid);
1240
+ return true;
1241
+ })
1242
+ .sort((left, right) => (parseTimestampMs(left.recordedAt) ?? 0) - (parseTimestampMs(right.recordedAt) ?? 0));
1243
+ for (const entry of candidates) {
1244
+ if ((0, processed_log_1.hasProcessedBlueBubblesMessage)(agentName, entry.sessionKey, entry.messageGuid)
1245
+ || isBlueBubblesMessageInFlight(entry.sessionKey, entry.messageGuid)) {
1246
+ result.skipped++;
1247
+ continue;
1248
+ }
1249
+ try {
1250
+ const repaired = await client.repairEvent(inboundEntryToRecoveryEvent(entry));
1251
+ if (repaired.kind !== "message") {
1252
+ result.skipped++;
1253
+ continue;
1254
+ }
1255
+ const handled = await handleBlueBubblesNormalizedEvent(repaired, resolvedDeps, entry.source);
1256
+ if (handled.reason === "already_processed") {
1257
+ result.skipped++;
1258
+ }
1259
+ else {
1260
+ result.recovered++;
1261
+ }
1262
+ }
1263
+ catch (error) {
1264
+ result.failed++;
1265
+ (0, runtime_1.emitNervesEvent)({
1266
+ level: "warn",
1267
+ component: "senses",
1268
+ event: "senses.bluebubbles_capture_recovery_error",
1269
+ message: "captured bluebubbles message recovery failed",
1270
+ meta: {
1271
+ messageGuid: entry.messageGuid,
1272
+ sessionKey: entry.sessionKey,
1273
+ reason: error instanceof Error ? error.message : String(error),
1274
+ },
1275
+ });
1276
+ }
1277
+ }
1278
+ return result;
1279
+ }
1112
1280
  async function recoverMissedBlueBubblesMessages(deps = {}) {
1113
1281
  const resolvedDeps = { ...defaultDeps, ...deps };
1114
1282
  const agentName = resolvedDeps.getAgentName();
1115
1283
  const client = resolvedDeps.createClient();
1116
1284
  const result = { recovered: 0, skipped: 0, pending: 0, failed: 0 };
1117
1285
  for (const candidate of (0, mutation_log_1.listBlueBubblesRecoveryCandidates)(agentName)) {
1118
- if ((0, inbound_log_1.hasRecordedBlueBubblesInbound)(agentName, candidate.sessionKey, candidate.messageGuid)) {
1286
+ if ((0, processed_log_1.hasProcessedBlueBubblesMessage)(agentName, candidate.sessionKey, candidate.messageGuid)
1287
+ || isBlueBubblesMessageInFlight(candidate.sessionKey, candidate.messageGuid)) {
1119
1288
  result.skipped++;
1120
1289
  continue;
1121
1290
  }