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.
- package/dist/agent.d.ts +11 -0
- package/dist/agent.js +435 -0
- package/dist/analyze.d.ts +28 -0
- package/dist/analyze.js +130 -0
- package/dist/auq.d.ts +15 -0
- package/dist/auq.js +61 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +333 -0
- package/dist/collectors/apps.d.ts +5 -0
- package/dist/collectors/apps.js +59 -0
- package/dist/collectors/calendar.d.ts +2 -0
- package/dist/collectors/calendar.js +115 -0
- package/dist/collectors/calls.d.ts +2 -0
- package/dist/collectors/calls.js +52 -0
- package/dist/collectors/chrome.d.ts +2 -0
- package/dist/collectors/chrome.js +49 -0
- package/dist/collectors/findmy.d.ts +2 -0
- package/dist/collectors/findmy.js +67 -0
- package/dist/collectors/imessage.d.ts +2 -0
- package/dist/collectors/imessage.js +125 -0
- package/dist/collectors/mail.d.ts +2 -0
- package/dist/collectors/mail.js +49 -0
- package/dist/collectors/notes.d.ts +2 -0
- package/dist/collectors/notes.js +42 -0
- package/dist/collectors/notifications.d.ts +2 -0
- package/dist/collectors/notifications.js +37 -0
- package/dist/collectors/recent-files.d.ts +2 -0
- package/dist/collectors/recent-files.js +46 -0
- package/dist/collectors/safari.d.ts +2 -0
- package/dist/collectors/safari.js +85 -0
- package/dist/collectors/screen-time.d.ts +2 -0
- package/dist/collectors/screen-time.js +72 -0
- package/dist/collectors/shell-history.d.ts +2 -0
- package/dist/collectors/shell-history.js +44 -0
- package/dist/contacts.d.ts +7 -0
- package/dist/contacts.js +88 -0
- package/dist/db.d.ts +9 -0
- package/dist/db.js +50 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +42 -0
- package/dist/profile.d.ts +18 -0
- package/dist/profile.js +88 -0
- package/dist/progress.d.ts +40 -0
- package/dist/progress.js +204 -0
- package/dist/state.d.ts +18 -0
- package/dist/state.js +101 -0
- package/dist/todo.d.ts +21 -0
- package/dist/todo.js +133 -0
- package/dist/tools.d.ts +22 -0
- package/dist/tools.js +1037 -0
- package/dist/types.d.ts +30 -0
- package/dist/types.js +2 -0
- 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
|
+
}
|