@ouro.bot/cli 0.1.0-alpha.21 → 0.1.0-alpha.210

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.
Files changed (227) hide show
  1. package/README.md +187 -191
  2. package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/agent.json +3 -2
  3. package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/SOUL.md +1 -1
  4. package/changelog.json +1312 -0
  5. package/dist/arc/attention-types.js +8 -0
  6. package/dist/arc/cares.js +179 -0
  7. package/dist/arc/episodes.js +119 -0
  8. package/dist/arc/intentions.js +173 -0
  9. package/dist/arc/obligations.js +321 -0
  10. package/dist/arc/presence.js +185 -0
  11. package/dist/heart/active-work.js +832 -0
  12. package/dist/heart/agent-entry.js +37 -2
  13. package/dist/heart/auth/auth-flow.js +444 -0
  14. package/dist/heart/bridges/manager.js +358 -0
  15. package/dist/heart/bridges/state-machine.js +135 -0
  16. package/dist/heart/bridges/store.js +123 -0
  17. package/dist/heart/commitments.js +111 -0
  18. package/dist/heart/config-registry.js +305 -0
  19. package/dist/heart/config.js +59 -30
  20. package/dist/heart/core.js +606 -131
  21. package/dist/heart/cross-chat-delivery.js +146 -0
  22. package/dist/heart/daemon/agent-config-check.js +149 -0
  23. package/dist/heart/daemon/agent-discovery.js +81 -0
  24. package/dist/heart/daemon/agent-service.js +360 -0
  25. package/dist/heart/daemon/cadence.js +70 -0
  26. package/dist/heart/daemon/cli-defaults.js +557 -0
  27. package/dist/heart/daemon/cli-exec.js +1847 -0
  28. package/dist/heart/daemon/cli-parse.js +759 -0
  29. package/dist/heart/daemon/cli-render.js +316 -0
  30. package/dist/heart/daemon/cli-types.js +8 -0
  31. package/dist/heart/daemon/daemon-cli.js +29 -1163
  32. package/dist/heart/daemon/daemon-entry.js +182 -4
  33. package/dist/heart/daemon/daemon-health.js +136 -0
  34. package/dist/heart/daemon/daemon-runtime-sync.js +153 -12
  35. package/dist/heart/daemon/daemon-tombstone.js +123 -0
  36. package/dist/heart/daemon/daemon.js +491 -18
  37. package/dist/heart/daemon/hooks/agent-config-v2.js +33 -0
  38. package/dist/heart/daemon/hooks/bundle-meta.js +206 -0
  39. package/dist/heart/daemon/inner-status.js +89 -0
  40. package/dist/heart/daemon/launchd.js +165 -0
  41. package/dist/heart/daemon/log-tailer.js +4 -3
  42. package/dist/heart/daemon/message-router.js +17 -8
  43. package/dist/heart/daemon/os-cron-deps.js +134 -0
  44. package/dist/heart/daemon/ouro-bot-entry.js +1 -1
  45. package/dist/heart/daemon/process-manager.js +123 -0
  46. package/dist/heart/daemon/run-hooks.js +39 -0
  47. package/dist/heart/daemon/runtime-logging.js +58 -15
  48. package/dist/heart/daemon/runtime-metadata.js +101 -0
  49. package/dist/heart/daemon/runtime-mode.js +67 -0
  50. package/dist/heart/daemon/safe-mode.js +161 -0
  51. package/dist/heart/daemon/sense-manager.js +71 -2
  52. package/dist/heart/daemon/session-id-resolver.js +131 -0
  53. package/dist/heart/daemon/skill-management-installer.js +94 -0
  54. package/dist/heart/daemon/socket-client.js +202 -0
  55. package/dist/heart/daemon/task-scheduler.js +3 -25
  56. package/dist/heart/daemon/thoughts.js +507 -0
  57. package/dist/heart/delegation.js +62 -0
  58. package/dist/heart/habits/habit-migration.js +181 -0
  59. package/dist/heart/habits/habit-parser.js +119 -0
  60. package/dist/heart/habits/habit-scheduler.js +369 -0
  61. package/dist/heart/{daemon → hatch}/hatch-flow.js +29 -119
  62. package/dist/heart/{daemon → hatch}/hatch-specialist.js +3 -3
  63. package/dist/heart/{daemon → hatch}/specialist-prompt.js +10 -7
  64. package/dist/heart/{daemon → hatch}/specialist-tools.js +49 -3
  65. package/dist/heart/identity.js +144 -55
  66. package/dist/heart/kicks.js +2 -20
  67. package/dist/heart/mcp/mcp-server.js +608 -0
  68. package/dist/heart/migrate-config.js +127 -0
  69. package/dist/heart/model-capabilities.js +48 -0
  70. package/dist/heart/outlook/outlook-http.js +439 -0
  71. package/dist/heart/outlook/outlook-read.js +1618 -0
  72. package/dist/heart/outlook/outlook-render.js +1032 -0
  73. package/dist/heart/outlook/outlook-types.js +7 -0
  74. package/dist/heart/outlook/outlook-view.js +194 -0
  75. package/dist/heart/progress-story.js +42 -0
  76. package/dist/heart/provider-failover.js +88 -0
  77. package/dist/heart/provider-ping.js +162 -0
  78. package/dist/heart/providers/anthropic-token.js +163 -0
  79. package/dist/heart/providers/anthropic.js +187 -41
  80. package/dist/heart/providers/azure.js +116 -10
  81. package/dist/heart/providers/github-copilot.js +157 -0
  82. package/dist/heart/providers/minimax.js +33 -3
  83. package/dist/heart/providers/openai-codex.js +51 -16
  84. package/dist/heart/session-activity.js +172 -0
  85. package/dist/heart/session-recall.js +216 -0
  86. package/dist/heart/start-of-turn-packet.js +330 -0
  87. package/dist/heart/streaming.js +36 -27
  88. package/dist/heart/sync.js +217 -0
  89. package/dist/heart/target-resolution.js +127 -0
  90. package/dist/heart/tempo.js +93 -0
  91. package/dist/heart/temporal-view.js +41 -0
  92. package/dist/heart/tool-activity-callbacks.js +36 -0
  93. package/dist/heart/tool-description.js +135 -0
  94. package/dist/heart/tool-loop.js +200 -0
  95. package/dist/heart/turn-context.js +358 -0
  96. package/dist/heart/turn-coordinator.js +28 -0
  97. package/dist/heart/versioning/ouro-bot-global-installer.js +128 -0
  98. package/dist/heart/{daemon → versioning}/ouro-bot-wrapper.js +1 -1
  99. package/dist/heart/{daemon → versioning}/ouro-path-installer.js +78 -35
  100. package/dist/heart/versioning/ouro-version-manager.js +171 -0
  101. package/dist/heart/versioning/staged-restart.js +138 -0
  102. package/dist/heart/versioning/update-checker.js +111 -0
  103. package/dist/heart/versioning/update-hooks.js +138 -0
  104. package/dist/heart/{daemon → versioning}/wrapper-publish-guard.js +41 -3
  105. package/dist/mind/associative-recall.js +122 -32
  106. package/dist/mind/bundle-manifest.js +19 -1
  107. package/dist/mind/context.js +125 -14
  108. package/dist/mind/{memory.js → diary.js} +69 -56
  109. package/dist/mind/file-state.js +179 -0
  110. package/dist/mind/first-impressions.js +16 -2
  111. package/dist/mind/friends/channel.js +56 -0
  112. package/dist/mind/friends/group-context.js +144 -0
  113. package/dist/mind/friends/store-file.js +36 -0
  114. package/dist/mind/friends/trust-explanation.js +74 -0
  115. package/dist/mind/friends/types.js +8 -0
  116. package/dist/mind/journal-index.js +161 -0
  117. package/dist/mind/obligation-steering.js +221 -0
  118. package/dist/mind/pending.js +76 -9
  119. package/dist/mind/phrases.js +1 -0
  120. package/dist/mind/prompt.js +795 -108
  121. package/dist/mind/scrutiny.js +173 -0
  122. package/dist/mind/token-estimate.js +8 -12
  123. package/dist/nerves/coverage/audit.js +1 -1
  124. package/dist/nerves/coverage/file-completeness.js +42 -5
  125. package/dist/nerves/coverage/run-artifacts.js +1 -1
  126. package/dist/nerves/event-buffer.js +111 -0
  127. package/dist/nerves/index.js +63 -3
  128. package/dist/nerves/observation.js +20 -0
  129. package/dist/nerves/runtime.js +5 -1
  130. package/dist/outlook-ui/assets/index-Ck8agNeO.js +61 -0
  131. package/dist/outlook-ui/assets/index-LwChZTgL.css +1 -0
  132. package/dist/outlook-ui/index.html +15 -0
  133. package/dist/repertoire/ado-semantic.js +11 -5
  134. package/dist/repertoire/coding/codex-jsonl.js +64 -0
  135. package/dist/repertoire/coding/context-pack.js +330 -0
  136. package/dist/repertoire/coding/feedback.js +197 -30
  137. package/dist/repertoire/coding/manager.js +149 -2
  138. package/dist/repertoire/coding/spawner.js +55 -9
  139. package/dist/repertoire/coding/tools.js +170 -7
  140. package/dist/repertoire/guardrails.js +291 -0
  141. package/dist/repertoire/mcp-client.js +254 -0
  142. package/dist/repertoire/mcp-manager.js +251 -0
  143. package/dist/repertoire/shell-sessions.js +133 -0
  144. package/dist/repertoire/skills.js +14 -23
  145. package/dist/repertoire/tasks/board.js +43 -5
  146. package/dist/repertoire/tasks/fix.js +182 -0
  147. package/dist/repertoire/tasks/index.js +28 -10
  148. package/dist/repertoire/tasks/lifecycle.js +2 -2
  149. package/dist/repertoire/tasks/parser.js +3 -2
  150. package/dist/repertoire/tasks/scanner.js +194 -37
  151. package/dist/repertoire/tasks/transitions.js +11 -28
  152. package/dist/repertoire/tools-base.js +31 -771
  153. package/dist/repertoire/tools-bluebubbles.js +94 -0
  154. package/dist/repertoire/tools-bridge.js +141 -0
  155. package/dist/repertoire/tools-config.js +266 -0
  156. package/dist/repertoire/tools-continuity.js +248 -0
  157. package/dist/repertoire/tools-files.js +342 -0
  158. package/dist/repertoire/tools-flow.js +62 -0
  159. package/dist/repertoire/tools-github.js +1 -6
  160. package/dist/repertoire/tools-memory.js +347 -0
  161. package/dist/repertoire/tools-session.js +682 -0
  162. package/dist/repertoire/tools-shell.js +120 -0
  163. package/dist/repertoire/tools-surface.js +228 -0
  164. package/dist/repertoire/tools-teams.js +12 -59
  165. package/dist/repertoire/tools.js +132 -124
  166. package/dist/scripts/claude-code-hook.js +41 -0
  167. package/dist/scripts/claude-code-stop-hook.js +47 -0
  168. package/dist/senses/attention-queue.js +97 -0
  169. package/dist/senses/bluebubbles-client.js +102 -3
  170. package/dist/senses/bluebubbles-entry.js +2 -0
  171. package/dist/senses/bluebubbles-inbound-log.js +109 -0
  172. package/dist/senses/bluebubbles-media.js +118 -23
  173. package/dist/senses/bluebubbles-model.js +12 -4
  174. package/dist/senses/bluebubbles-mutation-log.js +45 -5
  175. package/dist/senses/bluebubbles-runtime-state.js +109 -0
  176. package/dist/senses/bluebubbles-session-cleanup.js +72 -0
  177. package/dist/senses/bluebubbles.js +1008 -102
  178. package/dist/senses/cli/adapter.js +274 -0
  179. package/dist/senses/cli/ink-app.js +156 -0
  180. package/dist/senses/cli/inline-diff.js +64 -0
  181. package/dist/senses/cli/message-list.js +51 -0
  182. package/dist/senses/cli/ouro-tui.js +358 -0
  183. package/dist/senses/cli/spinner-imperative.js +135 -0
  184. package/dist/senses/cli/spinner.js +101 -0
  185. package/dist/senses/cli/status-line.js +60 -0
  186. package/dist/senses/cli/streaming-markdown.js +526 -0
  187. package/dist/senses/cli/tool-display.js +83 -0
  188. package/dist/senses/cli/tool-render.js +85 -0
  189. package/dist/senses/cli/tui-store.js +198 -0
  190. package/dist/senses/cli/virtual-list.js +35 -0
  191. package/dist/senses/cli-entry.js +1 -1
  192. package/dist/senses/cli-layout.js +187 -0
  193. package/dist/senses/cli.js +508 -243
  194. package/dist/senses/commands.js +65 -1
  195. package/dist/senses/continuity.js +94 -0
  196. package/dist/senses/habit-turn-message.js +108 -0
  197. package/dist/senses/inner-dialog-worker.js +112 -19
  198. package/dist/senses/inner-dialog.js +591 -84
  199. package/dist/senses/pipeline.js +559 -0
  200. package/dist/senses/shared-turn.js +196 -0
  201. package/dist/senses/surface-tool.js +68 -0
  202. package/dist/senses/teams.js +667 -120
  203. package/dist/senses/trust-gate.js +112 -2
  204. package/package.json +16 -6
  205. package/skills/configure-dev-tools.md +81 -0
  206. package/subagents/README.md +4 -70
  207. package/dist/heart/daemon/subagent-installer.js +0 -134
  208. package/dist/senses/debug-activity.js +0 -107
  209. package/subagents/work-doer.md +0 -235
  210. package/subagents/work-merger.md +0 -618
  211. package/subagents/work-planner.md +0 -382
  212. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/basilisk.md +0 -0
  213. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/jafar.md +0 -0
  214. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/jormungandr.md +0 -0
  215. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/kaa.md +0 -0
  216. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/medusa.md +0 -0
  217. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/monty.md +0 -0
  218. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/nagini.md +0 -0
  219. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/ouroboros.md +0 -0
  220. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/python.md +0 -0
  221. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/quetzalcoatl.md +0 -0
  222. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/sir-hiss.md +0 -0
  223. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/the-serpent.md +0 -0
  224. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/the-snake.md +0 -0
  225. /package/dist/heart/{daemon → hatch}/hatch-animation.js +0 -0
  226. /package/dist/heart/{daemon → hatch}/specialist-orchestrator.js +0 -0
  227. /package/dist/heart/{daemon → versioning}/ouro-uti.js +0 -0
@@ -33,25 +33,93 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.enrichReactionText = enrichReactionText;
37
+ exports.createStatusBatcher = createStatusBatcher;
36
38
  exports.handleBlueBubblesEvent = handleBlueBubblesEvent;
39
+ exports.recoverMissedBlueBubblesMessages = recoverMissedBlueBubblesMessages;
37
40
  exports.createBlueBubblesWebhookHandler = createBlueBubblesWebhookHandler;
41
+ exports.sendProactiveBlueBubblesMessageToSession = sendProactiveBlueBubblesMessageToSession;
42
+ exports.drainAndSendPendingBlueBubbles = drainAndSendPendingBlueBubbles;
38
43
  exports.startBlueBubblesApp = startBlueBubblesApp;
44
+ const fs = __importStar(require("node:fs"));
39
45
  const http = __importStar(require("node:http"));
40
46
  const path = __importStar(require("node:path"));
41
47
  const core_1 = require("../heart/core");
42
48
  const config_1 = require("../heart/config");
43
49
  const identity_1 = require("../heart/identity");
50
+ const turn_coordinator_1 = require("../heart/turn-coordinator");
44
51
  const context_1 = require("../mind/context");
45
52
  const tokens_1 = require("../mind/friends/tokens");
53
+ const group_context_1 = require("../mind/friends/group-context");
46
54
  const resolver_1 = require("../mind/friends/resolver");
47
55
  const store_file_1 = require("../mind/friends/store-file");
56
+ const types_1 = require("../mind/friends/types");
57
+ const channel_1 = require("../mind/friends/channel");
58
+ const pending_1 = require("../mind/pending");
48
59
  const prompt_1 = require("../mind/prompt");
49
- const phrases_1 = require("../mind/phrases");
60
+ const mcp_manager_1 = require("../repertoire/mcp-manager");
61
+ // getPhrases removed — no longer needed after debug-activity cleanup
50
62
  const runtime_1 = require("../nerves/runtime");
51
63
  const bluebubbles_model_1 = require("./bluebubbles-model");
52
64
  const bluebubbles_client_1 = require("./bluebubbles-client");
65
+ const bluebubbles_inbound_log_1 = require("./bluebubbles-inbound-log");
53
66
  const bluebubbles_mutation_log_1 = require("./bluebubbles-mutation-log");
54
- const debug_activity_1 = require("./debug-activity");
67
+ const bluebubbles_runtime_state_1 = require("./bluebubbles-runtime-state");
68
+ const bluebubbles_session_cleanup_1 = require("./bluebubbles-session-cleanup");
69
+ const tool_activity_callbacks_1 = require("../heart/tool-activity-callbacks");
70
+ const commands_1 = require("./commands");
71
+ const trust_gate_1 = require("./trust-gate");
72
+ const pipeline_1 = require("./pipeline");
73
+ const bbFailoverStates = new Map();
74
+ // Enrich reaction text with the original message content for context.
75
+ // If originalText is provided and non-empty, format as: baseText to: "truncated"
76
+ // Otherwise return baseText unchanged.
77
+ function enrichReactionText(baseText, originalText, maxLen) {
78
+ if (!originalText)
79
+ return baseText;
80
+ const truncated = originalText.length > maxLen
81
+ ? originalText.slice(0, maxLen - 3) + "..."
82
+ : originalText;
83
+ return `${baseText} to: "${truncated}"`;
84
+ }
85
+ /**
86
+ * Accumulates status descriptions and debounces them.
87
+ * If multiple descriptions arrive within `delayMs`, they are joined with ` · `
88
+ * and sent as a single message. Flush sends immediately and clears the timer.
89
+ */
90
+ function createStatusBatcher(send, delayMs) {
91
+ (0, runtime_1.emitNervesEvent)({
92
+ component: "senses",
93
+ event: "senses.bluebubbles_status_batcher_created",
94
+ message: "status batcher initialized",
95
+ meta: { delayMs },
96
+ });
97
+ let pending = [];
98
+ let timer = null;
99
+ function fire() {
100
+ if (pending.length === 0)
101
+ return;
102
+ const combined = pending.join(" \u00b7 ");
103
+ pending = [];
104
+ timer = null;
105
+ send(combined);
106
+ }
107
+ return {
108
+ add(text) {
109
+ pending.push(text);
110
+ if (timer !== null)
111
+ clearTimeout(timer);
112
+ timer = setTimeout(fire, delayMs);
113
+ },
114
+ flush() {
115
+ if (timer !== null) {
116
+ clearTimeout(timer);
117
+ timer = null;
118
+ }
119
+ fire();
120
+ },
121
+ };
122
+ }
55
123
  const defaultDeps = {
56
124
  getAgentName: identity_1.getAgentName,
57
125
  buildSystem: prompt_1.buildSystem,
@@ -66,6 +134,7 @@ const defaultDeps = {
66
134
  createFriendResolver: (store, params) => new resolver_1.FriendResolver(store, params),
67
135
  createServer: http.createServer,
68
136
  };
137
+ const BLUEBUBBLES_RUNTIME_SYNC_INTERVAL_MS = 30_000;
69
138
  function resolveFriendParams(event) {
70
139
  if (event.chat.isGroup) {
71
140
  const groupKey = event.chat.chatGuid ?? event.chat.chatIdentifier ?? event.sender.externalId;
@@ -83,19 +152,149 @@ function resolveFriendParams(event) {
83
152
  channel: "bluebubbles",
84
153
  };
85
154
  }
86
- function buildInboundText(event) {
155
+ function resolveGroupExternalId(event) {
156
+ const groupKey = event.chat.chatGuid ?? event.chat.chatIdentifier ?? event.sender.externalId;
157
+ return `group:${groupKey}`;
158
+ }
159
+ /**
160
+ * Check if any participant in a group chat is a known family member.
161
+ * Looks up each participant handle in the friend store.
162
+ */
163
+ async function checkGroupHasFamilyMember(store, event) {
164
+ if (!event.chat.isGroup)
165
+ return false;
166
+ for (const handle of event.chat.participantHandles ?? []) {
167
+ const friend = await store.findByExternalId("imessage-handle", handle);
168
+ if (friend?.trustLevel === "family")
169
+ return true;
170
+ }
171
+ return false;
172
+ }
173
+ /**
174
+ * Check if an acquaintance shares any group chat with a family member.
175
+ * Compares group-prefixed externalIds between the acquaintance and all family members.
176
+ */
177
+ async function checkHasExistingGroupWithFamily(store, senderFriend) {
178
+ const trustLevel = senderFriend.trustLevel ?? "friend";
179
+ if (trustLevel !== "acquaintance")
180
+ return false;
181
+ const acquaintanceGroups = new Set((senderFriend.externalIds ?? [])
182
+ .filter((eid) => eid.externalId.startsWith("group:"))
183
+ .map((eid) => eid.externalId));
184
+ if (acquaintanceGroups.size === 0)
185
+ return false;
186
+ const allFriends = await (store.listAll?.() ?? Promise.resolve([]));
187
+ for (const friend of allFriends) {
188
+ if (friend.trustLevel !== "family")
189
+ continue;
190
+ const friendGroups = (friend.externalIds ?? [])
191
+ .filter((eid) => eid.externalId.startsWith("group:"))
192
+ .map((eid) => eid.externalId);
193
+ for (const group of friendGroups) {
194
+ if (acquaintanceGroups.has(group))
195
+ return true;
196
+ }
197
+ }
198
+ return false;
199
+ }
200
+ function extractMessageText(content) {
201
+ if (typeof content === "string")
202
+ return content;
203
+ if (!Array.isArray(content))
204
+ return "";
205
+ return content
206
+ .map((part) => {
207
+ if (part && typeof part === "object" && "type" in part && part.type === "text" && typeof part.text === "string") {
208
+ return part.text;
209
+ }
210
+ return "";
211
+ })
212
+ .filter(Boolean)
213
+ .join("\n");
214
+ }
215
+ function isHistoricalLaneMetadataLine(line) {
216
+ return /^\[(conversation scope|recent active lanes|routing control):?/i.test(line)
217
+ || /^- (top_level|thread:[^:]+):/i.test(line);
218
+ }
219
+ function extractHistoricalLaneSummary(messages) {
220
+ const seen = new Set();
221
+ const summaries = [];
222
+ for (let index = messages.length - 1; index >= 0; index--) {
223
+ const message = messages[index];
224
+ if (message.role !== "user")
225
+ continue;
226
+ const text = extractMessageText(message.content);
227
+ if (!text)
228
+ continue;
229
+ const firstLine = text.split("\n")[0].trim();
230
+ const threadMatch = firstLine.match(/thread id: ([^\]|]+)/i);
231
+ const laneKey = threadMatch
232
+ ? `thread:${threadMatch[1].trim()}`
233
+ : /top[-_]level/i.test(firstLine)
234
+ ? "top_level"
235
+ : null;
236
+ if (!laneKey || seen.has(laneKey))
237
+ continue;
238
+ seen.add(laneKey);
239
+ const snippet = text
240
+ .split("\n")
241
+ .slice(1)
242
+ .map((line) => line.trim())
243
+ .find((line) => line.length > 0 && !isHistoricalLaneMetadataLine(line))
244
+ ?.slice(0, 80) ?? "(no recent text)";
245
+ summaries.push({
246
+ key: laneKey,
247
+ label: laneKey === "top_level" ? "top_level" : laneKey,
248
+ snippet,
249
+ });
250
+ if (summaries.length >= 5)
251
+ break;
252
+ }
253
+ return summaries;
254
+ }
255
+ function buildConversationScopePrefix(event, existingMessages, repliedToText) {
256
+ if (event.kind !== "message") {
257
+ return "";
258
+ }
259
+ const summaries = extractHistoricalLaneSummary(existingMessages);
260
+ const lines = [];
261
+ if (event.threadOriginatorGuid?.trim()) {
262
+ lines.push(`[conversation scope: existing chat trunk | current inbound lane: thread | current thread id: ${event.threadOriginatorGuid.trim()} | default outbound target for this turn: current_lane]`);
263
+ if (repliedToText) {
264
+ lines.push(`[replying to: "${repliedToText}"]`);
265
+ }
266
+ lines.push(`[if you need more context about what was being discussed, use query_session to search your session history, or recall to check your memory.]`);
267
+ }
268
+ else {
269
+ lines.push("[conversation scope: existing chat trunk | current inbound lane: top_level | default outbound target for this turn: top_level]");
270
+ }
271
+ if (summaries.length > 0) {
272
+ lines.push("[recent active lanes]");
273
+ for (const summary of summaries) {
274
+ lines.push(`- ${summary.label}: ${summary.snippet}`);
275
+ }
276
+ }
277
+ if (event.threadOriginatorGuid?.trim() || summaries.some((summary) => summary.key.startsWith("thread:"))) {
278
+ lines.push("[routing control: use bluebubbles_set_reply_target with target=top_level to widen back out, or target=thread plus a listed thread id to route into a specific active thread]");
279
+ }
280
+ return lines.join("\n");
281
+ }
282
+ function buildInboundText(event, existingMessages, repliedToText) {
283
+ const metadataPrefix = buildConversationScopePrefix(event, existingMessages, repliedToText);
87
284
  const baseText = event.repairNotice?.trim()
88
285
  ? `${event.textForAgent}\n[${event.repairNotice.trim()}]`
89
286
  : event.textForAgent;
90
- if (!event.chat.isGroup)
91
- return baseText;
287
+ if (!event.chat.isGroup) {
288
+ return metadataPrefix ? `${metadataPrefix}\n${baseText}` : baseText;
289
+ }
290
+ const scopedText = metadataPrefix ? `${metadataPrefix}\n${baseText}` : baseText;
92
291
  if (event.kind === "mutation") {
93
- return `${event.sender.displayName} ${baseText}`;
292
+ return `${event.sender.displayName} ${scopedText}`;
94
293
  }
95
- return `${event.sender.displayName}: ${baseText}`;
294
+ return `${event.sender.displayName}: ${scopedText}`;
96
295
  }
97
- function buildInboundContent(event) {
98
- const text = buildInboundText(event);
296
+ function buildInboundContent(event, existingMessages, repliedToText) {
297
+ const text = buildInboundText(event, existingMessages, repliedToText);
99
298
  if (event.kind !== "message" || !event.inputPartsForAgent || event.inputPartsForAgent.length === 0) {
100
299
  return text;
101
300
  }
@@ -104,48 +303,160 @@ function buildInboundContent(event) {
104
303
  ...event.inputPartsForAgent,
105
304
  ];
106
305
  }
107
- function createBlueBubblesCallbacks(client, chat, replyToMessageGuid) {
108
- let textBuffer = "";
109
- const phrases = (0, phrases_1.getPhrases)();
110
- const activity = (0, debug_activity_1.createDebugActivityController)({
111
- thinkingPhrases: phrases.thinking,
112
- followupPhrases: phrases.followup,
113
- transport: {
114
- sendStatus: async (text) => {
115
- const sent = await client.sendText({
116
- chat,
117
- text,
118
- replyToMessageGuid,
119
- });
120
- return sent.messageGuid;
121
- },
122
- editStatus: async (_messageGuid, text) => {
123
- await client.sendText({
124
- chat,
125
- text,
126
- replyToMessageGuid,
127
- });
128
- },
129
- setTyping: async (active) => {
130
- await client.setTyping(chat, active);
131
- },
306
+ function sessionLikelyContainsMessage(event, existingMessages) {
307
+ const fragment = event.textForAgent.trim();
308
+ if (!fragment)
309
+ return false;
310
+ return existingMessages.some((message) => {
311
+ if (message.role !== "user")
312
+ return false;
313
+ return extractMessageText(message.content).includes(fragment);
314
+ });
315
+ }
316
+ function mutationEntryToEvent(entry) {
317
+ return {
318
+ kind: "mutation",
319
+ eventType: entry.eventType,
320
+ mutationType: entry.mutationType,
321
+ messageGuid: entry.messageGuid,
322
+ targetMessageGuid: entry.targetMessageGuid ?? undefined,
323
+ timestamp: Date.parse(entry.recordedAt) || Date.now(),
324
+ fromMe: entry.fromMe,
325
+ sender: {
326
+ provider: "imessage-handle",
327
+ externalId: entry.chatIdentifier ?? entry.chatGuid ?? "unknown",
328
+ rawId: entry.chatIdentifier ?? entry.chatGuid ?? "unknown",
329
+ displayName: entry.chatIdentifier ?? entry.chatGuid ?? "Unknown",
330
+ },
331
+ chat: {
332
+ chatGuid: entry.chatGuid ?? undefined,
333
+ chatIdentifier: entry.chatIdentifier ?? undefined,
334
+ displayName: undefined,
335
+ isGroup: Boolean(entry.chatGuid?.includes(";+;")),
336
+ sessionKey: entry.sessionKey,
337
+ sendTarget: entry.chatGuid
338
+ ? { kind: "chat_guid", value: entry.chatGuid }
339
+ : { kind: "chat_identifier", value: entry.chatIdentifier ?? "unknown" },
340
+ participantHandles: [],
341
+ },
342
+ shouldNotifyAgent: entry.shouldNotifyAgent,
343
+ textForAgent: entry.textForAgent,
344
+ requiresRepair: true,
345
+ };
346
+ }
347
+ function getBlueBubblesContinuityIngressTexts(event) {
348
+ if (event.kind !== "message")
349
+ return [];
350
+ const text = event.textForAgent.trim();
351
+ if (text.length > 0)
352
+ return [text];
353
+ const fallbackText = (event.inputPartsForAgent ?? [])
354
+ .map((part) => {
355
+ if (part.type === "text" && typeof part.text === "string") {
356
+ return part.text.trim();
357
+ }
358
+ return "";
359
+ })
360
+ .filter(Boolean)
361
+ .join("\n");
362
+ return fallbackText ? [fallbackText] : [];
363
+ }
364
+ function createReplyTargetController(event) {
365
+ const defaultTargetLabel = event.kind === "message" && event.threadOriginatorGuid?.trim() ? "current_lane" : "top_level";
366
+ let selection = event.kind === "message" && event.threadOriginatorGuid?.trim()
367
+ ? { target: "current_lane" }
368
+ : { target: "top_level" };
369
+ return {
370
+ getReplyToMessageGuid() {
371
+ if (event.kind !== "message")
372
+ return undefined;
373
+ if (selection.target === "top_level")
374
+ return undefined;
375
+ if (selection.target === "thread")
376
+ return selection.threadOriginatorGuid.trim();
377
+ return event.threadOriginatorGuid?.trim() ? event.messageGuid : undefined;
132
378
  },
133
- onTransportError: (operation, error) => {
379
+ setSelection(next) {
380
+ selection = next;
381
+ if (next.target === "top_level") {
382
+ return "bluebubbles reply target override: top_level";
383
+ }
384
+ if (next.target === "thread") {
385
+ return `bluebubbles reply target override: thread:${next.threadOriginatorGuid}`;
386
+ }
387
+ return `bluebubbles reply target: using default for this turn (${defaultTargetLabel})`;
388
+ },
389
+ };
390
+ }
391
+ function emitBlueBubblesMarkReadWarning(chat, error) {
392
+ (0, runtime_1.emitNervesEvent)({
393
+ level: "warn",
394
+ component: "senses",
395
+ event: "senses.bluebubbles_mark_read_error",
396
+ message: "failed to mark bluebubbles chat as read",
397
+ meta: {
398
+ chatGuid: chat.chatGuid ?? null,
399
+ reason: error instanceof Error ? error.message : String(error),
400
+ },
401
+ });
402
+ }
403
+ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat) {
404
+ let textBuffer = "";
405
+ let typingActive = false;
406
+ let queue = Promise.resolve();
407
+ function enqueue(operation, task) {
408
+ queue = queue.then(task).catch((error) => {
134
409
  (0, runtime_1.emitNervesEvent)({
135
410
  level: "warn",
136
411
  component: "senses",
137
412
  event: "senses.bluebubbles_activity_error",
138
413
  message: "bluebubbles activity transport failed",
139
- meta: {
140
- operation,
141
- reason: error instanceof Error ? error.message : String(error),
142
- },
414
+ meta: { operation, reason: error instanceof Error ? error.message : String(error) },
143
415
  });
144
- },
416
+ });
417
+ }
418
+ function startTypingNow() {
419
+ /* v8 ignore next -- defensive guard: callers already check typingActive @preserve */
420
+ if (typingActive)
421
+ return;
422
+ typingActive = true;
423
+ enqueue("typing_start", async () => {
424
+ const [markReadResult, typingResult] = await Promise.allSettled([
425
+ client.markChatRead(chat),
426
+ client.setTyping(chat, true),
427
+ ]);
428
+ if (markReadResult.status === "rejected") {
429
+ emitBlueBubblesMarkReadWarning(chat, markReadResult.reason);
430
+ }
431
+ if (typingResult.status === "rejected") {
432
+ throw typingResult.reason;
433
+ }
434
+ });
435
+ }
436
+ function sendStatus(text) {
437
+ enqueue("send_status", async () => {
438
+ await client.sendText({
439
+ chat,
440
+ text,
441
+ replyToMessageGuid: replyTarget.getReplyToMessageGuid(),
442
+ });
443
+ // Re-enable typing indicator — sending a message clears the typing bubble
444
+ await client.setTyping(chat, true);
445
+ });
446
+ }
447
+ const statusBatcher = createStatusBatcher((text) => sendStatus(text), 500);
448
+ const toolCallbacks = (0, tool_activity_callbacks_1.createToolActivityCallbacks)({
449
+ onDescription: (text) => statusBatcher.add(text),
450
+ /* v8 ignore next -- onResult only called in debug mode; tested via tool-activity-callbacks.test.ts @preserve */
451
+ onResult: (text) => { statusBatcher.flush(); sendStatus(text); },
452
+ /* v8 ignore next -- onFailure only called on tool failure; tested via tool-activity-callbacks.test.ts @preserve */
453
+ onFailure: (text) => { statusBatcher.flush(); sendStatus(text); },
454
+ isDebug: commands_1.getDebugMode,
145
455
  });
146
456
  return {
147
457
  onModelStart() {
148
- activity.onModelStart();
458
+ if (!isGroupChat)
459
+ startTypingNow();
149
460
  (0, runtime_1.emitNervesEvent)({
150
461
  component: "senses",
151
462
  event: "senses.bluebubbles_turn_start",
@@ -162,12 +473,16 @@ function createBlueBubblesCallbacks(client, chat, replyToMessageGuid) {
162
473
  });
163
474
  },
164
475
  onTextChunk(text) {
165
- activity.onTextChunk(text);
476
+ if (isGroupChat && !typingActive)
477
+ startTypingNow();
166
478
  textBuffer += text;
167
479
  },
168
480
  onReasoningChunk(_text) { },
169
481
  onToolStart(name, _args) {
170
- activity.onToolStart(name, _args);
482
+ // Tool activity is a reply commitment — start typing if not already
483
+ if (!typingActive)
484
+ startTypingNow();
485
+ toolCallbacks.onToolStart(name, _args);
171
486
  (0, runtime_1.emitNervesEvent)({
172
487
  component: "senses",
173
488
  event: "senses.bluebubbles_tool_start",
@@ -176,7 +491,7 @@ function createBlueBubblesCallbacks(client, chat, replyToMessageGuid) {
176
491
  });
177
492
  },
178
493
  onToolEnd(name, summary, success) {
179
- activity.onToolEnd(name, summary, success);
494
+ toolCallbacks.onToolEnd(name, summary, success);
180
495
  (0, runtime_1.emitNervesEvent)({
181
496
  component: "senses",
182
497
  event: "senses.bluebubbles_tool_end",
@@ -185,7 +500,7 @@ function createBlueBubblesCallbacks(client, chat, replyToMessageGuid) {
185
500
  });
186
501
  },
187
502
  onError(error, severity) {
188
- activity.onError(error);
503
+ sendStatus(`\u2717 ${error.message}`);
189
504
  (0, runtime_1.emitNervesEvent)({
190
505
  level: severity === "terminal" ? "error" : "warn",
191
506
  component: "senses",
@@ -198,22 +513,39 @@ function createBlueBubblesCallbacks(client, chat, replyToMessageGuid) {
198
513
  textBuffer = "";
199
514
  },
200
515
  async flush() {
201
- await activity.drain();
516
+ statusBatcher.flush();
517
+ await queue;
202
518
  const trimmed = textBuffer.trim();
203
519
  if (!trimmed) {
204
- await activity.finish();
520
+ if (typingActive) {
521
+ typingActive = false;
522
+ enqueue("typing_stop", async () => { await client.setTyping(chat, false); });
523
+ await queue;
524
+ }
205
525
  return;
206
526
  }
207
527
  textBuffer = "";
528
+ /* v8 ignore next 4 -- branch: typing may already be stopped before flush @preserve */
529
+ if (typingActive) {
530
+ typingActive = false;
531
+ enqueue("typing_stop", async () => { await client.setTyping(chat, false); });
532
+ await queue;
533
+ }
208
534
  await client.sendText({
209
535
  chat,
210
536
  text: trimmed,
211
- replyToMessageGuid,
537
+ replyToMessageGuid: replyTarget.getReplyToMessageGuid(),
212
538
  });
213
- await activity.finish();
214
539
  },
215
540
  async finish() {
216
- await activity.finish();
541
+ statusBatcher.flush();
542
+ if (!typingActive) {
543
+ await queue;
544
+ return;
545
+ }
546
+ typingActive = false;
547
+ enqueue("typing_stop", async () => { await client.setTyping(chat, false); });
548
+ await queue;
217
549
  },
218
550
  };
219
551
  }
@@ -233,10 +565,8 @@ function isWebhookPasswordValid(url, expectedPassword) {
233
565
  const provided = url.searchParams.get("password");
234
566
  return !provided || provided === expectedPassword;
235
567
  }
236
- async function handleBlueBubblesEvent(payload, deps = {}) {
237
- const resolvedDeps = { ...defaultDeps, ...deps };
568
+ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source) {
238
569
  const client = resolvedDeps.createClient();
239
- const event = await client.repairEvent((0, bluebubbles_model_1.normalizeBlueBubblesEvent)(payload));
240
570
  if (event.fromMe) {
241
571
  (0, runtime_1.emitNervesEvent)({
242
572
  component: "senses",
@@ -279,75 +609,340 @@ async function handleBlueBubblesEvent(payload, deps = {}) {
279
609
  });
280
610
  return { handled: true, notifiedAgent: false, kind: event.kind, reason: "mutation_state_only" };
281
611
  }
612
+ // ── Adapter setup: friend, session, content, callbacks ──────────
282
613
  const store = resolvedDeps.createFriendStore();
283
614
  const resolver = resolvedDeps.createFriendResolver(store, resolveFriendParams(event));
284
- const context = await resolver.resolve();
285
- const toolContext = {
286
- signin: async () => undefined,
287
- friendStore: store,
288
- summarize: (0, core_1.createSummarize)(),
289
- context,
290
- codingFeedback: {
291
- send: async (message) => {
292
- await client.sendText({
293
- chat: event.chat,
294
- text: message,
295
- replyToMessageGuid: event.kind === "message" ? event.messageGuid : undefined,
296
- });
297
- },
298
- },
299
- };
615
+ const baseContext = await resolver.resolve();
616
+ const context = { ...baseContext, isGroupChat: event.chat.isGroup };
617
+ const replyTarget = createReplyTargetController(event);
300
618
  const friendId = context.friend.id;
301
619
  const sessPath = resolvedDeps.sessionPath(friendId, "bluebubbles", event.chat.sessionKey);
302
- const existing = resolvedDeps.loadSession(sessPath);
303
- const messages = existing?.messages && existing.messages.length > 0
304
- ? existing.messages
305
- : [{ role: "system", content: await resolvedDeps.buildSystem("bluebubbles", undefined, context) }];
306
- messages.push({ role: "user", content: buildInboundContent(event) });
307
- const callbacks = createBlueBubblesCallbacks(client, event.chat, event.kind === "message" ? event.messageGuid : undefined);
308
- const controller = new AbortController();
309
- const agentOptions = {
310
- toolContext,
311
- };
312
620
  try {
313
- const result = await resolvedDeps.runAgent(messages, callbacks, "bluebubbles", controller.signal, agentOptions);
314
- await callbacks.flush();
315
- resolvedDeps.postTurn(messages, sessPath, result.usage);
316
- await resolvedDeps.accumulateFriendTokens(store, friendId, result.usage);
621
+ (0, bluebubbles_session_cleanup_1.findObsoleteBlueBubblesThreadSessions)(sessPath);
622
+ }
623
+ catch (error) {
624
+ (0, runtime_1.emitNervesEvent)({
625
+ level: "warn",
626
+ component: "senses",
627
+ event: "senses.bluebubbles_thread_lane_cleanup_error",
628
+ message: "failed to inspect obsolete bluebubbles thread-lane sessions",
629
+ meta: {
630
+ sessionPath: sessPath,
631
+ reason: error instanceof Error ? error.message : String(error),
632
+ },
633
+ });
634
+ }
635
+ return (0, turn_coordinator_1.withSharedTurnLock)("bluebubbles", sessPath, async () => {
636
+ // Pre-load session inside the turn lock so same-chat deliveries cannot race on stale trunk state.
637
+ const existing = resolvedDeps.loadSession(sessPath);
638
+ const mcpManager = await (0, mcp_manager_1.getSharedMcpManager)() ?? undefined;
639
+ const sessionMessages = existing?.messages && existing.messages.length > 0
640
+ ? existing.messages
641
+ : [{ role: "system", content: await resolvedDeps.buildSystem("bluebubbles", { mcpManager }, context) }];
642
+ if (event.kind === "message") {
643
+ const agentName = resolvedDeps.getAgentName();
644
+ if ((0, bluebubbles_inbound_log_1.hasRecordedBlueBubblesInbound)(agentName, event.chat.sessionKey, event.messageGuid)) {
645
+ (0, runtime_1.emitNervesEvent)({
646
+ component: "senses",
647
+ event: "senses.bluebubbles_recovery_skip",
648
+ message: "skipped bluebubbles message already recorded as handled",
649
+ meta: {
650
+ messageGuid: event.messageGuid,
651
+ sessionKey: event.chat.sessionKey,
652
+ source,
653
+ },
654
+ });
655
+ return { handled: true, notifiedAgent: false, kind: event.kind, reason: "already_processed" };
656
+ }
657
+ // Record EARLY to prevent duplicate processing. BB webhooks can retry
658
+ // before the first turn completes — recording after the turn is too late.
659
+ (0, bluebubbles_inbound_log_1.recordBlueBubblesInbound)(agentName, event, source);
660
+ if (source !== "webhook" && sessionLikelyContainsMessage(event, existing?.messages ?? sessionMessages)) {
661
+ (0, bluebubbles_inbound_log_1.recordBlueBubblesInbound)(agentName, event, "recovery-bootstrap");
662
+ (0, runtime_1.emitNervesEvent)({
663
+ component: "senses",
664
+ event: "senses.bluebubbles_recovery_skip",
665
+ message: "skipped bluebubbles recovery because the session already contains the message text",
666
+ meta: {
667
+ messageGuid: event.messageGuid,
668
+ sessionKey: event.chat.sessionKey,
669
+ source,
670
+ },
671
+ });
672
+ return { handled: true, notifiedAgent: false, kind: event.kind, reason: "already_processed" };
673
+ }
674
+ }
675
+ if (event.kind === "message" && event.chat.isGroup) {
676
+ await (0, group_context_1.upsertGroupContextParticipants)({
677
+ store,
678
+ participants: (event.chat.participantHandles ?? []).map((externalId) => ({
679
+ provider: "imessage-handle",
680
+ externalId,
681
+ })),
682
+ groupExternalId: resolveGroupExternalId(event),
683
+ });
684
+ }
685
+ // Fetch the text of the message being replied to (if this is a threaded reply)
686
+ const threadGuid = event.kind === "message" ? event.threadOriginatorGuid?.trim() : undefined;
687
+ let repliedToText = null;
688
+ if (threadGuid) {
689
+ repliedToText = await client.getMessageText(threadGuid).catch(/* v8 ignore next */ () => null);
690
+ (0, runtime_1.emitNervesEvent)({
691
+ component: "senses",
692
+ event: "senses.bluebubbles_reply_context",
693
+ message: repliedToText ? "fetched replied-to message text" : "could not fetch replied-to message text",
694
+ meta: { threadGuid, hasText: !!repliedToText },
695
+ });
696
+ }
697
+ // Enrich reaction mutations with the original message text for context
698
+ const isReaction = event.kind === "mutation" && event.mutationType === "reaction";
699
+ if (isReaction && event.targetMessageGuid) {
700
+ /* v8 ignore start -- best-effort lookup; enrichReactionText covered by unit tests @preserve */
701
+ const originalText = await client.getMessageText(event.targetMessageGuid).catch(() => null);
702
+ if (originalText)
703
+ event.textForAgent = enrichReactionText(event.textForAgent, originalText, 80);
704
+ /* v8 ignore stop */
705
+ }
706
+ // Build inbound user message (adapter concern: BB-specific content formatting)
707
+ const userMessage = {
708
+ role: "user",
709
+ content: buildInboundContent(event, existing?.messages ?? sessionMessages, repliedToText),
710
+ };
711
+ const callbacks = createBlueBubblesCallbacks(client, event.chat, replyTarget, event.chat.isGroup);
712
+ const controller = new AbortController();
713
+ // BB-specific tool context wrappers
714
+ const summarize = (0, core_1.createSummarize)("human");
715
+ const bbCapabilities = (0, channel_1.getChannelCapabilities)("bluebubbles");
716
+ const pendingDir = (0, pending_1.getPendingDir)(resolvedDeps.getAgentName(), friendId, "bluebubbles", event.chat.sessionKey);
717
+ // ── Compute trust gate context for group/acquaintance rules ─────
718
+ const groupHasFamilyMember = await checkGroupHasFamilyMember(store, event);
719
+ const hasExistingGroupWithFamily = event.chat.isGroup
720
+ ? false
721
+ : await checkHasExistingGroupWithFamily(store, context.friend);
722
+ // ── Call shared pipeline ──────────────────────────────────────────
723
+ // Buffer terminal errors so failover can suppress them.
724
+ // If failover produces a message, the buffered error is skipped.
725
+ // If failover doesn't fire, the buffered error is replayed.
726
+ let bufferedTerminalError = null;
727
+ /* v8 ignore start -- failover-aware error buffering @preserve */
728
+ const failoverAwareCallbacks = {
729
+ ...callbacks,
730
+ onError(error, severity) {
731
+ if (severity === "terminal") {
732
+ bufferedTerminalError = error;
733
+ return;
734
+ }
735
+ callbacks.onError(error, severity);
736
+ },
737
+ };
738
+ /* v8 ignore stop */
739
+ try {
740
+ const result = await (0, pipeline_1.handleInboundTurn)({
741
+ channel: "bluebubbles",
742
+ sessionKey: event.chat.sessionKey,
743
+ capabilities: bbCapabilities,
744
+ messages: [userMessage],
745
+ continuityIngressTexts: getBlueBubblesContinuityIngressTexts(event),
746
+ friendResolver: { resolve: () => Promise.resolve(context) },
747
+ sessionLoader: {
748
+ loadOrCreate: () => Promise.resolve({
749
+ messages: sessionMessages,
750
+ sessionPath: sessPath,
751
+ state: existing?.state,
752
+ }),
753
+ },
754
+ pendingDir,
755
+ friendStore: store,
756
+ provider: "imessage-handle",
757
+ externalId: event.sender.externalId || event.sender.rawId,
758
+ isGroupChat: event.chat.isGroup,
759
+ groupHasFamilyMember,
760
+ hasExistingGroupWithFamily,
761
+ enforceTrustGate: trust_gate_1.enforceTrustGate,
762
+ drainPending: pending_1.drainPending,
763
+ drainDeferredReturns: (deferredFriendId) => (0, pending_1.drainDeferredReturns)(resolvedDeps.getAgentName(), deferredFriendId),
764
+ runAgent: (msgs, cb, channel, sig, opts) => resolvedDeps.runAgent(msgs, cb, channel, sig, {
765
+ ...opts,
766
+ toolContext: {
767
+ /* v8 ignore next -- default no-op signin; pipeline provides the real one @preserve */
768
+ signin: async () => undefined,
769
+ ...opts?.toolContext,
770
+ summarize,
771
+ bluebubblesReplyTarget: {
772
+ setSelection: (selection) => replyTarget.setSelection(selection),
773
+ },
774
+ codingFeedback: {
775
+ send: async (message) => {
776
+ await client.sendText({
777
+ chat: event.chat,
778
+ text: message,
779
+ replyToMessageGuid: replyTarget.getReplyToMessageGuid(),
780
+ });
781
+ },
782
+ },
783
+ },
784
+ }),
785
+ postTurn: resolvedDeps.postTurn,
786
+ accumulateFriendTokens: resolvedDeps.accumulateFriendTokens,
787
+ signal: controller.signal,
788
+ runAgentOptions: { mcpManager, ...(isReaction ? { isReactionSignal: true } : {}) },
789
+ callbacks: failoverAwareCallbacks,
790
+ failoverState: (() => {
791
+ if (!bbFailoverStates.has(event.chat.sessionKey)) {
792
+ bbFailoverStates.set(event.chat.sessionKey, { pending: null });
793
+ }
794
+ return bbFailoverStates.get(event.chat.sessionKey);
795
+ })(),
796
+ });
797
+ /* v8 ignore start -- failover display + error replay @preserve */
798
+ if (result.failoverMessage) {
799
+ // Failover handled it — show the failover message, skip the buffered error
800
+ await client.sendText({ chat: event.chat, text: result.failoverMessage });
801
+ }
802
+ else if (bufferedTerminalError) {
803
+ // No failover — replay the buffered terminal error
804
+ callbacks.onError(bufferedTerminalError, "terminal");
805
+ }
806
+ /* v8 ignore stop */
807
+ // ── Handle gate result ────────────────────────────────────────
808
+ if (!result.gateResult.allowed) {
809
+ // Send auto-reply via BB API if the gate provides one
810
+ if ("autoReply" in result.gateResult && result.gateResult.autoReply) {
811
+ await client.sendText({
812
+ chat: event.chat,
813
+ text: result.gateResult.autoReply,
814
+ });
815
+ }
816
+ if (event.kind === "message") {
817
+ (0, bluebubbles_inbound_log_1.recordBlueBubblesInbound)(resolvedDeps.getAgentName(), event, source);
818
+ }
819
+ return {
820
+ handled: true,
821
+ notifiedAgent: false,
822
+ kind: event.kind,
823
+ };
824
+ }
825
+ // Gate allowed — flush the agent's reply
826
+ await callbacks.flush();
827
+ if (event.kind === "message") {
828
+ (0, bluebubbles_inbound_log_1.recordBlueBubblesInbound)(resolvedDeps.getAgentName(), event, source);
829
+ }
830
+ (0, runtime_1.emitNervesEvent)({
831
+ component: "senses",
832
+ event: "senses.bluebubbles_turn_end",
833
+ message: "bluebubbles event handled",
834
+ meta: {
835
+ messageGuid: event.messageGuid,
836
+ kind: event.kind,
837
+ sessionKey: event.chat.sessionKey,
838
+ },
839
+ });
840
+ return {
841
+ handled: true,
842
+ notifiedAgent: true,
843
+ kind: event.kind,
844
+ };
845
+ }
846
+ finally {
847
+ // If a terminal error was buffered and never replayed (e.g., handleInboundTurn threw),
848
+ // replay it now so the user still sees the error.
849
+ /* v8 ignore start -- error replay on throw: tested via BB error test @preserve */
850
+ if (bufferedTerminalError) {
851
+ callbacks.onError(bufferedTerminalError, "terminal");
852
+ bufferedTerminalError = null;
853
+ }
854
+ /* v8 ignore stop */
855
+ await callbacks.finish();
856
+ }
857
+ });
858
+ }
859
+ async function handleBlueBubblesEvent(payload, deps = {}) {
860
+ const resolvedDeps = { ...defaultDeps, ...deps };
861
+ const client = resolvedDeps.createClient();
862
+ const event = await client.repairEvent((0, bluebubbles_model_1.normalizeBlueBubblesEvent)(payload));
863
+ return handleBlueBubblesNormalizedEvent(event, resolvedDeps, "webhook");
864
+ }
865
+ function countPendingRecoveryCandidates(agentName) {
866
+ return (0, bluebubbles_mutation_log_1.listBlueBubblesRecoveryCandidates)(agentName)
867
+ .filter((entry) => !(0, bluebubbles_inbound_log_1.hasRecordedBlueBubblesInbound)(agentName, entry.sessionKey, entry.messageGuid))
868
+ .length;
869
+ }
870
+ async function syncBlueBubblesRuntime(deps = {}) {
871
+ const resolvedDeps = { ...defaultDeps, ...deps };
872
+ const agentName = resolvedDeps.getAgentName();
873
+ const client = resolvedDeps.createClient();
874
+ const checkedAt = new Date().toISOString();
875
+ try {
876
+ await client.checkHealth();
877
+ const recovery = await recoverMissedBlueBubblesMessages(resolvedDeps);
878
+ (0, bluebubbles_runtime_state_1.writeBlueBubblesRuntimeState)(agentName, {
879
+ upstreamStatus: recovery.pending > 0 || recovery.failed > 0 ? "error" : "ok",
880
+ detail: recovery.failed > 0
881
+ ? `recovery failures: ${recovery.failed}`
882
+ : recovery.pending > 0
883
+ ? `pending recovery: ${recovery.pending}`
884
+ : "upstream reachable",
885
+ lastCheckedAt: checkedAt,
886
+ pendingRecoveryCount: recovery.pending,
887
+ lastRecoveredAt: recovery.recovered > 0 ? checkedAt : undefined,
888
+ });
889
+ }
890
+ catch (error) {
891
+ (0, bluebubbles_runtime_state_1.writeBlueBubblesRuntimeState)(agentName, {
892
+ upstreamStatus: "error",
893
+ detail: error instanceof Error ? error.message : String(error),
894
+ lastCheckedAt: checkedAt,
895
+ pendingRecoveryCount: countPendingRecoveryCandidates(agentName),
896
+ });
897
+ }
898
+ }
899
+ async function recoverMissedBlueBubblesMessages(deps = {}) {
900
+ const resolvedDeps = { ...defaultDeps, ...deps };
901
+ const agentName = resolvedDeps.getAgentName();
902
+ const client = resolvedDeps.createClient();
903
+ const result = { recovered: 0, skipped: 0, pending: 0, failed: 0 };
904
+ for (const candidate of (0, bluebubbles_mutation_log_1.listBlueBubblesRecoveryCandidates)(agentName)) {
905
+ if ((0, bluebubbles_inbound_log_1.hasRecordedBlueBubblesInbound)(agentName, candidate.sessionKey, candidate.messageGuid)) {
906
+ result.skipped++;
907
+ continue;
908
+ }
317
909
  try {
318
- await client.markChatRead(event.chat);
910
+ const repaired = await client.repairEvent(mutationEntryToEvent(candidate));
911
+ if (repaired.kind !== "message") {
912
+ result.pending++;
913
+ continue;
914
+ }
915
+ const handled = await handleBlueBubblesNormalizedEvent(repaired, resolvedDeps, "mutation-recovery");
916
+ if (handled.reason === "already_processed") {
917
+ result.skipped++;
918
+ }
919
+ else {
920
+ result.recovered++;
921
+ }
319
922
  }
320
923
  catch (error) {
924
+ result.failed++;
321
925
  (0, runtime_1.emitNervesEvent)({
322
926
  level: "warn",
323
927
  component: "senses",
324
- event: "senses.bluebubbles_mark_read_error",
325
- message: "failed to mark bluebubbles chat as read",
928
+ event: "senses.bluebubbles_recovery_error",
929
+ message: "bluebubbles backlog recovery failed",
326
930
  meta: {
327
- chatGuid: event.chat.chatGuid ?? null,
931
+ messageGuid: candidate.messageGuid,
328
932
  reason: error instanceof Error ? error.message : String(error),
329
933
  },
330
934
  });
331
935
  }
936
+ }
937
+ if (result.recovered > 0 || result.skipped > 0 || result.pending > 0 || result.failed > 0) {
332
938
  (0, runtime_1.emitNervesEvent)({
333
939
  component: "senses",
334
- event: "senses.bluebubbles_turn_end",
335
- message: "bluebubbles event handled",
336
- meta: {
337
- messageGuid: event.messageGuid,
338
- kind: event.kind,
339
- sessionKey: event.chat.sessionKey,
340
- },
940
+ event: "senses.bluebubbles_recovery_complete",
941
+ message: "bluebubbles backlog recovery pass completed",
942
+ meta: { ...result },
341
943
  });
342
- return {
343
- handled: true,
344
- notifiedAgent: true,
345
- kind: event.kind,
346
- };
347
- }
348
- finally {
349
- await callbacks.finish();
350
944
  }
945
+ return result;
351
946
  }
352
947
  function createBlueBubblesWebhookHandler(deps = {}) {
353
948
  return async (req, res) => {
@@ -404,11 +999,321 @@ function createBlueBubblesWebhookHandler(deps = {}) {
404
999
  }
405
1000
  };
406
1001
  }
1002
+ function findImessageHandle(friend) {
1003
+ for (const ext of friend.externalIds) {
1004
+ if (ext.provider === "imessage-handle" && !ext.externalId.startsWith("group:")) {
1005
+ return ext.externalId;
1006
+ }
1007
+ }
1008
+ return undefined;
1009
+ }
1010
+ function normalizeBlueBubblesSessionKey(sessionKey) {
1011
+ const trimmed = sessionKey.trim();
1012
+ if (trimmed.startsWith("chat_identifier_")) {
1013
+ return `chat_identifier:${trimmed.slice("chat_identifier_".length)}`;
1014
+ }
1015
+ if (trimmed.startsWith("chat_")) {
1016
+ return `chat:${trimmed.slice("chat_".length)}`;
1017
+ }
1018
+ return trimmed;
1019
+ }
1020
+ function extractChatIdentifierFromSessionKey(sessionKey) {
1021
+ const normalizedKey = normalizeBlueBubblesSessionKey(sessionKey);
1022
+ if (normalizedKey.startsWith("chat:")) {
1023
+ const chatGuid = normalizedKey.slice("chat:".length).trim();
1024
+ const parts = chatGuid.split(";");
1025
+ return parts.length >= 3 ? parts[2]?.trim() || undefined : undefined;
1026
+ }
1027
+ if (normalizedKey.startsWith("chat_identifier:")) {
1028
+ const identifier = normalizedKey.slice("chat_identifier:".length).trim();
1029
+ return identifier || undefined;
1030
+ }
1031
+ return undefined;
1032
+ }
1033
+ function buildChatRefForSessionKey(friend, sessionKey) {
1034
+ const normalizedKey = normalizeBlueBubblesSessionKey(sessionKey);
1035
+ if (normalizedKey.startsWith("chat:")) {
1036
+ const chatGuid = normalizedKey.slice("chat:".length).trim();
1037
+ if (!chatGuid)
1038
+ return null;
1039
+ return {
1040
+ chatGuid,
1041
+ chatIdentifier: extractChatIdentifierFromSessionKey(sessionKey) ?? findImessageHandle(friend),
1042
+ isGroup: chatGuid.includes(";+;"),
1043
+ sessionKey,
1044
+ sendTarget: { kind: "chat_guid", value: chatGuid },
1045
+ participantHandles: [],
1046
+ };
1047
+ }
1048
+ const chatIdentifier = extractChatIdentifierFromSessionKey(sessionKey) ?? findImessageHandle(friend);
1049
+ if (!chatIdentifier)
1050
+ return null;
1051
+ return {
1052
+ chatIdentifier,
1053
+ isGroup: false,
1054
+ sessionKey,
1055
+ sendTarget: { kind: "chat_identifier", value: chatIdentifier },
1056
+ participantHandles: [],
1057
+ };
1058
+ }
1059
+ async function sendProactiveBlueBubblesMessageToSession(params, deps = {}) {
1060
+ const resolvedDeps = { ...defaultDeps, ...deps };
1061
+ const client = resolvedDeps.createClient();
1062
+ const store = resolvedDeps.createFriendStore();
1063
+ let friend;
1064
+ try {
1065
+ friend = await store.get(params.friendId);
1066
+ }
1067
+ catch {
1068
+ friend = null;
1069
+ }
1070
+ if (!friend) {
1071
+ (0, runtime_1.emitNervesEvent)({
1072
+ level: "warn",
1073
+ component: "senses",
1074
+ event: "senses.bluebubbles_proactive_no_friend",
1075
+ message: "proactive send skipped: friend not found",
1076
+ meta: { friendId: params.friendId, sessionKey: params.sessionKey },
1077
+ });
1078
+ return { delivered: false, reason: "friend_not_found" };
1079
+ }
1080
+ const explicitCrossChatAuthorized = params.intent === "explicit_cross_chat"
1081
+ && types_1.TRUSTED_LEVELS.has(params.authorizingSession?.trustLevel ?? "stranger");
1082
+ if (!explicitCrossChatAuthorized && !types_1.TRUSTED_LEVELS.has(friend.trustLevel ?? "stranger")) {
1083
+ (0, runtime_1.emitNervesEvent)({
1084
+ component: "senses",
1085
+ event: "senses.bluebubbles_proactive_trust_skip",
1086
+ message: "proactive send skipped: trust level not allowed",
1087
+ meta: {
1088
+ friendId: params.friendId,
1089
+ sessionKey: params.sessionKey,
1090
+ trustLevel: friend.trustLevel ?? "unknown",
1091
+ intent: params.intent ?? "generic_outreach",
1092
+ authorizingTrustLevel: params.authorizingSession?.trustLevel ?? null,
1093
+ },
1094
+ });
1095
+ return { delivered: false, reason: "trust_skip" };
1096
+ }
1097
+ const chat = buildChatRefForSessionKey(friend, params.sessionKey);
1098
+ if (!chat) {
1099
+ (0, runtime_1.emitNervesEvent)({
1100
+ level: "warn",
1101
+ component: "senses",
1102
+ event: "senses.bluebubbles_proactive_no_handle",
1103
+ message: "proactive send skipped: no iMessage handle found",
1104
+ meta: { friendId: params.friendId, sessionKey: params.sessionKey },
1105
+ });
1106
+ return { delivered: false, reason: "missing_target" };
1107
+ }
1108
+ try {
1109
+ await client.sendText({ chat, text: params.text });
1110
+ (0, runtime_1.emitNervesEvent)({
1111
+ component: "senses",
1112
+ event: "senses.bluebubbles_proactive_sent",
1113
+ message: "proactive bluebubbles message sent",
1114
+ meta: {
1115
+ friendId: params.friendId,
1116
+ sessionKey: params.sessionKey,
1117
+ chatGuid: chat.chatGuid ?? null,
1118
+ chatIdentifier: chat.chatIdentifier ?? null,
1119
+ },
1120
+ });
1121
+ return { delivered: true };
1122
+ }
1123
+ catch (error) {
1124
+ (0, runtime_1.emitNervesEvent)({
1125
+ level: "error",
1126
+ component: "senses",
1127
+ event: "senses.bluebubbles_proactive_send_error",
1128
+ message: "proactive bluebubbles send failed",
1129
+ meta: {
1130
+ friendId: params.friendId,
1131
+ sessionKey: params.sessionKey,
1132
+ reason: error instanceof Error ? error.message : String(error),
1133
+ },
1134
+ });
1135
+ return { delivered: false, reason: "send_error" };
1136
+ }
1137
+ }
1138
+ function scanPendingBlueBubblesFiles(pendingRoot) {
1139
+ const results = [];
1140
+ let friendIds;
1141
+ try {
1142
+ friendIds = fs.readdirSync(pendingRoot);
1143
+ }
1144
+ catch {
1145
+ return results;
1146
+ }
1147
+ for (const friendId of friendIds) {
1148
+ const bbDir = path.join(pendingRoot, friendId, "bluebubbles");
1149
+ let keys;
1150
+ try {
1151
+ keys = fs.readdirSync(bbDir);
1152
+ }
1153
+ catch {
1154
+ continue;
1155
+ }
1156
+ for (const key of keys) {
1157
+ const keyDir = path.join(bbDir, key);
1158
+ let files;
1159
+ try {
1160
+ files = fs.readdirSync(keyDir);
1161
+ }
1162
+ catch {
1163
+ continue;
1164
+ }
1165
+ for (const file of files.filter((f) => f.endsWith(".json")).sort()) {
1166
+ const filePath = path.join(keyDir, file);
1167
+ try {
1168
+ const content = fs.readFileSync(filePath, "utf-8");
1169
+ results.push({ friendId, key, filePath, content });
1170
+ }
1171
+ catch {
1172
+ // skip unreadable files
1173
+ }
1174
+ }
1175
+ }
1176
+ }
1177
+ return results;
1178
+ }
1179
+ async function drainAndSendPendingBlueBubbles(deps = {}, pendingRoot) {
1180
+ const resolvedDeps = { ...defaultDeps, ...deps };
1181
+ const root = pendingRoot ?? path.join((0, identity_1.getAgentRoot)(), "state", "pending");
1182
+ const client = resolvedDeps.createClient();
1183
+ const store = resolvedDeps.createFriendStore();
1184
+ const pendingFiles = scanPendingBlueBubblesFiles(root);
1185
+ const result = { sent: 0, skipped: 0, failed: 0 };
1186
+ for (const { friendId, filePath, content } of pendingFiles) {
1187
+ let parsed;
1188
+ try {
1189
+ parsed = JSON.parse(content);
1190
+ }
1191
+ catch {
1192
+ result.failed++;
1193
+ try {
1194
+ fs.unlinkSync(filePath);
1195
+ }
1196
+ catch { /* ignore */ }
1197
+ continue;
1198
+ }
1199
+ const messageText = typeof parsed.content === "string" ? parsed.content : "";
1200
+ if (!messageText.trim()) {
1201
+ result.skipped++;
1202
+ try {
1203
+ fs.unlinkSync(filePath);
1204
+ }
1205
+ catch { /* ignore */ }
1206
+ continue;
1207
+ }
1208
+ let friend;
1209
+ try {
1210
+ friend = await store.get(friendId);
1211
+ }
1212
+ catch {
1213
+ friend = null;
1214
+ }
1215
+ if (!friend) {
1216
+ result.skipped++;
1217
+ try {
1218
+ fs.unlinkSync(filePath);
1219
+ }
1220
+ catch { /* ignore */ }
1221
+ (0, runtime_1.emitNervesEvent)({
1222
+ level: "warn",
1223
+ component: "senses",
1224
+ event: "senses.bluebubbles_proactive_no_friend",
1225
+ message: "proactive send skipped: friend not found",
1226
+ meta: { friendId },
1227
+ });
1228
+ continue;
1229
+ }
1230
+ if (!types_1.TRUSTED_LEVELS.has(friend.trustLevel ?? "stranger")) {
1231
+ result.skipped++;
1232
+ try {
1233
+ fs.unlinkSync(filePath);
1234
+ }
1235
+ catch { /* ignore */ }
1236
+ (0, runtime_1.emitNervesEvent)({
1237
+ component: "senses",
1238
+ event: "senses.bluebubbles_proactive_trust_skip",
1239
+ message: "proactive send skipped: trust level not allowed",
1240
+ meta: { friendId, trustLevel: friend.trustLevel ?? "unknown" },
1241
+ });
1242
+ continue;
1243
+ }
1244
+ const handle = findImessageHandle(friend);
1245
+ if (!handle) {
1246
+ result.skipped++;
1247
+ try {
1248
+ fs.unlinkSync(filePath);
1249
+ }
1250
+ catch { /* ignore */ }
1251
+ (0, runtime_1.emitNervesEvent)({
1252
+ level: "warn",
1253
+ component: "senses",
1254
+ event: "senses.bluebubbles_proactive_no_handle",
1255
+ message: "proactive send skipped: no iMessage handle found",
1256
+ meta: { friendId },
1257
+ });
1258
+ continue;
1259
+ }
1260
+ const chat = {
1261
+ chatIdentifier: handle,
1262
+ isGroup: false,
1263
+ sessionKey: friendId,
1264
+ sendTarget: { kind: "chat_identifier", value: handle },
1265
+ participantHandles: [],
1266
+ };
1267
+ try {
1268
+ await client.sendText({ chat, text: messageText });
1269
+ result.sent++;
1270
+ try {
1271
+ fs.unlinkSync(filePath);
1272
+ }
1273
+ catch { /* ignore */ }
1274
+ (0, runtime_1.emitNervesEvent)({
1275
+ component: "senses",
1276
+ event: "senses.bluebubbles_proactive_sent",
1277
+ message: "proactive bluebubbles message sent",
1278
+ meta: { friendId, handle },
1279
+ });
1280
+ }
1281
+ catch (error) {
1282
+ result.failed++;
1283
+ (0, runtime_1.emitNervesEvent)({
1284
+ level: "error",
1285
+ component: "senses",
1286
+ event: "senses.bluebubbles_proactive_send_error",
1287
+ message: "proactive bluebubbles send failed",
1288
+ meta: {
1289
+ friendId,
1290
+ handle,
1291
+ reason: error instanceof Error ? error.message : String(error),
1292
+ },
1293
+ });
1294
+ }
1295
+ }
1296
+ if (result.sent > 0 || result.skipped > 0 || result.failed > 0) {
1297
+ (0, runtime_1.emitNervesEvent)({
1298
+ component: "senses",
1299
+ event: "senses.bluebubbles_proactive_drain_complete",
1300
+ message: "bluebubbles proactive drain complete",
1301
+ meta: { sent: result.sent, skipped: result.skipped, failed: result.failed },
1302
+ });
1303
+ }
1304
+ return result;
1305
+ }
407
1306
  function startBlueBubblesApp(deps = {}) {
408
1307
  const resolvedDeps = { ...defaultDeps, ...deps };
409
1308
  resolvedDeps.createClient();
410
1309
  const channelConfig = (0, config_1.getBlueBubblesChannelConfig)();
411
1310
  const server = resolvedDeps.createServer(createBlueBubblesWebhookHandler(deps));
1311
+ const runtimeTimer = setInterval(() => {
1312
+ void syncBlueBubblesRuntime(resolvedDeps);
1313
+ }, BLUEBUBBLES_RUNTIME_SYNC_INTERVAL_MS);
1314
+ server.on?.("close", () => {
1315
+ clearInterval(runtimeTimer);
1316
+ });
412
1317
  server.listen(channelConfig.port, () => {
413
1318
  (0, runtime_1.emitNervesEvent)({
414
1319
  component: "channels",
@@ -417,5 +1322,6 @@ function startBlueBubblesApp(deps = {}) {
417
1322
  meta: { port: channelConfig.port, webhookPath: channelConfig.webhookPath },
418
1323
  });
419
1324
  });
1325
+ void syncBlueBubblesRuntime(resolvedDeps);
420
1326
  return server;
421
1327
  }