life-pulse 2.3.0 → 2.3.2

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/installer.js CHANGED
@@ -19,7 +19,6 @@ import { join } from 'path';
19
19
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
20
20
  import { execSync } from 'child_process';
21
21
  import { createInterface } from 'readline';
22
- import chalk from 'chalk';
23
22
  import { runPermissionFlow, hasRequiredPermissions } from './permissions.js';
24
23
  import { discoverPlatforms, savePlatforms } from './platforms.js';
25
24
  import { runDiscovery, formatDiscoveryReport } from './icloud-discovery.js';
@@ -29,7 +28,7 @@ import { updateAutoKnowledge } from './knowledge.js';
29
28
  import { scanForSkills } from './skill-loader.js';
30
29
  import { hasTailscale, getHostname as getTailscaleHostname, startFunnel } from './tunnel.js';
31
30
  import { startGateway } from './sms-gateway.js';
32
- import { renderCRMList, renderBrief, Spinner, GRN, DIM, HD, MID, RED } from './tui.js';
31
+ import { renderCRMList, renderBrief, Spinner, DIM, HD, MID } from './tui.js';
33
32
  import { saveContactSummaries } from './intelligence.js';
34
33
  import { startSession } from './session-progress.js';
35
34
  import * as rply from './rply-client.js';
@@ -62,9 +61,8 @@ function isDone(state, step) {
62
61
  }
63
62
  // ─── TUI Helpers ────────────────────────────────────────────────
64
63
  function stepHeader(num, title, done) {
65
- const icon = done ? GRN('✓') : chalk.bold.hex('#7AA2F7')(`${num}`);
66
64
  const label = done ? DIM(title) : HD(title);
67
- console.log(`\n ${icon} ${label}`);
65
+ console.log(`\n ${label}`);
68
66
  }
69
67
  async function prompt(question) {
70
68
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -93,9 +91,6 @@ export async function runInstaller(apiKey) {
93
91
  const state = loadState();
94
92
  const spinner = process.stdin.isTTY ? new Spinner() : undefined;
95
93
  console.log();
96
- console.log(chalk.bold.hex('#7AA2F7')(' life-pulse'));
97
- console.log(DIM(' personal AI operating system'));
98
- console.log();
99
94
  // ── Step 1: Pre-flight ──
100
95
  stepHeader(1, 'Pre-flight', isDone(state, 'preflight'));
101
96
  if (!isDone(state, 'preflight')) {
@@ -121,15 +116,15 @@ export async function runInstaller(apiKey) {
121
116
  process.env.ANTHROPIC_API_KEY = key;
122
117
  apiKey = key;
123
118
  state.apiKeySet = true;
124
- console.log(GRN(' API key saved'));
119
+ console.log(DIM(' API key saved'));
125
120
  }
126
121
  else {
127
- console.log(RED(' no API key — some features will be limited'));
122
+ console.log(DIM(' no API key — some features will be limited'));
128
123
  }
129
124
  }
130
125
  else {
131
126
  state.apiKeySet = true;
132
- console.log(GRN(' API key configured'));
127
+ console.log(DIM(' API key configured'));
133
128
  }
134
129
  markDone(state, 'preflight');
135
130
  }
@@ -139,13 +134,13 @@ export async function runInstaller(apiKey) {
139
134
  if (process.stdin.isTTY) {
140
135
  const allGranted = await runPermissionFlow();
141
136
  if (allGranted) {
142
- console.log(GRN(' all permissions granted'));
137
+ console.log(DIM(' all permissions granted'));
143
138
  }
144
139
  else if (hasRequiredPermissions()) {
145
140
  console.log(DIM(' required permissions granted (some optional skipped)'));
146
141
  }
147
142
  else {
148
- console.log(RED(' some required permissions missing — re-run to retry'));
143
+ console.log(HD(' some required permissions missing — re-run to retry'));
149
144
  }
150
145
  }
151
146
  markDone(state, 'permissions');
@@ -153,9 +148,9 @@ export async function runInstaller(apiKey) {
153
148
  // ── Step 3: Discovery ──
154
149
  stepHeader(3, 'Discovery', isDone(state, 'discovery'));
155
150
  if (!isDone(state, 'discovery')) {
156
- spinner?.start('scanning platforms');
151
+ spinner?.start('learning your world');
157
152
  const platformProfile = discoverPlatforms();
158
- spinner?.update('scanning iCloud');
153
+ spinner?.update('looking deeper');
159
154
  const discoveredApps = runDiscovery();
160
155
  spinner?.stop();
161
156
  if (discoveredApps.length) {
@@ -183,7 +178,7 @@ export async function runInstaller(apiKey) {
183
178
  const inactive = rplyStatus.platforms.filter(p => !p.is_available || !p.is_enabled);
184
179
  console.log();
185
180
  for (const p of active) {
186
- console.log(` ${GRN('✓')} ${p.display_name}`);
181
+ console.log(` ${HD(p.display_name)}`);
187
182
  }
188
183
  for (const p of inactive) {
189
184
  const hint = platformHint(p.platform);
@@ -191,12 +186,12 @@ export async function runInstaller(apiKey) {
191
186
  }
192
187
  if (inactive.length) {
193
188
  console.log();
194
- console.log(DIM(' sign into these services in Safari to enable them'));
195
- console.log(DIM(' re-run setup after signing in'));
189
+ console.log(DIM(' sign into these to connect them'));
190
+ console.log(DIM(' run setup again after'));
196
191
  }
197
192
  }
198
193
  else {
199
- console.log(DIM(' direct iMessage access (install rply-mac-server for multi-platform)'));
194
+ console.log(DIM(' iMessage only more platforms available'));
200
195
  }
201
196
  savePlatforms(platformProfile);
202
197
  markDone(state, 'discovery');
@@ -204,7 +199,7 @@ export async function runInstaller(apiKey) {
204
199
  // ── Step 4: CRM Build ──
205
200
  stepHeader(4, 'Relationship Map', isDone(state, 'crm'));
206
201
  if (!isDone(state, 'crm')) {
207
- spinner?.start('scanning messages');
202
+ spinner?.start('reading the room');
208
203
  const crm = await buildCRM();
209
204
  spinner?.stop();
210
205
  if (crm.threads.length) {
@@ -214,7 +209,7 @@ export async function runInstaller(apiKey) {
214
209
  console.log(DIM(` ${knowledgeCount} contact knowledge bases created`));
215
210
  // Enrichment
216
211
  if (apiKey) {
217
- spinner?.start('reading the room');
212
+ spinner?.start('understanding the room');
218
213
  let brief = '';
219
214
  const enriched = [];
220
215
  for await (const t of streamEnrichedCRM(crm, apiKey, {
@@ -236,7 +231,7 @@ export async function runInstaller(apiKey) {
236
231
  // ── Step 5: Archetype ──
237
232
  stepHeader(5, 'Archetype', isDone(state, 'archetype'));
238
233
  if (!isDone(state, 'archetype') && apiKey) {
239
- spinner?.start('generating archetype');
234
+ spinner?.start('figuring out who you are');
240
235
  try {
241
236
  const platforms = discoverPlatforms(); // re-read (may have been saved)
242
237
  const archetype = await generateArchetype(platforms, apiKey);
@@ -249,48 +244,40 @@ export async function runInstaller(apiKey) {
249
244
  }
250
245
  catch (err) {
251
246
  spinner?.stop();
252
- console.log(RED(` archetype generation failed: ${err instanceof Error ? err.message : String(err)}`));
247
+ console.log(DIM(` archetype generation failed: ${err instanceof Error ? err.message : String(err)}`));
253
248
  }
254
249
  markDone(state, 'archetype');
255
250
  }
256
- // ── Step 6: Messaging Gateway (Tailscale → rply-mac-server) ──
257
- stepHeader(6, 'Messaging Gateway', isDone(state, 'phone'));
251
+ // ── Step 6: Messaging ──
252
+ stepHeader(6, 'Messaging', isDone(state, 'phone'));
258
253
  if (!isDone(state, 'phone')) {
259
- // Start the inbound gateway (rply-mac-server pushes messages here)
260
254
  if (apiKey) {
261
255
  const gw = startGateway(apiKey);
262
- console.log(GRN(` gateway listening on :${gw.port}`));
263
- // Stop after installer finishes (daemon will restart it)
264
256
  setTimeout(() => gw.stop(), 60_000);
265
257
  }
266
- // Tailscale — required for rply-mac-server to reach this Mac
267
258
  if (hasTailscale()) {
268
259
  const hostname = getTailscaleHostname();
269
260
  if (hostname) {
270
261
  state.funnelUrl = `http://${hostname}:19877`;
271
- console.log(GRN(` Tailscale: ${hostname}`));
272
- console.log(DIM(` rply-mac-server → ${state.funnelUrl}/inbound`));
273
- // Optionally expose via Funnel for HTTPS
274
- spinner?.start('setting up tunnel');
262
+ spinner?.start('opening the connection');
275
263
  const funnelUrl = startFunnel(19877);
276
264
  spinner?.stop();
277
- if (funnelUrl) {
265
+ if (funnelUrl)
278
266
  state.funnelUrl = funnelUrl;
279
- console.log(GRN(` Funnel: ${funnelUrl}/inbound`));
280
- }
267
+ console.log(DIM(' connected'));
281
268
  }
282
269
  else {
283
- console.log(RED(' Tailscale installed but no hostname check tailscale status'));
270
+ console.log(HD(' network connection incompleterun setup again'));
284
271
  }
285
272
  }
286
273
  else {
287
- console.log(RED(' Tailscale not installedrply-mac-server cannot reach this Mac'));
288
- console.log(DIM(' install: https://tailscale.com/download/mac'));
274
+ console.log(HD(' missing network layermessages won\'t reach this machine'));
275
+ console.log(DIM(' tailscale.com/download'));
289
276
  }
290
277
  markDone(state, 'phone');
291
278
  }
292
279
  // ── Step 7: Daemon Install ──
293
- stepHeader(7, 'Daemon', isDone(state, 'daemon'));
280
+ stepHeader(7, 'Background', isDone(state, 'daemon'));
294
281
  if (!isDone(state, 'daemon')) {
295
282
  const logDir = join(homedir(), 'Library/Logs/life-pulse');
296
283
  const agentsDir = join(homedir(), 'Library/LaunchAgents');
@@ -356,35 +343,31 @@ export async function runInstaller(apiKey) {
356
343
  execSync('launchctl load ~/Library/LaunchAgents/com.life-pulse.daemon.plist', {
357
344
  stdio: 'pipe', timeout: 5000,
358
345
  });
359
- console.log(GRN(' daemon installed and started'));
360
- }
361
- catch {
362
- console.log(DIM(' plists written — load manually:'));
363
- console.log(DIM(' launchctl load ~/Library/LaunchAgents/com.life-pulse.daemon.plist'));
364
346
  }
347
+ catch { }
365
348
  // Load morning
366
349
  try {
367
350
  execSync('launchctl load ~/Library/LaunchAgents/com.life-pulse.morning.plist', {
368
351
  stdio: 'pipe', timeout: 5000,
369
352
  });
370
- console.log(GRN(' morning brief: 7:30 AM daily'));
371
- }
372
- catch {
373
- console.log(DIM(' launchctl load ~/Library/LaunchAgents/com.life-pulse.morning.plist'));
374
353
  }
354
+ catch { }
355
+ console.log(DIM(' always on'));
356
+ console.log(DIM(' morning brief at 7:30 AM'));
375
357
  markDone(state, 'daemon');
376
358
  }
377
359
  // ── Step 8: First Briefing ──
378
360
  stepHeader(8, 'First Briefing', isDone(state, 'briefing'));
379
361
  if (!isDone(state, 'briefing') && apiKey && process.stdin.isTTY) {
380
362
  console.log();
381
- console.log(DIM(' running your first briefing live...'));
363
+ console.log();
364
+ console.log(DIM(' let\'s see what\'s going on...'));
382
365
  console.log();
383
366
  // Start a session for the briefing
384
367
  startSession();
385
368
  // Import and run the agent directly
386
369
  const { runAgent } = await import('./agent.js');
387
- const { pickCard, renderSectionHeader, renderSection, renderHandled, RED: tRED, AMB: tAMB, CYN: tCYN, MAG: tMAG } = await import('./tui.js');
370
+ const { pickCard, renderSectionHeader, renderSection, renderHandled, HD: tHD, DIM: tDIM, MID: tMID } = await import('./tui.js');
388
371
  const { ProgressRenderer } = await import('./progress.js');
389
372
  const renderer = new ProgressRenderer();
390
373
  renderer.start();
@@ -403,31 +386,26 @@ export async function runInstaller(apiKey) {
403
386
  if (analysis.handled?.length)
404
387
  renderHandled(analysis.handled, 5);
405
388
  if (analysis.promises?.length) {
406
- renderSectionHeader('PROMISES', tRED);
389
+ renderSectionHeader('promises', tHD);
407
390
  }
408
391
  if (analysis.alpha?.length) {
409
- renderSection('ALPHA', analysis.alpha, '', tMAG.bold, tMAG);
392
+ renderSection('alpha', analysis.alpha, '·', tHD, tMID);
410
393
  }
411
394
  markDone(state, 'briefing');
412
395
  }
413
396
  // ── Summary ──
414
397
  console.log();
415
- console.log(chalk.bold.hex('#7AA2F7')(' Setup complete'));
398
+ console.log(HD(' You\'re all set.'));
416
399
  console.log();
417
400
  // Show active platform count
418
401
  const finalStatus = await rply.status();
419
402
  if (finalStatus) {
420
403
  const active = finalStatus.platforms.filter(p => p.is_available && p.is_enabled);
421
- const total = finalStatus.platforms.length;
422
- console.log(HD(` ${active.length}/${total} platforms active`) + ' ' +
423
- DIM(active.map(p => p.display_name).join(', ')));
424
- }
425
- if (state.funnelUrl) {
426
- console.log(HD(` Gateway: ${state.funnelUrl}/inbound`));
427
- console.log(DIM(' Configure rply-mac-server to POST here'));
404
+ if (active.length) {
405
+ console.log(DIM(` ${active.map(p => p.display_name).join(', ')}`));
406
+ }
428
407
  }
429
- console.log(DIM(' Daemon running auto-restarts on crash'));
430
- console.log(DIM(' Morning brief: 7:30 AM daily'));
431
- console.log(DIM(' Run --check to verify, --status for stats'));
408
+ console.log(DIM(' running in the background'));
409
+ console.log(DIM(' you\'ll hear from me at 7:30 AM'));
432
410
  console.log();
433
411
  }
package/dist/progress.js CHANGED
@@ -11,30 +11,30 @@ const STRUCTURE = chalk.hex('#3B3B3B');
11
11
  const OK = chalk.hex('#98C379');
12
12
  const FAIL = chalk.hex('#E06C75');
13
13
  const LABEL = {
14
- search_all_messages: 'checking messages',
15
- get_conversation: 'reading thread',
16
- profile_contact: 'looking up contact',
17
- get_messages: 'checking messages',
18
- get_unanswered_messages: 'checking unreplied texts',
19
- get_screen_time: 'checking screen time',
20
- get_browsing: 'checking browsing',
21
- get_calls: 'checking calls',
22
- scan_sources: 'scanning',
23
- lookup_contact: 'looking up contact',
24
- get_email_summary: 'checking email',
25
- get_git_activity: 'checking projects',
26
- get_recent_files: 'checking recent files',
27
- get_shell_history: 'checking history',
28
- get_notes: 'reading notes',
29
- get_claude_history: 'checking chats',
30
- get_chatgpt_history: 'checking chats',
31
- get_interests_for_plans: 'checking interests',
32
- get_calendar: 'checking calendar',
33
- get_reminders: 'checking reminders',
34
- get_notifications: 'checking notifications',
35
- discover_platforms: 'scanning apps',
36
- generate_archetype: 'building profile',
37
- search_emails: 'searching email',
14
+ search_all_messages: 'reading the room',
15
+ get_conversation: 'listening in',
16
+ profile_contact: 'learning who this is',
17
+ get_messages: 'reading the room',
18
+ get_unanswered_messages: 'finding what you missed',
19
+ get_screen_time: 'tracking your attention',
20
+ get_browsing: 'retracing your steps',
21
+ get_calls: 'checking who called',
22
+ scan_sources: 'pulling threads',
23
+ lookup_contact: 'learning who this is',
24
+ get_email_summary: 'going through your inbox',
25
+ get_git_activity: 'seeing what you shipped',
26
+ get_recent_files: 'noticing what you touched',
27
+ get_shell_history: 'retracing your steps',
28
+ get_notes: 'reading your thoughts',
29
+ get_claude_history: 'seeing what you asked',
30
+ get_chatgpt_history: 'seeing what you asked',
31
+ get_interests_for_plans: 'understanding your world',
32
+ get_calendar: 'looking at your day',
33
+ get_reminders: 'checking what you owe yourself',
34
+ get_notifications: 'catching up',
35
+ discover_platforms: 'figuring out your world',
36
+ generate_archetype: 'figuring out who you are',
37
+ search_emails: 'digging through mail',
38
38
  };
39
39
  const MAX_VISIBLE = 4;
40
40
  export class ProgressRenderer {
package/dist/router.js CHANGED
@@ -146,7 +146,7 @@ const stats = {
146
146
  lastForwarded: null,
147
147
  };
148
148
  function log(msg) {
149
- process.stderr.write(` router: ${msg}\n`);
149
+ process.stderr.write(` ${msg}\n`);
150
150
  }
151
151
  // ─── Poll Cycle ───────────────────────────────────────────────
152
152
  async function poll() {
@@ -87,7 +87,7 @@ async function handleInbound(req, res, apiKey) {
87
87
  catch (err) {
88
88
  stats.errors++;
89
89
  const msg = err instanceof Error ? err.message : String(err);
90
- process.stderr.write(` gateway: error for ${contactName}: ${msg}\n`);
90
+ process.stderr.write(` couldn't reply to ${contactName}\n`);
91
91
  respond(res, 500, { error: 'conversation failed' });
92
92
  }
93
93
  }
@@ -122,7 +122,7 @@ export function startGateway(apiKey) {
122
122
  respond(res, 404, { error: 'not found' });
123
123
  });
124
124
  server.listen(GATEWAY_PORT, '0.0.0.0', () => {
125
- process.stderr.write(` gateway: listening on :${GATEWAY_PORT}\n`);
125
+ // silent startup — no infrastructure noise
126
126
  });
127
127
  return {
128
128
  port: GATEWAY_PORT,
package/dist/tui.d.ts CHANGED
@@ -14,8 +14,14 @@ export declare const GRN: import("chalk").ChalkInstance;
14
14
  export declare const MAG: import("chalk").ChalkInstance;
15
15
  export declare const CYN: import("chalk").ChalkInstance;
16
16
  export declare const HD: import("chalk").ChalkInstance;
17
+ export declare function renderIntro(name?: string): Promise<void>;
17
18
  export declare function renderGreeting(name?: string): void;
18
19
  export declare function renderContextLine(): void;
20
+ export declare function renderReveal(stats: {
21
+ conversations: number;
22
+ innerCircle: number;
23
+ aboutToBreak: number;
24
+ }): Promise<void>;
19
25
  export declare function renderCRMList(contacts: {
20
26
  name: string;
21
27
  lastMsg: {
package/dist/tui.js CHANGED
@@ -26,7 +26,144 @@ function out(text) {
26
26
  else
27
27
  console.log(text);
28
28
  }
29
- // ─── Greeting ───
29
+ // ─── Intro ───
30
+ import chalk from 'chalk';
31
+ const _sleep = (ms) => new Promise(r => setTimeout(r, ms));
32
+ // Warm amber palette — fades like candlelight
33
+ const G1 = chalk.hex('#d4a574'); // name
34
+ const G2 = chalk.hex('#a8885c'); // location + weather
35
+ const G3 = chalk.hex('#7a6544'); // closing
36
+ async function typewrite(text, style, delay = 30) {
37
+ for (const ch of text) {
38
+ process.stdout.write(style(ch));
39
+ await _sleep(delay);
40
+ }
41
+ }
42
+ async function fetchWeather(city) {
43
+ try {
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' } });
47
+ clearTimeout(t);
48
+ const raw = (await r.text()).trim();
49
+ const [temp, cond] = raw.split('|');
50
+ if (!temp || !cond)
51
+ return null;
52
+ const deg = temp.replace(/[+\s]/g, '').replace(/°[CF]/, '°');
53
+ const sky = cond.trim().toLowerCase();
54
+ return `${deg} and ${sky}`;
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
60
+ function timeGreeting(h) {
61
+ if (h < 5)
62
+ return "Don't stay up too late.";
63
+ if (h < 12)
64
+ return 'Hope you have an incredible morning.';
65
+ if (h < 17)
66
+ return "Hope you're having a wonderful afternoon.";
67
+ if (h < 21)
68
+ return "Hope you're having a beautiful evening.";
69
+ return "Don't stay up too late.";
70
+ }
71
+ function weatherRemark(weather) {
72
+ const w = weather.toLowerCase();
73
+ if (w.includes('sunny') || w.includes('clear'))
74
+ return `${weather} out there.`;
75
+ if (w.includes('rain') || w.includes('drizzle'))
76
+ return `${weather} — good day to stay sharp.`;
77
+ if (w.includes('cloud') || w.includes('overcast'))
78
+ return `${weather}.`;
79
+ if (w.includes('snow'))
80
+ return `${weather}. Stay warm.`;
81
+ return `${weather}.`;
82
+ }
83
+ // ─── Geometric animation ───
84
+ function renderScanField(cols, rows) {
85
+ const cy = Math.floor(rows / 2);
86
+ const shades = [
87
+ chalk.hex('#1a1b26'),
88
+ chalk.hex('#1a1b26'),
89
+ chalk.hex('#292e42'),
90
+ chalk.hex('#292e42'),
91
+ chalk.hex('#3b4261'),
92
+ ];
93
+ const lines = [];
94
+ for (let y = 0; y < rows; y++) {
95
+ const d = Math.abs(y - cy) / Math.max(cy, 1);
96
+ const brightness = 1 - d;
97
+ const segW = Math.max(0, Math.floor(cols * brightness * 0.85));
98
+ const pad = Math.floor((cols - segW) / 2);
99
+ if (segW < 4) {
100
+ lines.push(' '.repeat(cols));
101
+ continue;
102
+ }
103
+ const si = Math.min(Math.floor(brightness * shades.length), shades.length - 1);
104
+ const shade = shades[si];
105
+ // Thin lines with periodic breaks — creates a lens/field shape
106
+ let seg = '';
107
+ for (let x = 0; x < segW; x++) {
108
+ if (x === 0 || x === segW - 1)
109
+ seg += shade('·');
110
+ else if (x % 12 === 0)
111
+ seg += shade('·');
112
+ else
113
+ seg += shade('─');
114
+ }
115
+ lines.push(' '.repeat(pad) + seg + ' '.repeat(Math.max(0, cols - pad - segW)));
116
+ }
117
+ return lines;
118
+ }
119
+ export async function renderIntro(name) {
120
+ 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);
125
+ const cols = process.stdout.columns || 80;
126
+ const rows = process.stdout.rows || 24;
127
+ // Push old content out of scrollback
128
+ process.stdout.write('\n'.repeat(rows * 2));
129
+ process.stdout.write('\x1Bc');
130
+ process.stdout.write('\x1B[?25l');
131
+ // === Phase 1: Geometric scan field ===
132
+ const field = renderScanField(cols, rows);
133
+ for (let y = 0; y < field.length; y++) {
134
+ process.stdout.write(`\x1B[${y + 1};1H${field[y]}`);
135
+ await _sleep(12);
136
+ }
137
+ await _sleep(350);
138
+ // === Phase 2: Greeting ===
139
+ process.stdout.write('\x1Bc');
140
+ process.stdout.write('\n');
141
+ // "Hey Molly."
142
+ const hey = firstName ? `Hey ${firstName}.` : 'Hey.';
143
+ process.stdout.write(' ');
144
+ await typewrite(hey, G1, 35);
145
+ process.stdout.write('\n');
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) {
153
+ process.stdout.write(' ');
154
+ await typewrite(locLine, G2, 20);
155
+ process.stdout.write('\n');
156
+ await _sleep(200);
157
+ }
158
+ // "Hope you're having an incredible morning."
159
+ const h = new Date().getHours();
160
+ process.stdout.write(' ');
161
+ await typewrite(timeGreeting(h), G3, 18);
162
+ process.stdout.write('\n\n');
163
+ if (USE_INK)
164
+ ink.initInk();
165
+ }
166
+ // ─── Greeting (legacy / non-interactive fallback) ───
30
167
  export function renderGreeting(name) {
31
168
  if (USE_INK) {
32
169
  ink.initInk();
@@ -49,6 +186,21 @@ export function renderContextLine() {
49
186
  out(` ${C.dim(`${days[d.getDay()]} · ${h12}:${min} ${ampm}`)}`);
50
187
  out('');
51
188
  }
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
+ }
52
204
  // ─── CRM List ───
53
205
  export function renderCRMList(contacts) {
54
206
  for (const t of contacts.slice(0, 12)) {
@@ -86,11 +238,11 @@ export function renderSection(title, items, bullet, colorBold, colorNorm) {
86
238
  export function renderHandled(items, max = 5) {
87
239
  if (!items.length)
88
240
  return;
89
- out(` ${C.dim('HANDLED')}`);
241
+ out(` ${C.faint('handled')}`);
90
242
  for (const item of items.slice(0, max))
91
- out(` ${C.ok('')} ${C.dim(item)}`);
243
+ out(` ${C.faint('·')} ${C.dim(item)}`);
92
244
  if (items.length > max)
93
- out(` ${C.dim(`+${items.length - max} more`)}`);
245
+ out(` ${C.faint(`+${items.length - max} more`)}`);
94
246
  out('');
95
247
  }
96
248
  // ─── Brief ───
@@ -151,7 +303,7 @@ export async function renderCRMStream(source, spinner) {
151
303
  const gap = Math.max(2, w - 4 - name.length - ago.length);
152
304
  out(` ${C.hd(name)}${' '.repeat(gap)}${C.dim(ago)}`);
153
305
  if (t.relationship) {
154
- out(` ${C.ok(t.relationship.slice(0, w - 8))}`);
306
+ out(` ${C.mid(t.relationship.slice(0, w - 8))}`);
155
307
  }
156
308
  rendered.push(t);
157
309
  }
@@ -167,7 +319,7 @@ export async function renderCRMContact(t) {
167
319
  const gap = Math.max(2, w - 4 - name.length - ago.length);
168
320
  out(` ${C.hd(name)}${' '.repeat(gap)}${C.dim(ago)}`);
169
321
  if (t.relationship)
170
- out(` ${C.ok(t.relationship.slice(0, w - 8))}`);
322
+ out(` ${C.mid(t.relationship.slice(0, w - 8))}`);
171
323
  }
172
324
  // ─── Destroy (call at exit) ───
173
325
  export function destroyUI() {
package/dist/tunnel.js CHANGED
@@ -49,12 +49,12 @@ export function isFunnelActive() {
49
49
  export function startFunnel(port = DEFAULT_PORT) {
50
50
  state.port = port;
51
51
  if (!hasTailscale()) {
52
- process.stderr.write(' tunnel: tailscale not found\n');
52
+ // silent caller handles missing network
53
53
  return null;
54
54
  }
55
55
  const hostname = getHostname();
56
56
  if (!hostname) {
57
- process.stderr.write(' tunnel: cannot determine tailscale hostname\n');
57
+ // silent caller handles missing hostname
58
58
  return null;
59
59
  }
60
60
  // Check if already active
@@ -78,7 +78,7 @@ export function startFunnel(port = DEFAULT_PORT) {
78
78
  child.on('exit', (code) => {
79
79
  state.healthy = false;
80
80
  if (code !== 0) {
81
- process.stderr.write(` tunnel: funnel exited with code ${code}\n`);
81
+ process.stderr.write(` connection dropped\n`);
82
82
  }
83
83
  });
84
84
  // Give it a moment to establish
@@ -86,7 +86,7 @@ export function startFunnel(port = DEFAULT_PORT) {
86
86
  return state.url;
87
87
  }
88
88
  catch (err) {
89
- process.stderr.write(` tunnel: failed to start — ${err instanceof Error ? err.message : String(err)}\n`);
89
+ process.stderr.write(` connection failed\n`);
90
90
  return null;
91
91
  }
92
92
  }