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/LICENSE +21 -0
- package/README.md +163 -0
- package/contacts.js +433 -0
- package/index.js +1502 -0
- package/indexer.js +1849 -0
- package/lib/audit.js +1083 -0
- package/lib/shell.js +321 -0
- package/lib/validators.js +432 -0
- package/package.json +116 -0
- package/scripts/audit-index.js +94 -0
- package/search.js +1652 -0
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 };
|