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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,319 @@
1
+ "use strict";
2
+ // Booking-intent relevance for mail search.
3
+ //
4
+ // Today the agent's mail search ranks by recency only — `searchText.includes(term)`
5
+ // then `receivedAt` desc. That works for "give me the latest message" but fails
6
+ // the workflow Slugger actually does most: "find the decisive booking message
7
+ // in this delegated mailbox so I can update a travel doc from real evidence."
8
+ // Recent newsletter / itinerary chatter from the same sender drowns the older
9
+ // confirmation.
10
+ //
11
+ // This module adds a small additive score per document. Signals are heuristic
12
+ // and intentionally legible — not learned. Each signal is also exposed so the
13
+ // renderer can surface a "matched on" hint to the agent for triage.
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.scoreMailSearchDocument = scoreMailSearchDocument;
16
+ exports.compareByRelevanceThenRecency = compareByRelevanceThenRecency;
17
+ exports.formatRelevanceHint = formatRelevanceHint;
18
+ const BOOKING_INTENT_TOKENS = [
19
+ "booking confirmation",
20
+ "booked",
21
+ "your booking",
22
+ "your reservation",
23
+ "your stay",
24
+ "your trip",
25
+ "reservation confirmation",
26
+ "reservation confirmed",
27
+ "confirmation number",
28
+ "e-ticket",
29
+ "eticket",
30
+ "itinerary",
31
+ "receipt",
32
+ "invoice",
33
+ "check-in",
34
+ "check in",
35
+ "departure",
36
+ "arrival",
37
+ "boarding pass",
38
+ "confirmed",
39
+ "confirmation",
40
+ "reservation",
41
+ ];
42
+ // Subject-only tokens get an extra bump because subjects rarely include them
43
+ // for non-decisive mail.
44
+ const SUBJECT_DECISIVE_TOKENS = [
45
+ "booking confirmation",
46
+ "reservation confirmation",
47
+ "your booking",
48
+ "your reservation",
49
+ "your stay",
50
+ "e-ticket",
51
+ "eticket",
52
+ "boarding pass",
53
+ "confirmed",
54
+ "itinerary",
55
+ "receipt",
56
+ ];
57
+ // Known travel-domain senders. Substring match against the from list. Kept
58
+ // short on purpose — the score still works without exhaustive coverage; this
59
+ // just reinforces obvious cases.
60
+ const KNOWN_TRAVEL_SENDER_PATTERNS = [
61
+ "booking.com",
62
+ "hotels.com",
63
+ "expedia",
64
+ "airbnb",
65
+ "marriott",
66
+ "hilton",
67
+ "hyatt",
68
+ "ihg",
69
+ "accorhotels",
70
+ "kayak",
71
+ "agoda",
72
+ "trivago",
73
+ "vrbo",
74
+ "swiss.com",
75
+ "lufthansa",
76
+ "ryanair",
77
+ "easyjet",
78
+ "ba.com",
79
+ "delta.com",
80
+ "united.com",
81
+ "aa.com",
82
+ "alaskaair",
83
+ "klm.com",
84
+ "airfrance",
85
+ "iberia",
86
+ "sas.se",
87
+ "norwegian.com",
88
+ "sbb.ch",
89
+ "sncf",
90
+ "trenitalia",
91
+ "renfe",
92
+ "eurail",
93
+ "trainline",
94
+ "omio",
95
+ "raileurope",
96
+ "amtrak",
97
+ "viarail",
98
+ "rentalcars",
99
+ "hertz",
100
+ "avis",
101
+ "europcar",
102
+ "sixt",
103
+ "uber.com",
104
+ "lyft.com",
105
+ "lime",
106
+ "tripit",
107
+ "kiwi.com",
108
+ "tap.pt",
109
+ ];
110
+ // Confirmation-number-shaped tokens. Two flavors:
111
+ // - alphanumeric mix at least 6 long: PNRs, hotel confirmation codes
112
+ // - long pure-digit runs (>= 8): airline ref numbers, OTA references
113
+ const ALPHANUM_CONF_RE = /\b(?=[A-Z0-9]*[A-Z])(?=[A-Z0-9]*\d)[A-Z0-9]{6,12}\b/g;
114
+ const LONG_DIGIT_RE = /\b\d{8,}\b/g;
115
+ // Currency amounts. Symbol or ISO 3-letter currency immediately followed by
116
+ // (or following) a number. Catches "$420", "€189.50", "CHF 320", "USD 199".
117
+ const CURRENCY_RE = /(?:[$£€¥₣]\s?\d{1,3}(?:[,.\s]\d{3})*(?:[.,]\d{2})?|\b(?:USD|EUR|GBP|CHF|JPY|CAD|AUD|SEK|NOK|DKK)\s?\d{1,3}(?:[,.\s]\d{3})*(?:[.,]\d{2})?)/g;
118
+ // ISO-8601 dates and "January 2[, 2026]" / "2 January 2026" forms.
119
+ const ISO_DATE_RE = /\b\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])\b/g;
120
+ const MONTH_DAY_RE = /\b(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)\s+\d{1,2}(?:,?\s+\d{4})?\b/gi;
121
+ const DAY_MONTH_RE = /\b\d{1,2}\s+(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)(?:,?\s+\d{4})?\b/gi;
122
+ // Status tokens — decisive truth signals. These often determine whether a
123
+ // "this trip is on" reading is correct. Lowercased substring match.
124
+ const STATUS_TOKENS = [
125
+ "confirmed",
126
+ "booked",
127
+ "cancelled",
128
+ "canceled",
129
+ "changed",
130
+ "rescheduled",
131
+ "refunded",
132
+ "refund",
133
+ "pending",
134
+ "tentative",
135
+ "waitlist",
136
+ "no longer",
137
+ "rebooked",
138
+ "modified",
139
+ ];
140
+ function uniqueLowercaseHits(text, regex) {
141
+ const hits = text.match(regex);
142
+ if (!hits)
143
+ return [];
144
+ const seen = new Set();
145
+ const out = [];
146
+ for (const hit of hits) {
147
+ const norm = hit.toLowerCase();
148
+ if (seen.has(norm))
149
+ continue;
150
+ seen.add(norm);
151
+ out.push(hit);
152
+ }
153
+ return out;
154
+ }
155
+ function findKnownTravelSender(fromList) {
156
+ for (const from of fromList) {
157
+ const lower = from.toLowerCase();
158
+ for (const pattern of KNOWN_TRAVEL_SENDER_PATTERNS) {
159
+ if (lower.includes(pattern))
160
+ return pattern;
161
+ }
162
+ }
163
+ return undefined;
164
+ }
165
+ function countOccurrences(haystack, needle) {
166
+ /* v8 ignore start -- defensive: BOOKING_INTENT_TOKENS are non-empty by construction */
167
+ if (!needle)
168
+ return 0;
169
+ /* v8 ignore stop */
170
+ let count = 0;
171
+ let from = 0;
172
+ while (true) {
173
+ const idx = haystack.indexOf(needle, from);
174
+ if (idx === -1)
175
+ break;
176
+ count++;
177
+ from = idx + needle.length;
178
+ }
179
+ return count;
180
+ }
181
+ /**
182
+ * Score a cached search document for booking-intent relevance against a list
183
+ * of (already lowercased, non-empty) query terms.
184
+ *
185
+ * Signals (additive, all small integers so the result stays interpretable):
186
+ * - +6 per query term hit in subject
187
+ * - +4 per query term hit in any from address
188
+ * - +2 per query term hit in body
189
+ * - +5 per booking-intent token in subject (extra +3 for decisive subject tokens)
190
+ * - +2 per booking-intent token in body
191
+ * - +4 if any confirmation-number-shaped token appears
192
+ * - +3 if any currency amount appears
193
+ * - +6 if any from address matches a known travel-sender pattern
194
+ *
195
+ * The numbers are tunable. They are chosen so that a noisy newsletter that
196
+ * mentions the query terms in body but has no booking signals scores below
197
+ * a decisive booking confirmation that has the query terms + booking tokens
198
+ * in subject + a confirmation code, even if the booking confirmation is older.
199
+ */
200
+ function scoreMailSearchDocument(document, queryTerms) {
201
+ const subjectLower = document.subject.toLowerCase();
202
+ const bodyLower = document.textExcerpt.toLowerCase();
203
+ const fromLower = document.from.join(" ").toLowerCase();
204
+ let score = 0;
205
+ const matchedFields = new Set();
206
+ for (const term of queryTerms) {
207
+ /* v8 ignore start -- defensive: callers always normalize/filter empty terms */
208
+ if (!term)
209
+ continue;
210
+ /* v8 ignore stop */
211
+ if (subjectLower.includes(term)) {
212
+ score += 6;
213
+ matchedFields.add("subject");
214
+ }
215
+ if (fromLower.includes(term)) {
216
+ score += 4;
217
+ matchedFields.add("from");
218
+ }
219
+ if (bodyLower.includes(term)) {
220
+ score += 2;
221
+ matchedFields.add("body");
222
+ }
223
+ }
224
+ const bookingHits = new Set();
225
+ for (const token of BOOKING_INTENT_TOKENS) {
226
+ const inSubject = countOccurrences(subjectLower, token);
227
+ const inBody = countOccurrences(bodyLower, token);
228
+ if (inSubject > 0) {
229
+ score += 5 * inSubject;
230
+ bookingHits.add(token);
231
+ }
232
+ if (inBody > 0) {
233
+ score += 2 * inBody;
234
+ bookingHits.add(token);
235
+ }
236
+ }
237
+ for (const decisive of SUBJECT_DECISIVE_TOKENS) {
238
+ if (subjectLower.includes(decisive))
239
+ score += 3;
240
+ }
241
+ const subjectAndBody = `${document.subject}\n${document.textExcerpt}`;
242
+ const subjectAndBodyLower = subjectAndBody.toLowerCase();
243
+ const confirmationTokens = [
244
+ ...uniqueLowercaseHits(subjectAndBody, ALPHANUM_CONF_RE),
245
+ ...uniqueLowercaseHits(subjectAndBody, LONG_DIGIT_RE),
246
+ ];
247
+ if (confirmationTokens.length > 0)
248
+ score += 4;
249
+ const currencyTokens = uniqueLowercaseHits(subjectAndBody, CURRENCY_RE);
250
+ if (currencyTokens.length > 0)
251
+ score += 3;
252
+ const dateTokens = [
253
+ ...uniqueLowercaseHits(subjectAndBody, ISO_DATE_RE),
254
+ ...uniqueLowercaseHits(subjectAndBody, MONTH_DAY_RE),
255
+ ...uniqueLowercaseHits(subjectAndBody, DAY_MONTH_RE),
256
+ ];
257
+ const statusTokens = [];
258
+ for (const token of STATUS_TOKENS) {
259
+ if (subjectAndBodyLower.includes(token))
260
+ statusTokens.push(token);
261
+ }
262
+ const travelSenderHint = findKnownTravelSender(document.from);
263
+ if (travelSenderHint)
264
+ score += 6;
265
+ const signal = {
266
+ score,
267
+ matchedFields: Array.from(matchedFields),
268
+ bookingTokens: Array.from(bookingHits),
269
+ confirmationTokens,
270
+ currencyTokens,
271
+ statusTokens,
272
+ dateTokens,
273
+ };
274
+ if (travelSenderHint)
275
+ signal.travelSenderHint = travelSenderHint;
276
+ return signal;
277
+ }
278
+ /**
279
+ * Sort comparator: booking relevance first, recency as tiebreaker.
280
+ * Returns negative when `a` should come before `b`.
281
+ */
282
+ function compareByRelevanceThenRecency(a, b) {
283
+ if (b.relevance.score !== a.relevance.score)
284
+ return b.relevance.score - a.relevance.score;
285
+ return b.document.receivedAt.localeCompare(a.document.receivedAt);
286
+ }
287
+ /**
288
+ * Render a short "matched on" hint for surfacing under a search result.
289
+ * Empty string when nothing notable to report (no signals, no fields). The
290
+ * caller decides whether to display the line.
291
+ */
292
+ function formatRelevanceHint(signal) {
293
+ if (signal.score === 0)
294
+ return "";
295
+ const parts = [];
296
+ if (signal.matchedFields.length > 0) {
297
+ parts.push(`fields: ${signal.matchedFields.join("+")}`);
298
+ }
299
+ if (signal.bookingTokens.length > 0) {
300
+ const preview = signal.bookingTokens.slice(0, 3).join(", ");
301
+ parts.push(`booking signals: ${preview}`);
302
+ }
303
+ if (signal.statusTokens.length > 0) {
304
+ parts.push(`status: ${signal.statusTokens.slice(0, 3).join(", ")}`);
305
+ }
306
+ if (signal.confirmationTokens.length > 0) {
307
+ parts.push(`conf token: ${signal.confirmationTokens[0]}`);
308
+ }
309
+ if (signal.currencyTokens.length > 0) {
310
+ parts.push(`amount: ${signal.currencyTokens[0]}`);
311
+ }
312
+ if (signal.dateTokens.length > 0) {
313
+ parts.push(`dates: ${signal.dateTokens.slice(0, 3).join(", ")}`);
314
+ }
315
+ if (signal.travelSenderHint) {
316
+ parts.push(`sender: ${signal.travelSenderHint}`);
317
+ }
318
+ return parts.join(" | ");
319
+ }
@@ -315,11 +315,43 @@ function postTurnPersist(sessPath, prepared, usage, state) {
315
315
  return envelope.events;
316
316
  }
317
317
  /**
318
- * Deferred persist: same as postTurnPersist but runs on the next event loop tick.
319
- * Returns a promise that resolves when the persist completes.
318
+ * Per-sessPath serialization queue. Without this, two concurrent
319
+ * `deferPostTurnPersist` calls (e.g. two BlueBubbles webhooks for the same
320
+ * chat firing back-to-back, or a CLI postTurn racing the inner-dialog turn
321
+ * for the same MCP session) would each load the envelope, both compute the
322
+ * same "next sequence", and write events with colliding ids. The session
323
+ * file would silently accumulate duplicates and replay would diverge from
324
+ * what was actually sent on the wire.
325
+ *
326
+ * The queue is in-process only — it does not protect against multiple Node
327
+ * processes writing to the same file. The dedup-on-load behaviour in
328
+ * parseSessionEnvelope keeps cross-process races from leaving permanent
329
+ * corruption; this serializer just keeps the common (single-process) case
330
+ * race-free in the first place.
331
+ */
332
+ const sessionPersistQueues = new Map();
333
+ function enqueueSessionPersist(sessPath, fn) {
334
+ const previous = sessionPersistQueues.get(sessPath) ?? Promise.resolve();
335
+ // Chain on the previous tail. fn runs whether previous resolved or rejected,
336
+ // so one failed turn cannot block subsequent turns on the same session.
337
+ const next = previous.then(fn, fn);
338
+ // Save a swallowed-rejection sentinel as the new tail so the next caller's
339
+ // `previous.then(fn, fn)` sees a clean resolution; the original `next` still
340
+ // propagates rejection to its own caller as expected.
341
+ /* v8 ignore start -- the swallow only matters when fn rejects, which is the failure path covered separately */
342
+ const sentinel = next.then(() => undefined, () => undefined);
343
+ /* v8 ignore stop */
344
+ sessionPersistQueues.set(sessPath, sentinel);
345
+ return next;
346
+ }
347
+ /**
348
+ * Deferred persist: same as postTurnPersist but runs on the next event loop
349
+ * tick AND serializes against any other deferred persist for the same
350
+ * sessPath, so concurrent turns cannot race and produce duplicate event ids
351
+ * in the saved session.
320
352
  */
321
353
  function deferPostTurnPersist(sessPath, prepared, usage, state) {
322
- return new Promise((resolve) => {
354
+ return enqueueSessionPersist(sessPath, () => new Promise((resolve) => {
323
355
  setImmediate(() => {
324
356
  try {
325
357
  const events = postTurnPersist(sessPath, prepared, usage, state);
@@ -336,7 +368,7 @@ function deferPostTurnPersist(sessPath, prepared, usage, state) {
336
368
  resolve([]);
337
369
  }
338
370
  });
339
- });
371
+ }));
340
372
  }
341
373
  function deleteSession(filePath) {
342
374
  try {
@@ -87,6 +87,21 @@ class FriendResolver {
87
87
  hasAnyFriends = false;
88
88
  }
89
89
  const isFirstImprint = !hasAnyFriends;
90
+ // BlueBubbles group chats route through here as `imessage-handle` with an
91
+ // externalId of the form `group:any;+;<chatHash>`. When the harness auto-
92
+ // creates the group friend at stranger trust, we mark the record so that
93
+ // the trust gate can surface the relationship for explicit acknowledgment
94
+ // later instead of letting messages accumulate silently.
95
+ const isImessageGroup = this.params.provider === "imessage-handle" &&
96
+ typeof this.params.externalId === "string" &&
97
+ this.params.externalId.startsWith("group:");
98
+ const notes = {};
99
+ if (this.params.displayName !== "Unknown") {
100
+ notes.name = { value: this.params.displayName, savedAt: now };
101
+ }
102
+ if (isImessageGroup && !isFirstImprint) {
103
+ notes.autoCreatedGroup = { value: "true", savedAt: now };
104
+ }
90
105
  const friend = {
91
106
  id: (0, crypto_1.randomUUID)(),
92
107
  name: this.params.displayName,
@@ -96,7 +111,7 @@ class FriendResolver {
96
111
  externalIds: [externalId],
97
112
  tenantMemberships,
98
113
  toolPreferences: {},
99
- notes: this.params.displayName !== "Unknown" ? { name: { value: this.params.displayName, savedAt: now } } : {},
114
+ notes,
100
115
  totalTokens: 0,
101
116
  createdAt: now,
102
117
  updatedAt: now,
@@ -97,6 +97,10 @@ const DISPATCH_EXEMPT_PATTERNS = [
97
97
  // consumed by server readers and the UI. Outlook read/render modules own
98
98
  // the observability for these projections.
99
99
  "heart/outlook/outlook-types",
100
+ // Mail search relevance scorer: pure heuristic function (regex + counter
101
+ // arithmetic). The caller (search-cache.ts searchMailSearchCache) owns
102
+ // observability via senses.mail_search_cache_upserted and friends.
103
+ "mailroom/search-relevance",
100
104
  // Outlook HTTP helper modules: route/static/transport/hook seams are
101
105
  // dispatched by outlook-http.ts, whose server lifecycle owns observability.
102
106
  "heart/outlook/outlook-http-transport",