life-pulse 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/agent.d.ts +11 -0
  2. package/dist/agent.js +435 -0
  3. package/dist/analyze.d.ts +28 -0
  4. package/dist/analyze.js +130 -0
  5. package/dist/auq.d.ts +15 -0
  6. package/dist/auq.js +61 -0
  7. package/dist/cli.d.ts +2 -0
  8. package/dist/cli.js +333 -0
  9. package/dist/collectors/apps.d.ts +5 -0
  10. package/dist/collectors/apps.js +59 -0
  11. package/dist/collectors/calendar.d.ts +2 -0
  12. package/dist/collectors/calendar.js +115 -0
  13. package/dist/collectors/calls.d.ts +2 -0
  14. package/dist/collectors/calls.js +52 -0
  15. package/dist/collectors/chrome.d.ts +2 -0
  16. package/dist/collectors/chrome.js +49 -0
  17. package/dist/collectors/findmy.d.ts +2 -0
  18. package/dist/collectors/findmy.js +67 -0
  19. package/dist/collectors/imessage.d.ts +2 -0
  20. package/dist/collectors/imessage.js +125 -0
  21. package/dist/collectors/mail.d.ts +2 -0
  22. package/dist/collectors/mail.js +49 -0
  23. package/dist/collectors/notes.d.ts +2 -0
  24. package/dist/collectors/notes.js +42 -0
  25. package/dist/collectors/notifications.d.ts +2 -0
  26. package/dist/collectors/notifications.js +37 -0
  27. package/dist/collectors/recent-files.d.ts +2 -0
  28. package/dist/collectors/recent-files.js +46 -0
  29. package/dist/collectors/safari.d.ts +2 -0
  30. package/dist/collectors/safari.js +85 -0
  31. package/dist/collectors/screen-time.d.ts +2 -0
  32. package/dist/collectors/screen-time.js +72 -0
  33. package/dist/collectors/shell-history.d.ts +2 -0
  34. package/dist/collectors/shell-history.js +44 -0
  35. package/dist/contacts.d.ts +7 -0
  36. package/dist/contacts.js +88 -0
  37. package/dist/db.d.ts +9 -0
  38. package/dist/db.js +50 -0
  39. package/dist/index.d.ts +9 -0
  40. package/dist/index.js +42 -0
  41. package/dist/profile.d.ts +18 -0
  42. package/dist/profile.js +88 -0
  43. package/dist/progress.d.ts +40 -0
  44. package/dist/progress.js +204 -0
  45. package/dist/state.d.ts +18 -0
  46. package/dist/state.js +101 -0
  47. package/dist/todo.d.ts +21 -0
  48. package/dist/todo.js +133 -0
  49. package/dist/tools.d.ts +22 -0
  50. package/dist/tools.js +1037 -0
  51. package/dist/types.d.ts +30 -0
  52. package/dist/types.js +2 -0
  53. package/package.json +38 -0
package/dist/tools.js ADDED
@@ -0,0 +1,1037 @@
1
+ /**
2
+ * Tool definitions for the life-pulse agent.
3
+ * Each tool is a focused query the agent can call to investigate specific areas.
4
+ */
5
+ import { homedir } from 'os';
6
+ import { join } from 'path';
7
+ import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
8
+ import { execSync } from 'child_process';
9
+ import { openDb, safeQuery } from './db.js';
10
+ import { resolveName, buildContactMap } from './contacts.js';
11
+ import { getUserName } from './profile.js';
12
+ import dayjs from 'dayjs';
13
+ const APPLE_EPOCH = 978307200;
14
+ const CHROME_EPOCH = 11644473600;
15
+ const home = homedir();
16
+ // ─── Helpers ─────────────────────────────────────────────────────
17
+ function daysAgoApple(days) {
18
+ return dayjs().subtract(days, 'day').unix() - APPLE_EPOCH;
19
+ }
20
+ function daysAgoUnix(days) {
21
+ return dayjs().subtract(days, 'day').unix();
22
+ }
23
+ function appleToHuman(appleTs) {
24
+ return dayjs.unix(appleTs + APPLE_EPOCH).format('ddd MMM D h:mm A');
25
+ }
26
+ function limit(arr, n) { return arr.slice(0, n); }
27
+ /** Filter out iMessage tapback reactions — "Loved", "Liked", "Laughed at", "Emphasized", "Disliked", "Questioned" */
28
+ const TAPBACK_RE = /^(Loved|Liked|Laughed at|Disliked|Emphasized|Questioned) (".*"|".*"|an? .+)$/;
29
+ function isReaction(text) {
30
+ if (!text)
31
+ return true;
32
+ return TAPBACK_RE.test(text.trim());
33
+ }
34
+ // ─── Tools ───────────────────────────────────────────────────────
35
+ export const TOOLS = [
36
+ // 1. Overview scan
37
+ {
38
+ name: 'scan_sources',
39
+ description: 'Quick scan of all available data sources. Returns what data exists and rough counts. Call this FIRST to understand what you have to work with.',
40
+ parameters: { type: 'object', properties: {}, required: [] },
41
+ async execute() {
42
+ const results = {};
43
+ // iMessage
44
+ const msgDb = openDb(join(home, 'Library/Messages/chat.db'));
45
+ if (msgDb) {
46
+ const dayAgoNano = (BigInt(daysAgoUnix(1) - APPLE_EPOCH) * BigInt(1e9)).toString();
47
+ const weekAgoNano = (BigInt(daysAgoUnix(7) - APPLE_EPOCH) * BigInt(1e9)).toString();
48
+ const last24h = safeQuery(msgDb, 'SELECT COUNT(*) as c FROM message WHERE date > ?', [dayAgoNano]);
49
+ const last7d = safeQuery(msgDb, 'SELECT COUNT(*) as c FROM message WHERE date > ?', [weekAgoNano]);
50
+ results.iMessage = { available: true, last24h: last24h[0]?.c, last7d: last7d[0]?.c };
51
+ msgDb.close();
52
+ }
53
+ // Calls
54
+ const callDb = openDb(join(home, 'Library/Application Support/CallHistoryDB/CallHistory.storedata'));
55
+ if (callDb) {
56
+ const missed = safeQuery(callDb, `SELECT COUNT(*) as c FROM ZCALLRECORD WHERE ZCALLTYPE = 3 AND ZDATE > ?`, [daysAgoApple(7)]);
57
+ results.calls = { available: true, missedLast7d: missed[0]?.c };
58
+ callDb.close();
59
+ }
60
+ // Screen time
61
+ const stDb = openDb(join(home, 'Library/Application Support/Knowledge/knowledgeC.db'));
62
+ if (stDb) {
63
+ results.screenTime = { available: true };
64
+ stDb.close();
65
+ }
66
+ // Safari
67
+ const safDb = openDb(join(home, 'Library/Safari/History.db'));
68
+ if (safDb) {
69
+ const cnt = safeQuery(safDb, `SELECT COUNT(*) as c FROM history_visits WHERE visit_time > ?`, [daysAgoApple(7)]);
70
+ results.safari = { available: true, visitsLast7d: cnt[0]?.c };
71
+ safDb.close();
72
+ }
73
+ // Mail
74
+ for (const v of ['V10', 'V9', 'V8']) {
75
+ const p = join(home, `Library/Mail/${v}/MailData/Envelope Index`);
76
+ if (existsSync(p)) {
77
+ const db = openDb(p);
78
+ if (db) {
79
+ const unread = safeQuery(db, 'SELECT COUNT(*) as c FROM messages WHERE read = 0');
80
+ const flagged = safeQuery(db, 'SELECT COUNT(*) as c FROM messages WHERE flagged = 1');
81
+ results.mail = { available: true, unread: unread[0]?.c, flagged: flagged[0]?.c };
82
+ db.close();
83
+ }
84
+ break;
85
+ }
86
+ }
87
+ // Calendar
88
+ const calDb = join(home, 'Library/Calendars/Calendar.sqlitedb');
89
+ results.calendar = { available: existsSync(calDb) };
90
+ // Git projects
91
+ try {
92
+ const projects = readdirSync(join(home, 'Projects')).filter(f => {
93
+ try {
94
+ return existsSync(join(home, 'Projects', f, '.git'));
95
+ }
96
+ catch {
97
+ return false;
98
+ }
99
+ });
100
+ results.gitProjects = { available: true, repos: projects };
101
+ }
102
+ catch {
103
+ results.gitProjects = { available: false };
104
+ }
105
+ // ChatGPT history
106
+ const chatgptPaths = [
107
+ join(home, 'Library/Application Support/com.openai.chat'),
108
+ join(home, 'Library/Group Containers/group.com.openai.chat'),
109
+ ];
110
+ for (const p of chatgptPaths) {
111
+ if (existsSync(p)) {
112
+ results.chatgpt = { available: true, path: p };
113
+ break;
114
+ }
115
+ }
116
+ if (!results.chatgpt)
117
+ results.chatgpt = { available: false };
118
+ // Claude CLI history
119
+ const claudeDir = join(home, '.claude');
120
+ results.claudeCLI = { available: existsSync(claudeDir) };
121
+ // Notes
122
+ const notesDb = join(home, 'Library/Containers/com.apple.Notes/Data/Library/Notes/NotesV7.storedata');
123
+ results.notes = { available: existsSync(notesDb) };
124
+ results.timestamp = dayjs().format('YYYY-MM-DD HH:mm:ss');
125
+ return JSON.stringify(results, null, 1);
126
+ }
127
+ },
128
+ // 2. iMessage queries
129
+ {
130
+ name: 'get_messages',
131
+ description: 'Search iMessage conversations. Can filter by contact name, time range, or keyword. Returns messages with sender names resolved.',
132
+ parameters: {
133
+ type: 'object',
134
+ properties: {
135
+ contact: { type: 'string', description: 'Filter by contact name (partial match). Leave empty for all.' },
136
+ days: { type: 'number', description: 'How many days back to look. Default 1.' },
137
+ keyword: { type: 'string', description: 'Search for keyword in message text.' },
138
+ limit: { type: 'number', description: 'Max messages to return. Default 50.' },
139
+ },
140
+ required: []
141
+ },
142
+ async execute(params) {
143
+ const db = openDb(join(home, 'Library/Messages/chat.db'));
144
+ if (!db)
145
+ return JSON.stringify({ error: 'iMessage DB not available' });
146
+ const days = params.days || 1;
147
+ const maxResults = params.limit || 50;
148
+ const contactFilter = params.contact;
149
+ const keyword = params.keyword;
150
+ const handles = safeQuery(db, 'SELECT ROWID, id FROM handle');
151
+ const handleMap = new Map(handles.map(h => [h.ROWID, h.id]));
152
+ const agoNano = (BigInt(daysAgoUnix(days) - APPLE_EPOCH) * BigInt(1e9)).toString();
153
+ let sql = `SELECT text, date, is_from_me, handle_id FROM message WHERE date > ?`;
154
+ const sqlParams = [agoNano];
155
+ if (keyword) {
156
+ sql += ` AND text LIKE ?`;
157
+ sqlParams.push(`%${keyword}%`);
158
+ }
159
+ sql += ` ORDER BY date DESC LIMIT ${maxResults * 3}`;
160
+ const msgs = safeQuery(db, sql, sqlParams);
161
+ const results = [];
162
+ for (const m of msgs) {
163
+ if (!m.text || isReaction(m.text))
164
+ continue;
165
+ const handle = handleMap.get(m.handle_id) || '';
166
+ const name = resolveName(handle);
167
+ if (contactFilter && !name.toLowerCase().includes(contactFilter.toLowerCase()))
168
+ continue;
169
+ const dateSeconds = Number(BigInt(m.date) / BigInt(1e9)) + APPLE_EPOCH;
170
+ results.push({
171
+ from: m.is_from_me ? getUserName() : name,
172
+ text: m.text.slice(0, 200),
173
+ when: dayjs.unix(dateSeconds).format('ddd MMM D h:mm A'),
174
+ direction: m.is_from_me ? 'sent' : 'received',
175
+ });
176
+ if (results.length >= maxResults)
177
+ break;
178
+ }
179
+ db.close();
180
+ return JSON.stringify({ count: results.length, messages: results }, null, 1);
181
+ }
182
+ },
183
+ // 3. Unanswered messages
184
+ {
185
+ name: 'get_unanswered_messages',
186
+ description: 'Find messages from other people that the user has NOT replied to. These are conversations where the last message is from someone else.',
187
+ parameters: {
188
+ type: 'object',
189
+ properties: {
190
+ days: { type: 'number', description: 'How many days back. Default 3.' },
191
+ },
192
+ required: []
193
+ },
194
+ async execute(params) {
195
+ const db = openDb(join(home, 'Library/Messages/chat.db'));
196
+ if (!db)
197
+ return JSON.stringify({ error: 'not available' });
198
+ const days = params.days || 3;
199
+ const handles = safeQuery(db, 'SELECT ROWID, id FROM handle');
200
+ const handleMap = new Map(handles.map(h => [h.ROWID, h.id]));
201
+ const agoNano = (BigInt(daysAgoUnix(days) - APPLE_EPOCH) * BigInt(1e9)).toString();
202
+ const msgs = safeQuery(db, `SELECT text, date, is_from_me, handle_id FROM message WHERE date > ? ORDER BY date DESC`, [agoNano]);
203
+ // Group by handle, find where last msg is from them
204
+ const lastByHandle = new Map();
205
+ for (const m of msgs) {
206
+ if (!lastByHandle.has(m.handle_id))
207
+ lastByHandle.set(m.handle_id, m);
208
+ }
209
+ const unanswered = [];
210
+ for (const [handleId, m] of lastByHandle) {
211
+ if (m.is_from_me || !m.text || isReaction(m.text))
212
+ continue;
213
+ const handle = handleMap.get(handleId) || '';
214
+ const name = resolveName(handle);
215
+ if (name === 'Unknown' || name === 'unknown')
216
+ continue;
217
+ const dateSeconds = Number(BigInt(m.date) / BigInt(1e9)) + APPLE_EPOCH;
218
+ unanswered.push({
219
+ from: name,
220
+ text: m.text.slice(0, 150),
221
+ when: dayjs.unix(dateSeconds).format('ddd MMM D h:mm A'),
222
+ });
223
+ }
224
+ unanswered.sort((a, b) => dayjs(b.when, 'ddd MMM D h:mm A').unix() - dayjs(a.when, 'ddd MMM D h:mm A').unix());
225
+ db.close();
226
+ return JSON.stringify({ unanswered: unanswered.slice(0, 20) }, null, 1);
227
+ }
228
+ },
229
+ // 4. Call history
230
+ {
231
+ name: 'get_calls',
232
+ description: 'Get recent call history. Shows missed, incoming, and outgoing calls with names.',
233
+ parameters: {
234
+ type: 'object',
235
+ properties: {
236
+ days: { type: 'number', description: 'Days back. Default 7.' },
237
+ missed_only: { type: 'boolean', description: 'Only show missed calls not returned.' },
238
+ },
239
+ required: []
240
+ },
241
+ async execute(params) {
242
+ const db = openDb(join(home, 'Library/Application Support/CallHistoryDB/CallHistory.storedata'));
243
+ if (!db)
244
+ return JSON.stringify({ error: 'not available' });
245
+ const days = params.days || 7;
246
+ const calls = safeQuery(db, `SELECT ZADDRESS as address, ZDURATION as duration, ZDATE as date, ZCALLTYPE as call_type
247
+ FROM ZCALLRECORD WHERE ZDATE > ? ORDER BY ZDATE DESC`, [daysAgoApple(days)]);
248
+ const missed = calls.filter(c => c.call_type === 3);
249
+ const outgoing = calls.filter(c => c.call_type === 2);
250
+ if (params.missed_only) {
251
+ const missedNotReturned = missed
252
+ .filter(m => !outgoing.some(o => o.address === m.address))
253
+ .map(m => ({ name: resolveName(m.address), when: appleToHuman(m.date) }));
254
+ db.close();
255
+ return JSON.stringify({ missedNotReturned }, null, 1);
256
+ }
257
+ const recent = calls.slice(0, 30).map(c => ({
258
+ name: resolveName(c.address),
259
+ type: c.call_type === 1 ? 'incoming' : c.call_type === 2 ? 'outgoing' : 'missed',
260
+ duration: `${Math.round(c.duration / 60)}min`,
261
+ when: appleToHuman(c.date),
262
+ }));
263
+ db.close();
264
+ return JSON.stringify({ total: calls.length, recent }, null, 1);
265
+ }
266
+ },
267
+ // 5. Screen time
268
+ {
269
+ name: 'get_screen_time',
270
+ description: 'Get app usage / screen time data. Shows which apps were used and for how long.',
271
+ parameters: {
272
+ type: 'object',
273
+ properties: {
274
+ days: { type: 'number', description: 'Days back. Default 1.' },
275
+ app: { type: 'string', description: 'Filter by app name (partial match).' },
276
+ },
277
+ required: []
278
+ },
279
+ async execute(params) {
280
+ const db = openDb(join(home, 'Library/Application Support/Knowledge/knowledgeC.db'));
281
+ if (!db)
282
+ return JSON.stringify({ error: 'not available' });
283
+ const days = params.days || 1;
284
+ const appFilter = params.app;
285
+ let sql = `SELECT ZVALUESTRING as app, (ZENDDATE - ZSTARTDATE) as duration,
286
+ datetime(ZSTARTDATE + ${APPLE_EPOCH}, 'unixepoch', 'localtime') as start_time
287
+ FROM ZOBJECT WHERE ZSTREAMNAME = '/app/usage'
288
+ AND ZSTARTDATE > ? AND ZVALUESTRING IS NOT NULL`;
289
+ const sqlParams = [daysAgoApple(days)];
290
+ if (appFilter) {
291
+ sql += ` AND ZVALUESTRING LIKE ?`;
292
+ sqlParams.push(`%${appFilter}%`);
293
+ }
294
+ sql += ` ORDER BY ZSTARTDATE DESC`;
295
+ const usage = safeQuery(db, sql, sqlParams);
296
+ const appTotals = new Map();
297
+ const hourly = new Array(24).fill(0);
298
+ for (const row of usage) {
299
+ if (!row.app || row.duration <= 0 || row.duration > 86400)
300
+ continue;
301
+ appTotals.set(row.app, (appTotals.get(row.app) || 0) + row.duration);
302
+ if (row.start_time) {
303
+ const hour = parseInt(row.start_time.split(' ')[1]?.split(':')[0] || '0');
304
+ hourly[hour] += row.duration;
305
+ }
306
+ }
307
+ const ranked = [...appTotals.entries()].sort((a, b) => b[1] - a[1]).slice(0, 20)
308
+ .map(([bundle, secs]) => ({
309
+ app: bundle.replace(/^com\./, '').replace(/\./g, ' '),
310
+ bundle,
311
+ hours: +(secs / 3600).toFixed(1),
312
+ }));
313
+ const lateNight = [23, 0, 1, 2, 3, 4].reduce((s, h) => s + hourly[h], 0) / 3600;
314
+ const peakHour = hourly.indexOf(Math.max(...hourly));
315
+ db.close();
316
+ return JSON.stringify({ totalHours: +(ranked.reduce((s, r) => s + r.hours, 0)).toFixed(1), topApps: ranked, lateNightHours: +lateNight.toFixed(1), peakHour }, null, 1);
317
+ }
318
+ },
319
+ // 6. Browser history + searches
320
+ {
321
+ name: 'get_browsing',
322
+ description: 'Get Safari/Chrome browsing history, search queries, and top domains.',
323
+ parameters: {
324
+ type: 'object',
325
+ properties: {
326
+ days: { type: 'number', description: 'Days back. Default 3.' },
327
+ searches_only: { type: 'boolean', description: 'Only return search queries.' },
328
+ domain: { type: 'string', description: 'Filter by domain.' },
329
+ },
330
+ required: []
331
+ },
332
+ async execute(params) {
333
+ const days = params.days || 3;
334
+ const results = {};
335
+ // Safari
336
+ const safDb = openDb(join(home, 'Library/Safari/History.db'));
337
+ if (safDb) {
338
+ const history = safeQuery(safDb, `SELECT hi.url, hv.title, datetime(hv.visit_time + ${APPLE_EPOCH}, 'unixepoch', 'localtime') as visited
339
+ FROM history_visits hv JOIN history_items hi ON hv.history_item = hi.id
340
+ WHERE hv.visit_time > ? ORDER BY hv.visit_time DESC LIMIT 2000`, [daysAgoApple(days)]);
341
+ // Searches
342
+ const searches = [];
343
+ const seen = new Set();
344
+ for (const row of history) {
345
+ try {
346
+ const u = new URL(row.url);
347
+ const q = u.searchParams.get('q') || u.searchParams.get('query') || u.searchParams.get('search_query');
348
+ if (q) {
349
+ const k = q.toLowerCase();
350
+ if (!seen.has(k)) {
351
+ seen.add(k);
352
+ searches.push({ query: q, when: row.visited });
353
+ }
354
+ }
355
+ }
356
+ catch { }
357
+ }
358
+ if (params.searches_only) {
359
+ safDb.close();
360
+ return JSON.stringify({ searches: searches.slice(0, 50) }, null, 1);
361
+ }
362
+ // Domains
363
+ const domains = new Map();
364
+ for (const r of history) {
365
+ try {
366
+ let d = new URL(r.url).hostname.replace('www.', '');
367
+ if (params.domain && !d.includes(params.domain))
368
+ continue;
369
+ domains.set(d, (domains.get(d) || 0) + 1);
370
+ }
371
+ catch { }
372
+ }
373
+ // Interesting pages
374
+ const pages = history.filter(h => h.title && h.title.length > 10 && !h.title.startsWith('Google'))
375
+ .slice(0, 20).map(h => ({ title: h.title, domain: new URL(h.url).hostname, when: h.visited }));
376
+ results.safari = {
377
+ totalVisits: history.length,
378
+ topDomains: [...domains.entries()].sort((a, b) => b[1] - a[1]).slice(0, 20).map(([d, c]) => ({ domain: d, visits: c })),
379
+ searches: searches.slice(0, 30),
380
+ recentPages: pages,
381
+ };
382
+ safDb.close();
383
+ }
384
+ // Chrome
385
+ const chromeBase = join(home, 'Library/Application Support/Google/Chrome');
386
+ for (const profile of ['Default', 'Profile 1']) {
387
+ const p = join(chromeBase, profile, 'History');
388
+ if (existsSync(p)) {
389
+ const db = openDb(p);
390
+ if (db) {
391
+ const agoChrome = (daysAgoUnix(days) + CHROME_EPOCH) * 1e6;
392
+ const history = safeQuery(db, `SELECT title, url FROM urls WHERE last_visit_time > ? ORDER BY last_visit_time DESC LIMIT 500`, [agoChrome]);
393
+ const domains = new Map();
394
+ for (const r of history) {
395
+ try {
396
+ const d = new URL(r.url).hostname.replace('www.', '');
397
+ domains.set(d, (domains.get(d) || 0) + 1);
398
+ }
399
+ catch { }
400
+ }
401
+ results.chrome = {
402
+ totalVisits: history.length,
403
+ topDomains: [...domains.entries()].sort((a, b) => b[1] - a[1]).slice(0, 15).map(([d, c]) => ({ domain: d, visits: c })),
404
+ };
405
+ db.close();
406
+ }
407
+ break;
408
+ }
409
+ }
410
+ return JSON.stringify(results, null, 1);
411
+ }
412
+ },
413
+ // 7. Email
414
+ {
415
+ name: 'get_email_summary',
416
+ description: 'Get email summary — unread count, flagged, top senders.',
417
+ parameters: { type: 'object', properties: {}, required: [] },
418
+ async execute() {
419
+ for (const v of ['V10', 'V9', 'V8']) {
420
+ const p = join(home, `Library/Mail/${v}/MailData/Envelope Index`);
421
+ if (!existsSync(p))
422
+ continue;
423
+ const db = openDb(p);
424
+ if (!db)
425
+ continue;
426
+ const unread = safeQuery(db, 'SELECT COUNT(*) as c FROM messages WHERE read = 0');
427
+ const flagged = safeQuery(db, 'SELECT COUNT(*) as c FROM messages WHERE flagged = 1');
428
+ const topSenders = safeQuery(db, `SELECT a.address as sender, COUNT(*) as cnt FROM messages m
429
+ JOIN addresses a ON m.sender = a.ROWID
430
+ WHERE m.read = 0 GROUP BY a.address ORDER BY cnt DESC LIMIT 15`);
431
+ db.close();
432
+ return JSON.stringify({
433
+ unread: unread[0]?.c, flagged: flagged[0]?.c,
434
+ topUnreadSenders: topSenders.map(s => ({ sender: s.sender, count: s.cnt })),
435
+ }, null, 1);
436
+ }
437
+ return JSON.stringify({ error: 'Mail DB not found' });
438
+ }
439
+ },
440
+ // 8. Git activity
441
+ {
442
+ name: 'get_git_activity',
443
+ description: 'Get recent git commits across all projects in ~/Projects. Shows what code work has been happening.',
444
+ parameters: {
445
+ type: 'object',
446
+ properties: {
447
+ days: { type: 'number', description: 'Days back. Default 7.' },
448
+ repo: { type: 'string', description: 'Filter to specific repo name.' },
449
+ },
450
+ required: []
451
+ },
452
+ async execute(params) {
453
+ const days = params.days || 7;
454
+ const repoFilter = params.repo;
455
+ const projectsDir = join(home, 'Projects');
456
+ if (!existsSync(projectsDir))
457
+ return JSON.stringify({ error: 'No ~/Projects dir' });
458
+ const repos = readdirSync(projectsDir).filter(f => {
459
+ if (repoFilter && f !== repoFilter)
460
+ return false;
461
+ try {
462
+ return existsSync(join(projectsDir, f, '.git'));
463
+ }
464
+ catch {
465
+ return false;
466
+ }
467
+ });
468
+ const allCommits = [];
469
+ for (const repo of repos) {
470
+ try {
471
+ const log = execSync(`git -C "${join(projectsDir, repo)}" log --since="${days} days ago" --pretty=format:"%s|||%ai|||%an" --no-merges 2>/dev/null`, { encoding: 'utf-8', timeout: 5000 }).trim();
472
+ if (!log)
473
+ continue;
474
+ for (const line of log.split('\n')) {
475
+ const [message, date, author] = line.split('|||');
476
+ if (message)
477
+ allCommits.push({ repo, message, date, author });
478
+ }
479
+ }
480
+ catch { }
481
+ }
482
+ // Group by repo
483
+ const byRepo = new Map();
484
+ for (const c of allCommits) {
485
+ const list = byRepo.get(c.repo) || [];
486
+ list.push({ message: c.message, date: c.date });
487
+ byRepo.set(c.repo, list);
488
+ }
489
+ const summary = [...byRepo.entries()].map(([repo, commits]) => ({
490
+ repo, commitCount: commits.length, recentCommits: commits.slice(0, 5),
491
+ }));
492
+ return JSON.stringify({ totalCommits: allCommits.length, repos: summary }, null, 1);
493
+ }
494
+ },
495
+ // 9. Recent files
496
+ {
497
+ name: 'get_recent_files',
498
+ description: 'List recent files in Downloads and Desktop folders.',
499
+ parameters: {
500
+ type: 'object',
501
+ properties: {
502
+ folder: { type: 'string', description: 'Which folder: "downloads", "desktop", or "both". Default "both".' },
503
+ },
504
+ required: []
505
+ },
506
+ async execute(params) {
507
+ const folder = params.folder || 'both';
508
+ const results = {};
509
+ const scan = (dir) => {
510
+ try {
511
+ const files = execSync(`ls -t "${dir}" | head -20`, { encoding: 'utf-8', timeout: 5000 }).trim().split('\n').filter(Boolean);
512
+ return files.map(f => {
513
+ try {
514
+ const stat = statSync(join(dir, f));
515
+ return { name: f, age: `${dayjs().diff(dayjs(stat.mtime), 'day')}d`, size: `${(stat.size / 1024).toFixed(0)}KB` };
516
+ }
517
+ catch {
518
+ return { name: f };
519
+ }
520
+ });
521
+ }
522
+ catch {
523
+ return [];
524
+ }
525
+ };
526
+ if (folder !== 'desktop')
527
+ results.downloads = scan(join(home, 'Downloads'));
528
+ if (folder !== 'downloads')
529
+ results.desktop = scan(join(home, 'Desktop'));
530
+ return JSON.stringify(results, null, 1);
531
+ }
532
+ },
533
+ // 10. Shell history
534
+ {
535
+ name: 'get_shell_history',
536
+ description: 'Get recent shell/terminal commands. Shows what the user has been doing in the terminal.',
537
+ parameters: {
538
+ type: 'object',
539
+ properties: {
540
+ limit: { type: 'number', description: 'Number of recent commands. Default 50.' },
541
+ },
542
+ required: []
543
+ },
544
+ async execute(params) {
545
+ const maxResults = params.limit || 50;
546
+ const zshPath = join(home, '.zsh_history');
547
+ const bashPath = join(home, '.bash_history');
548
+ const histPath = existsSync(zshPath) ? zshPath : existsSync(bashPath) ? bashPath : null;
549
+ if (!histPath)
550
+ return JSON.stringify({ error: 'No shell history found' });
551
+ const raw = readFileSync(histPath, 'utf-8');
552
+ const lines = raw.split('\n').filter(Boolean);
553
+ const commands = [];
554
+ for (const line of lines.slice(-maxResults * 2)) {
555
+ const match = line.match(/^: (\d+):\d+;(.*)/);
556
+ if (match) {
557
+ commands.push({ cmd: match[2], when: dayjs.unix(parseInt(match[1])).format('MMM D h:mm A') });
558
+ }
559
+ else if (!line.startsWith(':')) {
560
+ commands.push({ cmd: line });
561
+ }
562
+ }
563
+ return JSON.stringify({ recentCommands: commands.slice(-maxResults) }, null, 1);
564
+ }
565
+ },
566
+ // 11. Notes
567
+ {
568
+ name: 'get_notes',
569
+ description: 'Get recent Apple Notes titles and modification dates.',
570
+ parameters: {
571
+ type: 'object',
572
+ properties: {
573
+ limit: { type: 'number', description: 'Max notes. Default 20.' },
574
+ },
575
+ required: []
576
+ },
577
+ async execute(params) {
578
+ const maxResults = params.limit || 20;
579
+ const db = openDb(join(home, 'Library/Containers/com.apple.Notes/Data/Library/Notes/NotesV7.storedata'));
580
+ if (!db)
581
+ return JSON.stringify({ error: 'Notes DB not available' });
582
+ let notes = safeQuery(db, `SELECT ZTITLE2 as title, datetime(ZMODIFICATIONDATE1 + ${APPLE_EPOCH}, 'unixepoch', 'localtime') as modified
583
+ FROM ZICNOTEDATA WHERE ZTITLE2 IS NOT NULL ORDER BY ZMODIFICATIONDATE1 DESC LIMIT ?`, [maxResults]);
584
+ if (notes.length === 0) {
585
+ notes = safeQuery(db, `SELECT ZTITLE as title, datetime(ZMODIFICATIONDATE1 + ${APPLE_EPOCH}, 'unixepoch', 'localtime') as modified
586
+ FROM ZICNOTEDATA WHERE ZTITLE IS NOT NULL ORDER BY ZMODIFICATIONDATE1 DESC LIMIT ?`, [maxResults]);
587
+ }
588
+ db.close();
589
+ return JSON.stringify({ notes: notes.map(n => ({ title: n.title, modified: n.modified })) }, null, 1);
590
+ }
591
+ },
592
+ // 12. Claude CLI history
593
+ {
594
+ name: 'get_claude_history',
595
+ description: 'Read recent Claude Code CLI conversation topics. Shows what the user has been working on with Claude.',
596
+ parameters: {
597
+ type: 'object',
598
+ properties: {
599
+ days: { type: 'number', description: 'Days back. Default 3.' },
600
+ },
601
+ required: []
602
+ },
603
+ async execute(params) {
604
+ const days = params.days || 3;
605
+ const claudeDir = join(home, '.claude');
606
+ if (!existsSync(claudeDir))
607
+ return JSON.stringify({ error: 'No Claude CLI history' });
608
+ const projects = join(claudeDir, 'projects');
609
+ if (!existsSync(projects))
610
+ return JSON.stringify({ error: 'No projects dir' });
611
+ const cutoff = dayjs().subtract(days, 'day');
612
+ const conversations = [];
613
+ try {
614
+ const projectDirs = readdirSync(projects);
615
+ for (const pd of projectDirs) {
616
+ const pdPath = join(projects, pd);
617
+ try {
618
+ const files = readdirSync(pdPath).filter(f => f.endsWith('.jsonl'));
619
+ for (const f of files) {
620
+ const fPath = join(pdPath, f);
621
+ try {
622
+ const stat = statSync(fPath);
623
+ if (dayjs(stat.mtime).isBefore(cutoff))
624
+ continue;
625
+ // Read first user message
626
+ const content = readFileSync(fPath, 'utf-8');
627
+ const firstLine = content.split('\n').find(l => l.includes('"role":"user"'));
628
+ if (firstLine) {
629
+ try {
630
+ const parsed = JSON.parse(firstLine);
631
+ const msg = typeof parsed.message?.content === 'string'
632
+ ? parsed.message.content.slice(0, 100)
633
+ : Array.isArray(parsed.message?.content)
634
+ ? (parsed.message.content.find((c) => c.type === 'text')?.text || '').slice(0, 100)
635
+ : '';
636
+ if (msg) {
637
+ conversations.push({
638
+ project: pd.replace(/-/g, '/'),
639
+ firstMessage: msg,
640
+ date: dayjs(stat.mtime).format('MMM D h:mm A'),
641
+ });
642
+ }
643
+ }
644
+ catch { }
645
+ }
646
+ }
647
+ catch { }
648
+ }
649
+ }
650
+ catch { }
651
+ }
652
+ }
653
+ catch { }
654
+ conversations.sort((a, b) => dayjs(b.date, 'MMM D h:mm A').unix() - dayjs(a.date, 'MMM D h:mm A').unix());
655
+ return JSON.stringify({ conversations: conversations.slice(0, 15) }, null, 1);
656
+ }
657
+ },
658
+ // 13. Contact lookup
659
+ {
660
+ name: 'lookup_contact',
661
+ description: 'Look up a contact by name or phone/email to get their full info.',
662
+ parameters: {
663
+ type: 'object',
664
+ properties: {
665
+ query: { type: 'string', description: 'Name, phone number, or email to search for.' },
666
+ },
667
+ required: ['query']
668
+ },
669
+ async execute(params) {
670
+ const query = params.query.toLowerCase();
671
+ const map = buildContactMap();
672
+ const matches = [];
673
+ for (const [handle, name] of map) {
674
+ if (name.toLowerCase().includes(query) || handle.toLowerCase().includes(query)) {
675
+ matches.push({ handle, name });
676
+ }
677
+ }
678
+ // Dedupe by name
679
+ const seen = new Set();
680
+ const unique = matches.filter(m => { if (seen.has(m.name))
681
+ return false; seen.add(m.name); return true; });
682
+ return JSON.stringify({ results: unique.slice(0, 10) }, null, 1);
683
+ }
684
+ },
685
+ // 14. Broad message search across ALL conversations
686
+ {
687
+ name: 'search_all_messages',
688
+ description: 'Search ALL iMessage conversations for a keyword or phrase. Use this to chase down context — if someone mentions "Thursday", search for "thursday", "thurs", "thur" to find ALL related discussions across ALL contacts. Returns matching messages with who said them and when.',
689
+ parameters: {
690
+ type: 'object',
691
+ properties: {
692
+ keywords: {
693
+ type: 'array',
694
+ items: { type: 'string' },
695
+ description: 'List of keywords to search for. Will search for each independently. E.g. ["thursday", "thurs", "li po", "fireworks"]'
696
+ },
697
+ days: { type: 'number', description: 'Days back. Default 7.' },
698
+ limit: { type: 'number', description: 'Max results per keyword. Default 20.' },
699
+ },
700
+ required: ['keywords']
701
+ },
702
+ async execute(params) {
703
+ const db = openDb(join(home, 'Library/Messages/chat.db'));
704
+ if (!db)
705
+ return JSON.stringify({ error: 'not available' });
706
+ const keywords = params.keywords;
707
+ const days = params.days || 7;
708
+ const maxPer = params.limit || 20;
709
+ const handles = safeQuery(db, 'SELECT ROWID, id FROM handle');
710
+ const handleMap = new Map(handles.map(h => [h.ROWID, h.id]));
711
+ const agoNano = (BigInt(daysAgoUnix(days) - APPLE_EPOCH) * BigInt(1e9)).toString();
712
+ const results = {};
713
+ for (const kw of keywords) {
714
+ const msgs = safeQuery(db, `SELECT text, date, is_from_me, handle_id FROM message
715
+ WHERE date > ? AND text LIKE ? ORDER BY date DESC LIMIT ?`, [agoNano, `%${kw}%`, maxPer * 2]);
716
+ results[kw] = msgs
717
+ .filter(m => m.text && !isReaction(m.text))
718
+ .slice(0, maxPer)
719
+ .map(m => {
720
+ const handle = handleMap.get(m.handle_id) || '';
721
+ const dateSeconds = Number(BigInt(m.date) / BigInt(1e9)) + APPLE_EPOCH;
722
+ return {
723
+ from: m.is_from_me ? getUserName() : resolveName(handle),
724
+ text: m.text.slice(0, 200),
725
+ when: dayjs.unix(dateSeconds).format('ddd MMM D h:mm A'),
726
+ };
727
+ });
728
+ }
729
+ db.close();
730
+ return JSON.stringify(results, null, 1);
731
+ }
732
+ },
733
+ // 15. Deep relationship profile
734
+ {
735
+ name: 'profile_contact',
736
+ description: 'Build a deep relationship profile for a contact. Returns: how long you\'ve known them, message frequency over time, call frequency, recent topics, relationship tier, shared group chats, and whether the user has searched for them online. Use this to assess how important a commitment is and how flexible the relationship is.',
737
+ parameters: {
738
+ type: 'object',
739
+ properties: {
740
+ name: { type: 'string', description: 'Contact name to profile.' },
741
+ },
742
+ required: ['name']
743
+ },
744
+ async execute(params) {
745
+ const contactName = params.name.toLowerCase();
746
+ const profile = { name: params.name };
747
+ // --- iMessage history ---
748
+ const msgDb = openDb(join(home, 'Library/Messages/chat.db'));
749
+ if (msgDb) {
750
+ const handles = safeQuery(msgDb, 'SELECT ROWID, id FROM handle');
751
+ const matchingHandles = handles.filter(h => resolveName(h.id).toLowerCase().includes(contactName));
752
+ if (matchingHandles.length > 0) {
753
+ const handleIds = matchingHandles.map(h => h.ROWID);
754
+ const placeholders = handleIds.map(() => '?').join(',');
755
+ // Total messages ever
756
+ const total = safeQuery(msgDb, `SELECT COUNT(*) as c FROM message WHERE handle_id IN (${placeholders})`, handleIds);
757
+ // Messages by time period
758
+ const day1Nano = (BigInt(daysAgoUnix(1) - APPLE_EPOCH) * BigInt(1e9)).toString();
759
+ const day7Nano = (BigInt(daysAgoUnix(7) - APPLE_EPOCH) * BigInt(1e9)).toString();
760
+ const day30Nano = (BigInt(daysAgoUnix(30) - APPLE_EPOCH) * BigInt(1e9)).toString();
761
+ const day90Nano = (BigInt(daysAgoUnix(90) - APPLE_EPOCH) * BigInt(1e9)).toString();
762
+ const last24h = safeQuery(msgDb, `SELECT COUNT(*) as c FROM message WHERE handle_id IN (${placeholders}) AND date > ?`, [...handleIds, day1Nano]);
763
+ const last7d = safeQuery(msgDb, `SELECT COUNT(*) as c FROM message WHERE handle_id IN (${placeholders}) AND date > ?`, [...handleIds, day7Nano]);
764
+ const last30d = safeQuery(msgDb, `SELECT COUNT(*) as c FROM message WHERE handle_id IN (${placeholders}) AND date > ?`, [...handleIds, day30Nano]);
765
+ const last90d = safeQuery(msgDb, `SELECT COUNT(*) as c FROM message WHERE handle_id IN (${placeholders}) AND date > ?`, [...handleIds, day90Nano]);
766
+ // First ever message (how long you've known them)
767
+ const firstMsg = safeQuery(msgDb, `SELECT date FROM message WHERE handle_id IN (${placeholders}) ORDER BY date ASC LIMIT 1`, handleIds);
768
+ let firstContact = 'unknown';
769
+ if (firstMsg.length > 0) {
770
+ const firstDateSec = Number(BigInt(firstMsg[0].date) / BigInt(1e9)) + APPLE_EPOCH;
771
+ firstContact = dayjs.unix(firstDateSec).format('MMMM YYYY');
772
+ }
773
+ // Recent messages for topic extraction
774
+ const recentMsgs = safeQuery(msgDb, `SELECT text, is_from_me, date FROM message
775
+ WHERE handle_id IN (${placeholders}) AND text IS NOT NULL AND text != ''
776
+ ORDER BY date DESC LIMIT 10`, handleIds);
777
+ const recentTopics = recentMsgs.filter(m => m.text && m.text.length > 5 && !isReaction(m.text)).map(m => {
778
+ const dateSec = Number(BigInt(m.date) / BigInt(1e9)) + APPLE_EPOCH;
779
+ return {
780
+ from: m.is_from_me ? getUserName() : params.name,
781
+ text: m.text.slice(0, 120),
782
+ when: dayjs.unix(dateSec).format('ddd MMM D h:mm A'),
783
+ };
784
+ });
785
+ // Sent vs received ratio
786
+ const sentCount = safeQuery(msgDb, `SELECT COUNT(*) as c FROM message WHERE handle_id IN (${placeholders}) AND is_from_me = 1`, handleIds);
787
+ const recvCount = safeQuery(msgDb, `SELECT COUNT(*) as c FROM message WHERE handle_id IN (${placeholders}) AND is_from_me = 0`, handleIds);
788
+ // Shared group chats
789
+ const groups = safeQuery(msgDb, `SELECT DISTINCT c.display_name FROM chat c
790
+ JOIN chat_handle_join chj ON c.ROWID = chj.chat_id
791
+ WHERE chj.handle_id IN (${placeholders})
792
+ AND c.display_name IS NOT NULL AND c.display_name != ''`, handleIds);
793
+ // Determine relationship tier
794
+ const totalMsgs = total[0]?.c || 0;
795
+ const last30 = last30d[0]?.c || 0;
796
+ let tier = 'acquaintance';
797
+ if (last30 > 100)
798
+ tier = 'inner circle';
799
+ else if (last30 > 30)
800
+ tier = 'close friend';
801
+ else if (last30 > 10)
802
+ tier = 'regular contact';
803
+ else if (totalMsgs > 50)
804
+ tier = 'occasional contact';
805
+ // Message frequency assessment
806
+ let frequency = 'rarely';
807
+ if (last24h[0]?.c > 5)
808
+ frequency = 'multiple times daily';
809
+ else if (last7d[0]?.c > 20)
810
+ frequency = 'daily';
811
+ else if (last30d[0]?.c > 15)
812
+ frequency = 'weekly';
813
+ else if (last90d[0]?.c > 10)
814
+ frequency = 'monthly';
815
+ profile.messaging = {
816
+ totalMessagesEver: totalMsgs,
817
+ last24h: last24h[0]?.c || 0,
818
+ last7d: last7d[0]?.c || 0,
819
+ last30d: last30d[0]?.c || 0,
820
+ last90d: last90d[0]?.c || 0,
821
+ sentByUser: sentCount[0]?.c || 0,
822
+ receivedFromThem: recvCount[0]?.c || 0,
823
+ firstContact,
824
+ frequency,
825
+ tier,
826
+ sharedGroups: groups.map(g => g.display_name),
827
+ recentTopics,
828
+ };
829
+ }
830
+ msgDb.close();
831
+ }
832
+ // --- Call history ---
833
+ const callDb = openDb(join(home, 'Library/Application Support/CallHistoryDB/CallHistory.storedata'));
834
+ if (callDb) {
835
+ const allCalls = safeQuery(callDb, `SELECT ZADDRESS as address, ZDURATION as duration, ZDATE as date, ZCALLTYPE as call_type FROM ZCALLRECORD ORDER BY ZDATE DESC`);
836
+ const matchingCalls = allCalls.filter(c => {
837
+ const name = resolveName(c.address);
838
+ return name.toLowerCase().includes(contactName);
839
+ });
840
+ if (matchingCalls.length > 0) {
841
+ const totalMinutes = Math.round(matchingCalls.reduce((s, c) => s + (c.duration || 0), 0) / 60);
842
+ const lastCall = matchingCalls[0];
843
+ profile.calls = {
844
+ totalCalls: matchingCalls.length,
845
+ totalMinutes,
846
+ lastCall: lastCall ? appleToHuman(lastCall.date) : null,
847
+ lastCallType: lastCall?.call_type === 1 ? 'incoming' : lastCall?.call_type === 2 ? 'outgoing' : 'missed',
848
+ incoming: matchingCalls.filter(c => c.call_type === 1).length,
849
+ outgoing: matchingCalls.filter(c => c.call_type === 2).length,
850
+ missed: matchingCalls.filter(c => c.call_type === 3).length,
851
+ };
852
+ }
853
+ callDb.close();
854
+ }
855
+ // --- Browsing: has user searched for this person? ---
856
+ const safDb = openDb(join(home, 'Library/Safari/History.db'));
857
+ if (safDb) {
858
+ const nameWords = contactName.split(' ').filter(w => w.length > 2);
859
+ const searchedFor = [];
860
+ for (const word of nameWords) {
861
+ const hits = safeQuery(safDb, `SELECT hi.url, hv.title FROM history_visits hv
862
+ JOIN history_items hi ON hv.history_item = hi.id
863
+ WHERE hi.url LIKE ? OR hv.title LIKE ?
864
+ ORDER BY hv.visit_time DESC LIMIT 5`, [`%${word}%`, `%${word}%`]);
865
+ for (const h of hits) {
866
+ if (h.title && h.title.toLowerCase().includes(contactName)) {
867
+ searchedFor.push(`${h.title} (${new URL(h.url).hostname})`);
868
+ }
869
+ else if (h.url.toLowerCase().includes(contactName.replace(' ', ''))) {
870
+ searchedFor.push(`${h.title || h.url} (${new URL(h.url).hostname})`);
871
+ }
872
+ }
873
+ }
874
+ if (searchedFor.length > 0) {
875
+ profile.searchedOnline = [...new Set(searchedFor)].slice(0, 5);
876
+ }
877
+ safDb.close();
878
+ }
879
+ // --- Assessment ---
880
+ const tier = profile.messaging?.tier || 'unknown';
881
+ const freq = profile.messaging?.frequency || 'unknown';
882
+ const hasCalls = profile.calls?.totalCalls > 0;
883
+ let flexibility = 'unknown';
884
+ if (tier === 'inner circle') {
885
+ flexibility = 'very flexible — they know you well, would understand schedule changes without explanation';
886
+ }
887
+ else if (tier === 'close friend') {
888
+ flexibility = 'flexible — a quick heads-up is enough, they\'d get it';
889
+ }
890
+ else if (tier === 'regular contact') {
891
+ flexibility = 'moderate — worth a thoughtful message if rescheduling';
892
+ }
893
+ else if (tier === 'occasional contact' || tier === 'acquaintance') {
894
+ flexibility = 'low — changing plans could damage the relationship, be careful';
895
+ }
896
+ profile.assessment = {
897
+ relationshipTier: tier,
898
+ communicationFrequency: freq,
899
+ callsEachOther: hasCalls,
900
+ flexibility,
901
+ };
902
+ return JSON.stringify(profile, null, 1);
903
+ }
904
+ },
905
+ // 15. Cross-reference interests with browsing
906
+ {
907
+ name: 'get_interests_for_plans',
908
+ description: 'Find what the user has been browsing/searching that could be relevant for making plans — restaurants, activities, events, locations. Use this to suggest specific places or activities when making plans with someone.',
909
+ parameters: {
910
+ type: 'object',
911
+ properties: {
912
+ category: { type: 'string', description: 'Category to search for: "food", "events", "places", "activities", or "all". Default "all".' },
913
+ location: { type: 'string', description: 'Optional location filter.' },
914
+ },
915
+ required: []
916
+ },
917
+ async execute(params) {
918
+ const category = params.category || 'all';
919
+ const location = params.location;
920
+ const safDb = openDb(join(home, 'Library/Safari/History.db'));
921
+ if (!safDb)
922
+ return JSON.stringify({ error: 'Safari not available' });
923
+ const history = safeQuery(safDb, `SELECT hi.url, hv.title, datetime(hv.visit_time + ${APPLE_EPOCH}, 'unixepoch', 'localtime') as visited
924
+ FROM history_visits hv JOIN history_items hi ON hv.history_item = hi.id
925
+ WHERE hv.visit_time > ? ORDER BY hv.visit_time DESC LIMIT 3000`, [daysAgoApple(30)]);
926
+ const foodDomains = ['doordash.com', 'ubereats.com', 'yelp.com', 'opentable.com', 'resy.com', 'grubhub.com', 'seamless.com', 'toasttab.com'];
927
+ const eventDomains = ['eventbrite.com', 'meetup.com', 'dice.fm', 'seatgeek.com', 'ticketmaster.com', 'stubhub.com', 'lu.ma'];
928
+ const placeDomains = ['google.com/maps', 'yelp.com', 'tripadvisor.com', 'airbnb.com', 'zillow.com'];
929
+ const food = [];
930
+ const events = [];
931
+ const places = [];
932
+ const searches = [];
933
+ for (const h of history) {
934
+ if (!h.title || h.title.length < 5)
935
+ continue;
936
+ try {
937
+ const domain = new URL(h.url).hostname.replace('www.', '');
938
+ const entry = { title: h.title, domain, when: h.visited };
939
+ if (foodDomains.some(d => domain.includes(d)))
940
+ food.push(entry);
941
+ if (eventDomains.some(d => domain.includes(d)))
942
+ events.push(entry);
943
+ if (placeDomains.some(d => domain.includes(d)))
944
+ places.push(entry);
945
+ // Food/restaurant searches
946
+ const u = new URL(h.url);
947
+ const q = u.searchParams.get('q') || u.searchParams.get('query') || '';
948
+ if (q && (q.match(/restaurant|food|dinner|lunch|brunch|cafe|bar|sushi|pizza|thai|italian|mexican/i) ||
949
+ (location && q.toLowerCase().includes(location.toLowerCase())))) {
950
+ searches.push(q);
951
+ }
952
+ }
953
+ catch { }
954
+ }
955
+ safDb.close();
956
+ const results = {};
957
+ if (category === 'all' || category === 'food')
958
+ results.food = { recent: food.slice(0, 10), searches: [...new Set(searches)].slice(0, 10) };
959
+ if (category === 'all' || category === 'events')
960
+ results.events = events.slice(0, 10);
961
+ if (category === 'all' || category === 'places')
962
+ results.places = places.slice(0, 10);
963
+ return JSON.stringify(results, null, 1);
964
+ }
965
+ },
966
+ // 16. Get conversation thread
967
+ {
968
+ name: 'get_conversation',
969
+ description: 'Get the full recent conversation thread between the user and a specific person. Shows back-and-forth messages in order.',
970
+ parameters: {
971
+ type: 'object',
972
+ properties: {
973
+ contact: { type: 'string', description: 'Contact name to get conversation with.' },
974
+ days: { type: 'number', description: 'Days back. Default 2.' },
975
+ limit: { type: 'number', description: 'Max messages. Default 30.' },
976
+ },
977
+ required: ['contact']
978
+ },
979
+ async execute(params) {
980
+ const db = openDb(join(home, 'Library/Messages/chat.db'));
981
+ if (!db)
982
+ return JSON.stringify({ error: 'not available' });
983
+ const contactName = params.contact.toLowerCase();
984
+ const days = params.days || 2;
985
+ const maxMsgs = params.limit || 30;
986
+ const handles = safeQuery(db, 'SELECT ROWID, id FROM handle');
987
+ const handleMap = new Map(handles.map(h => [h.ROWID, h.id]));
988
+ // Find matching handle IDs
989
+ const matchingHandles = handles.filter(h => {
990
+ const name = resolveName(h.id);
991
+ return name.toLowerCase().includes(contactName);
992
+ }).map(h => h.ROWID);
993
+ if (matchingHandles.length === 0) {
994
+ db.close();
995
+ return JSON.stringify({ error: `No contact matching "${params.contact}"` });
996
+ }
997
+ const agoNano = (BigInt(daysAgoUnix(days) - APPLE_EPOCH) * BigInt(1e9)).toString();
998
+ const placeholders = matchingHandles.map(() => '?').join(',');
999
+ const msgs = safeQuery(db, `SELECT text, date, is_from_me, handle_id FROM message
1000
+ WHERE handle_id IN (${placeholders}) AND date > ?
1001
+ ORDER BY date ASC`, [...matchingHandles, agoNano]);
1002
+ const thread = msgs.filter(m => m.text && !isReaction(m.text)).slice(-maxMsgs).map(m => {
1003
+ const dateSeconds = Number(BigInt(m.date) / BigInt(1e9)) + APPLE_EPOCH;
1004
+ return {
1005
+ from: m.is_from_me ? getUserName() : resolveName(handleMap.get(m.handle_id) || ''),
1006
+ text: m.text.slice(0, 300),
1007
+ when: dayjs.unix(dateSeconds).format('ddd MMM D h:mm A'),
1008
+ };
1009
+ });
1010
+ db.close();
1011
+ return JSON.stringify({ contact: params.contact, thread }, null, 1);
1012
+ }
1013
+ },
1014
+ ];
1015
+ /** Build OpenAI-compatible tool schemas for API calls */
1016
+ export function getToolSchemas() {
1017
+ return TOOLS.map(t => ({
1018
+ type: 'function',
1019
+ function: {
1020
+ name: t.name,
1021
+ description: t.description,
1022
+ parameters: t.parameters,
1023
+ },
1024
+ }));
1025
+ }
1026
+ /** Execute a tool by name */
1027
+ export async function executeTool(name, params) {
1028
+ const tool = TOOLS.find(t => t.name === name);
1029
+ if (!tool)
1030
+ return JSON.stringify({ error: `Unknown tool: ${name}` });
1031
+ try {
1032
+ return await tool.execute(params);
1033
+ }
1034
+ catch (e) {
1035
+ return JSON.stringify({ error: `Tool ${name} failed: ${String(e)}` });
1036
+ }
1037
+ }