@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.
- package/changelog.json +15 -0
- package/dist/heart/active-work.js +89 -3
- package/dist/heart/background-operations.js +26 -3
- package/dist/heart/daemon/cli-exec.js +171 -9
- package/dist/heart/mail-import-discovery.js +37 -2
- package/dist/heart/providers/azure.js +1 -1
- package/dist/heart/providers/github-copilot.js +1 -1
- package/dist/heart/providers/openai-codex.js +1 -1
- package/dist/heart/streaming.js +13 -2
- package/dist/mailroom/blob-store.js +16 -10
- package/dist/mailroom/core.js +1 -1
- package/dist/mailroom/file-store.js +35 -9
- package/dist/mailroom/mbox-import.js +41 -0
- package/dist/mailroom/reader.js +22 -0
- package/dist/mailroom/search-cache.js +182 -0
- package/dist/mailroom/search-relevance.js +319 -0
- package/dist/nerves/coverage/file-completeness.js +4 -0
- package/dist/repertoire/tools-mail.js +453 -68
- package/dist/senses/bluebubbles/inbound-log.js +13 -0
- package/dist/senses/bluebubbles/index.js +406 -237
- package/dist/senses/bluebubbles/processed-log.js +111 -0
- package/dist/senses/mail.js +19 -3
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
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.
|
|
703
|
-
message:
|
|
704
|
-
meta: {
|
|
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
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
const
|
|
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
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
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:
|
|
911
|
+
notifiedAgent: true,
|
|
833
912
|
kind: event.kind,
|
|
834
913
|
};
|
|
835
914
|
}
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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
|
-
|
|
863
|
-
|
|
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
|
-
&&
|
|
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,
|
|
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
|
|
968
|
-
detail:
|
|
969
|
-
? `recovery
|
|
970
|
-
:
|
|
971
|
-
?
|
|
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,
|
|
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,
|
|
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
|
}
|