apple-tools-mcp 1.0.0

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.
package/search.js ADDED
@@ -0,0 +1,1652 @@
1
+ import * as lancedb from "@lancedb/lancedb";
2
+ import * as chrono from "chrono-node";
3
+ import { safeOsascript } from "./lib/shell.js";
4
+ import { safeMatch, validateSearchQuery } from "./lib/validators.js";
5
+ import { embed, INDEX_DIR, getRecentEmails, getEmailsByDateRange, getRecentMessages, getConversation, getCalendarByDate, getAllCalendarEvents, resolveEmail, resolvePhone, formatContact } from "./indexer.js";
6
+
7
+ let db = null;
8
+ let tables = {};
9
+
10
+ // ============ CACHING ============
11
+
12
+ // Embedding cache with TTL (5 minutes)
13
+ const EMBEDDING_CACHE_TTL = 5 * 60 * 1000;
14
+ const EMBEDDING_CACHE_MAX = 100;
15
+ const embeddingCache = new Map();
16
+
17
+ // Mailboxes to exclude by default (junk, trash, etc.)
18
+ const EXCLUDED_MAILBOXES = ['junk', 'trash', 'deleted messages', 'spam'];
19
+
20
+ function excludeJunkMail(results, includeJunk = false, explicitMailbox = null) {
21
+ // Don't filter if user explicitly requested junk/trash or specified a mailbox
22
+ if (includeJunk || explicitMailbox) return results;
23
+ return results.filter(r =>
24
+ !EXCLUDED_MAILBOXES.some(mb =>
25
+ (r.mailbox || "").toLowerCase().includes(mb)
26
+ )
27
+ );
28
+ }
29
+
30
+ async function cachedEmbed(text) {
31
+ const cached = embeddingCache.get(text);
32
+ if (cached && Date.now() - cached.timestamp < EMBEDDING_CACHE_TTL) {
33
+ return cached.vector;
34
+ }
35
+
36
+ const vector = await embed(text);
37
+
38
+ // Evict oldest if at capacity
39
+ if (embeddingCache.size >= EMBEDDING_CACHE_MAX) {
40
+ const oldest = [...embeddingCache.entries()]
41
+ .sort((a, b) => a[1].timestamp - b[1].timestamp)[0];
42
+ if (oldest) embeddingCache.delete(oldest[0]);
43
+ }
44
+
45
+ embeddingCache.set(text, { vector, timestamp: Date.now() });
46
+ return vector;
47
+ }
48
+
49
+ // Result cache with TTL (5 minutes)
50
+ const RESULT_CACHE_TTL = 5 * 60 * 1000;
51
+ const RESULT_CACHE_MAX = 50;
52
+ const resultCache = new Map();
53
+
54
+ function getCachedResult(cacheKey) {
55
+ const cached = resultCache.get(cacheKey);
56
+ if (cached && Date.now() - cached.timestamp < RESULT_CACHE_TTL) {
57
+ return cached.results;
58
+ }
59
+ return null;
60
+ }
61
+
62
+ function setCachedResult(cacheKey, results) {
63
+ // Evict oldest if at capacity
64
+ if (resultCache.size >= RESULT_CACHE_MAX) {
65
+ const oldest = [...resultCache.entries()]
66
+ .sort((a, b) => a[1].timestamp - b[1].timestamp)[0];
67
+ if (oldest) resultCache.delete(oldest[0]);
68
+ }
69
+ resultCache.set(cacheKey, { results, timestamp: Date.now() });
70
+ }
71
+
72
+ function buildCacheKey(type, query, options) {
73
+ return JSON.stringify({ type, query, options });
74
+ }
75
+
76
+ // ============ AGENTIC RAG HELPERS ============
77
+
78
+ // Follow-up context: Track recent queries and extracted entities for pronoun resolution
79
+ const queryContext = {
80
+ lastQuery: null,
81
+ lastPerson: null,
82
+ lastSource: null, // 'mail', 'messages', 'calendar'
83
+ lastTimestamp: 0
84
+ };
85
+
86
+ const CONTEXT_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes
87
+
88
+ // Update context after a search
89
+ function updateContext(query, extractedFilters, source) {
90
+ queryContext.lastQuery = query;
91
+ queryContext.lastSource = source;
92
+ queryContext.lastTimestamp = Date.now();
93
+ if (extractedFilters.person) {
94
+ queryContext.lastPerson = extractedFilters.person;
95
+ }
96
+ }
97
+
98
+ // Resolve pronouns like "they", "them", "their" to previous context
99
+ function resolvePronouns(query) {
100
+ // Check if context is still valid
101
+ if (Date.now() - queryContext.lastTimestamp > CONTEXT_EXPIRY_MS) {
102
+ return query;
103
+ }
104
+
105
+ const pronounPattern = /\b(they|them|their|he|him|his|she|her|hers)\b/gi;
106
+
107
+ if (pronounPattern.test(query) && queryContext.lastPerson) {
108
+ return query.replace(pronounPattern, queryContext.lastPerson);
109
+ }
110
+
111
+ return query;
112
+ }
113
+
114
+ // Extract entities (people, dates) from natural language query and convert to filters
115
+ function extractFiltersFromQuery(query) {
116
+ const filters = {};
117
+ const q = query.toLowerCase();
118
+
119
+ // Extract person names: "from John", "with Sarah", "John said", "to Mike"
120
+ // Use simpler patterns with possessive quantifiers to prevent ReDoS
121
+ // Limit input length for safety
122
+ const safeQuery = query.length > 500 ? query.substring(0, 500) : query;
123
+ const personPatterns = [
124
+ /(?:from|with|to)\s+([A-Z][a-z]{1,20}(?:\s[A-Z][a-z]{1,20})?)/, // "from John Smith" - limited name length
125
+ /([A-Z][a-z]{1,20}(?:\s[A-Z][a-z]{1,20})?)\s+(?:said|sent|wrote|messaged|texted|emailed)/, // "John said"
126
+ /(?:emails?|messages?|texts?|calls?)\s+(?:from|to|with)\s+([A-Z][a-z]{1,20})/i // "emails from John"
127
+ ];
128
+
129
+ for (const pattern of personPatterns) {
130
+ const match = safeMatch(safeQuery, pattern);
131
+ if (match && match[1] && match[1].length > 2) {
132
+ filters.person = match[1];
133
+ break;
134
+ }
135
+ }
136
+
137
+ // Extract date ranges using chrono-node (already imported)
138
+ const datePatterns = {
139
+ 'yesterday': 1,
140
+ 'last week': 7,
141
+ 'this week': 7,
142
+ 'last month': 30,
143
+ 'this month': 30,
144
+ 'last few days': 3,
145
+ 'past week': 7,
146
+ 'past month': 30,
147
+ 'recent': 7,
148
+ 'recently': 7,
149
+ 'today': 1
150
+ };
151
+
152
+ for (const [phrase, days] of Object.entries(datePatterns)) {
153
+ if (q.includes(phrase)) {
154
+ filters.daysBack = days;
155
+ break;
156
+ }
157
+ }
158
+
159
+ // Extract "last N days" pattern
160
+ const lastNDays = q.match(/last\s+(\d+)\s+days?/i);
161
+ if (lastNDays) {
162
+ filters.daysBack = parseInt(lastNDays[1], 10);
163
+ }
164
+
165
+ return filters;
166
+ }
167
+
168
+ // Apply extracted filters to search options
169
+ function applyExtractedFilters(options, extractedFilters) {
170
+ const merged = { ...options };
171
+
172
+ // Only apply if not already set by explicit options
173
+ if (extractedFilters.person && !options.sender && !options.contact) {
174
+ merged.sender = extractedFilters.person;
175
+ merged.contact = extractedFilters.person;
176
+ }
177
+
178
+ if (extractedFilters.daysBack && !options.daysBack) {
179
+ merged.daysBack = extractedFilters.daysBack;
180
+ }
181
+
182
+ return merged;
183
+ }
184
+
185
+ // Query expansion - generate alternative search queries for better recall
186
+ function expandQuery(query) {
187
+ const expansions = [query]; // Always include original
188
+
189
+ // 1. Simplified (remove time modifiers that don't affect meaning)
190
+ const simplified = query.replace(/\b(recently|last \w+ days?|this \w+|next \w+|about|regarding)\b/gi, '').trim();
191
+ if (simplified && simplified !== query && simplified.length > 3) {
192
+ expansions.push(simplified);
193
+ }
194
+
195
+ // 2. Synonym replacement for common terms
196
+ const synonymMap = {
197
+ 'meeting': ['call', 'sync', 'standup', 'discussion'],
198
+ 'budget': ['financial', 'costs', 'expense', 'spending'],
199
+ 'project': ['initiative', 'task', 'work', 'assignment'],
200
+ 'deadline': ['due date', 'due', 'timeline', 'delivery'],
201
+ 'review': ['feedback', 'evaluation', 'assessment', 'check'],
202
+ 'invoice': ['bill', 'payment', 'receipt', 'charge'],
203
+ 'schedule': ['calendar', 'appointment', 'booking'],
204
+ 'update': ['status', 'progress', 'news'],
205
+ 'help': ['assist', 'support', 'question'],
206
+ 'issue': ['problem', 'bug', 'error', 'concern']
207
+ };
208
+
209
+ for (const [word, syns] of Object.entries(synonymMap)) {
210
+ if (query.toLowerCase().includes(word)) {
211
+ // Add first synonym variant
212
+ expansions.push(query.replace(new RegExp(`\\b${word}\\b`, 'gi'), syns[0]));
213
+ break; // Only one synonym expansion
214
+ }
215
+ }
216
+
217
+ return [...new Set(expansions)].slice(0, 3); // Max 3 variants, deduplicated
218
+ }
219
+
220
+ // Parse negation terms from query: "meeting NOT weekly" -> { cleanQuery: "meeting", negations: ["weekly"] }
221
+ function parseNegation(query) {
222
+ const negations = [];
223
+ // Match "NOT term", "-term", "without term"
224
+ const negationPatterns = [
225
+ /\bNOT\s+(\w+)/gi,
226
+ /\s-(\w+)/g,
227
+ /\bwithout\s+(\w+)/gi,
228
+ /\bexcluding?\s+(\w+)/gi
229
+ ];
230
+
231
+ let cleanQuery = query;
232
+ for (const pattern of negationPatterns) {
233
+ let match;
234
+ while ((match = pattern.exec(query)) !== null) {
235
+ negations.push(match[1].toLowerCase());
236
+ }
237
+ cleanQuery = cleanQuery.replace(pattern, ' ');
238
+ }
239
+
240
+ return {
241
+ cleanQuery: cleanQuery.replace(/\s+/g, ' ').trim(),
242
+ negations: [...new Set(negations)]
243
+ };
244
+ }
245
+
246
+ // Filter out results containing negated terms
247
+ function applyNegationFilter(results, negations, textFields = ['text', 'body', 'subject', 'title', 'notes']) {
248
+ if (!negations || negations.length === 0) return results;
249
+
250
+ return results.filter(r => {
251
+ for (const field of textFields) {
252
+ const text = (r[field] || '').toLowerCase();
253
+ for (const neg of negations) {
254
+ if (text.includes(neg)) {
255
+ return false; // Exclude this result
256
+ }
257
+ }
258
+ }
259
+ return true;
260
+ });
261
+ }
262
+
263
+ // Reciprocal Rank Fusion (RRF) for merging results from multiple query variants
264
+ // RRF(d) = Σ 1/(k + rank(d)) where k is typically 60
265
+ const RRF_K = 60;
266
+
267
+ function reciprocalRankFusion(resultSets, keyField) {
268
+ const scores = new Map(); // key -> { doc, rrfScore }
269
+
270
+ for (const results of resultSets) {
271
+ for (let rank = 0; rank < results.length; rank++) {
272
+ const doc = results[rank];
273
+ const key = doc[keyField];
274
+ if (!key) continue;
275
+
276
+ const rrfScore = 1 / (RRF_K + rank + 1);
277
+ const existing = scores.get(key);
278
+
279
+ if (existing) {
280
+ existing.rrfScore += rrfScore;
281
+ // Keep the doc with better original score
282
+ if (doc._distance && (!existing.doc._distance || doc._distance < existing.doc._distance)) {
283
+ existing.doc = doc;
284
+ }
285
+ } else {
286
+ scores.set(key, { doc, rrfScore });
287
+ }
288
+ }
289
+ }
290
+
291
+ // Sort by RRF score descending
292
+ return Array.from(scores.values())
293
+ .sort((a, b) => b.rrfScore - a.rrfScore)
294
+ .map(({ doc, rrfScore }) => ({ ...doc, _rrfScore: rrfScore }));
295
+ }
296
+
297
+ // Deduplicate results by a key field, keeping highest score (legacy, used for single-query dedup)
298
+ function deduplicateResults(results, keyField) {
299
+ const seen = new Map();
300
+
301
+ for (const result of results) {
302
+ const key = result[keyField];
303
+ if (!key) continue;
304
+
305
+ const existing = seen.get(key);
306
+ const currentScore = result._distance ? (1 - result._distance) : 0;
307
+ const existingScore = existing?._distance ? (1 - existing._distance) : 0;
308
+
309
+ if (!existing || currentScore > existingScore) {
310
+ seen.set(key, result);
311
+ }
312
+ }
313
+
314
+ return Array.from(seen.values());
315
+ }
316
+
317
+ // Self-correcting retrieval - validates results and retries with broader query if needed
318
+ const MIN_CONFIDENCE_SCORE = 0.5; // Below this score, results are considered low confidence
319
+
320
+ function assessResultQuality(results) {
321
+ if (!results || results.length === 0) {
322
+ return { quality: 'empty', shouldRetry: true };
323
+ }
324
+
325
+ // Check top result confidence
326
+ const topScore = results[0]._distance ? (1 - results[0]._distance) : 0;
327
+
328
+ if (topScore < MIN_CONFIDENCE_SCORE) {
329
+ return { quality: 'low_confidence', shouldRetry: true, topScore };
330
+ }
331
+
332
+ if (results.length < 3 && topScore < 0.7) {
333
+ return { quality: 'sparse', shouldRetry: true, topScore };
334
+ }
335
+
336
+ return { quality: 'good', shouldRetry: false, topScore };
337
+ }
338
+
339
+ // Broaden a query by removing restrictive modifiers
340
+ function broadenQuery(query) {
341
+ // Remove time constraints
342
+ let broader = query.replace(/\b(recently|last \w+ days?|this \w+|next \w+|yesterday|today|tomorrow)\b/gi, '');
343
+ // Remove prepositions that narrow scope
344
+ broader = broader.replace(/\b(about|regarding|concerning|from|to|with)\b/gi, '');
345
+ // Clean up extra spaces
346
+ broader = broader.replace(/\s+/g, ' ').trim();
347
+
348
+ return broader.length > 3 ? broader : query;
349
+ }
350
+
351
+ // Hybrid search: combine vector search with keyword matching
352
+ // Returns combined score: (1 - vector_distance) * 0.7 + keyword_score * 0.3
353
+ function keywordMatch(text, keywords) {
354
+ if (!text || !keywords || keywords.length === 0) return 0;
355
+
356
+ const textLower = text.toLowerCase();
357
+ let matches = 0;
358
+ let totalWeight = 0;
359
+
360
+ for (const kw of keywords) {
361
+ const kwLower = kw.toLowerCase();
362
+ // Exact word match gets higher score
363
+ const wordBoundary = new RegExp(`\\b${kwLower}\\b`, 'i');
364
+ if (wordBoundary.test(text)) {
365
+ matches += 1.0;
366
+ } else if (textLower.includes(kwLower)) {
367
+ matches += 0.5; // Partial match
368
+ }
369
+ totalWeight += 1;
370
+ }
371
+
372
+ return totalWeight > 0 ? matches / totalWeight : 0;
373
+ }
374
+
375
+ function extractKeywords(query) {
376
+ // Remove common stop words and extract significant terms
377
+ const stopWords = new Set(['the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been',
378
+ 'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should',
379
+ 'may', 'might', 'must', 'can', 'to', 'of', 'in', 'for', 'on', 'with', 'at', 'by',
380
+ 'from', 'about', 'into', 'through', 'during', 'before', 'after', 'above', 'below',
381
+ 'between', 'under', 'again', 'further', 'then', 'once', 'here', 'there', 'when',
382
+ 'where', 'why', 'how', 'all', 'each', 'few', 'more', 'most', 'other', 'some', 'such',
383
+ 'no', 'nor', 'not', 'only', 'own', 'same', 'so', 'than', 'too', 'very', 'just',
384
+ 'and', 'but', 'if', 'or', 'because', 'as', 'until', 'while', 'any', 'both', 'what',
385
+ 'which', 'who', 'whom', 'this', 'that', 'these', 'those', 'am', 'it', 'its', 'my',
386
+ 'your', 'his', 'her', 'our', 'their', 'me', 'him', 'them', 'us', 'i', 'you', 'we']);
387
+
388
+ return query.toLowerCase()
389
+ .replace(/[^\w\s]/g, ' ')
390
+ .split(/\s+/)
391
+ .filter(w => w.length > 2 && !stopWords.has(w));
392
+ }
393
+
394
+ function applyHybridScoring(results, keywords, textFields = ['text', 'body', 'subject', 'title', 'snippet']) {
395
+ const VECTOR_WEIGHT = 0.7;
396
+ const KEYWORD_WEIGHT = 0.3;
397
+
398
+ return results.map(r => {
399
+ const vectorScore = r._distance ? (1 - r._distance) : 0;
400
+
401
+ // Calculate keyword score across relevant text fields
402
+ let keywordScore = 0;
403
+ let fieldCount = 0;
404
+ for (const field of textFields) {
405
+ if (r[field]) {
406
+ keywordScore += keywordMatch(r[field], keywords);
407
+ fieldCount++;
408
+ }
409
+ }
410
+ keywordScore = fieldCount > 0 ? keywordScore / fieldCount : 0;
411
+
412
+ const hybridScore = vectorScore * VECTOR_WEIGHT + keywordScore * KEYWORD_WEIGHT;
413
+
414
+ return { ...r, _hybridScore: hybridScore, _keywordScore: keywordScore };
415
+ }).sort((a, b) => b._hybridScore - a._hybridScore);
416
+ }
417
+
418
+ // Self-correcting search wrapper with RRF, hybrid scoring, and retry logic
419
+ async function searchWithRetry(_searchFn, tbl, query, options, keyField) {
420
+ const { limit = 10 } = options;
421
+
422
+ // First attempt with query expansion
423
+ const queries = expandQuery(query);
424
+ const fetchLimitPerQuery = Math.max(limit * 5, 50);
425
+
426
+ // PARALLEL: Embed all query variants simultaneously using cached embeddings
427
+ const vectors = await Promise.all(queries.map(q => cachedEmbed(q)));
428
+
429
+ // PARALLEL: Search all variants simultaneously
430
+ const searchPromises = vectors.map(v => tbl.search(v).limit(fetchLimitPerQuery).toArray());
431
+ const resultSets = await Promise.all(searchPromises);
432
+
433
+ // Use RRF to merge results from multiple query variants
434
+ let results = reciprocalRankFusion(resultSets, keyField);
435
+ const quality = assessResultQuality(results);
436
+
437
+ // Only retry if NO results (not just low confidence - reduces unnecessary embedding calls)
438
+ if (quality.quality === 'empty') {
439
+ const broadened = broadenQuery(query);
440
+ if (broadened !== query) {
441
+ const broadVector = await cachedEmbed(broadened);
442
+ const moreResults = await tbl.search(broadVector).limit(fetchLimitPerQuery).toArray();
443
+ resultSets.push(moreResults);
444
+ results = reciprocalRankFusion(resultSets, keyField);
445
+ }
446
+ }
447
+
448
+ // Apply hybrid scoring (vector + keyword)
449
+ const keywords = extractKeywords(query);
450
+ if (keywords.length > 0) {
451
+ results = applyHybridScoring(results, keywords);
452
+ }
453
+
454
+ return results;
455
+ }
456
+
457
+ async function getTable(type) {
458
+ if (tables[type]) return tables[type];
459
+
460
+ if (!db) {
461
+ db = await lancedb.connect(INDEX_DIR);
462
+ }
463
+
464
+ const tableNames = await db.tableNames();
465
+ if (!tableNames.includes(type)) {
466
+ return null;
467
+ }
468
+
469
+ tables[type] = await db.openTable(type);
470
+ return tables[type];
471
+ }
472
+
473
+ // Pre-warm all tables to eliminate first-query latency
474
+ export async function prewarmTables() {
475
+ try {
476
+ await Promise.all([
477
+ getTable('emails'),
478
+ getTable('messages'),
479
+ getTable('calendar')
480
+ ]);
481
+ console.error('Tables pre-warmed successfully');
482
+ } catch (e) {
483
+ console.error('Table pre-warm warning:', e.message);
484
+ }
485
+ }
486
+
487
+ // ============ DATE PARSING UTILITIES ============
488
+
489
+ // Format a date string to local timezone
490
+ function formatLocalDate(dateStr) {
491
+ if (!dateStr || dateStr === "Unknown") return dateStr;
492
+ try {
493
+ const d = new Date(dateStr);
494
+ if (isNaN(d.getTime())) return dateStr;
495
+ return d.toLocaleString();
496
+ } catch {
497
+ return dateStr;
498
+ }
499
+ }
500
+
501
+ // Parse natural language date to start of day timestamp
502
+ export function parseNaturalDate(dateStr) {
503
+ if (!dateStr) return null;
504
+
505
+ // Try chrono-node first for natural language
506
+ const parsed = chrono.parseDate(dateStr);
507
+ if (parsed) {
508
+ // Set to start of day
509
+ parsed.setHours(0, 0, 0, 0);
510
+ return parsed.getTime();
511
+ }
512
+
513
+ // Fallback to direct Date parsing
514
+ const d = new Date(dateStr);
515
+ if (!isNaN(d.getTime())) {
516
+ d.setHours(0, 0, 0, 0);
517
+ return d.getTime();
518
+ }
519
+
520
+ return null;
521
+ }
522
+
523
+ // Get start and end timestamps for a specific date
524
+ export function getDateRange(dateStr) {
525
+ const start = parseNaturalDate(dateStr);
526
+ if (!start) return null;
527
+
528
+ const end = start + (24 * 60 * 60 * 1000); // End of day
529
+ return { start, end };
530
+ }
531
+
532
+ // Parse various date formats and return timestamp (for filtering results)
533
+ function parseDate(dateStr) {
534
+ if (!dateStr) return null;
535
+ try {
536
+ // Try direct parsing first
537
+ let d = new Date(dateStr);
538
+ if (!isNaN(d.getTime())) return d.getTime();
539
+
540
+ // Handle AppleScript format like "Friday, January 10, 2025 at 9:00:00 AM"
541
+ const appleMatch = dateStr.match(/(\w+), (\w+ \d+, \d+) at (\d+:\d+:\d+ [AP]M)/i);
542
+ if (appleMatch) {
543
+ d = new Date(`${appleMatch[2]} ${appleMatch[3]}`);
544
+ if (!isNaN(d.getTime())) return d.getTime();
545
+ }
546
+
547
+ return null;
548
+ } catch {
549
+ return null;
550
+ }
551
+ }
552
+
553
+ // Filter results by date range
554
+ function filterByDateRange(results, daysBack, daysAhead, dateField = "date") {
555
+ const now = Date.now();
556
+ const cutoffPast = daysBack > 0 ? now - (daysBack * 24 * 60 * 60 * 1000) : 0;
557
+ const cutoffFuture = daysAhead > 0 ? now + (daysAhead * 24 * 60 * 60 * 1000) : Infinity;
558
+
559
+ if (daysBack === 0 && daysAhead === 0) return results;
560
+
561
+ return results.filter(r => {
562
+ const ts = r.dateTimestamp || parseDate(r[dateField]);
563
+ if (!ts) return daysBack === 0 && daysAhead === 0;
564
+ return ts >= cutoffPast && ts <= cutoffFuture;
565
+ });
566
+ }
567
+
568
+ // Sort results by date (newest first)
569
+ function sortByDate(results, descending = true) {
570
+ return results.sort((a, b) => {
571
+ const tsA = a.dateTimestamp || parseDate(a.date) || parseDate(a.start) || 0;
572
+ const tsB = b.dateTimestamp || parseDate(b.date) || parseDate(b.start) || 0;
573
+ return descending ? tsB - tsA : tsA - tsB;
574
+ });
575
+ }
576
+
577
+ // ============ EMAIL SEARCH ============
578
+
579
+ export async function searchEmails(query, options = {}) {
580
+ // Validate and sanitize query input
581
+ let validatedQuery;
582
+ try {
583
+ validatedQuery = validateSearchQuery(query);
584
+ } catch (e) {
585
+ return { results: [], error: e.message };
586
+ }
587
+
588
+ // Check result cache first
589
+ const cacheKey = buildCacheKey('emails', validatedQuery, options);
590
+ const cached = getCachedResult(cacheKey);
591
+ if (cached) {
592
+ return cached;
593
+ }
594
+
595
+ // Resolve pronouns from previous context (e.g., "what else did they send")
596
+ const resolvedQuery = resolvePronouns(validatedQuery);
597
+
598
+ // Extract entities and filters from natural language
599
+ const extractedFilters = extractFiltersFromQuery(resolvedQuery);
600
+
601
+ // Parse negation terms (e.g., "meeting NOT weekly")
602
+ const { cleanQuery, negations } = parseNegation(resolvedQuery);
603
+
604
+ // Merge extracted filters with explicit options (explicit options take precedence)
605
+ const mergedOptions = applyExtractedFilters(options, extractedFilters);
606
+
607
+ const {
608
+ limit = 30,
609
+ daysBack = 0,
610
+ sender = null,
611
+ recipient = null,
612
+ hasAttachment = null,
613
+ mailbox = null,
614
+ sentOnly = null, // true = sent, false = received, null = all
615
+ flaggedOnly = false,
616
+ includeJunk = false, // whether to include junk/trash/spam folders
617
+ sortBy = "relevance" // "relevance" or "date"
618
+ } = mergedOptions;
619
+
620
+ const tbl = await getTable("emails");
621
+
622
+ if (!tbl) {
623
+ return {
624
+ success: false,
625
+ error: "Email index not ready. Please wait for indexing to complete."
626
+ };
627
+ }
628
+
629
+ try {
630
+ // Self-correcting search with query expansion, RRF, and hybrid scoring
631
+ let results = await searchWithRetry(null, tbl, cleanQuery, { limit }, 'filePath');
632
+
633
+ // Apply negation filter
634
+ results = applyNegationFilter(results, negations, ['subject', 'body', 'snippet']);
635
+
636
+ // Apply filters
637
+ if (daysBack > 0) {
638
+ results = filterByDateRange(results, daysBack, 0, "date");
639
+ }
640
+
641
+ if (sender) {
642
+ const senderLower = sender.toLowerCase();
643
+ results = results.filter(r => {
644
+ const from = (r.from || "").toLowerCase();
645
+ const fromEmail = (r.fromEmail || "").toLowerCase();
646
+ return from.includes(senderLower) || fromEmail.includes(senderLower);
647
+ });
648
+ }
649
+
650
+ if (recipient) {
651
+ const recipientLower = recipient.toLowerCase();
652
+ results = results.filter(r => {
653
+ const to = (r.to || "").toLowerCase();
654
+ const toEmails = (r.toEmails || "").toLowerCase();
655
+ return to.includes(recipientLower) || toEmails.includes(recipientLower);
656
+ });
657
+ }
658
+
659
+ if (hasAttachment !== null) {
660
+ results = results.filter(r => r.hasAttachment === hasAttachment);
661
+ }
662
+
663
+ if (mailbox) {
664
+ const mailboxLower = mailbox.toLowerCase();
665
+ results = results.filter(r => (r.mailbox || "").toLowerCase().includes(mailboxLower));
666
+ }
667
+
668
+ if (sentOnly === true) {
669
+ results = results.filter(r => r.isSent === true);
670
+ } else if (sentOnly === false) {
671
+ results = results.filter(r => r.isSent === false);
672
+ }
673
+
674
+ if (flaggedOnly) {
675
+ results = results.filter(r => r.isFlagged === true);
676
+ }
677
+
678
+ // Exclude junk/trash by default unless explicitly included or mailbox specified
679
+ results = excludeJunkMail(results, includeJunk, mailbox);
680
+
681
+ // Sort by date if requested
682
+ if (sortBy === "date") {
683
+ results = sortByDate(results, true);
684
+ }
685
+
686
+ // Track count before slicing for "more results" indicator
687
+ const totalBeforeLimit = results.length;
688
+ results = results.slice(0, limit);
689
+ const hasMore = totalBeforeLimit > limit;
690
+
691
+ if (results.length === 0) {
692
+ let filterMsg = "";
693
+ if (daysBack > 0) filterMsg += ` in the last ${daysBack} days`;
694
+ if (sender) filterMsg += ` from ${sender}`;
695
+ if (recipient) filterMsg += ` to ${recipient}`;
696
+ if (hasAttachment) filterMsg += " with attachments";
697
+ if (mailbox) filterMsg += ` in ${mailbox}`;
698
+ if (sentOnly === true) filterMsg += " (sent)";
699
+ if (sentOnly === false) filterMsg += " (received)";
700
+ if (flaggedOnly) filterMsg += " (flagged)";
701
+ return { success: true, results: [], message: `No emails found matching: ${query}${filterMsg}` };
702
+ }
703
+
704
+ const formattedResults = results.map((row, idx) => {
705
+ // Resolve sender email to contact name
706
+ const contact = resolveEmail(row.fromEmail);
707
+ const contactName = contact ? formatContact(contact) : null;
708
+
709
+ return {
710
+ rank: idx + 1,
711
+ score: row._hybridScore ? row._hybridScore.toFixed(3) : (row._distance ? (1 - row._distance).toFixed(3) : "N/A"),
712
+ from: row.from || "Unknown",
713
+ fromContact: contactName, // Resolved contact name (if found)
714
+ to: row.to || "Unknown",
715
+ subject: row.subject || "No subject",
716
+ date: formatLocalDate(row.date) || "Unknown",
717
+ mailbox: row.mailbox || "Unknown",
718
+ hasAttachment: row.hasAttachment || false,
719
+ isFlagged: row.isFlagged || false,
720
+ preview: row.body?.substring(0, 200) || "",
721
+ filePath: row.filePath,
722
+ messageId: row.messageId || ""
723
+ };
724
+ });
725
+
726
+ // Update context for follow-up queries
727
+ updateContext(resolvedQuery, extractedFilters, 'mail');
728
+
729
+ const result = {
730
+ success: true,
731
+ results: formattedResults,
732
+ showing: formattedResults.length,
733
+ hasMore
734
+ };
735
+ setCachedResult(cacheKey, result);
736
+ return result;
737
+ } catch (e) {
738
+ return { success: false, error: `Search error: ${e.message}` };
739
+ }
740
+ }
741
+
742
+ // Get unread email subjects and senders from Mail.app via AppleScript
743
+ // Returns { emails: [{subject, sender}, ...], error: null } or { emails: [], error: "message" }
744
+ function getUnreadEmails() {
745
+ try {
746
+ // Query all mailboxes (not just inbox) for unread emails
747
+ // Returns subject and sender for precise matching
748
+ const script = `
749
+ tell application "Mail"
750
+ set unreadList to {}
751
+ set maxCount to 500
752
+ set currentCount to 0
753
+
754
+ repeat with acc in accounts
755
+ if currentCount >= maxCount then exit repeat
756
+ try
757
+ -- Get all mailboxes for this account
758
+ set allMailboxes to every mailbox of acc
759
+ repeat with mb in allMailboxes
760
+ if currentCount >= maxCount then exit repeat
761
+ try
762
+ -- Skip Junk/Trash/Spam folders
763
+ set mbName to name of mb
764
+ if mbName is not in {"Junk", "Trash", "Deleted Messages", "Spam", "Junk E-mail"} then
765
+ set unreadMsgs to (messages of mb whose read status is false)
766
+ repeat with msg in unreadMsgs
767
+ if currentCount >= maxCount then exit repeat
768
+ try
769
+ set msgSubject to subject of msg
770
+ set msgSender to sender of msg
771
+ set end of unreadList to msgSubject & "<<<>>>" & msgSender
772
+ set currentCount to currentCount + 1
773
+ end try
774
+ end repeat
775
+ end if
776
+ end try
777
+ end repeat
778
+ end try
779
+ end repeat
780
+
781
+ set AppleScript's text item delimiters to "|||"
782
+ return unreadList as string
783
+ end tell`;
784
+
785
+ const result = safeOsascript(script, { timeout: 60000 });
786
+
787
+ const emails = result.trim().split("|||")
788
+ .filter(s => s.length > 0)
789
+ .map(entry => {
790
+ const [subject, sender] = entry.split("<<<>>>");
791
+ return { subject: subject || "", sender: sender || "" };
792
+ });
793
+ return { emails, error: null };
794
+ } catch (e) {
795
+ console.error("Error getting unread emails:", e.message);
796
+ return { emails: [], error: e.message };
797
+ }
798
+ }
799
+
800
+ // Get recent emails without semantic search
801
+ export async function getRecentEmailResults(limit = 30, daysBack = 7, unreadOnly = false, includeJunk = false) {
802
+ try {
803
+ // When filtering for unread, fetch more emails since unread ones might not be the most recent
804
+ const fetchLimit = unreadOnly ? Math.max(limit * 10, 500) : limit * 3;
805
+ let results = await getRecentEmails(fetchLimit, daysBack);
806
+
807
+ // Exclude junk/trash by default
808
+ results = excludeJunkMail(results, includeJunk, null);
809
+
810
+ // If unreadOnly, filter using AppleScript unread check
811
+ if (unreadOnly) {
812
+ const unreadResult = getUnreadEmails();
813
+
814
+ // Handle AppleScript failure
815
+ if (unreadResult.error) {
816
+ return {
817
+ success: false,
818
+ error: `Could not retrieve unread status from Mail.app: ${unreadResult.error}. Make sure Mail.app is running and accessible.`
819
+ };
820
+ }
821
+
822
+ if (unreadResult.emails.length > 0) {
823
+ // Filter to only emails with matching subject AND sender
824
+ // This prevents false positives when multiple emails have similar subjects
825
+ results = results.filter(r => {
826
+ const subject = (r.subject || "").trim().toLowerCase();
827
+ // Index from field has two formats:
828
+ // 1. "Coinbase via Cloaked (Coinbase)" - just display name
829
+ // 2. "Renita Tyson via Cloaked (AiEdge)" <email@domain.com> - display name + email
830
+ // Extract just the display name from both formats
831
+ const fromRaw = (r.from || "").trim().toLowerCase();
832
+ const fromName = fromRaw.replace(/<[^>]+>$/, "").trim().replace(/^"|"$/g, "");
833
+
834
+ return unreadResult.emails.some(unread => {
835
+ const unreadSubject = (unread.subject || "").trim().toLowerCase();
836
+ // AppleScript returns: Coinbase via Cloaked (Coinbase) <email@domain.com>
837
+ // Extract just the display name (everything before the <email>)
838
+ const unreadSender = (unread.sender || "").trim().toLowerCase();
839
+ const unreadName = unreadSender.replace(/<[^>]+>$/, "").trim().replace(/^"|"$/g, "");
840
+
841
+ // Compare display names - must be strict to avoid false matches
842
+ // Extract just the name part before "via Cloaked" if present
843
+ const extractName = (s) => {
844
+ const viaIndex = s.indexOf(" via cloaked");
845
+ return viaIndex > 0 ? s.substring(0, viaIndex).trim() : s;
846
+ };
847
+ const fromNamePart = extractName(fromName);
848
+ const unreadNamePart = extractName(unreadName);
849
+
850
+ // Sender matches if:
851
+ // 1. Full names are equal, OR
852
+ // 2. Name parts (before "via Cloaked") are equal AND not empty
853
+ const senderMatches =
854
+ fromName === unreadName ||
855
+ (fromNamePart.length > 0 && unreadNamePart.length > 0 && fromNamePart === unreadNamePart);
856
+
857
+ if (!senderMatches) return false;
858
+
859
+ // Now check subject match with fuzzy matching
860
+ // Exact match
861
+ if (unreadSubject === subject) return true;
862
+ // Index subject is prefix of Mail.app subject (truncated in index)
863
+ if (unreadSubject.startsWith(subject) && subject.length > 20) return true;
864
+ // Mail.app subject is prefix of index subject
865
+ if (subject.startsWith(unreadSubject) && unreadSubject.length > 20) return true;
866
+ // First 40 chars match (handles calendar invites with different times)
867
+ const prefix1 = subject.substring(0, 40);
868
+ const prefix2 = unreadSubject.substring(0, 40);
869
+ if (prefix1 === prefix2 && prefix1.length >= 30) return true;
870
+ return false;
871
+ });
872
+ });
873
+ } else {
874
+ // AppleScript succeeded but no unread emails
875
+ return { success: true, results: [], message: "You're all caught up — no unread emails found!" };
876
+ }
877
+ }
878
+
879
+ // Track count before slicing
880
+ const totalBeforeLimit = results.length;
881
+ const hasMore = totalBeforeLimit > limit;
882
+
883
+ const formattedResults = results.slice(0, limit).map((row, idx) => ({
884
+ rank: idx + 1,
885
+ from: row.from || "Unknown",
886
+ to: row.to || "Unknown",
887
+ subject: row.subject || "No subject",
888
+ date: formatLocalDate(row.date) || "Unknown",
889
+ hasAttachment: row.hasAttachment || false,
890
+ preview: row.body?.substring(0, 200) || "",
891
+ filePath: row.filePath,
892
+ messageId: row.messageId || ""
893
+ }));
894
+
895
+ if (formattedResults.length === 0) {
896
+ return { success: true, results: [], message: `No emails found in the last ${daysBack} days` };
897
+ }
898
+
899
+ return {
900
+ success: true,
901
+ results: formattedResults,
902
+ showing: formattedResults.length,
903
+ hasMore
904
+ };
905
+ } catch (e) {
906
+ return { success: false, error: `Error getting recent emails: ${e.message}` };
907
+ }
908
+ }
909
+
910
+ // Get emails from a specific date
911
+ export async function getEmailDateResults(dateStr, includeJunk = false) {
912
+ try {
913
+ const range = getDateRange(dateStr);
914
+ if (!range) {
915
+ return { success: false, error: `Could not parse date: ${dateStr}` };
916
+ }
917
+
918
+ let results = await getEmailsByDateRange(range.start, range.end);
919
+
920
+ // Exclude junk/trash by default
921
+ results = excludeJunkMail(results, includeJunk, null);
922
+
923
+ const formattedResults = results.map((row, idx) => ({
924
+ rank: idx + 1,
925
+ from: row.from || "Unknown",
926
+ to: row.to || "Unknown",
927
+ subject: row.subject || "No subject",
928
+ date: formatLocalDate(row.date) || "Unknown",
929
+ hasAttachment: row.hasAttachment || false,
930
+ preview: row.body?.substring(0, 200) || "",
931
+ filePath: row.filePath,
932
+ messageId: row.messageId || ""
933
+ }));
934
+
935
+ const dateLabel = new Date(range.start).toLocaleDateString("en-US", {
936
+ weekday: "long",
937
+ year: "numeric",
938
+ month: "long",
939
+ day: "numeric"
940
+ });
941
+
942
+ if (formattedResults.length === 0) {
943
+ return { success: true, results: [], message: `No emails on ${dateLabel}` };
944
+ }
945
+
946
+ return { success: true, results: formattedResults };
947
+ } catch (e) {
948
+ return { success: false, error: `Error getting emails by date: ${e.message}` };
949
+ }
950
+ }
951
+
952
+ export function formatEmailResults(searchResult) {
953
+ if (!searchResult.success) return searchResult.error;
954
+ if (searchResult.results.length === 0) return searchResult.message;
955
+
956
+ const results = searchResult.results.map(r => {
957
+ let result = `[${r.rank}]`;
958
+ if (r.score) result += ` Score: ${r.score}`;
959
+ // Show contact name if resolved, otherwise show raw from
960
+ const fromDisplay = r.fromContact ? `${r.fromContact} <${r.from}>` : r.from;
961
+ result += `\nFrom: ${fromDisplay}\nTo: ${r.to}\nSubject: ${r.subject}\nDate: ${r.date}`;
962
+ if (r.hasAttachment) result += "\n📎 Has attachment";
963
+ result += `\nPreview: ${r.preview}...`;
964
+ result += `\nFile: ${r.filePath}`;
965
+ return result + "\n---";
966
+ }).join("\n");
967
+
968
+ return results;
969
+ }
970
+
971
+ // ============ MESSAGES SEARCH ============
972
+
973
+ export async function searchMessages(query, options = {}) {
974
+ // Validate and sanitize query input
975
+ let validatedQuery;
976
+ try {
977
+ validatedQuery = validateSearchQuery(query);
978
+ } catch (e) {
979
+ return { results: [], error: e.message };
980
+ }
981
+
982
+ // Check result cache first
983
+ const cacheKey = buildCacheKey('messages', validatedQuery, options);
984
+ const cached = getCachedResult(cacheKey);
985
+ if (cached) {
986
+ return cached;
987
+ }
988
+
989
+ // Resolve pronouns from previous context
990
+ const resolvedQuery = resolvePronouns(validatedQuery);
991
+
992
+ // Extract entities and filters from natural language
993
+ const extractedFilters = extractFiltersFromQuery(resolvedQuery);
994
+
995
+ // Parse negation terms
996
+ const { cleanQuery, negations } = parseNegation(resolvedQuery);
997
+
998
+ // Merge extracted filters with explicit options
999
+ const mergedOptions = applyExtractedFilters(options, extractedFilters);
1000
+
1001
+ const {
1002
+ limit = 10,
1003
+ daysBack = 0,
1004
+ contact = null,
1005
+ groupChatOnly = false,
1006
+ groupChatName = null,
1007
+ hasAttachment = null,
1008
+ sortBy = "relevance"
1009
+ } = mergedOptions;
1010
+
1011
+ const tbl = await getTable("messages");
1012
+
1013
+ if (!tbl) {
1014
+ return {
1015
+ success: false,
1016
+ error: "Messages index not ready. Please wait for indexing to complete."
1017
+ };
1018
+ }
1019
+
1020
+ try {
1021
+ // Self-correcting search with query expansion, RRF, and hybrid scoring
1022
+ let results = await searchWithRetry(null, tbl, cleanQuery, { limit }, 'id');
1023
+
1024
+ // Apply negation filter
1025
+ results = applyNegationFilter(results, negations, ['text']);
1026
+
1027
+ if (daysBack > 0) {
1028
+ results = filterByDateRange(results, daysBack, 0, "date");
1029
+ }
1030
+
1031
+ if (contact) {
1032
+ const contactLower = contact.toLowerCase();
1033
+ results = results.filter(r => {
1034
+ const sender = (r.sender || "").toLowerCase();
1035
+ const chatId = (r.chatIdentifier || "").toLowerCase();
1036
+ return sender.includes(contactLower) || chatId.includes(contactLower);
1037
+ });
1038
+ }
1039
+
1040
+ if (groupChatOnly) {
1041
+ results = results.filter(r => r.isGroupChat === true);
1042
+ }
1043
+
1044
+ if (groupChatName) {
1045
+ const nameLower = groupChatName.toLowerCase();
1046
+ results = results.filter(r => (r.chatName || "").toLowerCase().includes(nameLower));
1047
+ }
1048
+
1049
+ if (hasAttachment !== null) {
1050
+ results = results.filter(r => r.hasAttachment === hasAttachment);
1051
+ }
1052
+
1053
+ if (sortBy === "date") {
1054
+ results = sortByDate(results, true);
1055
+ }
1056
+
1057
+ // Track count before slicing
1058
+ const totalBeforeLimit = results.length;
1059
+ results = results.slice(0, limit);
1060
+ const hasMore = totalBeforeLimit > limit;
1061
+
1062
+ if (results.length === 0) {
1063
+ let filterMsg = "";
1064
+ if (daysBack > 0) filterMsg += ` in the last ${daysBack} days`;
1065
+ if (contact) filterMsg += ` with ${contact}`;
1066
+ if (groupChatOnly) filterMsg += " in group chats";
1067
+ if (groupChatName) filterMsg += ` in "${groupChatName}"`;
1068
+ if (hasAttachment) filterMsg += " with attachments";
1069
+ return { success: true, results: [], message: `No messages found matching: ${query}${filterMsg}` };
1070
+ }
1071
+
1072
+ const formattedResults = results.map((row, idx) => {
1073
+ // Resolve sender (phone/iMessage) to contact name
1074
+ const sender = row.sender || "Unknown";
1075
+ let senderContact = null;
1076
+ if (sender !== "Me" && sender !== "Unknown") {
1077
+ const contact = resolvePhone(sender);
1078
+ if (contact) {
1079
+ senderContact = formatContact(contact);
1080
+ }
1081
+ }
1082
+
1083
+ return {
1084
+ rank: idx + 1,
1085
+ score: row._hybridScore ? row._hybridScore.toFixed(3) : (row._distance ? (1 - row._distance).toFixed(3) : "N/A"),
1086
+ date: formatLocalDate(row.date) || "Unknown",
1087
+ sender: sender,
1088
+ senderContact: senderContact, // Resolved contact name (if found)
1089
+ text: row.text || "",
1090
+ chatName: row.chatName || "",
1091
+ isGroupChat: row.isGroupChat || false,
1092
+ hasAttachment: row.hasAttachment || false
1093
+ };
1094
+ });
1095
+
1096
+ // Update context for follow-up queries
1097
+ updateContext(resolvedQuery, extractedFilters, 'messages');
1098
+
1099
+ const result = {
1100
+ success: true,
1101
+ results: formattedResults,
1102
+ showing: formattedResults.length,
1103
+ hasMore
1104
+ };
1105
+ setCachedResult(cacheKey, result);
1106
+ return result;
1107
+ } catch (e) {
1108
+ return { success: false, error: `Search error: ${e.message}` };
1109
+ }
1110
+ }
1111
+
1112
+ // Get recent messages without semantic search
1113
+ export async function getRecentMessageResults(limit = 10, daysBack = 1) {
1114
+ try {
1115
+ const { messages, hasMore } = await getRecentMessages(limit, daysBack);
1116
+
1117
+ const formattedResults = messages.map((row, idx) => {
1118
+ const sender = row.sender || "Unknown";
1119
+ let senderContact = null;
1120
+ if (sender !== "Me" && sender !== "Unknown") {
1121
+ const contact = resolvePhone(sender);
1122
+ if (contact) {
1123
+ senderContact = formatContact(contact);
1124
+ }
1125
+ }
1126
+ return {
1127
+ rank: idx + 1,
1128
+ date: formatLocalDate(row.date) || "Unknown",
1129
+ sender: sender,
1130
+ senderContact: senderContact,
1131
+ text: row.text || "",
1132
+ isGroupChat: row.isGroupChat || false
1133
+ };
1134
+ });
1135
+
1136
+ if (formattedResults.length === 0) {
1137
+ return { success: true, results: [], message: `No messages found in the last ${daysBack} days` };
1138
+ }
1139
+
1140
+ return { success: true, results: formattedResults, showing: formattedResults.length, hasMore };
1141
+ } catch (e) {
1142
+ return { success: false, error: `Error getting recent messages: ${e.message}` };
1143
+ }
1144
+ }
1145
+
1146
+ // Get full conversation with a contact
1147
+ export async function getConversationResults(contact, limit = 50) {
1148
+ try {
1149
+ const results = await getConversation(contact, limit);
1150
+
1151
+ const formattedResults = results.map((row, idx) => ({
1152
+ index: idx + 1,
1153
+ date: formatLocalDate(row.date) || "Unknown",
1154
+ sender: row.sender || "Unknown",
1155
+ text: row.text || ""
1156
+ }));
1157
+
1158
+ if (formattedResults.length === 0) {
1159
+ return { success: true, results: [], message: `No conversation found with ${contact}` };
1160
+ }
1161
+
1162
+ return { success: true, results: formattedResults, contact };
1163
+ } catch (e) {
1164
+ return { success: false, error: `Error getting conversation: ${e.message}` };
1165
+ }
1166
+ }
1167
+
1168
+ export function formatMessageResults(searchResult) {
1169
+ if (!searchResult.success) return searchResult.error;
1170
+ if (searchResult.results.length === 0) return searchResult.message;
1171
+
1172
+ const results = searchResult.results.map(r => {
1173
+ let result = `[${r.rank || r.index}]`;
1174
+ if (r.score) result += ` Score: ${r.score}`;
1175
+ // Show contact name if resolved, otherwise show raw sender
1176
+ const senderDisplay = r.senderContact ? `${r.senderContact} (${r.sender})` : r.sender;
1177
+ result += `\nDate: ${r.date}\nFrom: ${senderDisplay}`;
1178
+ if (r.isGroupChat) result += " (Group)";
1179
+ result += `\nMessage: ${r.text}`;
1180
+ return result + "\n---";
1181
+ }).join("\n");
1182
+
1183
+ return results;
1184
+ }
1185
+
1186
+ export function formatConversationResults(searchResult) {
1187
+ if (!searchResult.success) return searchResult.error;
1188
+ if (searchResult.results.length === 0) return searchResult.message;
1189
+
1190
+ let output = `Conversation with ${searchResult.contact}:\n\n`;
1191
+ output += searchResult.results.map(r =>
1192
+ `[${r.date}] ${r.sender}: ${r.text}`
1193
+ ).join("\n");
1194
+ return output;
1195
+ }
1196
+
1197
+ // ============ CALENDAR SEARCH ============
1198
+
1199
+ export async function searchCalendar(query, options = {}) {
1200
+ // Validate and sanitize query input
1201
+ let validatedQuery;
1202
+ try {
1203
+ validatedQuery = validateSearchQuery(query);
1204
+ } catch (e) {
1205
+ return { results: [], error: e.message };
1206
+ }
1207
+
1208
+ // Check result cache first
1209
+ const cacheKey = buildCacheKey('calendar', validatedQuery, options);
1210
+ const cached = getCachedResult(cacheKey);
1211
+ if (cached) {
1212
+ return cached;
1213
+ }
1214
+
1215
+ // Resolve pronouns from previous context
1216
+ const resolvedQuery = resolvePronouns(validatedQuery);
1217
+
1218
+ // Extract entities and filters from natural language
1219
+ const extractedFilters = extractFiltersFromQuery(resolvedQuery);
1220
+
1221
+ // Parse negation terms
1222
+ const { cleanQuery, negations } = parseNegation(resolvedQuery);
1223
+
1224
+ // Merge extracted filters with explicit options
1225
+ const mergedOptions = applyExtractedFilters(options, extractedFilters);
1226
+
1227
+ const {
1228
+ limit = 10,
1229
+ daysBack = 0,
1230
+ daysAhead = 0,
1231
+ calendarName = null,
1232
+ allDayOnly = false,
1233
+ sortBy = "relevance"
1234
+ } = mergedOptions;
1235
+
1236
+ const tbl = await getTable("calendar");
1237
+
1238
+ if (!tbl) {
1239
+ return {
1240
+ success: false,
1241
+ error: "Calendar index not ready. Please wait for indexing to complete."
1242
+ };
1243
+ }
1244
+
1245
+ try {
1246
+ // Self-correcting search with query expansion, RRF, and hybrid scoring
1247
+ let results = await searchWithRetry(null, tbl, cleanQuery, { limit }, 'id');
1248
+
1249
+ // Apply negation filter
1250
+ results = applyNegationFilter(results, negations, ['title', 'notes', 'location']);
1251
+
1252
+ if (daysBack > 0 || daysAhead > 0) {
1253
+ results = filterByDateRange(results, daysBack, daysAhead, "start");
1254
+ }
1255
+
1256
+ if (calendarName) {
1257
+ const calLower = calendarName.toLowerCase();
1258
+ results = results.filter(r => (r.calendar || "").toLowerCase().includes(calLower));
1259
+ }
1260
+
1261
+ if (allDayOnly) {
1262
+ results = results.filter(r => r.isAllDay === true);
1263
+ }
1264
+
1265
+ if (sortBy === "date") {
1266
+ results = sortByDate(results, false); // Ascending for calendar
1267
+ }
1268
+
1269
+ // Track count before slicing
1270
+ const totalBeforeLimit = results.length;
1271
+ results = results.slice(0, limit);
1272
+ const hasMore = totalBeforeLimit > limit;
1273
+
1274
+ if (results.length === 0) {
1275
+ let timeMsg = "";
1276
+ if (daysBack > 0) timeMsg += ` from the last ${daysBack} days`;
1277
+ if (daysAhead > 0) timeMsg += ` in the next ${daysAhead} days`;
1278
+ if (calendarName) timeMsg += ` in ${calendarName}`;
1279
+ if (allDayOnly) timeMsg += " (all-day events only)";
1280
+ return { success: true, results: [], message: `No calendar events found matching: ${query}${timeMsg}` };
1281
+ }
1282
+
1283
+ const formattedResults = results.map((row, idx) => {
1284
+ // Parse attendees JSON
1285
+ let attendees = [];
1286
+ try {
1287
+ attendees = JSON.parse(row.attendees || "[]");
1288
+ } catch { attendees = []; }
1289
+
1290
+ return {
1291
+ rank: idx + 1,
1292
+ score: row._hybridScore ? row._hybridScore.toFixed(3) : (row._distance ? (1 - row._distance).toFixed(3) : "N/A"),
1293
+ title: row.title || "No title",
1294
+ start: formatLocalDate(row.start) || "Unknown",
1295
+ startTimestamp: row.startTimestamp || null,
1296
+ end: formatLocalDate(row.end) || "Unknown",
1297
+ calendar: row.calendar || "Unknown",
1298
+ location: row.location || "",
1299
+ notes: row.notes || "",
1300
+ isAllDay: row.isAllDay || false,
1301
+ attendees,
1302
+ attendeeCount: row.attendeeCount || 0
1303
+ };
1304
+ });
1305
+
1306
+ // Update context for follow-up queries
1307
+ updateContext(resolvedQuery, extractedFilters, 'calendar');
1308
+
1309
+ const result = {
1310
+ success: true,
1311
+ results: formattedResults,
1312
+ showing: formattedResults.length,
1313
+ hasMore
1314
+ };
1315
+ setCachedResult(cacheKey, result);
1316
+ return result;
1317
+ } catch (e) {
1318
+ return { success: false, error: `Search error: ${e.message}` };
1319
+ }
1320
+ }
1321
+
1322
+ // Get events on a specific date
1323
+ export async function getCalendarDateResults(dateStr) {
1324
+ try {
1325
+ const range = getDateRange(dateStr);
1326
+ if (!range) {
1327
+ return { success: false, error: `Could not parse date: ${dateStr}` };
1328
+ }
1329
+
1330
+ console.error(`[Calendar Date] Query for "${dateStr}"`);
1331
+ console.error(`[Calendar Date] Range: ${new Date(range.start).toISOString()} to ${new Date(range.end).toISOString()}`);
1332
+
1333
+ const results = await getCalendarByDate(range.start, range.end);
1334
+
1335
+ const formattedResults = results.map((row, idx) => ({
1336
+ index: idx + 1,
1337
+ title: row.title || "No title",
1338
+ start: formatLocalDate(row.start) || "Unknown",
1339
+ startTimestamp: row.startTimestamp || null,
1340
+ end: formatLocalDate(row.end) || "Unknown",
1341
+ calendar: row.calendar || "Unknown",
1342
+ location: row.location || "",
1343
+ isAllDay: row.isAllDay || false
1344
+ }));
1345
+
1346
+ const dateLabel = new Date(range.start).toLocaleDateString("en-US", {
1347
+ weekday: "long",
1348
+ year: "numeric",
1349
+ month: "long",
1350
+ day: "numeric"
1351
+ });
1352
+
1353
+ if (formattedResults.length === 0) {
1354
+ return { success: true, results: [], message: `No events on ${dateLabel}`, date: dateLabel };
1355
+ }
1356
+
1357
+ return { success: true, results: formattedResults, date: dateLabel };
1358
+ } catch (e) {
1359
+ return { success: false, error: `Error getting calendar events: ${e.message}` };
1360
+ }
1361
+ }
1362
+
1363
+ // Calculate free time slots on a specific date
1364
+ export async function calculateFreeTime(dateStr, options = {}) {
1365
+ const {
1366
+ startHour = 9,
1367
+ endHour = 17,
1368
+ calendarName = null
1369
+ } = options;
1370
+
1371
+ try {
1372
+ const range = getDateRange(dateStr);
1373
+ if (!range) {
1374
+ return { success: false, error: `Could not parse date: ${dateStr}` };
1375
+ }
1376
+
1377
+ let events = await getCalendarByDate(range.start, range.end);
1378
+
1379
+ // Filter by calendar if specified
1380
+ if (calendarName) {
1381
+ const calLower = calendarName.toLowerCase();
1382
+ events = events.filter(e => (e.calendar || "").toLowerCase().includes(calLower));
1383
+ }
1384
+
1385
+ // Calculate busy periods (in minutes from start of day)
1386
+ // Skip all-day events - they're typically reminders/holidays, not actual time blocks
1387
+ const busyPeriods = [];
1388
+ for (const evt of events) {
1389
+ if (evt.isAllDay) {
1390
+ continue;
1391
+ }
1392
+
1393
+ const evtStart = new Date(evt.startTimestamp);
1394
+ const evtEnd = evt.end ? parseDate(evt.end) : evt.startTimestamp + (60 * 60 * 1000); // Default 1 hour
1395
+
1396
+ const startMinutes = evtStart.getHours() * 60 + evtStart.getMinutes();
1397
+ const endMinutes = new Date(evtEnd).getHours() * 60 + new Date(evtEnd).getMinutes();
1398
+
1399
+ busyPeriods.push({
1400
+ start: Math.max(startMinutes, startHour * 60),
1401
+ end: Math.min(endMinutes, endHour * 60)
1402
+ });
1403
+ }
1404
+
1405
+ // Sort busy periods
1406
+ busyPeriods.sort((a, b) => a.start - b.start);
1407
+
1408
+ // Find free slots
1409
+ const freeSlots = [];
1410
+ let currentStart = startHour * 60;
1411
+
1412
+ for (const busy of busyPeriods) {
1413
+ if (busy.start > currentStart) {
1414
+ freeSlots.push({
1415
+ start: formatMinutes(currentStart),
1416
+ end: formatMinutes(busy.start),
1417
+ duration: busy.start - currentStart
1418
+ });
1419
+ }
1420
+ currentStart = Math.max(currentStart, busy.end);
1421
+ }
1422
+
1423
+ // Check for free time after last event
1424
+ if (currentStart < endHour * 60) {
1425
+ freeSlots.push({
1426
+ start: formatMinutes(currentStart),
1427
+ end: formatMinutes(endHour * 60),
1428
+ duration: endHour * 60 - currentStart
1429
+ });
1430
+ }
1431
+
1432
+ const dateLabel = new Date(range.start).toLocaleDateString("en-US", {
1433
+ weekday: "long",
1434
+ year: "numeric",
1435
+ month: "long",
1436
+ day: "numeric"
1437
+ });
1438
+
1439
+ return {
1440
+ success: true,
1441
+ date: dateLabel,
1442
+ workingHours: `${formatMinutes(startHour * 60)} - ${formatMinutes(endHour * 60)}`,
1443
+ totalEvents: events.length,
1444
+ freeSlots,
1445
+ totalFreeMinutes: freeSlots.reduce((sum, s) => sum + s.duration, 0)
1446
+ };
1447
+ } catch (e) {
1448
+ return { success: false, error: `Error calculating free time: ${e.message}` };
1449
+ }
1450
+ }
1451
+
1452
+ function formatMinutes(minutes) {
1453
+ const h = Math.floor(minutes / 60);
1454
+ const m = minutes % 60;
1455
+ const period = h >= 12 ? "PM" : "AM";
1456
+ const hour = h > 12 ? h - 12 : (h === 0 ? 12 : h);
1457
+ return `${hour}:${m.toString().padStart(2, "0")} ${period}`;
1458
+ }
1459
+
1460
+ export function formatCalendarResults(searchResult) {
1461
+ if (!searchResult.success) return searchResult.error;
1462
+ if (searchResult.results.length === 0) return searchResult.message;
1463
+
1464
+ let header = "";
1465
+ if (searchResult.date) {
1466
+ header = `Events on ${searchResult.date}:\n\n`;
1467
+ }
1468
+
1469
+ const results = searchResult.results.map(r => {
1470
+ let result = `[${r.rank || r.index}]`;
1471
+ if (r.score) result += ` Score: ${r.score}`;
1472
+ result += `\nEvent: ${r.title}`;
1473
+ if (r.isAllDay) result += " (All Day)";
1474
+ result += `\nCalendar: ${r.calendar}\nStart: ${r.start}\nEnd: ${r.end}`;
1475
+ if (r.location) result += `\nLocation: ${r.location}`;
1476
+ if (r.attendees && r.attendees.length > 0) {
1477
+ const attendeeList = r.attendees.map(a => `${a.name} (${a.status})`).join(", ");
1478
+ result += `\nAttendees: ${attendeeList}`;
1479
+ }
1480
+ if (r.notes) result += `\nNotes: ${r.notes}`;
1481
+ return result + "\n---";
1482
+ }).join("\n");
1483
+
1484
+ return header + results;
1485
+ }
1486
+
1487
+ export function formatFreeTimeResults(result) {
1488
+ if (!result.success) return result.error;
1489
+
1490
+ let output = `Free Time on ${result.date}\n`;
1491
+ output += `Working hours: ${result.workingHours}\n`;
1492
+ output += `Events scheduled: ${result.totalEvents}\n\n`;
1493
+
1494
+ if (result.freeSlots.length === 0) {
1495
+ output += "No free time available during working hours.";
1496
+ } else {
1497
+ output += "Available slots:\n";
1498
+ for (const slot of result.freeSlots) {
1499
+ const hours = Math.floor(slot.duration / 60);
1500
+ const mins = slot.duration % 60;
1501
+ const durationStr = hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
1502
+ output += ` • ${slot.start} - ${slot.end} (${durationStr})\n`;
1503
+ }
1504
+ const totalHours = Math.floor(result.totalFreeMinutes / 60);
1505
+ const totalMins = result.totalFreeMinutes % 60;
1506
+ output += `\nTotal free time: ${totalHours}h ${totalMins}m`;
1507
+ }
1508
+
1509
+ return output;
1510
+ }
1511
+
1512
+ // ============ NEW TOOLS FORMATTING - PHASE 1 ============
1513
+
1514
+ // Format mail_senders results
1515
+ export function formatSendersResults(senders) {
1516
+ if (!senders || senders.length === 0) {
1517
+ return "No senders found in the index.";
1518
+ }
1519
+
1520
+ let output = `Top ${senders.length} email senders:\n\n`;
1521
+ for (let i = 0; i < senders.length; i++) {
1522
+ const s = senders[i];
1523
+ output += ` ${i + 1}. ${s.email} (${s.messageCount} emails)\n`;
1524
+ }
1525
+ return output;
1526
+ }
1527
+
1528
+ // Format messages_contacts results
1529
+ export function formatMessageContactsResults(contacts) {
1530
+ if (!contacts || contacts.length === 0) {
1531
+ return "No message contacts found.";
1532
+ }
1533
+
1534
+ let output = `Found ${contacts.length} contacts:\n\n`;
1535
+ for (const c of contacts) {
1536
+ output += ` • ${c.contact}\n`;
1537
+ output += ` Messages: ${c.messageCount} | Last: ${c.lastMessageDate || "Unknown"}\n`;
1538
+ }
1539
+ return output;
1540
+ }
1541
+
1542
+ // Format calendar_upcoming results
1543
+ export function formatUpcomingEventsResults(result) {
1544
+ const events = result.events || result; // Handle both new {events, showing, hasMore} and old array format
1545
+ if (!events || events.length === 0) {
1546
+ return "No upcoming events found.";
1547
+ }
1548
+
1549
+ let output = "";
1550
+ for (let i = 0; i < events.length; i++) {
1551
+ const e = events[i];
1552
+ output += `${i + 1}. ${e.title || "No title"}${e.isAllDay ? " (All Day)" : ""}\n`;
1553
+ output += ` ${e.start}${e.isAllDay ? "" : ` - ${e.end}`}\n`;
1554
+ output += ` Calendar: ${e.calendar || "Unknown"}`;
1555
+ if (e.location) output += ` | Location: ${e.location}`;
1556
+ output += "\n\n";
1557
+ }
1558
+
1559
+ return output;
1560
+ }
1561
+
1562
+ // ============ NEW TOOLS FORMATTING - PHASE 2 ============
1563
+
1564
+ // Format mail_unread_count results
1565
+ export function formatUnreadCountResults(result) {
1566
+ if (result.error) {
1567
+ return `Error getting unread count: ${result.error}`;
1568
+ }
1569
+ return `Unread emails in ${result.mailbox}: ${result.unreadCount}`;
1570
+ }
1571
+
1572
+ // Format calendar_week results
1573
+ export function formatWeekEventsResults(result) {
1574
+ if (result.error) {
1575
+ return `Error getting week events: ${result.error}`;
1576
+ }
1577
+
1578
+ const events = result.events || [];
1579
+ if (events.length === 0) {
1580
+ return `No events scheduled for ${result.weekLabel} (${result.dateRange})`;
1581
+ }
1582
+
1583
+ let output = `${result.weekLabel} (${result.dateRange})\n`;
1584
+ output += `${events.length} events scheduled:\n\n`;
1585
+
1586
+ // Group by day
1587
+ const byDay = {};
1588
+ for (const e of events) {
1589
+ const day = e.start.split(" ")[0]; // Extract date part
1590
+ if (!byDay[day]) byDay[day] = [];
1591
+ byDay[day].push(e);
1592
+ }
1593
+
1594
+ for (const [day, dayEvents] of Object.entries(byDay)) {
1595
+ output += `${day}:\n`;
1596
+ for (const e of dayEvents) {
1597
+ const time = e.isAllDay ? "All Day" : e.start.split(" ")[1];
1598
+ output += ` • ${time} - ${e.title || "No title"}`;
1599
+ if (e.calendar) output += ` [${e.calendar}]`;
1600
+ output += "\n";
1601
+ }
1602
+ output += "\n";
1603
+ }
1604
+
1605
+ return output;
1606
+ }
1607
+
1608
+ // ============ NEW TOOLS FORMATTING - PHASE 3 ============
1609
+
1610
+ // Format mail_thread results
1611
+ export function formatEmailThreadResults(result) {
1612
+ if (result.error) {
1613
+ return `Error: ${result.error}`;
1614
+ }
1615
+
1616
+ const emails = result.emails || [];
1617
+ if (emails.length === 0) {
1618
+ return "No related emails found in thread.";
1619
+ }
1620
+
1621
+ let output = `Email Thread: "${result.baseSubject}"\n`;
1622
+ output += `Found ${result.threadCount} related emails:\n\n`;
1623
+
1624
+ for (let i = 0; i < emails.length; i++) {
1625
+ const e = emails[i];
1626
+ output += `${i + 1}. ${e.from || "Unknown"}\n`;
1627
+ output += ` Subject: ${e.subject || "No subject"}\n`;
1628
+ output += ` Date: ${e.date || "Unknown"}\n`;
1629
+ output += ` File: ${e.filePath}\n\n`;
1630
+ }
1631
+ return output;
1632
+ }
1633
+
1634
+ // Format calendar_recurring results
1635
+ export function formatRecurringEventsResults(result) {
1636
+ const events = result.events || result; // Handle both new {events, showing, hasMore} and old array format
1637
+ if (!events || events.length === 0) {
1638
+ return "No recurring events found.";
1639
+ }
1640
+
1641
+ let output = "";
1642
+ for (const e of events) {
1643
+ output += ` • ${e.title || "No title"}${e.isAllDay ? " (All Day)" : ""}\n`;
1644
+ output += ` Next: ${e.start} | Calendar: ${e.calendar || "Unknown"}\n`;
1645
+ output += ` Occurrences: ${e.occurrenceCount}\n\n`;
1646
+ }
1647
+
1648
+ return output;
1649
+ }
1650
+
1651
+ // Export internal functions for testing
1652
+ export { expandQuery, parseNegation, extractKeywords };