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/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/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('Health server port in use — another instance running?');
48
+ console.error(' already running');
49
49
  }
50
50
  });
51
51
  }
@@ -373,27 +373,27 @@ export function formatDiscoveryReport(discoveries) {
373
373
  if (!discoveries.length)
374
374
  return '';
375
375
  const lines = [];
376
- lines.push('📱 Here\'s what I found about you:\n');
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(' 📝 Notes & Writing');
382
+ lines.push(' notes & writing');
383
383
  for (const app of noteApps) {
384
- const skill = app.skillId ? ' skill available' : '';
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(' Task Management');
389
+ lines.push(' task management');
390
390
  for (const app of taskApps) {
391
- const skill = app.skillId ? ' skill available' : '';
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(' 📦 Other Apps');
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(` 💡 ${skillCount} skill${skillCount === 1 ? '' : 's'} available to install`);
404
+ lines.push(` ${skillCount} skill${skillCount === 1 ? '' : 's'} available`);
405
405
  }
406
406
  return lines.join('\n');
407
407
  }
@@ -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, isFunnelActive } from './tunnel.js';
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: 'iMessage DB',
65
+ check: 'messages',
66
66
  passed: msgOk,
67
- detail: msgOk ? undefined : 'cannot read Messages database need Full Disk Access',
67
+ detail: msgOk ? undefined : 'can\'t read messagesneeds 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: 'API key',
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: 'session progress',
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: 'crash state',
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: 'gateway (:19877)',
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: 'Tailscale',
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
- ? 'Ready for first-run setup: permissions → discovery → archetype → agent'
138
- : `Ready. ${timeSince.readable} since last session. ${activeSkills} skills active.`;
131
+ ? 'ready for setup'
132
+ : 'everything looks good';
139
133
  }
140
134
  else {
141
135
  const failing = checks.filter(c => !c.passed);
142
- recommendation = `${failing.length} check(s) failing: ${failing.map(c => c.check).join(', ')}. Fix before running agent.`;
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