@ouro.bot/cli 0.1.0-alpha.8 → 0.1.0-alpha.81

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 (127) hide show
  1. package/AdoptionSpecialist.ouro/agent.json +70 -9
  2. package/AdoptionSpecialist.ouro/psyche/SOUL.md +5 -2
  3. package/AdoptionSpecialist.ouro/psyche/identities/monty.md +2 -2
  4. package/README.md +147 -205
  5. package/assets/ouroboros.png +0 -0
  6. package/changelog.json +468 -0
  7. package/dist/heart/active-work.js +218 -0
  8. package/dist/heart/bridges/manager.js +358 -0
  9. package/dist/heart/bridges/state-machine.js +135 -0
  10. package/dist/heart/bridges/store.js +123 -0
  11. package/dist/heart/commitments.js +89 -0
  12. package/dist/heart/config.js +68 -23
  13. package/dist/heart/core.js +452 -93
  14. package/dist/heart/cross-chat-delivery.js +146 -0
  15. package/dist/heart/daemon/agent-discovery.js +81 -0
  16. package/dist/heart/daemon/auth-flow.js +430 -0
  17. package/dist/heart/daemon/daemon-cli.js +1779 -247
  18. package/dist/heart/daemon/daemon-entry.js +55 -6
  19. package/dist/heart/daemon/daemon-runtime-sync.js +212 -0
  20. package/dist/heart/daemon/daemon.js +216 -10
  21. package/dist/heart/daemon/hatch-animation.js +10 -3
  22. package/dist/heart/daemon/hatch-flow.js +7 -82
  23. package/dist/heart/daemon/hooks/bundle-meta.js +92 -0
  24. package/dist/heart/daemon/launchd.js +159 -0
  25. package/dist/heart/daemon/log-tailer.js +4 -3
  26. package/dist/heart/daemon/message-router.js +17 -8
  27. package/dist/heart/daemon/ouro-bot-entry.js +0 -0
  28. package/dist/heart/daemon/ouro-bot-global-installer.js +128 -0
  29. package/dist/heart/daemon/ouro-entry.js +0 -0
  30. package/dist/heart/daemon/ouro-path-installer.js +260 -0
  31. package/dist/heart/daemon/ouro-uti.js +11 -2
  32. package/dist/heart/daemon/ouro-version-manager.js +164 -0
  33. package/dist/heart/daemon/process-manager.js +14 -1
  34. package/dist/heart/daemon/run-hooks.js +37 -0
  35. package/dist/heart/daemon/runtime-logging.js +58 -15
  36. package/dist/heart/daemon/runtime-metadata.js +219 -0
  37. package/dist/heart/daemon/runtime-mode.js +67 -0
  38. package/dist/heart/daemon/sense-manager.js +307 -0
  39. package/dist/heart/daemon/skill-management-installer.js +94 -0
  40. package/dist/heart/daemon/socket-client.js +202 -0
  41. package/dist/heart/daemon/specialist-orchestrator.js +53 -84
  42. package/dist/heart/daemon/specialist-prompt.js +63 -11
  43. package/dist/heart/daemon/specialist-tools.js +211 -60
  44. package/dist/heart/daemon/staged-restart.js +114 -0
  45. package/dist/heart/daemon/thoughts.js +507 -0
  46. package/dist/heart/daemon/update-checker.js +111 -0
  47. package/dist/heart/daemon/update-hooks.js +138 -0
  48. package/dist/heart/daemon/wrapper-publish-guard.js +86 -0
  49. package/dist/heart/delegation.js +62 -0
  50. package/dist/heart/identity.js +126 -21
  51. package/dist/heart/kicks.js +1 -19
  52. package/dist/heart/model-capabilities.js +48 -0
  53. package/dist/heart/obligations.js +141 -0
  54. package/dist/heart/progress-story.js +42 -0
  55. package/dist/heart/providers/anthropic.js +74 -9
  56. package/dist/heart/providers/azure.js +86 -7
  57. package/dist/heart/providers/github-copilot.js +149 -0
  58. package/dist/heart/providers/minimax.js +4 -0
  59. package/dist/heart/providers/openai-codex.js +12 -3
  60. package/dist/heart/safe-workspace.js +228 -0
  61. package/dist/heart/sense-truth.js +61 -0
  62. package/dist/heart/session-activity.js +169 -0
  63. package/dist/heart/session-recall.js +116 -0
  64. package/dist/heart/streaming.js +100 -22
  65. package/dist/heart/target-resolution.js +123 -0
  66. package/dist/heart/turn-coordinator.js +28 -0
  67. package/dist/mind/associative-recall.js +14 -2
  68. package/dist/mind/bundle-manifest.js +70 -0
  69. package/dist/mind/context.js +27 -11
  70. package/dist/mind/first-impressions.js +16 -2
  71. package/dist/mind/friends/channel.js +35 -0
  72. package/dist/mind/friends/group-context.js +144 -0
  73. package/dist/mind/friends/store-file.js +19 -0
  74. package/dist/mind/friends/trust-explanation.js +74 -0
  75. package/dist/mind/friends/types.js +8 -0
  76. package/dist/mind/memory.js +27 -26
  77. package/dist/mind/pending.js +76 -9
  78. package/dist/mind/phrases.js +1 -0
  79. package/dist/mind/prompt.js +445 -77
  80. package/dist/mind/token-estimate.js +8 -12
  81. package/dist/nerves/cli-logging.js +15 -2
  82. package/dist/nerves/coverage/run-artifacts.js +1 -1
  83. package/dist/nerves/index.js +12 -0
  84. package/dist/repertoire/ado-client.js +4 -2
  85. package/dist/repertoire/coding/feedback.js +134 -0
  86. package/dist/repertoire/coding/index.js +4 -1
  87. package/dist/repertoire/coding/manager.js +62 -4
  88. package/dist/repertoire/coding/spawner.js +3 -3
  89. package/dist/repertoire/coding/tools.js +41 -2
  90. package/dist/repertoire/data/ado-endpoints.json +188 -0
  91. package/dist/repertoire/guardrails.js +290 -0
  92. package/dist/repertoire/mcp-client.js +254 -0
  93. package/dist/repertoire/mcp-manager.js +195 -0
  94. package/dist/repertoire/skills.js +3 -26
  95. package/dist/repertoire/tasks/board.js +12 -0
  96. package/dist/repertoire/tasks/index.js +23 -9
  97. package/dist/repertoire/tasks/transitions.js +1 -2
  98. package/dist/repertoire/tools-base.js +686 -251
  99. package/dist/repertoire/tools-bluebubbles.js +93 -0
  100. package/dist/repertoire/tools-teams.js +58 -25
  101. package/dist/repertoire/tools.js +95 -53
  102. package/dist/senses/bluebubbles-client.js +210 -5
  103. package/dist/senses/bluebubbles-entry.js +2 -0
  104. package/dist/senses/bluebubbles-inbound-log.js +109 -0
  105. package/dist/senses/bluebubbles-media.js +339 -0
  106. package/dist/senses/bluebubbles-model.js +12 -4
  107. package/dist/senses/bluebubbles-mutation-log.js +45 -5
  108. package/dist/senses/bluebubbles-runtime-state.js +109 -0
  109. package/dist/senses/bluebubbles-session-cleanup.js +72 -0
  110. package/dist/senses/bluebubbles.js +894 -45
  111. package/dist/senses/cli-layout.js +187 -0
  112. package/dist/senses/cli.js +405 -156
  113. package/dist/senses/continuity.js +94 -0
  114. package/dist/senses/debug-activity.js +154 -0
  115. package/dist/senses/inner-dialog-worker.js +47 -18
  116. package/dist/senses/inner-dialog.js +377 -83
  117. package/dist/senses/pipeline.js +307 -0
  118. package/dist/senses/teams.js +573 -129
  119. package/dist/senses/trust-gate.js +112 -2
  120. package/package.json +14 -3
  121. package/subagents/README.md +4 -70
  122. package/dist/heart/daemon/specialist-session.js +0 -142
  123. package/dist/heart/daemon/subagent-installer.js +0 -125
  124. package/dist/inner-worker-entry.js +0 -4
  125. package/subagents/work-doer.md +0 -233
  126. package/subagents/work-merger.md +0 -624
  127. package/subagents/work-planner.md +0 -373
@@ -34,22 +34,39 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.handleBlueBubblesEvent = handleBlueBubblesEvent;
37
+ exports.recoverMissedBlueBubblesMessages = recoverMissedBlueBubblesMessages;
37
38
  exports.createBlueBubblesWebhookHandler = createBlueBubblesWebhookHandler;
39
+ exports.sendProactiveBlueBubblesMessageToSession = sendProactiveBlueBubblesMessageToSession;
40
+ exports.drainAndSendPendingBlueBubbles = drainAndSendPendingBlueBubbles;
38
41
  exports.startBlueBubblesApp = startBlueBubblesApp;
42
+ const fs = __importStar(require("node:fs"));
39
43
  const http = __importStar(require("node:http"));
40
44
  const path = __importStar(require("node:path"));
41
45
  const core_1 = require("../heart/core");
42
46
  const config_1 = require("../heart/config");
43
47
  const identity_1 = require("../heart/identity");
48
+ const turn_coordinator_1 = require("../heart/turn-coordinator");
44
49
  const context_1 = require("../mind/context");
45
50
  const tokens_1 = require("../mind/friends/tokens");
51
+ const group_context_1 = require("../mind/friends/group-context");
46
52
  const resolver_1 = require("../mind/friends/resolver");
47
53
  const store_file_1 = require("../mind/friends/store-file");
54
+ const types_1 = require("../mind/friends/types");
55
+ const channel_1 = require("../mind/friends/channel");
56
+ const pending_1 = require("../mind/pending");
48
57
  const prompt_1 = require("../mind/prompt");
58
+ const mcp_manager_1 = require("../repertoire/mcp-manager");
59
+ const phrases_1 = require("../mind/phrases");
49
60
  const runtime_1 = require("../nerves/runtime");
50
61
  const bluebubbles_model_1 = require("./bluebubbles-model");
51
62
  const bluebubbles_client_1 = require("./bluebubbles-client");
63
+ const bluebubbles_inbound_log_1 = require("./bluebubbles-inbound-log");
52
64
  const bluebubbles_mutation_log_1 = require("./bluebubbles-mutation-log");
65
+ const bluebubbles_runtime_state_1 = require("./bluebubbles-runtime-state");
66
+ const bluebubbles_session_cleanup_1 = require("./bluebubbles-session-cleanup");
67
+ const debug_activity_1 = require("./debug-activity");
68
+ const trust_gate_1 = require("./trust-gate");
69
+ const pipeline_1 = require("./pipeline");
53
70
  const defaultDeps = {
54
71
  getAgentName: identity_1.getAgentName,
55
72
  buildSystem: prompt_1.buildSystem,
@@ -64,6 +81,7 @@ const defaultDeps = {
64
81
  createFriendResolver: (store, params) => new resolver_1.FriendResolver(store, params),
65
82
  createServer: http.createServer,
66
83
  };
84
+ const BLUEBUBBLES_RUNTIME_SYNC_INTERVAL_MS = 30_000;
67
85
  function resolveFriendParams(event) {
68
86
  if (event.chat.isGroup) {
69
87
  const groupKey = event.chat.chatGuid ?? event.chat.chatIdentifier ?? event.sender.externalId;
@@ -81,21 +99,309 @@ function resolveFriendParams(event) {
81
99
  channel: "bluebubbles",
82
100
  };
83
101
  }
84
- function buildInboundText(event) {
102
+ function resolveGroupExternalId(event) {
103
+ const groupKey = event.chat.chatGuid ?? event.chat.chatIdentifier ?? event.sender.externalId;
104
+ return `group:${groupKey}`;
105
+ }
106
+ /**
107
+ * Check if any participant in a group chat is a known family member.
108
+ * Looks up each participant handle in the friend store.
109
+ */
110
+ async function checkGroupHasFamilyMember(store, event) {
111
+ if (!event.chat.isGroup)
112
+ return false;
113
+ for (const handle of event.chat.participantHandles ?? []) {
114
+ const friend = await store.findByExternalId("imessage-handle", handle);
115
+ if (friend?.trustLevel === "family")
116
+ return true;
117
+ }
118
+ return false;
119
+ }
120
+ /**
121
+ * Check if an acquaintance shares any group chat with a family member.
122
+ * Compares group-prefixed externalIds between the acquaintance and all family members.
123
+ */
124
+ async function checkHasExistingGroupWithFamily(store, senderFriend) {
125
+ const trustLevel = senderFriend.trustLevel ?? "friend";
126
+ if (trustLevel !== "acquaintance")
127
+ return false;
128
+ const acquaintanceGroups = new Set((senderFriend.externalIds ?? [])
129
+ .filter((eid) => eid.externalId.startsWith("group:"))
130
+ .map((eid) => eid.externalId));
131
+ if (acquaintanceGroups.size === 0)
132
+ return false;
133
+ const allFriends = await (store.listAll?.() ?? Promise.resolve([]));
134
+ for (const friend of allFriends) {
135
+ if (friend.trustLevel !== "family")
136
+ continue;
137
+ const friendGroups = (friend.externalIds ?? [])
138
+ .filter((eid) => eid.externalId.startsWith("group:"))
139
+ .map((eid) => eid.externalId);
140
+ for (const group of friendGroups) {
141
+ if (acquaintanceGroups.has(group))
142
+ return true;
143
+ }
144
+ }
145
+ return false;
146
+ }
147
+ function extractMessageText(content) {
148
+ if (typeof content === "string")
149
+ return content;
150
+ if (!Array.isArray(content))
151
+ return "";
152
+ return content
153
+ .map((part) => {
154
+ if (part && typeof part === "object" && "type" in part && part.type === "text" && typeof part.text === "string") {
155
+ return part.text;
156
+ }
157
+ return "";
158
+ })
159
+ .filter(Boolean)
160
+ .join("\n");
161
+ }
162
+ function isHistoricalLaneMetadataLine(line) {
163
+ return /^\[(conversation scope|recent active lanes|routing control):?/i.test(line)
164
+ || /^- (top_level|thread:[^:]+):/i.test(line);
165
+ }
166
+ function extractHistoricalLaneSummary(messages) {
167
+ const seen = new Set();
168
+ const summaries = [];
169
+ for (let index = messages.length - 1; index >= 0; index--) {
170
+ const message = messages[index];
171
+ if (message.role !== "user")
172
+ continue;
173
+ const text = extractMessageText(message.content);
174
+ if (!text)
175
+ continue;
176
+ const firstLine = text.split("\n")[0].trim();
177
+ const threadMatch = firstLine.match(/thread id: ([^\]|]+)/i);
178
+ const laneKey = threadMatch
179
+ ? `thread:${threadMatch[1].trim()}`
180
+ : /top[-_]level/i.test(firstLine)
181
+ ? "top_level"
182
+ : null;
183
+ if (!laneKey || seen.has(laneKey))
184
+ continue;
185
+ seen.add(laneKey);
186
+ const snippet = text
187
+ .split("\n")
188
+ .slice(1)
189
+ .map((line) => line.trim())
190
+ .find((line) => line.length > 0 && !isHistoricalLaneMetadataLine(line))
191
+ ?.slice(0, 80) ?? "(no recent text)";
192
+ summaries.push({
193
+ key: laneKey,
194
+ label: laneKey === "top_level" ? "top_level" : laneKey,
195
+ snippet,
196
+ });
197
+ if (summaries.length >= 5)
198
+ break;
199
+ }
200
+ return summaries;
201
+ }
202
+ function buildConversationScopePrefix(event, existingMessages) {
203
+ if (event.kind !== "message") {
204
+ return "";
205
+ }
206
+ const summaries = extractHistoricalLaneSummary(existingMessages);
207
+ const lines = [];
208
+ if (event.threadOriginatorGuid?.trim()) {
209
+ 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]`);
210
+ }
211
+ else {
212
+ lines.push("[conversation scope: existing chat trunk | current inbound lane: top_level | default outbound target for this turn: top_level]");
213
+ }
214
+ if (summaries.length > 0) {
215
+ lines.push("[recent active lanes]");
216
+ for (const summary of summaries) {
217
+ lines.push(`- ${summary.label}: ${summary.snippet}`);
218
+ }
219
+ }
220
+ if (event.threadOriginatorGuid?.trim() || summaries.some((summary) => summary.key.startsWith("thread:"))) {
221
+ 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]");
222
+ }
223
+ return lines.join("\n");
224
+ }
225
+ function buildInboundText(event, existingMessages) {
226
+ const metadataPrefix = buildConversationScopePrefix(event, existingMessages);
85
227
  const baseText = event.repairNotice?.trim()
86
228
  ? `${event.textForAgent}\n[${event.repairNotice.trim()}]`
87
229
  : event.textForAgent;
88
- if (!event.chat.isGroup)
89
- return baseText;
230
+ if (!event.chat.isGroup) {
231
+ return metadataPrefix ? `${metadataPrefix}\n${baseText}` : baseText;
232
+ }
233
+ const scopedText = metadataPrefix ? `${metadataPrefix}\n${baseText}` : baseText;
90
234
  if (event.kind === "mutation") {
91
- return `${event.sender.displayName} ${baseText}`;
235
+ return `${event.sender.displayName} ${scopedText}`;
236
+ }
237
+ return `${event.sender.displayName}: ${scopedText}`;
238
+ }
239
+ function buildInboundContent(event, existingMessages) {
240
+ const text = buildInboundText(event, existingMessages);
241
+ if (event.kind !== "message" || !event.inputPartsForAgent || event.inputPartsForAgent.length === 0) {
242
+ return text;
92
243
  }
93
- return `${event.sender.displayName}: ${baseText}`;
244
+ return [
245
+ { type: "text", text },
246
+ ...event.inputPartsForAgent,
247
+ ];
94
248
  }
95
- function createBlueBubblesCallbacks(client, chat, replyToMessageGuid) {
249
+ function sessionLikelyContainsMessage(event, existingMessages) {
250
+ const fragment = event.textForAgent.trim();
251
+ if (!fragment)
252
+ return false;
253
+ return existingMessages.some((message) => {
254
+ if (message.role !== "user")
255
+ return false;
256
+ return extractMessageText(message.content).includes(fragment);
257
+ });
258
+ }
259
+ function mutationEntryToEvent(entry) {
260
+ return {
261
+ kind: "mutation",
262
+ eventType: entry.eventType,
263
+ mutationType: entry.mutationType,
264
+ messageGuid: entry.messageGuid,
265
+ targetMessageGuid: entry.targetMessageGuid ?? undefined,
266
+ timestamp: Date.parse(entry.recordedAt) || Date.now(),
267
+ fromMe: entry.fromMe,
268
+ sender: {
269
+ provider: "imessage-handle",
270
+ externalId: entry.chatIdentifier ?? entry.chatGuid ?? "unknown",
271
+ rawId: entry.chatIdentifier ?? entry.chatGuid ?? "unknown",
272
+ displayName: entry.chatIdentifier ?? entry.chatGuid ?? "Unknown",
273
+ },
274
+ chat: {
275
+ chatGuid: entry.chatGuid ?? undefined,
276
+ chatIdentifier: entry.chatIdentifier ?? undefined,
277
+ displayName: undefined,
278
+ isGroup: Boolean(entry.chatGuid?.includes(";+;")),
279
+ sessionKey: entry.sessionKey,
280
+ sendTarget: entry.chatGuid
281
+ ? { kind: "chat_guid", value: entry.chatGuid }
282
+ : { kind: "chat_identifier", value: entry.chatIdentifier ?? "unknown" },
283
+ participantHandles: [],
284
+ },
285
+ shouldNotifyAgent: entry.shouldNotifyAgent,
286
+ textForAgent: entry.textForAgent,
287
+ requiresRepair: true,
288
+ };
289
+ }
290
+ function getBlueBubblesContinuityIngressTexts(event) {
291
+ if (event.kind !== "message")
292
+ return [];
293
+ const text = event.textForAgent.trim();
294
+ if (text.length > 0)
295
+ return [text];
296
+ const fallbackText = (event.inputPartsForAgent ?? [])
297
+ .map((part) => {
298
+ if (part.type === "text" && typeof part.text === "string") {
299
+ return part.text.trim();
300
+ }
301
+ return "";
302
+ })
303
+ .filter(Boolean)
304
+ .join("\n");
305
+ return fallbackText ? [fallbackText] : [];
306
+ }
307
+ function createReplyTargetController(event) {
308
+ const defaultTargetLabel = event.kind === "message" && event.threadOriginatorGuid?.trim() ? "current_lane" : "top_level";
309
+ let selection = event.kind === "message" && event.threadOriginatorGuid?.trim()
310
+ ? { target: "current_lane" }
311
+ : { target: "top_level" };
312
+ return {
313
+ getReplyToMessageGuid() {
314
+ if (event.kind !== "message")
315
+ return undefined;
316
+ if (selection.target === "top_level")
317
+ return undefined;
318
+ if (selection.target === "thread")
319
+ return selection.threadOriginatorGuid.trim();
320
+ return event.threadOriginatorGuid?.trim() ? event.messageGuid : undefined;
321
+ },
322
+ setSelection(next) {
323
+ selection = next;
324
+ if (next.target === "top_level") {
325
+ return "bluebubbles reply target override: top_level";
326
+ }
327
+ if (next.target === "thread") {
328
+ return `bluebubbles reply target override: thread:${next.threadOriginatorGuid}`;
329
+ }
330
+ return `bluebubbles reply target: using default for this turn (${defaultTargetLabel})`;
331
+ },
332
+ };
333
+ }
334
+ function emitBlueBubblesMarkReadWarning(chat, error) {
335
+ (0, runtime_1.emitNervesEvent)({
336
+ level: "warn",
337
+ component: "senses",
338
+ event: "senses.bluebubbles_mark_read_error",
339
+ message: "failed to mark bluebubbles chat as read",
340
+ meta: {
341
+ chatGuid: chat.chatGuid ?? null,
342
+ reason: error instanceof Error ? error.message : String(error),
343
+ },
344
+ });
345
+ }
346
+ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat) {
96
347
  let textBuffer = "";
348
+ const phrases = (0, phrases_1.getPhrases)();
349
+ const activity = (0, debug_activity_1.createDebugActivityController)({
350
+ thinkingPhrases: phrases.thinking,
351
+ followupPhrases: phrases.followup,
352
+ startTypingOnModelStart: !isGroupChat,
353
+ startTypingOnFirstTextChunk: isGroupChat,
354
+ suppressInitialModelStatus: true,
355
+ suppressFollowupPhraseStatus: true,
356
+ transport: {
357
+ sendStatus: async (text) => {
358
+ const sent = await client.sendText({
359
+ chat,
360
+ text,
361
+ replyToMessageGuid: replyTarget.getReplyToMessageGuid(),
362
+ });
363
+ return sent.messageGuid;
364
+ },
365
+ editStatus: async (_messageGuid, text) => {
366
+ await client.sendText({
367
+ chat,
368
+ text,
369
+ replyToMessageGuid: replyTarget.getReplyToMessageGuid(),
370
+ });
371
+ },
372
+ setTyping: async (active) => {
373
+ if (!active) {
374
+ await client.setTyping(chat, false);
375
+ return;
376
+ }
377
+ const [markReadResult, typingResult] = await Promise.allSettled([
378
+ client.markChatRead(chat),
379
+ client.setTyping(chat, true),
380
+ ]);
381
+ if (markReadResult.status === "rejected") {
382
+ emitBlueBubblesMarkReadWarning(chat, markReadResult.reason);
383
+ }
384
+ if (typingResult.status === "rejected") {
385
+ throw typingResult.reason;
386
+ }
387
+ },
388
+ },
389
+ onTransportError: (operation, error) => {
390
+ (0, runtime_1.emitNervesEvent)({
391
+ level: "warn",
392
+ component: "senses",
393
+ event: "senses.bluebubbles_activity_error",
394
+ message: "bluebubbles activity transport failed",
395
+ meta: {
396
+ operation,
397
+ reason: error instanceof Error ? error.message : String(error),
398
+ },
399
+ });
400
+ },
401
+ });
97
402
  return {
98
403
  onModelStart() {
404
+ activity.onModelStart();
99
405
  (0, runtime_1.emitNervesEvent)({
100
406
  component: "senses",
101
407
  event: "senses.bluebubbles_turn_start",
@@ -112,10 +418,12 @@ function createBlueBubblesCallbacks(client, chat, replyToMessageGuid) {
112
418
  });
113
419
  },
114
420
  onTextChunk(text) {
421
+ activity.onTextChunk(text);
115
422
  textBuffer += text;
116
423
  },
117
424
  onReasoningChunk(_text) { },
118
425
  onToolStart(name, _args) {
426
+ activity.onToolStart(name, _args);
119
427
  (0, runtime_1.emitNervesEvent)({
120
428
  component: "senses",
121
429
  event: "senses.bluebubbles_tool_start",
@@ -124,6 +432,7 @@ function createBlueBubblesCallbacks(client, chat, replyToMessageGuid) {
124
432
  });
125
433
  },
126
434
  onToolEnd(name, summary, success) {
435
+ activity.onToolEnd(name, summary, success);
127
436
  (0, runtime_1.emitNervesEvent)({
128
437
  component: "senses",
129
438
  event: "senses.bluebubbles_tool_end",
@@ -132,6 +441,7 @@ function createBlueBubblesCallbacks(client, chat, replyToMessageGuid) {
132
441
  });
133
442
  },
134
443
  onError(error, severity) {
444
+ activity.onError(error);
135
445
  (0, runtime_1.emitNervesEvent)({
136
446
  level: severity === "terminal" ? "error" : "warn",
137
447
  component: "senses",
@@ -144,17 +454,23 @@ function createBlueBubblesCallbacks(client, chat, replyToMessageGuid) {
144
454
  textBuffer = "";
145
455
  },
146
456
  async flush() {
457
+ await activity.drain();
147
458
  const trimmed = textBuffer.trim();
148
459
  if (!trimmed) {
460
+ await activity.finish();
149
461
  return;
150
462
  }
151
463
  textBuffer = "";
464
+ await activity.finish();
152
465
  await client.sendText({
153
466
  chat,
154
467
  text: trimmed,
155
- replyToMessageGuid,
468
+ replyToMessageGuid: replyTarget.getReplyToMessageGuid(),
156
469
  });
157
470
  },
471
+ async finish() {
472
+ await activity.finish();
473
+ },
158
474
  };
159
475
  }
160
476
  async function readRequestBody(req) {
@@ -173,10 +489,8 @@ function isWebhookPasswordValid(url, expectedPassword) {
173
489
  const provided = url.searchParams.get("password");
174
490
  return !provided || provided === expectedPassword;
175
491
  }
176
- async function handleBlueBubblesEvent(payload, deps = {}) {
177
- const resolvedDeps = { ...defaultDeps, ...deps };
492
+ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source) {
178
493
  const client = resolvedDeps.createClient();
179
- const event = await client.repairEvent((0, bluebubbles_model_1.normalizeBlueBubblesEvent)(payload));
180
494
  if (event.fromMe) {
181
495
  (0, runtime_1.emitNervesEvent)({
182
496
  component: "senses",
@@ -219,46 +533,270 @@ async function handleBlueBubblesEvent(payload, deps = {}) {
219
533
  });
220
534
  return { handled: true, notifiedAgent: false, kind: event.kind, reason: "mutation_state_only" };
221
535
  }
536
+ // ── Adapter setup: friend, session, content, callbacks ──────────
222
537
  const store = resolvedDeps.createFriendStore();
223
538
  const resolver = resolvedDeps.createFriendResolver(store, resolveFriendParams(event));
224
- const context = await resolver.resolve();
225
- const toolContext = {
226
- signin: async () => undefined,
227
- friendStore: store,
228
- summarize: (0, core_1.createSummarize)(),
229
- context,
230
- };
539
+ const baseContext = await resolver.resolve();
540
+ const context = { ...baseContext, isGroupChat: event.chat.isGroup };
541
+ const replyTarget = createReplyTargetController(event);
231
542
  const friendId = context.friend.id;
232
543
  const sessPath = resolvedDeps.sessionPath(friendId, "bluebubbles", event.chat.sessionKey);
233
- const existing = resolvedDeps.loadSession(sessPath);
234
- const messages = existing?.messages && existing.messages.length > 0
235
- ? existing.messages
236
- : [{ role: "system", content: await resolvedDeps.buildSystem("bluebubbles", undefined, context) }];
237
- messages.push({ role: "user", content: buildInboundText(event) });
238
- const callbacks = createBlueBubblesCallbacks(client, event.chat, event.kind === "message" ? event.messageGuid : undefined);
239
- const controller = new AbortController();
240
- const agentOptions = {
241
- toolContext,
242
- };
243
- const result = await resolvedDeps.runAgent(messages, callbacks, "bluebubbles", controller.signal, agentOptions);
244
- await callbacks.flush();
245
- resolvedDeps.postTurn(messages, sessPath, result.usage);
246
- await resolvedDeps.accumulateFriendTokens(store, friendId, result.usage);
247
- (0, runtime_1.emitNervesEvent)({
248
- component: "senses",
249
- event: "senses.bluebubbles_turn_end",
250
- message: "bluebubbles event handled",
251
- meta: {
252
- messageGuid: event.messageGuid,
253
- kind: event.kind,
254
- sessionKey: event.chat.sessionKey,
255
- },
544
+ try {
545
+ (0, bluebubbles_session_cleanup_1.findObsoleteBlueBubblesThreadSessions)(sessPath);
546
+ }
547
+ catch (error) {
548
+ (0, runtime_1.emitNervesEvent)({
549
+ level: "warn",
550
+ component: "senses",
551
+ event: "senses.bluebubbles_thread_lane_cleanup_error",
552
+ message: "failed to inspect obsolete bluebubbles thread-lane sessions",
553
+ meta: {
554
+ sessionPath: sessPath,
555
+ reason: error instanceof Error ? error.message : String(error),
556
+ },
557
+ });
558
+ }
559
+ return (0, turn_coordinator_1.withSharedTurnLock)("bluebubbles", sessPath, async () => {
560
+ // Pre-load session inside the turn lock so same-chat deliveries cannot race on stale trunk state.
561
+ const existing = resolvedDeps.loadSession(sessPath);
562
+ const mcpManager = await (0, mcp_manager_1.getSharedMcpManager)() ?? undefined;
563
+ const sessionMessages = existing?.messages && existing.messages.length > 0
564
+ ? existing.messages
565
+ : [{ role: "system", content: await resolvedDeps.buildSystem("bluebubbles", { mcpManager }, context) }];
566
+ if (event.kind === "message") {
567
+ const agentName = resolvedDeps.getAgentName();
568
+ if ((0, bluebubbles_inbound_log_1.hasRecordedBlueBubblesInbound)(agentName, event.chat.sessionKey, event.messageGuid)) {
569
+ (0, runtime_1.emitNervesEvent)({
570
+ component: "senses",
571
+ event: "senses.bluebubbles_recovery_skip",
572
+ message: "skipped bluebubbles message already recorded as handled",
573
+ meta: {
574
+ messageGuid: event.messageGuid,
575
+ sessionKey: event.chat.sessionKey,
576
+ source,
577
+ },
578
+ });
579
+ return { handled: true, notifiedAgent: false, kind: event.kind, reason: "already_processed" };
580
+ }
581
+ if (source !== "webhook" && sessionLikelyContainsMessage(event, existing?.messages ?? sessionMessages)) {
582
+ (0, bluebubbles_inbound_log_1.recordBlueBubblesInbound)(agentName, event, "recovery-bootstrap");
583
+ (0, runtime_1.emitNervesEvent)({
584
+ component: "senses",
585
+ event: "senses.bluebubbles_recovery_skip",
586
+ message: "skipped bluebubbles recovery because the session already contains the message text",
587
+ meta: {
588
+ messageGuid: event.messageGuid,
589
+ sessionKey: event.chat.sessionKey,
590
+ source,
591
+ },
592
+ });
593
+ return { handled: true, notifiedAgent: false, kind: event.kind, reason: "already_processed" };
594
+ }
595
+ }
596
+ if (event.kind === "message" && event.chat.isGroup) {
597
+ await (0, group_context_1.upsertGroupContextParticipants)({
598
+ store,
599
+ participants: (event.chat.participantHandles ?? []).map((externalId) => ({
600
+ provider: "imessage-handle",
601
+ externalId,
602
+ })),
603
+ groupExternalId: resolveGroupExternalId(event),
604
+ });
605
+ }
606
+ // Build inbound user message (adapter concern: BB-specific content formatting)
607
+ const userMessage = {
608
+ role: "user",
609
+ content: buildInboundContent(event, existing?.messages ?? sessionMessages),
610
+ };
611
+ const callbacks = createBlueBubblesCallbacks(client, event.chat, replyTarget, event.chat.isGroup);
612
+ const controller = new AbortController();
613
+ // BB-specific tool context wrappers
614
+ const summarize = (0, core_1.createSummarize)();
615
+ const bbCapabilities = (0, channel_1.getChannelCapabilities)("bluebubbles");
616
+ const pendingDir = (0, pending_1.getPendingDir)(resolvedDeps.getAgentName(), friendId, "bluebubbles", event.chat.sessionKey);
617
+ // ── Compute trust gate context for group/acquaintance rules ─────
618
+ const groupHasFamilyMember = await checkGroupHasFamilyMember(store, event);
619
+ const hasExistingGroupWithFamily = event.chat.isGroup
620
+ ? false
621
+ : await checkHasExistingGroupWithFamily(store, context.friend);
622
+ // ── Call shared pipeline ──────────────────────────────────────────
623
+ try {
624
+ const result = await (0, pipeline_1.handleInboundTurn)({
625
+ channel: "bluebubbles",
626
+ sessionKey: event.chat.sessionKey,
627
+ capabilities: bbCapabilities,
628
+ messages: [userMessage],
629
+ continuityIngressTexts: getBlueBubblesContinuityIngressTexts(event),
630
+ callbacks,
631
+ friendResolver: { resolve: () => Promise.resolve(context) },
632
+ sessionLoader: { loadOrCreate: () => Promise.resolve({ messages: sessionMessages, sessionPath: sessPath, state: existing?.state }) },
633
+ pendingDir,
634
+ friendStore: store,
635
+ provider: "imessage-handle",
636
+ externalId: event.sender.externalId || event.sender.rawId,
637
+ isGroupChat: event.chat.isGroup,
638
+ groupHasFamilyMember,
639
+ hasExistingGroupWithFamily,
640
+ enforceTrustGate: trust_gate_1.enforceTrustGate,
641
+ drainPending: pending_1.drainPending,
642
+ drainDeferredReturns: (deferredFriendId) => (0, pending_1.drainDeferredReturns)(resolvedDeps.getAgentName(), deferredFriendId),
643
+ runAgent: (msgs, cb, channel, sig, opts) => resolvedDeps.runAgent(msgs, cb, channel, sig, {
644
+ ...opts,
645
+ toolContext: {
646
+ /* v8 ignore next -- default no-op signin; pipeline provides the real one @preserve */
647
+ signin: async () => undefined,
648
+ ...opts?.toolContext,
649
+ summarize,
650
+ bluebubblesReplyTarget: {
651
+ setSelection: (selection) => replyTarget.setSelection(selection),
652
+ },
653
+ codingFeedback: {
654
+ send: async (message) => {
655
+ await client.sendText({
656
+ chat: event.chat,
657
+ text: message,
658
+ replyToMessageGuid: replyTarget.getReplyToMessageGuid(),
659
+ });
660
+ },
661
+ },
662
+ },
663
+ }),
664
+ postTurn: resolvedDeps.postTurn,
665
+ accumulateFriendTokens: resolvedDeps.accumulateFriendTokens,
666
+ signal: controller.signal,
667
+ runAgentOptions: { mcpManager },
668
+ });
669
+ // ── Handle gate result ────────────────────────────────────────
670
+ if (!result.gateResult.allowed) {
671
+ // Send auto-reply via BB API if the gate provides one
672
+ if ("autoReply" in result.gateResult && result.gateResult.autoReply) {
673
+ await client.sendText({
674
+ chat: event.chat,
675
+ text: result.gateResult.autoReply,
676
+ });
677
+ }
678
+ if (event.kind === "message") {
679
+ (0, bluebubbles_inbound_log_1.recordBlueBubblesInbound)(resolvedDeps.getAgentName(), event, source);
680
+ }
681
+ return {
682
+ handled: true,
683
+ notifiedAgent: false,
684
+ kind: event.kind,
685
+ };
686
+ }
687
+ // Gate allowed — flush the agent's reply
688
+ await callbacks.flush();
689
+ if (event.kind === "message") {
690
+ (0, bluebubbles_inbound_log_1.recordBlueBubblesInbound)(resolvedDeps.getAgentName(), event, source);
691
+ }
692
+ (0, runtime_1.emitNervesEvent)({
693
+ component: "senses",
694
+ event: "senses.bluebubbles_turn_end",
695
+ message: "bluebubbles event handled",
696
+ meta: {
697
+ messageGuid: event.messageGuid,
698
+ kind: event.kind,
699
+ sessionKey: event.chat.sessionKey,
700
+ },
701
+ });
702
+ return {
703
+ handled: true,
704
+ notifiedAgent: true,
705
+ kind: event.kind,
706
+ };
707
+ }
708
+ finally {
709
+ await callbacks.finish();
710
+ }
256
711
  });
257
- return {
258
- handled: true,
259
- notifiedAgent: true,
260
- kind: event.kind,
261
- };
712
+ }
713
+ async function handleBlueBubblesEvent(payload, deps = {}) {
714
+ const resolvedDeps = { ...defaultDeps, ...deps };
715
+ const client = resolvedDeps.createClient();
716
+ const event = await client.repairEvent((0, bluebubbles_model_1.normalizeBlueBubblesEvent)(payload));
717
+ return handleBlueBubblesNormalizedEvent(event, resolvedDeps, "webhook");
718
+ }
719
+ function countPendingRecoveryCandidates(agentName) {
720
+ return (0, bluebubbles_mutation_log_1.listBlueBubblesRecoveryCandidates)(agentName)
721
+ .filter((entry) => !(0, bluebubbles_inbound_log_1.hasRecordedBlueBubblesInbound)(agentName, entry.sessionKey, entry.messageGuid))
722
+ .length;
723
+ }
724
+ async function syncBlueBubblesRuntime(deps = {}) {
725
+ const resolvedDeps = { ...defaultDeps, ...deps };
726
+ const agentName = resolvedDeps.getAgentName();
727
+ const client = resolvedDeps.createClient();
728
+ const checkedAt = new Date().toISOString();
729
+ try {
730
+ await client.checkHealth();
731
+ const recovery = await recoverMissedBlueBubblesMessages(resolvedDeps);
732
+ (0, bluebubbles_runtime_state_1.writeBlueBubblesRuntimeState)(agentName, {
733
+ upstreamStatus: recovery.pending > 0 || recovery.failed > 0 ? "error" : "ok",
734
+ detail: recovery.failed > 0
735
+ ? `recovery failures: ${recovery.failed}`
736
+ : recovery.pending > 0
737
+ ? `pending recovery: ${recovery.pending}`
738
+ : "upstream reachable",
739
+ lastCheckedAt: checkedAt,
740
+ pendingRecoveryCount: recovery.pending,
741
+ lastRecoveredAt: recovery.recovered > 0 ? checkedAt : undefined,
742
+ });
743
+ }
744
+ catch (error) {
745
+ (0, bluebubbles_runtime_state_1.writeBlueBubblesRuntimeState)(agentName, {
746
+ upstreamStatus: "error",
747
+ detail: error instanceof Error ? error.message : String(error),
748
+ lastCheckedAt: checkedAt,
749
+ pendingRecoveryCount: countPendingRecoveryCandidates(agentName),
750
+ });
751
+ }
752
+ }
753
+ async function recoverMissedBlueBubblesMessages(deps = {}) {
754
+ const resolvedDeps = { ...defaultDeps, ...deps };
755
+ const agentName = resolvedDeps.getAgentName();
756
+ const client = resolvedDeps.createClient();
757
+ const result = { recovered: 0, skipped: 0, pending: 0, failed: 0 };
758
+ for (const candidate of (0, bluebubbles_mutation_log_1.listBlueBubblesRecoveryCandidates)(agentName)) {
759
+ if ((0, bluebubbles_inbound_log_1.hasRecordedBlueBubblesInbound)(agentName, candidate.sessionKey, candidate.messageGuid)) {
760
+ result.skipped++;
761
+ continue;
762
+ }
763
+ try {
764
+ const repaired = await client.repairEvent(mutationEntryToEvent(candidate));
765
+ if (repaired.kind !== "message") {
766
+ result.pending++;
767
+ continue;
768
+ }
769
+ const handled = await handleBlueBubblesNormalizedEvent(repaired, resolvedDeps, "mutation-recovery");
770
+ if (handled.reason === "already_processed") {
771
+ result.skipped++;
772
+ }
773
+ else {
774
+ result.recovered++;
775
+ }
776
+ }
777
+ catch (error) {
778
+ result.failed++;
779
+ (0, runtime_1.emitNervesEvent)({
780
+ level: "warn",
781
+ component: "senses",
782
+ event: "senses.bluebubbles_recovery_error",
783
+ message: "bluebubbles backlog recovery failed",
784
+ meta: {
785
+ messageGuid: candidate.messageGuid,
786
+ reason: error instanceof Error ? error.message : String(error),
787
+ },
788
+ });
789
+ }
790
+ }
791
+ if (result.recovered > 0 || result.skipped > 0 || result.pending > 0 || result.failed > 0) {
792
+ (0, runtime_1.emitNervesEvent)({
793
+ component: "senses",
794
+ event: "senses.bluebubbles_recovery_complete",
795
+ message: "bluebubbles backlog recovery pass completed",
796
+ meta: { ...result },
797
+ });
798
+ }
799
+ return result;
262
800
  }
263
801
  function createBlueBubblesWebhookHandler(deps = {}) {
264
802
  return async (req, res) => {
@@ -315,11 +853,321 @@ function createBlueBubblesWebhookHandler(deps = {}) {
315
853
  }
316
854
  };
317
855
  }
856
+ function findImessageHandle(friend) {
857
+ for (const ext of friend.externalIds) {
858
+ if (ext.provider === "imessage-handle" && !ext.externalId.startsWith("group:")) {
859
+ return ext.externalId;
860
+ }
861
+ }
862
+ return undefined;
863
+ }
864
+ function normalizeBlueBubblesSessionKey(sessionKey) {
865
+ const trimmed = sessionKey.trim();
866
+ if (trimmed.startsWith("chat_identifier_")) {
867
+ return `chat_identifier:${trimmed.slice("chat_identifier_".length)}`;
868
+ }
869
+ if (trimmed.startsWith("chat_")) {
870
+ return `chat:${trimmed.slice("chat_".length)}`;
871
+ }
872
+ return trimmed;
873
+ }
874
+ function extractChatIdentifierFromSessionKey(sessionKey) {
875
+ const normalizedKey = normalizeBlueBubblesSessionKey(sessionKey);
876
+ if (normalizedKey.startsWith("chat:")) {
877
+ const chatGuid = normalizedKey.slice("chat:".length).trim();
878
+ const parts = chatGuid.split(";");
879
+ return parts.length >= 3 ? parts[2]?.trim() || undefined : undefined;
880
+ }
881
+ if (normalizedKey.startsWith("chat_identifier:")) {
882
+ const identifier = normalizedKey.slice("chat_identifier:".length).trim();
883
+ return identifier || undefined;
884
+ }
885
+ return undefined;
886
+ }
887
+ function buildChatRefForSessionKey(friend, sessionKey) {
888
+ const normalizedKey = normalizeBlueBubblesSessionKey(sessionKey);
889
+ if (normalizedKey.startsWith("chat:")) {
890
+ const chatGuid = normalizedKey.slice("chat:".length).trim();
891
+ if (!chatGuid)
892
+ return null;
893
+ return {
894
+ chatGuid,
895
+ chatIdentifier: extractChatIdentifierFromSessionKey(sessionKey) ?? findImessageHandle(friend),
896
+ isGroup: chatGuid.includes(";+;"),
897
+ sessionKey,
898
+ sendTarget: { kind: "chat_guid", value: chatGuid },
899
+ participantHandles: [],
900
+ };
901
+ }
902
+ const chatIdentifier = extractChatIdentifierFromSessionKey(sessionKey) ?? findImessageHandle(friend);
903
+ if (!chatIdentifier)
904
+ return null;
905
+ return {
906
+ chatIdentifier,
907
+ isGroup: false,
908
+ sessionKey,
909
+ sendTarget: { kind: "chat_identifier", value: chatIdentifier },
910
+ participantHandles: [],
911
+ };
912
+ }
913
+ async function sendProactiveBlueBubblesMessageToSession(params, deps = {}) {
914
+ const resolvedDeps = { ...defaultDeps, ...deps };
915
+ const client = resolvedDeps.createClient();
916
+ const store = resolvedDeps.createFriendStore();
917
+ let friend;
918
+ try {
919
+ friend = await store.get(params.friendId);
920
+ }
921
+ catch {
922
+ friend = null;
923
+ }
924
+ if (!friend) {
925
+ (0, runtime_1.emitNervesEvent)({
926
+ level: "warn",
927
+ component: "senses",
928
+ event: "senses.bluebubbles_proactive_no_friend",
929
+ message: "proactive send skipped: friend not found",
930
+ meta: { friendId: params.friendId, sessionKey: params.sessionKey },
931
+ });
932
+ return { delivered: false, reason: "friend_not_found" };
933
+ }
934
+ const explicitCrossChatAuthorized = params.intent === "explicit_cross_chat"
935
+ && types_1.TRUSTED_LEVELS.has(params.authorizingSession?.trustLevel ?? "stranger");
936
+ if (!explicitCrossChatAuthorized && !types_1.TRUSTED_LEVELS.has(friend.trustLevel ?? "stranger")) {
937
+ (0, runtime_1.emitNervesEvent)({
938
+ component: "senses",
939
+ event: "senses.bluebubbles_proactive_trust_skip",
940
+ message: "proactive send skipped: trust level not allowed",
941
+ meta: {
942
+ friendId: params.friendId,
943
+ sessionKey: params.sessionKey,
944
+ trustLevel: friend.trustLevel ?? "unknown",
945
+ intent: params.intent ?? "generic_outreach",
946
+ authorizingTrustLevel: params.authorizingSession?.trustLevel ?? null,
947
+ },
948
+ });
949
+ return { delivered: false, reason: "trust_skip" };
950
+ }
951
+ const chat = buildChatRefForSessionKey(friend, params.sessionKey);
952
+ if (!chat) {
953
+ (0, runtime_1.emitNervesEvent)({
954
+ level: "warn",
955
+ component: "senses",
956
+ event: "senses.bluebubbles_proactive_no_handle",
957
+ message: "proactive send skipped: no iMessage handle found",
958
+ meta: { friendId: params.friendId, sessionKey: params.sessionKey },
959
+ });
960
+ return { delivered: false, reason: "missing_target" };
961
+ }
962
+ try {
963
+ await client.sendText({ chat, text: params.text });
964
+ (0, runtime_1.emitNervesEvent)({
965
+ component: "senses",
966
+ event: "senses.bluebubbles_proactive_sent",
967
+ message: "proactive bluebubbles message sent",
968
+ meta: {
969
+ friendId: params.friendId,
970
+ sessionKey: params.sessionKey,
971
+ chatGuid: chat.chatGuid ?? null,
972
+ chatIdentifier: chat.chatIdentifier ?? null,
973
+ },
974
+ });
975
+ return { delivered: true };
976
+ }
977
+ catch (error) {
978
+ (0, runtime_1.emitNervesEvent)({
979
+ level: "error",
980
+ component: "senses",
981
+ event: "senses.bluebubbles_proactive_send_error",
982
+ message: "proactive bluebubbles send failed",
983
+ meta: {
984
+ friendId: params.friendId,
985
+ sessionKey: params.sessionKey,
986
+ reason: error instanceof Error ? error.message : String(error),
987
+ },
988
+ });
989
+ return { delivered: false, reason: "send_error" };
990
+ }
991
+ }
992
+ function scanPendingBlueBubblesFiles(pendingRoot) {
993
+ const results = [];
994
+ let friendIds;
995
+ try {
996
+ friendIds = fs.readdirSync(pendingRoot);
997
+ }
998
+ catch {
999
+ return results;
1000
+ }
1001
+ for (const friendId of friendIds) {
1002
+ const bbDir = path.join(pendingRoot, friendId, "bluebubbles");
1003
+ let keys;
1004
+ try {
1005
+ keys = fs.readdirSync(bbDir);
1006
+ }
1007
+ catch {
1008
+ continue;
1009
+ }
1010
+ for (const key of keys) {
1011
+ const keyDir = path.join(bbDir, key);
1012
+ let files;
1013
+ try {
1014
+ files = fs.readdirSync(keyDir);
1015
+ }
1016
+ catch {
1017
+ continue;
1018
+ }
1019
+ for (const file of files.filter((f) => f.endsWith(".json")).sort()) {
1020
+ const filePath = path.join(keyDir, file);
1021
+ try {
1022
+ const content = fs.readFileSync(filePath, "utf-8");
1023
+ results.push({ friendId, key, filePath, content });
1024
+ }
1025
+ catch {
1026
+ // skip unreadable files
1027
+ }
1028
+ }
1029
+ }
1030
+ }
1031
+ return results;
1032
+ }
1033
+ async function drainAndSendPendingBlueBubbles(deps = {}, pendingRoot) {
1034
+ const resolvedDeps = { ...defaultDeps, ...deps };
1035
+ const root = pendingRoot ?? path.join((0, identity_1.getAgentRoot)(), "state", "pending");
1036
+ const client = resolvedDeps.createClient();
1037
+ const store = resolvedDeps.createFriendStore();
1038
+ const pendingFiles = scanPendingBlueBubblesFiles(root);
1039
+ const result = { sent: 0, skipped: 0, failed: 0 };
1040
+ for (const { friendId, filePath, content } of pendingFiles) {
1041
+ let parsed;
1042
+ try {
1043
+ parsed = JSON.parse(content);
1044
+ }
1045
+ catch {
1046
+ result.failed++;
1047
+ try {
1048
+ fs.unlinkSync(filePath);
1049
+ }
1050
+ catch { /* ignore */ }
1051
+ continue;
1052
+ }
1053
+ const messageText = typeof parsed.content === "string" ? parsed.content : "";
1054
+ if (!messageText.trim()) {
1055
+ result.skipped++;
1056
+ try {
1057
+ fs.unlinkSync(filePath);
1058
+ }
1059
+ catch { /* ignore */ }
1060
+ continue;
1061
+ }
1062
+ let friend;
1063
+ try {
1064
+ friend = await store.get(friendId);
1065
+ }
1066
+ catch {
1067
+ friend = null;
1068
+ }
1069
+ if (!friend) {
1070
+ result.skipped++;
1071
+ try {
1072
+ fs.unlinkSync(filePath);
1073
+ }
1074
+ catch { /* ignore */ }
1075
+ (0, runtime_1.emitNervesEvent)({
1076
+ level: "warn",
1077
+ component: "senses",
1078
+ event: "senses.bluebubbles_proactive_no_friend",
1079
+ message: "proactive send skipped: friend not found",
1080
+ meta: { friendId },
1081
+ });
1082
+ continue;
1083
+ }
1084
+ if (!types_1.TRUSTED_LEVELS.has(friend.trustLevel ?? "stranger")) {
1085
+ result.skipped++;
1086
+ try {
1087
+ fs.unlinkSync(filePath);
1088
+ }
1089
+ catch { /* ignore */ }
1090
+ (0, runtime_1.emitNervesEvent)({
1091
+ component: "senses",
1092
+ event: "senses.bluebubbles_proactive_trust_skip",
1093
+ message: "proactive send skipped: trust level not allowed",
1094
+ meta: { friendId, trustLevel: friend.trustLevel ?? "unknown" },
1095
+ });
1096
+ continue;
1097
+ }
1098
+ const handle = findImessageHandle(friend);
1099
+ if (!handle) {
1100
+ result.skipped++;
1101
+ try {
1102
+ fs.unlinkSync(filePath);
1103
+ }
1104
+ catch { /* ignore */ }
1105
+ (0, runtime_1.emitNervesEvent)({
1106
+ level: "warn",
1107
+ component: "senses",
1108
+ event: "senses.bluebubbles_proactive_no_handle",
1109
+ message: "proactive send skipped: no iMessage handle found",
1110
+ meta: { friendId },
1111
+ });
1112
+ continue;
1113
+ }
1114
+ const chat = {
1115
+ chatIdentifier: handle,
1116
+ isGroup: false,
1117
+ sessionKey: friendId,
1118
+ sendTarget: { kind: "chat_identifier", value: handle },
1119
+ participantHandles: [],
1120
+ };
1121
+ try {
1122
+ await client.sendText({ chat, text: messageText });
1123
+ result.sent++;
1124
+ try {
1125
+ fs.unlinkSync(filePath);
1126
+ }
1127
+ catch { /* ignore */ }
1128
+ (0, runtime_1.emitNervesEvent)({
1129
+ component: "senses",
1130
+ event: "senses.bluebubbles_proactive_sent",
1131
+ message: "proactive bluebubbles message sent",
1132
+ meta: { friendId, handle },
1133
+ });
1134
+ }
1135
+ catch (error) {
1136
+ result.failed++;
1137
+ (0, runtime_1.emitNervesEvent)({
1138
+ level: "error",
1139
+ component: "senses",
1140
+ event: "senses.bluebubbles_proactive_send_error",
1141
+ message: "proactive bluebubbles send failed",
1142
+ meta: {
1143
+ friendId,
1144
+ handle,
1145
+ reason: error instanceof Error ? error.message : String(error),
1146
+ },
1147
+ });
1148
+ }
1149
+ }
1150
+ if (result.sent > 0 || result.skipped > 0 || result.failed > 0) {
1151
+ (0, runtime_1.emitNervesEvent)({
1152
+ component: "senses",
1153
+ event: "senses.bluebubbles_proactive_drain_complete",
1154
+ message: "bluebubbles proactive drain complete",
1155
+ meta: { sent: result.sent, skipped: result.skipped, failed: result.failed },
1156
+ });
1157
+ }
1158
+ return result;
1159
+ }
318
1160
  function startBlueBubblesApp(deps = {}) {
319
1161
  const resolvedDeps = { ...defaultDeps, ...deps };
320
1162
  resolvedDeps.createClient();
321
1163
  const channelConfig = (0, config_1.getBlueBubblesChannelConfig)();
322
1164
  const server = resolvedDeps.createServer(createBlueBubblesWebhookHandler(deps));
1165
+ const runtimeTimer = setInterval(() => {
1166
+ void syncBlueBubblesRuntime(resolvedDeps);
1167
+ }, BLUEBUBBLES_RUNTIME_SYNC_INTERVAL_MS);
1168
+ server.on?.("close", () => {
1169
+ clearInterval(runtimeTimer);
1170
+ });
323
1171
  server.listen(channelConfig.port, () => {
324
1172
  (0, runtime_1.emitNervesEvent)({
325
1173
  component: "channels",
@@ -328,5 +1176,6 @@ function startBlueBubblesApp(deps = {}) {
328
1176
  meta: { port: channelConfig.port, webhookPath: channelConfig.webhookPath },
329
1177
  });
330
1178
  });
1179
+ void syncBlueBubblesRuntime(resolvedDeps);
331
1180
  return server;
332
1181
  }