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 +3 -3
- package/dist/cli.js +3 -3
- package/dist/crm.d.ts +6 -7
- package/dist/crm.js +175 -98
- package/dist/installer.js +46 -2
- package/dist/knowledge.d.ts +1 -1
- package/dist/knowledge.js +22 -6
- package/dist/message-loop.js +3 -3
- package/dist/rply-client.d.ts +98 -0
- package/dist/rply-client.js +79 -0
- package/dist/tools.js +161 -12
- package/package.json +1 -1
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
32
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
|
|
206
|
-
|
|
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
|
|
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'));
|
package/dist/knowledge.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
120
|
-
return `${
|
|
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;
|
package/dist/message-loop.js
CHANGED
|
@@ -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);
|