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

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,
@@ -575,6 +598,7 @@ function isWebhookPasswordValid(url, expectedPassword) {
575
598
  }
576
599
  async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source) {
577
600
  const client = resolvedDeps.createClient();
601
+ const agentName = resolvedDeps.getAgentName();
578
602
  if (event.fromMe) {
579
603
  (0, runtime_1.emitNervesEvent)({
580
604
  component: "senses",
@@ -617,252 +641,284 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source) {
617
641
  });
618
642
  return { handled: true, notifiedAgent: false, kind: event.kind, reason: "mutation_state_only" };
619
643
  }
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),
644
+ let ownsInFlightMessage = false;
645
+ if (event.kind === "message") {
646
+ if ((0, processed_log_1.hasProcessedBlueBubblesMessage)(agentName, event.chat.sessionKey, event.messageGuid)) {
647
+ (0, runtime_1.emitNervesEvent)({
648
+ component: "senses",
649
+ event: "senses.bluebubbles_recovery_skip",
650
+ message: "skipped bluebubbles message already marked as handled",
651
+ meta: {
652
+ messageGuid: event.messageGuid,
653
+ sessionKey: event.chat.sessionKey,
654
+ source,
655
+ dedupeReason: "processed",
656
+ },
693
657
  });
658
+ return { handled: true, notifiedAgent: false, kind: event.kind, reason: "already_processed" };
694
659
  }
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);
660
+ if (!beginBlueBubblesMessageInFlight(event.chat.sessionKey, event.messageGuid)) {
700
661
  (0, runtime_1.emitNervesEvent)({
701
662
  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 },
663
+ event: "senses.bluebubbles_recovery_skip",
664
+ message: "skipped bluebubbles message already in flight",
665
+ meta: {
666
+ messageGuid: event.messageGuid,
667
+ sessionKey: event.chat.sessionKey,
668
+ source,
669
+ dedupeReason: "in_flight",
670
+ },
705
671
  });
672
+ return { handled: true, notifiedAgent: false, kind: event.kind, reason: "already_processed" };
706
673
  }
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 */
674
+ ownsInFlightMessage = true;
675
+ }
676
+ try {
677
+ // ── Adapter setup: friend, session, content, callbacks ──────────
678
+ const store = resolvedDeps.createFriendStore();
679
+ const resolver = resolvedDeps.createFriendResolver(store, resolveFriendParams(event));
680
+ const baseContext = await resolver.resolve();
681
+ const context = { ...baseContext, isGroupChat: event.chat.isGroup };
682
+ const replyTarget = createReplyTargetController(event);
683
+ const friendId = context.friend.id;
684
+ const sessPath = resolvedDeps.sessionPath(friendId, "bluebubbles", event.chat.sessionKey);
749
685
  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
- }),
686
+ (0, session_cleanup_1.findObsoleteBlueBubblesThreadSessions)(sessPath);
687
+ }
688
+ catch (error) {
689
+ (0, runtime_1.emitNervesEvent)({
690
+ level: "warn",
691
+ component: "senses",
692
+ event: "senses.bluebubbles_thread_lane_cleanup_error",
693
+ message: "failed to inspect obsolete bluebubbles thread-lane sessions",
694
+ meta: {
695
+ sessionPath: sessPath,
696
+ reason: error instanceof Error ? error.message : String(error),
764
697
  },
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),
698
+ });
699
+ }
700
+ return await (0, turn_coordinator_1.withSharedTurnLock)("bluebubbles", sessPath, async () => {
701
+ // Pre-load session inside the turn lock so same-chat deliveries cannot race on stale trunk state.
702
+ const existing = resolvedDeps.loadSession(sessPath);
703
+ const mcpManager = await (0, mcp_manager_1.getSharedMcpManager)() ?? undefined;
704
+ const sessionMessages = existing?.messages && existing.messages.length > 0
705
+ ? existing.messages
706
+ : [{ role: "system", content: (0, prompt_1.flattenSystemPrompt)(await resolvedDeps.buildSystem("bluebubbles", {}, context)) }];
707
+ if (event.kind === "message") {
708
+ // Record EARLY for audit and crash recovery. This is capture truth, not
709
+ // a claim that the agent turn completed successfully.
710
+ const inboundSource = source !== "webhook" && sessionLikelyContainsMessage(event, existing?.messages ?? sessionMessages)
711
+ ? "recovery-bootstrap"
712
+ : source;
713
+ (0, inbound_log_1.recordBlueBubblesInbound)(agentName, event, inboundSource);
714
+ if (inboundSource === "recovery-bootstrap") {
715
+ (0, runtime_1.emitNervesEvent)({
716
+ component: "senses",
717
+ event: "senses.bluebubbles_recovery_skip",
718
+ message: "skipped bluebubbles recovery because the session already contains the message text",
719
+ meta: {
720
+ messageGuid: event.messageGuid,
721
+ sessionKey: event.chat.sessionKey,
722
+ source,
784
723
  },
785
- codingFeedback: {
786
- send: async (message) => {
787
- await client.sendText({
788
- chat: event.chat,
789
- text: message,
790
- replyToMessageGuid: replyTarget.getReplyToMessageGuid(),
791
- });
724
+ });
725
+ (0, processed_log_1.recordProcessedBlueBubblesMessage)(agentName, event, inboundSource, "session-bootstrap");
726
+ return { handled: true, notifiedAgent: false, kind: event.kind, reason: "already_processed" };
727
+ }
728
+ }
729
+ if (event.kind === "message" && event.chat.isGroup) {
730
+ await (0, group_context_1.upsertGroupContextParticipants)({
731
+ store,
732
+ participants: (event.chat.participantHandles ?? []).map((externalId) => ({
733
+ provider: "imessage-handle",
734
+ externalId,
735
+ })),
736
+ groupExternalId: resolveGroupExternalId(event),
737
+ });
738
+ }
739
+ // Fetch the text of the message being replied to (if this is a threaded reply)
740
+ const threadGuid = event.kind === "message" ? event.threadOriginatorGuid?.trim() : undefined;
741
+ let repliedToText = null;
742
+ if (threadGuid) {
743
+ repliedToText = await client.getMessageText(threadGuid).catch(/* v8 ignore next */ () => null);
744
+ (0, runtime_1.emitNervesEvent)({
745
+ component: "senses",
746
+ event: "senses.bluebubbles_reply_context",
747
+ message: repliedToText ? "fetched replied-to message text" : "could not fetch replied-to message text",
748
+ meta: { threadGuid, hasText: !!repliedToText },
749
+ });
750
+ }
751
+ // Enrich reaction mutations with the original message text for context
752
+ const isReaction = event.kind === "mutation" && event.mutationType === "reaction";
753
+ if (isReaction && event.targetMessageGuid) {
754
+ /* v8 ignore start -- best-effort lookup; enrichReactionText covered by unit tests @preserve */
755
+ const originalText = await client.getMessageText(event.targetMessageGuid).catch(() => null);
756
+ if (originalText)
757
+ event.textForAgent = enrichReactionText(event.textForAgent, originalText, 80);
758
+ /* v8 ignore stop */
759
+ }
760
+ // Build inbound user message (adapter concern: BB-specific content formatting)
761
+ const userMessage = {
762
+ role: "user",
763
+ content: buildInboundContent(event, existing?.messages ?? sessionMessages, repliedToText),
764
+ };
765
+ const callbacks = createBlueBubblesCallbacks(client, event.chat, replyTarget, event.chat.isGroup);
766
+ const controller = new AbortController();
767
+ // BB-specific tool context wrappers
768
+ const summarize = (0, core_1.createSummarize)("human");
769
+ const bbCapabilities = (0, channel_1.getChannelCapabilities)("bluebubbles");
770
+ const pendingDir = (0, pending_1.getPendingDir)(resolvedDeps.getAgentName(), friendId, "bluebubbles", event.chat.sessionKey);
771
+ // ── Compute trust gate context for group/acquaintance rules ─────
772
+ const groupHasFamilyMember = await checkGroupHasFamilyMember(store, event);
773
+ const hasExistingGroupWithFamily = event.chat.isGroup
774
+ ? false
775
+ : await checkHasExistingGroupWithFamily(store, context.friend);
776
+ // ── Call shared pipeline ──────────────────────────────────────────
777
+ // Buffer terminal errors so failover can suppress them.
778
+ // If failover produces a message, the buffered error is skipped.
779
+ // If failover doesn't fire, the buffered error is replayed.
780
+ let bufferedTerminalError = null;
781
+ /* v8 ignore start -- failover-aware error buffering @preserve */
782
+ const failoverAwareCallbacks = {
783
+ ...callbacks,
784
+ onError(error, severity) {
785
+ if (severity === "terminal") {
786
+ bufferedTerminalError = error;
787
+ return;
788
+ }
789
+ callbacks.onError(error, severity);
790
+ },
791
+ };
792
+ /* v8 ignore stop */
793
+ try {
794
+ const result = await (0, pipeline_1.handleInboundTurn)({
795
+ channel: "bluebubbles",
796
+ sessionKey: event.chat.sessionKey,
797
+ capabilities: bbCapabilities,
798
+ messages: [userMessage],
799
+ continuityIngressTexts: getBlueBubblesContinuityIngressTexts(event),
800
+ friendResolver: { resolve: () => Promise.resolve(context) },
801
+ sessionLoader: {
802
+ loadOrCreate: () => Promise.resolve({
803
+ messages: sessionMessages,
804
+ sessionPath: sessPath,
805
+ state: existing?.state,
806
+ events: existing?.events,
807
+ }),
808
+ },
809
+ pendingDir,
810
+ friendStore: store,
811
+ provider: "imessage-handle",
812
+ externalId: event.sender.externalId || event.sender.rawId,
813
+ isGroupChat: event.chat.isGroup,
814
+ groupHasFamilyMember,
815
+ hasExistingGroupWithFamily,
816
+ enforceTrustGate: trust_gate_1.enforceTrustGate,
817
+ drainPending: pending_1.drainPending,
818
+ drainDeferredReturns: (deferredFriendId) => (0, pending_1.drainDeferredReturns)(resolvedDeps.getAgentName(), deferredFriendId),
819
+ runAgent: (msgs, cb, channel, sig, opts) => resolvedDeps.runAgent(msgs, cb, channel, sig, {
820
+ ...opts,
821
+ toolContext: {
822
+ /* v8 ignore next -- default no-op signin; pipeline provides the real one @preserve */
823
+ signin: async () => undefined,
824
+ ...opts?.toolContext,
825
+ summarize,
826
+ bluebubblesReplyTarget: {
827
+ setSelection: (selection) => replyTarget.setSelection(selection),
828
+ },
829
+ codingFeedback: {
830
+ send: async (message) => {
831
+ await client.sendText({
832
+ chat: event.chat,
833
+ text: message,
834
+ replyToMessageGuid: replyTarget.getReplyToMessageGuid(),
835
+ });
836
+ },
792
837
  },
793
838
  },
839
+ }),
840
+ postTurn: (turnMessages, sessionPathArg, usage, hooks, state) => {
841
+ const prepared = resolvedDeps.postTurnTrim(turnMessages, usage, hooks);
842
+ resolvedDeps.deferPostTurnPersist(sessionPathArg, prepared, usage, state);
794
843
  },
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 });
844
+ accumulateFriendTokens: resolvedDeps.accumulateFriendTokens,
845
+ signal: controller.signal,
846
+ runAgentOptions: { mcpManager, ...(isReaction ? { isReactionSignal: true } : {}) },
847
+ callbacks: failoverAwareCallbacks,
848
+ failoverState: (() => {
849
+ if (!bbFailoverStates.has(event.chat.sessionKey)) {
850
+ bbFailoverStates.set(event.chat.sessionKey, { pending: null });
851
+ }
852
+ return bbFailoverStates.get(event.chat.sessionKey);
853
+ })(),
854
+ });
855
+ /* v8 ignore start -- failover display + error replay @preserve */
856
+ if (result.failoverMessage) {
857
+ // Failover handled it — show the failover message, skip the buffered error
858
+ await client.sendText({ chat: event.chat, text: result.failoverMessage });
859
+ }
860
+ else if (bufferedTerminalError) {
861
+ // No failover — replay the buffered terminal error
862
+ callbacks.onError(bufferedTerminalError, "terminal");
863
+ }
864
+ /* v8 ignore stop */
865
+ // ── Handle gate result ────────────────────────────────────────
866
+ if (!result.gateResult.allowed) {
867
+ // Send auto-reply via BB API if the gate provides one
868
+ if ("autoReply" in result.gateResult && result.gateResult.autoReply) {
869
+ await client.sendText({
870
+ chat: event.chat,
871
+ text: result.gateResult.autoReply,
872
+ });
807
873
  }
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 failoverreplay 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
- });
874
+ if (event.kind === "message") {
875
+ (0, processed_log_1.recordProcessedBlueBubblesMessage)(agentName, event, source, "trust-gated");
876
+ }
877
+ return {
878
+ handled: true,
879
+ notifiedAgent: false,
880
+ kind: event.kind,
881
+ };
882
+ }
883
+ // Gate allowedflush the agent's reply
884
+ await callbacks.flush();
885
+ if (event.kind === "message") {
886
+ (0, processed_log_1.recordProcessedBlueBubblesMessage)(agentName, event, source, "turn-complete");
829
887
  }
888
+ (0, runtime_1.emitNervesEvent)({
889
+ component: "senses",
890
+ event: "senses.bluebubbles_turn_end",
891
+ message: "bluebubbles event handled",
892
+ meta: {
893
+ messageGuid: event.messageGuid,
894
+ kind: event.kind,
895
+ sessionKey: event.chat.sessionKey,
896
+ },
897
+ });
830
898
  return {
831
899
  handled: true,
832
- notifiedAgent: false,
900
+ notifiedAgent: true,
833
901
  kind: event.kind,
834
902
  };
835
903
  }
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;
904
+ finally {
905
+ // If a terminal error was buffered and never replayed (e.g., handleInboundTurn threw),
906
+ // replay it now so the user still sees the error.
907
+ /* v8 ignore start -- error replay on throw: tested via BB error test @preserve */
908
+ if (bufferedTerminalError) {
909
+ callbacks.onError(bufferedTerminalError, "terminal");
910
+ bufferedTerminalError = null;
911
+ }
912
+ /* v8 ignore stop */
913
+ await callbacks.finish();
861
914
  }
862
- /* v8 ignore stop */
863
- await callbacks.finish();
915
+ });
916
+ }
917
+ finally {
918
+ if (ownsInFlightMessage && event.kind === "message") {
919
+ endBlueBubblesMessageInFlight(event.chat.sessionKey, event.messageGuid);
864
920
  }
865
- });
921
+ }
866
922
  }
867
923
  async function handleBlueBubblesEvent(payload, deps = {}) {
868
924
  const resolvedDeps = { ...defaultDeps, ...deps };
@@ -908,8 +964,15 @@ async function handleBlueBubblesEvent(payload, deps = {}) {
908
964
  // the downstream `already_processed` path fires its observability events
909
965
  // and the caller sees a consistent return shape.
910
966
  const agentName = resolvedDeps.getAgentName();
967
+ // normalizeBlueBubblesEvent rejects guidless payloads, so duplicate handling
968
+ // only needs to discriminate between known processed, in-flight, or new.
969
+ const duplicateReason = (0, processed_log_1.hasProcessedBlueBubblesMessage)(agentName, normalized.chat.sessionKey, normalized.messageGuid)
970
+ ? "processed"
971
+ : isBlueBubblesMessageInFlight(normalized.chat.sessionKey, normalized.messageGuid)
972
+ ? "in_flight"
973
+ : null;
911
974
  if (normalized.messageGuid
912
- && (0, inbound_log_1.hasRecordedBlueBubblesInbound)(agentName, normalized.chat.sessionKey, normalized.messageGuid)) {
975
+ && duplicateReason) {
913
976
  (0, runtime_1.emitNervesEvent)({
914
977
  level: "warn",
915
978
  component: "senses",
@@ -920,6 +983,7 @@ async function handleBlueBubblesEvent(payload, deps = {}) {
920
983
  sessionKey: normalized.chat.sessionKey,
921
984
  eventType: normalized.eventType,
922
985
  normalizedKind: normalized.kind,
986
+ dedupeReason: duplicateReason,
923
987
  },
924
988
  });
925
989
  return handleBlueBubblesNormalizedEvent(normalized, resolvedDeps, "webhook");
@@ -929,7 +993,7 @@ async function handleBlueBubblesEvent(payload, deps = {}) {
929
993
  }
930
994
  function countPendingRecoveryCandidates(agentName) {
931
995
  return (0, mutation_log_1.listBlueBubblesRecoveryCandidates)(agentName)
932
- .filter((entry) => !(0, inbound_log_1.hasRecordedBlueBubblesInbound)(agentName, entry.sessionKey, entry.messageGuid))
996
+ .filter((entry) => !(0, processed_log_1.hasProcessedBlueBubblesMessage)(agentName, entry.sessionKey, entry.messageGuid))
933
997
  .length;
934
998
  }
935
999
  function parseTimestampMs(value) {
@@ -959,16 +1023,23 @@ async function syncBlueBubblesRuntime(deps = {}) {
959
1023
  const previousState = (0, runtime_state_1.readBlueBubblesRuntimeState)(agentName);
960
1024
  try {
961
1025
  await client.checkHealth();
1026
+ const captured = await recoverCapturedBlueBubblesInboundMessages(resolvedDeps);
962
1027
  const recovery = await recoverMissedBlueBubblesMessages(resolvedDeps);
963
1028
  const catchUp = await catchUpMissedBlueBubblesMessages(resolvedDeps, previousState);
964
- const failed = recovery.failed + catchUp.failed;
965
- const recovered = recovery.recovered + catchUp.recovered;
1029
+ const failed = captured.failed + recovery.failed + catchUp.failed;
1030
+ const recovered = captured.recovered + recovery.recovered + catchUp.recovered;
1031
+ // upstreamStatus reflects whether BlueBubbles itself is healthy and we
1032
+ // have unprocessed work (pendingRecoveryCount). Per-cycle recovery
1033
+ // failures are noted in `detail` for transparency but do NOT flip the
1034
+ // status to error: a single permanently-unrecoverable message would
1035
+ // otherwise stick the sense in "error" forever, contradicting `ouro
1036
+ // doctor` which only checks upstream reachability.
966
1037
  (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}`
1038
+ upstreamStatus: recovery.pending > 0 ? "error" : "ok",
1039
+ detail: recovery.pending > 0
1040
+ ? `pending recovery: ${recovery.pending}`
1041
+ : failed > 0
1042
+ ? `${failed} message(s) unrecoverable this cycle; upstream ok`
972
1043
  : catchUp.recovered > 0
973
1044
  ? formatRecoveredCount(catchUp.recovered)
974
1045
  : "upstream reachable",
@@ -1066,7 +1137,8 @@ async function catchUpMissedBlueBubblesMessages(deps = {}, previousState) {
1066
1137
  result.inspected++;
1067
1138
  if (event.fromMe
1068
1139
  || event.timestamp < catchUpSince
1069
- || (0, inbound_log_1.hasRecordedBlueBubblesInbound)(agentName, event.chat.sessionKey, event.messageGuid)) {
1140
+ || (0, processed_log_1.hasProcessedBlueBubblesMessage)(agentName, event.chat.sessionKey, event.messageGuid)
1141
+ || isBlueBubblesMessageInFlight(event.chat.sessionKey, event.messageGuid)) {
1070
1142
  result.skipped++;
1071
1143
  continue;
1072
1144
  }
@@ -1109,13 +1181,99 @@ async function catchUpMissedBlueBubblesMessages(deps = {}, previousState) {
1109
1181
  }
1110
1182
  return result;
1111
1183
  }
1184
+ function inboundEntryToRecoveryEvent(entry) {
1185
+ const chatIdentifier = entry.chatIdentifier ?? extractChatIdentifierFromSessionKey(entry.sessionKey) ?? "unknown";
1186
+ const normalizedSessionKey = normalizeBlueBubblesSessionKey(entry.sessionKey);
1187
+ const chatGuid = entry.chatGuid ?? (normalizedSessionKey.startsWith("chat:") ? normalizedSessionKey.slice("chat:".length) : undefined);
1188
+ const isGroup = normalizedSessionKey.includes("+;");
1189
+ return {
1190
+ kind: "message",
1191
+ eventType: "new-message",
1192
+ messageGuid: entry.messageGuid,
1193
+ timestamp: parseTimestampMs(entry.recordedAt) ?? Date.now(),
1194
+ fromMe: false,
1195
+ sender: {
1196
+ provider: "imessage-handle",
1197
+ externalId: chatIdentifier,
1198
+ rawId: chatIdentifier,
1199
+ displayName: chatIdentifier,
1200
+ },
1201
+ chat: {
1202
+ chatGuid,
1203
+ chatIdentifier,
1204
+ isGroup,
1205
+ sessionKey: normalizedSessionKey,
1206
+ sendTarget: chatGuid
1207
+ ? { kind: "chat_guid", value: chatGuid }
1208
+ : { kind: "chat_identifier", value: chatIdentifier },
1209
+ participantHandles: [],
1210
+ },
1211
+ text: entry.textForAgent,
1212
+ textForAgent: entry.textForAgent,
1213
+ attachments: [],
1214
+ hasPayloadData: false,
1215
+ requiresRepair: true,
1216
+ };
1217
+ }
1218
+ async function recoverCapturedBlueBubblesInboundMessages(deps = {}) {
1219
+ const resolvedDeps = { ...defaultDeps, ...deps };
1220
+ const agentName = resolvedDeps.getAgentName();
1221
+ const client = resolvedDeps.createClient();
1222
+ const result = { recovered: 0, skipped: 0, failed: 0 };
1223
+ const seenMessageGuids = new Set();
1224
+ const candidates = (0, inbound_log_1.listRecordedBlueBubblesInbound)(agentName)
1225
+ .filter((entry) => {
1226
+ if (seenMessageGuids.has(entry.messageGuid))
1227
+ return false;
1228
+ seenMessageGuids.add(entry.messageGuid);
1229
+ return true;
1230
+ })
1231
+ .sort((left, right) => (parseTimestampMs(left.recordedAt) ?? 0) - (parseTimestampMs(right.recordedAt) ?? 0));
1232
+ for (const entry of candidates) {
1233
+ if ((0, processed_log_1.hasProcessedBlueBubblesMessage)(agentName, entry.sessionKey, entry.messageGuid)
1234
+ || isBlueBubblesMessageInFlight(entry.sessionKey, entry.messageGuid)) {
1235
+ result.skipped++;
1236
+ continue;
1237
+ }
1238
+ try {
1239
+ const repaired = await client.repairEvent(inboundEntryToRecoveryEvent(entry));
1240
+ if (repaired.kind !== "message") {
1241
+ result.skipped++;
1242
+ continue;
1243
+ }
1244
+ const handled = await handleBlueBubblesNormalizedEvent(repaired, resolvedDeps, entry.source);
1245
+ if (handled.reason === "already_processed") {
1246
+ result.skipped++;
1247
+ }
1248
+ else {
1249
+ result.recovered++;
1250
+ }
1251
+ }
1252
+ catch (error) {
1253
+ result.failed++;
1254
+ (0, runtime_1.emitNervesEvent)({
1255
+ level: "warn",
1256
+ component: "senses",
1257
+ event: "senses.bluebubbles_capture_recovery_error",
1258
+ message: "captured bluebubbles message recovery failed",
1259
+ meta: {
1260
+ messageGuid: entry.messageGuid,
1261
+ sessionKey: entry.sessionKey,
1262
+ reason: error instanceof Error ? error.message : String(error),
1263
+ },
1264
+ });
1265
+ }
1266
+ }
1267
+ return result;
1268
+ }
1112
1269
  async function recoverMissedBlueBubblesMessages(deps = {}) {
1113
1270
  const resolvedDeps = { ...defaultDeps, ...deps };
1114
1271
  const agentName = resolvedDeps.getAgentName();
1115
1272
  const client = resolvedDeps.createClient();
1116
1273
  const result = { recovered: 0, skipped: 0, pending: 0, failed: 0 };
1117
1274
  for (const candidate of (0, mutation_log_1.listBlueBubblesRecoveryCandidates)(agentName)) {
1118
- if ((0, inbound_log_1.hasRecordedBlueBubblesInbound)(agentName, candidate.sessionKey, candidate.messageGuid)) {
1275
+ if ((0, processed_log_1.hasProcessedBlueBubblesMessage)(agentName, candidate.sessionKey, candidate.messageGuid)
1276
+ || isBlueBubblesMessageInFlight(candidate.sessionKey, candidate.messageGuid)) {
1119
1277
  result.skipped++;
1120
1278
  continue;
1121
1279
  }