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/indexer.js ADDED
@@ -0,0 +1,1849 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { exec } from "child_process";
4
+ import { promisify } from "util";
5
+ import * as lancedb from "@lancedb/lancedb";
6
+ import { pipeline } from "@xenova/transformers";
7
+ import {
8
+ validateEmailPath,
9
+ validateMailboxName,
10
+ escapeAppleScript,
11
+ validateLimit,
12
+ validateLanceDBId,
13
+ escapeSQL,
14
+ stripHtmlTags
15
+ } from "./lib/validators.js";
16
+ import { safeSqlite3Json, safeOsascript } from "./lib/shell.js";
17
+
18
+ // Re-export contact functions for use by other modules
19
+ export {
20
+ loadContacts,
21
+ resolveEmail,
22
+ resolvePhone,
23
+ resolveByName,
24
+ lookupContact,
25
+ searchContacts,
26
+ getContactIdentifiers,
27
+ formatContact,
28
+ getContactStats
29
+ } from "./contacts.js";
30
+
31
+ const execAsync = promisify(exec);
32
+
33
+ // Support env var overrides for testing with separate index
34
+ export const INDEX_DIR = process.env.APPLE_TOOLS_INDEX_DIR ||
35
+ path.join(process.env.HOME, ".apple-tools-mcp", "vector-index");
36
+ const META_FILE = process.env.APPLE_TOOLS_META_FILE ||
37
+ path.join(process.env.HOME, ".apple-tools-mcp", "index-meta.json");
38
+ // Support filtering by date for testing (default: null = no filter)
39
+ const DAYS_BACK = process.env.APPLE_TOOLS_INDEX_DAYS_BACK ?
40
+ parseInt(process.env.APPLE_TOOLS_INDEX_DAYS_BACK, 10) : null;
41
+ const MAIL_DIR = path.join(process.env.HOME, "Library", "Mail");
42
+
43
+ // Load/save index metadata (timestamps, etc.)
44
+ function loadIndexMeta() {
45
+ try {
46
+ if (fs.existsSync(META_FILE)) {
47
+ return JSON.parse(fs.readFileSync(META_FILE, "utf-8"));
48
+ }
49
+ } catch {}
50
+ return {};
51
+ }
52
+
53
+ function saveIndexMeta(meta) {
54
+ const dir = path.dirname(META_FILE);
55
+ if (!fs.existsSync(dir)) {
56
+ fs.mkdirSync(dir, { recursive: true });
57
+ }
58
+ fs.writeFileSync(META_FILE, JSON.stringify(meta, null, 2));
59
+ }
60
+ const MESSAGES_DB = path.join(process.env.HOME, "Library", "Messages", "chat.db");
61
+ const CALENDAR_DB = path.join(process.env.HOME, "Library", "Group Containers", "group.com.apple.calendar", "Calendar.sqlitedb");
62
+ const BATCH_SIZE = 32; // Optimized for batch embedding throughput
63
+ const BATCH_DELAY_MS = 100; // Throttle to prevent thermal crashes
64
+
65
+ // Mac Absolute Time epoch: Jan 1, 2001 00:00:00 UTC
66
+ const MAC_ABSOLUTE_EPOCH = 978307200;
67
+
68
+ let embeddingPipeline = null;
69
+ let db = null;
70
+ let tables = {};
71
+
72
+ async function getEmbedder() {
73
+ if (!embeddingPipeline) {
74
+ console.error("Loading embedding model (first time may take a minute)...");
75
+ embeddingPipeline = await pipeline("feature-extraction", "Xenova/all-MiniLM-L6-v2");
76
+ console.error("Embedding model loaded.");
77
+ }
78
+ return embeddingPipeline;
79
+ }
80
+
81
+ export async function embed(text) {
82
+ const embedder = await getEmbedder();
83
+ const result = await embedder(text, { pooling: "mean", normalize: true });
84
+ return Array.from(result.data);
85
+ }
86
+
87
+ // True batch embedding - single forward pass for multiple texts
88
+ const EMBEDDING_DIM = 384; // all-MiniLM-L6-v2 dimension
89
+
90
+ // Timeout wrapper for promises
91
+ function withTimeout(promise, timeoutMs, operation = "Operation") {
92
+ return Promise.race([
93
+ promise,
94
+ new Promise((_, reject) =>
95
+ setTimeout(() => reject(new Error(`${operation} timed out after ${timeoutMs}ms`)), timeoutMs)
96
+ )
97
+ ]);
98
+ }
99
+
100
+ export async function embedBatch(texts) {
101
+ if (texts.length === 0) return [];
102
+
103
+ const embedder = await getEmbedder();
104
+
105
+ // Add timeout protection: 30 seconds per batch
106
+ // This prevents the process from hanging if embedding gets stuck
107
+ const EMBEDDING_TIMEOUT_MS = 30000;
108
+
109
+ try {
110
+ // Process all texts in a single forward pass
111
+ const result = await withTimeout(
112
+ embedder(texts, { pooling: "mean", normalize: true }),
113
+ EMBEDDING_TIMEOUT_MS,
114
+ "Batch embedding"
115
+ );
116
+
117
+ // Result.data is a flat Float32Array of shape [batch_size * embedding_dim]
118
+ const embeddings = [];
119
+ for (let i = 0; i < texts.length; i++) {
120
+ const start = i * EMBEDDING_DIM;
121
+ const end = start + EMBEDDING_DIM;
122
+ embeddings.push(Array.from(result.data.slice(start, end)));
123
+ }
124
+ return embeddings;
125
+ } catch (error) {
126
+ if (error.message.includes("timed out")) {
127
+ console.error(`⚠️ Embedding batch timed out (${texts.length} texts). Retrying with smaller batches...`);
128
+ // Fall back to processing one at a time with timeouts
129
+ const embeddings = [];
130
+ for (const text of texts) {
131
+ try {
132
+ const emb = await withTimeout(embed(text), 10000, "Single embedding");
133
+ embeddings.push(emb);
134
+ } catch (e) {
135
+ console.error(`Failed to embed text: ${e.message}`);
136
+ // Return zero vector as fallback
137
+ embeddings.push(new Array(EMBEDDING_DIM).fill(0));
138
+ }
139
+ }
140
+ return embeddings;
141
+ }
142
+ throw error;
143
+ }
144
+ }
145
+
146
+ // ============ UTILITY FUNCTIONS ============
147
+
148
+ // Extract email address from "Name <email>" format
149
+ function extractEmail(str) {
150
+ if (!str) return "";
151
+ const match = str.match(/<([^>]+)>/);
152
+ if (match) return match[1].toLowerCase();
153
+ // If no angle brackets, check if it looks like an email
154
+ if (str.includes("@")) return str.trim().toLowerCase();
155
+ return ""; // Return empty string if no email found
156
+ }
157
+
158
+ // Extract all email addresses from a string (handles multiple recipients)
159
+ function extractEmails(str) {
160
+ if (!str) return [];
161
+ const emails = [];
162
+ // Split by comma and process each
163
+ for (const part of str.split(",")) {
164
+ const email = extractEmail(part.trim());
165
+ if (email && email.includes("@")) {
166
+ emails.push(email);
167
+ }
168
+ }
169
+ return emails;
170
+ }
171
+
172
+ // Parse date string to timestamp
173
+ function parseDateTime(dateStr) {
174
+ if (!dateStr) return 0;
175
+ try {
176
+ // Try direct parsing
177
+ let d = new Date(dateStr);
178
+ if (!isNaN(d.getTime())) return d.getTime();
179
+
180
+ // Handle AppleScript format: "Friday, January 10, 2025 at 9:00:00 AM"
181
+ const appleMatch = dateStr.match(/(\w+), (\w+ \d+, \d+) at (\d+:\d+:\d+ [AP]M)/i);
182
+ if (appleMatch) {
183
+ d = new Date(`${appleMatch[2]} ${appleMatch[3]}`);
184
+ if (!isNaN(d.getTime())) return d.getTime();
185
+ }
186
+
187
+ return 0;
188
+ } catch {
189
+ return 0;
190
+ }
191
+ }
192
+
193
+ // ============ EMAIL INDEXING ============
194
+
195
+ // Extract mailbox name from file path (e.g., "INBOX.mbox" -> "INBOX")
196
+ function extractMailbox(filePath) {
197
+ const match = filePath.match(/([^/]+)\.mbox/);
198
+ return match ? match[1] : "Unknown";
199
+ }
200
+
201
+ // Mailbox priority for deduplication - lower number = higher priority
202
+ // When the same email exists in multiple folders (IMAP behavior), we prefer
203
+ // to index the copy from higher-priority folders so mailbox searches work correctly
204
+ const MAILBOX_PRIORITY = {
205
+ // Highest priority - user's primary mailboxes
206
+ "INBOX": 1,
207
+ "Sent": 2,
208
+ "Sent Messages": 2,
209
+ "Sent Mail": 2,
210
+ "Drafts": 3,
211
+ "Flagged": 4,
212
+
213
+ // Medium priority - organizational folders
214
+ "Archive": 5,
215
+ "Archives": 5,
216
+
217
+ // Low priority - catch-all folders
218
+ "All Mail": 90,
219
+ "[Gmail]": 90,
220
+
221
+ // Lowest priority - folders users rarely search intentionally
222
+ "Junk": 95,
223
+ "Spam": 95,
224
+ "Junk E-mail": 95,
225
+ "Trash": 99,
226
+ "Deleted Messages": 99,
227
+ "Deleted Items": 99,
228
+ "Bin": 99
229
+ };
230
+
231
+ // Get priority for a mailbox (lower = higher priority)
232
+ // Unknown/custom folders get priority 50 (between Archive and All Mail)
233
+ function getMailboxPriority(filePath) {
234
+ const mailbox = extractMailbox(filePath);
235
+ return MAILBOX_PRIORITY[mailbox] ?? 50;
236
+ }
237
+
238
+ function parseEmlx(filePath) {
239
+ try {
240
+ const rawContent = fs.readFileSync(filePath, "utf-8");
241
+ const isPartial = filePath.endsWith(".partial.emlx");
242
+
243
+ // Handle Apple Mail envelope format: first line is byte count, followed by newline
244
+ // Strip the preamble to get the actual RFC822 email content
245
+ let content = rawContent;
246
+ const lines = rawContent.split("\n");
247
+ if (lines[0] && /^\d+\s*$/.test(lines[0])) {
248
+ // First line is a number (byte count) with optional whitespace, skip it
249
+ content = lines.slice(1).join("\n");
250
+ }
251
+
252
+ // Extract headers
253
+ const fromMatch = content.match(/^From:\s*(.+)$/m);
254
+ const subjectMatch = content.match(/^Subject:\s*(.+)$/m);
255
+ const dateMatch = content.match(/^Date:\s*(.+)$/m);
256
+ const toMatch = content.match(/^To:\s*(.+)$/m);
257
+ const ccMatch = content.match(/^Cc:\s*(.+)$/m);
258
+ const messageIdMatch = content.match(/^Message-ID:\s*(.+)$/im);
259
+ const flaggedMatch = content.match(/^X-Flagged:\s*(.+)$/im) || content.match(/flags.*flagged/i);
260
+
261
+ // Check for attachments
262
+ const hasAttachment = /Content-Disposition:\s*attachment/i.test(content) ||
263
+ /multipart\/mixed/i.test(content) ||
264
+ /filename=/i.test(content);
265
+
266
+ // Extract body
267
+ const headerEnd = content.search(/\r?\n\r?\n/);
268
+ let body = "";
269
+ if (headerEnd > 0) {
270
+ body = content.substring(headerEnd + 2, Math.min(headerEnd + 2000, content.length));
271
+ // Use safe HTML stripping to prevent ReDoS
272
+ body = stripHtmlTags(body);
273
+ }
274
+
275
+ const fromRaw = fromMatch?.[1]?.trim() || "";
276
+ const toRaw = toMatch?.[1]?.trim() || "";
277
+ const ccRaw = ccMatch?.[1]?.trim() || "";
278
+ const subject = subjectMatch?.[1]?.trim() || "";
279
+ const date = dateMatch?.[1]?.trim() || "";
280
+
281
+ // Extract normalized email addresses
282
+ const fromEmail = extractEmail(fromRaw);
283
+ const toEmails = extractEmails(toRaw);
284
+ const ccEmails = extractEmails(ccRaw);
285
+ const allRecipients = [...toEmails, ...ccEmails];
286
+
287
+ // Parse date to timestamp
288
+ const dateTimestamp = parseDateTime(date);
289
+
290
+ // Determine mailbox and if sent
291
+ const mailbox = extractMailbox(filePath);
292
+ const isSent = mailbox.toLowerCase().includes("sent");
293
+ const isFlagged = !!flaggedMatch;
294
+
295
+ const searchText = `From: ${fromRaw}\nTo: ${toRaw}\nSubject: ${subject}\n${body}`.substring(0, 1000);
296
+ const messageId = messageIdMatch?.[1]?.trim() || "";
297
+
298
+ // For partial emails, allow indexing even with minimal data
299
+ // Partial emails may lack complete headers but should still be indexed
300
+ const hasMinimalContent = subject.length > 0 || fromRaw.length > 0 || searchText.length > 10;
301
+
302
+ if (!hasMinimalContent && isPartial) {
303
+ console.error(`[PARTIAL] Skipping ${filePath}: insufficient content (subject: ${subject.length}, from: ${fromRaw.length}, searchText: ${searchText.length})`);
304
+ return null;
305
+ }
306
+
307
+ return {
308
+ from: fromRaw,
309
+ fromEmail,
310
+ to: toRaw,
311
+ toEmails: allRecipients.join(","),
312
+ subject,
313
+ date,
314
+ dateTimestamp,
315
+ hasAttachment,
316
+ mailbox,
317
+ isSent,
318
+ isFlagged,
319
+ messageId,
320
+ body: body.substring(0, 500),
321
+ searchText,
322
+ filePath
323
+ };
324
+ } catch (e) {
325
+ const fileName = filePath.split("/").pop();
326
+ const isPartial = filePath.endsWith(".partial.emlx");
327
+ console.error(`[PARSE_ERROR] ${isPartial ? "PARTIAL" : "COMPLETE"} ${fileName}: ${e.message}`);
328
+ return null;
329
+ }
330
+ }
331
+
332
+ // Full scan - used for first run and rebuild_index
333
+ // Includes both .emlx and .partial.emlx files (partial = not fully downloaded via IMAP)
334
+ async function findAllEmlxFiles() {
335
+ try {
336
+ // Find both .emlx and .partial.emlx files
337
+ const cmd = `find "${MAIL_DIR}" \\( -name "*.emlx" -o -name "*.partial.emlx" \\) 2>/dev/null`;
338
+ const { stdout } = await execAsync(cmd, { encoding: "utf-8", maxBuffer: 50 * 1024 * 1024, timeout: 120000 });
339
+ return stdout.trim().split("\n").filter(f => f);
340
+ } catch (e) {
341
+ console.error("Error finding emlx files:", e.message);
342
+ return [];
343
+ }
344
+ }
345
+
346
+ // Fast incremental scan - uses find with -mtime filter (more reliable than mdfind/Spotlight)
347
+ async function findNewEmlxFiles(sinceTimestamp) {
348
+ if (!sinceTimestamp) {
349
+ // First run - fall back to full scan
350
+ console.error("No previous index timestamp, doing full scan...");
351
+ return findAllEmlxFiles();
352
+ }
353
+
354
+ try {
355
+ // Convert timestamp to days ago for -mtime filter
356
+ // -mtime -N means modified in the last N days
357
+ const daysAgo = Math.ceil((Date.now() - sinceTimestamp) / (24 * 60 * 60 * 1000));
358
+
359
+ // Use find with -mtime instead of mdfind for reliability
360
+ // find is more reliable than Spotlight which can have stale/incomplete indexes
361
+ const cmd = `find "${MAIL_DIR}" \\( -name "*.emlx" -o -name "*.partial.emlx" \\) -mtime -${daysAgo} 2>/dev/null`;
362
+ const { stdout } = await execAsync(cmd, { encoding: "utf-8", maxBuffer: 50 * 1024 * 1024, timeout: 120000 });
363
+ const files = stdout.trim().split("\n").filter(f => f);
364
+ console.error(`find found ${files.length} new/modified emails in last ${daysAgo} days`);
365
+ return files;
366
+ } catch (e) {
367
+ console.error("find failed, falling back to full scan:", e.message);
368
+ return findAllEmlxFiles();
369
+ }
370
+ }
371
+
372
+ // ============ MESSAGES INDEXING ============
373
+
374
+ /**
375
+ * Extract text from NSAttributedString BLOB (attributedBody field)
376
+ * macOS stores message text in attributedBody as NSKeyedArchiver format
377
+ * The text is embedded as UTF-8 after the NSString class marker
378
+ */
379
+ /**
380
+ * Validate extracted text to ensure it's not garbage
381
+ */
382
+ function validateExtractedText(text) {
383
+ if (!text || typeof text !== 'string') return false;
384
+ text = text.trim();
385
+ if (text.length < 1 || text.length > 10000) return false;
386
+
387
+ // Must be mostly printable characters
388
+ const printableRatio = (text.match(/[\x20-\x7E\u00A0-\uFFFF]/g) || []).length / text.length;
389
+ return printableRatio >= 0.8;
390
+ }
391
+
392
+ /**
393
+ * Strategy 1: Current NSString+'+' pattern (backward compatibility)
394
+ */
395
+ function extractStrategy1_CurrentPattern(buf) {
396
+ const nsStringMarker = Buffer.from('NSString');
397
+ let markerIndex = buf.indexOf(nsStringMarker);
398
+ if (markerIndex === -1) return null;
399
+
400
+ const searchStart = markerIndex + nsStringMarker.length;
401
+ const plusIndex = buf.indexOf(0x2B, searchStart); // '+' character
402
+ if (plusIndex === -1 || plusIndex >= buf.length - 2) return null;
403
+
404
+ const lengthByte = buf[plusIndex + 1];
405
+ const textStart = plusIndex + 2;
406
+ if (lengthByte === 0 || textStart >= buf.length) return null;
407
+
408
+ let textLength = lengthByte;
409
+ let actualTextStart = textStart;
410
+
411
+ if (lengthByte & 0x80) {
412
+ actualTextStart = textStart;
413
+ textLength = Math.min(500, buf.length - actualTextStart);
414
+ }
415
+
416
+ const textEnd = Math.min(actualTextStart + textLength, buf.length);
417
+ let text = buf.slice(actualTextStart, textEnd).toString('utf-8');
418
+
419
+ const cleanEnd = text.search(/[\x00-\x08\x0B\x0C\x0E-\x1F]|(\x84\x84)/);
420
+ if (cleanEnd > 0) {
421
+ text = text.substring(0, cleanEnd);
422
+ }
423
+
424
+ return text.trim();
425
+ }
426
+
427
+ /**
428
+ * Strategy 2: Direct UTF-8 scanning - find longest printable sequence
429
+ */
430
+ function extractStrategy2_DirectUTF8Scan(buf) {
431
+ const fullText = buf.toString('utf-8');
432
+ // Find sequences of printable characters at least 20 chars long
433
+ const regex = /[\x20-\x7E\u00A0-\uFFFF]{20,}/g;
434
+ const matches = fullText.match(regex);
435
+
436
+ if (!matches || matches.length === 0) return null;
437
+
438
+ // Return longest match
439
+ return matches.reduce((a, b) => a.length > b.length ? a : b).trim();
440
+ }
441
+
442
+ /**
443
+ * Strategy 3: NSKeyedArchiver $objects parser - handles modern macOS format
444
+ */
445
+ function extractStrategy3_NSKeyedArchiver(buf) {
446
+ const objectsMarker = Buffer.from('$objects');
447
+ const objectsIndex = buf.indexOf(objectsMarker);
448
+ if (objectsIndex === -1) return null;
449
+
450
+ const searchStart = objectsIndex + objectsMarker.length;
451
+ const nsStringMarker = Buffer.from('NSString');
452
+ const extractedTexts = [];
453
+
454
+ let currentPos = searchStart;
455
+ while (currentPos < buf.length - 100) {
456
+ const nsIdx = buf.indexOf(nsStringMarker, currentPos);
457
+ if (nsIdx === -1) break;
458
+
459
+ // Try to extract text after this NSString marker
460
+ const textStart = nsIdx + nsStringMarker.length + 10; // Skip class definition
461
+ const potentialText = buf.slice(textStart, Math.min(textStart + 500, buf.length))
462
+ .toString('utf-8');
463
+
464
+ const cleanEnd = potentialText.search(/[\x00-\x08\x0B\x0C\x0E-\x1F]/);
465
+ if (cleanEnd > 5) {
466
+ const extracted = potentialText.substring(0, cleanEnd).trim();
467
+ if (extracted.length > 10) {
468
+ extractedTexts.push(extracted);
469
+ }
470
+ }
471
+
472
+ currentPos = nsIdx + 20;
473
+ }
474
+
475
+ if (extractedTexts.length === 0) return null;
476
+ // Return longest extracted text
477
+ return extractedTexts.reduce((a, b) => a.length > b.length ? a : b);
478
+ }
479
+
480
+ /**
481
+ * Strategy 4: Regex-based pattern matching for common message structures
482
+ */
483
+ function extractStrategy4_RegexBased(buf) {
484
+ const text = buf.toString('utf-8', 0, Math.min(buf.length, 2000));
485
+
486
+ // Common patterns in messages
487
+ const patterns = [
488
+ /(?:wrote|said):\s*\n?\s*(.{20,})/i, // Reply pattern
489
+ /"(.{20,})"/, // Quoted text
490
+ /\n\s*([A-Z].{20,})\s*\n/, // Paragraph pattern
491
+ ];
492
+
493
+ for (const pattern of patterns) {
494
+ const match = text.match(pattern);
495
+ if (match && match[1]) {
496
+ const extracted = match[1].trim();
497
+ // Clean up control characters
498
+ const cleanEnd = extracted.search(/[\x00-\x08\x0B\x0C\x0E-\x1F]/);
499
+ if (cleanEnd > 20) {
500
+ return extracted.substring(0, cleanEnd).trim();
501
+ } else if (cleanEnd === -1 && extracted.length > 20) {
502
+ return extracted;
503
+ }
504
+ }
505
+ }
506
+
507
+ return null;
508
+ }
509
+
510
+ /**
511
+ * Multi-strategy attributedBody text extraction
512
+ * Tries multiple approaches to extract text from binary NSAttributedString format
513
+ */
514
+ function extractTextFromAttributedBody(buffer) {
515
+ if (!buffer || buffer.length === 0) return null;
516
+
517
+ try {
518
+ const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
519
+
520
+ // Try multiple strategies in order
521
+ const strategies = [
522
+ extractStrategy1_CurrentPattern, // Keep for backward compatibility
523
+ extractStrategy2_DirectUTF8Scan, // Fast, works on most formats
524
+ extractStrategy3_NSKeyedArchiver, // Modern macOS NSAttributedString
525
+ extractStrategy4_RegexBased // Pattern-based extraction
526
+ ];
527
+
528
+ for (const strategy of strategies) {
529
+ try {
530
+ const text = strategy(buf);
531
+ if (text && validateExtractedText(text)) {
532
+ return text;
533
+ }
534
+ } catch (e) {
535
+ // Try next strategy
536
+ continue;
537
+ }
538
+ }
539
+
540
+ return null;
541
+ } catch (e) {
542
+ return null;
543
+ }
544
+ }
545
+
546
+ function getMessages(sinceTimestamp = null) {
547
+ try {
548
+ // Enhanced query to get chat info, group chat detection, and attachments
549
+ // Include attributedBody for messages where text is NULL (newer macOS format)
550
+ // sinceTimestamp is Unix ms - convert to Mac Absolute Time nanoseconds
551
+ let dateFilter = '';
552
+
553
+ // NOTE: Index ALL messages without any date filtering
554
+ // Only emails use the DAYS_BACK filter - messages and calendar are comprehensive
555
+
556
+ if (sinceTimestamp) {
557
+ const macAbsoluteNs = (sinceTimestamp / 1000 - MAC_ABSOLUTE_EPOCH) * 1000000000;
558
+ dateFilter = `AND m.date >= ${macAbsoluteNs}`;
559
+ }
560
+
561
+ const query = `
562
+ SELECT
563
+ m.ROWID as id,
564
+ datetime(m.date/1000000000 + 978307200, 'unixepoch', 'localtime') as date,
565
+ m.date/1000000000 + 978307200 as dateTimestamp,
566
+ CASE WHEN m.is_from_me = 1 THEN 'Me' ELSE coalesce(h.id, 'Unknown') END as sender,
567
+ m.text,
568
+ CASE WHEN m.text IS NULL OR m.text = '' THEN hex(m.attributedBody) ELSE NULL END as attributedBodyHex,
569
+ c.ROWID as chatId,
570
+ c.chat_identifier as chatIdentifier,
571
+ c.display_name as chatName,
572
+ (SELECT COUNT(*) FROM chat_handle_join WHERE chat_id = c.ROWID) as participantCount,
573
+ (SELECT COUNT(*) FROM message_attachment_join WHERE message_id = m.ROWID) as attachmentCount
574
+ FROM message m
575
+ LEFT JOIN handle h ON m.handle_id = h.ROWID
576
+ LEFT JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
577
+ LEFT JOIN chat c ON cmj.chat_id = c.ROWID
578
+ WHERE ((m.text IS NOT NULL AND m.text <> '') OR m.attributedBody IS NOT NULL)
579
+ ${dateFilter}
580
+ ORDER BY m.date DESC
581
+ `;
582
+ const results = safeSqlite3Json(MESSAGES_DB, query, { timeout: 60000 });
583
+
584
+ // Post-process: extract text from attributedBody where text is NULL
585
+ let extractedCount = 0;
586
+ for (const msg of results) {
587
+ if ((!msg.text || msg.text === '') && msg.attributedBodyHex) {
588
+ try {
589
+ const buffer = Buffer.from(msg.attributedBodyHex, 'hex');
590
+ const extracted = extractTextFromAttributedBody(buffer);
591
+ if (extracted) {
592
+ msg.text = extracted;
593
+ extractedCount++;
594
+ }
595
+ } catch (e) {
596
+ // Skip this message if extraction fails
597
+ }
598
+ }
599
+ // Clean up - don't keep the hex blob in memory
600
+ delete msg.attributedBodyHex;
601
+ }
602
+
603
+ if (extractedCount > 0) {
604
+ console.error(`Extracted text from attributedBody for ${extractedCount} messages`);
605
+ }
606
+
607
+ // Filter out messages that still have no text
608
+ return results.filter(msg => msg.text && msg.text.trim() !== '');
609
+ } catch (e) {
610
+ console.error("Error reading messages:", e.message);
611
+ return [];
612
+ }
613
+ }
614
+
615
+ // ============ CALENDAR INDEXING ============
616
+
617
+ // Convert Mac Absolute Time to Unix timestamp (ms)
618
+ function macAbsoluteToUnixMs(macTime) {
619
+ if (!macTime) return 0;
620
+ return (macTime + MAC_ABSOLUTE_EPOCH) * 1000;
621
+ }
622
+
623
+ // Convert Unix timestamp (ms) to Mac Absolute Time
624
+ function unixMsToMacAbsolute(unixMs) {
625
+ return (unixMs / 1000) - MAC_ABSOLUTE_EPOCH;
626
+ }
627
+
628
+ // Format date from Mac Absolute Time
629
+ function formatMacAbsoluteDate(macTime) {
630
+ if (!macTime) return "";
631
+ const date = new Date(macAbsoluteToUnixMs(macTime));
632
+ return date.toLocaleString();
633
+ }
634
+
635
+ // Map participant status codes to human-readable strings
636
+ function getParticipantStatus(status) {
637
+ const statusMap = {
638
+ 0: "unknown",
639
+ 1: "accepted",
640
+ 2: "declined",
641
+ 3: "tentative",
642
+ 4: "pending",
643
+ 7: "needs-action"
644
+ };
645
+ return statusMap[status] || "unknown";
646
+ }
647
+
648
+ function getCalendarEvents() {
649
+ try {
650
+ // NOTE: We index ALL calendar events, not filtered by date
651
+ // Calendar events don't have a "file modification time" like emails do,
652
+ // so we can't use the mdfind + DAYS_BACK approach.
653
+ // Calendar indexing is always comprehensive - filtering by date would lose historical context.
654
+ const now = Date.now();
655
+ const pastDate = unixMsToMacAbsolute(now - 10 * 365 * 24 * 60 * 60 * 1000); // 10 years back
656
+ const futureDate = unixMsToMacAbsolute(now + 10 * 365 * 24 * 60 * 60 * 1000); // 10 years ahead
657
+
658
+ // Query OccurrenceCache for recurring events and their calculated occurrences
659
+ // This includes both recurring and non-recurring events
660
+ // GROUP BY to avoid duplicate entries for recurring events
661
+ const query = `
662
+ SELECT
663
+ ci.ROWID as id,
664
+ ci.summary,
665
+ MIN(COALESCE(oc.occurrence_end_date - (ci.end_date - ci.start_date), ci.start_date)) as start_date,
666
+ MAX(COALESCE(oc.occurrence_end_date, ci.end_date)) as end_date,
667
+ ci.all_day,
668
+ ci.description,
669
+ c.title as calendar_name,
670
+ l.title as location,
671
+ COUNT(DISTINCT oc.day) as occurrenceCount
672
+ FROM OccurrenceCache oc
673
+ INNER JOIN CalendarItem ci ON oc.event_id = ci.ROWID
674
+ LEFT JOIN Calendar c ON ci.calendar_id = c.ROWID
675
+ LEFT JOIN Location l ON ci.location_id = l.ROWID
676
+ WHERE oc.day IS NOT NULL
677
+ AND oc.day >= ${pastDate}
678
+ AND oc.day <= ${futureDate}
679
+ GROUP BY ci.ROWID
680
+ ORDER BY MIN(oc.day) ASC
681
+ `;
682
+
683
+ const rows = safeSqlite3Json(CALENDAR_DB, query, { timeout: 30000 });
684
+
685
+ // Get attendees for events that have them (separate query for efficiency)
686
+ const attendeesQuery = `
687
+ SELECT
688
+ p.owner_id,
689
+ COALESCE(i.display_name, p.email, 'Unknown') as name,
690
+ p.status
691
+ FROM Participant p
692
+ LEFT JOIN Identity i ON p.identity_id = i.ROWID
693
+ WHERE p.entity_type = 0
694
+ `;
695
+
696
+ let attendeesMap = new Map();
697
+ try {
698
+ const attendeesRows = safeSqlite3Json(CALENDAR_DB, attendeesQuery, { timeout: 10000 });
699
+
700
+ // Group attendees by owner_id (event id)
701
+ for (const att of attendeesRows) {
702
+ if (!attendeesMap.has(att.owner_id)) {
703
+ attendeesMap.set(att.owner_id, []);
704
+ }
705
+ attendeesMap.get(att.owner_id).push({
706
+ name: att.name,
707
+ status: getParticipantStatus(att.status)
708
+ });
709
+ }
710
+ } catch (e) {
711
+ console.error("Warning: Could not fetch attendees:", e.message);
712
+ }
713
+
714
+ const events = [];
715
+ for (const row of rows) {
716
+ if (!row.summary) continue;
717
+
718
+ const startTimestamp = macAbsoluteToUnixMs(row.start_date);
719
+ const attendees = attendeesMap.get(row.id) || [];
720
+
721
+ if (row.all_day === 1) {
722
+ console.error(`[Indexing] All-day event: "${row.summary}" at ${new Date(startTimestamp).toISOString()}`);
723
+ }
724
+
725
+ events.push({
726
+ dbId: row.id, // Stable database ID for deduplication
727
+ title: row.summary,
728
+ start: formatMacAbsoluteDate(row.start_date),
729
+ end: formatMacAbsoluteDate(row.end_date),
730
+ calendar: row.calendar_name || "Unknown",
731
+ location: row.location || "",
732
+ notes: row.description || "",
733
+ isAllDay: row.all_day === 1,
734
+ startTimestamp,
735
+ attendees: JSON.stringify(attendees),
736
+ attendeeCount: attendees.length
737
+ });
738
+ }
739
+
740
+ console.error(`Calendar: Retrieved ${events.length} events via SQLite (~${Math.round((Date.now() - now))}ms)`);
741
+ return events;
742
+ } catch (e) {
743
+ console.error("Error reading calendar:", e.message);
744
+ return [];
745
+ }
746
+ }
747
+
748
+ // ============ DATABASE FUNCTIONS ============
749
+
750
+ export async function initDB() {
751
+ if (db) return { db, tables };
752
+
753
+ fs.mkdirSync(INDEX_DIR, { recursive: true });
754
+ db = await lancedb.connect(INDEX_DIR);
755
+
756
+ const tableNames = await db.tableNames();
757
+ if (tableNames.includes("emails")) {
758
+ tables.emails = await db.openTable("emails");
759
+ }
760
+ if (tableNames.includes("messages")) {
761
+ tables.messages = await db.openTable("messages");
762
+ }
763
+ if (tableNames.includes("calendar")) {
764
+ tables.calendar = await db.openTable("calendar");
765
+ }
766
+
767
+ return { db, tables };
768
+ }
769
+
770
+ export async function clearEmailsTable() {
771
+ await initDB();
772
+ if (tables.emails) {
773
+ await db.dropTable("emails");
774
+ tables.emails = null;
775
+ console.error("Emails table dropped. Re-run indexing to rebuild.");
776
+ return { cleared: true };
777
+ }
778
+ return { cleared: false, message: "No emails table exists" };
779
+ }
780
+
781
+ export async function clearMessagesTable() {
782
+ await initDB();
783
+ if (tables.messages) {
784
+ await db.dropTable("messages");
785
+ tables.messages = null;
786
+ console.error("Messages table dropped. Re-run indexing to rebuild.");
787
+ return { cleared: true };
788
+ }
789
+ return { cleared: false, message: "No messages table exists" };
790
+ }
791
+
792
+ export async function clearCalendarTable() {
793
+ await initDB();
794
+ if (tables.calendar) {
795
+ await db.dropTable("calendar");
796
+ tables.calendar = null;
797
+ console.error("Calendar table dropped. Re-run indexing to rebuild.");
798
+ return { cleared: true };
799
+ }
800
+ return { cleared: false, message: "No calendar table exists" };
801
+ }
802
+
803
+ export async function rebuildIndex(sources = ["emails", "messages", "calendar"], progressCallback = null) {
804
+ const results = {
805
+ cleared: {},
806
+ indexed: {},
807
+ errors: []
808
+ };
809
+
810
+ // Clear requested sources
811
+ for (const source of sources) {
812
+ try {
813
+ if (source === "emails") {
814
+ const clearResult = await clearEmailsTable();
815
+ results.cleared.emails = clearResult.cleared;
816
+ } else if (source === "messages") {
817
+ const clearResult = await clearMessagesTable();
818
+ results.cleared.messages = clearResult.cleared;
819
+ } else if (source === "calendar") {
820
+ const clearResult = await clearCalendarTable();
821
+ results.cleared.calendar = clearResult.cleared;
822
+ }
823
+ } catch (e) {
824
+ results.errors.push({ source, phase: "clear", error: e.message });
825
+ }
826
+ }
827
+
828
+ // Reset module-level cache after dropping tables
829
+ // This ensures that initDB() will re-initialize and pick up the newly created tables
830
+ db = null;
831
+ tables = {};
832
+
833
+ // Re-index requested sources
834
+ for (const source of sources) {
835
+ try {
836
+ if (source === "emails") {
837
+ if (progressCallback) progressCallback("Indexing emails...");
838
+ // Force full scan for rebuild (forceFullScan = true)
839
+ const indexResult = await indexEmails(progressCallback, true);
840
+ results.indexed.emails = indexResult;
841
+ } else if (source === "messages") {
842
+ if (progressCallback) progressCallback("Indexing messages...");
843
+ // Force full scan for rebuild (forceFullScan = true)
844
+ const indexResult = await indexMessages(true);
845
+ results.indexed.messages = indexResult;
846
+ } else if (source === "calendar") {
847
+ if (progressCallback) progressCallback("Indexing calendar...");
848
+ const indexResult = await indexCalendar();
849
+ results.indexed.calendar = indexResult;
850
+ }
851
+ } catch (e) {
852
+ results.errors.push({ source, phase: "index", error: e.message });
853
+ }
854
+ }
855
+
856
+ return results;
857
+ }
858
+
859
+ async function getIndexedIds(tableName, idField) {
860
+ await initDB();
861
+ if (!tables[tableName]) return new Set();
862
+
863
+ try {
864
+ const results = await tables[tableName].query().select([idField]).toArray();
865
+ return new Set(results.map(r => r[idField]));
866
+ } catch (e) {
867
+ // THROW instead of returning empty Set to prevent duplicate entries
868
+ console.error(`Error getting indexed IDs from ${tableName}: ${e.message}`);
869
+ throw new Error(`Failed to get indexed IDs from ${tableName}: ${e.message}`);
870
+ }
871
+ }
872
+
873
+ /**
874
+ * Get indexed IDs with retry logic to prevent race conditions
875
+ * @param {string} tableName - Table name
876
+ * @param {string} idField - ID field name
877
+ * @param {number} maxRetries - Maximum retry attempts (default: 3)
878
+ * @returns {Promise<Set>} Set of indexed IDs
879
+ */
880
+ async function getIndexedIdsWithRetry(tableName, idField, maxRetries = 3) {
881
+ let lastError;
882
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
883
+ try {
884
+ return await getIndexedIds(tableName, idField);
885
+ } catch (e) {
886
+ lastError = e;
887
+ if (attempt < maxRetries) {
888
+ const backoff = Math.min(1000 * Math.pow(2, attempt - 1), 5000); // Exponential backoff, max 5s
889
+ console.error(`Retry ${attempt}/${maxRetries} after ${backoff}ms...`);
890
+ await new Promise(resolve => setTimeout(resolve, backoff));
891
+ }
892
+ }
893
+ }
894
+ throw lastError;
895
+ }
896
+
897
+ // ============ INDEX ALL CONTENT ============
898
+
899
+ export async function indexEmails(progressCallback = null, forceFullScan = false) {
900
+ await initDB();
901
+
902
+ // Load last index timestamp for incremental scanning
903
+ const meta = loadIndexMeta();
904
+ // Use a 1-day buffer to catch any edge cases (files modified during previous scan, etc.)
905
+ const ONE_DAY_MS = 24 * 60 * 60 * 1000;
906
+
907
+ // Determine the timestamp for mdfind filtering
908
+ let lastEmailIndexTime;
909
+ if (DAYS_BACK) {
910
+ // DAYS_BACK is set - use it for mdfind filter (e.g., for testing or rebuild with constraints)
911
+ lastEmailIndexTime = Date.now() - (DAYS_BACK * 24 * 60 * 60 * 1000);
912
+ console.error(`Using DAYS_BACK=${DAYS_BACK} for mdfind filter`);
913
+ } else if (forceFullScan) {
914
+ // Force full scan only when no DAYS_BACK constraint
915
+ lastEmailIndexTime = null;
916
+ console.error("Force full scan requested - finding all emails");
917
+ } else if (meta.lastEmailIndexTime) {
918
+ lastEmailIndexTime = meta.lastEmailIndexTime - ONE_DAY_MS;
919
+ } else {
920
+ lastEmailIndexTime = null;
921
+ }
922
+
923
+ // Save the current time BEFORE we start - any emails arriving during indexing
924
+ // will be picked up on the next incremental scan
925
+ const indexStartTime = Date.now();
926
+
927
+ // Use fast incremental scan if we have a previous timestamp
928
+ const startTime = Date.now();
929
+ const newFiles = await findNewEmlxFiles(lastEmailIndexTime);
930
+ console.error(`Found ${newFiles.length} new/modified email files (${Date.now() - startTime}ms)`);
931
+
932
+ const indexedPaths = await getIndexedIdsWithRetry("emails", "filePath");
933
+ const indexedMessageIds = await getIndexedIdsWithRetry("emails", "messageId");
934
+ console.error(`Already indexed: ${indexedPaths.size} emails`);
935
+
936
+ // Filter out already-indexed files
937
+ let toIndex = newFiles.filter(f => !indexedPaths.has(f));
938
+ console.error(`Need to index: ${toIndex.length} emails`);
939
+
940
+ // Sort by mailbox priority so higher-priority folders are indexed first
941
+ // This ensures INBOX copies are kept over Junk/Trash copies during deduplication
942
+ toIndex.sort((a, b) => getMailboxPriority(a) - getMailboxPriority(b));
943
+
944
+ if (toIndex.length === 0) {
945
+ // Save timestamp for next incremental scan
946
+ saveIndexMeta({ ...meta, lastEmailIndexTime: indexStartTime });
947
+ return { indexed: indexedPaths.size, added: 0 };
948
+ }
949
+
950
+ let processed = 0;
951
+ let skippedCount = { parseNull: 0, shortSearchText: 0, duplicateMessageId: 0 };
952
+
953
+ for (let i = 0; i < toIndex.length; i += BATCH_SIZE) {
954
+ const batch = toIndex.slice(i, i + BATCH_SIZE);
955
+
956
+ // Parse all emails in batch first
957
+ const parsedEmails = [];
958
+ const searchTexts = [];
959
+
960
+ for (const filePath of batch) {
961
+ const parsed = parseEmlx(filePath);
962
+ if (!parsed) {
963
+ skippedCount.parseNull++;
964
+ const fileName = filePath.split("/").pop();
965
+ console.error(`[SKIPPED] Parse failed: ${fileName}`);
966
+ continue;
967
+ }
968
+ if (parsed.searchText.length <= 20) {
969
+ skippedCount.shortSearchText++;
970
+ const fileName = filePath.split("/").pop();
971
+ console.error(`[SKIPPED] Short searchText (${parsed.searchText.length} chars): ${fileName} - Subject: "${parsed.subject}"`);
972
+ continue;
973
+ }
974
+ // Skip duplicate messageIds - same email in multiple folders (IMAP behavior)
975
+ if (parsed.messageId && indexedMessageIds.has(parsed.messageId)) {
976
+ skippedCount.duplicateMessageId++;
977
+ continue; // Skip this duplicate
978
+ }
979
+ // NOTE: Don't filter by email header date - mdfind already filters by file modification time
980
+ // The DAYS_BACK filter is applied at the mdfind level (finding recently modified files),
981
+ // not at the email content level (email header dates can be old for recently modified messages)
982
+ parsedEmails.push({ filePath, parsed });
983
+ searchTexts.push(parsed.searchText);
984
+ }
985
+
986
+ // Generate all embeddings in a single batch call
987
+ let vectors = [];
988
+ if (searchTexts.length > 0) {
989
+ try {
990
+ vectors = await embedBatch(searchTexts);
991
+ } catch (e) {
992
+ console.error("Batch embedding error:", e.message);
993
+ continue;
994
+ }
995
+ }
996
+
997
+ // Build records with vectors
998
+ const records = parsedEmails.map((item, idx) => ({
999
+ filePath: item.filePath,
1000
+ from: item.parsed.from,
1001
+ fromEmail: item.parsed.fromEmail,
1002
+ to: item.parsed.to,
1003
+ toEmails: item.parsed.toEmails,
1004
+ subject: item.parsed.subject,
1005
+ date: item.parsed.date,
1006
+ dateTimestamp: item.parsed.dateTimestamp,
1007
+ hasAttachment: item.parsed.hasAttachment,
1008
+ mailbox: item.parsed.mailbox,
1009
+ isSent: item.parsed.isSent,
1010
+ isFlagged: item.parsed.isFlagged,
1011
+ messageId: item.parsed.messageId,
1012
+ body: item.parsed.body,
1013
+ vector: vectors[idx]
1014
+ })).filter(r => r.vector); // Only records with vectors
1015
+
1016
+ if (records.length > 0) {
1017
+ // Defensive duplicate check: remove any duplicates within the batch itself
1018
+ const uniqueRecords = [];
1019
+ const seenPaths = new Set();
1020
+ for (const record of records) {
1021
+ if (!seenPaths.has(record.filePath)) {
1022
+ seenPaths.add(record.filePath);
1023
+ uniqueRecords.push(record);
1024
+ }
1025
+ }
1026
+
1027
+ if (uniqueRecords.length !== records.length) {
1028
+ const duplicatePaths = records
1029
+ .map(r => r.filePath)
1030
+ .filter((path, idx, arr) => arr.indexOf(path) !== idx);
1031
+ console.error(`WARNING: Removed ${records.length - uniqueRecords.length} duplicate(s) from batch. Paths: ${duplicatePaths.slice(0, 5).join(', ')}`);
1032
+ console.error(`This suggests findNewEmlxFiles() is returning duplicate paths. Investigate mdfind query.`);
1033
+ }
1034
+
1035
+ if (uniqueRecords.length > 0) {
1036
+ if (!tables.emails) {
1037
+ tables.emails = await db.createTable("emails", uniqueRecords, { mode: "overwrite" });
1038
+ } else {
1039
+ // Double-check: verify these IDs truly aren't in the index
1040
+ const currentIndexed = await getIndexedIdsWithRetry("emails", "filePath");
1041
+ const trulyNew = uniqueRecords.filter(r => !currentIndexed.has(r.filePath));
1042
+
1043
+ if (trulyNew.length < uniqueRecords.length) {
1044
+ console.error(`WARNING: ${uniqueRecords.length - trulyNew.length} records were already indexed. Filtering them out.`);
1045
+ }
1046
+
1047
+ if (trulyNew.length > 0) {
1048
+ const beforeCount = await tables.emails.countRows();
1049
+ await tables.emails.add(trulyNew);
1050
+ const afterCount = await tables.emails.countRows();
1051
+ const actualAdded = afterCount - beforeCount;
1052
+
1053
+ if (actualAdded !== trulyNew.length) {
1054
+ console.error(`WARNING: Expected to add ${trulyNew.length} records but index grew by ${actualAdded}. Possible duplicate issue.`);
1055
+ }
1056
+
1057
+ // FIX: Update the messageId tracking Set with newly added records
1058
+ // This ensures subsequent batches can detect messageIds from previous batches
1059
+ for (const record of trulyNew) {
1060
+ if (record.messageId) {
1061
+ indexedMessageIds.add(record.messageId);
1062
+ }
1063
+ }
1064
+ }
1065
+ }
1066
+ }
1067
+ }
1068
+
1069
+ processed += batch.length;
1070
+ console.error(`Indexed ${processed}/${toIndex.length} emails...`);
1071
+
1072
+ // Report progress after each batch
1073
+ if (progressCallback) {
1074
+ progressCallback(`emails-batch-${processed}/${toIndex.length}`);
1075
+ }
1076
+
1077
+ // Throttle to prevent thermal crashes
1078
+ if (i + BATCH_SIZE < toIndex.length) {
1079
+ await new Promise(r => setTimeout(r, BATCH_DELAY_MS));
1080
+ }
1081
+ }
1082
+
1083
+ // Save timestamp for next incremental scan
1084
+ saveIndexMeta({ ...meta, lastEmailIndexTime: indexStartTime });
1085
+
1086
+ // Log skip summary
1087
+ const totalSkipped = skippedCount.parseNull + skippedCount.shortSearchText + skippedCount.duplicateMessageId;
1088
+ console.error(`\nEmail indexing summary:`);
1089
+ console.error(` Files to index: ${toIndex.length}`);
1090
+ console.error(` Successfully indexed: ${processed}`);
1091
+ console.error(` Skipped - parse errors: ${skippedCount.parseNull}`);
1092
+ console.error(` Skipped - short searchText: ${skippedCount.shortSearchText}`);
1093
+ console.error(` Skipped - duplicate messageId: ${skippedCount.duplicateMessageId}`);
1094
+ console.error(` Total skipped: ${totalSkipped}`);
1095
+ console.error(` Discrepancy: ${toIndex.length - processed - totalSkipped}\n`);
1096
+
1097
+ // Return actual indexed count from database, not stale cache
1098
+ const finalIndexedPaths = await getIndexedIdsWithRetry("emails", "filePath");
1099
+ return { indexed: finalIndexedPaths.size, added: processed };
1100
+ }
1101
+
1102
+ export async function indexMessages(forceFullScan = false) {
1103
+ await initDB();
1104
+
1105
+ // Load last index timestamp for incremental scanning
1106
+ const meta = loadIndexMeta();
1107
+ // Use a 1-hour buffer to catch any edge cases
1108
+ const ONE_HOUR_MS = 60 * 60 * 1000;
1109
+ const lastMessageIndexTime = forceFullScan ? null :
1110
+ (meta.lastMessageIndexTime ? meta.lastMessageIndexTime - ONE_HOUR_MS : null);
1111
+
1112
+ // Save the current time BEFORE we start
1113
+ const indexStartTime = Date.now();
1114
+
1115
+ // Use incremental scan if we have a previous timestamp
1116
+ const messages = getMessages(lastMessageIndexTime);
1117
+ console.error(`Found ${messages.length} messages${lastMessageIndexTime ? ' (incremental)' : ' (full scan)'}`);
1118
+
1119
+ const indexed = await getIndexedIdsWithRetry("messages", "id");
1120
+ console.error(`Already indexed: ${indexed.size} messages`);
1121
+
1122
+ const toIndex = messages.filter(m => !indexed.has(String(m.id)));
1123
+ console.error(`Need to index: ${toIndex.length} messages`);
1124
+
1125
+ if (toIndex.length === 0) {
1126
+ // Save timestamp for next incremental scan
1127
+ saveIndexMeta({ ...meta, lastMessageIndexTime: indexStartTime });
1128
+ return { indexed: indexed.size, added: 0 };
1129
+ }
1130
+
1131
+ let processed = 0;
1132
+ for (let i = 0; i < toIndex.length; i += BATCH_SIZE) {
1133
+ const batch = toIndex.slice(i, i + BATCH_SIZE);
1134
+
1135
+ // Prepare all messages and search texts
1136
+ const preparedMsgs = [];
1137
+ const searchTexts = [];
1138
+
1139
+ for (const msg of batch) {
1140
+ const searchText = `From: ${msg.sender}\nMessage: ${msg.text}`.substring(0, 500);
1141
+ if (searchText.length > 10) {
1142
+ preparedMsgs.push(msg);
1143
+ searchTexts.push(searchText);
1144
+ }
1145
+ }
1146
+
1147
+ // Generate all embeddings in a single batch call
1148
+ let vectors = [];
1149
+ if (searchTexts.length > 0) {
1150
+ try {
1151
+ vectors = await embedBatch(searchTexts);
1152
+ } catch (e) {
1153
+ console.error("Batch embedding error:", e.message);
1154
+ continue;
1155
+ }
1156
+ }
1157
+
1158
+ // Build records with vectors
1159
+ const records = preparedMsgs.map((msg, idx) => {
1160
+ // Explicitly cast to boolean to avoid LanceDB schema inference issues
1161
+ const isGroupChat = Boolean((parseInt(msg.participantCount) || 0) > 2 || (msg.chatName && msg.chatName.length > 0));
1162
+ const hasAttachment = Boolean((parseInt(msg.attachmentCount) || 0) > 0);
1163
+ return {
1164
+ id: String(msg.id),
1165
+ date: msg.date,
1166
+ dateTimestamp: msg.dateTimestamp || 0,
1167
+ sender: msg.sender,
1168
+ text: msg.text?.substring(0, 500) || "",
1169
+ chatId: String(msg.chatId || ""),
1170
+ chatIdentifier: msg.chatIdentifier || "",
1171
+ chatName: msg.chatName || "",
1172
+ isGroupChat,
1173
+ hasAttachment,
1174
+ vector: vectors[idx]
1175
+ };
1176
+ }).filter(r => r.vector); // Only records with vectors
1177
+
1178
+ if (records.length > 0) {
1179
+ // Defensive duplicate check: remove any duplicates within the batch itself
1180
+ const uniqueRecords = [];
1181
+ const seenIds = new Set();
1182
+ for (const record of records) {
1183
+ if (!seenIds.has(record.id)) {
1184
+ seenIds.add(record.id);
1185
+ uniqueRecords.push(record);
1186
+ }
1187
+ }
1188
+
1189
+ if (uniqueRecords.length !== records.length) {
1190
+ const duplicateIds = records
1191
+ .map(r => r.id)
1192
+ .filter((id, idx, arr) => arr.indexOf(id) !== idx);
1193
+ console.error(`WARNING: Removed ${records.length - uniqueRecords.length} duplicate(s) from batch. IDs: ${duplicateIds.join(', ')}`);
1194
+ console.error(`This suggests getMessages() is returning duplicate rows. Investigate SQL query.`);
1195
+ }
1196
+
1197
+ if (uniqueRecords.length > 0) {
1198
+ if (!tables.messages) {
1199
+ tables.messages = await db.createTable("messages", uniqueRecords, { mode: "overwrite" });
1200
+ } else {
1201
+ // Double-check: verify these IDs truly aren't in the index
1202
+ const currentIndexed = await getIndexedIdsWithRetry("messages", "id");
1203
+ const trulyNew = uniqueRecords.filter(r => !currentIndexed.has(r.id));
1204
+
1205
+ if (trulyNew.length < uniqueRecords.length) {
1206
+ console.error(`WARNING: ${uniqueRecords.length - trulyNew.length} records were already indexed. Filtering them out.`);
1207
+ }
1208
+
1209
+ if (trulyNew.length > 0) {
1210
+ const beforeCount = await tables.messages.countRows();
1211
+ await tables.messages.add(trulyNew);
1212
+ const afterCount = await tables.messages.countRows();
1213
+ const actualAdded = afterCount - beforeCount;
1214
+
1215
+ if (actualAdded !== trulyNew.length) {
1216
+ console.error(`WARNING: Expected to add ${trulyNew.length} records but index grew by ${actualAdded}. Possible duplicate issue.`);
1217
+ }
1218
+ }
1219
+ }
1220
+ }
1221
+ }
1222
+
1223
+ processed += batch.length;
1224
+ console.error(`Indexed ${processed}/${toIndex.length} messages...`);
1225
+
1226
+ // Throttle to prevent thermal crashes
1227
+ if (i + BATCH_SIZE < toIndex.length) {
1228
+ await new Promise(r => setTimeout(r, BATCH_DELAY_MS));
1229
+ }
1230
+ }
1231
+
1232
+ // Save timestamp for next incremental scan
1233
+ saveIndexMeta({ ...meta, lastMessageIndexTime: indexStartTime });
1234
+
1235
+ return { indexed: indexed.size, added: processed };
1236
+ }
1237
+
1238
+ export async function indexCalendar() {
1239
+ await initDB();
1240
+
1241
+ const events = getCalendarEvents();
1242
+ console.error(`Found ${events.length} calendar events`);
1243
+
1244
+ // Get already indexed event IDs for incremental indexing
1245
+ const indexed = await getIndexedIdsWithRetry("calendar", "id");
1246
+ console.error(`Already indexed: ${indexed.size} calendar events`);
1247
+
1248
+ // Build set of current event IDs for stale detection
1249
+ // Use dbId only - must match the ID format used when storing events (line 1139)
1250
+ const currentIds = new Set(events.map(evt => `${evt.dbId}`));
1251
+
1252
+ // Find and remove stale entries (indexed but no longer in calendar)
1253
+ const staleIds = [...indexed].filter(id => !currentIds.has(id));
1254
+ if (staleIds.length > 0 && tables.calendar) {
1255
+ console.error(`Removing ${staleIds.length} stale calendar entries...`);
1256
+
1257
+ // Validate all IDs first
1258
+ const validIds = [];
1259
+ for (const staleId of staleIds) {
1260
+ const validatedId = validateLanceDBId(staleId);
1261
+ if (!validatedId) {
1262
+ console.error(`Skipping invalid stale ID: ${staleId.substring(0, 50)}...`);
1263
+ continue;
1264
+ }
1265
+ validIds.push(escapeSQL(validatedId));
1266
+ }
1267
+
1268
+ // Batch delete in chunks of 100 to avoid query size limits
1269
+ const BATCH_DELETE_SIZE = 100;
1270
+ for (let i = 0; i < validIds.length; i += BATCH_DELETE_SIZE) {
1271
+ const batch = validIds.slice(i, i + BATCH_DELETE_SIZE);
1272
+ if (batch.length > 0) {
1273
+ try {
1274
+ // Use OR conditions for batch delete (LanceDB compatible)
1275
+ const conditions = batch.map(id => `id = '${id}'`).join(' OR ');
1276
+ await tables.calendar.delete(conditions);
1277
+ } catch (e) {
1278
+ console.error(`Failed to delete batch: ${e.message}`);
1279
+ }
1280
+ }
1281
+ }
1282
+ }
1283
+
1284
+ // Prepare only NEW events and search texts
1285
+ const validEvents = [];
1286
+ const searchTexts = [];
1287
+
1288
+ for (const evt of events) {
1289
+ const eventId = `${evt.dbId}`; // Remove timestamp from ID to avoid duplicates
1290
+ // Skip if already indexed
1291
+ if (indexed.has(eventId)) continue;
1292
+
1293
+ const searchText = `Event: ${evt.title}\nCalendar: ${evt.calendar}\nLocation: ${evt.location}\nNotes: ${evt.notes}`.substring(0, 500);
1294
+ if (searchText.length > 10) {
1295
+ validEvents.push({ ...evt, id: eventId });
1296
+ searchTexts.push(searchText);
1297
+ }
1298
+ }
1299
+
1300
+ if (validEvents.length === 0) {
1301
+ console.error("No new calendar events to index");
1302
+ return { indexed: indexed.size, added: 0, removed: staleIds.length };
1303
+ }
1304
+
1305
+ console.error(`Indexing ${validEvents.length} new calendar events...`);
1306
+
1307
+ // Generate embeddings in batches
1308
+ const allVectors = [];
1309
+ for (let i = 0; i < searchTexts.length; i += BATCH_SIZE) {
1310
+ const batchTexts = searchTexts.slice(i, i + BATCH_SIZE);
1311
+ try {
1312
+ const batchVectors = await embedBatch(batchTexts);
1313
+ allVectors.push(...batchVectors);
1314
+ } catch (e) {
1315
+ console.error("Batch embedding error:", e.message);
1316
+ }
1317
+ console.error(`Embedded ${Math.min(i + BATCH_SIZE, searchTexts.length)}/${validEvents.length} new calendar events...`);
1318
+ }
1319
+
1320
+ // Build records with vectors
1321
+ const records = validEvents.map((evt, idx) => ({
1322
+ id: evt.id,
1323
+ title: evt.title,
1324
+ start: evt.start,
1325
+ end: evt.end,
1326
+ startTimestamp: evt.startTimestamp,
1327
+ calendar: evt.calendar,
1328
+ location: evt.location,
1329
+ notes: evt.notes?.substring(0, 200) || "",
1330
+ isAllDay: evt.isAllDay,
1331
+ attendees: evt.attendees || "[]",
1332
+ attendeeCount: evt.attendeeCount || 0,
1333
+ vector: allVectors[idx]
1334
+ })).filter(r => r.vector); // Only include records with valid vectors
1335
+
1336
+ if (records.length > 0) {
1337
+ // Defensive duplicate check: remove any duplicates within the batch itself
1338
+ const uniqueRecords = [];
1339
+ const seenIds = new Set();
1340
+ for (const record of records) {
1341
+ if (!seenIds.has(record.id)) {
1342
+ seenIds.add(record.id);
1343
+ uniqueRecords.push(record);
1344
+ }
1345
+ }
1346
+
1347
+ if (uniqueRecords.length !== records.length) {
1348
+ console.error(`Removed ${records.length - uniqueRecords.length} duplicate(s) from batch`);
1349
+ }
1350
+
1351
+ if (uniqueRecords.length > 0) {
1352
+ if (tables.calendar) {
1353
+ // Add to existing table
1354
+ await tables.calendar.add(uniqueRecords);
1355
+ } else {
1356
+ // Create new table
1357
+ tables.calendar = await db.createTable("calendar", uniqueRecords);
1358
+ }
1359
+ console.error(`Indexed ${uniqueRecords.length} new calendar events`);
1360
+ }
1361
+ }
1362
+
1363
+ return { indexed: indexed.size, added: records.length, removed: staleIds.length };
1364
+ }
1365
+
1366
+ export async function indexAll(progressCallback = null) {
1367
+ console.error("Starting full index...");
1368
+
1369
+ if (progressCallback) progressCallback('emails-start');
1370
+ const emailResult = await indexEmails(progressCallback);
1371
+ if (progressCallback) progressCallback('emails-complete');
1372
+
1373
+ if (progressCallback) progressCallback('messages-start');
1374
+ const messageResult = await indexMessages();
1375
+ if (progressCallback) progressCallback('messages-complete');
1376
+
1377
+ if (progressCallback) progressCallback('calendar-start');
1378
+ const calendarResult = await indexCalendar();
1379
+ if (progressCallback) progressCallback('calendar-complete');
1380
+
1381
+ console.error("Full index complete.");
1382
+ return { emails: emailResult, messages: messageResult, calendar: calendarResult };
1383
+ }
1384
+
1385
+ export async function isIndexReady(type = "emails") {
1386
+ await initDB();
1387
+ return tables[type] !== null && tables[type] !== undefined;
1388
+ }
1389
+
1390
+ // ============ DIRECT QUERIES (for recent items) ============
1391
+
1392
+ export async function getRecentEmails(limit = 10, daysBack = 7) {
1393
+ await initDB();
1394
+ if (!tables.emails) return [];
1395
+
1396
+ const cutoff = Date.now() - (daysBack * 24 * 60 * 60 * 1000);
1397
+
1398
+ try {
1399
+ // Fetch all and filter in JavaScript (LanceDB where clause with quoted columns is unreliable)
1400
+ const results = await tables.emails.query()
1401
+ .select(["filePath", "from", "fromEmail", "to", "subject", "date", "dateTimestamp", "hasAttachment", "mailbox", "isSent", "isFlagged", "messageId", "body"])
1402
+ .toArray();
1403
+
1404
+ // Filter by date, sort by timestamp descending, and limit
1405
+ return results
1406
+ .filter(r => r.dateTimestamp >= cutoff)
1407
+ .sort((a, b) => b.dateTimestamp - a.dateTimestamp)
1408
+ .slice(0, limit);
1409
+ } catch (e) {
1410
+ // Fallback with minimal columns for older indexes
1411
+ console.error("Using fallback query:", e.message);
1412
+ try {
1413
+ const results = await tables.emails.query()
1414
+ .select(["filePath", "from", "fromEmail", "to", "subject", "date", "dateTimestamp", "hasAttachment", "body"])
1415
+ .toArray();
1416
+ return results
1417
+ .filter(r => r.dateTimestamp >= cutoff)
1418
+ .sort((a, b) => b.dateTimestamp - a.dateTimestamp)
1419
+ .slice(0, limit);
1420
+ } catch (e2) {
1421
+ console.error("Error getting recent emails:", e2.message);
1422
+ return [];
1423
+ }
1424
+ }
1425
+ }
1426
+
1427
+ export async function getEmailsByDateRange(startTs, endTs) {
1428
+ await initDB();
1429
+ if (!tables.emails) return [];
1430
+
1431
+ try {
1432
+ // LanceDB where clause with quoted column names doesn't work reliably
1433
+ // So we fetch all and filter in JavaScript
1434
+ const results = await tables.emails.query()
1435
+ .select(["filePath", "from", "fromEmail", "to", "subject", "date", "dateTimestamp", "hasAttachment", "mailbox", "isSent", "isFlagged", "messageId", "body"])
1436
+ .toArray();
1437
+
1438
+ // Filter by date range and sort by timestamp descending
1439
+ return results
1440
+ .filter(r => r.dateTimestamp >= startTs && r.dateTimestamp < endTs)
1441
+ .sort((a, b) => b.dateTimestamp - a.dateTimestamp);
1442
+ } catch (e) {
1443
+ // Fallback with minimal columns for older indexes
1444
+ console.error("Using fallback query for date range:", e.message);
1445
+ try {
1446
+ const results = await tables.emails.query()
1447
+ .select(["filePath", "from", "fromEmail", "to", "subject", "date", "dateTimestamp", "hasAttachment", "body"])
1448
+ .toArray();
1449
+ return results
1450
+ .filter(r => r.dateTimestamp >= startTs && r.dateTimestamp < endTs)
1451
+ .sort((a, b) => b.dateTimestamp - a.dateTimestamp);
1452
+ } catch (e2) {
1453
+ console.error("Error getting emails by date range:", e2.message);
1454
+ return [];
1455
+ }
1456
+ }
1457
+ }
1458
+
1459
+ export async function getRecentMessages(limit = 10, daysBack = 1) {
1460
+ await initDB();
1461
+ if (!tables.messages) return { messages: [], hasMore: false };
1462
+
1463
+ try {
1464
+ const cutoff = Date.now() / 1000 - (daysBack * 24 * 60 * 60); // Messages use Unix timestamp
1465
+ const results = await tables.messages.query()
1466
+ .select(["id", "date", "dateTimestamp", "sender", "text", "chatId", "isGroupChat"])
1467
+ .toArray();
1468
+
1469
+ const filtered = results
1470
+ .filter(r => r.dateTimestamp >= cutoff)
1471
+ .sort((a, b) => b.dateTimestamp - a.dateTimestamp);
1472
+
1473
+ const hasMore = filtered.length > limit;
1474
+ const messages = filtered.slice(0, limit);
1475
+ return { messages, hasMore };
1476
+ } catch (e) {
1477
+ console.error("Error getting recent messages:", e.message);
1478
+ return { messages: [], hasMore: false };
1479
+ }
1480
+ }
1481
+
1482
+ export async function getConversation(contact, limit = 50) {
1483
+ await initDB();
1484
+ if (!tables.messages) return [];
1485
+
1486
+ try {
1487
+ const contactLower = contact.toLowerCase();
1488
+ const results = await tables.messages.query()
1489
+ .select(["id", "date", "dateTimestamp", "sender", "text", "chatId", "chatIdentifier"])
1490
+ .toArray();
1491
+
1492
+ // Find messages where sender or chatIdentifier contains the contact
1493
+ const filtered = results.filter(r => {
1494
+ const sender = (r.sender || "").toLowerCase();
1495
+ const chatId = (r.chatIdentifier || "").toLowerCase();
1496
+ return sender.includes(contactLower) || chatId.includes(contactLower);
1497
+ });
1498
+
1499
+ // Sort chronologically (oldest first for conversation view)
1500
+ return filtered
1501
+ .sort((a, b) => a.dateTimestamp - b.dateTimestamp)
1502
+ .slice(-limit); // Take last N messages
1503
+ } catch (e) {
1504
+ console.error("Error getting conversation:", e.message);
1505
+ return [];
1506
+ }
1507
+ }
1508
+
1509
+ export async function getCalendarByDate(startTimestamp, endTimestamp) {
1510
+ await initDB();
1511
+ if (!tables.calendar) return [];
1512
+
1513
+ try {
1514
+ const results = await tables.calendar.query()
1515
+ .select(["id", "title", "start", "end", "startTimestamp", "calendar", "location", "notes", "isAllDay"])
1516
+ .toArray();
1517
+
1518
+ console.error(`[Calendar Query] Searching between ${new Date(startTimestamp).toISOString()} and ${new Date(endTimestamp).toISOString()}`);
1519
+ console.error(`[Calendar Query] Total events in index: ${results.length}`);
1520
+
1521
+ const filtered = results.filter(r => r.startTimestamp >= startTimestamp && r.startTimestamp < endTimestamp);
1522
+
1523
+ console.error(`[Calendar Query] Matched events: ${filtered.length}`);
1524
+ if (filtered.length === 0 && results.length > 0) {
1525
+ // Show events near the boundary for debugging
1526
+ const nearby = results.filter(r =>
1527
+ r.startTimestamp >= startTimestamp - 86400000 &&
1528
+ r.startTimestamp <= endTimestamp + 86400000
1529
+ );
1530
+ console.error(`[Calendar Query] Events within ±1 day: ${nearby.length}`);
1531
+ nearby.forEach(e => {
1532
+ console.error(` - "${e.title}" at ${new Date(e.startTimestamp).toISOString()} (all-day: ${e.isAllDay})`);
1533
+ });
1534
+ }
1535
+
1536
+ return filtered.sort((a, b) => a.startTimestamp - b.startTimestamp);
1537
+ } catch (e) {
1538
+ console.error("Error getting calendar by date:", e.message);
1539
+ return [];
1540
+ }
1541
+ }
1542
+
1543
+ export async function getAllCalendarEvents() {
1544
+ await initDB();
1545
+ if (!tables.calendar) return [];
1546
+
1547
+ try {
1548
+ const results = await tables.calendar.query()
1549
+ .select(["id", "title", "start", "end", "startTimestamp", "calendar", "location", "isAllDay"])
1550
+ .toArray();
1551
+
1552
+ return results.sort((a, b) => a.startTimestamp - b.startTimestamp);
1553
+ } catch (e) {
1554
+ console.error("Error getting all calendar events:", e.message);
1555
+ return [];
1556
+ }
1557
+ }
1558
+
1559
+ // ============ NEW TOOLS - PHASE 1 ============
1560
+
1561
+ // Mailboxes to exclude by default
1562
+ const EXCLUDED_MAILBOXES = ['junk', 'trash', 'deleted messages', 'spam'];
1563
+
1564
+ // mail_senders: List most frequent email senders
1565
+ export async function getFrequentSenders(limit = 30, daysBack = 0, includeJunk = false) {
1566
+ await initDB();
1567
+ if (!tables.emails) return [];
1568
+
1569
+ try {
1570
+ let emails = await tables.emails.query().select(["fromEmail", "from", "dateTimestamp", "mailbox"]).toArray();
1571
+ if (daysBack > 0) {
1572
+ const cutoff = Date.now() - daysBack * 24 * 60 * 60 * 1000;
1573
+ emails = emails.filter(e => e.dateTimestamp >= cutoff);
1574
+ }
1575
+ // Exclude junk/trash by default
1576
+ if (!includeJunk) {
1577
+ emails = emails.filter(e =>
1578
+ !EXCLUDED_MAILBOXES.some(mb =>
1579
+ (e.mailbox || "").toLowerCase().includes(mb)
1580
+ )
1581
+ );
1582
+ }
1583
+ const senderCounts = {};
1584
+ emails.forEach(e => {
1585
+ const key = e.fromEmail || e.from || "Unknown";
1586
+ senderCounts[key] = (senderCounts[key] || 0) + 1;
1587
+ });
1588
+ return Object.entries(senderCounts)
1589
+ .sort((a, b) => b[1] - a[1])
1590
+ .slice(0, limit)
1591
+ .map(([email, count]) => ({ email, messageCount: count }));
1592
+ } catch (e) {
1593
+ console.error("Error getting frequent senders:", e.message);
1594
+ return [];
1595
+ }
1596
+ }
1597
+
1598
+ // messages_contacts: List all contacts you've messaged
1599
+ export function getMessageContacts(limit = 50) {
1600
+ try {
1601
+ // Validate limit to prevent SQL issues
1602
+ const safeLimit = validateLimit(limit, 50, 500);
1603
+
1604
+ const query = `
1605
+ SELECT
1606
+ h.id as contact,
1607
+ COUNT(m.ROWID) as messageCount,
1608
+ datetime(MAX(m.date)/1000000000 + 978307200, 'unixepoch', 'localtime') as lastMessageDate
1609
+ FROM message m
1610
+ LEFT JOIN handle h ON m.handle_id = h.ROWID
1611
+ WHERE h.id IS NOT NULL
1612
+ GROUP BY h.id
1613
+ ORDER BY MAX(m.date) DESC
1614
+ LIMIT ${safeLimit}
1615
+ `;
1616
+ return safeSqlite3Json(MESSAGES_DB, query, { timeout: 30000 });
1617
+ } catch (e) {
1618
+ console.error("Error getting message contacts:", e.message);
1619
+ return [];
1620
+ }
1621
+ }
1622
+
1623
+ // calendar_upcoming: Get next N upcoming events
1624
+ export function getUpcomingEvents(limit = 10) {
1625
+ try {
1626
+ // Validate limit to prevent SQL issues
1627
+ const safeLimit = validateLimit(limit, 10, 100);
1628
+ const nowMac = Math.floor(Date.now() / 1000) - 978307200;
1629
+ const fetchLimit = safeLimit + 1; // Fetch one extra to detect if more exist
1630
+
1631
+ // Query OccurrenceCache to include recurring event occurrences
1632
+ const query = `
1633
+ SELECT
1634
+ ci.summary as title,
1635
+ datetime(COALESCE(oc.occurrence_end_date - (ci.end_date - ci.start_date), ci.start_date) + 978307200, 'unixepoch', 'localtime') as start,
1636
+ datetime(COALESCE(oc.occurrence_end_date, ci.end_date) + 978307200, 'unixepoch', 'localtime') as end,
1637
+ ci.all_day as isAllDay,
1638
+ c.title as calendar,
1639
+ l.title as location,
1640
+ oc.day as sort_day
1641
+ FROM OccurrenceCache oc
1642
+ INNER JOIN CalendarItem ci ON oc.event_id = ci.ROWID
1643
+ LEFT JOIN Calendar c ON ci.calendar_id = c.ROWID
1644
+ LEFT JOIN Location l ON ci.location_id = l.ROWID
1645
+ WHERE oc.day >= ${nowMac} AND ci.summary IS NOT NULL AND ci.summary <> ''
1646
+ ORDER BY sort_day ASC
1647
+ LIMIT ${fetchLimit}
1648
+ `;
1649
+ const events = safeSqlite3Json(CALENDAR_DB, query, { timeout: 10000 });
1650
+ const hasMore = events.length > safeLimit;
1651
+ const limitedEvents = events.slice(0, safeLimit);
1652
+ return { events: limitedEvents, showing: limitedEvents.length, hasMore };
1653
+ } catch (e) {
1654
+ console.error("Error getting upcoming events:", e.message);
1655
+ return { events: [], showing: 0, hasMore: false };
1656
+ }
1657
+ }
1658
+
1659
+ // ============ NEW TOOLS - PHASE 2 ============
1660
+
1661
+ // mail_unread_count: Get count of unread emails via AppleScript
1662
+ export function getUnreadCount(mailbox = null) {
1663
+ try {
1664
+ let script;
1665
+
1666
+ if (mailbox) {
1667
+ // Validate mailbox name to prevent AppleScript injection
1668
+ const validatedMailbox = validateMailboxName(mailbox);
1669
+ if (!validatedMailbox) {
1670
+ return { unreadCount: 0, mailbox, error: "Invalid mailbox name. Only alphanumeric characters, spaces, hyphens, underscores, and periods are allowed." };
1671
+ }
1672
+
1673
+ // Escape for AppleScript double-quoted string (belt and suspenders)
1674
+ const escapedMailbox = escapeAppleScript(validatedMailbox);
1675
+
1676
+ script = `tell application "Mail"
1677
+ set unreadCount to 0
1678
+ repeat with acc in accounts
1679
+ try
1680
+ set mb to mailbox "${escapedMailbox}" of acc
1681
+ set unreadCount to unreadCount + (count of (messages of mb whose read status is false))
1682
+ end try
1683
+ end repeat
1684
+ return unreadCount
1685
+ end tell`;
1686
+ } else {
1687
+ script = `tell application "Mail"
1688
+ return unread count of inbox
1689
+ end tell`;
1690
+ }
1691
+
1692
+ const result = safeOsascript(script, { timeout: 15000 });
1693
+ return { unreadCount: parseInt(result.trim()) || 0, mailbox: mailbox || "INBOX" };
1694
+ } catch (e) {
1695
+ console.error("Error getting unread count:", e.message);
1696
+ return { unreadCount: 0, mailbox: mailbox || "INBOX", error: e.message };
1697
+ }
1698
+ }
1699
+
1700
+ // calendar_week: Get events for current or next week
1701
+ export function getWeekEvents(weekOffset = 0) {
1702
+ try {
1703
+ const now = new Date();
1704
+ // Get Monday of the current week
1705
+ const monday = new Date(now);
1706
+ const day = now.getDay();
1707
+ const diff = day === 0 ? -6 : 1 - day; // If Sunday, go back 6 days; otherwise go to Monday
1708
+ monday.setDate(now.getDate() + diff + (weekOffset * 7));
1709
+ monday.setHours(0, 0, 0, 0);
1710
+
1711
+ // Get Sunday end of week
1712
+ const sunday = new Date(monday);
1713
+ sunday.setDate(monday.getDate() + 6);
1714
+ sunday.setHours(23, 59, 59, 999);
1715
+
1716
+ const startMac = Math.floor(monday.getTime() / 1000) - 978307200;
1717
+ const endMac = Math.floor(sunday.getTime() / 1000) - 978307200;
1718
+
1719
+ // Query OccurrenceCache to include recurring event occurrences
1720
+ const query = `
1721
+ SELECT
1722
+ ci.summary as title,
1723
+ datetime(COALESCE(oc.occurrence_end_date - (ci.end_date - ci.start_date), ci.start_date) + 978307200, 'unixepoch', 'localtime') as start,
1724
+ datetime(COALESCE(oc.occurrence_end_date, ci.end_date) + 978307200, 'unixepoch', 'localtime') as end,
1725
+ ci.all_day as isAllDay,
1726
+ c.title as calendar,
1727
+ l.title as location
1728
+ FROM OccurrenceCache oc
1729
+ INNER JOIN CalendarItem ci ON oc.event_id = ci.ROWID
1730
+ LEFT JOIN Calendar c ON ci.calendar_id = c.ROWID
1731
+ LEFT JOIN Location l ON ci.location_id = l.ROWID
1732
+ WHERE oc.day >= ${startMac} AND oc.day <= ${endMac}
1733
+ AND ci.summary IS NOT NULL AND ci.summary <> ''
1734
+ ORDER BY oc.day ASC
1735
+ `;
1736
+ const events = safeSqlite3Json(CALENDAR_DB, query, { timeout: 15000 });
1737
+
1738
+ const weekStart = monday.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
1739
+ const weekEnd = sunday.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
1740
+
1741
+ return {
1742
+ events,
1743
+ weekLabel: weekOffset === 0 ? "This Week" : weekOffset === 1 ? "Next Week" : `Week of ${weekStart}`,
1744
+ dateRange: `${weekStart} - ${weekEnd}`
1745
+ };
1746
+ } catch (e) {
1747
+ console.error("Error getting week events:", e.message);
1748
+ return { events: [], weekLabel: "Unknown", dateRange: "", error: e.message };
1749
+ }
1750
+ }
1751
+
1752
+ // ============ NEW TOOLS - PHASE 3 ============
1753
+
1754
+ // mail_thread: Get email thread by searching for related emails
1755
+ // Uses subject-based matching since Message-ID isn't indexed
1756
+ export async function getEmailThread(filePath, limit = 20) {
1757
+ await initDB();
1758
+ if (!tables.emails) return { error: "Email index not ready", emails: [] };
1759
+
1760
+ try {
1761
+ // Validate file path to prevent path traversal attacks
1762
+ let validatedPath;
1763
+ try {
1764
+ validatedPath = validateEmailPath(filePath, MAIL_DIR);
1765
+ } catch (e) {
1766
+ return { error: `Invalid file path: ${e.message}`, emails: [] };
1767
+ }
1768
+
1769
+ // Verify file exists
1770
+ if (!fs.existsSync(validatedPath)) {
1771
+ return { error: "Email file not found", emails: [] };
1772
+ }
1773
+
1774
+ // Read the email to get subject
1775
+ const content = fs.readFileSync(validatedPath, "utf-8");
1776
+ const subjectMatch = content.match(/^Subject:\s*(.+)$/m);
1777
+ if (!subjectMatch) {
1778
+ return { error: "Could not extract subject from email", emails: [] };
1779
+ }
1780
+
1781
+ // Clean subject - remove Re:, Fwd:, etc.
1782
+ let subject = subjectMatch[1].trim();
1783
+ const baseSubject = subject.replace(/^(Re|Fwd|Fw):\s*/gi, "").trim();
1784
+
1785
+ if (baseSubject.length < 5) {
1786
+ return { error: "Subject too short to find thread", emails: [] };
1787
+ }
1788
+
1789
+ // Search for emails with similar subjects
1790
+ const allEmails = await tables.emails.query()
1791
+ .select(["filePath", "from", "to", "subject", "date", "dateTimestamp"])
1792
+ .toArray();
1793
+
1794
+ // Filter to emails with matching base subject
1795
+ const threadEmails = allEmails
1796
+ .filter(e => {
1797
+ const eBaseSubject = (e.subject || "").replace(/^(Re|Fwd|Fw):\s*/gi, "").trim();
1798
+ return eBaseSubject.toLowerCase() === baseSubject.toLowerCase();
1799
+ })
1800
+ .sort((a, b) => a.dateTimestamp - b.dateTimestamp)
1801
+ .slice(0, limit);
1802
+
1803
+ return {
1804
+ emails: threadEmails,
1805
+ baseSubject,
1806
+ threadCount: threadEmails.length
1807
+ };
1808
+ } catch (e) {
1809
+ console.error("Error getting email thread:", e.message);
1810
+ return { error: e.message, emails: [] };
1811
+ }
1812
+ }
1813
+
1814
+ // calendar_recurring: Get recurring events (shows events with recurrence rules and their upcoming occurrences)
1815
+ export function getRecurringEvents(limit = 20) {
1816
+ try {
1817
+ // Validate limit to prevent SQL issues
1818
+ const safeLimit = validateLimit(limit, 20, 100);
1819
+ const nowMac = Math.floor(Date.now() / 1000) - 978307200;
1820
+ const fetchLimit = safeLimit + 1; // Fetch one extra to detect if more exist
1821
+
1822
+ // Query events that have recurrence rules defined in the Recurrence table
1823
+ // Show the next upcoming occurrence of each recurring event
1824
+ const query = `
1825
+ SELECT DISTINCT
1826
+ ci.summary as title,
1827
+ datetime(COALESCE(MIN(oc.occurrence_end_date) - (ci.end_date - ci.start_date), MIN(oc.day)) + 978307200, 'unixepoch', 'localtime') as start,
1828
+ c.title as calendar,
1829
+ ci.all_day as isAllDay,
1830
+ COUNT(DISTINCT oc.day) as occurrenceCount
1831
+ FROM Recurrence r
1832
+ INNER JOIN CalendarItem ci ON r.owner_id = ci.ROWID
1833
+ INNER JOIN OccurrenceCache oc ON oc.event_id = ci.ROWID
1834
+ LEFT JOIN Calendar c ON ci.calendar_id = c.ROWID
1835
+ WHERE oc.day >= ${nowMac}
1836
+ AND ci.summary IS NOT NULL AND ci.summary <> ''
1837
+ GROUP BY ci.ROWID, ci.summary, c.title, ci.all_day
1838
+ ORDER BY occurrenceCount DESC, MIN(oc.day) ASC
1839
+ LIMIT ${fetchLimit}
1840
+ `;
1841
+ const events = safeSqlite3Json(CALENDAR_DB, query, { timeout: 30000 });
1842
+ const hasMore = events.length > safeLimit;
1843
+ const limitedEvents = events.slice(0, safeLimit);
1844
+ return { events: limitedEvents, showing: limitedEvents.length, hasMore };
1845
+ } catch (e) {
1846
+ console.error("Error getting recurring events:", e.message);
1847
+ return { events: [], showing: 0, hasMore: false };
1848
+ }
1849
+ }