apple-tools-mcp 1.1.4 → 1.2.1

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/index.js CHANGED
@@ -9,143 +9,111 @@ import {
9
9
  import fs from "fs";
10
10
  import path from "path";
11
11
  import { validateEmailPath, stripHtmlTags, unfoldRfc822Headers, validateLimit, validateDaysBack, validateWeekOffset, toUnixMillis } from "./lib/validators.js";
12
- import { isSearchBlockedByIndexing, cycleEndFlags, indexUnavailableMessage } from "./lib/indexGate.js";
12
+ import { cycleEndFlags, indexUnavailableMessage, indexQueryGate } from "./lib/indexGate.js";
13
+ import { isIndexerMode } from "./lib/processMode.js";
14
+ import { loadResolvedIndexInterval, logResolvedInterval } from "./lib/config.js";
15
+ import { createIndexerLock, DEFAULT_LOCK_HEARTBEAT_MS } from "./lib/indexerLock.js";
16
+ import {
17
+ shouldConnectMcpStdio,
18
+ bindStdinCloseExit,
19
+ beginIndexCycle,
20
+ applyIndexerCycleEnd,
21
+ mcpIndexingStartup,
22
+ waitForIndexerLock,
23
+ beginOwnedIndexing
24
+ } from "./lib/indexerRuntime.js";
13
25
 
14
26
  const PACKAGE_VERSION = JSON.parse(
15
27
  fs.readFileSync(new URL("./package.json", import.meta.url), "utf8")
16
28
  ).version;
17
29
 
30
+ // Canonical indexer entrypoint: `node index.js --mode=indexer` or `apple-tools-indexer`.
31
+ const INDEXER_MODE = isIndexerMode();
32
+ const resolvedIndexInterval = loadResolvedIndexInterval();
33
+ const INDEX_INTERVAL = resolvedIndexInterval.ms;
34
+ const LOCK_HEARTBEAT_MS = DEFAULT_LOCK_HEARTBEAT_MS;
35
+ const LOCK_RETRY_MS = 5 * 1000;
36
+
18
37
  // Lock file to prevent duplicate indexing processes
19
38
  const LOCK_FILE = path.join(process.env.HOME, ".apple-tools-mcp", "indexer.lock");
20
- const LOCK_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes - if lock is older, assume hung process
39
+ const indexerLock = createIndexerLock({
40
+ lockFile: LOCK_FILE,
41
+ log: (msg) => console.error(msg)
42
+ });
21
43
  // True only while this process won the indexer lock. Distinct from
22
44
  // sessionIndexComplete: a secondary instance that lost the lock never
23
45
  // completes a local cycle and must not stay on "still indexing" forever.
24
46
  let ownsIndexLock = false;
25
47
 
26
48
  function acquireLock() {
27
- try {
28
- // Ensure directory exists first
29
- const lockDir = path.dirname(LOCK_FILE);
30
- if (!fs.existsSync(lockDir)) {
31
- fs.mkdirSync(lockDir, { recursive: true });
32
- }
49
+ const ok = indexerLock.acquire();
50
+ ownsIndexLock = indexerLock.ownsLock;
51
+ if (ok) {
52
+ startLockHeartbeat();
53
+ }
54
+ return ok;
55
+ }
33
56
 
34
- // Check for existing lock file
35
- if (fs.existsSync(LOCK_FILE)) {
36
- const lockData = fs.readFileSync(LOCK_FILE, "utf8");
37
- const [pidStr, timestampStr] = lockData.split(':');
38
- const pid = parseInt(pidStr);
39
- const timestamp = parseInt(timestampStr) || Date.now();
40
- const lockAge = Date.now() - timestamp;
41
-
42
- // If we already hold the lock, return true
43
- if (pid === process.pid) {
44
- ownsIndexLock = true;
45
- return true;
46
- }
57
+ function releaseLock() {
58
+ indexerLock.release();
59
+ ownsIndexLock = indexerLock.ownsLock;
60
+ if (!ownsIndexLock) {
61
+ stopLockHeartbeat();
62
+ }
63
+ }
47
64
 
48
- try {
49
- process.kill(pid, 0); // Check if process exists (signal 0 = no-op)
50
-
51
- // Process exists - check if lock is stale (hung process)
52
- if (lockAge > LOCK_TIMEOUT_MS) {
53
- console.error(`Lock file is ${Math.round(lockAge / 60000)} minutes old. Assuming hung process (PID ${pid}). Removing stale lock.`);
54
- fs.unlinkSync(LOCK_FILE);
55
- } else {
56
- console.error(`Another indexing instance running (PID ${pid}). Skipping indexing.`);
57
- ownsIndexLock = false;
58
- return false;
59
- }
60
- } catch {
61
- // Process doesn't exist, stale lock file - remove it
62
- console.error(`Removing stale lock file (PID ${pid} not running)`);
63
- fs.unlinkSync(LOCK_FILE);
64
- }
65
- }
65
+ function startLockHeartbeat() {
66
+ indexerLock.startHeartbeat(LOCK_HEARTBEAT_MS);
67
+ }
66
68
 
67
- // Use atomic 'wx' flag to create lock file exclusively
68
- // This prevents TOCTOU race condition - will throw EEXIST if file was created between check and write
69
- try {
70
- fs.writeFileSync(LOCK_FILE, `${process.pid}:${Date.now()}`, { flag: 'wx' });
71
- ownsIndexLock = true;
72
- return true;
73
- } catch (err) {
74
- if (err.code === 'EEXIST') {
75
- // Another process won the race
76
- console.error("Another process acquired lock during race. Skipping indexing.");
77
- ownsIndexLock = false;
78
- return false;
79
- }
80
- throw err; // Re-throw unexpected errors
81
- }
82
- } catch (e) {
83
- console.error("Lock file error:", e.message);
84
- ownsIndexLock = false;
85
- return false; // On error, fail safe - don't proceed
86
- }
69
+ function stopLockHeartbeat() {
70
+ indexerLock.stopHeartbeat();
87
71
  }
88
72
 
89
- function releaseLock() {
90
- try {
91
- if (fs.existsSync(LOCK_FILE)) {
92
- const lockData = fs.readFileSync(LOCK_FILE, "utf8");
93
- const [pidStr] = lockData.split(':');
94
- const pid = parseInt(pidStr);
95
- if (pid === process.pid) {
96
- fs.unlinkSync(LOCK_FILE);
97
- ownsIndexLock = false;
98
- console.error(`Released lock file (PID ${process.pid})`);
99
- }
100
- }
101
- } catch (err) {
102
- // Log error but don't throw - we're likely shutting down
103
- console.error(`Error releasing lock: ${err.message}`);
73
+ function shutdownIndexing(exitCode) {
74
+ stopBackgroundIndexing();
75
+ stopLockHeartbeat();
76
+ releaseLock();
77
+ if (exitCode !== undefined) {
78
+ process.exit(exitCode);
104
79
  }
105
80
  }
106
81
 
107
82
  // Clean up lock and timer on exit
108
83
  process.on("exit", () => {
109
84
  stopBackgroundIndexing();
85
+ stopLockHeartbeat();
110
86
  releaseLock();
111
87
  });
112
88
  process.on("SIGINT", () => {
113
- stopBackgroundIndexing();
114
- releaseLock();
89
+ shutdownIndexing();
115
90
  process.exit();
116
91
  });
117
92
  process.on("SIGTERM", () => {
118
- stopBackgroundIndexing();
119
- releaseLock();
93
+ shutdownIndexing();
120
94
  process.exit();
121
95
  });
122
96
  process.on("SIGHUP", () => {
123
- stopBackgroundIndexing();
124
- releaseLock();
97
+ shutdownIndexing();
125
98
  process.exit();
126
99
  });
127
100
 
128
101
  // Handle uncaught errors - cleanup before crashing
129
102
  process.on("uncaughtException", (err) => {
130
103
  console.error("Uncaught exception:", err);
131
- stopBackgroundIndexing();
132
- releaseLock();
133
- process.exit(1);
104
+ shutdownIndexing(1);
134
105
  });
135
106
 
136
107
  process.on("unhandledRejection", (reason, promise) => {
137
108
  console.error("Unhandled rejection at:", promise, "reason:", reason);
138
- stopBackgroundIndexing();
139
- releaseLock();
140
- process.exit(1);
109
+ shutdownIndexing(1);
141
110
  });
142
111
 
143
- // Exit when stdin closes (MCP client disconnected)
144
- process.stdin.on("close", () => {
112
+ // MCP stdio clients exit when the host closes stdin. The indexer daemon must
113
+ // not — LaunchAgent / KeepAlive often attaches stdin to /dev/null.
114
+ bindStdinCloseExit(process.stdin, INDEXER_MODE, () => {
145
115
  console.error("Client disconnected. Exiting.");
146
- stopBackgroundIndexing();
147
- releaseLock();
148
- process.exit(0);
116
+ shutdownIndexing(0);
149
117
  });
150
118
 
151
119
  // Vector search imports
@@ -199,10 +167,9 @@ let sessionIndexComplete = false; // Track if this session's indexing is done
199
167
  let isFirstEverRun = true; // True if no index exists yet
200
168
  let lastIndexTime = 0;
201
169
  let lastProgressTime = 0; // Track when we last made progress (for hung detection)
202
- // Allow environment variable to override default 5-minute interval
203
- const INDEX_INTERVAL = parseInt(process.env.INDEX_INTERVAL_MS || (5 * 60 * 1000));
204
170
  let indexTimer = null;
205
171
  let progressCheckTimer = null;
172
+ let loggedIndexInterval = false;
206
173
 
207
174
  // Check if this is the first ever run (no index exists)
208
175
  async function checkIfFirstRun() {
@@ -213,23 +180,17 @@ async function checkIfFirstRun() {
213
180
  return !(emailsReady || messagesReady || calendarReady);
214
181
  }
215
182
 
216
- // Get appropriate status message based on indexing state
217
- function getIndexingMessage() {
218
- if (isFirstEverRun) {
219
- return "Building initial index. This may take several minutes on first run. Please try again shortly.";
220
- }
221
- return "Indexing new data. Please try again in a moment.";
222
- }
223
-
224
183
  // Run a single indexing cycle (called by background timer)
225
184
  function runIndexCycle() {
226
- if (indexingInProgress) {
227
- console.error("Indexing already in progress, skipping cycle");
185
+ const cycle = beginIndexCycle(indexingInProgress);
186
+ if (!cycle.started) {
228
187
  return;
229
188
  }
189
+ indexingInProgress = cycle.indexingInProgress;
230
190
 
231
191
  // Safety net: check lock before indexing
232
192
  if (!acquireLock()) {
193
+ indexingInProgress = false;
233
194
  console.error("Another instance is indexing. Skipping.");
234
195
  return;
235
196
  }
@@ -299,6 +260,11 @@ function triggerIndexIfNeeded() {
299
260
 
300
261
  // Start continuous background indexing
301
262
  function startBackgroundIndexing() {
263
+ if (!loggedIndexInterval) {
264
+ logResolvedInterval(resolvedIndexInterval);
265
+ loggedIndexInterval = true;
266
+ }
267
+
302
268
  // Run indexing immediately on startup
303
269
  runIndexCycle();
304
270
 
@@ -307,7 +273,7 @@ function startBackgroundIndexing() {
307
273
  runIndexCycle();
308
274
  }, INDEX_INTERVAL);
309
275
 
310
- console.error(`Background indexing started (interval: ${INDEX_INTERVAL / 1000}s)`);
276
+ console.error(`Background indexing started (interval: ${resolvedIndexInterval.human} / ${INDEX_INTERVAL} ms)`);
311
277
  }
312
278
 
313
279
  // Stop background indexing and clean up timers
@@ -323,44 +289,97 @@ function stopBackgroundIndexing() {
323
289
  console.error("Background indexing stopped");
324
290
  }
325
291
 
326
- // Unblock searches and drop the indexer lock after a cycle ends.
327
- // Must run on failure as well as success so tools are not stuck forever.
292
+ // Unblock searches after a cycle ends. Must run on failure as well as success
293
+ // so tools are not stuck forever. The indexer daemon keeps indexer.lock for
294
+ // the process lifetime; MCP local-fallback still releases between cycles.
328
295
  function applyCycleEnd(success) {
329
- const flags = cycleEndFlags(success);
330
- indexingInProgress = flags.indexingInProgress;
331
- sessionIndexComplete = flags.sessionIndexComplete;
332
- ownsIndexLock = flags.ownsIndexLock;
333
- if (flags.isFirstEverRun === false) {
296
+ const result = applyIndexerCycleEnd({
297
+ success,
298
+ indexerMode: INDEXER_MODE,
299
+ cycleEndFlags,
300
+ releaseLock
301
+ });
302
+ indexingInProgress = result.indexingInProgress;
303
+ sessionIndexComplete = result.sessionIndexComplete;
304
+ ownsIndexLock = result.ownsIndexLock;
305
+ if (result.isFirstEverRun === false) {
334
306
  isFirstEverRun = false;
335
307
  }
336
- releaseLock();
337
308
  }
338
309
 
339
310
  // Index-backed tools wait only while THIS process owns the lock and has not
340
311
  // finished its cycle. Lost-lock secondaries fall through to isIndexReady().
341
312
  function stillIndexingMessage() {
342
- if (isSearchBlockedByIndexing(sessionIndexComplete, ownsIndexLock)) {
343
- return getIndexingMessage();
313
+ const gate = indexQueryGate({
314
+ sessionIndexComplete,
315
+ ownsIndexLock,
316
+ indexReady: true,
317
+ isFirstEverRun
318
+ });
319
+ return gate.ok ? null : gate.message;
320
+ }
321
+
322
+ /** Shared preflight for index-backed query tools (on-disk tables, not local cycle). */
323
+ async function requireIndex(type) {
324
+ const indexing = stillIndexingMessage();
325
+ if (indexing) {
326
+ return indexing;
344
327
  }
345
- return null;
328
+ const ready = await isIndexReady(type);
329
+ const gate = indexQueryGate({
330
+ sessionIndexComplete,
331
+ ownsIndexLock,
332
+ indexReady: ready,
333
+ type,
334
+ isFirstEverRun
335
+ });
336
+ return gate.ok ? null : gate.message;
337
+ }
338
+
339
+ function waitForLockAndStartDaemon() {
340
+ waitForIndexerLock(acquireLock, {
341
+ retryMs: LOCK_RETRY_MS,
342
+ onAcquired: () => {
343
+ beginOwnedIndexing({
344
+ startHeartbeat: startLockHeartbeat,
345
+ startBackground: startBackgroundIndexing
346
+ });
347
+ }
348
+ });
346
349
  }
347
350
 
348
351
  // Initialize and start indexing
349
352
  async function initializeIndexing() {
350
353
  isFirstEverRun = await checkIfFirstRun();
351
354
 
352
- // Try to acquire lock - if another instance is indexing, skip background
353
- // indexing but keep the MCP server running so search still works.
354
- if (!acquireLock()) {
355
+ if (INDEXER_MODE) {
356
+ console.error(`Apple Tools MCP indexer running (v${PACKAGE_VERSION})`);
357
+ logResolvedInterval(resolvedIndexInterval);
358
+ loggedIndexInterval = true;
359
+ waitForLockAndStartDaemon();
360
+ return;
361
+ }
362
+
363
+ // MCP stdio: if the indexer daemon (or another instance) holds the lock,
364
+ // skip background refresh and use the shared index. If nothing holds the
365
+ // lock, index locally as before so the stdio happy path still works.
366
+ const startup = mcpIndexingStartup(() => acquireLock());
367
+ if (!startup.startBackground) {
355
368
  console.error("Another apple-tools-mcp instance is indexing. Server will run without background indexing.");
356
369
  // Lost lock is not "still indexing": this process will never complete a
357
- // local cycle. Searches proceed whenever isIndexReady() is true.
370
+ // local cycle. Searches proceed whenever isIndexReady() is true (initDB
371
+ // re-lists shared on-disk tables; it must not cache an empty first connect).
358
372
  ownsIndexLock = false;
359
373
  return;
360
374
  }
361
375
 
362
- // Start background indexing
363
- startBackgroundIndexing();
376
+ // Start background indexing (local fallback when no daemon is running)
377
+ // using the same heartbeat path as the daemon so a long first-index cycle
378
+ // cannot look like a stale lock to another waiter.
379
+ beginOwnedIndexing({
380
+ startHeartbeat: startLockHeartbeat,
381
+ startBackground: startBackgroundIndexing
382
+ });
364
383
  }
365
384
 
366
385
  // Start indexing immediately on server startup
@@ -373,14 +392,9 @@ async function mailSearch(query, options = {}) {
373
392
  return "Error: query parameter is required for mail_search";
374
393
  }
375
394
 
376
- const indexing = stillIndexingMessage();
377
- if (indexing) {
378
- return indexing;
379
- }
380
-
381
- const ready = await isIndexReady("emails");
382
- if (!ready) {
383
- return indexUnavailableMessage("emails");
395
+ const blocked = await requireIndex("emails");
396
+ if (blocked) {
397
+ return blocked;
384
398
  }
385
399
 
386
400
  const result = await searchEmails(query, options);
@@ -388,14 +402,9 @@ async function mailSearch(query, options = {}) {
388
402
  }
389
403
 
390
404
  async function mailRecent(limit = 30, daysBack = 7, unreadOnly = false, includeJunk = false) {
391
- const indexing = stillIndexingMessage();
392
- if (indexing) {
393
- return indexing;
394
- }
395
-
396
- const ready = await isIndexReady("emails");
397
- if (!ready) {
398
- return indexUnavailableMessage("emails");
405
+ const blocked = await requireIndex("emails");
406
+ if (blocked) {
407
+ return blocked;
399
408
  }
400
409
 
401
410
  const result = await getRecentEmailResults(limit, daysBack, unreadOnly, includeJunk);
@@ -403,14 +412,9 @@ async function mailRecent(limit = 30, daysBack = 7, unreadOnly = false, includeJ
403
412
  }
404
413
 
405
414
  async function mailDate(date, includeJunk = false) {
406
- const indexing = stillIndexingMessage();
407
- if (indexing) {
408
- return indexing;
409
- }
410
-
411
- const ready = await isIndexReady("emails");
412
- if (!ready) {
413
- return indexUnavailableMessage("emails");
415
+ const blocked = await requireIndex("emails");
416
+ if (blocked) {
417
+ return blocked;
414
418
  }
415
419
 
416
420
  const result = await getEmailDateResults(date, includeJunk);
@@ -418,14 +422,9 @@ async function mailDate(date, includeJunk = false) {
418
422
  }
419
423
 
420
424
  async function messagesSearch(query, options = {}) {
421
- const indexing = stillIndexingMessage();
422
- if (indexing) {
423
- return indexing;
424
- }
425
-
426
- const ready = await isIndexReady("messages");
427
- if (!ready) {
428
- return indexUnavailableMessage("messages");
425
+ const blocked = await requireIndex("messages");
426
+ if (blocked) {
427
+ return blocked;
429
428
  }
430
429
 
431
430
  const result = await searchMessages(query, options);
@@ -433,14 +432,9 @@ async function messagesSearch(query, options = {}) {
433
432
  }
434
433
 
435
434
  async function messagesRecent(limit = 10, daysBack = 1) {
436
- const indexing = stillIndexingMessage();
437
- if (indexing) {
438
- return indexing;
439
- }
440
-
441
- const ready = await isIndexReady("messages");
442
- if (!ready) {
443
- return indexUnavailableMessage("messages");
435
+ const blocked = await requireIndex("messages");
436
+ if (blocked) {
437
+ return blocked;
444
438
  }
445
439
 
446
440
  const result = await getRecentMessageResults(limit, daysBack);
@@ -448,14 +442,9 @@ async function messagesRecent(limit = 10, daysBack = 1) {
448
442
  }
449
443
 
450
444
  async function messagesConversation(contact, limit = 50) {
451
- const indexing = stillIndexingMessage();
452
- if (indexing) {
453
- return indexing;
454
- }
455
-
456
- const ready = await isIndexReady("messages");
457
- if (!ready) {
458
- return indexUnavailableMessage("messages");
445
+ const blocked = await requireIndex("messages");
446
+ if (blocked) {
447
+ return blocked;
459
448
  }
460
449
 
461
450
  const result = await getConversationResults(contact, limit);
@@ -463,14 +452,9 @@ async function messagesConversation(contact, limit = 50) {
463
452
  }
464
453
 
465
454
  async function calendarSearch(query, options = {}) {
466
- const indexing = stillIndexingMessage();
467
- if (indexing) {
468
- return indexing;
469
- }
470
-
471
- const ready = await isIndexReady("calendar");
472
- if (!ready) {
473
- return indexUnavailableMessage("calendar");
455
+ const blocked = await requireIndex("calendar");
456
+ if (blocked) {
457
+ return blocked;
474
458
  }
475
459
 
476
460
  const result = await searchCalendar(query, options);
@@ -1391,16 +1375,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1391
1375
  // Mail tools
1392
1376
  case "mail_senders":
1393
1377
  {
1394
- const indexing = stillIndexingMessage();
1395
- if (indexing) {
1396
- result = indexing;
1378
+ const blocked = await requireIndex("emails");
1379
+ if (blocked) {
1380
+ result = blocked;
1397
1381
  break;
1398
1382
  }
1399
1383
  }
1400
- if (!(await isIndexReady("emails"))) {
1401
- result = indexUnavailableMessage("emails");
1402
- break;
1403
- }
1404
1384
  result = formatSendersResults(await getFrequentSenders(
1405
1385
  validateLimit(args?.limit, 30),
1406
1386
  validateDaysBack(args?.days_back),
@@ -1475,16 +1455,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1475
1455
 
1476
1456
  case "mail_thread":
1477
1457
  {
1478
- const indexing = stillIndexingMessage();
1479
- if (indexing) {
1480
- result = indexing;
1458
+ const blocked = await requireIndex("emails");
1459
+ if (blocked) {
1460
+ result = blocked;
1481
1461
  break;
1482
1462
  }
1483
1463
  }
1484
- if (!(await isIndexReady("emails"))) {
1485
- result = indexUnavailableMessage("emails");
1486
- break;
1487
- }
1488
1464
  result = formatEmailThreadResults(await getEmailThread(args.file_path, validateLimit(args?.limit, 30)));
1489
1465
  break;
1490
1466
 
@@ -1524,7 +1500,10 @@ async function main() {
1524
1500
  const transport = new StdioServerTransport();
1525
1501
  await server.connect(transport);
1526
1502
  console.error(`Apple Tools MCP server running (v${PACKAGE_VERSION})`);
1527
- // Background indexing runs automatically on startup and every INDEX_INTERVAL
1503
+ // Background indexing: indexer daemon when --mode=indexer; otherwise local
1504
+ // fallback on this stdio process only if indexer.lock is free.
1528
1505
  }
1529
1506
 
1530
- main().catch(console.error);
1507
+ if (shouldConnectMcpStdio(INDEXER_MODE)) {
1508
+ main().catch(console.error);
1509
+ }
package/indexer.js CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  } from "./lib/validators.js";
17
17
  import { safeSqlite3Json, safeOsascript, safeFind } from "./lib/shell.js";
18
18
  import { indexUnavailableMessage } from "./lib/indexGate.js";
19
+ import { createLanceTableCache, LANCE_CONNECT_OPTIONS } from "./lib/lancedbTables.js";
19
20
 
20
21
  // Re-export contact functions for use by other modules
21
22
  export {
@@ -70,8 +71,17 @@ const MAC_ABSOLUTE_EPOCH = 978307200;
70
71
  export const CALENDAR_TMP_PREFIX = "apple-tools-cal-";
71
72
 
72
73
  let embeddingPipeline = null;
74
+
75
+ // One connection per process. Do not early-return a first connect that saw an
76
+ // empty catalog — MCP stdio must pick up tables the indexer daemon already wrote.
77
+ const lanceCache = createLanceTableCache({
78
+ connect: (uri, options) => lancedb.connect(uri, options),
79
+ indexDir: INDEX_DIR,
80
+ mkdirSync: (p, o) => fs.mkdirSync(p, o),
81
+ connectOptions: LANCE_CONNECT_OPTIONS
82
+ });
83
+ const tables = lanceCache.tables;
73
84
  let db = null;
74
- let tables = {};
75
85
 
76
86
  async function getEmbedder() {
77
87
  if (!embeddingPipeline) {
@@ -751,23 +761,19 @@ function getCalendarEvents() {
751
761
  // ============ DATABASE FUNCTIONS ============
752
762
 
753
763
  export async function initDB() {
754
- if (db) return { db, tables };
755
-
756
- fs.mkdirSync(INDEX_DIR, { recursive: true });
757
- db = await lancedb.connect(INDEX_DIR);
764
+ const result = await lanceCache.initDB();
765
+ db = result.db;
766
+ return { db, tables };
767
+ }
758
768
 
759
- const tableNames = await db.tableNames();
760
- if (tableNames.includes("emails")) {
761
- tables.emails = await db.openTable("emails");
762
- }
763
- if (tableNames.includes("messages")) {
764
- tables.messages = await db.openTable("messages");
765
- }
766
- if (tableNames.includes("calendar")) {
767
- tables.calendar = await db.openTable("calendar");
768
- }
769
+ export async function getOpenTable(type) {
770
+ await initDB();
771
+ return tables[type] || null;
772
+ }
769
773
 
770
- return { db, tables };
774
+ function resetLanceConnection() {
775
+ lanceCache.reset();
776
+ db = null;
771
777
  }
772
778
 
773
779
  export async function clearEmailsTable() {
@@ -828,10 +834,9 @@ export async function rebuildIndex(sources = ["emails", "messages", "calendar"],
828
834
  }
829
835
  }
830
836
 
831
- // Reset module-level cache after dropping tables
832
- // This ensures that initDB() will re-initialize and pick up the newly created tables
833
- db = null;
834
- tables = {};
837
+ // Reset connection cache after dropping tables so initDB() re-opens
838
+ // (or recreates) them instead of holding stale/empty handles.
839
+ resetLanceConnection();
835
840
 
836
841
  // Re-index requested sources
837
842
  for (const source of sources) {
@@ -1451,7 +1456,7 @@ export async function indexAll(progressCallback = null) {
1451
1456
 
1452
1457
  export async function isIndexReady(type = "emails") {
1453
1458
  await initDB();
1454
- return tables[type] !== null && tables[type] !== undefined;
1459
+ return tables[type] != null;
1455
1460
  }
1456
1461
 
1457
1462
  // ============ DIRECT QUERIES (for recent items) ============
package/lib/audit.js CHANGED
@@ -15,6 +15,7 @@ import fs from "fs";
15
15
  import path from "path";
16
16
  import { safeSqlite3Json, safeFind } from "./shell.js";
17
17
  import * as lancedb from "@lancedb/lancedb";
18
+ import { LANCE_CONNECT_OPTIONS, openMissingIndexTables } from "./lancedbTables.js";
18
19
 
19
20
  // ============================================================================
20
21
  // CONSTANTS AND PATHS
@@ -55,22 +56,29 @@ let tables = {};
55
56
  // ============================================================================
56
57
 
57
58
  async function initDB() {
58
- if (db) return { db, tables };
59
-
60
- try {
61
- db = await lancedb.connect(INDEX_DIR);
62
- const tableNames = await db.tableNames();
63
-
64
- if (tableNames.includes("emails")) {
65
- tables.emails = await db.openTable("emails");
66
- }
67
- if (tableNames.includes("messages")) {
68
- tables.messages = await db.openTable("messages");
69
- }
70
- if (tableNames.includes("calendar")) {
71
- tables.calendar = await db.openTable("calendar");
59
+ if (!db) {
60
+ try {
61
+ db = await lancedb.connect(INDEX_DIR, LANCE_CONNECT_OPTIONS);
62
+ } catch (e) {
63
+ console.error("Error initializing database:", e.message);
64
+ return { db: null, tables: {} };
72
65
  }
66
+ }
73
67
 
68
+ try {
69
+ const result = await openMissingIndexTables({
70
+ db,
71
+ tables,
72
+ indexDir: INDEX_DIR,
73
+ reconnect: async () => {
74
+ try {
75
+ db?.close?.();
76
+ } catch {}
77
+ db = await lancedb.connect(INDEX_DIR, LANCE_CONNECT_OPTIONS);
78
+ return db;
79
+ }
80
+ });
81
+ db = result.db;
74
82
  return { db, tables };
75
83
  } catch (e) {
76
84
  console.error("Error initializing database:", e.message);