life-pulse 2.3.7 → 2.3.8

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/cli.js CHANGED
@@ -6,11 +6,11 @@ import { saveState, saveDecisions } from './state.js';
6
6
  // ProgressRenderer unused — removed
7
7
  import { InkProgress } from './ui/progress.js';
8
8
  import { addTodo, resolveTodos, pruneOld } from './todo.js';
9
- import { renderIntro, renderCRMList, renderCRMContact, pickCard, renderSection, renderSectionHeader, renderHandled, renderDivider, renderBrief, renderArchetype, Spinner, destroyUI, MAG, CYN, RED, AMB, MID, DIM } from './tui.js';
9
+ import { renderIntro, renderCRMList, revealContacts, revealInsights, pickCard, renderSection, renderSectionHeader, renderHandled, renderDivider, renderBrief, renderArchetype, Spinner, destroyUI, MAG, CYN, RED, AMB, MID, DIM } from './tui.js';
10
10
  import { needsDiscovery, discoverPlatforms, savePlatforms } from './platforms.js';
11
11
  import { generateArchetype } from './archetype.js';
12
12
  import { runPermissionFlow, getMissingPermissions } from './permissions.js';
13
- import { buildCRM, streamEnrichedCRM } from './crm.js';
13
+ import { buildCRM, streamEnrichedCRM, generateInsights } from './crm.js';
14
14
  import { saveContactSummaries } from './intelligence.js';
15
15
  import { getUserName } from './profile.js';
16
16
  // Session progress tracking (Anthropic long-running agent pattern)
@@ -89,28 +89,27 @@ async function showCRM(apiKey, opts) {
89
89
  return false;
90
90
  }
91
91
  opts?.spinner?.stop();
92
+ console.log();
93
+ // Fire insights in background — they'll resolve while user steps through contacts
94
+ const insightsGen = generateInsights(crm);
92
95
  const calendarContext = opts?.calendarContext ?? '';
93
96
  const hour = new Date().getHours();
94
97
  const timeOfDay = hour < 12 ? 'morning' : hour < 17 ? 'afternoon' : 'evening';
95
- // Stream contacts as they enrich — each name + relationship appears live
96
98
  let brief = '';
97
- const enriched = [];
98
- const enrichedNames = new Set();
99
- for await (const t of streamEnrichedCRM(crm, apiKey, {
99
+ const enriched = await revealContacts(streamEnrichedCRM(crm, apiKey, {
100
100
  calendarContext,
101
101
  timeOfDay,
102
102
  onBrief: (b) => { brief = b; },
103
- })) {
104
- enriched.push(t);
105
- enrichedNames.add(t.name);
106
- await renderCRMContact(t);
107
- }
103
+ }));
104
+ const enrichedNames = new Set(enriched.map(t => t.name));
108
105
  // Show remaining contacts (not enriched) — just names
109
106
  const remaining = crm.threads.filter(t => !enrichedNames.has(t.name)).slice(0, 5);
110
107
  if (remaining.length)
111
108
  renderCRMList(remaining);
112
109
  if (brief)
113
110
  await renderBrief(brief);
111
+ // Insights drip in after contacts
112
+ await revealInsights(insightsGen);
114
113
  saveContactSummaries(enriched);
115
114
  return true;
116
115
  }
package/dist/crm.d.ts CHANGED
@@ -40,4 +40,9 @@ export declare function streamEnrichedCRM(crm: CRM, _apiKey?: string, opts?: {
40
40
  timeOfDay?: string;
41
41
  onBrief?: (brief: string) => void;
42
42
  }): AsyncGenerator<ThreadState>;
43
+ export interface Insight {
44
+ label: string;
45
+ text: string;
46
+ }
47
+ export declare function generateInsights(crm: CRM): AsyncGenerator<Insight>;
43
48
  export declare function buildCRMContext(crm: CRM): string;
package/dist/crm.js CHANGED
@@ -12,13 +12,31 @@ import { join } from 'path';
12
12
  import { openDb, safeQuery } from './db.js';
13
13
  import { resolveName } from './contacts.js';
14
14
  import * as rply from './rply-client.js';
15
- // OpenRouter key for fast CRM enrichment (GLM-4.7)
16
- const OPENROUTER_KEY = process.env.OPENROUTER_API_KEY || '';
15
+ const ENRICH_KEY = process.env.ANTHROPIC_API_KEY || '';
17
16
  import dayjs from 'dayjs';
18
17
  import relativeTime from 'dayjs/plugin/relativeTime.js';
19
18
  dayjs.extend(relativeTime);
20
19
  import { APPLE_EPOCH } from './types.js';
21
20
  const NANO = BigInt(1e9);
21
+ // ─── Contact quality filter ─────────────────────────────────────
22
+ const BUSINESS_PATTERNS = [
23
+ /^apple\s/i, /\bstore\b/i, /\bsupport\b/i, /\bairlines?\b/i,
24
+ /\bbank\b/i, /\binsurance\b/i, /\bpharmacy\b/i, /\bclinic\b/i,
25
+ /\bhotel\b/i, /\bairport\b/i, /\bdelivery\b/i, /\bservice\b/i,
26
+ /\binc\.?\b/i, /\bllc\b/i, /\bcorp\b/i, /^doordash/i, /^uber/i,
27
+ /^lyft/i, /^venmo/i, /^cashapp/i, /^paypal/i, /^amazon/i,
28
+ ];
29
+ function isHumanContact(name) {
30
+ if (!name || name.length < 3)
31
+ return false;
32
+ // Raw phone numbers
33
+ if (/^\+?\d[\d\s()-]{6,}$/.test(name.trim()))
34
+ return false;
35
+ // Businesses
36
+ if (BUSINESS_PATTERNS.some(p => p.test(name)))
37
+ return false;
38
+ return true;
39
+ }
22
40
  function nowNano() {
23
41
  return (BigInt(dayjs().unix() - APPLE_EPOCH) * NANO).toString();
24
42
  }
@@ -118,7 +136,14 @@ async function buildCRMFromRPLY() {
118
136
  recentSnippets: recentSnippets.slice(0, 30),
119
137
  });
120
138
  }
121
- threads.sort((a, b) => b.msgs30d - a.msgs30d);
139
+ // Sort by total message volume (all-time proxy: total fetched msgs, not just 30d)
140
+ threads.sort((a, b) => {
141
+ // Total messages fetched is the best all-time proxy from RPLY (200 limit per contact)
142
+ // Fall back to 30d if equal
143
+ const aTotal = (a.conversationPairs * 2) + a.msgs30d;
144
+ const bTotal = (b.conversationPairs * 2) + b.msgs30d;
145
+ return bTotal - aTotal;
146
+ });
122
147
  const yourAvg = allResponseTimes.length > 0
123
148
  ? allResponseTimes.reduce((s, r) => s + r.avg * r.pairs, 0)
124
149
  / allResponseTimes.reduce((s, r) => s + r.pairs, 0)
@@ -190,7 +215,7 @@ function buildCRMFromSQLite() {
190
215
  ORDER BY avg_response_seconds ASC LIMIT 50
191
216
  `, [ago30, now]);
192
217
  const responseMap = new Map(responseTimes.map(r => [r.handle_id, r]));
193
- // ── 2. Top contacts by 30-day volume ──
218
+ // ── 2. Top contacts by ALL-TIME volume ──
194
219
  const topContacts = safeQuery(db, `
195
220
  WITH NonGroupChats AS (
196
221
  SELECT chat_id FROM chat_handle_join GROUP BY chat_id HAVING COUNT(DISTINCT handle_id) = 1
@@ -199,9 +224,9 @@ function buildCRMFromSQLite() {
199
224
  FROM message m JOIN handle h ON m.handle_id = h.ROWID
200
225
  JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
201
226
  JOIN NonGroupChats ngc ON ngc.chat_id = cmj.chat_id
202
- WHERE m.date >= ? AND m.date <= ? AND m.associated_message_type = 0 AND h.id NOT LIKE 'urn:biz:%'
227
+ WHERE m.associated_message_type = 0 AND h.id NOT LIKE 'urn:biz:%'
203
228
  GROUP BY h.id ORDER BY msg_count DESC LIMIT 30
204
- `, [ago30, now]);
229
+ `);
205
230
  // ── 3. Last message per contact ──
206
231
  const lastMessages = safeQuery(db, `
207
232
  WITH NonGroupChats AS (
@@ -308,7 +333,7 @@ function buildCRMFromSQLite() {
308
333
  }
309
334
  threads.push({
310
335
  name, handle: handleId,
311
- msgs30d: contact?.msg_count || 0,
336
+ msgs30d: contact?.msg_count || 0, // now all-time volume from SQL
312
337
  avgResponseSec: response?.avg_response_seconds ?? null,
313
338
  conversationPairs: response?.conversation_pairs ?? 0,
314
339
  lastMsg, waitingOnYou, waitingOnThem, waitingSince,
@@ -324,10 +349,55 @@ function buildCRMFromSQLite() {
324
349
  : null;
325
350
  return { threads: threads.slice(0, 25), yourAvgResponseSec: yourAvg, generatedAt: dayjs().toISOString() };
326
351
  }
352
+ // ─── Per-contact enrichment ──────────────────────────────────────
353
+ async function enrichContact(t) {
354
+ const snippets = (t.recentSnippets || []).slice(0, 25).reverse().join('\n');
355
+ if (!snippets)
356
+ return null;
357
+ const tag = t.waitingOnYou ? ' [WAITING ON YOU]'
358
+ : t.waitingOnThem ? ` [WAITING ON THEM — ${t.waitingSince}]` : '';
359
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
360
+ method: 'POST',
361
+ headers: {
362
+ 'x-api-key': ENRICH_KEY,
363
+ 'anthropic-version': '2023-06-01',
364
+ 'content-type': 'application/json',
365
+ },
366
+ body: JSON.stringify({
367
+ model: 'claude-sonnet-4-5-20250929',
368
+ max_tokens: 100,
369
+ messages: [{
370
+ role: 'user',
371
+ content: `${t.name}.${tag} Two sentences max. What's the vibe, what's live right now. Use names from the messages. No fluff.
372
+ Priority: "work", "personal", or "service" (businesses/bots/automated).
373
+
374
+ ${snippets}
375
+
376
+ JSON only: {"summary": "...", "priority": "work|personal|service"}`,
377
+ }],
378
+ }),
379
+ });
380
+ if (!res.ok)
381
+ return null;
382
+ const data = await res.json();
383
+ const content = data.content?.[0]?.text?.trim() || '';
384
+ const jsonMatch = content.match(/\{[\s\S]*\}/);
385
+ if (!jsonMatch)
386
+ return { relationship: content.slice(0, 300), priority: 'personal' };
387
+ try {
388
+ const parsed = JSON.parse(jsonMatch[0]);
389
+ return { relationship: parsed.summary || '', priority: parsed.priority || 'personal' };
390
+ }
391
+ catch {
392
+ return { relationship: content.slice(0, 300), priority: 'personal' };
393
+ }
394
+ }
327
395
  // ─── Stream-enrich CRM ──────────────────────────────────────────
328
396
  export async function* streamEnrichedCRM(crm, _apiKey, opts) {
329
- const top = crm.threads.slice(0, 10);
330
- if (!top.length || !OPENROUTER_KEY) {
397
+ // Filter: only real people, not businesses or raw phone numbers
398
+ const humans = crm.threads.filter(t => isHumanContact(t.name));
399
+ const top = humans.slice(0, 10);
400
+ if (!top.length || !ENRICH_KEY) {
331
401
  for (const t of top)
332
402
  yield t;
333
403
  return;
@@ -338,165 +408,149 @@ export async function* streamEnrichedCRM(crm, _apiKey, opts) {
338
408
  yield t;
339
409
  return;
340
410
  }
341
- const contactBlocks = withSnippets.map(t => {
342
- const snippets = (t.recentSnippets || []).slice(0, 25).reverse().join('\n');
343
- const tag = t.waitingOnYou ? ' [WAITING ON YOU]'
344
- : t.waitingOnThem ? ` [WAITING ON THEM — ${t.waitingSince}]` : '';
345
- return `[${t.name}]${tag}\n${snippets}`;
346
- }).join('\n\n---\n\n');
347
- const calendar = opts?.calendarContext
348
- ? `\nTODAY'S CALENDAR:\n${opts.calendarContext}\n` : '';
349
- const timeOfDay = opts?.timeOfDay || 'today';
350
- const nameMap = new Map(top.map(t => [t.name.toLowerCase(), t]));
351
- const yielded = new Set();
352
- const ready = [];
353
- let curName = null;
354
- let curLines = [];
355
- let curPriority = null;
356
- const flushContact = () => {
357
- if (!curName)
358
- return;
359
- const thread = nameMap.get(curName.toLowerCase());
360
- if (thread && !yielded.has(thread.name)) {
361
- thread.relationship = curLines.join(' ').trim();
362
- thread.priority = curPriority || undefined;
363
- yielded.add(thread.name);
364
- ready.push(thread);
411
+ const indexed = withSnippets.map((t, i) => enrichContact(t)
412
+ .then(r => {
413
+ if (r) {
414
+ t.relationship = r.relationship;
415
+ t.priority = r.priority;
365
416
  }
366
- curName = null;
367
- curLines = [];
368
- curPriority = null;
369
- };
370
- try {
371
- const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
372
- method: 'POST',
373
- headers: {
374
- 'Authorization': `Bearer ${OPENROUTER_KEY}`,
375
- 'Content-Type': 'application/json',
376
- 'HTTP-Referer': 'https://life-pulse.dev',
377
- },
378
- body: JSON.stringify({
379
- model: 'z-ai/glm-4.7',
380
- max_tokens: 3000,
381
- stream: true,
382
- messages: [{
383
- role: 'user',
384
- content: `You are a personal chief of staff analyzing someone's message history this ${timeOfDay}.
385
-
386
- Recent iMessage threads below. → = they sent, ← = contact sent.
387
- ${calendar}
388
- For each contact, write a detailed relationship summary based on the actual messages.
389
-
390
- OUTPUT FORMAT:
391
-
392
- CONTACTS:
393
-
394
- [Name]
395
- 2-4 sentence summary. First sentence: describe the relationship type and tone. Then: what they're specifically discussing — name projects, events, plans, people, asks. Last: conversation style and patterns.
396
- Priority: work|personal|service
397
-
398
- [Next Name]
399
- ...
400
-
401
- BRIEF:
402
- 2-3 warm, specific sentences about what matters most right now across all relationships. Reference real names and details. Write like a friend, not a report.
403
-
404
- STYLE GUIDE:
405
- - Be specific from the messages. Name projects, events, topics, people mentioned.
406
- - Good: "Professional, collaborative relationship — coordinating a product launch and troubleshooting UI bugs. Will found a demo mode issue and pushed a MainActor fix to the PR branch. Conversation is concise, technical, and focused on shipping."
407
- - Good: "Casual, affectionate exchange between two close friends. They're planning a Tulum trip — she sent dates and is waiting for confirmation. Conversation style is informal, enthusiastic, and emoji-laden."
408
- - Bad: "Colleague work discussions"
409
- - Bad: "Close friend — regular communication"
410
- - Priority: "work" for professional/business, "personal" for friends/family/social, "service" for automated/transactional/bots
411
- - BRIEF: synthesize across relationships. Mention names. Don't list — narrate.
412
- - If someone has a calendar meeting today, weave that in naturally.
413
-
414
- ${contactBlocks}`,
415
- }],
416
- }),
417
- });
418
- if (!res.ok || !res.body)
419
- throw new Error(`OpenRouter ${res.status}`);
420
- // Stream SSE deltas — yield contacts the moment each block completes
421
- let section = 'preamble';
422
- const briefLines = [];
423
- let lineBuf = '';
424
- let sseBuf = '';
425
- const processLine = (rawLine) => {
426
- const line = rawLine.trim();
427
- if (/^CONTACTS:?$/i.test(line)) {
428
- section = 'contacts';
429
- return;
430
- }
431
- if (/^BRIEF:?$/i.test(line)) {
432
- flushContact();
433
- section = 'brief';
434
- return;
435
- }
436
- const bracketMatch = line.match(/^\[([^\]]+)\]/);
437
- if (bracketMatch && (section === 'contacts' || section === 'preamble')) {
438
- flushContact();
439
- curName = bracketMatch[1].trim();
440
- section = 'contacts';
441
- return;
442
- }
443
- if (!line)
444
- return;
445
- if (section === 'contacts' || section === 'preamble') {
446
- if (line.startsWith('Priority:')) {
447
- curPriority = line.replace('Priority:', '').trim().toLowerCase();
448
- }
449
- else if (curName) {
450
- curLines.push(line);
451
- }
452
- }
453
- else if (section === 'brief') {
454
- briefLines.push(line);
455
- }
456
- };
457
- const decoder = new TextDecoder();
458
- const reader = res.body.getReader();
459
- while (true) {
460
- const { done, value } = await reader.read();
461
- if (done)
462
- break;
463
- sseBuf += decoder.decode(value, { stream: true });
464
- const sseLines = sseBuf.split('\n');
465
- sseBuf = sseLines.pop() || '';
466
- for (const sseLine of sseLines) {
467
- if (!sseLine.startsWith('data: ') || sseLine === 'data: [DONE]')
468
- continue;
469
- try {
470
- const json = JSON.parse(sseLine.slice(6));
471
- const delta = json.choices?.[0]?.delta?.content;
472
- if (!delta)
473
- continue;
474
- lineBuf += delta;
475
- const parts = lineBuf.split('\n');
476
- lineBuf = parts.pop() || '';
477
- for (const line of parts)
478
- processLine(line);
479
- while (ready.length)
480
- yield ready.shift();
417
+ return { thread: t, idx: i };
418
+ })
419
+ .catch(() => ({ thread: t, idx: i })));
420
+ const pending = new Map(indexed.map((p, i) => [i, p]));
421
+ const yielded = new Set();
422
+ while (pending.size > 0) {
423
+ const resolved = await Promise.race(pending.values());
424
+ pending.delete(resolved.idx);
425
+ // Skip contacts classified as "service" (businesses, bots)
426
+ if (resolved.thread.priority === 'service')
427
+ continue;
428
+ yielded.add(resolved.thread.name);
429
+ yield resolved.thread;
430
+ }
431
+ // Yield remaining contacts without snippets (already filtered to humans)
432
+ for (const t of top) {
433
+ if (!yielded.has(t.name) && t.priority !== 'service')
434
+ yield t;
435
+ }
436
+ // Brief from collected summaries
437
+ if (opts?.onBrief) {
438
+ const summaries = top.filter(t => t.relationship).map(t => `${t.name}: ${t.relationship}`).join('\n');
439
+ if (summaries) {
440
+ try {
441
+ const briefRes = await fetch('https://api.anthropic.com/v1/messages', {
442
+ method: 'POST',
443
+ headers: {
444
+ 'x-api-key': ENRICH_KEY,
445
+ 'anthropic-version': '2023-06-01',
446
+ 'content-type': 'application/json',
447
+ },
448
+ body: JSON.stringify({
449
+ model: 'claude-sonnet-4-5-20250929',
450
+ max_tokens: 150,
451
+ messages: [{
452
+ role: 'user',
453
+ content: `You're their trusted aide. Given these relationship summaries, write 2 short sentences about what matters most in their world right now. Address them as "you". Reference real names. No lists, no counting, no statistics.\n\n${summaries}`,
454
+ }],
455
+ }),
456
+ });
457
+ if (briefRes.ok) {
458
+ const data = await briefRes.json();
459
+ const brief = data.content?.[0]?.text?.trim() || '';
460
+ if (brief)
461
+ opts.onBrief(brief);
481
462
  }
482
- catch { }
483
463
  }
464
+ catch { }
484
465
  }
485
- // Flush remaining buffer
486
- if (lineBuf.trim())
487
- processLine(lineBuf);
488
- flushContact();
489
- while (ready.length)
490
- yield ready.shift();
491
- if (briefLines.length && opts?.onBrief)
492
- opts.onBrief(briefLines.join(' '));
493
466
  }
494
- catch (err) {
495
- // silent — contacts still show without enrichment
467
+ }
468
+ function extractSignals(crm) {
469
+ const t = crm.threads;
470
+ const hot = t.filter(x => x.threadTemp === 'hot').length;
471
+ const warm = t.filter(x => x.threadTemp === 'warm').length;
472
+ const cooling = t.filter(x => x.threadTemp === 'cooling').length;
473
+ const cold = t.filter(x => x.threadTemp === 'cold').length;
474
+ const rising = t.filter(x => x.monthlyTrend === 'rising');
475
+ const declining = t.filter(x => x.monthlyTrend === 'declining');
476
+ const waiting = t.filter(x => x.waitingOnYou);
477
+ const totalMsgs = t.reduce((s, x) => s + x.msgs30d, 0);
478
+ const activeThreads = t.filter(x => x.msgs30d > 0).length;
479
+ // Response time buckets
480
+ const withResponse = t.filter(x => x.avgResponseSec != null && x.conversationPairs >= 3);
481
+ const fastResponders = withResponse.filter(x => x.avgResponseSec < 600); // <10 min
482
+ const slowResponders = withResponse.filter(x => x.avgResponseSec > 86400); // >1 day
483
+ // Message timing (hour buckets from lastMsg timestamps)
484
+ const hourBuckets = new Array(24).fill(0);
485
+ for (const thread of t) {
486
+ if (thread.lastMsg?.when) {
487
+ const h = new Date(thread.lastMsg.when).getHours();
488
+ hourBuckets[h] += thread.msgs30d;
489
+ }
496
490
  }
497
- for (const t of top) {
498
- if (!yielded.has(t.name) && t.recentSnippets?.length)
499
- yield t;
491
+ return {
492
+ hot, warm, cooling, cold, rising, declining, waiting,
493
+ totalMsgs, activeThreads, withResponse, fastResponders, slowResponders,
494
+ hourBuckets, threads: t,
495
+ yourAvgSec: crm.yourAvgResponseSec,
496
+ };
497
+ }
498
+ async function callInsight(prompt) {
499
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
500
+ method: 'POST',
501
+ headers: {
502
+ 'x-api-key': ENRICH_KEY,
503
+ 'anthropic-version': '2023-06-01',
504
+ 'content-type': 'application/json',
505
+ },
506
+ body: JSON.stringify({
507
+ model: 'claude-sonnet-4-5-20250929',
508
+ max_tokens: 120,
509
+ messages: [{ role: 'user', content: prompt }],
510
+ }),
511
+ });
512
+ if (!res.ok)
513
+ throw new Error(`insight ${res.status}`);
514
+ const data = await res.json();
515
+ return data.content?.[0]?.text?.trim() || '';
516
+ }
517
+ export async function* generateInsights(crm) {
518
+ if (!ENRICH_KEY || crm.threads.length < 3)
519
+ return;
520
+ const s = extractSignals(crm);
521
+ const prompts = [
522
+ {
523
+ label: 'chronotype',
524
+ prompt: `Based on this message volume by hour (index = hour 0-23): [${s.hourBuckets.join(',')}]
525
+
526
+ Write ONE sentence about whether this person is a morning person, night owl, or peaks midday. Be specific about the hours. No hedging. Address them as "you".`,
527
+ },
528
+ {
529
+ label: 'response personality',
530
+ prompt: `This person has ${s.withResponse.length} contacts they regularly text.
531
+ Fast replies (<10 min avg): ${s.fastResponders.map(t => t.name).join(', ') || 'none'}
532
+ Slow replies (>1 day avg): ${s.slowResponders.map(t => t.name).join(', ') || 'none'}
533
+ Overall avg response: ${s.yourAvgSec ? fmtTime(s.yourAvgSec) : 'unknown'}
534
+
535
+ Write ONE sentence about their response patterns. Name names. Be direct. Address them as "you".`,
536
+ },
537
+ {
538
+ label: 'inner circle',
539
+ prompt: `This person's relationship map: ${s.hot} hot (daily), ${s.warm} warm (every few days), ${s.cooling} cooling (weekly), ${s.cold} cold (dormant).
540
+ Top 3 by volume: ${s.threads.slice(0, 3).map(t => `${t.name} (${t.msgs30d} msgs/mo)`).join(', ')}
541
+
542
+ Write ONE sentence about the shape of their social world. Be specific. Address them as "you".`,
543
+ },
544
+ ];
545
+ const indexed = prompts.map((p, i) => callInsight(p.prompt)
546
+ .then(text => ({ label: p.label, text, idx: i }))
547
+ .catch(() => ({ label: p.label, text: '', idx: i })));
548
+ const pending = new Map(indexed.map((p, i) => [i, p]));
549
+ while (pending.size > 0) {
550
+ const resolved = await Promise.race(pending.values());
551
+ pending.delete(resolved.idx);
552
+ if (resolved.text)
553
+ yield { label: resolved.label, text: resolved.text };
500
554
  }
501
555
  }
502
556
  // ─── CRM context for agent prompt injection ─────────────────────
@@ -373,7 +373,7 @@ export function formatDiscoveryReport(discoveries) {
373
373
  if (!discoveries.length)
374
374
  return '';
375
375
  const lines = [];
376
- lines.push(' here\'s what I found:\n');
376
+ lines.push('');
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));
package/dist/tui.d.ts CHANGED
@@ -63,7 +63,11 @@ interface CRMThread {
63
63
  } | null;
64
64
  relationship?: string;
65
65
  }
66
- export declare function renderCRMStream(source: AsyncIterable<CRMThread>, spinner?: Spinner): Promise<CRMThread[]>;
67
- export declare function renderCRMContact(t: CRMThread): Promise<void>;
66
+ export declare function revealContacts(source: AsyncIterable<CRMThread>): Promise<CRMThread[]>;
67
+ interface InsightItem {
68
+ label: string;
69
+ text: string;
70
+ }
71
+ export declare function revealInsights(source: AsyncIterable<InsightItem>): Promise<void>;
68
72
  export declare function destroyUI(): void;
69
73
  export {};
package/dist/tui.js CHANGED
@@ -39,21 +39,34 @@ async function typewrite(text, style, delay = 30) {
39
39
  await _sleep(delay);
40
40
  }
41
41
  }
42
+ // WMO weather code → short description
43
+ const WMO = {
44
+ 0: 'clear', 1: 'mostly clear', 2: 'partly cloudy', 3: 'overcast',
45
+ 45: 'foggy', 48: 'foggy', 51: 'light drizzle', 53: 'drizzle', 55: 'drizzle',
46
+ 61: 'light rain', 63: 'rain', 65: 'heavy rain', 66: 'freezing rain', 67: 'freezing rain',
47
+ 71: 'light snow', 73: 'snow', 75: 'heavy snow', 77: 'snow',
48
+ 80: 'rain showers', 81: 'rain showers', 82: 'heavy showers',
49
+ 85: 'snow showers', 86: 'heavy snow', 95: 'thunderstorm', 96: 'thunderstorm', 99: 'thunderstorm',
50
+ };
42
51
  async function fetchLocationWeather() {
43
52
  try {
44
- const ac = new AbortController();
45
- const t = setTimeout(() => ac.abort(), 3000);
46
- const r = await fetch(`https://wttr.in/?format=%l|%t|%C`, { signal: ac.signal, headers: { 'User-Agent': 'life-pulse' } });
47
- clearTimeout(t);
48
- const raw = (await r.text()).trim();
49
- const [loc, temp, cond] = raw.split('|');
50
- if (!loc || !temp || !cond)
53
+ // 1. System timezone → city name + coordinates via Open-Meteo geocoding
54
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
55
+ const cityGuess = tz.split('/').pop()?.replace(/_/g, ' ');
56
+ if (!cityGuess)
51
57
  return null;
52
- // loc comes back as "City, Region, Country" — take just the city
53
- const city = loc.split(',')[0].trim();
54
- const deg = temp.replace(/[+\s]/g, '').replace(/°[CF]/, '°');
55
- const sky = cond.trim().toLowerCase();
56
- return { city, weather: `${deg} and ${sky}` };
58
+ const geoR = await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(cityGuess)}&count=1`, { signal: AbortSignal.timeout(3000) });
59
+ const geo = await geoR.json();
60
+ const place = geo.results?.[0];
61
+ if (!place)
62
+ return null;
63
+ // 2. Current weather from coordinates — no API key
64
+ const wxR = await fetch(`https://api.open-meteo.com/v1/forecast?latitude=${place.latitude}&longitude=${place.longitude}&current=temperature_2m,weather_code&temperature_unit=fahrenheit`, { signal: AbortSignal.timeout(3000) });
65
+ const wx = await wxR.json();
66
+ const temp = Math.round(wx.current?.temperature_2m ?? 0);
67
+ const code = wx.current?.weather_code ?? -1;
68
+ const sky = WMO[code] || 'clear';
69
+ return { city: place.name, weather: `${temp}° and ${sky}` };
57
70
  }
58
71
  catch {
59
72
  return null;
@@ -63,7 +76,7 @@ function timeGreeting(h) {
63
76
  if (h < 5)
64
77
  return "Don't stay up too late.";
65
78
  if (h < 12)
66
- return 'Hope you have an incredible morning.';
79
+ return "Hope you're having an incredible morning.";
67
80
  if (h < 17)
68
81
  return "Hope you're having a wonderful afternoon.";
69
82
  if (h < 21)
@@ -72,14 +85,16 @@ function timeGreeting(h) {
72
85
  }
73
86
  function weatherRemark(weather) {
74
87
  const w = weather.toLowerCase();
75
- if (w.includes('sunny') || w.includes('clear'))
88
+ if (w.includes('clear') || w.includes('sunny'))
76
89
  return `${weather} out there.`;
77
- if (w.includes('rain') || w.includes('drizzle'))
90
+ if (w.includes('rain') || w.includes('drizzle') || w.includes('shower'))
78
91
  return `${weather} — good day to stay sharp.`;
79
- if (w.includes('cloud') || w.includes('overcast'))
92
+ if (w.includes('cloud') || w.includes('overcast') || w.includes('fog'))
80
93
  return `${weather}.`;
81
94
  if (w.includes('snow'))
82
95
  return `${weather}. Stay warm.`;
96
+ if (w.includes('thunder'))
97
+ return `${weather}. Stay inside.`;
83
98
  return `${weather}.`;
84
99
  }
85
100
  // ─── Geometric animation ───
@@ -167,6 +182,7 @@ export async function renderIntro(name) {
167
182
  greetLines.push(` ${G2(`I see you're in ${loc.city}. ${weatherRemark(loc.weather)}`)}`);
168
183
  greetLines.push(` ${G3(greeting)}`);
169
184
  greetLines.push('');
185
+ greetLines.push('');
170
186
  if (USE_INK)
171
187
  ink.initInk(greetLines);
172
188
  }
@@ -279,35 +295,82 @@ export class Spinner {
279
295
  export function renderFYI(title, context) {
280
296
  out(` ${C.dim('·')} ${title}${context ? ' ' + C.dim(context) : ''}`);
281
297
  }
282
- export async function renderCRMStream(source, spinner) {
283
- const w = W();
284
- const rendered = [];
285
- let first = true;
286
- for await (const t of source) {
287
- if (first) {
288
- spinner?.stop();
289
- out(` ${C.faint('─'.repeat(w - 4))}`);
290
- out('');
291
- first = false;
298
+ export async function revealContacts(source) {
299
+ const buffer = [];
300
+ let done = false;
301
+ const revealed = [];
302
+ // Background: drain async generator into buffer
303
+ const drain = (async () => {
304
+ for await (const t of source)
305
+ buffer.push(t);
306
+ done = true;
307
+ })();
308
+ if (USE_INK)
309
+ ink.showSpinner('reading the room');
310
+ while (!done || buffer.length > 0) {
311
+ if (buffer.length === 0) {
312
+ await _sleep(80);
313
+ continue;
292
314
  }
293
- out(` ${C.hd(t.name.slice(0, 40))}`);
315
+ if (USE_INK)
316
+ ink.hideSpinner();
317
+ const t = buffer.shift();
318
+ revealed.push(t);
319
+ const w = W();
320
+ const name = t.name.slice(0, 24);
321
+ const ago = shortAgo(t.lastMsg?.ago || '');
322
+ const gap = Math.max(2, w - 4 - name.length - ago.length);
323
+ out('');
324
+ out(` ${C.hd(name)}${' '.repeat(gap)}${C.dim(ago)}`);
294
325
  if (t.relationship) {
295
- out(` ${C.mid(t.relationship.slice(0, w - 8))}`);
326
+ // Word-wrap relationship across lines
327
+ const maxW = w - 8;
328
+ const words = t.relationship.split(' ');
329
+ let line = '';
330
+ for (const word of words) {
331
+ if (line.length + word.length + 1 > maxW && line) {
332
+ out(` ${C.mid(line)}`);
333
+ line = word;
334
+ }
335
+ else {
336
+ line = line ? line + ' ' + word : word;
337
+ }
338
+ }
339
+ if (line)
340
+ out(` ${C.mid(line)}`);
341
+ }
342
+ // Wait for Enter before showing next (interactive only)
343
+ if (USE_INK && (!done || buffer.length > 0)) {
344
+ await ink.waitForEnter();
296
345
  }
297
- rendered.push(t);
298
- await _sleep(180);
299
346
  }
300
- if (first)
301
- spinner?.stop();
347
+ await drain;
348
+ if (USE_INK)
349
+ ink.hideSpinner();
302
350
  out('');
303
- return rendered;
351
+ return revealed;
304
352
  }
305
- export async function renderCRMContact(t) {
353
+ export async function revealInsights(source) {
306
354
  const w = W();
307
- out(` ${C.hd(t.name.slice(0, 40))}`);
308
- if (t.relationship)
309
- out(` ${C.mid(t.relationship.slice(0, w - 8))}`);
310
- await _sleep(180);
355
+ for await (const insight of source) {
356
+ // Word-wrap the insight text
357
+ const maxW = w - 6;
358
+ const words = insight.text.split(' ');
359
+ let line = '';
360
+ let first = true;
361
+ for (const word of words) {
362
+ if (line.length + word.length + 1 > maxW && line) {
363
+ out(` ${first ? C.dim('·') : ' '} ${C.dim(line)}`);
364
+ first = false;
365
+ line = word;
366
+ }
367
+ else {
368
+ line = line ? line + ' ' + word : word;
369
+ }
370
+ }
371
+ if (line)
372
+ out(` ${first ? C.dim('·') : ' '} ${C.dim(line)}`);
373
+ }
311
374
  }
312
375
  // ─── Destroy (call at exit) ───
313
376
  export function destroyUI() {
package/dist/ui/app.d.ts CHANGED
@@ -30,3 +30,4 @@ export declare function showProgress(phase: string, done: number, total: number,
30
30
  tool?: string;
31
31
  }[], thinking: boolean): void;
32
32
  export declare function hideProgress(): void;
33
+ export declare function waitForEnter(): Promise<void>;
package/dist/ui/app.js CHANGED
@@ -7,17 +7,18 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
7
7
  */
8
8
  import React, { useState, useEffect, useRef } from 'react';
9
9
  import { render as inkRender, Box, Text, useInput, useApp } from 'ink';
10
- import { C, SPIN, BAR_CHARS } from './theme.js';
10
+ import { C, BAR_CHARS } from './theme.js';
11
11
  // ─── Module bridge ────────────────────────────────
12
12
  let _update = null;
13
13
  let _cardResolve = null;
14
+ let _enterResolve = null;
14
15
  const INIT = {
15
16
  lines: [], spinner: null,
16
17
  card: null, cardNum: 0, cardSel: 0,
17
18
  progress: null,
18
19
  };
19
20
  // ─── Components ───────────────────────────────────
20
- const SpinnerLine = ({ label, frame }) => (_jsxs(Text, { children: [' ', _jsx(Text, { color: "#565f89", children: SPIN[frame % SPIN.length] }), ' ', _jsx(Text, { color: "#565f89", children: label })] }));
21
+ const SpinnerLine = ({ label, frame }) => (_jsxs(Text, { children: [' ', _jsx(Text, { color: "#565f89", children: BAR_CHARS[Math.floor(frame / 3) % BAR_CHARS.length] }), ' ', _jsx(Text, { color: "#565f89", children: label })] }));
21
22
  const BAR_W = 20;
22
23
  const MAX_VIS = 4;
23
24
  const Bar = ({ done, total }) => {
@@ -76,8 +77,14 @@ const App = () => {
76
77
  _cardResolve = null;
77
78
  }
78
79
  }
80
+ if (key.return && !s.card && _enterResolve) {
81
+ const r = _enterResolve;
82
+ _enterResolve = null;
83
+ r();
84
+ }
79
85
  if (key.ctrl && input === 'c') {
80
86
  _cardResolve?.('skip');
87
+ _enterResolve?.();
81
88
  exit();
82
89
  process.stdout.write('\x1B[?25h');
83
90
  process.exit(0);
@@ -176,3 +183,8 @@ export function showProgress(phase, done, total, items, thinking) {
176
183
  export function hideProgress() {
177
184
  _update?.(p => ({ ...p, progress: null }));
178
185
  }
186
+ // ── Wait for Enter ──
187
+ export function waitForEnter() {
188
+ ensureStarted();
189
+ return new Promise(resolve => { _enterResolve = resolve; });
190
+ }
package/dist/ui/theme.js CHANGED
@@ -51,7 +51,7 @@ export const W = () => Math.min(process.stdout.columns || 80, 80);
51
51
  export const INNER = () => W() - 4;
52
52
  // Spinner frames
53
53
  export const SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
54
- export const BAR_CHARS = ['✶', '*', '✢'];
54
+ export const BAR_CHARS = ['✶', '*', '✢', '·', '✦', '⊹', '✧'];
55
55
  // Progress label map — personality, not developer console
56
56
  export const TOOL_LABEL = {
57
57
  search_all_messages: 'reading the room',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "life-pulse",
3
- "version": "2.3.7",
3
+ "version": "2.3.8",
4
4
  "description": "macOS life diagnostic — reads local data sources, generates actionable insights",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {