@ouro.bot/cli 0.1.0-alpha.722 → 0.1.0-alpha.723

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.
@@ -808,7 +808,7 @@ function tripStoresDiffer(deps, durableRoot, legacyRoot) {
808
808
  }
809
809
  return false;
810
810
  }
811
- function checkMailroom(deps) {
811
+ async function checkMailroom(deps) {
812
812
  const checks = [];
813
813
  const agents = discoverAgents(deps);
814
814
  if (agents.length === 0) {
@@ -853,7 +853,7 @@ function checkMailroom(deps) {
853
853
  continue;
854
854
  }
855
855
  const messagesDir = `${mailroomRoot}/messages`;
856
- const listing = listStoredMailMessages(deps, messagesDir);
856
+ const listing = listJsonDocuments(deps, messagesDir);
857
857
  /* v8 ignore start -- defensive: pluralization branches depend on filesystem-state fixtures not exhaustively covered @preserve */
858
858
  checks.push({
859
859
  label: `${agentDir} mailroom`,
@@ -861,20 +861,90 @@ function checkMailroom(deps) {
861
861
  detail: `${mailboxes.length} mailbox${mailboxes.length === 1 ? "" : "es"}, ${sourceGrants.length} source grant${sourceGrants.length === 1 ? "" : "s"}, ${listing.count} message${listing.count === 1 ? "" : "s"} stored (cumulative — not a liveness signal)`,
862
862
  });
863
863
  /* v8 ignore stop */
864
- checks.push(mailIngestLivenessCheck(deps, agentDir, mailroomRoot, messagesDir, listing));
864
+ checks.push(mailIngestLivenessCheck(deps, agentDir, mailroomRoot, messagesDir, listing, await resolveMailStore(agentDir)));
865
865
  }
866
866
  return { name: "Mailroom", checks };
867
867
  }
868
- function listStoredMailMessages(deps, messagesDir) {
869
- if (!deps.existsSync(messagesDir))
868
+ function listJsonDocuments(deps, dir) {
869
+ if (!deps.existsSync(dir))
870
870
  return { count: 0, readable: true };
871
871
  try {
872
- return { count: deps.readdirSync(messagesDir).filter((name) => name.endsWith(".json")).length, readable: true };
872
+ return { count: deps.readdirSync(dir).filter((name) => name.endsWith(".json")).length, readable: true };
873
873
  }
874
874
  catch {
875
875
  return { count: 0, readable: false };
876
876
  }
877
877
  }
878
+ async function resolveMailStore(agentDir) {
879
+ const agentName = agentDir.replace(/\.ouro$/, "");
880
+ // An unreadable vault means the mode is unknown, so this falls back to the
881
+ // reader's own default — the local file store. `mail config` in checkSenses
882
+ // is where an unavailable runtime config is reported, and it fails hard.
883
+ const runtimeConfig = await (0, runtime_credentials_1.refreshRuntimeCredentialConfig)(agentName, { preserveCachedOnFailure: true });
884
+ if (!runtimeConfig.ok)
885
+ return { hosted: false };
886
+ const mailroom = asRecord(runtimeConfig.config.mailroom);
887
+ const azureAccountUrl = textField(mailroom, "azureAccountUrl");
888
+ if (!azureAccountUrl)
889
+ return { hosted: false };
890
+ const azureContainer = textField(mailroom, "azureContainer") || "mailroom";
891
+ return { hosted: true, label: `hosted azure-blob ${azureAccountUrl}/${azureContainer}` };
892
+ }
893
+ /**
894
+ * Local-file Mailroom liveness signal.
895
+ *
896
+ * The `messages/` directory mtime is both the cheapest and the most correct
897
+ * option. Cheapest: one stat regardless of store size, and the production store
898
+ * holds ~45k message files. Most correct: a message's `receivedAt` is when the
899
+ * mail was *sent* (an mbox backfill writes month-old values today), so parsing
900
+ * message bodies would answer a different question than "did this machine
901
+ * ingest anything recently?".
902
+ */
903
+ function localMailStoreProbe(deps, messagesDir, listing) {
904
+ if (!listing.readable) {
905
+ return {
906
+ observation: { kind: "unknown", reason: `${messagesDir} exists but could not be listed` },
907
+ provenance: `attempted directory listing of ${messagesDir}`,
908
+ };
909
+ }
910
+ return (0, freshness_1.observeCreatePerEventStore)(messagesDir, deps, { hasEntries: listing.count > 0 });
911
+ }
912
+ /**
913
+ * Hosted Mailroom liveness signal.
914
+ *
915
+ * Once an agent is cut over to the hosted store, messages live in the Blob
916
+ * container and nothing writes to `state/mailroom/messages` any more, so its
917
+ * mtime freezes at the cutover. Reading it in hosted mode is how this check
918
+ * reported "no mail ingested in 77 days" on 2026-07-27 while the container was
919
+ * taking mail that same morning — a false failure, which trains operators to
920
+ * ignore the check and so undoes the reason it exists.
921
+ *
922
+ * The local artifact that does still move is the hosted reader's search cache:
923
+ * `AzureBlobMailroomStore` writes `state/mail-search/<messageId>.json` for
924
+ * every message it decrypts (see `mailroom/reader.ts`, which hands the hosted
925
+ * store that cache directory). Message ids are content hashes, so a *new* file
926
+ * appears exactly when this machine sees a message it has never seen before;
927
+ * re-reading old mail rewrites existing files and leaves the directory mtime
928
+ * untouched. Same create-per-event shape as the local store, same O(1) stat.
929
+ *
930
+ * It is deliberately one step downstream of ingest: it proves hosted mail
931
+ * reached this machine, which is the strongest claim available without network
932
+ * access or Azure credentials — a health check must need neither. The activity
933
+ * wording, provenance and remediation all say so rather than implying doctor
934
+ * measured the container itself, and an empty mirror reports `unknown` rather
935
+ * than the hosted store being empty.
936
+ */
937
+ function hostedMailMirrorProbe(deps, agentDir, storeLabel) {
938
+ const mirrorDir = `${deps.bundlesRoot}/${agentDir}/state/mail-search`;
939
+ const listing = listJsonDocuments(deps, mirrorDir);
940
+ if (!listing.readable) {
941
+ return {
942
+ observation: { kind: "unknown", reason: `${mirrorDir} exists but could not be listed` },
943
+ provenance: `attempted directory listing of ${mirrorDir}`,
944
+ };
945
+ }
946
+ return (0, freshness_1.observeMirroredStore)(mirrorDir, deps, { hasEntries: listing.count > 0, remote: storeLabel });
947
+ }
878
948
  /**
879
949
  * Mail-ingest liveness — "is mail still arriving?", as opposed to the check
880
950
  * above, which only says "is a mailbox configured?".
@@ -883,30 +953,27 @@ function listStoredMailMessages(deps, messagesDir) {
883
953
  * its config, and a cumulative count of 45,479 messages all reported ✔ for 77
884
954
  * days while zero mail was ingested.
885
955
  *
886
- * The signal is the `messages/` directory mtime, which is both the cheapest
887
- * and the most correct option. Cheapest: one stat regardless of store size,
888
- * and the production store holds ~45k message files. Most correct: a message's
889
- * `receivedAt` is when the mail was *sent* (an mbox backfill writes month-old
890
- * values today), so parsing message bodies would answer a different question
891
- * than "did this machine ingest anything recently?".
956
+ * Which signal is honest depends on where the store lives, so the probe, the
957
+ * activity wording and the remediation are all chosen per store kind. Reusing
958
+ * the local path in hosted mode measures a directory nothing writes to.
892
959
  */
893
- function mailIngestLivenessCheck(deps, agentDir, mailroomRoot, messagesDir, listing) {
894
- const probe = listing.readable
895
- ? (0, freshness_1.observeCreatePerEventStore)(messagesDir, deps, { hasEntries: listing.count > 0 })
896
- : {
897
- observation: { kind: "unknown", reason: `${messagesDir} exists but could not be listed` },
898
- provenance: `attempted directory listing of ${messagesDir}`,
899
- };
960
+ function mailIngestLivenessCheck(deps, agentDir, mailroomRoot, messagesDir, listing, store) {
961
+ const hosted = store.hosted ? store : null;
962
+ const probe = hosted
963
+ ? hostedMailMirrorProbe(deps, agentDir, hosted.label)
964
+ : localMailStoreProbe(deps, messagesDir, listing);
900
965
  return pipelineLivenessCheck({
901
966
  id: "mail.ingest_liveness",
902
967
  label: `${agentDir} mail ingest liveness`,
903
- activity: "mail ingested",
968
+ activity: hosted ? "hosted mail observed locally" : "mail ingested",
904
969
  unit: "message",
905
970
  probe,
906
971
  thresholds: (0, freshness_1.resolveFreshnessThresholds)(exports.DEFAULT_MAIL_INGEST_THRESHOLDS, senseFreshnessOverride(deps, agentDir, "mail")),
907
- remediation: "mail is configured but nothing is arriving — re-check the mailbox grant and keyIds against the vault (a server-side key rotation silently orphans ingestion), run `ouro connect mail --agent <agent>`, and inspect the mailroom ingress logs",
972
+ remediation: hosted
973
+ ? `doctor measures hosted mail only through this machine's local mirror and makes no network calls — confirm the container itself by listing \`messages/\` blobs in ${hosted.label} by Last-Modified, or read the mailbox directly (\`ouro mailbox\`, or the agent's \`mail_recent\` tool); if the mirror is empty or stale while the container is current, the hosted reader on this machine is not running`
974
+ : "mail is configured but nothing is arriving — re-check the mailbox grant and keyIds against the vault (a server-side key rotation silently orphans ingestion), run `ouro connect mail --agent <agent>`, and inspect the mailroom ingress logs",
908
975
  configuredSinceMs: pathMtimeMs(deps, `${mailroomRoot}/registry.json`),
909
- context: "mailbox configured",
976
+ context: hosted ? `mailbox configured; ${hosted.label}` : "mailbox configured",
910
977
  nowMs: Date.now(),
911
978
  });
912
979
  }
@@ -35,6 +35,7 @@ exports.formatFreshnessAge = formatFreshnessAge;
35
35
  exports.formatFreshnessTimestamp = formatFreshnessTimestamp;
36
36
  exports.evaluateFreshness = evaluateFreshness;
37
37
  exports.observeCreatePerEventStore = observeCreatePerEventStore;
38
+ exports.observeMirroredStore = observeMirroredStore;
38
39
  exports.observeAppendPerEventStore = observeAppendPerEventStore;
39
40
  exports.resolveFreshnessThresholds = resolveFreshnessThresholds;
40
41
  const runtime_1 = require("../../nerves/runtime");
@@ -215,6 +216,46 @@ function observeCreatePerEventStore(dir, deps, options) {
215
216
  };
216
217
  }
217
218
  }
219
+ /**
220
+ * Last-observation probe for a **local mirror of a remote store** — the cache
221
+ * this machine writes as it reads a store that lives somewhere else, such as
222
+ * the hosted Mailroom's `state/mail-search/<messageId>.json` documents.
223
+ *
224
+ * Mechanically this is `observeCreatePerEventStore`: one file per event, named
225
+ * by a content-addressed id, so the directory mtime answers "when did this
226
+ * machine last record something it had never seen before" for O(1) cost.
227
+ *
228
+ * One rule differs, and it is the entire reason this exists: an absent or empty
229
+ * mirror is `unknown`, never `none`. For a local store the directory *is* the
230
+ * store, so empty means "nothing was ever delivered". For a mirror the
231
+ * authoritative store is remote and deliberately not read here — a health check
232
+ * must not need network access or credentials — so empty means only "this
233
+ * machine holds no record". That is absence of evidence, and rendering it as a
234
+ * confident "never delivered" would be the same crying-wolf failure as reading
235
+ * a local directory that nothing writes to any more.
236
+ */
237
+ function observeMirroredStore(dir, deps, options) {
238
+ const provenance = `derived from the local mirror at ${dir} (directory mtime, O(1), no per-entry scan); the authoritative store is ${options.remote}, which doctor does not read`;
239
+ const exists = deps.existsSync(dir);
240
+ if (!exists || !options.hasEntries) {
241
+ return {
242
+ observation: {
243
+ kind: "unknown",
244
+ reason: `the local mirror at ${dir} is ${exists ? "empty" : "absent"}, and ${options.remote} is not read by doctor (no network calls, no credentials), so recency cannot be measured on this machine`,
245
+ },
246
+ provenance,
247
+ };
248
+ }
249
+ try {
250
+ return { observation: { kind: "activity", atMs: deps.statSync(dir).mtimeMs }, provenance };
251
+ }
252
+ catch (error) {
253
+ return {
254
+ observation: { kind: "unknown", reason: `${dir} could not be stat'd: ${error instanceof Error ? error.message : String(error)}` },
255
+ provenance,
256
+ };
257
+ }
258
+ }
218
259
  /**
219
260
  * Last-write probe for **append-per-event** stores — a small set of long-lived
220
261
  * logs appended in place, such as the BlueBubbles inbound ndjson (one file per
@@ -72,9 +72,17 @@ function extractOrderedListReferents(text) {
72
72
  }
73
73
  return referents;
74
74
  }
75
- function hasCorrectionMarker(text) {
76
- return /\b(hang on|wait|actually|not that|not this|wrong|misunderstood|you(?:'re| are) right|correct)\b/i.test(text)
77
- || /^\s*no[\s,]/i.test(text);
75
+ /**
76
+ * Returns the marker that fired, not just a boolean: when the hold blocks an action
77
+ * the agent has to be able to see *which* word triggered it. In the 2026-07-27
78
+ * incident it guessed wrong twice ("it's because of the word 'useful'") because the
79
+ * block message named no trigger.
80
+ */
81
+ function correctionMarkerIn(text) {
82
+ const phrase = /\b(hang on|wait|actually|not that|not this|wrong|misunderstood|you(?:'re| are) right|correct)\b/i.exec(text);
83
+ if (phrase)
84
+ return phrase[0].toLowerCase();
85
+ return /^\s*no[\s,]/i.test(text) ? "no" : undefined;
78
86
  }
79
87
  function isTerseReferent(text) {
80
88
  const normalized = text.trim().toLowerCase().replace(/[.!?]+$/g, "");
@@ -93,12 +101,16 @@ function shouldAttachStructuredOutput(combinedSpeech, signals) {
93
101
  return true;
94
102
  return signals.some((signal) => signal === "terse_referent" || signal === "correction_marker");
95
103
  }
96
- function deriveSignals(currentUserSpeech, priorAssistantReferents, latestStructuredOutput) {
104
+ function deriveSignals(currentUserSpeech, priorAssistantReferents, latestStructuredOutput, speechKind) {
97
105
  const combined = currentUserSpeech.join("\n").trim();
98
- if (!combined)
106
+ // A reaction is an acknowledgement of an existing message, not new speech. Its
107
+ // text is synthesised by the sense from the message being reacted to, so scanning
108
+ // it would read the *quoted* message's words as the human's correction — and a
109
+ // positive tapback is approval, which must never gate outbound work.
110
+ if (!combined || speechKind === "reaction")
99
111
  return [];
100
112
  const signals = [];
101
- if (hasCorrectionMarker(combined))
113
+ if (correctionMarkerIn(combined))
102
114
  signals.push("correction_marker");
103
115
  if (priorAssistantReferents.length > 0 && isTerseReferent(combined))
104
116
  signals.push("terse_referent");
@@ -106,15 +118,42 @@ function deriveSignals(currentUserSpeech, priorAssistantReferents, latestStructu
106
118
  signals.push("structured_referent");
107
119
  return [...new Set(signals)];
108
120
  }
109
- function deriveActionPolicy(signals) {
110
- if (signals.length === 0)
121
+ /**
122
+ * The hold exists for corrections that are terse or ambiguous ("no, not that one").
123
+ * A correction word buried in a long, self-contained instruction is neither: on
124
+ * 2026-07-27 the word "actually" — in "Do NOT claim a hotel has AC unless you
125
+ * actually saw evidence" — armed the hold inside a 195-word directive that had zero
126
+ * prior referents and no structured output, and blocked five consecutive
127
+ * `send_message` calls. The same directive was what authorised the send, and with
128
+ * nothing to disambiguate the remedy the block named could not be performed.
129
+ */
130
+ const TERSE_CORRECTION_MAX_WORDS = 12;
131
+ const CORRECTION_HOLD_CLEARED_BY = "Resolve the referent (orientation_get lists the candidates) or restate the request as a standalone, correction-free instruction. The policy is recomputed from each user turn, so it never carries past this turn.";
132
+ function countWords(text) {
133
+ return text.trim().split(/\s+/).filter(Boolean).length;
134
+ }
135
+ function correctionMarkerArmsHold(combinedSpeech, priorAssistantReferents, latestStructuredOutput) {
136
+ if (countWords(combinedSpeech) <= TERSE_CORRECTION_MAX_WORDS)
137
+ return true;
138
+ // Long speech still holds when there is something concrete to disambiguate.
139
+ return priorAssistantReferents.length > 0 || Boolean(latestStructuredOutput);
140
+ }
141
+ function deriveActionPolicy(signals, combinedSpeech, priorAssistantReferents, latestStructuredOutput) {
142
+ const holdSignals = signals.filter((signal) => signal !== "correction_marker"
143
+ || correctionMarkerArmsHold(combinedSpeech, priorAssistantReferents, latestStructuredOutput));
144
+ if (holdSignals.length === 0)
111
145
  return { mode: "normal" };
146
+ const triggeredBy = holdSignals.includes("correction_marker")
147
+ ? correctionMarkerIn(combinedSpeech)
148
+ : undefined;
112
149
  return {
113
150
  mode: "correction_hold",
114
- reason: signals.includes("terse_referent")
151
+ reason: holdSignals.includes("terse_referent")
115
152
  ? "Current user speech appears referent-dependent; inspect orientation before mutating durable state."
116
153
  : "Current user speech appears to correct or revise prior understanding; inspect orientation before mutating durable state.",
117
154
  blockedMutationKinds: ["durable_state_write", "external_side_effect"],
155
+ ...(triggeredBy ? { triggeredBy } : {}),
156
+ clearedBy: CORRECTION_HOLD_CLEARED_BY,
118
157
  };
119
158
  }
120
159
  function buildOrientationFrame(input) {
@@ -127,12 +166,13 @@ function buildOrientationFrame(input) {
127
166
  const previousAssistant = latestAssistantBefore(input.messages, derivedCurrent.firstIndex);
128
167
  const priorAssistantReferents = extractOrderedListReferents(extractMessageText(previousAssistant));
129
168
  const candidateStructuredOutput = latestStructuredOutputFrom(input.structuredOutputs);
130
- const signals = deriveSignals(currentUserSpeech, priorAssistantReferents, candidateStructuredOutput);
169
+ const speechKind = input.speechKind ?? "utterance";
170
+ const signals = deriveSignals(currentUserSpeech, priorAssistantReferents, candidateStructuredOutput, speechKind);
131
171
  const combinedSpeech = currentUserSpeech.join("\n").trim();
132
172
  const latestStructuredOutput = candidateStructuredOutput && shouldAttachStructuredOutput(combinedSpeech, signals)
133
173
  ? candidateStructuredOutput
134
174
  : undefined;
135
- const actionPolicy = deriveActionPolicy(signals);
175
+ const actionPolicy = deriveActionPolicy(signals, combinedSpeech, priorAssistantReferents, candidateStructuredOutput);
136
176
  const frame = {
137
177
  schemaVersion: 1,
138
178
  channel: input.channel,
@@ -142,6 +182,7 @@ function buildOrientationFrame(input) {
142
182
  signals,
143
183
  actionPolicy,
144
184
  ...(input.source ? { source: input.source } : {}),
185
+ ...(speechKind === "utterance" ? {} : { speechKind }),
145
186
  };
146
187
  (0, runtime_1.emitNervesEvent)({
147
188
  component: "engine",
@@ -155,6 +196,27 @@ function buildOrientationFrame(input) {
155
196
  policy: actionPolicy.mode,
156
197
  },
157
198
  });
199
+ if (actionPolicy.mode === "correction_hold") {
200
+ // Distinct + greppable: a hold that blocks an explicitly requested action must
201
+ // leave a trace naming what it blocks and what releases it.
202
+ (0, runtime_1.emitNervesEvent)({
203
+ level: "warn",
204
+ component: "engine",
205
+ event: "orientation.correction_hold_armed",
206
+ message: "orientation correction hold armed",
207
+ meta: {
208
+ channel: input.channel,
209
+ speechKind,
210
+ signals,
211
+ blockedMutationKinds: actionPolicy.blockedMutationKinds,
212
+ triggeredBy: actionPolicy.triggeredBy ?? null,
213
+ referentCount: priorAssistantReferents.length,
214
+ structuredOutput: candidateStructuredOutput?.id ?? null,
215
+ speechWordCount: countWords(combinedSpeech),
216
+ clearedBy: actionPolicy.clearedBy,
217
+ },
218
+ });
219
+ }
158
220
  return frame;
159
221
  }
160
222
  function renderOrientationFrame(frame) {
@@ -165,6 +227,15 @@ function renderOrientationFrame(frame) {
165
227
  ];
166
228
  if (frame.actionPolicy.mode === "correction_hold") {
167
229
  lines.push(`policy reason: ${frame.actionPolicy.reason}`);
230
+ lines.push(`policy blocks: ${frame.actionPolicy.blockedMutationKinds.join(", ")}`);
231
+ if (frame.actionPolicy.triggeredBy) {
232
+ lines.push(`policy trigger: correction marker "${frame.actionPolicy.triggeredBy}" in current user speech`);
233
+ }
234
+ if (frame.actionPolicy.clearedBy)
235
+ lines.push(`policy clears when: ${frame.actionPolicy.clearedBy}`);
236
+ }
237
+ if (frame.speechKind) {
238
+ lines.push(`speech kind: ${frame.speechKind}`);
168
239
  }
169
240
  if (frame.source) {
170
241
  lines.push("source:");
@@ -687,5 +687,54 @@ function createBlueBubblesClient(config = (0, config_1.getBlueBubblesConfig)(),
687
687
  return null;
688
688
  }
689
689
  },
690
+ async getMessageDetails(messageGuid) {
691
+ const url = buildRepairUrl(config.serverUrl, messageGuid, config.password);
692
+ try {
693
+ const response = await fetch(url, {
694
+ method: "GET",
695
+ signal: AbortSignal.timeout(channelConfig.requestTimeoutMs),
696
+ });
697
+ if (!response.ok) {
698
+ (0, runtime_1.emitNervesEvent)({
699
+ level: "warn",
700
+ component: "senses",
701
+ event: "senses.bluebubbles_get_message_details_error",
702
+ message: "failed to fetch message details",
703
+ meta: { messageGuid, status: response.status },
704
+ });
705
+ return null;
706
+ }
707
+ const data = extractRepairData(await parseJsonBody(response));
708
+ if (!data) {
709
+ (0, runtime_1.emitNervesEvent)({
710
+ level: "warn",
711
+ component: "senses",
712
+ event: "senses.bluebubbles_get_message_details_error",
713
+ message: "message payload was not a record",
714
+ meta: { messageGuid },
715
+ });
716
+ return null;
717
+ }
718
+ const text = typeof data.text === "string" ? data.text.trim() || null : null;
719
+ const fromMe = typeof data.isFromMe === "boolean" ? data.isFromMe : null;
720
+ (0, runtime_1.emitNervesEvent)({
721
+ component: "senses",
722
+ event: "senses.bluebubbles_get_message_details",
723
+ message: "fetched message details by guid",
724
+ meta: { messageGuid, hasText: text !== null, hasAuthorship: fromMe !== null },
725
+ });
726
+ return { text, fromMe };
727
+ }
728
+ catch (error) {
729
+ (0, runtime_1.emitNervesEvent)({
730
+ level: "warn",
731
+ component: "senses",
732
+ event: "senses.bluebubbles_get_message_details_error",
733
+ message: "exception fetching message details",
734
+ meta: { messageGuid, reason: error instanceof Error ? error.message : String(error) },
735
+ });
736
+ return null;
737
+ }
738
+ },
690
739
  };
691
740
  }
@@ -34,7 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.BB_IN_FLIGHT_MAX_AGE_MS = void 0;
37
- exports.enrichReactionText = enrichReactionText;
37
+ exports.resolveReactionTarget = resolveReactionTarget;
38
38
  exports.tokenizeForDedupe = tokenizeForDedupe;
39
39
  exports.jaccardSimilarity = jaccardSimilarity;
40
40
  exports.createStatusBatcher = createStatusBatcher;
@@ -116,16 +116,19 @@ const bbInFlightMessageClaims = new Map();
116
116
  * a leaked marker from blocking forward progress for hours.
117
117
  */
118
118
  exports.BB_IN_FLIGHT_MAX_AGE_MS = 15 * 60_000;
119
- // Enrich reaction text with the original message content for context.
120
- // If originalText is provided and non-empty, format as: baseText to: "truncated"
121
- // Otherwise return baseText unchanged.
122
- function enrichReactionText(baseText, originalText, maxLen) {
123
- if (!originalText)
124
- return baseText;
125
- const truncated = originalText.length > maxLen
126
- ? originalText.slice(0, maxLen - 3) + "..."
127
- : originalText;
128
- return `${baseText} to: "${truncated}"`;
119
+ // Resolve the message a reaction points at, so the agent is told *what* was
120
+ // reacted to and *whose* message it was rather than a bare "reacted with love".
121
+ // Prefers the authorship-carrying lookup; falls back to text-only when the client
122
+ // does not provide one. Every failure path returns an explicit "unknown" rather
123
+ // than a value the caller could mistake for a resolved target.
124
+ async function resolveReactionTarget(client, targetMessageGuid) {
125
+ const base = { guid: targetMessageGuid };
126
+ if (client.getMessageDetails) {
127
+ const details = await client.getMessageDetails(targetMessageGuid).catch(() => null);
128
+ return { ...base, text: details?.text ?? null, fromMe: details?.fromMe ?? null };
129
+ }
130
+ const text = await client.getMessageText(targetMessageGuid).catch(() => null);
131
+ return { ...base, text, fromMe: null };
129
132
  }
130
133
  // ── Near-duplicate outward-text detection ────────────────────────
131
134
  // Used by createBlueBubblesCallbacks to collapse mid-turn rephrasings of the
@@ -1390,13 +1393,14 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, opt
1390
1393
  meta: { threadGuid, hasText: !!repliedToText },
1391
1394
  });
1392
1395
  }
1393
- // Enrich reaction mutations with the original message text for context
1396
+ // Anchor reaction mutations to the message they point at, including whose
1397
+ // message it was. Unresolved targets keep the explicit "unidentified message"
1398
+ // wording that normalizeBlueBubblesEvent already produced.
1394
1399
  const isReaction = event.kind === "mutation" && event.mutationType === "reaction";
1395
- if (isReaction && event.targetMessageGuid) {
1396
- /* v8 ignore start -- best-effort lookup; enrichReactionText covered by unit tests @preserve */
1397
- const originalText = await client.getMessageText(event.targetMessageGuid).catch(() => null);
1398
- if (originalText)
1399
- event.textForAgent = enrichReactionText(event.textForAgent, originalText, 80);
1400
+ if (isReaction && event.reaction && event.targetMessageGuid) {
1401
+ /* v8 ignore start -- best-effort network lookup; resolveReactionTarget and renderBlueBubblesReactionText are covered by unit tests @preserve */
1402
+ const target = await resolveReactionTarget(client, event.targetMessageGuid);
1403
+ event.textForAgent = (0, model_1.renderBlueBubblesReactionText)(event.reaction, target);
1400
1404
  /* v8 ignore stop */
1401
1405
  }
1402
1406
  // Build inbound user message as user-visible speech only; source/routing facts live in the orientation frame.
@@ -1411,6 +1415,7 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, opt
1411
1415
  currentUserMessages: [userMessage],
1412
1416
  structuredOutputs: existing?.structuredOutputs ?? [],
1413
1417
  source: buildBlueBubblesOrientationSource(event, priorMessages, repliedToText),
1418
+ ...(isReaction ? { speechKind: "reaction" } : {}),
1414
1419
  });
1415
1420
  const visibleActivityTurnId = activeTurnId;
1416
1421
  const callbacks = createBlueBubblesCallbacks(client, event.chat, replyTarget, event.chat.isGroup, visibleActivityTurnId
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.BlueBubblesIgnoredEventError = void 0;
4
+ exports.describeBlueBubblesReaction = describeBlueBubblesReaction;
5
+ exports.renderBlueBubblesReactionText = renderBlueBubblesReactionText;
4
6
  exports.normalizeBlueBubblesEvent = normalizeBlueBubblesEvent;
5
7
  const runtime_1 = require("../../nerves/runtime");
6
8
  const render_1 = require("../../heart/attachments/render");
@@ -150,11 +152,70 @@ function formatMessageText(data, attachments) {
150
152
  return formatAttachmentText(attachments);
151
153
  }
152
154
  function normalizeReactionName(value) {
155
+ // chat.db stores associated_message_type as an integer; BlueBubbles forwards it
156
+ // as either the integer, its decimal string, or a name ("love", "-love").
157
+ if (typeof value === "number") {
158
+ return Number.isFinite(value) && value !== 0 ? String(value) : undefined;
159
+ }
153
160
  if (typeof value !== "string")
154
161
  return undefined;
155
162
  const trimmed = value.trim();
156
163
  return trimmed ? trimmed.toLowerCase() : undefined;
157
164
  }
165
+ /**
166
+ * iMessage tapbacks: associated_message_type 2000-2005 add a tapback, 3000-3005
167
+ * remove the same tapback. BlueBubbles may send either the code or the name.
168
+ */
169
+ const REACTION_VOCABULARY = [
170
+ { code: 2000, name: "love", verb: "loved", noun: "love" },
171
+ { code: 2001, name: "like", verb: "liked", noun: "like" },
172
+ { code: 2002, name: "dislike", verb: "disliked", noun: "dislike" },
173
+ { code: 2003, name: "laugh", verb: "laughed at", noun: "laugh" },
174
+ { code: 2004, name: "emphasize", verb: "emphasized", noun: "emphasis" },
175
+ { code: 2005, name: "question", verb: "questioned", noun: "question" },
176
+ ];
177
+ const REACTION_EXCERPT_MAX_LENGTH = 80;
178
+ function describeBlueBubblesReaction(raw) {
179
+ const stripped = raw.startsWith("-") ? raw.slice(1) : raw;
180
+ const code = /^\d+$/.test(stripped) ? Number(stripped) : undefined;
181
+ const removal = raw.startsWith("-") || (code !== undefined && code >= 3000 && code < 4000);
182
+ const action = removal ? "remove" : "add";
183
+ const entry = REACTION_VOCABULARY.find((candidate) => candidate.name === stripped || candidate.code === code || candidate.code + 1000 === code);
184
+ if (!entry)
185
+ return { raw, action };
186
+ return { raw, action, verb: entry.verb, noun: entry.noun };
187
+ }
188
+ function reactionExcerpt(text) {
189
+ return text.length > REACTION_EXCERPT_MAX_LENGTH
190
+ ? `${text.slice(0, REACTION_EXCERPT_MAX_LENGTH - 3)}...`
191
+ : text;
192
+ }
193
+ function reactionTargetPhrase(target) {
194
+ const text = target.text?.trim();
195
+ if (text) {
196
+ const owner = target.fromMe === true ? "your" : target.fromMe === false ? "their" : "a";
197
+ return `${owner} message: "${reactionExcerpt(text)}"`;
198
+ }
199
+ // Never hand the agent a bare "reacted with love" stub — say plainly that the
200
+ // referent is missing so it can ask instead of guessing what was approved.
201
+ return target.guid
202
+ ? `an unidentified message (target guid ${target.guid}; its text could not be resolved)`
203
+ : "an unidentified message (the reaction carried no target message reference)";
204
+ }
205
+ /**
206
+ * Render a reaction as speech the agent can act on: what the tapback was, whether
207
+ * it was added or removed, and which message it points at.
208
+ */
209
+ function renderBlueBubblesReactionText(reaction, target) {
210
+ const phrase = reactionTargetPhrase(target);
211
+ if (reaction.action === "remove") {
212
+ const noun = reaction.noun ?? reaction.raw.replace(/^-/, "");
213
+ return `removed their ${noun} reaction from ${phrase}`;
214
+ }
215
+ return reaction.verb
216
+ ? `${reaction.verb} ${phrase}`
217
+ : `reacted with ${reaction.raw} to ${phrase}`;
218
+ }
158
219
  function stripPartPrefix(guid) {
159
220
  if (!guid)
160
221
  return undefined;
@@ -162,10 +223,10 @@ function stripPartPrefix(guid) {
162
223
  const marker = trimmed.lastIndexOf("/");
163
224
  return marker >= 0 ? trimmed.slice(marker + 1) : trimmed;
164
225
  }
165
- function buildMutationText(mutationType, data, reactionName) {
166
- if (mutationType === "reaction") {
167
- return `reacted with ${reactionName}`;
168
- }
226
+ // Reactions never reach here: `detectMutationType` returns "reaction" only when a
227
+ // reaction name was decoded, and that same name produces the descriptor the caller
228
+ // renders with `renderBlueBubblesReactionText` instead.
229
+ function buildMutationText(mutationType, data) {
169
230
  if (mutationType === "edit") {
170
231
  const editedText = readString(data, "text")?.trim() ?? "";
171
232
  return editedText ? `edited message: ${editedText}` : "edited a message";
@@ -228,6 +289,10 @@ function normalizeBlueBubblesEvent(payload) {
228
289
  const fromMe = readBoolean(data, "isFromMe") ?? false;
229
290
  const attachments = extractAttachments(data);
230
291
  const reactionName = normalizeReactionName(data.associatedMessageType);
292
+ const reaction = reactionName ? describeBlueBubblesReaction(reactionName) : undefined;
293
+ const targetMessageGuid = reaction
294
+ ? stripPartPrefix(readString(data, "associatedMessageGuid"))
295
+ : undefined;
231
296
  const mutationType = detectMutationType(eventType, data, reactionName);
232
297
  const requiresRepair = (readBoolean(data, "hasPayloadData") ?? false) ||
233
298
  attachments.length > 0 ||
@@ -238,16 +303,17 @@ function normalizeBlueBubblesEvent(payload) {
238
303
  eventType,
239
304
  mutationType,
240
305
  messageGuid,
241
- targetMessageGuid: mutationType === "reaction"
242
- ? stripPartPrefix(readString(data, "associatedMessageGuid"))
243
- : undefined,
306
+ targetMessageGuid,
244
307
  timestamp,
245
308
  fromMe,
246
309
  sender,
247
310
  chat,
248
311
  shouldNotifyAgent: mutationType === "reaction" || mutationType === "edit" || mutationType === "unsend",
249
- textForAgent: buildMutationText(mutationType, data, reactionName),
312
+ textForAgent: reaction
313
+ ? renderBlueBubblesReactionText(reaction, { guid: targetMessageGuid })
314
+ : buildMutationText(mutationType, data),
250
315
  requiresRepair,
316
+ ...(reaction ? { reaction } : {}),
251
317
  }
252
318
  : {
253
319
  kind: "message",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.722",
3
+ "version": "0.1.0-alpha.723",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",