life-pulse 2.2.2 → 2.3.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.js CHANGED
@@ -94,12 +94,12 @@ function buildCustomMcpServer(onCard) {
94
94
  });
95
95
  }
96
96
  // ─── Executive Operating System prompt ──────────────────────────
97
- function buildOrchestratorPrompt() {
97
+ async function buildOrchestratorPrompt() {
98
98
  const delta = buildDeltaContext();
99
99
  const todos = buildTodoContext();
100
100
  const profile = buildProfile();
101
101
  const name = profile.name;
102
- const crm = buildCRM();
102
+ const crm = await buildCRM();
103
103
  const crmContext = buildCRMContext(crm);
104
104
  const projectSection = profile.projects.length > 0
105
105
  ? `\n- Active projects: ${profile.projects.join(', ')}`
@@ -387,7 +387,7 @@ RULES:
387
387
  for await (const msg of query({
388
388
  prompt,
389
389
  options: {
390
- systemPrompt: buildOrchestratorPrompt(),
390
+ systemPrompt: await buildOrchestratorPrompt(),
391
391
  model: ORCHESTRATOR_MODEL,
392
392
  env,
393
393
  mcpServers,
package/dist/cli.js CHANGED
@@ -83,7 +83,7 @@ async function fetchCalendarContext() {
83
83
  return '';
84
84
  }
85
85
  async function showCRM(apiKey, opts) {
86
- const crm = buildCRM();
86
+ const crm = await buildCRM();
87
87
  if (!crm.threads.length || !apiKey) {
88
88
  opts?.spinner?.stop();
89
89
  return false;
@@ -686,9 +686,9 @@ async function main() {
686
686
  }
687
687
  // Generate knowledge bases from CRM on startup
688
688
  try {
689
- const crm = buildCRM();
689
+ const crm = await buildCRM();
690
690
  if (crm.threads.length)
691
- updateAutoKnowledge(crm);
691
+ await updateAutoKnowledge(crm);
692
692
  }
693
693
  catch { }
694
694
  // Periodic progress save (every 5 min while running)
package/dist/crm.d.ts CHANGED
@@ -1,12 +1,11 @@
1
1
  /**
2
2
  * Relationship Map — high-touch contact intelligence.
3
3
  *
4
- * Queries iMessage DB directly with CTE-based SQL:
5
- * - Response time pairing (their msg your first reply)
6
- * - Ghost detection (waiting on you / you're waiting on them)
7
- * - Thread temperature from message velocity
8
- * - Recent message pulls for LLM-powered relationship summaries
9
- * - Filters out group chats, tapbacks, business messages
4
+ * Two data paths:
5
+ * 1. RPLY API (localhost:19851) preferred, no FDA needed, multi-platform
6
+ * 2. Direct iMessage SQLite fallback when RPLY unavailable
7
+ *
8
+ * Both paths produce the same CRM/ThreadState interface.
10
9
  */
11
10
  export interface ThreadState {
12
11
  name: string;
@@ -35,7 +34,7 @@ export interface CRM {
35
34
  yourAvgResponseSec: number | null;
36
35
  generatedAt: string;
37
36
  }
38
- export declare function buildCRM(): CRM;
37
+ export declare function buildCRM(): Promise<CRM>;
39
38
  export declare function streamEnrichedCRM(crm: CRM, apiKey: string, opts?: {
40
39
  calendarContext?: string;
41
40
  timeOfDay?: string;
package/dist/crm.js CHANGED
@@ -1,17 +1,17 @@
1
1
  /**
2
2
  * Relationship Map — high-touch contact intelligence.
3
3
  *
4
- * Queries iMessage DB directly with CTE-based SQL:
5
- * - Response time pairing (their msg your first reply)
6
- * - Ghost detection (waiting on you / you're waiting on them)
7
- * - Thread temperature from message velocity
8
- * - Recent message pulls for LLM-powered relationship summaries
9
- * - Filters out group chats, tapbacks, business messages
4
+ * Two data paths:
5
+ * 1. RPLY API (localhost:19851) preferred, no FDA needed, multi-platform
6
+ * 2. Direct iMessage SQLite fallback when RPLY unavailable
7
+ *
8
+ * Both paths produce the same CRM/ThreadState interface.
10
9
  */
11
10
  import { homedir } from 'os';
12
11
  import { join } from 'path';
13
12
  import { openDb, safeQuery } from './db.js';
14
13
  import { resolveName } from './contacts.js';
14
+ import * as rply from './rply-client.js';
15
15
  import Anthropic from '@anthropic-ai/sdk';
16
16
  import dayjs from 'dayjs';
17
17
  import relativeTime from 'dayjs/plugin/relativeTime.js';
@@ -28,92 +28,196 @@ function nanoToDay(nano) {
28
28
  const n = typeof nano === 'bigint' ? nano : BigInt(Math.trunc(Number(nano)));
29
29
  return dayjs.unix(Number(n / NANO) + APPLE_EPOCH);
30
30
  }
31
- // ─── Build CRM ──────────────────────────────────────────────────
32
- export function buildCRM() {
31
+ function emptyCRM() {
32
+ return { threads: [], yourAvgResponseSec: null, generatedAt: dayjs().toISOString() };
33
+ }
34
+ // ─── Build CRM (async, RPLY-first) ──────────────────────────────
35
+ export async function buildCRM() {
36
+ if (await rply.isAvailable()) {
37
+ try {
38
+ const crm = await buildCRMFromRPLY();
39
+ if (crm.threads.length > 0)
40
+ return crm;
41
+ }
42
+ catch (e) {
43
+ process.stderr.write(` rply crm fallback: ${e instanceof Error ? e.message : String(e)}\n`);
44
+ }
45
+ }
46
+ return buildCRMFromSQLite();
47
+ }
48
+ // ─── RPLY Path ──────────────────────────────────────────────────
49
+ async function buildCRMFromRPLY() {
50
+ // Fetch top 50 iMessage conversations (non-group, sorted by recency)
51
+ const result = await rply.listConversations({ platform: 'system', limit: 50 });
52
+ if (!result?.data.length)
53
+ return emptyCRM();
54
+ const convos = result.data.filter(c => !c.is_group_chat && c.title.length > 2);
55
+ // Fetch messages for top 30 in parallel
56
+ const top = convos.slice(0, 30);
57
+ const msgResults = await Promise.all(top.map(c => rply.listMessages(c.id, { limit: 200 })));
58
+ const now = dayjs();
59
+ const ago30 = now.subtract(30, 'day');
60
+ const ago60 = now.subtract(60, 'day');
61
+ const threads = [];
62
+ const allResponseTimes = [];
63
+ for (let i = 0; i < top.length; i++) {
64
+ const conv = top[i];
65
+ // Messages: index 0 = newest, index N = oldest
66
+ const msgs = (msgResults[i]?.data || []).filter(m => m.content?.type === 'text' && m.content.text);
67
+ if (!msgs.length)
68
+ continue;
69
+ // ── msgs30d
70
+ const msgs30d = msgs.filter(m => dayjs(m.date).isAfter(ago30)).length;
71
+ // ── Response time pairs: each received → first sent after it
72
+ let totalResponseSec = 0;
73
+ let pairs = 0;
74
+ for (let j = msgs.length - 1; j >= 0; j--) {
75
+ if (msgs[j].direction !== 'received')
76
+ continue;
77
+ const received = dayjs(msgs[j].date);
78
+ if (received.isBefore(ago30))
79
+ continue;
80
+ for (let k = j - 1; k >= 0; k--) {
81
+ if (msgs[k].direction === 'sent') {
82
+ const gap = dayjs(msgs[k].date).diff(received, 'second');
83
+ if (gap > 0 && gap < 14 * 86400) {
84
+ totalResponseSec += gap;
85
+ pairs++;
86
+ }
87
+ break;
88
+ }
89
+ }
90
+ }
91
+ const avgResponseSec = pairs >= 3 ? totalResponseSec / pairs : null;
92
+ if (avgResponseSec != null && avgResponseSec < 604800) {
93
+ allResponseTimes.push({ avg: avgResponseSec, pairs });
94
+ }
95
+ // ── Last message + ghost detection
96
+ const lastMsg = buildLastMsg(msgs[0], now);
97
+ const { waitingOnYou, waitingOnThem, waitingSince, silentDays } = buildGhostState(msgs[0], now);
98
+ // ── Thread temperature
99
+ const threadTemp = silentDays <= 1 ? 'hot' : silentDays <= 3 ? 'warm' : silentDays <= 7 ? 'cooling' : 'cold';
100
+ // ── Monthly trend
101
+ const recent = msgs.filter(m => dayjs(m.date).isAfter(ago30)).length;
102
+ const prior = msgs.filter(m => { const d = dayjs(m.date); return d.isAfter(ago60) && !d.isAfter(ago30); }).length;
103
+ let monthlyTrend = null;
104
+ const ratio = prior > 0 ? recent / prior : recent > 0 ? 2 : 0;
105
+ if (ratio > 0)
106
+ monthlyTrend = ratio > 1.3 ? 'rising' : ratio < 0.7 ? 'declining' : 'stable';
107
+ // ── Recent snippets (for LLM enrichment)
108
+ const recentSnippets = msgs
109
+ .filter(m => dayjs(m.date).isAfter(ago30))
110
+ .slice(0, 40)
111
+ .map(m => `${m.direction === 'sent' ? '→' : '←'} ${m.content.text.slice(0, 100)}`);
112
+ threads.push({
113
+ name: conv.title, handle: conv.id,
114
+ msgs30d, avgResponseSec, conversationPairs: pairs,
115
+ lastMsg, waitingOnYou, waitingOnThem, waitingSince,
116
+ silentDays, threadTemp, monthlyTrend,
117
+ recentSnippets: recentSnippets.slice(0, 30),
118
+ });
119
+ }
120
+ threads.sort((a, b) => b.msgs30d - a.msgs30d);
121
+ const yourAvg = allResponseTimes.length > 0
122
+ ? allResponseTimes.reduce((s, r) => s + r.avg * r.pairs, 0)
123
+ / allResponseTimes.reduce((s, r) => s + r.pairs, 0)
124
+ : null;
125
+ return { threads: threads.slice(0, 25), yourAvgResponseSec: yourAvg, generatedAt: now.toISOString() };
126
+ }
127
+ function buildLastMsg(msg, now) {
128
+ if (!msg)
129
+ return null;
130
+ const when = dayjs(msg.date);
131
+ const daysDiff = now.diff(when, 'day');
132
+ if (daysDiff < -1 || daysDiff > 365)
133
+ return null;
134
+ return {
135
+ text: msg.content.text.slice(0, 120),
136
+ fromThem: msg.direction === 'received',
137
+ when: when.toISOString(),
138
+ ago: when.fromNow(),
139
+ };
140
+ }
141
+ function buildGhostState(msg, now) {
142
+ let waitingOnYou = false, waitingOnThem = false, waitingSince = null, silentDays = 99;
143
+ if (!msg)
144
+ return { waitingOnYou, waitingOnThem, waitingSince, silentDays };
145
+ const when = dayjs(msg.date);
146
+ const daysDiff = now.diff(when, 'day');
147
+ if (daysDiff < -1 || daysDiff > 365)
148
+ return { waitingOnYou, waitingOnThem, waitingSince, silentDays };
149
+ silentDays = daysDiff;
150
+ if (msg.direction === 'received' && now.diff(when, 'hour') > 4) {
151
+ waitingOnYou = true;
152
+ waitingSince = when.fromNow(true);
153
+ }
154
+ else if (msg.direction === 'sent' && now.diff(when, 'hour') > 24) {
155
+ waitingOnThem = true;
156
+ waitingSince = when.fromNow(true);
157
+ }
158
+ return { waitingOnYou, waitingOnThem, waitingSince, silentDays };
159
+ }
160
+ // ─── SQLite Path (existing, unchanged) ──────────────────────────
161
+ function buildCRMFromSQLite() {
33
162
  const home = homedir();
34
163
  const db = openDb(join(home, 'Library/Messages/chat.db'));
35
164
  if (!db)
36
- return { threads: [], yourAvgResponseSec: null, generatedAt: dayjs().toISOString() };
165
+ return emptyCRM();
37
166
  const now = nowNano();
38
167
  const ago30 = agoNano(30);
39
- const ago14 = agoNano(14);
40
168
  const ago90 = agoNano(90);
41
169
  // ── 1. Response time per contact (ConversationPairs CTE) ──
42
- // Pairs each incoming msg with your first reply within 14 days
43
170
  const responseTimes = safeQuery(db, `
44
171
  WITH NonGroupChats AS (
45
172
  SELECT chat_id FROM chat_handle_join GROUP BY chat_id HAVING COUNT(DISTINCT handle_id) = 1
46
173
  ),
47
174
  ConversationPairs AS (
48
- SELECT
49
- m1.handle_id as contact_id,
50
- m1.date as received_date,
51
- MIN(m2.date) as first_response_date
175
+ SELECT m1.handle_id as contact_id, m1.date as received_date, MIN(m2.date) as first_response_date
52
176
  FROM message AS m1
53
177
  JOIN chat_message_join cmj ON m1.ROWID = cmj.message_id
54
178
  JOIN NonGroupChats ngc ON ngc.chat_id = cmj.chat_id
55
179
  LEFT JOIN message AS m2
56
- ON m2.handle_id = m1.handle_id
57
- AND m2.date > m1.date
58
- AND m2.is_from_me = 1
180
+ ON m2.handle_id = m1.handle_id AND m2.date > m1.date AND m2.is_from_me = 1
59
181
  AND m2.date <= m1.date + 1209600000000000
60
- WHERE m1.is_from_me = 0
61
- AND m1.date >= ?
62
- AND m1.date <= ?
63
- AND m1.associated_message_type = 0
64
- GROUP BY m1.ROWID
65
- HAVING first_response_date IS NOT NULL
182
+ WHERE m1.is_from_me = 0 AND m1.date >= ? AND m1.date <= ? AND m1.associated_message_type = 0
183
+ GROUP BY m1.ROWID HAVING first_response_date IS NOT NULL
66
184
  )
67
- SELECT
68
- h.id as handle_id,
69
- COUNT(*) as conversation_pairs,
185
+ SELECT h.id as handle_id, COUNT(*) as conversation_pairs,
70
186
  AVG((cp.first_response_date - cp.received_date) / 1000000000.0) as avg_response_seconds
71
- FROM ConversationPairs cp
72
- JOIN handle h ON h.ROWID = cp.contact_id
73
- GROUP BY h.id
74
- HAVING conversation_pairs >= 3 AND avg_response_seconds < 604800
75
- ORDER BY avg_response_seconds ASC
76
- LIMIT 50
187
+ FROM ConversationPairs cp JOIN handle h ON h.ROWID = cp.contact_id
188
+ GROUP BY h.id HAVING conversation_pairs >= 3 AND avg_response_seconds < 604800
189
+ ORDER BY avg_response_seconds ASC LIMIT 50
77
190
  `, [ago30, now]);
78
191
  const responseMap = new Map(responseTimes.map(r => [r.handle_id, r]));
79
- // ── 2. Top contacts by 30-day volume (non-group only) ──
192
+ // ── 2. Top contacts by 30-day volume ──
80
193
  const topContacts = safeQuery(db, `
81
194
  WITH NonGroupChats AS (
82
195
  SELECT chat_id FROM chat_handle_join GROUP BY chat_id HAVING COUNT(DISTINCT handle_id) = 1
83
196
  )
84
197
  SELECT h.id as handle_id, COUNT(*) as msg_count
85
- FROM message m
86
- JOIN handle h ON m.handle_id = h.ROWID
198
+ FROM message m JOIN handle h ON m.handle_id = h.ROWID
87
199
  JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
88
200
  JOIN NonGroupChats ngc ON ngc.chat_id = cmj.chat_id
89
- WHERE m.date >= ? AND m.date <= ?
90
- AND m.associated_message_type = 0
91
- AND h.id NOT LIKE 'urn:biz:%'
92
- GROUP BY h.id
93
- ORDER BY msg_count DESC
94
- LIMIT 30
201
+ WHERE m.date >= ? AND m.date <= ? AND m.associated_message_type = 0 AND h.id NOT LIKE 'urn:biz:%'
202
+ GROUP BY h.id ORDER BY msg_count DESC LIMIT 30
95
203
  `, [ago30, now]);
96
- // ── 3. Last message per contact (who sent, when, what) ──
204
+ // ── 3. Last message per contact ──
97
205
  const lastMessages = safeQuery(db, `
98
206
  WITH NonGroupChats AS (
99
207
  SELECT chat_id FROM chat_handle_join GROUP BY chat_id HAVING COUNT(DISTINCT handle_id) = 1
100
208
  ),
101
209
  RankedMessages AS (
102
210
  SELECT h.id as handle_id, m.text, m.is_from_me, m.date,
103
- ROW_NUMBER() OVER (PARTITION BY h.id ORDER BY m.date DESC) as rn
104
- FROM message m
105
- JOIN handle h ON m.handle_id = h.ROWID
211
+ ROW_NUMBER() OVER (PARTITION BY h.id ORDER BY m.date DESC) as rn
212
+ FROM message m JOIN handle h ON m.handle_id = h.ROWID
106
213
  JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
107
214
  JOIN NonGroupChats ngc ON ngc.chat_id = cmj.chat_id
108
- WHERE m.text IS NOT NULL AND m.text != ''
109
- AND m.associated_message_type = 0
110
- AND m.date >= ?
215
+ WHERE m.text IS NOT NULL AND m.text != '' AND m.associated_message_type = 0 AND m.date >= ?
111
216
  )
112
- SELECT handle_id, text, is_from_me, date
113
- FROM RankedMessages WHERE rn = 1
217
+ SELECT handle_id, text, is_from_me, date FROM RankedMessages WHERE rn = 1
114
218
  `, [ago90]);
115
219
  const lastMsgMap = new Map(lastMessages.map(m => [m.handle_id, m]));
116
- // ── 4. Monthly trend (compare last 30d vs prior 30d) ──
220
+ // ── 4. Monthly trend ──
117
221
  const ago60 = agoNano(60);
118
222
  const monthlyComparison = safeQuery(db, `
119
223
  WITH NonGroupChats AS (
@@ -122,46 +226,39 @@ export function buildCRM() {
122
226
  SELECT h.id as handle_id,
123
227
  COUNT(CASE WHEN m.date >= ? THEN 1 END) as recent,
124
228
  COUNT(CASE WHEN m.date >= ? AND m.date < ? THEN 1 END) as prior
125
- FROM message m
126
- JOIN handle h ON m.handle_id = h.ROWID
229
+ FROM message m JOIN handle h ON m.handle_id = h.ROWID
127
230
  JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
128
231
  JOIN NonGroupChats ngc ON ngc.chat_id = cmj.chat_id
129
232
  WHERE m.date >= ? AND m.associated_message_type = 0
130
233
  GROUP BY h.id
131
234
  `, [ago30, ago60, ago30, ago60]);
132
235
  const trendMap = new Map(monthlyComparison.map(t => [t.handle_id, t]));
133
- // ── 5. Recent messages per contact (for relationship summaries) ──
236
+ // ── 5. Recent messages per contact ──
134
237
  const recentMessages = safeQuery(db, `
135
238
  WITH NonGroupChats AS (
136
239
  SELECT chat_id FROM chat_handle_join GROUP BY chat_id HAVING COUNT(DISTINCT handle_id) = 1
137
240
  ),
138
241
  RankedRecent AS (
139
242
  SELECT h.id as handle_id, m.text, m.is_from_me, m.date,
140
- ROW_NUMBER() OVER (PARTITION BY h.id ORDER BY m.date DESC) as rn
141
- FROM message m
142
- JOIN handle h ON m.handle_id = h.ROWID
243
+ ROW_NUMBER() OVER (PARTITION BY h.id ORDER BY m.date DESC) as rn
244
+ FROM message m JOIN handle h ON m.handle_id = h.ROWID
143
245
  JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
144
246
  JOIN NonGroupChats ngc ON ngc.chat_id = cmj.chat_id
145
- WHERE m.text IS NOT NULL AND m.text != ''
146
- AND m.associated_message_type = 0
147
- AND m.date >= ?
247
+ WHERE m.text IS NOT NULL AND m.text != '' AND m.associated_message_type = 0 AND m.date >= ?
148
248
  )
149
- SELECT handle_id, text, is_from_me, date
150
- FROM RankedRecent WHERE rn <= 40
249
+ SELECT handle_id, text, is_from_me, date FROM RankedRecent WHERE rn <= 40
151
250
  ORDER BY handle_id, date DESC
152
251
  `, [ago30]);
153
252
  const snippetMap = new Map();
154
253
  for (const m of recentMessages) {
155
254
  const arr = snippetMap.get(m.handle_id) || [];
156
- const dir = m.is_from_me ? '→' : '←';
157
- arr.push(`${dir} ${(m.text || '').slice(0, 100)}`);
255
+ arr.push(`${m.is_from_me ? '→' : '←'} ${(m.text || '').slice(0, 100)}`);
158
256
  snippetMap.set(m.handle_id, arr);
159
257
  }
160
258
  db.close();
161
259
  // ── Assemble threads ──
162
260
  const seen = new Set();
163
261
  const threads = [];
164
- // Include all top contacts + anyone with response time data
165
262
  const allHandles = new Set([
166
263
  ...topContacts.map(c => c.handle_id),
167
264
  ...responseTimes.map(r => r.handle_id),
@@ -171,7 +268,7 @@ export function buildCRM() {
171
268
  if (name === 'Unknown' || name === handleId || seen.has(name))
172
269
  continue;
173
270
  if (name.length <= 2)
174
- continue; // skip bots/short handles
271
+ continue;
175
272
  seen.add(name);
176
273
  const contact = topContacts.find(c => c.handle_id === handleId);
177
274
  const response = responseMap.get(handleId);
@@ -190,29 +287,19 @@ export function buildCRM() {
190
287
  const fromThem = last.is_from_me === 0;
191
288
  lastMsg = {
192
289
  text: (last.text || '').slice(0, 120),
193
- fromThem,
194
- when: when.toISOString(),
195
- ago: when.fromNow(),
290
+ fromThem, when: when.toISOString(), ago: when.fromNow(),
196
291
  };
197
- if (fromThem) {
198
- const hoursSince = dayjs().diff(when, 'hour');
199
- if (hoursSince > 4) {
200
- waitingOnYou = true;
201
- waitingSince = when.fromNow(true);
202
- }
292
+ if (fromThem && dayjs().diff(when, 'hour') > 4) {
293
+ waitingOnYou = true;
294
+ waitingSince = when.fromNow(true);
203
295
  }
204
- else {
205
- const hoursSince = dayjs().diff(when, 'hour');
206
- if (hoursSince > 24) {
207
- waitingOnThem = true;
208
- waitingSince = when.fromNow(true);
209
- }
296
+ else if (!fromThem && dayjs().diff(when, 'hour') > 24) {
297
+ waitingOnThem = true;
298
+ waitingSince = when.fromNow(true);
210
299
  }
211
300
  }
212
301
  }
213
- const threadTemp = silentDays <= 1 ? 'hot' :
214
- silentDays <= 3 ? 'warm' :
215
- silentDays <= 7 ? 'cooling' : 'cold';
302
+ const threadTemp = silentDays <= 1 ? 'hot' : silentDays <= 3 ? 'warm' : silentDays <= 7 ? 'cooling' : 'cold';
216
303
  let monthlyTrend = null;
217
304
  if (trend) {
218
305
  const ratio = trend.prior > 0 ? trend.recent / trend.prior : trend.recent > 0 ? 2 : 0;
@@ -228,21 +315,15 @@ export function buildCRM() {
228
315
  recentSnippets: snippetMap.get(handleId)?.slice(0, 30),
229
316
  });
230
317
  }
231
- // Sort by closeness: most frequent first
232
318
  threads.sort((a, b) => b.msgs30d - a.msgs30d);
233
- // Your overall avg response time
234
319
  const allPairs = responseTimes.filter(r => r.avg_response_seconds < 604800);
235
320
  const yourAvg = allPairs.length > 0
236
321
  ? allPairs.reduce((sum, r) => sum + r.avg_response_seconds * r.conversation_pairs, 0)
237
322
  / allPairs.reduce((sum, r) => sum + r.conversation_pairs, 0)
238
323
  : null;
239
- return {
240
- threads: threads.slice(0, 25),
241
- yourAvgResponseSec: yourAvg,
242
- generatedAt: dayjs().toISOString(),
243
- };
324
+ return { threads: threads.slice(0, 25), yourAvgResponseSec: yourAvg, generatedAt: dayjs().toISOString() };
244
325
  }
245
- // ─── Stream-enrich CRM: contacts drip in with deep summaries, then a brief ──
326
+ // ─── Stream-enrich CRM ──────────────────────────────────────────
246
327
  export async function* streamEnrichedCRM(crm, apiKey, opts) {
247
328
  const top = crm.threads.slice(0, 10);
248
329
  if (!top.length || !apiKey) {
@@ -268,7 +349,6 @@ export async function* streamEnrichedCRM(crm, apiKey, opts) {
268
349
  const nameMap = new Map(top.map(t => [t.name.toLowerCase(), t]));
269
350
  const yielded = new Set();
270
351
  const ready = [];
271
- // Multi-line accumulator for current contact
272
352
  let curName = null;
273
353
  let curLines = [];
274
354
  let curPriority = null;
@@ -326,7 +406,6 @@ STYLE GUIDE:
326
406
  ${contactBlocks}`,
327
407
  }],
328
408
  });
329
- // Collect full response then parse — streaming line-by-line was unreliable
330
409
  const response = await stream.finalMessage();
331
410
  const fullText = response.content
332
411
  .filter(b => b.type === 'text')
@@ -367,12 +446,10 @@ ${contactBlocks}`,
367
446
  }
368
447
  }
369
448
  flushContact();
370
- // Yield enriched contacts one by one (drip-feed effect)
371
449
  while (ready.length)
372
450
  yield ready.shift();
373
- if (briefLines.length && opts?.onBrief) {
451
+ if (briefLines.length && opts?.onBrief)
374
452
  opts.onBrief(briefLines.join(' '));
375
- }
376
453
  }
377
454
  catch (err) {
378
455
  process.stderr.write(`\x1B[2K\r enrichment error: ${err instanceof Error ? err.stack || err.message : String(err)}\n`);
package/dist/installer.js CHANGED
@@ -32,6 +32,7 @@ import { startGateway } from './sms-gateway.js';
32
32
  import { renderCRMList, renderBrief, Spinner, GRN, DIM, HD, MID, RED } from './tui.js';
33
33
  import { saveContactSummaries } from './intelligence.js';
34
34
  import { startSession } from './session-progress.js';
35
+ import * as rply from './rply-client.js';
35
36
  const BASE = join(homedir(), '.life-pulse');
36
37
  const STATE_PATH = join(BASE, 'install-state.json');
37
38
  function loadState() {
@@ -74,6 +75,19 @@ async function prompt(question) {
74
75
  });
75
76
  });
76
77
  }
78
+ // ─── Platform Hints ─────────────────────────────────────────────
79
+ const PLATFORM_HINTS = {
80
+ slack: 'sign into slack.com in Safari',
81
+ x: 'sign into x.com in Safari',
82
+ email: 'sign into gmail.com in Safari',
83
+ instagram: 'sign into instagram.com in Safari',
84
+ linkedin: 'sign into linkedin.com in Safari',
85
+ whatsApp: 'sign into web.whatsapp.com in Safari',
86
+ telegram: 'sign into web.telegram.org in Safari',
87
+ };
88
+ function platformHint(platform) {
89
+ return PLATFORM_HINTS[platform] || '';
90
+ }
77
91
  // ─── Main Installer Flow ────────────────────────────────────────
78
92
  export async function runInstaller(apiKey) {
79
93
  const state = loadState();
@@ -162,6 +176,28 @@ export async function runInstaller(apiKey) {
162
176
  if (skills.active.length) {
163
177
  console.log(DIM(` ${skills.active.length} skills activated`));
164
178
  }
179
+ // RPLY platform connectivity
180
+ const rplyStatus = await rply.status();
181
+ if (rplyStatus) {
182
+ const active = rplyStatus.platforms.filter(p => p.is_available && p.is_enabled);
183
+ const inactive = rplyStatus.platforms.filter(p => !p.is_available || !p.is_enabled);
184
+ console.log();
185
+ for (const p of active) {
186
+ console.log(` ${GRN('✓')} ${p.display_name}`);
187
+ }
188
+ for (const p of inactive) {
189
+ const hint = platformHint(p.platform);
190
+ console.log(` ${DIM('○')} ${DIM(p.display_name)}${hint ? ' ' + DIM(hint) : ''}`);
191
+ }
192
+ if (inactive.length) {
193
+ console.log();
194
+ console.log(DIM(' sign into these services in Safari to enable them'));
195
+ console.log(DIM(' re-run setup after signing in'));
196
+ }
197
+ }
198
+ else {
199
+ console.log(DIM(' direct iMessage access (install rply-mac-server for multi-platform)'));
200
+ }
165
201
  savePlatforms(platformProfile);
166
202
  markDone(state, 'discovery');
167
203
  }
@@ -169,12 +205,12 @@ export async function runInstaller(apiKey) {
169
205
  stepHeader(4, 'Relationship Map', isDone(state, 'crm'));
170
206
  if (!isDone(state, 'crm')) {
171
207
  spinner?.start('scanning messages');
172
- const crm = buildCRM();
208
+ const crm = await buildCRM();
173
209
  spinner?.stop();
174
210
  if (crm.threads.length) {
175
211
  renderCRMList(crm.threads);
176
212
  // Knowledge base generation
177
- const knowledgeCount = updateAutoKnowledge(crm);
213
+ const knowledgeCount = await updateAutoKnowledge(crm);
178
214
  console.log(DIM(` ${knowledgeCount} contact knowledge bases created`));
179
215
  // Enrichment
180
216
  if (apiKey) {
@@ -378,6 +414,14 @@ export async function runInstaller(apiKey) {
378
414
  console.log();
379
415
  console.log(chalk.bold.hex('#7AA2F7')(' Setup complete'));
380
416
  console.log();
417
+ // Show active platform count
418
+ const finalStatus = await rply.status();
419
+ if (finalStatus) {
420
+ const active = finalStatus.platforms.filter(p => p.is_available && p.is_enabled);
421
+ const total = finalStatus.platforms.length;
422
+ console.log(HD(` ${active.length}/${total} platforms active`) + ' ' +
423
+ DIM(active.map(p => p.display_name).join(', ')));
424
+ }
381
425
  if (state.funnelUrl) {
382
426
  console.log(HD(` Gateway: ${state.funnelUrl}/inbound`));
383
427
  console.log(DIM(' Configure rply-mac-server to POST here'));
@@ -16,7 +16,7 @@ export declare function loadKnowledge(contactName: string): string;
16
16
  /** Get the knowledge directory for a contact (creates if needed) */
17
17
  export declare function getKnowledgeDir(contactName: string): string;
18
18
  /** Regenerate auto knowledge for all CRM contacts */
19
- export declare function updateAutoKnowledge(crm: CRM): number;
19
+ export declare function updateAutoKnowledge(crm: CRM): Promise<number>;
20
20
  interface ConversationTurn {
21
21
  role: 'user' | 'assistant';
22
22
  content: string;
package/dist/knowledge.js CHANGED
@@ -14,6 +14,7 @@ import { homedir } from 'os';
14
14
  import { join } from 'path';
15
15
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, } from 'fs';
16
16
  import { openDb, safeQuery } from './db.js';
17
+ import * as rply from './rply-client.js';
17
18
  const BASE = join(homedir(), '.life-pulse');
18
19
  const KNOWLEDGE_DIR = join(BASE, 'knowledge');
19
20
  const CONVERSATIONS_DIR = join(BASE, 'conversations');
@@ -93,8 +94,23 @@ function writeRelationship(dir, thread) {
93
94
  lines.push(`Waiting on them since ${thread.waitingSince}`);
94
95
  writeFileSync(join(dir, 'relationship.txt'), lines.join('\n'), 'utf-8');
95
96
  }
96
- /** Update recent-threads.txt from iMessage DB */
97
- function writeRecentThreads(dir, handle) {
97
+ /** Update recent-threads.txt RPLY first, SQLite fallback */
98
+ async function writeRecentThreads(dir, handle) {
99
+ // ── RPLY path ──
100
+ if (await rply.isAvailable()) {
101
+ const msgs = await rply.listMessages(handle, { limit: MAX_RECENT_MSGS });
102
+ if (msgs?.data.length) {
103
+ const lines = msgs.data
104
+ .filter(m => m.content?.type === 'text' && m.content.text)
105
+ .reverse() // oldest first
106
+ .map(m => `${m.direction === 'sent' ? '→' : '←'} ${m.content.text.slice(0, 200)}`);
107
+ if (lines.length) {
108
+ writeFileSync(join(dir, 'recent-threads.txt'), lines.join('\n'), 'utf-8');
109
+ return;
110
+ }
111
+ }
112
+ }
113
+ // ── SQLite fallback ──
98
114
  const db = openDb(join(homedir(), 'Library/Messages/chat.db'));
99
115
  if (!db)
100
116
  return;
@@ -116,8 +132,8 @@ function writeRecentThreads(dir, handle) {
116
132
  if (!rows.length)
117
133
  return;
118
134
  const lines = rows.reverse().map(r => {
119
- const dir = r.is_from_me ? '→' : '←';
120
- return `${dir} ${(r.text || '').slice(0, 200)}`;
135
+ const d = r.is_from_me ? '→' : '←';
136
+ return `${d} ${(r.text || '').slice(0, 200)}`;
121
137
  });
122
138
  writeFileSync(join(dir, 'recent-threads.txt'), lines.join('\n'), 'utf-8');
123
139
  }
@@ -126,7 +142,7 @@ function writeRecentThreads(dir, handle) {
126
142
  }
127
143
  }
128
144
  /** Regenerate auto knowledge for all CRM contacts */
129
- export function updateAutoKnowledge(crm) {
145
+ export async function updateAutoKnowledge(crm) {
130
146
  mkdirSync(KNOWLEDGE_DIR, { recursive: true });
131
147
  let count = 0;
132
148
  for (const thread of crm.threads) {
@@ -134,7 +150,7 @@ export function updateAutoKnowledge(crm) {
134
150
  continue;
135
151
  const dir = getKnowledgeDir(thread.name);
136
152
  writeRelationship(dir, thread);
137
- writeRecentThreads(dir, thread.handle);
153
+ await writeRecentThreads(dir, thread.handle);
138
154
  count++;
139
155
  }
140
156
  return count;
@@ -129,7 +129,7 @@ function fetchNewMessages() {
129
129
  }
130
130
  }
131
131
  /** Resolve a handle to a contact name */
132
- function resolveHandle(handle) {
132
+ async function resolveHandle(handle) {
133
133
  try {
134
134
  const profile = buildProfile();
135
135
  const lower = handle.toLowerCase();
@@ -141,7 +141,7 @@ function resolveHandle(handle) {
141
141
  catch { }
142
142
  // Fallback: use CRM for better resolution
143
143
  try {
144
- const crm = buildCRM();
144
+ const crm = await buildCRM();
145
145
  for (const t of crm.threads) {
146
146
  if (t.handle === handle) {
147
147
  const tier = t.msgs30d > 50 ? 'T1' : t.msgs30d > 15 ? 'T2' : t.msgs30d > 5 ? 'T3' : 'T4';
@@ -191,7 +191,7 @@ async function pollCycle(config) {
191
191
  skippedCount++;
192
192
  continue;
193
193
  }
194
- const { name, tier } = resolveHandle(msg.handle);
194
+ const { name, tier } = await resolveHandle(msg.handle);
195
195
  // Only respond to configured tiers
196
196
  if (!config.respondTiers.includes(tier)) {
197
197
  processed.add(msg.rowid);
@@ -0,0 +1,98 @@
1
+ /**
2
+ * RPLY API client — localhost:19851
3
+ *
4
+ * Unified messaging API. When available, replaces direct SQLite access
5
+ * to iMessage/chat.db, removing the Full Disk Access requirement.
6
+ */
7
+ export interface RPLYConversation {
8
+ id: string;
9
+ title: string;
10
+ platform: string;
11
+ last_message_date: string;
12
+ unread_count: number;
13
+ is_group_chat: boolean;
14
+ is_never_reply: boolean;
15
+ is_dismissed: boolean;
16
+ priority?: string;
17
+ participants: RPLYParticipant[];
18
+ reply?: RPLYReply;
19
+ messages_load_state: string;
20
+ }
21
+ export interface RPLYParticipant {
22
+ display_name: string;
23
+ full_name: string;
24
+ identifier: string;
25
+ platform: string;
26
+ }
27
+ export interface RPLYReply {
28
+ id: string;
29
+ text: string;
30
+ original_text: string;
31
+ classification_reason: string;
32
+ reply_date: string;
33
+ was_edited: boolean;
34
+ }
35
+ export interface RPLYMessage {
36
+ direction: 'sent' | 'received';
37
+ date: string;
38
+ content: {
39
+ type: string;
40
+ text: string;
41
+ };
42
+ id?: string;
43
+ }
44
+ export interface RPLYContact {
45
+ display_name: string;
46
+ full_name: string;
47
+ identifier: string;
48
+ platform: string;
49
+ }
50
+ export interface RPLYSearchResult {
51
+ message: RPLYMessage & {
52
+ id: string;
53
+ };
54
+ conversation_id: string;
55
+ }
56
+ export interface RPLYStatus {
57
+ conversation_count: number;
58
+ version: string;
59
+ is_loading: boolean;
60
+ platforms: {
61
+ platform: string;
62
+ display_name: string;
63
+ is_available: boolean;
64
+ is_enabled: boolean;
65
+ }[];
66
+ }
67
+ interface Paginated<T> {
68
+ data: T[];
69
+ total: number;
70
+ limit: number;
71
+ offset: number;
72
+ }
73
+ export declare function isAvailable(): Promise<boolean>;
74
+ export declare function resetCache(): void;
75
+ export declare function status(): Promise<RPLYStatus | null>;
76
+ export declare function listConversations(opts?: {
77
+ limit?: number;
78
+ offset?: number;
79
+ category?: string;
80
+ platform?: string;
81
+ }): Promise<Paginated<RPLYConversation> | null>;
82
+ export declare function getConversation(id: string): Promise<(RPLYConversation & {
83
+ messages: RPLYMessage[];
84
+ }) | null>;
85
+ export declare function listMessages(conversationId: string, opts?: {
86
+ limit?: number;
87
+ offset?: number;
88
+ }): Promise<Paginated<RPLYMessage> | null>;
89
+ export declare function searchConversations(q: string): Promise<RPLYConversation[] | null>;
90
+ export declare function searchMessages(q: string, opts?: {
91
+ limit?: number;
92
+ }): Promise<RPLYSearchResult[] | null>;
93
+ export declare function listContacts(opts?: {
94
+ limit?: number;
95
+ offset?: number;
96
+ }): Promise<Paginated<RPLYContact> | null>;
97
+ export declare function searchContacts(q: string): Promise<RPLYContact[] | null>;
98
+ export {};
@@ -0,0 +1,79 @@
1
+ /**
2
+ * RPLY API client — localhost:19851
3
+ *
4
+ * Unified messaging API. When available, replaces direct SQLite access
5
+ * to iMessage/chat.db, removing the Full Disk Access requirement.
6
+ */
7
+ const BASE = 'http://localhost:19851';
8
+ const TIMEOUT = 5_000;
9
+ // ─── Core ────────────────────────────────────────────────────
10
+ let _available = null;
11
+ async function get(path) {
12
+ try {
13
+ const r = await fetch(`${BASE}${path}`, { signal: AbortSignal.timeout(TIMEOUT) });
14
+ if (!r.ok)
15
+ return null;
16
+ return await r.json();
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ export async function isAvailable() {
23
+ if (_available !== null)
24
+ return _available;
25
+ const s = await get('/v1/status');
26
+ _available = s !== null && !s.is_loading;
27
+ return _available;
28
+ }
29
+ export function resetCache() { _available = null; }
30
+ // ─── Endpoints ───────────────────────────────────────────────
31
+ export async function status() {
32
+ return get('/v1/status');
33
+ }
34
+ export async function listConversations(opts) {
35
+ const p = new URLSearchParams();
36
+ if (opts?.limit)
37
+ p.set('limit', String(opts.limit));
38
+ if (opts?.offset)
39
+ p.set('offset', String(opts.offset));
40
+ if (opts?.category)
41
+ p.set('category', opts.category);
42
+ if (opts?.platform)
43
+ p.set('platform', opts.platform);
44
+ const qs = p.toString();
45
+ return get(`/v1/conversations${qs ? '?' + qs : ''}`);
46
+ }
47
+ export async function getConversation(id) {
48
+ return get(`/v1/conversations/${encodeURIComponent(id)}`);
49
+ }
50
+ export async function listMessages(conversationId, opts) {
51
+ const p = new URLSearchParams();
52
+ if (opts?.limit)
53
+ p.set('limit', String(opts.limit));
54
+ if (opts?.offset)
55
+ p.set('offset', String(opts.offset));
56
+ const qs = p.toString();
57
+ return get(`/v1/conversations/${encodeURIComponent(conversationId)}/messages${qs ? '?' + qs : ''}`);
58
+ }
59
+ export async function searchConversations(q) {
60
+ return get(`/v1/conversations/search?q=${encodeURIComponent(q)}`);
61
+ }
62
+ export async function searchMessages(q, opts) {
63
+ const p = new URLSearchParams({ q });
64
+ if (opts?.limit)
65
+ p.set('limit', String(opts.limit));
66
+ return get(`/v1/messages/search?${p}`);
67
+ }
68
+ export async function listContacts(opts) {
69
+ const p = new URLSearchParams();
70
+ if (opts?.limit)
71
+ p.set('limit', String(opts.limit));
72
+ if (opts?.offset)
73
+ p.set('offset', String(opts.offset));
74
+ const qs = p.toString();
75
+ return get(`/v1/contacts${qs ? '?' + qs : ''}`);
76
+ }
77
+ export async function searchContacts(q) {
78
+ return get(`/v1/contacts/search?q=${encodeURIComponent(q)}`);
79
+ }
package/dist/tools.js CHANGED
@@ -9,6 +9,7 @@ import { execSync } from 'child_process';
9
9
  import { openDb, safeQuery } from './db.js';
10
10
  import { resolveName, buildContactMap } from './contacts.js';
11
11
  import { getUserName } from './profile.js';
12
+ import * as rply from './rply-client.js';
12
13
  import dayjs from 'dayjs';
13
14
  import { APPLE_EPOCH, CHROME_EPOCH } from './types.js';
14
15
  const home = homedir();
@@ -163,13 +164,92 @@ export const TOOLS = [
163
164
  required: []
164
165
  },
165
166
  async execute(params) {
166
- const db = openDb(join(home, 'Library/Messages/chat.db'));
167
- if (!db)
168
- return JSON.stringify({ error: 'iMessage DB not available' });
169
167
  const days = params.days || 1;
170
168
  const maxResults = params.limit || 50;
171
169
  const contactFilter = params.contact;
172
170
  const keyword = params.keyword;
171
+ // ── RPLY path ──
172
+ if (await rply.isAvailable()) {
173
+ const cutoff = dayjs().subtract(days, 'day');
174
+ const results = [];
175
+ if (keyword) {
176
+ const hits = await rply.searchMessages(keyword, { limit: maxResults * 2 });
177
+ if (hits) {
178
+ for (const h of hits) {
179
+ const d = dayjs(h.message.date);
180
+ if (d.isBefore(cutoff))
181
+ continue;
182
+ if (contactFilter) {
183
+ const conv = await rply.getConversation(h.conversation_id);
184
+ if (conv && !conv.title.toLowerCase().includes(contactFilter.toLowerCase()))
185
+ continue;
186
+ }
187
+ results.push({
188
+ from: h.message.direction === 'sent' ? getUserName() : h.conversation_id,
189
+ text: h.message.content.text.slice(0, 200),
190
+ when: d.format('ddd MMM D h:mm A'),
191
+ direction: h.message.direction,
192
+ });
193
+ if (results.length >= maxResults)
194
+ break;
195
+ }
196
+ return JSON.stringify({ count: results.length, messages: results }, null, 1);
197
+ }
198
+ }
199
+ if (contactFilter) {
200
+ const convos = await rply.searchConversations(contactFilter);
201
+ if (convos?.length) {
202
+ for (const c of convos) {
203
+ const msgs = await rply.listMessages(c.id, { limit: maxResults });
204
+ if (!msgs)
205
+ continue;
206
+ for (const m of msgs.data) {
207
+ if (dayjs(m.date).isBefore(cutoff))
208
+ continue;
209
+ if (m.content?.type !== 'text' || !m.content.text)
210
+ continue;
211
+ results.push({
212
+ from: m.direction === 'sent' ? getUserName() : c.title,
213
+ text: m.content.text.slice(0, 200),
214
+ when: dayjs(m.date).format('ddd MMM D h:mm A'),
215
+ direction: m.direction,
216
+ });
217
+ }
218
+ if (results.length >= maxResults)
219
+ break;
220
+ }
221
+ return JSON.stringify({ count: results.length, messages: results.slice(0, maxResults) }, null, 1);
222
+ }
223
+ }
224
+ // No filter: return recent messages across conversations
225
+ const convos = await rply.listConversations({ limit: 20 });
226
+ if (convos) {
227
+ for (const c of convos.data) {
228
+ const msgs = await rply.listMessages(c.id, { limit: 5 });
229
+ if (!msgs)
230
+ continue;
231
+ for (const m of msgs.data) {
232
+ if (dayjs(m.date).isBefore(cutoff))
233
+ continue;
234
+ if (m.content?.type !== 'text' || !m.content.text)
235
+ continue;
236
+ results.push({
237
+ from: m.direction === 'sent' ? getUserName() : c.title,
238
+ text: m.content.text.slice(0, 200),
239
+ when: dayjs(m.date).format('ddd MMM D h:mm A'),
240
+ direction: m.direction,
241
+ });
242
+ }
243
+ if (results.length >= maxResults)
244
+ break;
245
+ }
246
+ return JSON.stringify({ count: results.length, messages: results.slice(0, maxResults) }, null, 1);
247
+ }
248
+ }
249
+ // ── SQLite fallback ──
250
+ const db = openDb(join(home, 'Library/Messages/chat.db'));
251
+ if (!db)
252
+ return JSON.stringify({ error: 'iMessage DB not available' });
173
253
  const handles = safeQuery(db, 'SELECT ROWID, id FROM handle');
174
254
  const handleMap = new Map(handles.map(h => [h.ROWID, h.id]));
175
255
  const agoNano = (BigInt(daysAgoUnix(days) - APPLE_EPOCH) * BigInt(1e9)).toString();
@@ -215,6 +295,20 @@ export const TOOLS = [
215
295
  required: []
216
296
  },
217
297
  async execute(params) {
298
+ // ── RPLY path ──
299
+ if (await rply.isAvailable()) {
300
+ const result = await rply.listConversations({ category: 'needs_response', limit: 20 });
301
+ if (result) {
302
+ const unanswered = result.data.map(c => ({
303
+ from: c.title,
304
+ text: c.reply?.text?.slice(0, 150) || '(pending)',
305
+ when: dayjs(c.last_message_date).format('ddd MMM D h:mm A'),
306
+ platform: c.platform,
307
+ }));
308
+ return JSON.stringify({ unanswered }, null, 1);
309
+ }
310
+ }
311
+ // ── SQLite fallback ──
218
312
  const db = openDb(join(home, 'Library/Messages/chat.db'));
219
313
  if (!db)
220
314
  return JSON.stringify({ error: 'not available' });
@@ -223,7 +317,6 @@ export const TOOLS = [
223
317
  const handleMap = new Map(handles.map(h => [h.ROWID, h.id]));
224
318
  const agoNano = (BigInt(daysAgoUnix(days) - APPLE_EPOCH) * BigInt(1e9)).toString();
225
319
  const msgs = safeQuery(db, `SELECT text, date, is_from_me, handle_id FROM message WHERE date > ? ORDER BY date DESC`, [agoNano]);
226
- // Group by handle, find where last msg is from them
227
320
  const lastByHandle = new Map();
228
321
  for (const m of msgs) {
229
322
  if (!lastByHandle.has(m.handle_id))
@@ -868,6 +961,23 @@ export const TOOLS = [
868
961
  },
869
962
  async execute(params) {
870
963
  const query = params.query.toLowerCase();
964
+ // ── RPLY path ──
965
+ if (await rply.isAvailable()) {
966
+ const hits = await rply.searchContacts(query);
967
+ if (hits) {
968
+ const seen = new Set();
969
+ const results = hits.filter(c => {
970
+ if (seen.has(c.full_name))
971
+ return false;
972
+ seen.add(c.full_name);
973
+ return true;
974
+ }).slice(0, 10).map(c => ({
975
+ handle: c.identifier, name: c.full_name, platform: c.platform,
976
+ }));
977
+ return JSON.stringify({ results }, null, 1);
978
+ }
979
+ }
980
+ // ── SQLite fallback ──
871
981
  const map = buildContactMap();
872
982
  const matches = [];
873
983
  for (const [handle, name] of map) {
@@ -875,7 +985,6 @@ export const TOOLS = [
875
985
  matches.push({ handle, name });
876
986
  }
877
987
  }
878
- // Dedupe by name
879
988
  const seen = new Set();
880
989
  const unique = matches.filter(m => { if (seen.has(m.name))
881
990
  return false; seen.add(m.name); return true; });
@@ -900,12 +1009,30 @@ export const TOOLS = [
900
1009
  required: ['keywords']
901
1010
  },
902
1011
  async execute(params) {
903
- const db = openDb(join(home, 'Library/Messages/chat.db'));
904
- if (!db)
905
- return JSON.stringify({ error: 'not available' });
906
1012
  const keywords = params.keywords;
907
1013
  const days = params.days || 7;
908
1014
  const maxPer = params.limit || 20;
1015
+ const cutoff = dayjs().subtract(days, 'day');
1016
+ // ── RPLY path ──
1017
+ if (await rply.isAvailable()) {
1018
+ const results = {};
1019
+ for (const kw of keywords) {
1020
+ const hits = await rply.searchMessages(kw, { limit: maxPer * 2 });
1021
+ results[kw] = (hits || [])
1022
+ .filter(h => dayjs(h.message.date).isAfter(cutoff) && h.message.content?.text)
1023
+ .slice(0, maxPer)
1024
+ .map(h => ({
1025
+ from: h.message.direction === 'sent' ? getUserName() : h.conversation_id,
1026
+ text: h.message.content.text.slice(0, 200),
1027
+ when: dayjs(h.message.date).format('ddd MMM D h:mm A'),
1028
+ }));
1029
+ }
1030
+ return JSON.stringify(results, null, 1);
1031
+ }
1032
+ // ── SQLite fallback ──
1033
+ const db = openDb(join(home, 'Library/Messages/chat.db'));
1034
+ if (!db)
1035
+ return JSON.stringify({ error: 'not available' });
909
1036
  const handles = safeQuery(db, 'SELECT ROWID, id FROM handle');
910
1037
  const handleMap = new Map(handles.map(h => [h.ROWID, h.id]));
911
1038
  const agoNano = (BigInt(daysAgoUnix(days) - APPLE_EPOCH) * BigInt(1e9)).toString();
@@ -1177,15 +1304,37 @@ export const TOOLS = [
1177
1304
  required: ['contact']
1178
1305
  },
1179
1306
  async execute(params) {
1180
- const db = openDb(join(home, 'Library/Messages/chat.db'));
1181
- if (!db)
1182
- return JSON.stringify({ error: 'not available' });
1183
1307
  const contactName = params.contact.toLowerCase();
1184
1308
  const days = params.days || 2;
1185
1309
  const maxMsgs = params.limit || 30;
1310
+ const cutoff = dayjs().subtract(days, 'day');
1311
+ // ── RPLY path ──
1312
+ if (await rply.isAvailable()) {
1313
+ const convos = await rply.searchConversations(params.contact);
1314
+ if (convos?.length) {
1315
+ const conv = convos[0];
1316
+ const msgs = await rply.listMessages(conv.id, { limit: maxMsgs * 2 });
1317
+ if (msgs) {
1318
+ const thread = msgs.data
1319
+ .filter(m => m.content?.type === 'text' && m.content.text && dayjs(m.date).isAfter(cutoff))
1320
+ .reverse() // oldest first for thread view
1321
+ .slice(-maxMsgs)
1322
+ .map(m => ({
1323
+ from: m.direction === 'sent' ? getUserName() : conv.title,
1324
+ text: m.content.text.slice(0, 300),
1325
+ when: dayjs(m.date).format('ddd MMM D h:mm A'),
1326
+ }));
1327
+ return JSON.stringify({ contact: conv.title, thread }, null, 1);
1328
+ }
1329
+ }
1330
+ return JSON.stringify({ error: `No contact matching "${params.contact}"` });
1331
+ }
1332
+ // ── SQLite fallback ──
1333
+ const db = openDb(join(home, 'Library/Messages/chat.db'));
1334
+ if (!db)
1335
+ return JSON.stringify({ error: 'not available' });
1186
1336
  const handles = safeQuery(db, 'SELECT ROWID, id FROM handle');
1187
1337
  const handleMap = new Map(handles.map(h => [h.ROWID, h.id]));
1188
- // Find matching handle IDs
1189
1338
  const matchingHandles = handles.filter(h => {
1190
1339
  const name = resolveName(h.id);
1191
1340
  return name.toLowerCase().includes(contactName);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "life-pulse",
3
- "version": "2.2.2",
3
+ "version": "2.3.0",
4
4
  "description": "macOS life diagnostic — reads local data sources, generates actionable insights",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {