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/lib/audit.js ADDED
@@ -0,0 +1,1083 @@
1
+ /**
2
+ * Index Audit Module for apple-tools-mcp
3
+ *
4
+ * Provides comprehensive auditing of the vector index against source data
5
+ * with 0% tolerance. Identifies missing items, orphaned entries, and duplicates.
6
+ *
7
+ * Core Features:
8
+ * - 100% source data validation (no filtering by date)
9
+ * - Detailed verbose reporting with file paths and metadata
10
+ * - Performance optimized for 100k+ items
11
+ * - Report-only (no auto-fix)
12
+ */
13
+
14
+ import fs from "fs";
15
+ import path from "path";
16
+ import { execSync } from "child_process";
17
+ import { safeSqlite3Json } from "./shell.js";
18
+ import * as lancedb from "@lancedb/lancedb";
19
+
20
+ // ============================================================================
21
+ // CONSTANTS AND PATHS
22
+ // ============================================================================
23
+
24
+ const HOME = process.env.HOME;
25
+ const INDEX_DIR = process.env.APPLE_TOOLS_INDEX_DIR ||
26
+ path.join(HOME, ".apple-tools-mcp", "vector-index");
27
+ const MAIL_DIR = path.join(HOME, "Library", "Mail");
28
+ const MESSAGES_DB = path.join(HOME, "Library", "Messages", "chat.db");
29
+ const CALENDAR_DB = path.join(HOME, "Library", "Group Containers", "group.com.apple.calendar", "Calendar.sqlitedb");
30
+
31
+ // Mac Absolute Time epoch: Jan 1, 2001 00:00:00 UTC
32
+ const MAC_ABSOLUTE_EPOCH = 978307200;
33
+
34
+ // Email indexing time window (matches indexer.js behavior)
35
+ const DAYS_BACK = process.env.APPLE_TOOLS_INDEX_DAYS_BACK ?
36
+ parseInt(process.env.APPLE_TOOLS_INDEX_DAYS_BACK, 10) : null;
37
+
38
+ // Exclude these folders from email indexing (matches indexer behavior)
39
+ const EXCLUDED_FOLDERS = ["Junk.mbox", "Saved Junk.mbox", "Trash.mbox", "Deleted Messages.mbox"];
40
+
41
+ let db = null;
42
+ let tables = {};
43
+
44
+ // ============================================================================
45
+ // DATABASE CONNECTION
46
+ // ============================================================================
47
+
48
+ async function initDB() {
49
+ if (db) return { db, tables };
50
+
51
+ try {
52
+ db = await lancedb.connect(INDEX_DIR);
53
+ const tableNames = await db.tableNames();
54
+
55
+ if (tableNames.includes("emails")) {
56
+ tables.emails = await db.openTable("emails");
57
+ }
58
+ if (tableNames.includes("messages")) {
59
+ tables.messages = await db.openTable("messages");
60
+ }
61
+ if (tableNames.includes("calendar")) {
62
+ tables.calendar = await db.openTable("calendar");
63
+ }
64
+
65
+ return { db, tables };
66
+ } catch (e) {
67
+ console.error("Error initializing database:", e.message);
68
+ return { db: null, tables: {} };
69
+ }
70
+ }
71
+
72
+ // ============================================================================
73
+ // SOURCE COUNTING FUNCTIONS
74
+ // ============================================================================
75
+
76
+ /**
77
+ * Count all .emlx files (excluding Junk/Trash)
78
+ * Respects APPLE_TOOLS_INDEX_DAYS_BACK environment variable
79
+ * @returns {number} Total count of email files
80
+ */
81
+ export function countRawEmails() {
82
+ if (!fs.existsSync(MAIL_DIR)) return 0;
83
+
84
+ try {
85
+ // Build exclusion pattern for find command
86
+ const exclusions = EXCLUDED_FOLDERS.map(folder => `-path "*/${folder}/*"`).join(" -o ");
87
+
88
+ // Add time filter if DAYS_BACK is set (matches indexer behavior)
89
+ const timeFilter = DAYS_BACK ? `-mtime -${DAYS_BACK}` : "";
90
+ const cmd = `find "${MAIL_DIR}" \\( -name "*.emlx" -o -name "*.partial.emlx" \\) ! \\( ${exclusions} \\) ${timeFilter} -type f 2>/dev/null | wc -l`;
91
+
92
+ const result = execSync(cmd, { encoding: "utf-8", timeout: 120000 }).trim();
93
+ return parseInt(result) || 0;
94
+ } catch (e) {
95
+ console.error("Error counting emails:", e.message);
96
+ return 0;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Count all messages with text or attributedBody
102
+ * @returns {number} Total count of indexable messages
103
+ */
104
+ export function countRawMessages() {
105
+ if (!fs.existsSync(MESSAGES_DB)) return 0;
106
+
107
+ try {
108
+ const query = `SELECT COUNT(*) as count FROM message
109
+ WHERE (text IS NOT NULL AND text != '')
110
+ OR attributedBody IS NOT NULL`;
111
+ const results = safeSqlite3Json(MESSAGES_DB, query);
112
+ return results[0]?.count || 0;
113
+ } catch (e) {
114
+ console.error("Error counting messages:", e.message);
115
+ return 0;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Count calendar events in the configured time window
121
+ * (90 days back, 365 days forward)
122
+ * @returns {number} Total count of calendar events
123
+ */
124
+ export function countRawCalendarEvents() {
125
+ if (!fs.existsSync(CALENDAR_DB)) return 0;
126
+
127
+ try {
128
+ const now = Date.now();
129
+ // Match indexer's 10-year window for comprehensive calendar indexing
130
+ const pastDate = (now / 1000) - MAC_ABSOLUTE_EPOCH - (10 * 365 * 24 * 60 * 60);
131
+ const futureDate = (now / 1000) - MAC_ABSOLUTE_EPOCH + (10 * 365 * 24 * 60 * 60);
132
+
133
+ // Count only calendar items that have occurrences (real scheduled events)
134
+ // Don't count database junk like far-future placeholders or deleted events
135
+ const query = `
136
+ SELECT COUNT(DISTINCT ci.ROWID) as count
137
+ FROM OccurrenceCache oc
138
+ INNER JOIN CalendarItem ci ON oc.event_id = ci.ROWID
139
+ WHERE oc.day IS NOT NULL
140
+ AND oc.day >= ${pastDate}
141
+ AND oc.day <= ${futureDate}
142
+ AND ci.summary IS NOT NULL
143
+ `;
144
+ const results = safeSqlite3Json(CALENDAR_DB, query);
145
+ return results[0]?.count || 0;
146
+ } catch (e) {
147
+ console.error("Error counting calendar events:", e.message);
148
+ return 0;
149
+ }
150
+ }
151
+
152
+ // ============================================================================
153
+ // ID EXTRACTION FUNCTIONS
154
+ // ============================================================================
155
+
156
+ /**
157
+ * Get all email file paths (excluding Junk/Trash)
158
+ * Respects APPLE_TOOLS_INDEX_DAYS_BACK environment variable
159
+ * @returns {Set<string>} Set of absolute file paths
160
+ */
161
+ export function getRawEmailIds() {
162
+ if (!fs.existsSync(MAIL_DIR)) return new Set();
163
+
164
+ try {
165
+ const exclusions = EXCLUDED_FOLDERS.map(folder => `-path "*/${folder}/*"`).join(" -o ");
166
+
167
+ // Add time filter if DAYS_BACK is set (matches indexer behavior)
168
+ const timeFilter = DAYS_BACK ? `-mtime -${DAYS_BACK}` : "";
169
+ const cmd = `find "${MAIL_DIR}" \\( -name "*.emlx" -o -name "*.partial.emlx" \\) ! \\( ${exclusions} \\) ${timeFilter} -type f 2>/dev/null`;
170
+
171
+ const result = execSync(cmd, { encoding: "utf-8", timeout: 120000, maxBuffer: 50 * 1024 * 1024 }).trim();
172
+ const paths = result.split("\n").filter(p => p);
173
+ return new Set(paths);
174
+ } catch (e) {
175
+ console.error("Error getting email IDs:", e.message);
176
+ return new Set();
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Get all message ROWIDs with text or attributedBody
182
+ * @returns {Set<string>} Set of message IDs (as strings)
183
+ */
184
+ export function getRawMessageIds() {
185
+ if (!fs.existsSync(MESSAGES_DB)) return new Set();
186
+
187
+ try {
188
+ const query = `SELECT ROWID as id FROM message
189
+ WHERE (text IS NOT NULL AND text != '')
190
+ OR attributedBody IS NOT NULL`;
191
+ const results = safeSqlite3Json(MESSAGES_DB, query);
192
+ return new Set(results.map(r => String(r.id)));
193
+ } catch (e) {
194
+ console.error("Error getting message IDs:", e.message);
195
+ return new Set();
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Get all calendar event IDs (just dbId, no timestamp)
201
+ * Uses CalendarItem table with GROUP BY to match indexer's behavior
202
+ * @returns {Set<string>} Set of event IDs
203
+ */
204
+ export function getRawCalendarIds() {
205
+ if (!fs.existsSync(CALENDAR_DB)) return new Set();
206
+
207
+ try {
208
+ const now = Date.now();
209
+ // Match indexer's 10-year window for comprehensive calendar indexing
210
+ const pastDate = (now / 1000) - MAC_ABSOLUTE_EPOCH - (10 * 365 * 24 * 60 * 60);
211
+ const futureDate = (now / 1000) - MAC_ABSOLUTE_EPOCH + (10 * 365 * 24 * 60 * 60);
212
+
213
+ const query = `
214
+ SELECT DISTINCT ci.ROWID as dbId
215
+ FROM OccurrenceCache oc
216
+ INNER JOIN CalendarItem ci ON oc.event_id = ci.ROWID
217
+ WHERE oc.day IS NOT NULL
218
+ AND oc.day >= ${pastDate}
219
+ AND oc.day <= ${futureDate}
220
+ AND ci.summary IS NOT NULL
221
+ GROUP BY ci.ROWID
222
+ `;
223
+
224
+ const results = safeSqlite3Json(CALENDAR_DB, query);
225
+ // Return just the dbId (no timestamp) to match indexer's new format
226
+ return new Set(results.map(r => String(r.dbId)));
227
+ } catch (e) {
228
+ console.error("Error getting calendar IDs:", e.message);
229
+ return new Set();
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Get all indexed IDs from a LanceDB table
235
+ * @param {string} tableName - Name of the table
236
+ * @param {string} idField - Field name containing the ID
237
+ * @returns {Promise<Set>} Set of indexed IDs
238
+ */
239
+ export async function getIndexedIds(tableName, idField) {
240
+ await initDB();
241
+
242
+ if (!tables[tableName]) return new Set();
243
+
244
+ try {
245
+ // Fetch only the ID field for performance
246
+ const results = await tables[tableName].query().select([idField]).limit(1000000).toArray();
247
+ return new Set(results.map(r => String(r[idField])));
248
+ } catch (e) {
249
+ console.error(`Error getting indexed IDs from ${tableName}:`, e.message);
250
+ return new Set();
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Get all indexed items with metadata for detailed reporting
256
+ * @param {string} tableName - Name of the table
257
+ * @param {Array<string>} fields - Fields to retrieve
258
+ * @returns {Promise<Array>} Array of indexed items
259
+ */
260
+ async function getIndexedItems(tableName, fields) {
261
+ await initDB();
262
+
263
+ if (!tables[tableName]) return [];
264
+
265
+ try {
266
+ const results = await tables[tableName].query().select(fields).limit(1000000).toArray();
267
+ return results;
268
+ } catch (e) {
269
+ console.error(`Error getting indexed items from ${tableName}:`, e.message);
270
+ return [];
271
+ }
272
+ }
273
+
274
+ // ============================================================================
275
+ // DISCREPANCY DETECTION
276
+ // ============================================================================
277
+
278
+ /**
279
+ * Find items in source but not in index
280
+ * @param {Set} sourceIds - IDs from source data
281
+ * @param {Set} indexedIds - IDs from index
282
+ * @returns {Array<string>} Array of missing IDs
283
+ */
284
+ export function findMissing(sourceIds, indexedIds) {
285
+ const missing = [];
286
+ for (const id of sourceIds) {
287
+ if (!indexedIds.has(id)) {
288
+ missing.push(id);
289
+ }
290
+ }
291
+ return missing;
292
+ }
293
+
294
+ /**
295
+ * Find items in index but deleted from source (orphaned)
296
+ * @param {Set} indexedIds - IDs from index
297
+ * @param {Function} sourceValidator - Function to check if source exists
298
+ * @returns {Promise<Array<string>>} Array of orphaned IDs
299
+ */
300
+ export async function findOrphaned(indexedIds, sourceValidator) {
301
+ const orphaned = [];
302
+ for (const id of indexedIds) {
303
+ if (!await sourceValidator(id)) {
304
+ orphaned.push(id);
305
+ }
306
+ }
307
+ return orphaned;
308
+ }
309
+
310
+ /**
311
+ * Find duplicate entries in index (same ID indexed multiple times)
312
+ * @param {Array} indexedItems - All items from index
313
+ * @param {string} keyField - Field to check for duplicates
314
+ * @returns {Array<{id: string, count: number}>} Duplicates with counts
315
+ */
316
+ export function findDuplicates(indexedItems, keyField) {
317
+ const counts = new Map();
318
+
319
+ for (const item of indexedItems) {
320
+ const key = String(item[keyField]);
321
+ counts.set(key, (counts.get(key) || 0) + 1);
322
+ }
323
+
324
+ const duplicates = [];
325
+ for (const [id, count] of counts.entries()) {
326
+ if (count > 1) {
327
+ duplicates.push({ id, count });
328
+ }
329
+ }
330
+
331
+ return duplicates;
332
+ }
333
+
334
+ // ============================================================================
335
+ // METADATA EXTRACTION
336
+ // ============================================================================
337
+
338
+ /**
339
+ * Get email metadata for detailed reporting
340
+ * @param {string} filePath - Path to .emlx file
341
+ * @returns {object} Email metadata
342
+ */
343
+ function getEmailMetadata(filePath) {
344
+ try {
345
+ if (!fs.existsSync(filePath)) {
346
+ return { subject: "Unknown", from: "Unknown", date: "Unknown", messageId: null, exists: false };
347
+ }
348
+
349
+ const rawContent = fs.readFileSync(filePath, "utf-8");
350
+
351
+ // Handle Apple Mail envelope format: first line is byte count
352
+ // Strip the preamble to get the actual RFC822 email content
353
+ let content = rawContent;
354
+ const lines = rawContent.split("\n");
355
+ if (lines[0] && /^\d+\s*$/.test(lines[0])) {
356
+ content = lines.slice(1).join("\n");
357
+ }
358
+
359
+ // Use regex-based extraction (same approach as indexer.js)
360
+ // This handles folded headers, case-insensitivity, and optional whitespace
361
+ const subjectMatch = content.match(/^Subject:\s*(.+)$/im);
362
+ const fromMatch = content.match(/^From:\s*(.+)$/im);
363
+ const dateMatch = content.match(/^Date:\s*(.+)$/im);
364
+ const messageIdMatch = content.match(/^Message-ID:\s*(.+)$/im);
365
+
366
+ return {
367
+ subject: subjectMatch?.[1]?.trim() || "Unknown",
368
+ from: fromMatch?.[1]?.trim() || "Unknown",
369
+ date: dateMatch?.[1]?.trim() || "Unknown",
370
+ messageId: messageIdMatch?.[1]?.trim() || null,
371
+ exists: true
372
+ };
373
+ } catch (e) {
374
+ return { subject: "Error", from: "Error", date: "Error", messageId: null, exists: false };
375
+ }
376
+ }
377
+
378
+ /**
379
+ * Get message metadata for detailed reporting
380
+ * @param {string} messageId - Message ROWID
381
+ * @returns {object} Message metadata
382
+ */
383
+ function getMessageMetadata(messageId) {
384
+ try {
385
+ const query = `
386
+ SELECT
387
+ m.text,
388
+ datetime(m.date/1000000000 + ${MAC_ABSOLUTE_EPOCH}, 'unixepoch', 'localtime') as date,
389
+ CASE WHEN m.is_from_me = 1 THEN 'Me' ELSE coalesce(h.id, 'Unknown') END as sender
390
+ FROM message m
391
+ LEFT JOIN handle h ON m.handle_id = h.ROWID
392
+ WHERE m.ROWID = ${messageId}
393
+ `;
394
+
395
+ const results = safeSqlite3Json(MESSAGES_DB, query);
396
+ if (results.length > 0) {
397
+ const msg = results[0];
398
+ return {
399
+ text: (msg.text || "").substring(0, 100),
400
+ date: msg.date,
401
+ sender: msg.sender
402
+ };
403
+ }
404
+ } catch (e) {
405
+ // Silent error
406
+ }
407
+
408
+ return { text: "Unknown", date: "Unknown", sender: "Unknown" };
409
+ }
410
+
411
+ /**
412
+ * Get calendar event metadata for detailed reporting
413
+ * @param {string} dbId - Database ID (just the ROWID, no timestamp)
414
+ * @returns {object} Calendar event metadata
415
+ */
416
+ function getCalendarMetadata(dbId) {
417
+ try {
418
+ const query = `
419
+ SELECT
420
+ summary as title,
421
+ datetime(start_date + ${MAC_ABSOLUTE_EPOCH}, 'unixepoch', 'localtime') as start
422
+ FROM CalendarItem
423
+ WHERE ROWID = ${dbId}
424
+ `;
425
+
426
+ const results = safeSqlite3Json(CALENDAR_DB, query);
427
+ if (results.length > 0) {
428
+ return {
429
+ title: results[0].title,
430
+ start: results[0].start
431
+ };
432
+ }
433
+ } catch (e) {
434
+ // Silent error
435
+ }
436
+
437
+ return { title: "Unknown", start: "Unknown" };
438
+ }
439
+
440
+ // ============================================================================
441
+ // MAIN AUDIT FUNCTIONS
442
+ // ============================================================================
443
+
444
+ /**
445
+ * Audit emails
446
+ * @param {object} options - Audit options
447
+ * @returns {Promise<object>} Audit results
448
+ */
449
+ export async function auditEmails(options = {}) {
450
+ const { maxItems = 100 } = options;
451
+
452
+ console.error("Auditing emails...");
453
+
454
+ // Phase 1: COUNT
455
+ const sourceCount = countRawEmails();
456
+ const sourceIds = getRawEmailIds();
457
+ const indexedIds = await getIndexedIds("emails", "filePath");
458
+ const indexedCount = indexedIds.size;
459
+
460
+ // Phase 2: IDENTIFY
461
+ const missing = findMissing(sourceIds, indexedIds);
462
+ const orphaned = await findOrphaned(indexedIds, (id) => fs.existsSync(id));
463
+
464
+ // Get all indexed items for duplicate detection and messageId mapping
465
+ const indexedItems = await getIndexedItems("emails", ["filePath", "subject", "messageId"]);
466
+ const duplicates = findDuplicates(indexedItems, "filePath");
467
+
468
+ // Create messageId -> indexed items map for deduplication detection
469
+ const indexedMessageIds = new Map();
470
+ for (const item of indexedItems) {
471
+ if (item.messageId) {
472
+ if (!indexedMessageIds.has(item.messageId)) {
473
+ indexedMessageIds.set(item.messageId, []);
474
+ }
475
+ indexedMessageIds.get(item.messageId).push(item);
476
+ }
477
+ }
478
+
479
+ // Phase 3: PREPARE DETAILED ITEMS - Categorize missing items
480
+ const missingDetailed = [];
481
+ let deduplicatedCount = 0;
482
+
483
+ for (const filePath of missing.slice(0, maxItems > 0 ? maxItems : missing.length)) {
484
+ const metadata = getEmailMetadata(filePath);
485
+
486
+ // Check if this missing file is a duplicate by messageId
487
+ let reason = "Not indexed";
488
+ let isDuplicate = false;
489
+
490
+ if (metadata.messageId && indexedMessageIds.has(metadata.messageId)) {
491
+ // This file has the same messageId as an indexed email
492
+ const indexedDuplicates = indexedMessageIds.get(metadata.messageId);
493
+ if (indexedDuplicates.length > 0) {
494
+ reason = `Deduplicated (duplicate messageId - same as: ${indexedDuplicates[0].subject || "Unknown"})`;
495
+ isDuplicate = true;
496
+ deduplicatedCount++;
497
+ }
498
+ }
499
+
500
+ missingDetailed.push({
501
+ filePath,
502
+ ...metadata,
503
+ reason,
504
+ isDuplicate
505
+ });
506
+ }
507
+
508
+ // Count total deduplicates (for items not shown in detail)
509
+ let totalDeduplicates = 0;
510
+ for (const filePath of missing) {
511
+ const metadata = getEmailMetadata(filePath);
512
+ if (metadata.messageId && indexedMessageIds.has(metadata.messageId)) {
513
+ totalDeduplicates++;
514
+ }
515
+ }
516
+ const trulyMissingCount = missing.length - totalDeduplicates;
517
+
518
+ const orphanedDetailed = orphaned.slice(0, maxItems > 0 ? maxItems : orphaned.length).map(filePath => {
519
+ const indexedItem = indexedItems.find(item => item.filePath === filePath);
520
+ return {
521
+ filePath,
522
+ subject: indexedItem?.subject || "Unknown",
523
+ reason: "File no longer exists (deleted from Mail.app)"
524
+ };
525
+ });
526
+
527
+ const duplicatesDetailed = duplicates.slice(0, maxItems > 0 ? maxItems : duplicates.length).map(dup => {
528
+ const items = indexedItems.filter(item => item.filePath === dup.id);
529
+ return {
530
+ filePath: dup.id,
531
+ count: dup.count,
532
+ subject: items[0]?.subject || "Unknown"
533
+ };
534
+ });
535
+
536
+ return {
537
+ dataType: "emails",
538
+ counts: {
539
+ source: sourceCount,
540
+ indexed: indexedCount,
541
+ unique: indexedCount,
542
+ coverage: sourceCount > 0 ? indexedCount / sourceCount : 0,
543
+ notes: {
544
+ totalSourceFiles: sourceCount,
545
+ indexedUniqueEmails: indexedCount,
546
+ deduplicatedFiles: totalDeduplicates,
547
+ trulyMissingCount: trulyMissingCount,
548
+ explanation: `${indexedCount} unique emails indexed from ${sourceCount} source files (${totalDeduplicates} duplicate messageIds correctly deduplicated, ${trulyMissingCount} truly missing)`
549
+ }
550
+ },
551
+ discrepancies: {
552
+ missing: missingDetailed,
553
+ orphaned: orphanedDetailed,
554
+ duplicates: duplicatesDetailed,
555
+ missingCount: trulyMissingCount,
556
+ deduplicatedCount: totalDeduplicates,
557
+ orphanedCount: orphaned.length,
558
+ duplicateCount: duplicates.length
559
+ }
560
+ };
561
+ }
562
+
563
+ /**
564
+ * Audit messages
565
+ * @param {object} options - Audit options
566
+ * @returns {Promise<object>} Audit results
567
+ */
568
+ export async function auditMessages(options = {}) {
569
+ const { maxItems = 100 } = options;
570
+
571
+ console.error("Auditing messages...");
572
+
573
+ // Phase 1: COUNT
574
+ const sourceCount = countRawMessages();
575
+ const sourceIds = getRawMessageIds();
576
+ const indexedIds = await getIndexedIds("messages", "id");
577
+ const indexedCount = indexedIds.size;
578
+
579
+ // Phase 2: IDENTIFY
580
+ const missing = findMissing(sourceIds, indexedIds);
581
+
582
+ // Messages don't have orphaned entries (database persists)
583
+ const orphaned = [];
584
+
585
+ // Get all indexed items for duplicate detection
586
+ const indexedItems = await getIndexedItems("messages", ["id", "text", "sender"]);
587
+ const duplicates = findDuplicates(indexedItems, "id");
588
+
589
+ // Phase 3: PREPARE DETAILED ITEMS
590
+ const missingDetailed = missing.slice(0, maxItems > 0 ? maxItems : missing.length).map(id => ({
591
+ id,
592
+ ...getMessageMetadata(id),
593
+ reason: "Not indexed"
594
+ }));
595
+
596
+ const duplicatesDetailed = duplicates.slice(0, maxItems > 0 ? maxItems : duplicates.length).map(dup => {
597
+ const items = indexedItems.filter(item => String(item.id) === dup.id);
598
+ return {
599
+ id: dup.id,
600
+ count: dup.count,
601
+ text: items[0]?.text?.substring(0, 100) || "Unknown",
602
+ sender: items[0]?.sender || "Unknown"
603
+ };
604
+ });
605
+
606
+ return {
607
+ dataType: "messages",
608
+ counts: {
609
+ source: sourceCount,
610
+ indexed: indexedCount,
611
+ coverage: sourceCount > 0 ? indexedCount / sourceCount : 0
612
+ },
613
+ discrepancies: {
614
+ missing: missingDetailed,
615
+ orphaned: [],
616
+ duplicates: duplicatesDetailed,
617
+ missingCount: missing.length,
618
+ orphanedCount: 0,
619
+ duplicateCount: duplicates.length
620
+ }
621
+ };
622
+ }
623
+
624
+ /**
625
+ * Audit calendar events
626
+ * @param {object} options - Audit options
627
+ * @returns {Promise<object>} Audit results
628
+ */
629
+ export async function auditCalendar(options = {}) {
630
+ const { maxItems = 100 } = options;
631
+
632
+ console.error("Auditing calendar...");
633
+
634
+ // Phase 1: COUNT
635
+ const sourceCount = countRawCalendarEvents();
636
+ const sourceIds = getRawCalendarIds();
637
+ const indexedIds = await getIndexedIds("calendar", "id");
638
+ const indexedCount = indexedIds.size;
639
+
640
+ // Phase 2: IDENTIFY
641
+ const missing = findMissing(sourceIds, indexedIds);
642
+ const orphaned = findMissing(indexedIds, sourceIds); // Reverse check for stale entries
643
+
644
+ // Get all indexed items for duplicate detection
645
+ const indexedItems = await getIndexedItems("calendar", ["id", "title"]);
646
+ const duplicates = findDuplicates(indexedItems, "id");
647
+
648
+ // Phase 3: PREPARE DETAILED ITEMS
649
+ const missingDetailed = missing.slice(0, maxItems > 0 ? maxItems : missing.length).map(id => ({
650
+ id,
651
+ ...getCalendarMetadata(id),
652
+ reason: "Not indexed"
653
+ }));
654
+
655
+ const orphanedDetailed = orphaned.slice(0, maxItems > 0 ? maxItems : orphaned.length).map(id => {
656
+ const indexedItem = indexedItems.find(item => item.id === id);
657
+ return {
658
+ id,
659
+ title: indexedItem?.title || "Unknown",
660
+ reason: "Event no longer exists in calendar"
661
+ };
662
+ });
663
+
664
+ const duplicatesDetailed = duplicates.slice(0, maxItems > 0 ? maxItems : duplicates.length).map(dup => {
665
+ const items = indexedItems.filter(item => item.id === dup.id);
666
+ return {
667
+ id: dup.id,
668
+ count: dup.count,
669
+ title: items[0]?.title || "Unknown"
670
+ };
671
+ });
672
+
673
+ return {
674
+ dataType: "calendar",
675
+ counts: {
676
+ source: sourceCount,
677
+ indexed: indexedCount,
678
+ coverage: sourceCount > 0 ? indexedCount / sourceCount : 0
679
+ },
680
+ discrepancies: {
681
+ missing: missingDetailed,
682
+ orphaned: orphanedDetailed,
683
+ duplicates: duplicatesDetailed,
684
+ missingCount: missing.length,
685
+ orphanedCount: orphaned.length,
686
+ duplicateCount: duplicates.length
687
+ }
688
+ };
689
+ }
690
+
691
+ /**
692
+ * Audit all data sources
693
+ * @param {object} options - Audit options
694
+ * @returns {Promise<object>} Combined audit results
695
+ */
696
+ export async function auditAll(options = {}) {
697
+ const { sources = ["emails", "messages", "calendar"], maxItems = 100 } = options;
698
+
699
+ const results = {};
700
+
701
+ // Run audits in parallel for performance
702
+ const promises = [];
703
+ if (sources.includes("emails")) {
704
+ promises.push(auditEmails({ maxItems }).then(r => ({ type: "emails", result: r })));
705
+ }
706
+ if (sources.includes("messages")) {
707
+ promises.push(auditMessages({ maxItems }).then(r => ({ type: "messages", result: r })));
708
+ }
709
+ if (sources.includes("calendar")) {
710
+ promises.push(auditCalendar({ maxItems }).then(r => ({ type: "calendar", result: r })));
711
+ }
712
+
713
+ const allResults = await Promise.all(promises);
714
+ for (const { type, result } of allResults) {
715
+ results[type] = result;
716
+ }
717
+
718
+ return results;
719
+ }
720
+
721
+ // ============================================================================
722
+ // REPORT FORMATTING
723
+ // ============================================================================
724
+
725
+ /**
726
+ * Format audit results as verbose text report
727
+ * @param {object} results - Audit results from auditAll()
728
+ * @returns {string} Formatted report
729
+ */
730
+ export function formatAuditReport(results) {
731
+ const timestamp = new Date().toISOString().replace("T", " ").substring(0, 19);
732
+
733
+ let report = "=== INDEX AUDIT REPORT ===\n";
734
+ report += `Generated: ${timestamp}\n\n`;
735
+
736
+ for (const [dataType, result] of Object.entries(results)) {
737
+ const { counts, discrepancies } = result;
738
+ const { source, indexed, coverage, notes } = counts;
739
+ const { missing, orphaned, duplicates, missingCount, orphanedCount, duplicateCount, deduplicatedCount } = discrepancies;
740
+
741
+ const isPerfect = missingCount === 0 && orphanedCount === 0 && duplicateCount === 0;
742
+ const statusIcon = isPerfect ? "✓" : "✗";
743
+
744
+ report += "━".repeat(60) + "\n";
745
+ report += `${dataType.toUpperCase()}\n`;
746
+ report += "━".repeat(60) + "\n\n";
747
+
748
+ // For emails, show adjusted coverage that accounts for deduplication
749
+ if (dataType === "emails" && notes && notes.deduplicatedFiles) {
750
+ const uniqueExpected = source - notes.deduplicatedFiles;
751
+ const uniqueCoverage = uniqueExpected > 0 ? Math.min(indexed / uniqueExpected, 1.0) : 1.0;
752
+ const trulyMissingCount = Math.max(0, uniqueExpected - indexed);
753
+
754
+ report += `${statusIcon} Files on disk: ${source.toLocaleString()}\n`;
755
+ report += ` └─ Unique emails: ${uniqueExpected.toLocaleString()}\n`;
756
+ report += ` └─ Duplicate files (same email, multiple folders): ${notes.deduplicatedFiles.toLocaleString()}\n`;
757
+ report += `${statusIcon} Indexed: ${indexed.toLocaleString()} unique emails\n`;
758
+ report += `${statusIcon} Unique Email Coverage: ${(uniqueCoverage * 100).toFixed(1)}%`;
759
+
760
+ if (trulyMissingCount === 0 && orphanedCount === 0 && duplicateCount === 0) {
761
+ report += " (Perfect!)";
762
+ } else if (trulyMissingCount > 0 || orphanedCount > 0 || duplicateCount > 0) {
763
+ const issues = [];
764
+ if (trulyMissingCount > 0) issues.push(`${trulyMissingCount} missing`);
765
+ if (orphanedCount > 0) issues.push(`${orphanedCount} orphaned`);
766
+ if (duplicateCount > 0) issues.push(`${duplicateCount} duplicates`);
767
+ report += ` (${issues.join(", ")})`;
768
+ }
769
+ report += "\n";
770
+ } else {
771
+ report += `${statusIcon} Source: ${source.toLocaleString()} ${dataType}\n`;
772
+ report += `${statusIcon} Indexed: ${indexed.toLocaleString()} ${dataType}\n`;
773
+ report += `${statusIcon} Coverage: ${(coverage * 100).toFixed(1)}%`;
774
+
775
+ if (!isPerfect) {
776
+ report += ` (${missingCount} missing, ${orphanedCount} orphaned, ${duplicateCount} duplicates)`;
777
+ } else {
778
+ report += " (Perfect!)";
779
+ }
780
+ report += "\n";
781
+ }
782
+ report += "\n";
783
+
784
+ // Missing items
785
+ if (missingCount > 0 || (dataType === "emails" && deduplicatedCount > 0)) {
786
+ // Separate truly missing from deduplicated items
787
+ const trulyMissing = missing.filter(item => !item.isDuplicate);
788
+ const deduplicated = missing.filter(item => item.isDuplicate);
789
+
790
+ if (trulyMissing.length > 0) {
791
+ report += "─".repeat(60) + "\n";
792
+ report += `MISSING ITEMS (${trulyMissing.length} truly missing)\n`;
793
+ report += "─".repeat(60) + "\n\n";
794
+
795
+ trulyMissing.forEach((item, index) => {
796
+ report += `${index + 1}. `;
797
+ if (dataType === "emails") {
798
+ report += `${item.filePath}\n`;
799
+ report += ` Subject: ${item.subject}\n`;
800
+ report += ` From: ${item.from}\n`;
801
+ report += ` Date: ${item.date}\n`;
802
+ } else if (dataType === "messages") {
803
+ report += `Message ID: ${item.id}\n`;
804
+ report += ` Text: ${item.text}\n`;
805
+ report += ` Sender: ${item.sender}\n`;
806
+ report += ` Date: ${item.date}\n`;
807
+ } else if (dataType === "calendar") {
808
+ report += `Event ID: ${item.id}\n`;
809
+ report += ` Title: ${item.title}\n`;
810
+ report += ` Start: ${item.start}\n`;
811
+ }
812
+ report += ` Reason: ${item.reason}\n\n`;
813
+ });
814
+ }
815
+
816
+ // Deduplicated items are not listed individually - count is shown in summary
817
+ }
818
+
819
+ // Orphaned items
820
+ if (orphanedCount > 0) {
821
+ report += "─".repeat(60) + "\n";
822
+ report += `ORPHANED ITEMS (${orphanedCount} total)\n`;
823
+ report += "─".repeat(60) + "\n\n";
824
+
825
+ orphaned.forEach((item, index) => {
826
+ report += `${index + 1}. `;
827
+ if (dataType === "emails") {
828
+ report += `${item.filePath}\n`;
829
+ report += ` Subject: ${item.subject}\n`;
830
+ } else if (dataType === "calendar") {
831
+ report += `Event ID: ${item.id}\n`;
832
+ report += ` Title: ${item.title}\n`;
833
+ }
834
+ report += ` Reason: ${item.reason}\n\n`;
835
+ });
836
+ }
837
+
838
+ // Duplicates
839
+ if (duplicateCount > 0) {
840
+ report += "─".repeat(60) + "\n";
841
+ report += `DUPLICATE ITEMS (${duplicateCount} total)\n`;
842
+ report += "─".repeat(60) + "\n\n";
843
+
844
+ duplicates.forEach((item, index) => {
845
+ report += `${index + 1}. `;
846
+ if (dataType === "emails") {
847
+ report += `FilePath indexed ${item.count} times:\n`;
848
+ report += ` ${item.filePath}\n`;
849
+ report += ` Subject: ${item.subject}\n\n`;
850
+ } else if (dataType === "messages") {
851
+ report += `Message ID ${item.id} indexed ${item.count} times:\n`;
852
+ report += ` Text: ${item.text}\n`;
853
+ report += ` Sender: ${item.sender}\n\n`;
854
+ } else if (dataType === "calendar") {
855
+ report += `Event ID ${item.id} indexed ${item.count} times:\n`;
856
+ report += ` Title: ${item.title}\n\n`;
857
+ }
858
+ });
859
+ }
860
+ }
861
+
862
+ // Remediation suggestions
863
+ report += "━".repeat(60) + "\n";
864
+ report += "REMEDIATION SUGGESTIONS\n";
865
+ report += "━".repeat(60) + "\n\n";
866
+
867
+ const sourcesWithIssues = [];
868
+ let totalDiscrepancies = 0;
869
+
870
+ for (const [dataType, result] of Object.entries(results)) {
871
+ const { discrepancies } = result;
872
+ const count = discrepancies.missingCount + discrepancies.orphanedCount + discrepancies.duplicateCount;
873
+ if (count > 0) {
874
+ sourcesWithIssues.push(dataType);
875
+ totalDiscrepancies += count;
876
+ }
877
+ }
878
+
879
+ if (sourcesWithIssues.length > 0) {
880
+ report += `1. Run rebuild_index with sources: ${JSON.stringify(sourcesWithIssues)}\n`;
881
+ report += `2. Total items affected: ${totalDiscrepancies}\n`;
882
+ report += `3. Estimated rebuild time: 3-10 minutes\n`;
883
+ report += `4. Orphaned entries will be removed during rebuild\n`;
884
+ report += `5. Duplicates indicate index corruption - rebuild recommended\n`;
885
+ } else {
886
+ report += "✓ No issues found! Index is in perfect sync with source data.\n";
887
+ }
888
+
889
+ // Summary Report
890
+ report += "\n" + "━".repeat(60) + "\n";
891
+ report += "SUMMARY REPORT\n";
892
+ report += "━".repeat(60) + "\n\n";
893
+
894
+ let totalSource = 0;
895
+ let totalIndexed = 0;
896
+ let totalMissing = 0;
897
+ let totalOrphaned = 0;
898
+ let totalDuplicates = 0;
899
+ let totalDeduplicates = 0;
900
+
901
+ for (const [dataType, result] of Object.entries(results)) {
902
+ totalSource += result.counts.source;
903
+ totalIndexed += result.counts.indexed;
904
+ totalMissing += result.discrepancies.missingCount;
905
+ totalOrphaned += result.discrepancies.orphanedCount;
906
+ totalDuplicates += result.discrepancies.duplicateCount;
907
+ totalDeduplicates += result.discrepancies.deduplicatedCount || 0;
908
+ }
909
+
910
+ // Calculate adjusted coverage (accounting for deduplication)
911
+ const uniqueSource = totalSource - totalDeduplicates;
912
+ const adjustedCoverage = uniqueSource > 0 ? Math.min(totalIndexed / uniqueSource, 1.0) : 1.0;
913
+ const rawCoverage = totalSource > 0 ? (totalIndexed / totalSource) : 0;
914
+ const totalIssues = totalMissing + totalOrphaned + totalDuplicates;
915
+ const healthStatus = totalIssues === 0 ? "HEALTHY ✓" : totalIssues <= 10 ? "MINOR ISSUES ⚠" : "NEEDS ATTENTION ✗";
916
+
917
+ report += `Data Sources Audited: ${Object.keys(results).length}\n`;
918
+ report += `Total Files: ${totalSource.toLocaleString()}\n`;
919
+ if (totalDeduplicates > 0) {
920
+ report += ` └─ Unique items: ${uniqueSource.toLocaleString()}\n`;
921
+ report += ` └─ Duplicate files: ${totalDeduplicates.toLocaleString()} (same email in multiple folders)\n`;
922
+ }
923
+ report += `Total Indexed: ${totalIndexed.toLocaleString()}\n`;
924
+ report += `Unique Item Coverage: ${(adjustedCoverage * 100).toFixed(1)}%\n`;
925
+ report += `Health Status: ${healthStatus}\n\n`;
926
+
927
+ // Calculate truly missing (excluding deduplicated)
928
+ let trulyMissingCount = 0;
929
+ for (const result of Object.values(results)) {
930
+ const trulyMissing = result.discrepancies.missing.filter(item => !item.isDuplicate);
931
+ trulyMissingCount += trulyMissing.length;
932
+ }
933
+ const realIssues = trulyMissingCount + totalOrphaned + totalDuplicates;
934
+
935
+ if (realIssues > 0 || totalDeduplicates > 0) {
936
+ report += "Issue Breakdown:\n";
937
+ if (trulyMissingCount > 0) {
938
+ report += ` Truly Missing: ${trulyMissingCount.toLocaleString()} (${((trulyMissingCount/uniqueSource)*100).toFixed(2)}% of unique items)\n`;
939
+ }
940
+ if (totalDeduplicates > 0) {
941
+ report += ` Deduplicated Files: ${totalDeduplicates.toLocaleString()} (same email in multiple folders - NORMAL)\n`;
942
+ }
943
+ if (totalOrphaned > 0) {
944
+ report += ` Orphaned Items: ${totalOrphaned.toLocaleString()} (${((totalOrphaned/totalIndexed)*100).toFixed(2)}% of index)\n`;
945
+ }
946
+ if (totalDuplicates > 0) {
947
+ report += ` Duplicate Items: ${totalDuplicates.toLocaleString()}\n`;
948
+ }
949
+ if (realIssues > 0) {
950
+ report += ` Total Issues: ${realIssues.toLocaleString()}\n\n`;
951
+ } else if (totalDeduplicates > 0) {
952
+ report += ` (No issues - deduplication is expected behavior)\n\n`;
953
+ }
954
+
955
+ // Per-source breakdown
956
+ report += "Per-Source Status:\n";
957
+ for (const [dataType, result] of Object.entries(results)) {
958
+ const { counts, discrepancies } = result;
959
+ const sourceIssues = discrepancies.missingCount + discrepancies.orphanedCount + discrepancies.duplicateCount;
960
+ const status = sourceIssues === 0 ? "✓" : "✗";
961
+
962
+ // For emails, show adjusted coverage
963
+ let coverage;
964
+ if (dataType === "emails" && counts.notes && counts.notes.deduplicatedFiles) {
965
+ const uniqueExpected = counts.source - counts.notes.deduplicatedFiles;
966
+ coverage = uniqueExpected > 0 ? Math.min((counts.indexed / uniqueExpected) * 100, 100).toFixed(1) : "100.0";
967
+ } else {
968
+ coverage = (counts.coverage * 100).toFixed(1);
969
+ }
970
+ report += ` ${status} ${dataType}: ${coverage}% coverage (${sourceIssues} issues)\n`;
971
+ }
972
+
973
+ // Detailed discrepancy list
974
+ report += "\n" + "─".repeat(60) + "\n";
975
+ report += "ALL DISCREPANCIES (Detailed List)\n";
976
+ report += "─".repeat(60) + "\n\n";
977
+
978
+ // Collect all discrepancies from all sources
979
+ let itemNumber = 1;
980
+
981
+ // Missing items (excluding deduplicated emails which are shown separately)
982
+ // Calculate truly missing count (excluding deduplicated emails)
983
+ let trulyMissingTotal = 0;
984
+ for (const [dataType, result] of Object.entries(results)) {
985
+ const trulyMissing = result.discrepancies.missing.filter(item => !item.isDuplicate);
986
+ trulyMissingTotal += trulyMissing.length;
987
+ }
988
+
989
+ if (trulyMissingTotal > 0) {
990
+ report += `MISSING ITEMS (${trulyMissingTotal} truly missing):\n\n`;
991
+ for (const [dataType, result] of Object.entries(results)) {
992
+ const trulyMissing = result.discrepancies.missing.filter(item => !item.isDuplicate);
993
+ if (trulyMissing.length > 0) {
994
+ report += ` From ${dataType}:\n`;
995
+ trulyMissing.forEach((item) => {
996
+ report += ` ${itemNumber}. `;
997
+ if (dataType === "emails") {
998
+ report += `${item.filePath}\n`;
999
+ report += ` Subject: ${item.subject}\n`;
1000
+ report += ` From: ${item.from}\n`;
1001
+ report += ` Date: ${item.date}\n`;
1002
+ } else if (dataType === "messages") {
1003
+ report += `Message ID: ${item.id}\n`;
1004
+ report += ` Text: ${item.text}\n`;
1005
+ report += ` Sender: ${item.sender}\n`;
1006
+ report += ` Date: ${item.date}\n`;
1007
+ } else if (dataType === "calendar") {
1008
+ report += `Event ID: ${item.id}\n`;
1009
+ report += ` Title: ${item.title}\n`;
1010
+ report += ` Start: ${item.start}\n`;
1011
+ }
1012
+ report += ` Reason: ${item.reason}\n\n`;
1013
+ itemNumber++;
1014
+ });
1015
+ }
1016
+ }
1017
+ }
1018
+
1019
+ // Orphaned items
1020
+ if (totalOrphaned > 0) {
1021
+ report += `ORPHANED ITEMS (${totalOrphaned} total):\n\n`;
1022
+ itemNumber = 1;
1023
+ for (const [dataType, result] of Object.entries(results)) {
1024
+ if (result.discrepancies.orphaned.length > 0) {
1025
+ report += ` From ${dataType}:\n`;
1026
+ result.discrepancies.orphaned.forEach((item) => {
1027
+ report += ` ${itemNumber}. `;
1028
+ if (dataType === "emails") {
1029
+ report += `${item.filePath}\n`;
1030
+ report += ` Subject: ${item.subject}\n`;
1031
+ } else if (dataType === "messages") {
1032
+ report += `Message ID: ${item.id}\n`;
1033
+ report += ` Text: ${item.text}\n`;
1034
+ } else if (dataType === "calendar") {
1035
+ report += `Event ID: ${item.id}\n`;
1036
+ report += ` Title: ${item.title}\n`;
1037
+ }
1038
+ report += ` Reason: ${item.reason}\n\n`;
1039
+ itemNumber++;
1040
+ });
1041
+ }
1042
+ }
1043
+ }
1044
+
1045
+ // Duplicate items
1046
+ if (totalDuplicates > 0) {
1047
+ report += `DUPLICATE ITEMS (${totalDuplicates} total):\n\n`;
1048
+ itemNumber = 1;
1049
+ for (const [dataType, result] of Object.entries(results)) {
1050
+ if (result.discrepancies.duplicates.length > 0) {
1051
+ report += ` From ${dataType}:\n`;
1052
+ result.discrepancies.duplicates.forEach((item) => {
1053
+ report += ` ${itemNumber}. `;
1054
+ if (dataType === "emails") {
1055
+ report += `FilePath indexed ${item.count} times:\n`;
1056
+ report += ` ${item.filePath}\n`;
1057
+ report += ` Subject: ${item.subject}\n\n`;
1058
+ } else if (dataType === "messages") {
1059
+ report += `Message ID ${item.id} indexed ${item.count} times:\n`;
1060
+ report += ` Text: ${item.text}\n`;
1061
+ report += ` Sender: ${item.sender}\n\n`;
1062
+ } else if (dataType === "calendar") {
1063
+ report += `Event ID ${item.id} indexed ${item.count} times:\n`;
1064
+ report += ` Title: ${item.title}\n\n`;
1065
+ }
1066
+ itemNumber++;
1067
+ });
1068
+ }
1069
+ }
1070
+ }
1071
+ } else {
1072
+ report += "✓ Perfect index health - all source data is correctly indexed\n";
1073
+ report += "✓ No missing items\n";
1074
+ report += "✓ No orphaned entries\n";
1075
+ report += "✓ No duplicate entries\n";
1076
+ }
1077
+
1078
+ report += "\n" + "=".repeat(60) + "\n";
1079
+ report += "END OF AUDIT REPORT\n";
1080
+ report += "=".repeat(60) + "\n";
1081
+
1082
+ return report;
1083
+ }