apple-tools-mcp 1.0.0 → 1.1.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/README.md +133 -23
- package/index.js +5 -64
- package/indexer.js +173 -30
- package/lib/audit.js +13 -20
- package/lib/validators.js +2 -2
- package/package.json +28 -55
- package/search.js +61 -21
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# apple-tools-mcp
|
|
2
2
|
|
|
3
|
-
An MCP (Model Context Protocol) server that provides semantic search across Apple Mail, Messages, and
|
|
3
|
+
An MCP (Model Context Protocol) server that provides semantic search across Apple Mail, Messages, Calendar, and Contacts on macOS. Use natural language to search your emails, iMessages, calendar events, and contacts directly from Claude.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
@@ -25,17 +25,44 @@ An MCP (Model Context Protocol) server that provides semantic search across Appl
|
|
|
25
25
|
npm install -g apple-tools-mcp
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
+
Or from source:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
git clone https://github.com/sfls1397/Apple-Tools-MCP.git
|
|
32
|
+
cd Apple-Tools-MCP
|
|
33
|
+
npm install
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
If you installed from source, point Claude Desktop at the local `index.js` instead of `npx` in step 3:
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
"command": "node",
|
|
40
|
+
"args": ["/absolute/path/to/Apple-Tools-MCP/index.js"]
|
|
41
|
+
```
|
|
42
|
+
|
|
28
43
|
### 2. Grant Full Disk Access
|
|
29
44
|
|
|
30
45
|
The MCP server needs access to read your Mail, Messages, and Calendar databases.
|
|
31
46
|
|
|
32
|
-
1.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
47
|
+
1. First, find your Node.js path by running in Terminal:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
which node
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
This will output something like `/opt/homebrew/bin/node` or `/usr/local/bin/node`
|
|
54
|
+
|
|
55
|
+
2. Open **System Settings** → **Privacy & Security** → **Full Disk Access**
|
|
56
|
+
|
|
57
|
+
3. Click the **+** button
|
|
58
|
+
|
|
59
|
+
4. Press **Cmd+Shift+G** to open the "Go to Folder" dialog
|
|
60
|
+
|
|
61
|
+
5. Paste the path from step 1 (e.g., `/opt/homebrew/bin/node`) and press Enter
|
|
62
|
+
|
|
63
|
+
6. Select the `node` file and click **Open**
|
|
64
|
+
|
|
65
|
+
7. Ensure the toggle for Node.js is enabled
|
|
39
66
|
|
|
40
67
|
### 3. Configure Claude Desktop
|
|
41
68
|
|
|
@@ -60,16 +87,16 @@ Quit and reopen Claude Desktop to load the MCP server.
|
|
|
60
87
|
|
|
61
88
|
## Building the Index
|
|
62
89
|
|
|
63
|
-
On first use, the server will automatically build a vector index of your
|
|
90
|
+
On first use, the server will automatically build a vector index of your emails, messages, and calendar events. Email history is unlimited by default. This may take a while depending on the volume of data.
|
|
64
91
|
|
|
65
92
|
You can manually rebuild the index:
|
|
66
93
|
|
|
67
94
|
```bash
|
|
68
|
-
# Index
|
|
69
|
-
|
|
95
|
+
# Index all email history (default)
|
|
96
|
+
npm run build-index
|
|
70
97
|
|
|
71
|
-
#
|
|
72
|
-
APPLE_TOOLS_INDEX_DAYS_BACK=
|
|
98
|
+
# Optional: cap email lookback (e.g. for a faster test rebuild)
|
|
99
|
+
APPLE_TOOLS_INDEX_DAYS_BACK=30 npm run build-index
|
|
73
100
|
```
|
|
74
101
|
|
|
75
102
|
The index is stored in `~/.apple-tools-mcp/vector-index/`.
|
|
@@ -78,15 +105,57 @@ The index is stored in `~/.apple-tools-mcp/vector-index/`.
|
|
|
78
105
|
|
|
79
106
|
Once configured, Claude can use these tools:
|
|
80
107
|
|
|
108
|
+
### Universal Search
|
|
109
|
+
|
|
110
|
+
| Tool | Description |
|
|
111
|
+
|------|-------------|
|
|
112
|
+
| `smart_search` | Intelligent search across all sources - automatically determines which to search |
|
|
113
|
+
| `person_search` | Find ALL communication with a person across Mail, Messages, and Calendar |
|
|
114
|
+
|
|
115
|
+
### Email Tools
|
|
116
|
+
|
|
81
117
|
| Tool | Description |
|
|
82
118
|
|------|-------------|
|
|
83
|
-
| `
|
|
84
|
-
| `
|
|
85
|
-
| `
|
|
86
|
-
| `
|
|
87
|
-
| `
|
|
88
|
-
| `
|
|
89
|
-
|
|
119
|
+
| `mail_search` | Semantic search for emails with filters (sender, recipient, attachments, mailbox) |
|
|
120
|
+
| `mail_recent` | Get most recent emails (supports unread filter) |
|
|
121
|
+
| `mail_date` | Get emails from a specific date ("today", "yesterday", "Nov 13") |
|
|
122
|
+
| `mail_read` | Read full email content by file path |
|
|
123
|
+
| `mail_senders` | List most frequent email senders |
|
|
124
|
+
| `mail_thread` | Get all emails in a conversation thread |
|
|
125
|
+
|
|
126
|
+
### Messages Tools
|
|
127
|
+
|
|
128
|
+
| Tool | Description |
|
|
129
|
+
|------|-------------|
|
|
130
|
+
| `messages_search` | Semantic search for iMessages/SMS with filters |
|
|
131
|
+
| `messages_recent` | Get most recent messages |
|
|
132
|
+
| `messages_conversation` | Get full conversation history with a contact |
|
|
133
|
+
| `messages_contacts` | List all contacts you've messaged |
|
|
134
|
+
|
|
135
|
+
### Calendar Tools
|
|
136
|
+
|
|
137
|
+
| Tool | Description |
|
|
138
|
+
|------|-------------|
|
|
139
|
+
| `calendar_search` | Semantic search for events with filters |
|
|
140
|
+
| `calendar_date` | Get events on a specific date (live Calendar.app, not the search index) |
|
|
141
|
+
| `calendar_upcoming` | Get next N upcoming events |
|
|
142
|
+
| `calendar_week` | Get all events for current or future week |
|
|
143
|
+
| `calendar_free_time` | Find available time slots on a date (live Calendar.app, not the search index) |
|
|
144
|
+
| `calendar_recurring` | List recurring events |
|
|
145
|
+
|
|
146
|
+
### Contacts Tools
|
|
147
|
+
|
|
148
|
+
| Tool | Description |
|
|
149
|
+
|------|-------------|
|
|
150
|
+
| `contacts_search` | Search contacts by name, email, phone, or organization |
|
|
151
|
+
| `contacts_lookup` | Look up a specific contact's full details |
|
|
152
|
+
|
|
153
|
+
### Admin Tools
|
|
154
|
+
|
|
155
|
+
| Tool | Description |
|
|
156
|
+
|------|-------------|
|
|
157
|
+
| `rebuild_index` | Rebuild search index for one or all sources |
|
|
158
|
+
| `audit_index` | Audit index health and coverage |
|
|
90
159
|
|
|
91
160
|
## Example Queries
|
|
92
161
|
|
|
@@ -97,6 +166,8 @@ Ask Claude things like:
|
|
|
97
166
|
- "When is my next dentist appointment?"
|
|
98
167
|
- "Search for emails about the AWS bill from November"
|
|
99
168
|
- "Find all calendar events with Zoom links"
|
|
169
|
+
- "What's Sarah's phone number?"
|
|
170
|
+
- "Show me all communication with David from last month"
|
|
100
171
|
|
|
101
172
|
## Privacy & Security
|
|
102
173
|
|
|
@@ -114,7 +185,7 @@ Ensure Node.js has Full Disk Access (see Installation step 2).
|
|
|
114
185
|
### Empty search results
|
|
115
186
|
|
|
116
187
|
1. Check that the index was built: `ls ~/.apple-tools-mcp/vector-index/`
|
|
117
|
-
2. Rebuild the index if needed: `
|
|
188
|
+
2. Rebuild the index if needed: `npm run build-index`
|
|
118
189
|
|
|
119
190
|
### Server not appearing in Claude
|
|
120
191
|
|
|
@@ -122,19 +193,58 @@ Ensure Node.js has Full Disk Access (see Installation step 2).
|
|
|
122
193
|
2. Restart Claude Desktop completely (Cmd+Q, then reopen)
|
|
123
194
|
3. Check Claude's MCP logs for errors
|
|
124
195
|
|
|
196
|
+
### Force rebuild the index
|
|
197
|
+
|
|
198
|
+
If the index becomes corrupted or out of sync:
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
# Remove existing index files
|
|
202
|
+
rm -rf ~/.apple-tools-mcp/vector-index
|
|
203
|
+
rm -f ~/.apple-tools-mcp/index-meta.json
|
|
204
|
+
rm -f ~/.apple-tools-mcp/indexer.lock
|
|
205
|
+
|
|
206
|
+
# Restart Claude Desktop to trigger a fresh rebuild
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### Monitor indexing progress
|
|
210
|
+
|
|
211
|
+
Watch the MCP server logs in real-time:
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
tail -f ~/Library/Logs/Claude/mcp-server-apple-tools.log
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### Audit the index
|
|
218
|
+
|
|
219
|
+
Check index health and coverage:
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
# Quick audit
|
|
223
|
+
npm run audit
|
|
224
|
+
|
|
225
|
+
# Detailed audit saved to file
|
|
226
|
+
npm run audit -- --reporter=verbose > audit-report.txt
|
|
227
|
+
```
|
|
228
|
+
|
|
125
229
|
## Development
|
|
126
230
|
|
|
127
231
|
```bash
|
|
128
232
|
# Clone the repo
|
|
129
|
-
git clone https://github.com/sfls1397/
|
|
130
|
-
cd
|
|
233
|
+
git clone https://github.com/sfls1397/Apple-Tools-MCP.git
|
|
234
|
+
cd Apple-Tools-MCP
|
|
131
235
|
|
|
132
236
|
# Install dependencies
|
|
133
237
|
npm install
|
|
134
238
|
|
|
239
|
+
# Install test dependencies
|
|
240
|
+
npm install -D vitest @vitest/coverage-v8 fast-check
|
|
241
|
+
|
|
135
242
|
# Run tests
|
|
136
243
|
npm test
|
|
137
244
|
|
|
245
|
+
# Run tests with verbose coverage report
|
|
246
|
+
npx vitest run --coverage --reporter=verbose
|
|
247
|
+
|
|
138
248
|
# Build index with debug output
|
|
139
249
|
npm run build-index
|
|
140
250
|
|
package/index.js
CHANGED
|
@@ -89,35 +89,6 @@ function releaseLock() {
|
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
// Kill any zombie MCP processes on startup (except this one)
|
|
93
|
-
function cleanupZombieProcesses() {
|
|
94
|
-
try {
|
|
95
|
-
const { execSync } = require('child_process');
|
|
96
|
-
// Find all apple-tools-mcp index.js processes
|
|
97
|
-
const psOutput = execSync('ps aux | grep "apple-tools-mcp/index.js" | grep -v grep || true', { encoding: 'utf-8' });
|
|
98
|
-
const lines = psOutput.trim().split('\n').filter(l => l);
|
|
99
|
-
|
|
100
|
-
for (const line of lines) {
|
|
101
|
-
const parts = line.trim().split(/\s+/);
|
|
102
|
-
const pid = parseInt(parts[1]);
|
|
103
|
-
|
|
104
|
-
// Skip this process
|
|
105
|
-
if (pid === process.pid) continue;
|
|
106
|
-
|
|
107
|
-
// Check if process is still running and kill it
|
|
108
|
-
try {
|
|
109
|
-
process.kill(pid, 0); // Check if exists
|
|
110
|
-
console.error(`Killing zombie MCP process: ${pid}`);
|
|
111
|
-
process.kill(pid, 'SIGTERM');
|
|
112
|
-
} catch {
|
|
113
|
-
// Process already dead
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
} catch (e) {
|
|
117
|
-
// Ignore errors - cleanup is best-effort
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
92
|
// Clean up lock and timer on exit
|
|
122
93
|
process.on("exit", () => {
|
|
123
94
|
stopBackgroundIndexing();
|
|
@@ -235,16 +206,6 @@ function getIndexingMessage() {
|
|
|
235
206
|
return "Indexing new data. Please try again in a moment.";
|
|
236
207
|
}
|
|
237
208
|
|
|
238
|
-
// Timeout wrapper for promises
|
|
239
|
-
function withTimeout(promise, timeoutMs, operation = "Operation") {
|
|
240
|
-
return Promise.race([
|
|
241
|
-
promise,
|
|
242
|
-
new Promise((_, reject) =>
|
|
243
|
-
setTimeout(() => reject(new Error(`${operation} timed out after ${timeoutMs}ms`)), timeoutMs)
|
|
244
|
-
)
|
|
245
|
-
]);
|
|
246
|
-
}
|
|
247
|
-
|
|
248
209
|
// Run a single indexing cycle (called by background timer)
|
|
249
210
|
function runIndexCycle() {
|
|
250
211
|
if (indexingInProgress) {
|
|
@@ -356,10 +317,11 @@ function stopBackgroundIndexing() {
|
|
|
356
317
|
async function initializeIndexing() {
|
|
357
318
|
isFirstEverRun = await checkIfFirstRun();
|
|
358
319
|
|
|
359
|
-
// Try to acquire lock - if another instance is
|
|
320
|
+
// Try to acquire lock - if another instance is indexing, skip background
|
|
321
|
+
// indexing but keep the MCP server running so search still works.
|
|
360
322
|
if (!acquireLock()) {
|
|
361
|
-
console.error("Another apple-tools-mcp instance is
|
|
362
|
-
|
|
323
|
+
console.error("Another apple-tools-mcp instance is indexing. Server will run without background indexing.");
|
|
324
|
+
return;
|
|
363
325
|
}
|
|
364
326
|
|
|
365
327
|
// Start background indexing
|
|
@@ -474,29 +436,11 @@ async function calendarSearch(query, options = {}) {
|
|
|
474
436
|
}
|
|
475
437
|
|
|
476
438
|
async function calendarDate(date) {
|
|
477
|
-
if (!sessionIndexComplete) {
|
|
478
|
-
return getIndexingMessage();
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
const ready = await isIndexReady("calendar");
|
|
482
|
-
if (!ready) {
|
|
483
|
-
return "Calendar index not available. Please try again shortly.";
|
|
484
|
-
}
|
|
485
|
-
|
|
486
439
|
const result = await getCalendarDateResults(date);
|
|
487
440
|
return formatCalendarResults(result);
|
|
488
441
|
}
|
|
489
442
|
|
|
490
443
|
async function calendarFreeTime(date, options = {}) {
|
|
491
|
-
if (!sessionIndexComplete) {
|
|
492
|
-
return getIndexingMessage();
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
const ready = await isIndexReady("calendar");
|
|
496
|
-
if (!ready) {
|
|
497
|
-
return "Calendar index not available. Please try again shortly.";
|
|
498
|
-
}
|
|
499
|
-
|
|
500
444
|
const result = await calculateFreeTime(date, options);
|
|
501
445
|
return formatFreeTimeResults(result);
|
|
502
446
|
}
|
|
@@ -1490,12 +1434,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1490
1434
|
|
|
1491
1435
|
// Start the server
|
|
1492
1436
|
async function main() {
|
|
1493
|
-
// Kill any zombie processes from previous sessions
|
|
1494
|
-
cleanupZombieProcesses();
|
|
1495
|
-
|
|
1496
1437
|
const transport = new StdioServerTransport();
|
|
1497
1438
|
await server.connect(transport);
|
|
1498
|
-
console.error("Apple Tools MCP server running (
|
|
1439
|
+
console.error("Apple Tools MCP server running (v1.1.1)");
|
|
1499
1440
|
// Background indexing runs automatically on startup and every INDEX_INTERVAL
|
|
1500
1441
|
}
|
|
1501
1442
|
|
package/indexer.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
|
-
import { exec } from "child_process";
|
|
4
|
-
import { promisify } from "util";
|
|
5
3
|
import * as lancedb from "@lancedb/lancedb";
|
|
6
4
|
import { pipeline } from "@xenova/transformers";
|
|
7
5
|
import {
|
|
@@ -13,7 +11,7 @@ import {
|
|
|
13
11
|
escapeSQL,
|
|
14
12
|
stripHtmlTags
|
|
15
13
|
} from "./lib/validators.js";
|
|
16
|
-
import { safeSqlite3Json, safeOsascript } from "./lib/shell.js";
|
|
14
|
+
import { safeSqlite3Json, safeOsascript, safeFind } from "./lib/shell.js";
|
|
17
15
|
|
|
18
16
|
// Re-export contact functions for use by other modules
|
|
19
17
|
export {
|
|
@@ -28,16 +26,17 @@ export {
|
|
|
28
26
|
getContactStats
|
|
29
27
|
} from "./contacts.js";
|
|
30
28
|
|
|
31
|
-
const execAsync = promisify(exec);
|
|
32
|
-
|
|
33
29
|
// Support env var overrides for testing with separate index
|
|
34
30
|
export const INDEX_DIR = process.env.APPLE_TOOLS_INDEX_DIR ||
|
|
35
31
|
path.join(process.env.HOME, ".apple-tools-mcp", "vector-index");
|
|
36
32
|
const META_FILE = process.env.APPLE_TOOLS_META_FILE ||
|
|
37
33
|
path.join(process.env.HOME, ".apple-tools-mcp", "index-meta.json");
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
34
|
+
// Optional date filter for email indexing. Default is unlimited (index all emails).
|
|
35
|
+
// Set APPLE_TOOLS_INDEX_DAYS_BACK=30 to cap the window (tests do this).
|
|
36
|
+
// Set APPLE_TOOLS_INDEX_DAYS_BACK=0 to explicitly disable filtering.
|
|
37
|
+
const DAYS_BACK = process.env.APPLE_TOOLS_INDEX_DAYS_BACK !== undefined
|
|
38
|
+
? parseInt(process.env.APPLE_TOOLS_INDEX_DAYS_BACK, 10) || null // 0 means null (no filter)
|
|
39
|
+
: null; // Default: no date filter
|
|
41
40
|
const MAIL_DIR = path.join(process.env.HOME, "Library", "Mail");
|
|
42
41
|
|
|
43
42
|
// Load/save index metadata (timestamps, etc.)
|
|
@@ -59,11 +58,12 @@ function saveIndexMeta(meta) {
|
|
|
59
58
|
}
|
|
60
59
|
const MESSAGES_DB = path.join(process.env.HOME, "Library", "Messages", "chat.db");
|
|
61
60
|
const CALENDAR_DB = path.join(process.env.HOME, "Library", "Group Containers", "group.com.apple.calendar", "Calendar.sqlitedb");
|
|
62
|
-
const BATCH_SIZE =
|
|
63
|
-
const BATCH_DELAY_MS =
|
|
61
|
+
const BATCH_SIZE = 64; // Optimized for batch embedding throughput (increased for faster indexing)
|
|
62
|
+
const BATCH_DELAY_MS = 0; // No delay needed - benchmarks showed no thermal throttling
|
|
64
63
|
|
|
65
64
|
// Mac Absolute Time epoch: Jan 1, 2001 00:00:00 UTC
|
|
66
65
|
const MAC_ABSOLUTE_EPOCH = 978307200;
|
|
66
|
+
export const CALENDAR_TMP_PREFIX = "apple-tools-cal-";
|
|
67
67
|
|
|
68
68
|
let embeddingPipeline = null;
|
|
69
69
|
let db = null;
|
|
@@ -89,11 +89,14 @@ const EMBEDDING_DIM = 384; // all-MiniLM-L6-v2 dimension
|
|
|
89
89
|
|
|
90
90
|
// Timeout wrapper for promises
|
|
91
91
|
function withTimeout(promise, timeoutMs, operation = "Operation") {
|
|
92
|
+
let timer;
|
|
93
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
94
|
+
timer = setTimeout(() => reject(new Error(`${operation} timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
95
|
+
if (timer.unref) timer.unref();
|
|
96
|
+
});
|
|
92
97
|
return Promise.race([
|
|
93
|
-
promise,
|
|
94
|
-
|
|
95
|
-
setTimeout(() => reject(new Error(`${operation} timed out after ${timeoutMs}ms`)), timeoutMs)
|
|
96
|
-
)
|
|
98
|
+
promise.finally(() => clearTimeout(timer)),
|
|
99
|
+
timeoutPromise
|
|
97
100
|
]);
|
|
98
101
|
}
|
|
99
102
|
|
|
@@ -333,10 +336,8 @@ function parseEmlx(filePath) {
|
|
|
333
336
|
// Includes both .emlx and .partial.emlx files (partial = not fully downloaded via IMAP)
|
|
334
337
|
async function findAllEmlxFiles() {
|
|
335
338
|
try {
|
|
336
|
-
//
|
|
337
|
-
|
|
338
|
-
const { stdout } = await execAsync(cmd, { encoding: "utf-8", maxBuffer: 50 * 1024 * 1024, timeout: 120000 });
|
|
339
|
-
return stdout.trim().split("\n").filter(f => f);
|
|
339
|
+
// "*.emlx" also matches "*.partial.emlx"
|
|
340
|
+
return safeFind(MAIL_DIR, { name: "*.emlx", type: "f" });
|
|
340
341
|
} catch (e) {
|
|
341
342
|
console.error("Error finding emlx files:", e.message);
|
|
342
343
|
return [];
|
|
@@ -358,9 +359,7 @@ async function findNewEmlxFiles(sinceTimestamp) {
|
|
|
358
359
|
|
|
359
360
|
// Use find with -mtime instead of mdfind for reliability
|
|
360
361
|
// find is more reliable than Spotlight which can have stale/incomplete indexes
|
|
361
|
-
const
|
|
362
|
-
const { stdout } = await execAsync(cmd, { encoding: "utf-8", maxBuffer: 50 * 1024 * 1024, timeout: 120000 });
|
|
363
|
-
const files = stdout.trim().split("\n").filter(f => f);
|
|
362
|
+
const files = safeFind(MAIL_DIR, { name: "*.emlx", type: "f", mtime: `-${daysAgo}` });
|
|
364
363
|
console.error(`find found ${files.length} new/modified emails in last ${daysAgo} days`);
|
|
365
364
|
return files;
|
|
366
365
|
} catch (e) {
|
|
@@ -718,10 +717,6 @@ function getCalendarEvents() {
|
|
|
718
717
|
const startTimestamp = macAbsoluteToUnixMs(row.start_date);
|
|
719
718
|
const attendees = attendeesMap.get(row.id) || [];
|
|
720
719
|
|
|
721
|
-
if (row.all_day === 1) {
|
|
722
|
-
console.error(`[Indexing] All-day event: "${row.summary}" at ${new Date(startTimestamp).toISOString()}`);
|
|
723
|
-
}
|
|
724
|
-
|
|
725
720
|
events.push({
|
|
726
721
|
dbId: row.id, // Stable database ID for deduplication
|
|
727
722
|
title: row.summary,
|
|
@@ -909,15 +904,20 @@ export async function indexEmails(progressCallback = null, forceFullScan = false
|
|
|
909
904
|
if (DAYS_BACK) {
|
|
910
905
|
// DAYS_BACK is set - use it for mdfind filter (e.g., for testing or rebuild with constraints)
|
|
911
906
|
lastEmailIndexTime = Date.now() - (DAYS_BACK * 24 * 60 * 60 * 1000);
|
|
912
|
-
console.error(
|
|
907
|
+
console.error(`\n=== EMAIL INDEXING (DAYS_BACK=${DAYS_BACK}) ===`);
|
|
908
|
+
console.error(`Will search for emails modified since: ${new Date(lastEmailIndexTime).toISOString()}`);
|
|
913
909
|
} else if (forceFullScan) {
|
|
914
910
|
// Force full scan only when no DAYS_BACK constraint
|
|
915
911
|
lastEmailIndexTime = null;
|
|
912
|
+
console.error("\n=== EMAIL INDEXING (FULL SCAN) ===");
|
|
916
913
|
console.error("Force full scan requested - finding all emails");
|
|
917
914
|
} else if (meta.lastEmailIndexTime) {
|
|
918
915
|
lastEmailIndexTime = meta.lastEmailIndexTime - ONE_DAY_MS;
|
|
916
|
+
console.error(`\n=== EMAIL INDEXING (INCREMENTAL) ===`);
|
|
917
|
+
console.error(`Searching for emails modified since: ${new Date(lastEmailIndexTime).toISOString()}`);
|
|
919
918
|
} else {
|
|
920
919
|
lastEmailIndexTime = null;
|
|
920
|
+
console.error("\n=== EMAIL INDEXING (FIRST RUN - FULL SCAN) ===");
|
|
921
921
|
}
|
|
922
922
|
|
|
923
923
|
// Save the current time BEFORE we start - any emails arriving during indexing
|
|
@@ -926,6 +926,7 @@ export async function indexEmails(progressCallback = null, forceFullScan = false
|
|
|
926
926
|
|
|
927
927
|
// Use fast incremental scan if we have a previous timestamp
|
|
928
928
|
const startTime = Date.now();
|
|
929
|
+
console.error(`Calling findNewEmlxFiles with timestamp: ${lastEmailIndexTime ? new Date(lastEmailIndexTime).toISOString() : 'null (full scan)'}`);
|
|
929
930
|
const newFiles = await findNewEmlxFiles(lastEmailIndexTime);
|
|
930
931
|
console.error(`Found ${newFiles.length} new/modified email files (${Date.now() - startTime}ms)`);
|
|
931
932
|
|
|
@@ -950,6 +951,11 @@ export async function indexEmails(progressCallback = null, forceFullScan = false
|
|
|
950
951
|
let processed = 0;
|
|
951
952
|
let skippedCount = { parseNull: 0, shortSearchText: 0, duplicateMessageId: 0 };
|
|
952
953
|
|
|
954
|
+
// Progress tracking
|
|
955
|
+
const totalBatches = Math.ceil(toIndex.length / BATCH_SIZE);
|
|
956
|
+
const startProcessTime = Date.now();
|
|
957
|
+
console.error(`\nProcessing ${toIndex.length} emails in ${totalBatches} batches (batch size: ${BATCH_SIZE})...`);
|
|
958
|
+
|
|
953
959
|
for (let i = 0; i < toIndex.length; i += BATCH_SIZE) {
|
|
954
960
|
const batch = toIndex.slice(i, i + BATCH_SIZE);
|
|
955
961
|
|
|
@@ -1067,7 +1073,16 @@ export async function indexEmails(progressCallback = null, forceFullScan = false
|
|
|
1067
1073
|
}
|
|
1068
1074
|
|
|
1069
1075
|
processed += batch.length;
|
|
1070
|
-
|
|
1076
|
+
|
|
1077
|
+
// Calculate and display progress with time estimates
|
|
1078
|
+
const currentBatch = Math.floor(i / BATCH_SIZE) + 1;
|
|
1079
|
+
const elapsedMs = Date.now() - startProcessTime;
|
|
1080
|
+
const avgTimePerBatch = elapsedMs / currentBatch;
|
|
1081
|
+
const remainingBatches = totalBatches - currentBatch;
|
|
1082
|
+
const estimatedRemainingMs = avgTimePerBatch * remainingBatches;
|
|
1083
|
+
const estimatedRemainingMin = Math.ceil(estimatedRemainingMs / 60000);
|
|
1084
|
+
|
|
1085
|
+
console.error(`Batch ${currentBatch}/${totalBatches} complete | Processed: ${processed}/${toIndex.length} emails | Est. remaining: ${estimatedRemainingMin}m`);
|
|
1071
1086
|
|
|
1072
1087
|
// Report progress after each batch
|
|
1073
1088
|
if (progressCallback) {
|
|
@@ -1083,7 +1098,8 @@ export async function indexEmails(progressCallback = null, forceFullScan = false
|
|
|
1083
1098
|
// Save timestamp for next incremental scan
|
|
1084
1099
|
saveIndexMeta({ ...meta, lastEmailIndexTime: indexStartTime });
|
|
1085
1100
|
|
|
1086
|
-
// Log skip summary
|
|
1101
|
+
// Log skip summary with timing
|
|
1102
|
+
const totalProcessingTime = Date.now() - startProcessTime;
|
|
1087
1103
|
const totalSkipped = skippedCount.parseNull + skippedCount.shortSearchText + skippedCount.duplicateMessageId;
|
|
1088
1104
|
console.error(`\nEmail indexing summary:`);
|
|
1089
1105
|
console.error(` Files to index: ${toIndex.length}`);
|
|
@@ -1092,7 +1108,8 @@ export async function indexEmails(progressCallback = null, forceFullScan = false
|
|
|
1092
1108
|
console.error(` Skipped - short searchText: ${skippedCount.shortSearchText}`);
|
|
1093
1109
|
console.error(` Skipped - duplicate messageId: ${skippedCount.duplicateMessageId}`);
|
|
1094
1110
|
console.error(` Total skipped: ${totalSkipped}`);
|
|
1095
|
-
console.error(` Discrepancy: ${toIndex.length - processed - totalSkipped}
|
|
1111
|
+
console.error(` Discrepancy: ${toIndex.length - processed - totalSkipped}`);
|
|
1112
|
+
console.error(` Total time: ${Math.round(totalProcessingTime / 1000)}s (${Math.round(totalProcessingTime / 60000)}m)\n`);
|
|
1096
1113
|
|
|
1097
1114
|
// Return actual indexed count from database, not stale cache
|
|
1098
1115
|
const finalIndexedPaths = await getIndexedIdsWithRetry("emails", "filePath");
|
|
@@ -1129,6 +1146,12 @@ export async function indexMessages(forceFullScan = false) {
|
|
|
1129
1146
|
}
|
|
1130
1147
|
|
|
1131
1148
|
let processed = 0;
|
|
1149
|
+
|
|
1150
|
+
// Progress tracking
|
|
1151
|
+
const totalBatches = Math.ceil(toIndex.length / BATCH_SIZE);
|
|
1152
|
+
const startProcessTime = Date.now();
|
|
1153
|
+
console.error(`\nProcessing ${toIndex.length} messages in ${totalBatches} batches (batch size: ${BATCH_SIZE})...`);
|
|
1154
|
+
|
|
1132
1155
|
for (let i = 0; i < toIndex.length; i += BATCH_SIZE) {
|
|
1133
1156
|
const batch = toIndex.slice(i, i + BATCH_SIZE);
|
|
1134
1157
|
|
|
@@ -1221,7 +1244,16 @@ export async function indexMessages(forceFullScan = false) {
|
|
|
1221
1244
|
}
|
|
1222
1245
|
|
|
1223
1246
|
processed += batch.length;
|
|
1224
|
-
|
|
1247
|
+
|
|
1248
|
+
// Calculate and display progress with time estimates
|
|
1249
|
+
const currentBatch = Math.floor(i / BATCH_SIZE) + 1;
|
|
1250
|
+
const elapsedMs = Date.now() - startProcessTime;
|
|
1251
|
+
const avgTimePerBatch = elapsedMs / currentBatch;
|
|
1252
|
+
const remainingBatches = totalBatches - currentBatch;
|
|
1253
|
+
const estimatedRemainingMs = avgTimePerBatch * remainingBatches;
|
|
1254
|
+
const estimatedRemainingMin = Math.ceil(estimatedRemainingMs / 60000);
|
|
1255
|
+
|
|
1256
|
+
console.error(`Batch ${currentBatch}/${totalBatches} complete | Processed: ${processed}/${toIndex.length} messages | Est. remaining: ${estimatedRemainingMin}m`);
|
|
1225
1257
|
|
|
1226
1258
|
// Throttle to prevent thermal crashes
|
|
1227
1259
|
if (i + BATCH_SIZE < toIndex.length) {
|
|
@@ -1656,6 +1688,117 @@ export function getUpcomingEvents(limit = 10) {
|
|
|
1656
1688
|
}
|
|
1657
1689
|
}
|
|
1658
1690
|
|
|
1691
|
+
// Convert local-day unix ms bounds to Apple/Core Data seconds (unix - 978307200)
|
|
1692
|
+
export function localDayToMacBounds(startMs, endMs) {
|
|
1693
|
+
const startMac = Math.floor(Number(startMs) / 1000) - MAC_ABSOLUTE_EPOCH;
|
|
1694
|
+
const endMac = Math.floor(Number(endMs) / 1000) - MAC_ABSOLUTE_EPOCH;
|
|
1695
|
+
if (!Number.isFinite(startMac) || !Number.isFinite(endMac)) {
|
|
1696
|
+
throw new Error("Invalid date bounds");
|
|
1697
|
+
}
|
|
1698
|
+
return { startMac, endMac };
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
// Date-bounded OccurrenceCache query: one row per occurrence, no GROUP BY ci.ROWID
|
|
1702
|
+
export function buildEventsOnDateQuery(startMac, endMac) {
|
|
1703
|
+
const startBound = Math.floor(Number(startMac));
|
|
1704
|
+
const endBound = Math.floor(Number(endMac));
|
|
1705
|
+
if (!Number.isFinite(startBound) || !Number.isFinite(endBound)) {
|
|
1706
|
+
throw new Error("Invalid Mac Absolute Time bounds");
|
|
1707
|
+
}
|
|
1708
|
+
const timedStart = "COALESCE(oc.occurrence_end_date - (ci.end_date - ci.start_date), ci.start_date)";
|
|
1709
|
+
const occEnd = "COALESCE(oc.occurrence_end_date, ci.end_date)";
|
|
1710
|
+
// First local day = occurrence-end local date minus (durationDays - 1). Day math, not seconds (DST-safe).
|
|
1711
|
+
const allDayStartDate = `date(${occEnd} + 978307200, 'unixepoch', 'localtime', '-' || (CAST(round((ci.end_date - ci.start_date) / 86400.0) AS INTEGER) - 1) || ' days')`;
|
|
1712
|
+
const allDayStartMac = `CAST(strftime('%s', ${allDayStartDate} || ' 00:00:00', 'utc') AS INTEGER) - 978307200`;
|
|
1713
|
+
return `
|
|
1714
|
+
SELECT DISTINCT
|
|
1715
|
+
ci.ROWID as itemId,
|
|
1716
|
+
ci.summary as title,
|
|
1717
|
+
datetime(CASE WHEN ci.all_day THEN ${allDayStartMac} ELSE ${timedStart} END + 978307200, 'unixepoch', 'localtime') as start,
|
|
1718
|
+
datetime(${occEnd} + 978307200, 'unixepoch', 'localtime') as end,
|
|
1719
|
+
CASE WHEN ci.all_day THEN ${allDayStartMac} ELSE ${timedStart} END as startMac,
|
|
1720
|
+
${occEnd} as endMac,
|
|
1721
|
+
ci.all_day as isAllDay,
|
|
1722
|
+
c.title as calendar,
|
|
1723
|
+
l.title as location
|
|
1724
|
+
FROM OccurrenceCache oc
|
|
1725
|
+
INNER JOIN CalendarItem ci ON oc.event_id = ci.ROWID
|
|
1726
|
+
LEFT JOIN Calendar c ON ci.calendar_id = c.ROWID
|
|
1727
|
+
LEFT JOIN Location l ON ci.location_id = l.ROWID
|
|
1728
|
+
WHERE ci.summary IS NOT NULL AND ci.summary <> ''
|
|
1729
|
+
AND (c.title IS NULL OR c.title NOT IN ('Found in Mail', 'Found in Natural Language'))
|
|
1730
|
+
AND (
|
|
1731
|
+
(oc.day >= ${startBound} AND oc.day < ${endBound})
|
|
1732
|
+
OR (
|
|
1733
|
+
ci.all_day = 0
|
|
1734
|
+
AND ${timedStart} < ${startBound}
|
|
1735
|
+
AND ${occEnd} > ${startBound}
|
|
1736
|
+
)
|
|
1737
|
+
)
|
|
1738
|
+
ORDER BY startMac ASC, title ASC
|
|
1739
|
+
`;
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
// Copy Calendar.sqlitedb (+ wal/shm) to /tmp for one query, then delete the copies.
|
|
1743
|
+
// Calendar.app holds a lock on the live db; sqlite can read a snapshot copy.
|
|
1744
|
+
function withCalendarCopy(fn) {
|
|
1745
|
+
const id = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1746
|
+
const tmpDb = path.join("/tmp", `${CALENDAR_TMP_PREFIX}${id}.sqlitedb`);
|
|
1747
|
+
const tmpWal = `${tmpDb}-wal`;
|
|
1748
|
+
const tmpShm = `${tmpDb}-shm`;
|
|
1749
|
+
const srcWal = `${CALENDAR_DB}-wal`;
|
|
1750
|
+
const srcShm = `${CALENDAR_DB}-shm`;
|
|
1751
|
+
|
|
1752
|
+
try {
|
|
1753
|
+
if (!fs.existsSync(CALENDAR_DB)) {
|
|
1754
|
+
throw new Error("Calendar database not found");
|
|
1755
|
+
}
|
|
1756
|
+
fs.copyFileSync(CALENDAR_DB, tmpDb);
|
|
1757
|
+
if (fs.existsSync(srcWal)) {
|
|
1758
|
+
fs.copyFileSync(srcWal, tmpWal);
|
|
1759
|
+
}
|
|
1760
|
+
if (fs.existsSync(srcShm)) {
|
|
1761
|
+
fs.copyFileSync(srcShm, tmpShm);
|
|
1762
|
+
}
|
|
1763
|
+
return fn(tmpDb);
|
|
1764
|
+
} finally {
|
|
1765
|
+
for (const file of [tmpDb, tmpWal, tmpShm]) {
|
|
1766
|
+
try {
|
|
1767
|
+
fs.unlinkSync(file);
|
|
1768
|
+
} catch {
|
|
1769
|
+
// already gone or never created
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
// calendar_date: live OccurrenceCache for every occurrence on a local day
|
|
1776
|
+
export function getEventsOnDate(startMs, endMs) {
|
|
1777
|
+
try {
|
|
1778
|
+
if (!fs.existsSync(CALENDAR_DB)) {
|
|
1779
|
+
return { events: [], error: "Calendar database not found" };
|
|
1780
|
+
}
|
|
1781
|
+
const { startMac, endMac } = localDayToMacBounds(startMs, endMs);
|
|
1782
|
+
const query = buildEventsOnDateQuery(startMac, endMac);
|
|
1783
|
+
let lastError;
|
|
1784
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
1785
|
+
try {
|
|
1786
|
+
const events = withCalendarCopy((dbPath) => {
|
|
1787
|
+
return safeSqlite3Json(dbPath, query, { timeout: 15000 });
|
|
1788
|
+
});
|
|
1789
|
+
return { events, error: null };
|
|
1790
|
+
} catch (e) {
|
|
1791
|
+
lastError = e;
|
|
1792
|
+
console.error(`Error getting events on date (attempt ${attempt + 1}):`, e.message);
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
return { events: [], error: lastError.message };
|
|
1796
|
+
} catch (e) {
|
|
1797
|
+
console.error("Error getting events on date:", e.message);
|
|
1798
|
+
return { events: [], error: e.message };
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1659
1802
|
// ============ NEW TOOLS - PHASE 2 ============
|
|
1660
1803
|
|
|
1661
1804
|
// mail_unread_count: Get count of unread emails via AppleScript
|
package/lib/audit.js
CHANGED
|
@@ -13,8 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import fs from "fs";
|
|
15
15
|
import path from "path";
|
|
16
|
-
import {
|
|
17
|
-
import { safeSqlite3Json } from "./shell.js";
|
|
16
|
+
import { safeSqlite3Json, safeFind } from "./shell.js";
|
|
18
17
|
import * as lancedb from "@lancedb/lancedb";
|
|
19
18
|
|
|
20
19
|
// ============================================================================
|
|
@@ -38,6 +37,16 @@ const DAYS_BACK = process.env.APPLE_TOOLS_INDEX_DAYS_BACK ?
|
|
|
38
37
|
// Exclude these folders from email indexing (matches indexer behavior)
|
|
39
38
|
const EXCLUDED_FOLDERS = ["Junk.mbox", "Saved Junk.mbox", "Trash.mbox", "Deleted Messages.mbox"];
|
|
40
39
|
|
|
40
|
+
function listEmailFiles() {
|
|
41
|
+
const options = { name: "*.emlx", type: "f" };
|
|
42
|
+
if (DAYS_BACK) {
|
|
43
|
+
options.mtime = `-${DAYS_BACK}`;
|
|
44
|
+
}
|
|
45
|
+
return safeFind(MAIL_DIR, options).filter(
|
|
46
|
+
p => !EXCLUDED_FOLDERS.some(folder => p.includes(`/${folder}/`))
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
41
50
|
let db = null;
|
|
42
51
|
let tables = {};
|
|
43
52
|
|
|
@@ -82,15 +91,7 @@ export function countRawEmails() {
|
|
|
82
91
|
if (!fs.existsSync(MAIL_DIR)) return 0;
|
|
83
92
|
|
|
84
93
|
try {
|
|
85
|
-
|
|
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
|
+
return listEmailFiles().length;
|
|
94
95
|
} catch (e) {
|
|
95
96
|
console.error("Error counting emails:", e.message);
|
|
96
97
|
return 0;
|
|
@@ -162,15 +163,7 @@ export function getRawEmailIds() {
|
|
|
162
163
|
if (!fs.existsSync(MAIL_DIR)) return new Set();
|
|
163
164
|
|
|
164
165
|
try {
|
|
165
|
-
|
|
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);
|
|
166
|
+
return new Set(listEmailFiles());
|
|
174
167
|
} catch (e) {
|
|
175
168
|
console.error("Error getting email IDs:", e.message);
|
|
176
169
|
return new Set();
|
package/lib/validators.js
CHANGED
|
@@ -137,8 +137,8 @@ export function validateMailboxName(mailbox) {
|
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
// Only allow alphanumeric, spaces, hyphens, underscores, and periods
|
|
140
|
-
//
|
|
141
|
-
if (!/^[a-zA-Z0-9
|
|
140
|
+
// Literal space only — \s would also allow newlines/tabs (injection risk)
|
|
141
|
+
if (!/^[a-zA-Z0-9._ -]+$/.test(mailbox)) {
|
|
142
142
|
return null;
|
|
143
143
|
}
|
|
144
144
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "apple-tools-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "MCP server for semantic search across Apple Mail, Messages, and Calendar",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -11,12 +11,12 @@
|
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"repository": {
|
|
13
13
|
"type": "git",
|
|
14
|
-
"url": "git+https://github.com/sfls1397/
|
|
14
|
+
"url": "git+https://github.com/sfls1397/Apple-Tools-MCP.git"
|
|
15
15
|
},
|
|
16
16
|
"bugs": {
|
|
17
|
-
"url": "https://github.com/sfls1397/
|
|
17
|
+
"url": "https://github.com/sfls1397/Apple-Tools-MCP/issues"
|
|
18
18
|
},
|
|
19
|
-
"homepage": "https://github.com/sfls1397/
|
|
19
|
+
"homepage": "https://github.com/sfls1397/Apple-Tools-MCP#readme",
|
|
20
20
|
"keywords": [
|
|
21
21
|
"mcp",
|
|
22
22
|
"model-context-protocol",
|
|
@@ -34,6 +34,12 @@
|
|
|
34
34
|
"engines": {
|
|
35
35
|
"node": ">=18.0.0"
|
|
36
36
|
},
|
|
37
|
+
"os": [
|
|
38
|
+
"darwin"
|
|
39
|
+
],
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
37
43
|
"files": [
|
|
38
44
|
"index.js",
|
|
39
45
|
"indexer.js",
|
|
@@ -46,61 +52,28 @@
|
|
|
46
52
|
],
|
|
47
53
|
"scripts": {
|
|
48
54
|
"start": "node index.js",
|
|
49
|
-
"build-index": "
|
|
50
|
-
"audit": "
|
|
55
|
+
"build-index": "node -e \"import('./indexer.js').then(i=>i.rebuildIndex()).catch(e=>{console.error(e.message);process.exit(1)})\"",
|
|
56
|
+
"audit": "node scripts/audit-index.js",
|
|
51
57
|
"test": "vitest run",
|
|
52
58
|
"test:watch": "vitest",
|
|
53
|
-
"test:coverage": "vitest run --coverage",
|
|
54
59
|
"test:unit": "vitest run tests/unit",
|
|
55
60
|
"test:integration": "vitest run tests/integration",
|
|
56
|
-
"test:
|
|
57
|
-
"test:perf": "vitest run tests/performance",
|
|
58
|
-
"test:fuzz": "vitest run tests/fuzz",
|
|
59
|
-
"test:chaos": "vitest run tests/chaos",
|
|
60
|
-
"test:contract": "vitest run tests/contract",
|
|
61
|
-
"test:concurrency": "vitest run tests/concurrency",
|
|
62
|
-
"test:recovery": "vitest run tests/recovery",
|
|
63
|
-
"test:timezone": "vitest run tests/timezone",
|
|
64
|
-
"test:stress": "vitest run tests/stress",
|
|
65
|
-
"test:snapshot": "vitest run tests/snapshots",
|
|
66
|
-
"test:all": "vitest run",
|
|
61
|
+
"test:coverage": "vitest run --coverage",
|
|
67
62
|
"test:idx": "vitest run tests/indexing",
|
|
68
|
-
"test:idx:unit": "vitest run tests/indexing/unit",
|
|
69
|
-
"test:idx:integration": "vitest run tests/indexing/integration",
|
|
70
63
|
"test:idx:accuracy": "vitest run tests/indexing/accuracy",
|
|
71
|
-
"test:idx:perf": "vitest run tests/indexing/performance",
|
|
72
64
|
"test:idx:resource": "vitest run tests/indexing/resource",
|
|
73
|
-
"test:
|
|
74
|
-
"
|
|
75
|
-
"
|
|
76
|
-
"
|
|
77
|
-
"
|
|
78
|
-
"
|
|
79
|
-
"
|
|
80
|
-
"
|
|
81
|
-
"perf": "
|
|
82
|
-
"perf:
|
|
83
|
-
"perf:
|
|
84
|
-
"perf:
|
|
85
|
-
"perf:search": "vitest run tests/perf/search.perf.test.js --reporter=verbose",
|
|
86
|
-
"perf:tools": "vitest run tests/perf/tools.perf.test.js --reporter=verbose",
|
|
87
|
-
"perf:server": "vitest run tests/perf/mcp-server.perf.test.js --reporter=verbose",
|
|
88
|
-
"perf:embedding": "vitest run tests/perf/embedding.perf.test.js --reporter=verbose",
|
|
89
|
-
"perf:datasources": "vitest run tests/perf/datasources.perf.test.js --reporter=verbose",
|
|
90
|
-
"perf:memory": "vitest run tests/perf/memory.perf.test.js --reporter=verbose",
|
|
91
|
-
"perf:stress": "vitest run tests/perf/stress.perf.test.js --reporter=verbose --testTimeout=120000",
|
|
92
|
-
"perf:mail": "vitest run tests/perf/datasources.perf.test.js -t 'Email' --reporter=verbose",
|
|
93
|
-
"perf:messages": "vitest run tests/perf/datasources.perf.test.js -t 'Messages' --reporter=verbose",
|
|
94
|
-
"perf:calendar": "vitest run tests/perf/datasources.perf.test.js -t 'Calendar' --reporter=verbose",
|
|
95
|
-
"perf:contacts": "vitest run tests/perf/datasources.perf.test.js -t 'Contacts' --reporter=verbose",
|
|
96
|
-
"perf:quick": "vitest run tests/perf/tools.perf.test.js tests/perf/search.perf.test.js --reporter=verbose",
|
|
97
|
-
"perf:negative": "vitest run tests/perf/negative.perf.test.js --reporter=verbose",
|
|
98
|
-
"perf:edge-cases": "vitest run tests/perf/edge-cases.perf.test.js --reporter=verbose",
|
|
99
|
-
"perf:regression": "vitest run tests/perf/regression.perf.test.js --reporter=verbose",
|
|
100
|
-
"perf:lancedb": "vitest run tests/perf/lancedb.perf.test.js --reporter=verbose",
|
|
101
|
-
"perf:background": "vitest run tests/perf/background-indexing.perf.test.js --reporter=verbose",
|
|
102
|
-
"perf:dates": "vitest run tests/perf/date-parsing.perf.test.js --reporter=verbose",
|
|
103
|
-
"perf:full": "vitest run tests/perf --reporter=verbose --testTimeout=120000"
|
|
65
|
+
"test:security": "vitest run tests/indexing/security",
|
|
66
|
+
"perf": "vitest run tests/perf",
|
|
67
|
+
"perf:quick": "vitest run tests/perf/tools.perf.test.js",
|
|
68
|
+
"perf:embedding": "vitest run tests/perf/embedding.perf.test.js",
|
|
69
|
+
"perf:stress": "vitest run tests/perf/stress.perf.test.js",
|
|
70
|
+
"perf:indexing": "vitest run tests/perf/indexing.perf.test.js",
|
|
71
|
+
"perf:search": "vitest run tests/perf/search.perf.test.js",
|
|
72
|
+
"perf:negative": "vitest run tests/perf/negative.perf.test.js",
|
|
73
|
+
"perf:regression": "vitest run tests/perf/regression.perf.test.js",
|
|
74
|
+
"perf:lancedb": "vitest run tests/perf/lancedb.perf.test.js",
|
|
75
|
+
"perf:background": "vitest run tests/perf/background-indexing.perf.test.js",
|
|
76
|
+
"perf:dates": "vitest run tests/perf/date-parsing.perf.test.js"
|
|
104
77
|
},
|
|
105
78
|
"dependencies": {
|
|
106
79
|
"@lancedb/lancedb": "^0.22.3",
|
|
@@ -109,8 +82,8 @@
|
|
|
109
82
|
"chrono-node": "^2.9.0"
|
|
110
83
|
},
|
|
111
84
|
"devDependencies": {
|
|
112
|
-
"
|
|
113
|
-
"
|
|
114
|
-
"
|
|
85
|
+
"vitest": "^2.1.0",
|
|
86
|
+
"@vitest/coverage-v8": "^2.1.0",
|
|
87
|
+
"fast-check": "^3.22.0"
|
|
115
88
|
}
|
|
116
89
|
}
|
package/search.js
CHANGED
|
@@ -2,7 +2,7 @@ import * as lancedb from "@lancedb/lancedb";
|
|
|
2
2
|
import * as chrono from "chrono-node";
|
|
3
3
|
import { safeOsascript } from "./lib/shell.js";
|
|
4
4
|
import { safeMatch, validateSearchQuery } from "./lib/validators.js";
|
|
5
|
-
import { embed, INDEX_DIR, getRecentEmails, getEmailsByDateRange, getRecentMessages, getConversation,
|
|
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 = {};
|
|
@@ -826,7 +826,7 @@ export async function getRecentEmailResults(limit = 30, daysBack = 7, unreadOnly
|
|
|
826
826
|
const subject = (r.subject || "").trim().toLowerCase();
|
|
827
827
|
// Index from field has two formats:
|
|
828
828
|
// 1. "Coinbase via Cloaked (Coinbase)" - just display name
|
|
829
|
-
// 2. "
|
|
829
|
+
// 2. "Jane Smith via Cloaked (Company)" <email@domain.com> - display name + email
|
|
830
830
|
// Extract just the display name from both formats
|
|
831
831
|
const fromRaw = (r.from || "").trim().toLowerCase();
|
|
832
832
|
const fromName = fromRaw.replace(/<[^>]+>$/, "").trim().replace(/^"|"$/g, "");
|
|
@@ -1319,28 +1319,40 @@ export async function searchCalendar(query, options = {}) {
|
|
|
1319
1319
|
}
|
|
1320
1320
|
}
|
|
1321
1321
|
|
|
1322
|
-
//
|
|
1322
|
+
// Local midnight of parsed date through next local midnight (handles DST; not +24h)
|
|
1323
|
+
export function getLocalDayBounds(dateStr) {
|
|
1324
|
+
const start = parseNaturalDate(dateStr);
|
|
1325
|
+
if (start == null) return null;
|
|
1326
|
+
const endDate = new Date(start);
|
|
1327
|
+
endDate.setDate(endDate.getDate() + 1);
|
|
1328
|
+
return { start, end: endDate.getTime() };
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
// Get events on a specific date from live Calendar.sqlitedb (not the vector index)
|
|
1323
1332
|
export async function getCalendarDateResults(dateStr) {
|
|
1324
1333
|
try {
|
|
1325
|
-
const range =
|
|
1334
|
+
const range = getLocalDayBounds(dateStr);
|
|
1326
1335
|
if (!range) {
|
|
1327
1336
|
return { success: false, error: `Could not parse date: ${dateStr}` };
|
|
1328
1337
|
}
|
|
1329
1338
|
|
|
1330
1339
|
console.error(`[Calendar Date] Query for "${dateStr}"`);
|
|
1331
|
-
console.error(`[Calendar Date] Range: ${new Date(range.start).
|
|
1340
|
+
console.error(`[Calendar Date] Range: ${new Date(range.start).toString()} to ${new Date(range.end).toString()}`);
|
|
1332
1341
|
|
|
1333
|
-
const
|
|
1342
|
+
const { events, error } = getEventsOnDate(range.start, range.end);
|
|
1343
|
+
if (error) {
|
|
1344
|
+
return { success: false, error: `Error getting calendar events: ${error}` };
|
|
1345
|
+
}
|
|
1334
1346
|
|
|
1335
|
-
const formattedResults =
|
|
1347
|
+
const formattedResults = events.map((row, idx) => ({
|
|
1336
1348
|
index: idx + 1,
|
|
1337
1349
|
title: row.title || "No title",
|
|
1338
|
-
start: formatLocalDate(row.start) || "Unknown",
|
|
1339
|
-
startTimestamp: row.
|
|
1340
|
-
end: formatLocalDate(row.end) || "Unknown",
|
|
1350
|
+
start: formatLocalDate(row.start) || row.start || "Unknown",
|
|
1351
|
+
startTimestamp: row.startMac != null ? (Number(row.startMac) + 978307200) * 1000 : null,
|
|
1352
|
+
end: formatLocalDate(row.end) || row.end || "Unknown",
|
|
1341
1353
|
calendar: row.calendar || "Unknown",
|
|
1342
1354
|
location: row.location || "",
|
|
1343
|
-
isAllDay: row.isAllDay
|
|
1355
|
+
isAllDay: !!row.isAllDay
|
|
1344
1356
|
}));
|
|
1345
1357
|
|
|
1346
1358
|
const dateLabel = new Date(range.start).toLocaleDateString("en-US", {
|
|
@@ -1360,6 +1372,24 @@ export async function getCalendarDateResults(dateStr) {
|
|
|
1360
1372
|
}
|
|
1361
1373
|
}
|
|
1362
1374
|
|
|
1375
|
+
// Clamp a timed event to [dayStartMs, dayEndMs) and return minutes from local midnight.
|
|
1376
|
+
// Overnight events (23:00–01:00) contribute only the slice that falls on this day.
|
|
1377
|
+
export function clampBusyToLocalDay(evtStartMs, evtEndMs, dayStartMs, dayEndMs) {
|
|
1378
|
+
const clampedStart = Math.max(evtStartMs, dayStartMs);
|
|
1379
|
+
const clampedEnd = Math.min(evtEndMs, dayEndMs);
|
|
1380
|
+
if (!(clampedEnd > clampedStart)) return null;
|
|
1381
|
+
|
|
1382
|
+
const startMinutes = clampedStart <= dayStartMs
|
|
1383
|
+
? 0
|
|
1384
|
+
: new Date(clampedStart).getHours() * 60 + new Date(clampedStart).getMinutes();
|
|
1385
|
+
const endMinutes = clampedEnd >= dayEndMs
|
|
1386
|
+
? 24 * 60
|
|
1387
|
+
: new Date(clampedEnd).getHours() * 60 + new Date(clampedEnd).getMinutes();
|
|
1388
|
+
|
|
1389
|
+
if (!(endMinutes > startMinutes)) return null;
|
|
1390
|
+
return { startMinutes, endMinutes };
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1363
1393
|
// Calculate free time slots on a specific date
|
|
1364
1394
|
export async function calculateFreeTime(dateStr, options = {}) {
|
|
1365
1395
|
const {
|
|
@@ -1369,12 +1399,17 @@ export async function calculateFreeTime(dateStr, options = {}) {
|
|
|
1369
1399
|
} = options;
|
|
1370
1400
|
|
|
1371
1401
|
try {
|
|
1372
|
-
const range =
|
|
1402
|
+
const range = getLocalDayBounds(dateStr);
|
|
1373
1403
|
if (!range) {
|
|
1374
1404
|
return { success: false, error: `Could not parse date: ${dateStr}` };
|
|
1375
1405
|
}
|
|
1376
1406
|
|
|
1377
|
-
|
|
1407
|
+
const { events: liveEvents, error } = getEventsOnDate(range.start, range.end);
|
|
1408
|
+
if (error) {
|
|
1409
|
+
return { success: false, error: `Error calculating free time: ${error}` };
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
let events = liveEvents;
|
|
1378
1413
|
|
|
1379
1414
|
// Filter by calendar if specified
|
|
1380
1415
|
if (calendarName) {
|
|
@@ -1390,16 +1425,21 @@ export async function calculateFreeTime(dateStr, options = {}) {
|
|
|
1390
1425
|
continue;
|
|
1391
1426
|
}
|
|
1392
1427
|
|
|
1393
|
-
const evtStart =
|
|
1394
|
-
|
|
1428
|
+
const evtStart = evt.startMac != null
|
|
1429
|
+
? (Number(evt.startMac) + 978307200) * 1000
|
|
1430
|
+
: (evt.startTimestamp || parseDate(evt.start));
|
|
1431
|
+
const evtEnd = evt.endMac != null
|
|
1432
|
+
? (Number(evt.endMac) + 978307200) * 1000
|
|
1433
|
+
: (evt.end ? parseDate(evt.end) : evtStart + (60 * 60 * 1000));
|
|
1395
1434
|
|
|
1396
|
-
const
|
|
1397
|
-
|
|
1435
|
+
const clamped = clampBusyToLocalDay(evtStart, evtEnd, range.start, range.end);
|
|
1436
|
+
if (!clamped) continue;
|
|
1398
1437
|
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1438
|
+
const start = Math.max(clamped.startMinutes, startHour * 60);
|
|
1439
|
+
const end = Math.min(clamped.endMinutes, endHour * 60);
|
|
1440
|
+
if (end > start) {
|
|
1441
|
+
busyPeriods.push({ start, end });
|
|
1442
|
+
}
|
|
1403
1443
|
}
|
|
1404
1444
|
|
|
1405
1445
|
// Sort busy periods
|