life-pulse 2.2.2 → 2.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/agent.js +7 -7
- package/dist/cli.js +54 -63
- package/dist/crm.d.ts +6 -7
- package/dist/crm.js +175 -98
- package/dist/health.js +1 -1
- package/dist/icloud-discovery.js +7 -7
- package/dist/init-check.js +13 -17
- package/dist/installer.js +79 -57
- package/dist/knowledge.d.ts +1 -1
- package/dist/knowledge.js +22 -6
- package/dist/message-loop.js +3 -3
- package/dist/progress.js +24 -24
- package/dist/router.js +1 -1
- package/dist/rply-client.d.ts +98 -0
- package/dist/rply-client.js +79 -0
- package/dist/sms-gateway.js +2 -2
- package/dist/tools.js +161 -12
- package/dist/tui.d.ts +6 -0
- package/dist/tui.js +93 -6
- package/dist/tunnel.js +4 -4
- package/dist/ui/app.js +10 -13
- package/dist/ui/progress.js +24 -24
- package/dist/ui/theme.d.ts +2 -2
- package/dist/ui/theme.js +42 -41
- package/package.json +1 -1
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/health.js
CHANGED
|
@@ -45,7 +45,7 @@ export function startHealthServer() {
|
|
|
45
45
|
healthServer.on('error', (e) => {
|
|
46
46
|
if (e.code === 'EADDRINUSE') {
|
|
47
47
|
// Another instance is running
|
|
48
|
-
console.error('
|
|
48
|
+
console.error(' already running');
|
|
49
49
|
}
|
|
50
50
|
});
|
|
51
51
|
}
|
package/dist/icloud-discovery.js
CHANGED
|
@@ -373,27 +373,27 @@ export function formatDiscoveryReport(discoveries) {
|
|
|
373
373
|
if (!discoveries.length)
|
|
374
374
|
return '';
|
|
375
375
|
const lines = [];
|
|
376
|
-
lines.push('
|
|
376
|
+
lines.push(' here\'s what I found:\n');
|
|
377
377
|
// Group by category
|
|
378
378
|
const noteApps = discoveries.filter(d => ['Obsidian', 'Bear', 'Logseq', 'Apple Notes', 'Craft', 'iA Writer', 'Ulysses'].includes(d.app));
|
|
379
379
|
const taskApps = discoveries.filter(d => ['Things 3', 'OmniFocus', 'Todoist'].includes(d.app));
|
|
380
380
|
const otherApps = discoveries.filter(d => !noteApps.includes(d) && !taskApps.includes(d));
|
|
381
381
|
if (noteApps.length) {
|
|
382
|
-
lines.push('
|
|
382
|
+
lines.push(' notes & writing');
|
|
383
383
|
for (const app of noteApps) {
|
|
384
|
-
const skill = app.skillId ? '
|
|
384
|
+
const skill = app.skillId ? ' — skill available' : '';
|
|
385
385
|
lines.push(` ${app.app}${skill}`);
|
|
386
386
|
}
|
|
387
387
|
}
|
|
388
388
|
if (taskApps.length) {
|
|
389
|
-
lines.push('
|
|
389
|
+
lines.push(' task management');
|
|
390
390
|
for (const app of taskApps) {
|
|
391
|
-
const skill = app.skillId ? '
|
|
391
|
+
const skill = app.skillId ? ' — skill available' : '';
|
|
392
392
|
lines.push(` ${app.app}${skill}`);
|
|
393
393
|
}
|
|
394
394
|
}
|
|
395
395
|
if (otherApps.length) {
|
|
396
|
-
lines.push('
|
|
396
|
+
lines.push(' other');
|
|
397
397
|
for (const app of otherApps) {
|
|
398
398
|
lines.push(` ${app.app}`);
|
|
399
399
|
}
|
|
@@ -401,7 +401,7 @@ export function formatDiscoveryReport(discoveries) {
|
|
|
401
401
|
const skillCount = discoveries.filter(d => d.skillId).length;
|
|
402
402
|
if (skillCount > 0) {
|
|
403
403
|
lines.push('');
|
|
404
|
-
lines.push(`
|
|
404
|
+
lines.push(` ${skillCount} skill${skillCount === 1 ? '' : 's'} available`);
|
|
405
405
|
}
|
|
406
406
|
return lines.join('\n');
|
|
407
407
|
}
|
package/dist/init-check.js
CHANGED
|
@@ -20,7 +20,7 @@ import { hasRequiredPermissions, getMissingPermissions } from './permissions.js'
|
|
|
20
20
|
import { loadProgress, getTimeSinceLastSession } from './session-progress.js';
|
|
21
21
|
import { getCrashStats } from './watchdog.js';
|
|
22
22
|
import { getSkills } from './skill-loader.js';
|
|
23
|
-
import { hasTailscale
|
|
23
|
+
import { hasTailscale } from './tunnel.js';
|
|
24
24
|
const home = homedir();
|
|
25
25
|
/** Run all pre-flight checks. Returns readiness status. */
|
|
26
26
|
export function runInitCheck() {
|
|
@@ -62,32 +62,29 @@ export function runInitCheck() {
|
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
checks.push({
|
|
65
|
-
check: '
|
|
65
|
+
check: 'messages',
|
|
66
66
|
passed: msgOk,
|
|
67
|
-
detail: msgOk ? undefined : '
|
|
67
|
+
detail: msgOk ? undefined : 'can\'t read messages — needs Full Disk Access',
|
|
68
68
|
});
|
|
69
69
|
// 4. API key available
|
|
70
70
|
const hasKey = !!(process.env.ANTHROPIC_API_KEY || process.env.OPENROUTER_API_KEY);
|
|
71
71
|
checks.push({
|
|
72
|
-
check: '
|
|
72
|
+
check: 'intelligence',
|
|
73
73
|
passed: hasKey,
|
|
74
|
-
detail: hasKey ? undefined : 'set ANTHROPIC_API_KEY in ~/.config/life-pulse/.env',
|
|
75
74
|
});
|
|
76
75
|
// 5. Session progress integrity
|
|
77
76
|
const progress = loadProgress();
|
|
78
77
|
const progressOk = progress.version === 2;
|
|
79
78
|
checks.push({
|
|
80
|
-
check: '
|
|
79
|
+
check: 'memory',
|
|
81
80
|
passed: progressOk,
|
|
82
|
-
detail: progressOk ? `${progress.totalSessions} sessions` : 'needs migration',
|
|
83
81
|
});
|
|
84
82
|
// 6. Crash state
|
|
85
83
|
const crashes = getCrashStats();
|
|
86
84
|
const crashOk = crashes.consecutive === 0;
|
|
87
85
|
checks.push({
|
|
88
|
-
check: '
|
|
86
|
+
check: 'stability',
|
|
89
87
|
passed: crashOk,
|
|
90
|
-
detail: crashOk ? undefined : `${crashes.consecutive} consecutive failures`,
|
|
91
88
|
});
|
|
92
89
|
// 7. Skills loaded
|
|
93
90
|
const skills = getSkills();
|
|
@@ -95,7 +92,6 @@ export function runInitCheck() {
|
|
|
95
92
|
checks.push({
|
|
96
93
|
check: 'skills',
|
|
97
94
|
passed: activeSkills > 0,
|
|
98
|
-
detail: `${activeSkills} active`,
|
|
99
95
|
});
|
|
100
96
|
// 8. Inbound gateway (port 19877)
|
|
101
97
|
let gwOk = false;
|
|
@@ -107,16 +103,14 @@ export function runInitCheck() {
|
|
|
107
103
|
}
|
|
108
104
|
catch { }
|
|
109
105
|
checks.push({
|
|
110
|
-
check: '
|
|
106
|
+
check: 'messaging',
|
|
111
107
|
passed: gwOk,
|
|
112
|
-
detail: gwOk ? 'listening' : 'not running — start daemon',
|
|
113
108
|
});
|
|
114
109
|
// 9. Tailscale installed
|
|
115
110
|
const tsOk = hasTailscale();
|
|
116
111
|
checks.push({
|
|
117
|
-
check: '
|
|
112
|
+
check: 'network',
|
|
118
113
|
passed: tsOk,
|
|
119
|
-
detail: tsOk ? (isFunnelActive() ? 'funnel active' : 'installed, funnel not active') : 'not installed',
|
|
120
114
|
});
|
|
121
115
|
// Determine mode
|
|
122
116
|
const timeSince = getTimeSinceLastSession();
|
|
@@ -134,12 +128,14 @@ export function runInitCheck() {
|
|
|
134
128
|
const allPassed = checks.every(c => c.passed);
|
|
135
129
|
if (allPassed) {
|
|
136
130
|
recommendation = mode === 'first_run'
|
|
137
|
-
? '
|
|
138
|
-
:
|
|
131
|
+
? 'ready for setup'
|
|
132
|
+
: 'everything looks good';
|
|
139
133
|
}
|
|
140
134
|
else {
|
|
141
135
|
const failing = checks.filter(c => !c.passed);
|
|
142
|
-
recommendation =
|
|
136
|
+
recommendation = failing.length === 1
|
|
137
|
+
? `${failing[0].check} needs attention`
|
|
138
|
+
: `${failing.length} things need attention`;
|
|
143
139
|
}
|
|
144
140
|
return {
|
|
145
141
|
ready: allPassed || (permOk && hasKey), // Can still run in degraded mode
|