life-pulse 2.3.3 → 2.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/agent.js CHANGED
@@ -348,22 +348,21 @@ progress, onCard) {
348
348
  // User prompt
349
349
  const prompt = `Run the executor protocol. One shot. Tell me what actually matters.
350
350
 
351
- 1. SCAN (in order):
352
- - FIRST: get_claude_history(days=3), get_chatgpt_history
353
- - THEN: scan_sources, get_calendar(days_ahead=7), get_unanswered_messages, get_messages(days=7, limit=100), get_calls(missed_only=true)
354
- - For unknown contacts: search_emails(sender=NAME) before labeling unknown
351
+ 1. SCAN — call these in parallel:
352
+ get_claude_history(days=3), get_chatgpt_history, scan_sources, get_calendar(days_ahead=7), get_unanswered_messages, get_messages(days=7, limit=100), get_calls(missed_only=true)
353
+ For unknown contacts: search_emails(sender=NAME) before labeling unknown.
355
354
 
356
- 2. CHASE CONTEXT: Search for every plan, event, date, money reference across all conversations. Be relentless.
355
+ 2. STREAM as SOON as you identify an item, dispatch a worker AND present it:
356
+ - Dispatch a Task worker to investigate (draft reply, gather context, etc.)
357
+ - Immediately call ask_user with your best recommendation. Don't wait for other workers.
358
+ - ${name} picks inline while other workers run in parallel.
359
+ - CRITICAL: present the FIRST card within 30 seconds. Don't wait until all scanning is done.
357
360
 
358
- 3. DISPATCH WORKERS via Task tool for each item — one per item, parallelize. They have FULL capabilities.
359
-
360
- 4. For each decision, present via ask_user. ${name} picks inline.
361
-
362
- 5. SYNTHESIZE into final briefing.
361
+ 3. SYNTHESIZE after all cards are presented, output final JSON.
363
362
 
364
363
  Output JSON (no markdown, no code fences):
365
364
  {
366
- "greeting": "[Time] [N] items need you.",
365
+ "greeting": "Short, warm one-liner. No counts or statistics.",
367
366
  "promises": [{ "title": "...", "urgency": "now|today|this_week", "who": "...", "when_due": "...", "options": [{"label":"...","description":"..."}] }],
368
367
  "blockers": [{ "title": "...", "urgency": "now|today|this_week", "who": "...", "waiting_since": "...", "options": [{"label":"...","description":"..."}] }],
369
368
  "bumps": [{ "title": "...", "who": "...", "context": "...", "options": [{"label":"...","description":"..."}] }],
@@ -374,7 +373,7 @@ Output JSON (no markdown, no code fences):
374
373
 
375
374
  RULES:
376
375
  - Promises/blockers: 2-3 options. First = recommendation. Description = draft text.
377
- - Present each decision via ask_user as you go. Don't batch.
376
+ - Present each decision via ask_user THE MOMENT you have enough context. Never batch.
378
377
  - Bumps: 1-2 options. Alpha: plain strings.
379
378
  - HARD LIMIT: 4 promises, 4 blockers, 3 bumps, 3 alpha, 3 handled.
380
379
  - Empty sections = fine. Short briefing = gift.`;
package/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ 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, pickCard, renderSection, renderSectionHeader, renderHandled, renderDivider, renderBrief, renderArchetype, Spinner, destroyUI, MAG, CYN, RED, AMB, MID, DIM } from './tui.js';
9
+ import { renderIntro, renderCRMList, renderCRMContact, 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';
@@ -89,20 +89,26 @@ async function showCRM(apiKey, opts) {
89
89
  return false;
90
90
  }
91
91
  opts?.spinner?.stop();
92
- opts?.spinner?.start('reading the room');
93
92
  const calendarContext = opts?.calendarContext ?? '';
94
93
  const hour = new Date().getHours();
95
94
  const timeOfDay = hour < 12 ? 'morning' : hour < 17 ? 'afternoon' : 'evening';
95
+ // Stream contacts as they enrich — each name + relationship appears live
96
96
  let brief = '';
97
97
  const enriched = [];
98
+ const enrichedNames = new Set();
98
99
  for await (const t of streamEnrichedCRM(crm, apiKey, {
99
100
  calendarContext,
100
101
  timeOfDay,
101
102
  onBrief: (b) => { brief = b; },
102
103
  })) {
103
104
  enriched.push(t);
105
+ enrichedNames.add(t.name);
106
+ await renderCRMContact(t);
104
107
  }
105
- opts?.spinner?.stop();
108
+ // Show remaining contacts (not enriched) — just names
109
+ const remaining = crm.threads.filter(t => !enrichedNames.has(t.name)).slice(0, 5);
110
+ if (remaining.length)
111
+ renderCRMList(remaining);
106
112
  if (brief)
107
113
  await renderBrief(brief);
108
114
  saveContactSummaries(enriched);
package/dist/crm.d.ts CHANGED
@@ -35,7 +35,7 @@ export interface CRM {
35
35
  generatedAt: string;
36
36
  }
37
37
  export declare function buildCRM(): Promise<CRM>;
38
- export declare function streamEnrichedCRM(crm: CRM, apiKey: string, opts?: {
38
+ export declare function streamEnrichedCRM(crm: CRM, _apiKey?: string, opts?: {
39
39
  calendarContext?: string;
40
40
  timeOfDay?: string;
41
41
  onBrief?: (brief: string) => void;
package/dist/crm.js CHANGED
@@ -12,7 +12,8 @@ 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
- import Anthropic from '@anthropic-ai/sdk';
15
+ // OpenRouter key for fast CRM enrichment (GLM-4.7)
16
+ const OPENROUTER_KEY = process.env.OPENROUTER_API_KEY || '';
16
17
  import dayjs from 'dayjs';
17
18
  import relativeTime from 'dayjs/plugin/relativeTime.js';
18
19
  dayjs.extend(relativeTime);
@@ -324,9 +325,9 @@ function buildCRMFromSQLite() {
324
325
  return { threads: threads.slice(0, 25), yourAvgResponseSec: yourAvg, generatedAt: dayjs().toISOString() };
325
326
  }
326
327
  // ─── Stream-enrich CRM ──────────────────────────────────────────
327
- export async function* streamEnrichedCRM(crm, apiKey, opts) {
328
+ export async function* streamEnrichedCRM(crm, _apiKey, opts) {
328
329
  const top = crm.threads.slice(0, 10);
329
- if (!top.length || !apiKey) {
330
+ if (!top.length || !OPENROUTER_KEY) {
330
331
  for (const t of top)
331
332
  yield t;
332
333
  return;
@@ -367,13 +368,20 @@ export async function* streamEnrichedCRM(crm, apiKey, opts) {
367
368
  curPriority = null;
368
369
  };
369
370
  try {
370
- const client = new Anthropic({ apiKey });
371
- const stream = client.messages.stream({
372
- model: 'claude-sonnet-4-5-20250929',
373
- max_tokens: 3000,
374
- messages: [{
375
- role: 'user',
376
- content: `You are a personal chief of staff analyzing someone's message history this ${timeOfDay}.
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}.
377
385
 
378
386
  Recent iMessage threads below. → = they sent, ← = contact sent.
379
387
  ${calendar}
@@ -404,35 +412,36 @@ STYLE GUIDE:
404
412
  - If someone has a calendar meeting today, weave that in naturally.
405
413
 
406
414
  ${contactBlocks}`,
407
- }],
415
+ }],
416
+ }),
408
417
  });
409
- const response = await stream.finalMessage();
410
- const fullText = response.content
411
- .filter(b => b.type === 'text')
412
- .map(b => b.text)
413
- .join('');
418
+ if (!res.ok || !res.body)
419
+ throw new Error(`OpenRouter ${res.status}`);
420
+ // Stream SSE deltas — yield contacts the moment each block completes
414
421
  let section = 'preamble';
415
422
  const briefLines = [];
416
- for (const rawLine of fullText.split('\n')) {
423
+ let lineBuf = '';
424
+ let sseBuf = '';
425
+ const processLine = (rawLine) => {
417
426
  const line = rawLine.trim();
418
427
  if (/^CONTACTS:?$/i.test(line)) {
419
428
  section = 'contacts';
420
- continue;
429
+ return;
421
430
  }
422
431
  if (/^BRIEF:?$/i.test(line)) {
423
432
  flushContact();
424
433
  section = 'brief';
425
- continue;
434
+ return;
426
435
  }
427
436
  const bracketMatch = line.match(/^\[([^\]]+)\]/);
428
437
  if (bracketMatch && (section === 'contacts' || section === 'preamble')) {
429
438
  flushContact();
430
439
  curName = bracketMatch[1].trim();
431
440
  section = 'contacts';
432
- continue;
441
+ return;
433
442
  }
434
443
  if (!line)
435
- continue;
444
+ return;
436
445
  if (section === 'contacts' || section === 'preamble') {
437
446
  if (line.startsWith('Priority:')) {
438
447
  curPriority = line.replace('Priority:', '').trim().toLowerCase();
@@ -444,7 +453,38 @@ ${contactBlocks}`,
444
453
  else if (section === 'brief') {
445
454
  briefLines.push(line);
446
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();
481
+ }
482
+ catch { }
483
+ }
447
484
  }
485
+ // Flush remaining buffer
486
+ if (lineBuf.trim())
487
+ processLine(lineBuf);
448
488
  flushContact();
449
489
  while (ready.length)
450
490
  yield ready.shift();
@@ -452,7 +492,7 @@ ${contactBlocks}`,
452
492
  opts.onBrief(briefLines.join(' '));
453
493
  }
454
494
  catch (err) {
455
- process.stderr.write(`\x1B[2K\r enrichment error: ${err instanceof Error ? err.stack || err.message : String(err)}\n`);
495
+ // silent contacts still show without enrichment
456
496
  }
457
497
  for (const t of top) {
458
498
  if (!yielded.has(t.name) && t.recentSnippets?.length)
package/dist/tui.d.ts CHANGED
@@ -17,11 +17,6 @@ export declare const HD: import("chalk").ChalkInstance;
17
17
  export declare function renderIntro(name?: string): Promise<void>;
18
18
  export declare function renderGreeting(name?: string): void;
19
19
  export declare function renderContextLine(): void;
20
- export declare function renderReveal(stats: {
21
- conversations: number;
22
- innerCircle: number;
23
- aboutToBreak: number;
24
- }): Promise<void>;
25
20
  export declare function renderCRMList(contacts: {
26
21
  name: string;
27
22
  lastMsg: {
package/dist/tui.js CHANGED
@@ -39,19 +39,21 @@ async function typewrite(text, style, delay = 30) {
39
39
  await _sleep(delay);
40
40
  }
41
41
  }
42
- async function fetchWeather(city) {
42
+ async function fetchLocationWeather() {
43
43
  try {
44
44
  const ac = new AbortController();
45
- const t = setTimeout(() => ac.abort(), 2000);
46
- const r = await fetch(`https://wttr.in/${encodeURIComponent(city)}?format=%t|%C`, { signal: ac.signal, headers: { 'User-Agent': 'life-pulse' } });
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
47
  clearTimeout(t);
48
48
  const raw = (await r.text()).trim();
49
- const [temp, cond] = raw.split('|');
50
- if (!temp || !cond)
49
+ const [loc, temp, cond] = raw.split('|');
50
+ if (!loc || !temp || !cond)
51
51
  return null;
52
+ // loc comes back as "City, Region, Country" — take just the city
53
+ const city = loc.split(',')[0].trim();
52
54
  const deg = temp.replace(/[+\s]/g, '').replace(/°[CF]/, '°');
53
55
  const sky = cond.trim().toLowerCase();
54
- return `${deg} and ${sky}`;
56
+ return { city, weather: `${deg} and ${sky}` };
55
57
  }
56
58
  catch {
57
59
  return null;
@@ -118,10 +120,8 @@ function renderScanField(cols, rows) {
118
120
  }
119
121
  export async function renderIntro(name) {
120
122
  const firstName = name?.split(' ')[0] || name;
121
- const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
122
- const city = tz.split('/').pop()?.replace(/_/g, ' ') || '';
123
- // Fire weather fetch while animation runs
124
- const weatherP = city ? fetchWeather(city) : Promise.resolve(null);
123
+ // Fire IP-based location + weather fetch while animation runs
124
+ const locP = fetchLocationWeather();
125
125
  const cols = process.stdout.columns || 80;
126
126
  const rows = process.stdout.rows || 24;
127
127
  // Push old content out of scrollback
@@ -144,12 +144,10 @@ export async function renderIntro(name) {
144
144
  await typewrite(hey, G1, 35);
145
145
  process.stdout.write('\n');
146
146
  await _sleep(250);
147
- // "I see you're in Los Angeles. 72° and sunny out there."
148
- const weather = await weatherP;
149
- const locLine = weather
150
- ? `I see you're in ${city}. ${weatherRemark(weather)}`
151
- : city ? `I see you're in ${city}.` : '';
152
- if (locLine) {
147
+ // "I see you're in Short Hills. 42° and cloudy."
148
+ const loc = await locP;
149
+ if (loc) {
150
+ const locLine = `I see you're in ${loc.city}. ${weatherRemark(loc.weather)}`;
153
151
  process.stdout.write(' ');
154
152
  await typewrite(locLine, G2, 20);
155
153
  process.stdout.write('\n');
@@ -157,11 +155,20 @@ export async function renderIntro(name) {
157
155
  }
158
156
  // "Hope you're having an incredible morning."
159
157
  const h = new Date().getHours();
158
+ const greeting = timeGreeting(h);
160
159
  process.stdout.write(' ');
161
- await typewrite(timeGreeting(h), G3, 18);
160
+ await typewrite(greeting, G3, 18);
162
161
  process.stdout.write('\n\n');
162
+ await _sleep(1200);
163
+ // Build greeting lines for Ink to preserve
164
+ const greetLines = [''];
165
+ greetLines.push(` ${G1(hey)}`);
166
+ if (loc)
167
+ greetLines.push(` ${G2(`I see you're in ${loc.city}. ${weatherRemark(loc.weather)}`)}`);
168
+ greetLines.push(` ${G3(greeting)}`);
169
+ greetLines.push('');
163
170
  if (USE_INK)
164
- ink.initInk();
171
+ ink.initInk(greetLines);
165
172
  }
166
173
  // ─── Greeting (legacy / non-interactive fallback) ───
167
174
  export function renderGreeting(name) {
@@ -186,21 +193,6 @@ export function renderContextLine() {
186
193
  out(` ${C.dim(`${days[d.getDay()]} · ${h12}:${min} ${ampm}`)}`);
187
194
  out('');
188
195
  }
189
- // ─── Reveal (the moment after discovery) ───
190
- export async function renderReveal(stats) {
191
- await _sleep(600);
192
- process.stdout.write(' ');
193
- await typewrite(`${stats.conversations} conversations.`, G2, 22);
194
- await _sleep(400);
195
- process.stdout.write(' ');
196
- await typewrite(`${stats.innerCircle} people who matter.`, G1, 25);
197
- await _sleep(500);
198
- if (stats.aboutToBreak > 0) {
199
- process.stdout.write(' ');
200
- await typewrite(`${stats.aboutToBreak} thread${stats.aboutToBreak === 1 ? '' : 's'} about to break.`, chalk.bold.hex('#c0caf5'), 28);
201
- }
202
- process.stdout.write('\n\n');
203
- }
204
196
  // ─── CRM List ───
205
197
  export function renderCRMList(contacts) {
206
198
  for (const t of contacts.slice(0, 12)) {
@@ -298,14 +290,12 @@ export async function renderCRMStream(source, spinner) {
298
290
  out('');
299
291
  first = false;
300
292
  }
301
- const name = t.name.slice(0, 24);
302
- const ago = shortAgo(t.lastMsg?.ago || '');
303
- const gap = Math.max(2, w - 4 - name.length - ago.length);
304
- out(` ${C.hd(name)}${' '.repeat(gap)}${C.dim(ago)}`);
293
+ out(` ${C.hd(t.name.slice(0, 40))}`);
305
294
  if (t.relationship) {
306
295
  out(` ${C.mid(t.relationship.slice(0, w - 8))}`);
307
296
  }
308
297
  rendered.push(t);
298
+ await _sleep(180);
309
299
  }
310
300
  if (first)
311
301
  spinner?.stop();
@@ -314,12 +304,10 @@ export async function renderCRMStream(source, spinner) {
314
304
  }
315
305
  export async function renderCRMContact(t) {
316
306
  const w = W();
317
- const name = t.name.slice(0, 24);
318
- const ago = shortAgo(t.lastMsg?.ago || '');
319
- const gap = Math.max(2, w - 4 - name.length - ago.length);
320
- out(` ${C.hd(name)}${' '.repeat(gap)}${C.dim(ago)}`);
307
+ out(` ${C.hd(t.name.slice(0, 40))}`);
321
308
  if (t.relationship)
322
309
  out(` ${C.mid(t.relationship.slice(0, w - 8))}`);
310
+ await _sleep(180);
323
311
  }
324
312
  // ─── Destroy (call at exit) ───
325
313
  export function destroyUI() {
package/dist/ui/app.d.ts CHANGED
@@ -17,7 +17,7 @@ export interface CardData {
17
17
  who?: string;
18
18
  urgency?: string;
19
19
  }
20
- export declare function initInk(): void;
20
+ export declare function initInk(seedLines?: string[]): void;
21
21
  export declare function destroy(): void;
22
22
  export declare function log(text: string): void;
23
23
  export declare function gap(): void;
package/dist/ui/app.js CHANGED
@@ -25,13 +25,13 @@ const Bar = ({ done, total }) => {
25
25
  return (_jsxs(Text, { children: [_jsx(Text, { color: "#565f89", children: '─'.repeat(f) }), _jsx(Text, { color: "#292e42", children: '─'.repeat(BAR_W - f) }), ' ', _jsx(Text, { color: "#565f89", children: done }), _jsx(Text, { color: "#3b4261", children: "/" }), _jsx(Text, { color: "#565f89", children: total })] }));
26
26
  };
27
27
  const ProgressView = ({ progress, frame }) => {
28
- const spin = (off = 0) => BAR_CHARS[(frame + off) % BAR_CHARS.length];
28
+ const spin = (off = 0) => BAR_CHARS[(Math.floor((frame + off) / 5)) % BAR_CHARS.length];
29
29
  if (progress.thinking && !progress.items.length && !progress.total) {
30
30
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: ' ' }), _jsxs(Text, { children: [' ', _jsx(Text, { color: "#3b4261", children: spin() }), ' ', _jsx(Text, { color: "#565f89", children: "thinking" })] })] }));
31
31
  }
32
32
  const { phase, done, total, items } = progress;
33
33
  const allDone = done === total && total > 0;
34
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: ' ' }), allDone ? (_jsxs(Text, { children: [' ', _jsx(Text, { color: "#565f89", children: '·' }), ' ', _jsx(Text, { color: "#565f89", children: phase === 'scanning' ? 'scanned' : 'done' })] })) : total > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [' ', _jsx(Text, { color: "#565f89", children: phase || 'working' }), ' ', _jsx(Bar, { done: done, total: total })] }), items.slice(0, MAX_VIS).map((item, i) => (_jsxs(Text, { children: [' ', _jsx(Text, { color: "#3b4261", children: spin(i) }), ' ', _jsx(Text, { color: "#565f89", children: item.label }), item.tool && _jsxs(Text, { color: "#3b4261", children: [' — ', item.tool] })] }, i))), items.length > MAX_VIS && _jsxs(Text, { color: "#3b4261", children: [' ', "+", items.length - MAX_VIS, " more"] })] })) : null, progress.thinking && total > 0 && (_jsxs(Text, { children: [' ', _jsx(Text, { color: "#3b4261", children: spin() }), ' ', _jsx(Text, { color: "#565f89", children: "thinking" })] }))] }));
34
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: ' ' }), allDone ? (_jsxs(Text, { children: [' ', _jsx(Text, { color: "#3b4261", children: spin() }), ' ', _jsx(Text, { color: "#565f89", children: phase === 'scanning' ? 'scanned' : 'putting it together' })] })) : total > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [' ', _jsx(Text, { color: "#565f89", children: phase || 'working' }), ' ', _jsx(Bar, { done: done, total: total })] }), items.slice(0, MAX_VIS).map((item, i) => (_jsxs(Text, { children: [' ', _jsx(Text, { color: "#3b4261", children: spin(i) }), ' ', _jsx(Text, { color: "#565f89", children: item.label }), item.tool && _jsxs(Text, { color: "#3b4261", children: [' — ', item.tool] })] }, i))), items.length > MAX_VIS && _jsxs(Text, { color: "#3b4261", children: [' ', "+", items.length - MAX_VIS, " more"] })] })) : null, progress.thinking && total > 0 && (_jsxs(Text, { children: [' ', _jsx(Text, { color: "#3b4261", children: spin() }), ' ', _jsx(Text, { color: "#565f89", children: "thinking" })] }))] }));
35
35
  };
36
36
  const CardView = ({ card, num, sel }) => {
37
37
  const opts = card.options || [];
@@ -89,6 +89,7 @@ const App = () => {
89
89
  let _inst = null;
90
90
  let _started = false;
91
91
  const _origLog = console.log.bind(console);
92
+ let _seedLines = [];
92
93
  function ensureStarted() {
93
94
  if (_started)
94
95
  return;
@@ -96,6 +97,15 @@ function ensureStarted() {
96
97
  process.stdout.write('\x1Bc'); // clear screen
97
98
  process.stdout.write('\x1B[?25l'); // hide cursor
98
99
  _inst = inkRender(React.createElement(App));
100
+ // Seed initial lines (e.g. greeting from intro)
101
+ if (_seedLines.length) {
102
+ const lines = _seedLines;
103
+ _seedLines = [];
104
+ // Small delay to let React mount before pushing state
105
+ setTimeout(() => {
106
+ _update?.(p => ({ ...p, lines: [...lines, ...p.lines] }));
107
+ }, 50);
108
+ }
99
109
  // Redirect console.log through Ink
100
110
  console.log = (...args) => {
101
111
  const text = args.map(a => typeof a === 'string' ? a : String(a)).join(' ');
@@ -105,7 +115,11 @@ function ensureStarted() {
105
115
  };
106
116
  }
107
117
  // ─── Exports ──────────────────────────────────────
108
- export function initInk() { ensureStarted(); }
118
+ export function initInk(seedLines) {
119
+ if (seedLines)
120
+ _seedLines = seedLines;
121
+ ensureStarted();
122
+ }
109
123
  export function destroy() {
110
124
  console.log = _origLog;
111
125
  _inst?.unmount();
@@ -43,14 +43,14 @@ export class InkProgress {
43
43
  const workersDone = this.workers.filter(w => w.done).length;
44
44
  const activeWorkers = this.workers.filter(w => !w.done);
45
45
  if (this.workers.length > 0) {
46
- showProgress(activeWorkers.length > 0 ? 'working' : 'done', workersDone, this.workers.length, activeWorkers.map(w => ({
46
+ showProgress(activeWorkers.length > 0 ? 'working' : 'finishing up', workersDone, this.workers.length, activeWorkers.map(w => ({
47
47
  label: w.label,
48
48
  tool: w.tool ? this.s(w.tool) : undefined,
49
49
  })), this._thinking);
50
50
  }
51
51
  else if (this.tools.length > 0) {
52
52
  const active = this.tools.filter(t => !t.done);
53
- showProgress(toolsDone === this.tools.length ? 'done' : 'scanning', toolsDone, this.tools.length, active.map(t => ({ label: this.s(t.name) })), this._thinking);
53
+ showProgress(toolsDone === this.tools.length ? 'finishing up' : 'scanning', toolsDone, this.tools.length, active.map(t => ({ label: this.s(t.name) })), this._thinking);
54
54
  }
55
55
  else if (this._thinking) {
56
56
  showProgress('', 0, 0, [], true);
@@ -71,7 +71,8 @@ export class InkProgress {
71
71
  const t = this.tools.find(t => t.name === name && !t.done);
72
72
  if (t)
73
73
  t.done = true;
74
- this.paint();
74
+ // Delay repaint so the label lingers on screen
75
+ setTimeout(() => this.paint(), 400);
75
76
  }
76
77
  workerStart(id, label) {
77
78
  this._thinking = false;
@@ -113,6 +114,9 @@ export class InkProgress {
113
114
  }
114
115
  pause() { this._paused = true; hideProgress(); }
115
116
  resume() { this._paused = false; this.paint(); }
116
- stop() { this.paint(); hideProgress(); }
117
+ stop() {
118
+ // Let the final state linger so it doesn't vanish instantly
119
+ setTimeout(() => hideProgress(), 800);
120
+ }
117
121
  clear() { hideProgress(); }
118
122
  }
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.3",
3
+ "version": "2.3.7",
4
4
  "description": "macOS life diagnostic — reads local data sources, generates actionable insights",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {