@ouro.bot/cli 0.1.0-alpha.485 → 0.1.0-alpha.488

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
@@ -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",