apple-tools-mcp 1.1.0 → 1.1.2

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/README.md CHANGED
@@ -137,10 +137,10 @@ Once configured, Claude can use these tools:
137
137
  | Tool | Description |
138
138
  |------|-------------|
139
139
  | `calendar_search` | Semantic search for events with filters |
140
- | `calendar_date` | Get events on a specific date |
140
+ | `calendar_date` | Get events on a specific date (live Calendar.app, not the search index) |
141
141
  | `calendar_upcoming` | Get next N upcoming events |
142
142
  | `calendar_week` | Get all events for current or future week |
143
- | `calendar_free_time` | Find available time slots on a date |
143
+ | `calendar_free_time` | Find available time slots on a date (live Calendar.app, not the search index) |
144
144
  | `calendar_recurring` | List recurring events |
145
145
 
146
146
  ### Contacts Tools
package/index.js CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  } from "@modelcontextprotocol/sdk/types.js";
9
9
  import fs from "fs";
10
10
  import path from "path";
11
- import { validateEmailPath, stripHtmlTags } from "./lib/validators.js";
11
+ import { validateEmailPath, stripHtmlTags, unfoldRfc822Headers, validateLimit, validateDaysBack, validateWeekOffset, toUnixMillis } from "./lib/validators.js";
12
12
 
13
13
  // Lock file to prevent duplicate indexing processes
14
14
  const LOCK_FILE = path.join(process.env.HOME, ".apple-tools-mcp", "indexer.lock");
@@ -234,12 +234,12 @@ function runIndexCycle() {
234
234
  progressCheckTimer = setInterval(() => {
235
235
  const timeSinceProgress = Date.now() - lastProgressTime;
236
236
  if (timeSinceProgress > MAX_NO_PROGRESS_MS) {
237
- console.error(`⚠️ No indexing progress for ${Math.round(timeSinceProgress / 60000)} minutes. Terminating hung process.`);
237
+ console.error(`⚠️ No indexing progress for ${Math.round(timeSinceProgress / 60000)} minutes. Indexing still running; not starting another cycle.`);
238
238
  clearInterval(progressCheckTimer);
239
239
  progressCheckTimer = null;
240
- indexingInProgress = false;
240
+ // Allow searches, but do NOT clear indexingInProgress or release the lock
241
+ // while indexAll() is still running — overlapping writes can corrupt LanceDB.
241
242
  sessionIndexComplete = true;
242
- releaseLock();
243
243
  }
244
244
  }, PROGRESS_CHECK_INTERVAL_MS);
245
245
 
@@ -436,29 +436,11 @@ async function calendarSearch(query, options = {}) {
436
436
  }
437
437
 
438
438
  async function calendarDate(date) {
439
- if (!sessionIndexComplete) {
440
- return getIndexingMessage();
441
- }
442
-
443
- const ready = await isIndexReady("calendar");
444
- if (!ready) {
445
- return "Calendar index not available. Please try again shortly.";
446
- }
447
-
448
439
  const result = await getCalendarDateResults(date);
449
440
  return formatCalendarResults(result);
450
441
  }
451
442
 
452
443
  async function calendarFreeTime(date, options = {}) {
453
- if (!sessionIndexComplete) {
454
- return getIndexingMessage();
455
- }
456
-
457
- const ready = await isIndexReady("calendar");
458
- if (!ready) {
459
- return "Calendar index not available. Please try again shortly.";
460
- }
461
-
462
444
  const result = await calculateFreeTime(date, options);
463
445
  return formatFreeTimeResults(result);
464
446
  }
@@ -482,7 +464,7 @@ function readFullEmail(filePath) {
482
464
  return "Email file not found.";
483
465
  }
484
466
 
485
- const content = fs.readFileSync(validatedPath, 'utf-8');
467
+ const content = unfoldRfc822Headers(fs.readFileSync(validatedPath, 'utf-8'));
486
468
 
487
469
  // Parse email headers and body
488
470
  const fromMatch = content.match(/^From:\s*(.+)$/m);
@@ -583,7 +565,7 @@ function formatSmartSearchResults(results, synthesizedGroups = null) {
583
565
  sections.push(` [${r.rank}] Score: ${r.score}`);
584
566
  sections.push(` From: ${r.sender}${r.isGroupChat ? ' (Group)' : ''}`);
585
567
  sections.push(` Date: ${r.date}`);
586
- sections.push(` Text: ${r.text.substring(0, 100)}...`);
568
+ sections.push(` Text: ${(r.text || "").substring(0, 100)}${(r.text || "").length > 100 ? "..." : ""}`);
587
569
  }
588
570
  sections.push("");
589
571
  }
@@ -609,6 +591,10 @@ function formatSmartSearchResults(results, synthesizedGroups = null) {
609
591
 
610
592
  // Smart search - routes to appropriate sources and optionally synthesizes results
611
593
  async function smartSearch(query, options = {}) {
594
+ if (!sessionIndexComplete) {
595
+ return getIndexingMessage();
596
+ }
597
+
612
598
  const { limit = 5, synthesize = true } = options;
613
599
 
614
600
  const sources = detectSources(query);
@@ -689,21 +675,21 @@ function synthesizeResults(mailResults, messageResults, calendarResults) {
689
675
 
690
676
  // Add mail results
691
677
  for (const r of mailResults) {
692
- const ts = r.dateTimestamp || new Date(r.date).getTime();
678
+ const ts = toUnixMillis(r.dateTimestamp) || new Date(r.date).getTime();
693
679
  const bucket = getBucket(ts);
694
680
  if (bucket) bucket.mail.push(r);
695
681
  }
696
682
 
697
683
  // Add message results
698
684
  for (const r of messageResults) {
699
- const ts = r.dateTimestamp || new Date(r.date).getTime();
685
+ const ts = toUnixMillis(r.dateTimestamp) || new Date(r.date).getTime();
700
686
  const bucket = getBucket(ts);
701
687
  if (bucket) bucket.messages.push(r);
702
688
  }
703
689
 
704
690
  // Add calendar results
705
691
  for (const r of calendarResults) {
706
- const ts = r.startTimestamp || new Date(r.start).getTime();
692
+ const ts = toUnixMillis(r.startTimestamp) || new Date(r.start).getTime();
707
693
  const bucket = getBucket(ts);
708
694
  if (bucket) bucket.calendar.push(r);
709
695
  }
@@ -777,6 +763,10 @@ function formatContactLookupResult(contact) {
777
763
  // ============ PERSON SEARCH (CROSS-SOURCE) ============
778
764
 
779
765
  async function personSearch(name, limit = 10) {
766
+ if (!sessionIndexComplete) {
767
+ return getIndexingMessage();
768
+ }
769
+
780
770
  // First, try to find the contact to get all their identifiers
781
771
  const contacts = searchContacts(name, 5);
782
772
 
@@ -905,7 +895,7 @@ function formatPersonSearchResults(results) {
905
895
  if (results.messages && results.messages.results && results.messages.results.length > 0) {
906
896
  sections.push(`💬 MESSAGES (${results.messages.results.length}):`);
907
897
  for (const r of results.messages.results.slice(0, 10)) {
908
- sections.push(` • ${r.text.substring(0, 80)}${r.text.length > 80 ? "..." : ""}`);
898
+ sections.push(` • ${(r.text || "").substring(0, 80)}${(r.text || "").length > 80 ? "..." : ""}`);
909
899
  sections.push(` ${r.date}`);
910
900
  }
911
901
  sections.push("");
@@ -1262,7 +1252,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1262
1252
  // Smart search (agentic)
1263
1253
  case "smart_search":
1264
1254
  result = await smartSearch(args.query, {
1265
- limit: args?.limit || 5,
1255
+ limit: validateLimit(args?.limit, 5, 100),
1266
1256
  synthesize: args?.synthesize !== false
1267
1257
  });
1268
1258
  break;
@@ -1270,8 +1260,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1270
1260
  // Email tools
1271
1261
  case "mail_search":
1272
1262
  result = await mailSearch(args.query, {
1273
- limit: args?.limit || 30,
1274
- daysBack: args?.days_back || 0,
1263
+ limit: validateLimit(args?.limit, 30),
1264
+ daysBack: validateDaysBack(args?.days_back),
1275
1265
  sender: args?.sender || null,
1276
1266
  recipient: args?.recipient || null,
1277
1267
  hasAttachment: args?.has_attachment ?? null,
@@ -1284,7 +1274,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1284
1274
  break;
1285
1275
 
1286
1276
  case "mail_recent":
1287
- result = await mailRecent(args?.limit || 30, args?.days_back || 7, args?.unread_only || false, args?.include_junk || false);
1277
+ result = await mailRecent(
1278
+ validateLimit(args?.limit, 30),
1279
+ validateDaysBack(args?.days_back) || 7,
1280
+ args?.unread_only || false,
1281
+ args?.include_junk || false
1282
+ );
1288
1283
  break;
1289
1284
 
1290
1285
  case "mail_date":
@@ -1298,8 +1293,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1298
1293
  // Messages tools
1299
1294
  case "messages_search":
1300
1295
  result = await messagesSearch(args.query, {
1301
- limit: args?.limit || 30,
1302
- daysBack: args?.days_back || 0,
1296
+ limit: validateLimit(args?.limit, 30),
1297
+ daysBack: validateDaysBack(args?.days_back),
1303
1298
  contact: args?.contact || null,
1304
1299
  groupChatOnly: args?.group_chat_only || false,
1305
1300
  groupChatName: args?.group_chat_name || null,
@@ -1309,19 +1304,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1309
1304
  break;
1310
1305
 
1311
1306
  case "messages_recent":
1312
- result = await messagesRecent(args?.limit || 30, args?.days_back || 1);
1307
+ result = await messagesRecent(
1308
+ validateLimit(args?.limit, 30),
1309
+ validateDaysBack(args?.days_back) || 1
1310
+ );
1313
1311
  break;
1314
1312
 
1315
1313
  case "messages_conversation":
1316
- result = await messagesConversation(args.contact, args?.limit || 50);
1314
+ result = await messagesConversation(args.contact, validateLimit(args?.limit, 50));
1317
1315
  break;
1318
1316
 
1319
1317
  // Calendar tools
1320
1318
  case "calendar_search":
1321
1319
  result = await calendarSearch(args.query, {
1322
- limit: args?.limit || 30,
1323
- daysBack: args?.days_back || 0,
1324
- daysAhead: args?.days_ahead || 0,
1320
+ limit: validateLimit(args?.limit, 30),
1321
+ daysBack: validateDaysBack(args?.days_back),
1322
+ daysAhead: validateDaysBack(args?.days_ahead),
1325
1323
  calendarName: args?.calendar_name || null,
1326
1324
  allDayOnly: args?.all_day_only || false,
1327
1325
  sortBy: args?.sort_by || "relevance"
@@ -1344,7 +1342,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1344
1342
 
1345
1343
  // Mail tools
1346
1344
  case "mail_senders":
1347
- result = formatSendersResults(await getFrequentSenders(args?.limit || 30, args?.days_back || 0, args?.include_junk || false));
1345
+ if (!sessionIndexComplete) {
1346
+ result = getIndexingMessage();
1347
+ break;
1348
+ }
1349
+ result = formatSendersResults(await getFrequentSenders(
1350
+ validateLimit(args?.limit, 30),
1351
+ validateDaysBack(args?.days_back),
1352
+ args?.include_junk || false
1353
+ ));
1348
1354
  break;
1349
1355
 
1350
1356
  case "rebuild_index":
@@ -1362,6 +1368,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1362
1368
 
1363
1369
  // Start rebuild in background and return immediately
1364
1370
  indexingInProgress = true;
1371
+ sessionIndexComplete = false;
1365
1372
  const rebuildSources = args?.sources || ["emails", "messages", "calendar"];
1366
1373
 
1367
1374
  // Fire and forget - don't await
@@ -1399,34 +1406,38 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1399
1406
 
1400
1407
  // Messages tools
1401
1408
  case "messages_contacts":
1402
- result = formatMessageContactsResults(getMessageContacts(args?.limit || 50));
1409
+ result = formatMessageContactsResults(getMessageContacts(validateLimit(args?.limit, 50, 500)));
1403
1410
  break;
1404
1411
 
1405
1412
  // Calendar tools
1406
1413
  case "calendar_upcoming":
1407
- result = formatUpcomingEventsResults(getUpcomingEvents(args?.limit || 30));
1414
+ result = formatUpcomingEventsResults(getUpcomingEvents(validateLimit(args?.limit, 30, 100)));
1408
1415
  break;
1409
1416
 
1410
1417
  // ============ NEW TOOLS - PHASE 2 ============
1411
1418
 
1412
1419
  case "calendar_week":
1413
- result = formatWeekEventsResults(getWeekEvents(args?.week_offset || 0));
1420
+ result = formatWeekEventsResults(getWeekEvents(validateWeekOffset(args?.week_offset)));
1414
1421
  break;
1415
1422
 
1416
1423
  // ============ NEW TOOLS - PHASE 3 ============
1417
1424
 
1418
1425
  case "mail_thread":
1419
- result = formatEmailThreadResults(await getEmailThread(args.file_path, args?.limit || 30));
1426
+ if (!sessionIndexComplete) {
1427
+ result = getIndexingMessage();
1428
+ break;
1429
+ }
1430
+ result = formatEmailThreadResults(await getEmailThread(args.file_path, validateLimit(args?.limit, 30)));
1420
1431
  break;
1421
1432
 
1422
1433
  case "calendar_recurring":
1423
- result = formatRecurringEventsResults(getRecurringEvents(args?.limit || 30));
1434
+ result = formatRecurringEventsResults(getRecurringEvents(validateLimit(args?.limit, 30, 100)));
1424
1435
  break;
1425
1436
 
1426
1437
  // ============ CONTACTS TOOLS ============
1427
1438
 
1428
1439
  case "contacts_search":
1429
- result = formatContactsSearchResults(searchContacts(args.query, args?.limit || 30));
1440
+ result = formatContactsSearchResults(searchContacts(args.query, validateLimit(args?.limit, 30)));
1430
1441
  break;
1431
1442
 
1432
1443
  case "contacts_lookup":
@@ -1434,7 +1445,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1434
1445
  break;
1435
1446
 
1436
1447
  case "person_search":
1437
- result = await personSearch(args.name, args?.limit || 10);
1448
+ result = await personSearch(args.name, validateLimit(args?.limit, 10));
1438
1449
  break;
1439
1450
 
1440
1451
  default:
@@ -1454,7 +1465,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1454
1465
  async function main() {
1455
1466
  const transport = new StdioServerTransport();
1456
1467
  await server.connect(transport);
1457
- console.error("Apple Tools MCP server running (v1.1.0)");
1468
+ console.error("Apple Tools MCP server running (v1.1.2)");
1458
1469
  // Background indexing runs automatically on startup and every INDEX_INTERVAL
1459
1470
  }
1460
1471
 
package/indexer.js CHANGED
@@ -9,7 +9,10 @@ import {
9
9
  validateLimit,
10
10
  validateLanceDBId,
11
11
  escapeSQL,
12
- stripHtmlTags
12
+ stripHtmlTags,
13
+ toUnixMillis,
14
+ unfoldRfc822Headers,
15
+ stripSubjectPrefixes
13
16
  } from "./lib/validators.js";
14
17
  import { safeSqlite3Json, safeOsascript, safeFind } from "./lib/shell.js";
15
18
 
@@ -63,6 +66,7 @@ const BATCH_DELAY_MS = 0; // No delay needed - benchmarks showed no thermal thro
63
66
 
64
67
  // Mac Absolute Time epoch: Jan 1, 2001 00:00:00 UTC
65
68
  const MAC_ABSOLUTE_EPOCH = 978307200;
69
+ export const CALENDAR_TMP_PREFIX = "apple-tools-cal-";
66
70
 
67
71
  let embeddingPipeline = null;
68
72
  let db = null;
@@ -251,14 +255,17 @@ function parseEmlx(filePath) {
251
255
  content = lines.slice(1).join("\n");
252
256
  }
253
257
 
258
+ // Unfold RFC 822 wrapped headers so long From/Subject/To values are complete
259
+ const unfolded = unfoldRfc822Headers(content);
260
+
254
261
  // Extract headers
255
- const fromMatch = content.match(/^From:\s*(.+)$/m);
256
- const subjectMatch = content.match(/^Subject:\s*(.+)$/m);
257
- const dateMatch = content.match(/^Date:\s*(.+)$/m);
258
- const toMatch = content.match(/^To:\s*(.+)$/m);
259
- const ccMatch = content.match(/^Cc:\s*(.+)$/m);
260
- const messageIdMatch = content.match(/^Message-ID:\s*(.+)$/im);
261
- const flaggedMatch = content.match(/^X-Flagged:\s*(.+)$/im) || content.match(/flags.*flagged/i);
262
+ const fromMatch = unfolded.match(/^From:\s*(.+)$/m);
263
+ const subjectMatch = unfolded.match(/^Subject:\s*(.+)$/m);
264
+ const dateMatch = unfolded.match(/^Date:\s*(.+)$/m);
265
+ const toMatch = unfolded.match(/^To:\s*(.+)$/m);
266
+ const ccMatch = unfolded.match(/^Cc:\s*(.+)$/m);
267
+ const messageIdMatch = unfolded.match(/^Message-ID:\s*(.+)$/im);
268
+ const flaggedMatch = unfolded.match(/^X-Flagged:\s*(.+)$/im) || unfolded.match(/flags.*flagged/i);
262
269
 
263
270
  // Check for attachments
264
271
  const hasAttachment = /Content-Disposition:\s*attachment/i.test(content) ||
@@ -334,13 +341,10 @@ function parseEmlx(filePath) {
334
341
  // Full scan - used for first run and rebuild_index
335
342
  // Includes both .emlx and .partial.emlx files (partial = not fully downloaded via IMAP)
336
343
  async function findAllEmlxFiles() {
337
- try {
338
- // "*.emlx" also matches "*.partial.emlx"
339
- return safeFind(MAIL_DIR, { name: "*.emlx", type: "f" });
340
- } catch (e) {
341
- console.error("Error finding emlx files:", e.message);
342
- return [];
343
- }
344
+ // "*.emlx" also matches "*.partial.emlx"
345
+ // Let permission/IO errors propagate so indexEmails does not persist a
346
+ // timestamp for an empty scan and skip real mail forever.
347
+ return safeFind(MAIL_DIR, { name: "*.emlx", type: "f" });
344
348
  }
345
349
 
346
350
  // Fast incremental scan - uses find with -mtime filter (more reliable than mdfind/Spotlight)
@@ -578,6 +582,9 @@ function getMessages(sinceTimestamp = null) {
578
582
  ORDER BY m.date DESC
579
583
  `;
580
584
  const results = safeSqlite3Json(MESSAGES_DB, query, { timeout: 60000 });
585
+ if (!Array.isArray(results)) {
586
+ throw new Error("Unexpected sqlite3 result for messages");
587
+ }
581
588
 
582
589
  // Post-process: extract text from attributedBody where text is NULL
583
590
  let extractedCount = 0;
@@ -606,7 +613,7 @@ function getMessages(sinceTimestamp = null) {
606
613
  return results.filter(msg => msg.text && msg.text.trim() !== '');
607
614
  } catch (e) {
608
615
  console.error("Error reading messages:", e.message);
609
- return [];
616
+ throw e;
610
617
  }
611
618
  }
612
619
 
@@ -644,19 +651,24 @@ function getParticipantStatus(status) {
644
651
  }
645
652
 
646
653
  function getCalendarEvents() {
647
- try {
648
- // NOTE: We index ALL calendar events, not filtered by date
649
- // Calendar events don't have a "file modification time" like emails do,
650
- // so we can't use the mdfind + DAYS_BACK approach.
651
- // Calendar indexing is always comprehensive - filtering by date would lose historical context.
652
- const now = Date.now();
653
- const pastDate = unixMsToMacAbsolute(now - 10 * 365 * 24 * 60 * 60 * 1000); // 10 years back
654
- const futureDate = unixMsToMacAbsolute(now + 10 * 365 * 24 * 60 * 60 * 1000); // 10 years ahead
655
-
656
- // Query OccurrenceCache for recurring events and their calculated occurrences
657
- // This includes both recurring and non-recurring events
658
- // GROUP BY to avoid duplicate entries for recurring events
659
- const query = `
654
+ // NOTE: We index ALL calendar events, not filtered by date
655
+ // Calendar events don't have a "file modification time" like emails do,
656
+ // so we can't use the mdfind + DAYS_BACK approach.
657
+ // Calendar indexing is always comprehensive - filtering by date would lose historical context.
658
+ // Must throw on source-read failure: a silent [] would look like "every event
659
+ // was deleted" and the stale-entry pass would wipe the calendar index.
660
+ if (!fs.existsSync(CALENDAR_DB)) {
661
+ throw new Error("Calendar database not found");
662
+ }
663
+
664
+ const now = Date.now();
665
+ const pastDate = unixMsToMacAbsolute(now - 10 * 365 * 24 * 60 * 60 * 1000); // 10 years back
666
+ const futureDate = unixMsToMacAbsolute(now + 10 * 365 * 24 * 60 * 60 * 1000); // 10 years ahead
667
+
668
+ // Query OccurrenceCache for recurring events and their calculated occurrences
669
+ // This includes both recurring and non-recurring events
670
+ // GROUP BY to avoid duplicate entries for recurring events
671
+ const query = `
660
672
  SELECT
661
673
  ci.ROWID as id,
662
674
  ci.summary,
@@ -678,10 +690,7 @@ function getCalendarEvents() {
678
690
  ORDER BY MIN(oc.day) ASC
679
691
  `;
680
692
 
681
- const rows = safeSqlite3Json(CALENDAR_DB, query, { timeout: 30000 });
682
-
683
- // Get attendees for events that have them (separate query for efficiency)
684
- const attendeesQuery = `
693
+ const attendeesQuery = `
685
694
  SELECT
686
695
  p.owner_id,
687
696
  COALESCE(i.display_name, p.email, 'Unknown') as name,
@@ -691,11 +700,13 @@ function getCalendarEvents() {
691
700
  WHERE p.entity_type = 0
692
701
  `;
693
702
 
703
+ return withCalendarCopy((dbPath) => {
704
+ const rows = safeSqlite3Json(dbPath, query, { timeout: 30000 });
705
+
694
706
  let attendeesMap = new Map();
695
707
  try {
696
- const attendeesRows = safeSqlite3Json(CALENDAR_DB, attendeesQuery, { timeout: 10000 });
708
+ const attendeesRows = safeSqlite3Json(dbPath, attendeesQuery, { timeout: 10000 });
697
709
 
698
- // Group attendees by owner_id (event id)
699
710
  for (const att of attendeesRows) {
700
711
  if (!attendeesMap.has(att.owner_id)) {
701
712
  attendeesMap.set(att.owner_id, []);
@@ -733,10 +744,7 @@ function getCalendarEvents() {
733
744
 
734
745
  console.error(`Calendar: Retrieved ${events.length} events via SQLite (~${Math.round((Date.now() - now))}ms)`);
735
746
  return events;
736
- } catch (e) {
737
- console.error("Error reading calendar:", e.message);
738
- return [];
739
- }
747
+ });
740
748
  }
741
749
 
742
750
  // ============ DATABASE FUNCTIONS ============
@@ -926,7 +934,13 @@ export async function indexEmails(progressCallback = null, forceFullScan = false
926
934
  // Use fast incremental scan if we have a previous timestamp
927
935
  const startTime = Date.now();
928
936
  console.error(`Calling findNewEmlxFiles with timestamp: ${lastEmailIndexTime ? new Date(lastEmailIndexTime).toISOString() : 'null (full scan)'}`);
929
- const newFiles = await findNewEmlxFiles(lastEmailIndexTime);
937
+ let newFiles;
938
+ try {
939
+ newFiles = await findNewEmlxFiles(lastEmailIndexTime);
940
+ } catch (e) {
941
+ console.error(`Email source read failed; skipping this cycle so the lookback timestamp is not advanced: ${e.message}`);
942
+ return { indexed: 0, added: 0, error: e.message };
943
+ }
930
944
  console.error(`Found ${newFiles.length} new/modified email files (${Date.now() - startTime}ms)`);
931
945
 
932
946
  const indexedPaths = await getIndexedIdsWithRetry("emails", "filePath");
@@ -1040,6 +1054,13 @@ export async function indexEmails(progressCallback = null, forceFullScan = false
1040
1054
  if (uniqueRecords.length > 0) {
1041
1055
  if (!tables.emails) {
1042
1056
  tables.emails = await db.createTable("emails", uniqueRecords, { mode: "overwrite" });
1057
+ // Track messageIds from the first batch so later batches can
1058
+ // dedupe IMAP copies (INBOX vs Junk) of the same Message-ID.
1059
+ for (const record of uniqueRecords) {
1060
+ if (record.messageId) {
1061
+ indexedMessageIds.add(record.messageId);
1062
+ }
1063
+ }
1043
1064
  } else {
1044
1065
  // Double-check: verify these IDs truly aren't in the index
1045
1066
  const currentIndexed = await getIndexedIdsWithRetry("emails", "filePath");
@@ -1129,7 +1150,13 @@ export async function indexMessages(forceFullScan = false) {
1129
1150
  const indexStartTime = Date.now();
1130
1151
 
1131
1152
  // Use incremental scan if we have a previous timestamp
1132
- const messages = getMessages(lastMessageIndexTime);
1153
+ let messages;
1154
+ try {
1155
+ messages = getMessages(lastMessageIndexTime);
1156
+ } catch (e) {
1157
+ console.error(`Messages source read failed; skipping this cycle so the lookback timestamp is not advanced: ${e.message}`);
1158
+ return { indexed: 0, added: 0, error: e.message };
1159
+ }
1133
1160
  console.error(`Found ${messages.length} messages${lastMessageIndexTime ? ' (incremental)' : ' (full scan)'}`);
1134
1161
 
1135
1162
  const indexed = await getIndexedIdsWithRetry("messages", "id");
@@ -1185,7 +1212,7 @@ export async function indexMessages(forceFullScan = false) {
1185
1212
  return {
1186
1213
  id: String(msg.id),
1187
1214
  date: msg.date,
1188
- dateTimestamp: msg.dateTimestamp || 0,
1215
+ dateTimestamp: toUnixMillis(msg.dateTimestamp),
1189
1216
  sender: msg.sender,
1190
1217
  text: msg.text?.substring(0, 500) || "",
1191
1218
  chatId: String(msg.chatId || ""),
@@ -1269,7 +1296,15 @@ export async function indexMessages(forceFullScan = false) {
1269
1296
  export async function indexCalendar() {
1270
1297
  await initDB();
1271
1298
 
1272
- const events = getCalendarEvents();
1299
+ let events;
1300
+ try {
1301
+ events = getCalendarEvents();
1302
+ } catch (e) {
1303
+ // Do not treat a source-read failure as "zero events" — that would mark
1304
+ // every indexed row stale and delete the calendar index.
1305
+ console.error(`Calendar source read failed; skipping index update to avoid data loss: ${e.message}`);
1306
+ return { indexed: 0, added: 0, removed: 0, error: e.message };
1307
+ }
1273
1308
  console.error(`Found ${events.length} calendar events`);
1274
1309
 
1275
1310
  // Get already indexed event IDs for incremental indexing
@@ -1492,14 +1527,14 @@ export async function getRecentMessages(limit = 10, daysBack = 1) {
1492
1527
  if (!tables.messages) return { messages: [], hasMore: false };
1493
1528
 
1494
1529
  try {
1495
- const cutoff = Date.now() / 1000 - (daysBack * 24 * 60 * 60); // Messages use Unix timestamp
1530
+ const cutoff = Date.now() - (daysBack * 24 * 60 * 60 * 1000);
1496
1531
  const results = await tables.messages.query()
1497
1532
  .select(["id", "date", "dateTimestamp", "sender", "text", "chatId", "isGroupChat"])
1498
1533
  .toArray();
1499
1534
 
1500
1535
  const filtered = results
1501
- .filter(r => r.dateTimestamp >= cutoff)
1502
- .sort((a, b) => b.dateTimestamp - a.dateTimestamp);
1536
+ .filter(r => toUnixMillis(r.dateTimestamp) >= cutoff)
1537
+ .sort((a, b) => toUnixMillis(b.dateTimestamp) - toUnixMillis(a.dateTimestamp));
1503
1538
 
1504
1539
  const hasMore = filtered.length > limit;
1505
1540
  const messages = filtered.slice(0, limit);
@@ -1529,7 +1564,7 @@ export async function getConversation(contact, limit = 50) {
1529
1564
 
1530
1565
  // Sort chronologically (oldest first for conversation view)
1531
1566
  return filtered
1532
- .sort((a, b) => a.dateTimestamp - b.dateTimestamp)
1567
+ .sort((a, b) => toUnixMillis(a.dateTimestamp) - toUnixMillis(b.dateTimestamp))
1533
1568
  .slice(-limit); // Take last N messages
1534
1569
  } catch (e) {
1535
1570
  console.error("Error getting conversation:", e.message);
@@ -1647,7 +1682,7 @@ export function getMessageContacts(limit = 50) {
1647
1682
  return safeSqlite3Json(MESSAGES_DB, query, { timeout: 30000 });
1648
1683
  } catch (e) {
1649
1684
  console.error("Error getting message contacts:", e.message);
1650
- return [];
1685
+ return { error: e.message };
1651
1686
  }
1652
1687
  }
1653
1688
 
@@ -1677,13 +1712,126 @@ export function getUpcomingEvents(limit = 10) {
1677
1712
  ORDER BY sort_day ASC
1678
1713
  LIMIT ${fetchLimit}
1679
1714
  `;
1680
- const events = safeSqlite3Json(CALENDAR_DB, query, { timeout: 10000 });
1715
+ const events = withCalendarCopy((dbPath) => {
1716
+ return safeSqlite3Json(dbPath, query, { timeout: 10000 });
1717
+ });
1681
1718
  const hasMore = events.length > safeLimit;
1682
1719
  const limitedEvents = events.slice(0, safeLimit);
1683
1720
  return { events: limitedEvents, showing: limitedEvents.length, hasMore };
1684
1721
  } catch (e) {
1685
1722
  console.error("Error getting upcoming events:", e.message);
1686
- return { events: [], showing: 0, hasMore: false };
1723
+ return { events: [], showing: 0, hasMore: false, error: e.message };
1724
+ }
1725
+ }
1726
+
1727
+ // Convert local-day unix ms bounds to Apple/Core Data seconds (unix - 978307200)
1728
+ export function localDayToMacBounds(startMs, endMs) {
1729
+ const startMac = Math.floor(Number(startMs) / 1000) - MAC_ABSOLUTE_EPOCH;
1730
+ const endMac = Math.floor(Number(endMs) / 1000) - MAC_ABSOLUTE_EPOCH;
1731
+ if (!Number.isFinite(startMac) || !Number.isFinite(endMac)) {
1732
+ throw new Error("Invalid date bounds");
1733
+ }
1734
+ return { startMac, endMac };
1735
+ }
1736
+
1737
+ // Date-bounded OccurrenceCache query: one row per occurrence, no GROUP BY ci.ROWID
1738
+ export function buildEventsOnDateQuery(startMac, endMac) {
1739
+ const startBound = Math.floor(Number(startMac));
1740
+ const endBound = Math.floor(Number(endMac));
1741
+ if (!Number.isFinite(startBound) || !Number.isFinite(endBound)) {
1742
+ throw new Error("Invalid Mac Absolute Time bounds");
1743
+ }
1744
+ const timedStart = "COALESCE(oc.occurrence_end_date - (ci.end_date - ci.start_date), ci.start_date)";
1745
+ const occEnd = "COALESCE(oc.occurrence_end_date, ci.end_date)";
1746
+ // First local day = occurrence-end local date minus (durationDays - 1). Day math, not seconds (DST-safe).
1747
+ const allDayStartDate = `date(${occEnd} + 978307200, 'unixepoch', 'localtime', '-' || (CAST(round((ci.end_date - ci.start_date) / 86400.0) AS INTEGER) - 1) || ' days')`;
1748
+ const allDayStartMac = `CAST(strftime('%s', ${allDayStartDate} || ' 00:00:00', 'utc') AS INTEGER) - 978307200`;
1749
+ return `
1750
+ SELECT DISTINCT
1751
+ ci.ROWID as itemId,
1752
+ ci.summary as title,
1753
+ datetime(CASE WHEN ci.all_day THEN ${allDayStartMac} ELSE ${timedStart} END + 978307200, 'unixepoch', 'localtime') as start,
1754
+ datetime(${occEnd} + 978307200, 'unixepoch', 'localtime') as end,
1755
+ CASE WHEN ci.all_day THEN ${allDayStartMac} ELSE ${timedStart} END as startMac,
1756
+ ${occEnd} as endMac,
1757
+ ci.all_day as isAllDay,
1758
+ c.title as calendar,
1759
+ l.title as location
1760
+ FROM OccurrenceCache oc
1761
+ INNER JOIN CalendarItem ci ON oc.event_id = ci.ROWID
1762
+ LEFT JOIN Calendar c ON ci.calendar_id = c.ROWID
1763
+ LEFT JOIN Location l ON ci.location_id = l.ROWID
1764
+ WHERE ci.summary IS NOT NULL AND ci.summary <> ''
1765
+ AND (c.title IS NULL OR c.title NOT IN ('Found in Mail', 'Found in Natural Language'))
1766
+ AND (
1767
+ (oc.day >= ${startBound} AND oc.day < ${endBound})
1768
+ OR (
1769
+ ci.all_day = 0
1770
+ AND ${timedStart} < ${startBound}
1771
+ AND ${occEnd} > ${startBound}
1772
+ )
1773
+ )
1774
+ ORDER BY startMac ASC, title ASC
1775
+ `;
1776
+ }
1777
+
1778
+ // Copy Calendar.sqlitedb (+ wal/shm) to /tmp for one query, then delete the copies.
1779
+ // Calendar.app holds a lock on the live db; sqlite can read a snapshot copy.
1780
+ function withCalendarCopy(fn) {
1781
+ const id = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1782
+ const tmpDb = path.join("/tmp", `${CALENDAR_TMP_PREFIX}${id}.sqlitedb`);
1783
+ const tmpWal = `${tmpDb}-wal`;
1784
+ const tmpShm = `${tmpDb}-shm`;
1785
+ const srcWal = `${CALENDAR_DB}-wal`;
1786
+ const srcShm = `${CALENDAR_DB}-shm`;
1787
+
1788
+ try {
1789
+ if (!fs.existsSync(CALENDAR_DB)) {
1790
+ throw new Error("Calendar database not found");
1791
+ }
1792
+ fs.copyFileSync(CALENDAR_DB, tmpDb);
1793
+ if (fs.existsSync(srcWal)) {
1794
+ fs.copyFileSync(srcWal, tmpWal);
1795
+ }
1796
+ if (fs.existsSync(srcShm)) {
1797
+ fs.copyFileSync(srcShm, tmpShm);
1798
+ }
1799
+ return fn(tmpDb);
1800
+ } finally {
1801
+ for (const file of [tmpDb, tmpWal, tmpShm]) {
1802
+ try {
1803
+ fs.unlinkSync(file);
1804
+ } catch {
1805
+ // already gone or never created
1806
+ }
1807
+ }
1808
+ }
1809
+ }
1810
+
1811
+ // calendar_date: live OccurrenceCache for every occurrence on a local day
1812
+ export function getEventsOnDate(startMs, endMs) {
1813
+ try {
1814
+ if (!fs.existsSync(CALENDAR_DB)) {
1815
+ return { events: [], error: "Calendar database not found" };
1816
+ }
1817
+ const { startMac, endMac } = localDayToMacBounds(startMs, endMs);
1818
+ const query = buildEventsOnDateQuery(startMac, endMac);
1819
+ let lastError;
1820
+ for (let attempt = 0; attempt < 3; attempt++) {
1821
+ try {
1822
+ const events = withCalendarCopy((dbPath) => {
1823
+ return safeSqlite3Json(dbPath, query, { timeout: 15000 });
1824
+ });
1825
+ return { events, error: null };
1826
+ } catch (e) {
1827
+ lastError = e;
1828
+ console.error(`Error getting events on date (attempt ${attempt + 1}):`, e.message);
1829
+ }
1830
+ }
1831
+ return { events: [], error: lastError.message };
1832
+ } catch (e) {
1833
+ console.error("Error getting events on date:", e.message);
1834
+ return { events: [], error: e.message };
1687
1835
  }
1688
1836
  }
1689
1837
 
@@ -1764,7 +1912,9 @@ export function getWeekEvents(weekOffset = 0) {
1764
1912
  AND ci.summary IS NOT NULL AND ci.summary <> ''
1765
1913
  ORDER BY oc.day ASC
1766
1914
  `;
1767
- const events = safeSqlite3Json(CALENDAR_DB, query, { timeout: 15000 });
1915
+ const events = withCalendarCopy((dbPath) => {
1916
+ return safeSqlite3Json(dbPath, query, { timeout: 15000 });
1917
+ });
1768
1918
 
1769
1919
  const weekStart = monday.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
1770
1920
  const weekEnd = sunday.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
@@ -1803,15 +1953,13 @@ export async function getEmailThread(filePath, limit = 20) {
1803
1953
  }
1804
1954
 
1805
1955
  // Read the email to get subject
1806
- const content = fs.readFileSync(validatedPath, "utf-8");
1956
+ const content = unfoldRfc822Headers(fs.readFileSync(validatedPath, "utf-8"));
1807
1957
  const subjectMatch = content.match(/^Subject:\s*(.+)$/m);
1808
1958
  if (!subjectMatch) {
1809
1959
  return { error: "Could not extract subject from email", emails: [] };
1810
1960
  }
1811
1961
 
1812
- // Clean subject - remove Re:, Fwd:, etc.
1813
- let subject = subjectMatch[1].trim();
1814
- const baseSubject = subject.replace(/^(Re|Fwd|Fw):\s*/gi, "").trim();
1962
+ const baseSubject = stripSubjectPrefixes(subjectMatch[1]);
1815
1963
 
1816
1964
  if (baseSubject.length < 5) {
1817
1965
  return { error: "Subject too short to find thread", emails: [] };
@@ -1825,7 +1973,7 @@ export async function getEmailThread(filePath, limit = 20) {
1825
1973
  // Filter to emails with matching base subject
1826
1974
  const threadEmails = allEmails
1827
1975
  .filter(e => {
1828
- const eBaseSubject = (e.subject || "").replace(/^(Re|Fwd|Fw):\s*/gi, "").trim();
1976
+ const eBaseSubject = stripSubjectPrefixes(e.subject || "");
1829
1977
  return eBaseSubject.toLowerCase() === baseSubject.toLowerCase();
1830
1978
  })
1831
1979
  .sort((a, b) => a.dateTimestamp - b.dateTimestamp)
@@ -1869,12 +2017,14 @@ export function getRecurringEvents(limit = 20) {
1869
2017
  ORDER BY occurrenceCount DESC, MIN(oc.day) ASC
1870
2018
  LIMIT ${fetchLimit}
1871
2019
  `;
1872
- const events = safeSqlite3Json(CALENDAR_DB, query, { timeout: 30000 });
2020
+ const events = withCalendarCopy((dbPath) => {
2021
+ return safeSqlite3Json(dbPath, query, { timeout: 30000 });
2022
+ });
1873
2023
  const hasMore = events.length > safeLimit;
1874
2024
  const limitedEvents = events.slice(0, safeLimit);
1875
2025
  return { events: limitedEvents, showing: limitedEvents.length, hasMore };
1876
2026
  } catch (e) {
1877
2027
  console.error("Error getting recurring events:", e.message);
1878
- return { events: [], showing: 0, hasMore: false };
2028
+ return { events: [], showing: 0, hasMore: false, error: e.message };
1879
2029
  }
1880
2030
  }
package/lib/shell.js CHANGED
@@ -93,8 +93,7 @@ export function safeSqlite3Json(dbPath, query, options = {}) {
93
93
  try {
94
94
  return JSON.parse(output);
95
95
  } catch (e) {
96
- console.error('Failed to parse sqlite3 JSON output:', e.message);
97
- return [];
96
+ throw new Error(`Failed to parse sqlite3 JSON output: ${e.message}`);
98
97
  }
99
98
  }
100
99
 
@@ -267,12 +266,20 @@ export function safeFind(searchPath, options = {}) {
267
266
  throw result.error;
268
267
  }
269
268
 
270
- // find may return non-zero if some paths are inaccessible
271
- // We still want to return whatever paths it found
272
- return result.stdout
269
+ const paths = (result.stdout || '')
273
270
  .split('\n')
274
271
  .map(line => line.trim())
275
272
  .filter(line => line.length > 0);
273
+
274
+ // Partial permission errors can still yield some paths; keep those.
275
+ // A complete failure (no paths + non-zero status) must not be treated as
276
+ // "zero files" — callers persist index timestamps on empty scans.
277
+ if (result.status !== 0 && paths.length === 0) {
278
+ const errorMsg = (result.stderr || '').trim() || `find exited with code ${result.status}`;
279
+ throw new Error(errorMsg);
280
+ }
281
+
282
+ return paths;
276
283
  }
277
284
 
278
285
  // ============ GENERIC SAFE SPAWN ============
package/lib/validators.js CHANGED
@@ -118,10 +118,14 @@ export function escapeAppleScript(str) {
118
118
  if (!str || typeof str !== 'string') {
119
119
  return '';
120
120
  }
121
- // Escape backslashes first, then double quotes
121
+ // Escape backslashes first, then quotes, then line breaks.
122
+ // Unescaped CR/LF inside an AppleScript double-quoted string can close the
123
+ // string and inject additional statements.
122
124
  return str
123
125
  .replace(/\\/g, '\\\\')
124
- .replace(/"/g, '\\"');
126
+ .replace(/"/g, '\\"')
127
+ .replace(/\r/g, '\\r')
128
+ .replace(/\n/g, '\\n');
125
129
  }
126
130
 
127
131
  /**
@@ -407,6 +411,64 @@ export function stripHtmlTags(html, maxLength = 100000) {
407
411
  */
408
412
  const MAC_ABSOLUTE_EPOCH = 978307200;
409
413
 
414
+ /**
415
+ * Normalize a Unix timestamp to milliseconds.
416
+ * Messages chat.db values are stored as seconds; email/calendar timestamps
417
+ * are milliseconds. Treating seconds as ms drops every date-filtered message.
418
+ *
419
+ * Threshold 1e11 ms is 1973-03-03 — well before any real mail/message date,
420
+ * and far above Unix seconds for year 2100 (~4e9).
421
+ *
422
+ * @param {number|string|null|undefined} ts
423
+ * @returns {number} Timestamp in milliseconds, or 0 if invalid
424
+ */
425
+ export function toUnixMillis(ts) {
426
+ if (ts === null || ts === undefined || ts === '') {
427
+ return 0;
428
+ }
429
+ const n = Number(ts);
430
+ if (!Number.isFinite(n) || n <= 0) {
431
+ return 0;
432
+ }
433
+ return n < 1e11 ? n * 1000 : n;
434
+ }
435
+
436
+ /**
437
+ * Unfold RFC 822 wrapped header lines (CRLF + WSP continuation) so
438
+ * From/Subject/To matchers see the full header value.
439
+ *
440
+ * @param {string} content - Raw email content (headers + body)
441
+ * @returns {string} Content with folded headers joined into single lines
442
+ */
443
+ export function unfoldRfc822Headers(content) {
444
+ if (!content || typeof content !== 'string') {
445
+ return '';
446
+ }
447
+ const split = content.search(/\r?\n\r?\n/);
448
+ const headers = split >= 0 ? content.slice(0, split) : content;
449
+ const body = split >= 0 ? content.slice(split) : '';
450
+ return headers.replace(/\r?\n[ \t]+/g, ' ') + body;
451
+ }
452
+
453
+ /**
454
+ * Strip repeated Re:/Fwd:/Fw: prefixes from an email subject for thread matching.
455
+ *
456
+ * @param {string} subject
457
+ * @returns {string}
458
+ */
459
+ export function stripSubjectPrefixes(subject) {
460
+ if (!subject || typeof subject !== 'string') {
461
+ return '';
462
+ }
463
+ let s = subject.trim();
464
+ let prev;
465
+ do {
466
+ prev = s;
467
+ s = s.replace(/^(re|fwd|fw)\s*:\s*/i, '').trim();
468
+ } while (s !== prev);
469
+ return s;
470
+ }
471
+
410
472
  /**
411
473
  * Convert Mac Absolute Time to Unix timestamp (milliseconds)
412
474
  * Handles both seconds and nanoseconds formats
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-tools-mcp",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "MCP server for semantic search across Apple Mail, Messages, and Calendar",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/search.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import * as lancedb from "@lancedb/lancedb";
2
2
  import * as chrono from "chrono-node";
3
3
  import { safeOsascript } from "./lib/shell.js";
4
- import { safeMatch, validateSearchQuery } from "./lib/validators.js";
5
- import { embed, INDEX_DIR, getRecentEmails, getEmailsByDateRange, getRecentMessages, getConversation, getCalendarByDate, getAllCalendarEvents, resolveEmail, resolvePhone, formatContact } from "./indexer.js";
4
+ import { safeMatch, validateSearchQuery, toUnixMillis } from "./lib/validators.js";
5
+ import { embed, INDEX_DIR, getRecentEmails, getEmailsByDateRange, getRecentMessages, getConversation, getEventsOnDate, resolveEmail, resolvePhone, formatContact } from "./indexer.js";
6
6
 
7
7
  let db = null;
8
8
  let tables = {};
@@ -102,13 +102,16 @@ function resolvePronouns(query) {
102
102
  return query;
103
103
  }
104
104
 
105
- const pronounPattern = /\b(they|them|their|he|him|his|she|her|hers)\b/gi;
106
-
107
- if (pronounPattern.test(query) && queryContext.lastPerson) {
108
- return query.replace(pronounPattern, queryContext.lastPerson);
105
+ if (!queryContext.lastPerson) {
106
+ return query;
109
107
  }
110
108
 
111
- return query;
109
+ // Do not use RegExp#test with /g — lastIndex is stateful and can skip the
110
+ // first (or only) pronoun. replace() with a fresh regex is sufficient.
111
+ return query.replace(
112
+ /\b(they|them|their|he|him|his|she|her|hers)\b/gi,
113
+ queryContext.lastPerson
114
+ );
112
115
  }
113
116
 
114
117
  // Extract entities (people, dates) from natural language query and convert to filters
@@ -525,8 +528,10 @@ export function getDateRange(dateStr) {
525
528
  const start = parseNaturalDate(dateStr);
526
529
  if (!start) return null;
527
530
 
528
- const end = start + (24 * 60 * 60 * 1000); // End of day
529
- return { start, end };
531
+ // Local next midnight — not +24h, which is wrong on DST transition days
532
+ const endDate = new Date(start);
533
+ endDate.setDate(endDate.getDate() + 1);
534
+ return { start, end: endDate.getTime() };
530
535
  }
531
536
 
532
537
  // Parse various date formats and return timestamp (for filtering results)
@@ -559,7 +564,7 @@ function filterByDateRange(results, daysBack, daysAhead, dateField = "date") {
559
564
  if (daysBack === 0 && daysAhead === 0) return results;
560
565
 
561
566
  return results.filter(r => {
562
- const ts = r.dateTimestamp || parseDate(r[dateField]);
567
+ const ts = toUnixMillis(r.dateTimestamp) || parseDate(r[dateField]);
563
568
  if (!ts) return daysBack === 0 && daysAhead === 0;
564
569
  return ts >= cutoffPast && ts <= cutoffFuture;
565
570
  });
@@ -568,8 +573,8 @@ function filterByDateRange(results, daysBack, daysAhead, dateField = "date") {
568
573
  // Sort results by date (newest first)
569
574
  function sortByDate(results, descending = true) {
570
575
  return results.sort((a, b) => {
571
- const tsA = a.dateTimestamp || parseDate(a.date) || parseDate(a.start) || 0;
572
- const tsB = b.dateTimestamp || parseDate(b.date) || parseDate(b.start) || 0;
576
+ const tsA = toUnixMillis(a.dateTimestamp) || parseDate(a.date) || parseDate(a.start) || 0;
577
+ const tsB = toUnixMillis(b.dateTimestamp) || parseDate(b.date) || parseDate(b.start) || 0;
573
578
  return descending ? tsB - tsA : tsA - tsB;
574
579
  });
575
580
  }
@@ -582,7 +587,7 @@ export async function searchEmails(query, options = {}) {
582
587
  try {
583
588
  validatedQuery = validateSearchQuery(query);
584
589
  } catch (e) {
585
- return { results: [], error: e.message };
590
+ return { success: false, results: [], error: e.message };
586
591
  }
587
592
 
588
593
  // Check result cache first
@@ -714,6 +719,7 @@ export async function searchEmails(query, options = {}) {
714
719
  to: row.to || "Unknown",
715
720
  subject: row.subject || "No subject",
716
721
  date: formatLocalDate(row.date) || "Unknown",
722
+ dateTimestamp: toUnixMillis(row.dateTimestamp) || null,
717
723
  mailbox: row.mailbox || "Unknown",
718
724
  hasAttachment: row.hasAttachment || false,
719
725
  isFlagged: row.isFlagged || false,
@@ -950,7 +956,7 @@ export async function getEmailDateResults(dateStr, includeJunk = false) {
950
956
  }
951
957
 
952
958
  export function formatEmailResults(searchResult) {
953
- if (!searchResult.success) return searchResult.error;
959
+ if (!searchResult.success) return searchResult.error || "Email search failed";
954
960
  if (searchResult.results.length === 0) return searchResult.message;
955
961
 
956
962
  const results = searchResult.results.map(r => {
@@ -965,6 +971,9 @@ export function formatEmailResults(searchResult) {
965
971
  return result + "\n---";
966
972
  }).join("\n");
967
973
 
974
+ if (searchResult.hasMore) {
975
+ return results + `\n\nShowing ${searchResult.showing} results. More matches exist; increase limit to see them.`;
976
+ }
968
977
  return results;
969
978
  }
970
979
 
@@ -976,7 +985,7 @@ export async function searchMessages(query, options = {}) {
976
985
  try {
977
986
  validatedQuery = validateSearchQuery(query);
978
987
  } catch (e) {
979
- return { results: [], error: e.message };
988
+ return { success: false, results: [], error: e.message };
980
989
  }
981
990
 
982
991
  // Check result cache first
@@ -1084,6 +1093,7 @@ export async function searchMessages(query, options = {}) {
1084
1093
  rank: idx + 1,
1085
1094
  score: row._hybridScore ? row._hybridScore.toFixed(3) : (row._distance ? (1 - row._distance).toFixed(3) : "N/A"),
1086
1095
  date: formatLocalDate(row.date) || "Unknown",
1096
+ dateTimestamp: toUnixMillis(row.dateTimestamp) || null,
1087
1097
  sender: sender,
1088
1098
  senderContact: senderContact, // Resolved contact name (if found)
1089
1099
  text: row.text || "",
@@ -1126,6 +1136,7 @@ export async function getRecentMessageResults(limit = 10, daysBack = 1) {
1126
1136
  return {
1127
1137
  rank: idx + 1,
1128
1138
  date: formatLocalDate(row.date) || "Unknown",
1139
+ dateTimestamp: toUnixMillis(row.dateTimestamp) || null,
1129
1140
  sender: sender,
1130
1141
  senderContact: senderContact,
1131
1142
  text: row.text || "",
@@ -1166,7 +1177,7 @@ export async function getConversationResults(contact, limit = 50) {
1166
1177
  }
1167
1178
 
1168
1179
  export function formatMessageResults(searchResult) {
1169
- if (!searchResult.success) return searchResult.error;
1180
+ if (!searchResult.success) return searchResult.error || "Message search failed";
1170
1181
  if (searchResult.results.length === 0) return searchResult.message;
1171
1182
 
1172
1183
  const results = searchResult.results.map(r => {
@@ -1180,6 +1191,9 @@ export function formatMessageResults(searchResult) {
1180
1191
  return result + "\n---";
1181
1192
  }).join("\n");
1182
1193
 
1194
+ if (searchResult.hasMore) {
1195
+ return results + `\n\nShowing ${searchResult.showing} results. More matches exist; increase limit to see them.`;
1196
+ }
1183
1197
  return results;
1184
1198
  }
1185
1199
 
@@ -1202,7 +1216,7 @@ export async function searchCalendar(query, options = {}) {
1202
1216
  try {
1203
1217
  validatedQuery = validateSearchQuery(query);
1204
1218
  } catch (e) {
1205
- return { results: [], error: e.message };
1219
+ return { success: false, results: [], error: e.message };
1206
1220
  }
1207
1221
 
1208
1222
  // Check result cache first
@@ -1319,28 +1333,40 @@ export async function searchCalendar(query, options = {}) {
1319
1333
  }
1320
1334
  }
1321
1335
 
1322
- // Get events on a specific date
1336
+ // Local midnight of parsed date through next local midnight (handles DST; not +24h)
1337
+ export function getLocalDayBounds(dateStr) {
1338
+ const start = parseNaturalDate(dateStr);
1339
+ if (start == null) return null;
1340
+ const endDate = new Date(start);
1341
+ endDate.setDate(endDate.getDate() + 1);
1342
+ return { start, end: endDate.getTime() };
1343
+ }
1344
+
1345
+ // Get events on a specific date from live Calendar.sqlitedb (not the vector index)
1323
1346
  export async function getCalendarDateResults(dateStr) {
1324
1347
  try {
1325
- const range = getDateRange(dateStr);
1348
+ const range = getLocalDayBounds(dateStr);
1326
1349
  if (!range) {
1327
1350
  return { success: false, error: `Could not parse date: ${dateStr}` };
1328
1351
  }
1329
1352
 
1330
1353
  console.error(`[Calendar Date] Query for "${dateStr}"`);
1331
- console.error(`[Calendar Date] Range: ${new Date(range.start).toISOString()} to ${new Date(range.end).toISOString()}`);
1354
+ console.error(`[Calendar Date] Range: ${new Date(range.start).toString()} to ${new Date(range.end).toString()}`);
1332
1355
 
1333
- const results = await getCalendarByDate(range.start, range.end);
1356
+ const { events, error } = getEventsOnDate(range.start, range.end);
1357
+ if (error) {
1358
+ return { success: false, error: `Error getting calendar events: ${error}` };
1359
+ }
1334
1360
 
1335
- const formattedResults = results.map((row, idx) => ({
1361
+ const formattedResults = events.map((row, idx) => ({
1336
1362
  index: idx + 1,
1337
1363
  title: row.title || "No title",
1338
- start: formatLocalDate(row.start) || "Unknown",
1339
- startTimestamp: row.startTimestamp || null,
1340
- end: formatLocalDate(row.end) || "Unknown",
1364
+ start: formatLocalDate(row.start) || row.start || "Unknown",
1365
+ startTimestamp: row.startMac != null ? (Number(row.startMac) + 978307200) * 1000 : null,
1366
+ end: formatLocalDate(row.end) || row.end || "Unknown",
1341
1367
  calendar: row.calendar || "Unknown",
1342
1368
  location: row.location || "",
1343
- isAllDay: row.isAllDay || false
1369
+ isAllDay: !!row.isAllDay
1344
1370
  }));
1345
1371
 
1346
1372
  const dateLabel = new Date(range.start).toLocaleDateString("en-US", {
@@ -1360,6 +1386,24 @@ export async function getCalendarDateResults(dateStr) {
1360
1386
  }
1361
1387
  }
1362
1388
 
1389
+ // Clamp a timed event to [dayStartMs, dayEndMs) and return minutes from local midnight.
1390
+ // Overnight events (23:00–01:00) contribute only the slice that falls on this day.
1391
+ export function clampBusyToLocalDay(evtStartMs, evtEndMs, dayStartMs, dayEndMs) {
1392
+ const clampedStart = Math.max(evtStartMs, dayStartMs);
1393
+ const clampedEnd = Math.min(evtEndMs, dayEndMs);
1394
+ if (!(clampedEnd > clampedStart)) return null;
1395
+
1396
+ const startMinutes = clampedStart <= dayStartMs
1397
+ ? 0
1398
+ : new Date(clampedStart).getHours() * 60 + new Date(clampedStart).getMinutes();
1399
+ const endMinutes = clampedEnd >= dayEndMs
1400
+ ? 24 * 60
1401
+ : new Date(clampedEnd).getHours() * 60 + new Date(clampedEnd).getMinutes();
1402
+
1403
+ if (!(endMinutes > startMinutes)) return null;
1404
+ return { startMinutes, endMinutes };
1405
+ }
1406
+
1363
1407
  // Calculate free time slots on a specific date
1364
1408
  export async function calculateFreeTime(dateStr, options = {}) {
1365
1409
  const {
@@ -1369,12 +1413,17 @@ export async function calculateFreeTime(dateStr, options = {}) {
1369
1413
  } = options;
1370
1414
 
1371
1415
  try {
1372
- const range = getDateRange(dateStr);
1416
+ const range = getLocalDayBounds(dateStr);
1373
1417
  if (!range) {
1374
1418
  return { success: false, error: `Could not parse date: ${dateStr}` };
1375
1419
  }
1376
1420
 
1377
- let events = await getCalendarByDate(range.start, range.end);
1421
+ const { events: liveEvents, error } = getEventsOnDate(range.start, range.end);
1422
+ if (error) {
1423
+ return { success: false, error: `Error calculating free time: ${error}` };
1424
+ }
1425
+
1426
+ let events = liveEvents;
1378
1427
 
1379
1428
  // Filter by calendar if specified
1380
1429
  if (calendarName) {
@@ -1390,16 +1439,21 @@ export async function calculateFreeTime(dateStr, options = {}) {
1390
1439
  continue;
1391
1440
  }
1392
1441
 
1393
- const evtStart = new Date(evt.startTimestamp);
1394
- const evtEnd = evt.end ? parseDate(evt.end) : evt.startTimestamp + (60 * 60 * 1000); // Default 1 hour
1442
+ const evtStart = evt.startMac != null
1443
+ ? (Number(evt.startMac) + 978307200) * 1000
1444
+ : (evt.startTimestamp || parseDate(evt.start));
1445
+ const evtEnd = evt.endMac != null
1446
+ ? (Number(evt.endMac) + 978307200) * 1000
1447
+ : (evt.end ? parseDate(evt.end) : evtStart + (60 * 60 * 1000));
1395
1448
 
1396
- const startMinutes = evtStart.getHours() * 60 + evtStart.getMinutes();
1397
- const endMinutes = new Date(evtEnd).getHours() * 60 + new Date(evtEnd).getMinutes();
1449
+ const clamped = clampBusyToLocalDay(evtStart, evtEnd, range.start, range.end);
1450
+ if (!clamped) continue;
1398
1451
 
1399
- busyPeriods.push({
1400
- start: Math.max(startMinutes, startHour * 60),
1401
- end: Math.min(endMinutes, endHour * 60)
1402
- });
1452
+ const start = Math.max(clamped.startMinutes, startHour * 60);
1453
+ const end = Math.min(clamped.endMinutes, endHour * 60);
1454
+ if (end > start) {
1455
+ busyPeriods.push({ start, end });
1456
+ }
1403
1457
  }
1404
1458
 
1405
1459
  // Sort busy periods
@@ -1458,7 +1512,7 @@ function formatMinutes(minutes) {
1458
1512
  }
1459
1513
 
1460
1514
  export function formatCalendarResults(searchResult) {
1461
- if (!searchResult.success) return searchResult.error;
1515
+ if (!searchResult.success) return searchResult.error || "Calendar search failed";
1462
1516
  if (searchResult.results.length === 0) return searchResult.message;
1463
1517
 
1464
1518
  let header = "";
@@ -1481,6 +1535,9 @@ export function formatCalendarResults(searchResult) {
1481
1535
  return result + "\n---";
1482
1536
  }).join("\n");
1483
1537
 
1538
+ if (searchResult.hasMore) {
1539
+ return header + results + `\n\nShowing ${searchResult.showing} results. More matches exist; increase limit to see them.`;
1540
+ }
1484
1541
  return header + results;
1485
1542
  }
1486
1543
 
@@ -1527,6 +1584,9 @@ export function formatSendersResults(senders) {
1527
1584
 
1528
1585
  // Format messages_contacts results
1529
1586
  export function formatMessageContactsResults(contacts) {
1587
+ if (contacts?.error) {
1588
+ return `Error getting message contacts: ${contacts.error}`;
1589
+ }
1530
1590
  if (!contacts || contacts.length === 0) {
1531
1591
  return "No message contacts found.";
1532
1592
  }
@@ -1541,6 +1601,9 @@ export function formatMessageContactsResults(contacts) {
1541
1601
 
1542
1602
  // Format calendar_upcoming results
1543
1603
  export function formatUpcomingEventsResults(result) {
1604
+ if (result?.error) {
1605
+ return `Error getting upcoming events: ${result.error}`;
1606
+ }
1544
1607
  const events = result.events || result; // Handle both new {events, showing, hasMore} and old array format
1545
1608
  if (!events || events.length === 0) {
1546
1609
  return "No upcoming events found.";
@@ -1633,6 +1696,9 @@ export function formatEmailThreadResults(result) {
1633
1696
 
1634
1697
  // Format calendar_recurring results
1635
1698
  export function formatRecurringEventsResults(result) {
1699
+ if (result?.error) {
1700
+ return `Error getting recurring events: ${result.error}`;
1701
+ }
1636
1702
  const events = result.events || result; // Handle both new {events, showing, hasMore} and old array format
1637
1703
  if (!events || events.length === 0) {
1638
1704
  return "No recurring events found.";
@@ -1649,4 +1715,4 @@ export function formatRecurringEventsResults(result) {
1649
1715
  }
1650
1716
 
1651
1717
  // Export internal functions for testing
1652
- export { expandQuery, parseNegation, extractKeywords };
1718
+ export { expandQuery, parseNegation, extractKeywords, resolvePronouns, updateContext };