life-pulse 2.3.9 → 2.3.11

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
@@ -1,18 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  import { collectAll } from './index.js';
3
3
  import { runAgent } from './agent.js';
4
- import { analyzeWithLLM } from './analyze.js';
5
4
  import { saveState, saveDecisions } from './state.js';
6
5
  // ProgressRenderer unused — removed
7
6
  import { InkProgress } from './ui/progress.js';
8
7
  import { addTodo, resolveTodos, pruneOld } from './todo.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';
8
+ import { renderIntro, revealContacts, revealInsights, pickCard, renderSection, renderSectionHeader, renderHandled, renderDivider, renderBrief, renderArchetype, Spinner, destroyUI, MAG, CYN, HD, RED, AMB, MID, DIM } from './tui.js';
10
9
  import { needsDiscovery, discoverPlatforms, savePlatforms } from './platforms.js';
11
10
  import { generateArchetype } from './archetype.js';
12
- import { runPermissionFlow, getMissingPermissions } from './permissions.js';
13
- import { buildCRM, streamEnrichedCRM, generateInsights, isHumanContact } from './crm.js';
11
+ import { runPermissionFlow, hasRequiredPermissions, getMissingPermissions } from './permissions.js';
12
+ import { buildCRM, streamEnrichedCRM, generateInsights } from './crm.js';
14
13
  import { saveContactSummaries } from './intelligence.js';
15
- import { getUserName } from './profile.js';
14
+ import { buildPersonalSummary, getUserName } from './profile.js';
16
15
  // Session progress tracking (Anthropic long-running agent pattern)
17
16
  import { startSession, endSession, loadProgress, recordDecision, recordSurfaced, getTimeSinceLastSession, getNewDiscoveries } from './session-progress.js';
18
17
  import { runDiscovery, formatDiscoveryReport } from './icloud-discovery.js';
@@ -21,7 +20,7 @@ import { startHealthServer, stopHealthServer, isDaemonRunning, getHealthStatus,
21
20
  // New: long-running agent harness modules
22
21
  import { runInitCheck } from './init-check.js';
23
22
  import { installCrashHandlers, recordSuccess, recordCrash, checkpoint, getCrashStats } from './watchdog.js';
24
- import { startMessageLoop } from './message-loop.js';
23
+ import { startMessageLoop, sendMessage } from './message-loop.js';
25
24
  import { scanForSkills } from './skill-loader.js';
26
25
  import { TransportManager, IMessageTransport, TelegramTransport } from './transport.js';
27
26
  import { runInstaller } from './installer.js';
@@ -29,12 +28,16 @@ import { converse } from './conversation.js';
29
28
  import { startGateway, isGatewayUp } from './sms-gateway.js';
30
29
  import { updateAutoKnowledge } from './knowledge.js';
31
30
  import { startRouter, initClientRegistry } from './router.js';
31
+ import { findHandlesForName } from './contacts.js';
32
+ import { ensurePromptLayerFiles } from './prompt-layers.js';
33
+ import { hasTailscale, startFunnel, getHostname as getTailscaleHostname } from './tunnel.js';
32
34
  import chalk from 'chalk';
33
35
  import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'fs';
34
36
  import { join, dirname } from 'path';
35
37
  import { fileURLToPath } from 'url';
36
38
  import { homedir } from 'os';
37
39
  import { execSync, execFileSync } from 'child_process';
40
+ import { createInterface } from 'readline';
38
41
  import dayjs from 'dayjs';
39
42
  const collectedDecisions = [];
40
43
  const DEFAULT_CONFIG = {
@@ -42,6 +45,7 @@ const DEFAULT_CONFIG = {
42
45
  monitorIntervalSec: 30,
43
46
  notifyTiers: ['T1', 'T2'],
44
47
  quietHours: { start: '22:00', end: '07:00' },
48
+ briefSmsEnabled: true,
45
49
  };
46
50
  function loadConfig() {
47
51
  const p = join(homedir(), 'Library/Application Support/life-pulse/config.json');
@@ -64,12 +68,9 @@ function loadEnvFile(path) {
64
68
  // Project-local .env first (dev), then ~/.config/life-pulse/.env (npm users)
65
69
  loadEnvFile(join(dirname(fileURLToPath(import.meta.url)), '..', '.env'));
66
70
  loadEnvFile(join(homedir(), '.config', 'life-pulse', '.env'));
67
- const API_KEY = process.env.OPENROUTER_API_KEY || process.env.LLM_API_KEY || '';
68
71
  const ANTHROPIC_KEY = process.env.ANTHROPIC_API_KEY || '';
69
- function renderList(items, bullet) {
70
- for (const item of items) {
71
- console.log(` ${bullet} ${item}`);
72
- }
72
+ function sleep(ms) {
73
+ return new Promise(resolve => setTimeout(resolve, ms));
73
74
  }
74
75
  async function fetchCalendarContext() {
75
76
  try {
@@ -82,52 +83,195 @@ async function fetchCalendarContext() {
82
83
  catch { }
83
84
  return '';
84
85
  }
86
+ function normalizePhoneCandidate(raw) {
87
+ const t = raw.trim();
88
+ if (!t)
89
+ return '';
90
+ if (t.startsWith('+'))
91
+ return '+' + t.slice(1).replace(/\D/g, '');
92
+ return t.replace(/\D/g, '');
93
+ }
94
+ async function promptLine(question, fallback = '') {
95
+ if (!process.stdin.isTTY)
96
+ return fallback;
97
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
98
+ const suffix = fallback ? ` [${fallback}]` : '';
99
+ return new Promise(resolve => {
100
+ rl.question(` ${question}${suffix}: `, answer => {
101
+ rl.close();
102
+ const value = answer.trim();
103
+ resolve(value || fallback);
104
+ });
105
+ });
106
+ }
107
+ function detectOpenClawToken() {
108
+ const keys = [
109
+ 'gateway.http.auth.token',
110
+ 'gateway.auth.token',
111
+ 'gateway.http.token',
112
+ ];
113
+ for (const key of keys) {
114
+ try {
115
+ const out = execSync(`openclaw config get ${key}`, { stdio: 'pipe', timeout: 5000, encoding: 'utf-8' }).trim();
116
+ if (!out)
117
+ continue;
118
+ const low = out.toLowerCase();
119
+ if (low.includes('not set') || low.includes('unknown') || low.includes('error'))
120
+ continue;
121
+ return out;
122
+ }
123
+ catch { }
124
+ }
125
+ return '';
126
+ }
127
+ function writeNoxRouteJson(payload) {
128
+ const desktop = join(homedir(), 'Desktop');
129
+ mkdirSync(desktop, { recursive: true });
130
+ const path = join(desktop, 'nox-route.json');
131
+ writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, 'utf-8');
132
+ try {
133
+ execSync(`cat ${JSON.stringify(path)} | pbcopy`, { stdio: 'pipe', timeout: 5000 });
134
+ }
135
+ catch { }
136
+ try {
137
+ execSync(`open -R ${JSON.stringify(path)}`, { stdio: 'pipe', timeout: 5000 });
138
+ }
139
+ catch { }
140
+ return path;
141
+ }
142
+ function loadPhoneFromClientsRegistry() {
143
+ const p = join(homedir(), '.life-pulse', 'clients.json');
144
+ if (!existsSync(p))
145
+ return null;
146
+ try {
147
+ const json = JSON.parse(readFileSync(p, 'utf-8'));
148
+ const enabled = (json.clients || []).filter(c => c.enabled !== false && c.phone);
149
+ if (!enabled.length)
150
+ return null;
151
+ const first = normalizePhoneCandidate(enabled[0].phone || '');
152
+ return first || null;
153
+ }
154
+ catch {
155
+ return null;
156
+ }
157
+ }
158
+ function resolveBriefSmsTarget(config) {
159
+ const explicit = [
160
+ process.env.LIFE_PULSE_BRIEF_SMS_PHONE,
161
+ process.env.LIFE_PULSE_SELF_PHONE,
162
+ process.env.NOX_OWNER_PHONE,
163
+ config.briefSmsPhone,
164
+ ].map(v => normalizePhoneCandidate(v || '')).find(Boolean);
165
+ if (explicit)
166
+ return explicit;
167
+ const fromName = findHandlesForName(getUserName())
168
+ .map(h => normalizePhoneCandidate(h))
169
+ .find(h => /^\+?\d{10,15}$/.test(h));
170
+ if (fromName)
171
+ return fromName;
172
+ return loadPhoneFromClientsRegistry();
173
+ }
174
+ function buildBriefingText(name, analysis) {
175
+ const first = name.split(' ')[0] || name;
176
+ const lines = [`hey ${first}, quick life-pulse brief:`];
177
+ const addItems = (label, items, max) => {
178
+ if (!items?.length)
179
+ return;
180
+ let added = 0;
181
+ for (const item of items) {
182
+ if (added >= max)
183
+ break;
184
+ const title = String(item?.title || '').trim();
185
+ if (!title)
186
+ continue;
187
+ const rec = item?.options?.[0]?.label ? ` -> ${item.options[0].label}` : '';
188
+ lines.push(`${label} ${title}${rec}`);
189
+ added++;
190
+ }
191
+ };
192
+ addItems('PROMISE:', analysis.promises, 2);
193
+ addItems('BLOCKER:', analysis.blockers, 2);
194
+ addItems('BUMP:', analysis.bumps, 1);
195
+ if (analysis.alpha?.length) {
196
+ const alpha = String(analysis.alpha[0] || '').trim();
197
+ if (alpha)
198
+ lines.push(`ALPHA: ${alpha}`);
199
+ }
200
+ if (lines.length === 1)
201
+ lines.push('all clear. nothing urgent right now.');
202
+ return lines.join('\n').slice(0, 1400);
203
+ }
204
+ async function sendViaImwebserver(phone, message) {
205
+ try {
206
+ const resp = await fetch('http://127.0.0.1:8888/sendMessage', {
207
+ method: 'POST',
208
+ headers: { 'Content-Type': 'application/json' },
209
+ body: JSON.stringify({ phone, message }),
210
+ signal: AbortSignal.timeout(5_000),
211
+ });
212
+ return resp.ok;
213
+ }
214
+ catch {
215
+ return false;
216
+ }
217
+ }
218
+ async function sendBriefingText(phone, message) {
219
+ if (await sendViaImwebserver(phone, message))
220
+ return true;
221
+ return sendMessage(phone, message, false);
222
+ }
85
223
  async function showCRM(apiKey, opts) {
86
- const crm = await buildCRM();
224
+ const crm = opts?.crmPromise ? await opts.crmPromise : await buildCRM();
87
225
  if (!crm.threads.length || !apiKey) {
88
226
  opts?.spinner?.stop();
89
227
  return false;
90
228
  }
91
229
  opts?.spinner?.stop();
92
230
  console.log();
231
+ console.log(` ${HD('your inner circle')}`);
232
+ console.log();
93
233
  // Fire insights in background — they'll resolve while user steps through contacts
94
234
  const insightsGen = generateInsights(crm);
95
235
  const calendarContext = opts?.calendarContext ?? '';
96
236
  const hour = new Date().getHours();
97
237
  const timeOfDay = hour < 12 ? 'morning' : hour < 17 ? 'afternoon' : 'evening';
98
238
  let brief = '';
239
+ let resolveBrief = null;
240
+ const briefReady = new Promise(resolve => { resolveBrief = resolve; });
99
241
  const enriched = await revealContacts(streamEnrichedCRM(crm, apiKey, {
100
242
  calendarContext,
101
243
  timeOfDay,
102
- onBrief: (b) => { brief = b; },
244
+ onBrief: (b) => {
245
+ brief = b;
246
+ resolveBrief?.();
247
+ },
248
+ briefAsync: true,
103
249
  }));
104
- const enrichedNames = new Set(enriched.map(t => t.name));
105
- // Show remaining human contacts (not enriched) — just names
106
- const remaining = crm.threads
107
- .filter(t => !enrichedNames.has(t.name) && isHumanContact(t.name))
108
- .slice(0, 5);
109
- if (remaining.length)
110
- renderCRMList(remaining);
111
- if (brief)
250
+ // Optimistic brief: wait a beat, then move on.
251
+ await Promise.race([briefReady, sleep(300)]);
252
+ if (brief) {
253
+ renderDivider();
112
254
  await renderBrief(brief);
113
- // Insights drip in after contacts
114
- await revealInsights(insightsGen);
255
+ }
256
+ // Optimistic insights: show if they land quickly, don't block the flow.
257
+ await revealInsights(insightsGen, { maxWaitMs: 900 });
258
+ renderDivider();
115
259
  saveContactSummaries(enriched);
116
260
  return true;
117
261
  }
118
262
  async function main() {
263
+ const createdPromptFiles = ensurePromptLayerFiles();
119
264
  const jsonMode = process.argv.includes('--json');
120
265
  const rawMode = process.argv.includes('--raw');
121
- const legacyMode = process.argv.includes('--legacy');
122
266
  const statusMode = process.argv.includes('--status');
123
267
  const daemonMode = process.argv.includes('--daemon');
124
268
  const healthMode = process.argv.includes('--health');
125
269
  const initCheckMode = process.argv.includes('--check');
126
270
  const phoneSetupMode = process.argv.includes('--phone-setup');
127
271
  const testSmsMode = process.argv.includes('--test-sms');
272
+ const pairMode = process.argv.includes('--pair') || process.argv.includes('--export-route');
128
273
  const routerMode = process.argv.includes('--router');
129
274
  const initClientsMode = process.argv.includes('--init-clients');
130
- const key = process.argv.find(a => a.startsWith('--key='))?.split('=')[1] || API_KEY;
131
275
  // Install crash handlers early (Anthropic pattern: recover from failures)
132
276
  installCrashHandlers((err) => {
133
277
  console.error(chalk.red(` crash: ${err.message}`));
@@ -145,7 +289,7 @@ async function main() {
145
289
  console.log();
146
290
  return;
147
291
  }
148
- // --router: run message router on YOUR Mac (routes to client Mac Minis)
292
+ // --router: legacy bridge mode (multi-machine relay setups)
149
293
  if (routerMode) {
150
294
  console.log(chalk.bold.hex('#c0caf5')(' life-pulse'));
151
295
  console.log(chalk.dim(' routing messages'));
@@ -191,6 +335,52 @@ async function main() {
191
335
  }
192
336
  return;
193
337
  }
338
+ // --pair / --export-route: generate Desktop/nox-route.json for NOX routing
339
+ if (pairMode) {
340
+ const defaultName = getUserName() || '';
341
+ const detectedHost = getTailscaleHostname() || '';
342
+ const detectedToken = detectOpenClawToken();
343
+ const envPhone = normalizePhoneCandidate(process.env.LIFE_PULSE_SELF_PHONE
344
+ || process.env.NOX_OWNER_PHONE
345
+ || process.env.LIFE_PULSE_BRIEF_SMS_PHONE
346
+ || '');
347
+ console.log();
348
+ console.log(chalk.bold.hex('#c0caf5')(' pair with nox'));
349
+ console.log(chalk.dim(' we will create Desktop/nox-route.json'));
350
+ console.log();
351
+ const name = await promptLine('name', defaultName);
352
+ const phoneRaw = await promptLine('iphone number (+1...)', envPhone);
353
+ const phone = normalizePhoneCandidate(phoneRaw);
354
+ if (!phone) {
355
+ console.log(chalk.red(' missing phone number'));
356
+ return;
357
+ }
358
+ const host = await promptLine('tailscale host/ip', detectedHost);
359
+ if (!host) {
360
+ console.log(chalk.red(' missing tailscale host/ip'));
361
+ return;
362
+ }
363
+ const token = await promptLine('openclaw token', detectedToken);
364
+ if (!token) {
365
+ console.log(chalk.red(' missing openclaw token'));
366
+ return;
367
+ }
368
+ const payload = {
369
+ name: name || 'User',
370
+ phone,
371
+ openclaw_url: `http://${host}:18789`,
372
+ openclaw_token: token,
373
+ openclaw_agent_id: 'main',
374
+ openclaw_model: 'openclaw:main',
375
+ enabled: true,
376
+ };
377
+ const out = writeNoxRouteJson(payload);
378
+ console.log();
379
+ console.log(chalk.green(` saved ${out}`));
380
+ console.log(chalk.dim(' send this file to your operator'));
381
+ console.log();
382
+ return;
383
+ }
194
384
  // --health: check if daemon is running and get status
195
385
  if (healthMode) {
196
386
  if (isDaemonRunning()) {
@@ -254,7 +444,8 @@ async function main() {
254
444
  activeContacts,
255
445
  pendingFollowUps,
256
446
  });
257
- console.log(chalk.dim('\n progress saved'));
447
+ console.log(chalk.green('\n demo wrapped cleanly'));
448
+ console.log(chalk.dim(' progress saved'));
258
449
  process.exit(0);
259
450
  };
260
451
  // Auto-detect installer mode: first run with no state → white-glove installer
@@ -262,7 +453,7 @@ async function main() {
262
453
  const installStateExists = existsSync(join(stateDir, 'install-state.json'));
263
454
  const noSessionHistory = sessionProgress.totalSessions <= 1;
264
455
  const isSetupFlag = process.argv.includes('--setup');
265
- if ((noSessionHistory && !installStateExists && !rawMode && !jsonMode && !legacyMode && !daemonMode && !process.argv.includes('--install'))
456
+ if ((noSessionHistory && !installStateExists && !rawMode && !jsonMode && !daemonMode && !process.argv.includes('--install'))
266
457
  || isSetupFlag) {
267
458
  // First run: launch full installer
268
459
  await runInstaller(ANTHROPIC_KEY);
@@ -348,16 +539,35 @@ async function main() {
348
539
  </plist>`;
349
540
  writeFileSync(join(agentsDir, 'com.life-pulse.morning.plist'), morningPlist);
350
541
  writeFileSync(join(agentsDir, 'com.life-pulse.daemon.plist'), daemonPlist);
542
+ // Prefer direct NOX -> this Mac flow: ensure tailscale endpoint is configured.
543
+ let noxEndpoint = null;
544
+ if (hasTailscale()) {
545
+ noxEndpoint = startFunnel(19877);
546
+ if (!noxEndpoint) {
547
+ const host = getTailscaleHostname();
548
+ if (host)
549
+ noxEndpoint = `https://${host}`;
550
+ }
551
+ }
351
552
  console.log(chalk.dim(' running in the background'));
352
553
  console.log(chalk.dim(` morning brief at ${config.briefTime}`));
554
+ if (noxEndpoint) {
555
+ console.log(chalk.dim(` nox endpoint ${noxEndpoint}`));
556
+ }
557
+ else {
558
+ console.log(chalk.dim(' nox endpoint unavailable — tailscale needed'));
559
+ }
353
560
  console.log();
354
561
  return;
355
562
  }
356
- if (!key && !ANTHROPIC_KEY) {
563
+ if (!ANTHROPIC_KEY) {
357
564
  console.log(chalk.red('\n\n missing API key\n'));
358
565
  process.exit(1);
359
566
  }
360
- const interactive = process.stdin.isTTY && !jsonMode && !legacyMode;
567
+ const interactive = process.stdin.isTTY && !jsonMode;
568
+ if (interactive && createdPromptFiles.length) {
569
+ console.log(chalk.dim(' initialized identity files'));
570
+ }
361
571
  // ── Fire calendar fetch early ──
362
572
  const calendarP = interactive ? fetchCalendarContext() : Promise.resolve('');
363
573
  // ── Instant greeting + context line ──
@@ -370,6 +580,14 @@ async function main() {
370
580
  await renderIntro(userName);
371
581
  }
372
582
  const spinner = interactive ? new Spinner() : undefined;
583
+ let crmWarmupP = null;
584
+ // Optimistic prewarm: kick off heavy profile context in the background.
585
+ if (interactive && hasRequiredPermissions()) {
586
+ crmWarmupP = buildCRM();
587
+ }
588
+ if (interactive && ANTHROPIC_KEY) {
589
+ void buildPersonalSummary();
590
+ }
373
591
  // ── First-run: permissions → discovery → archetype ──
374
592
  let crmShown = false;
375
593
  const setupMode = process.argv.includes('--setup');
@@ -386,6 +604,9 @@ async function main() {
386
604
  }
387
605
  if (process.stdin.isTTY)
388
606
  await runPermissionFlow();
607
+ if (interactive && !crmWarmupP && hasRequiredPermissions()) {
608
+ crmWarmupP = buildCRM();
609
+ }
389
610
  spinner?.start('learning your world');
390
611
  const platformProfile = discoverPlatforms();
391
612
  // iCloud + local app discovery
@@ -419,7 +640,13 @@ async function main() {
419
640
  if (interactive) {
420
641
  spinner?.update('getting to know you');
421
642
  const calCtx = await calendarP;
422
- crmShown = await showCRM(ANTHROPIC_KEY, { calendarContext: calCtx, spinner });
643
+ if (!crmWarmupP)
644
+ crmWarmupP = buildCRM();
645
+ crmShown = await showCRM(ANTHROPIC_KEY, {
646
+ calendarContext: calCtx,
647
+ spinner,
648
+ crmPromise: crmWarmupP,
649
+ });
423
650
  }
424
651
  if (ANTHROPIC_KEY) {
425
652
  spinner?.start('figuring out who you are');
@@ -444,50 +671,27 @@ async function main() {
444
671
  console.log();
445
672
  }
446
673
  }
447
- // Legacy mode: single-shot LLM call (old behavior)
448
- if (legacyMode) {
449
- spinner?.stop();
450
- if (!jsonMode)
451
- process.stdout.write(chalk.dim('\n pulling threads...'));
452
- const collected = await collectAll();
453
- if (!jsonMode)
454
- process.stdout.write(chalk.dim(` ${collected.sources.length} sources... thinking`));
455
- const analysis = await analyzeWithLLM(collected.data, key);
456
- if (jsonMode) {
457
- console.log(JSON.stringify({ collected, analysis }, null, 2));
458
- return;
459
- }
460
- renderAnalysis(analysis, collected.sources.length, collected.generated);
461
- saveState(analysis);
462
- return;
463
- }
464
- // Agent mode (default): multi-turn investigation with Anthropic
674
+ // Agent mode: multi-turn investigation with Anthropic
465
675
  if (!ANTHROPIC_KEY) {
466
676
  spinner?.stop();
467
677
  console.log(chalk.red('\n\n missing API key\n'));
468
678
  process.exit(1);
469
679
  }
470
- // ── CRM: show relationship map before agent runs ──
471
- if (interactive && !crmShown) {
472
- spinner?.start('getting to know you');
473
- const calCtx = await calendarP;
474
- await showCRM(ANTHROPIC_KEY, { calendarContext: calCtx, spinner });
475
- }
476
- const renderer = interactive ? new InkProgress() : undefined;
477
- renderer?.start();
478
680
  // Track streamed cards so we don't double-show from final output
479
681
  const streamedTitles = new Set();
480
682
  let cardCount = 0;
481
- const onCard = interactive ? async (card) => {
683
+ // ── Start agent in background immediately cards generate while CRM shows ──
684
+ const cardBuffer = [];
685
+ let cardsLive = false;
686
+ const renderer = interactive ? new InkProgress() : undefined;
687
+ const processCard = async (card) => {
482
688
  cardCount++;
483
689
  streamedTitles.add(card.title);
484
690
  const pick = await pickCard(card, cardCount);
485
691
  addTodo(card.title, pick, card.urgency || 'today', card.fyi || pick === 'noted');
486
692
  collectedDecisions.push({ title: card.title, picked: pick });
487
- // Record in session progress (Anthropic pattern: track decisions)
488
693
  recordDecision(card.title, pick);
489
694
  recordSurfaced(card.title, card.category || 'bump');
490
- // Track active contacts for follow-ups
491
695
  if (card.contact && !activeContacts.includes(card.contact)) {
492
696
  activeContacts.push(card.contact);
493
697
  }
@@ -495,8 +699,35 @@ async function main() {
495
699
  pendingFollowUps.push(card.title);
496
700
  }
497
701
  return pick;
702
+ };
703
+ const onCard = interactive ? async (card) => {
704
+ if (cardsLive)
705
+ return processCard(card);
706
+ return new Promise((resolve) => {
707
+ cardBuffer.push({ card, resolve });
708
+ });
498
709
  } : undefined;
499
- const analysis = await runAgent(ANTHROPIC_KEY, renderer, onCard);
710
+ const agentPromise = runAgent(ANTHROPIC_KEY, renderer, onCard);
711
+ // ── CRM: show relationship map while agent scans in background ──
712
+ if (interactive && !crmShown) {
713
+ spinner?.start('getting to know you');
714
+ const calCtx = await calendarP;
715
+ if (!crmWarmupP)
716
+ crmWarmupP = buildCRM();
717
+ await showCRM(ANTHROPIC_KEY, {
718
+ calendarContext: calCtx,
719
+ spinner,
720
+ crmPromise: crmWarmupP,
721
+ });
722
+ }
723
+ renderer?.start();
724
+ cardsLive = true;
725
+ // Drain any cards that arrived during CRM display
726
+ for (const { card, resolve } of cardBuffer) {
727
+ resolve(await processCard(card));
728
+ }
729
+ cardBuffer.length = 0;
730
+ const analysis = await agentPromise;
500
731
  renderer?.stop();
501
732
  renderer?.clear();
502
733
  if (jsonMode) {
@@ -527,12 +758,6 @@ async function main() {
527
758
  for (const a of analysis.alpha)
528
759
  console.log(` ★ ${a}`);
529
760
  }
530
- // Fallback: old format
531
- for (const d of (analysis.decisions || [])) {
532
- const rec = d.options?.[0];
533
- const tag = d.fyi ? '[fyi]' : `[${d.urgency}]`;
534
- console.log(d.fyi ? ` ${tag} ${d.title}` : ` ${tag} ${d.title} → ${rec?.label || 'no rec'}`);
535
- }
536
761
  saveState(analysis);
537
762
  return;
538
763
  }
@@ -547,45 +772,42 @@ async function main() {
547
772
  // Handled (compact)
548
773
  if (analysis.handled?.length)
549
774
  renderHandled(analysis.handled, 5);
775
+ const remainingPromises = (analysis.promises || []).filter((d) => !streamedTitles.has(d.title));
776
+ const remainingBlockers = (analysis.blockers || []).filter((d) => !streamedTitles.has(d.title));
777
+ const remainingBumps = (analysis.bumps || []).filter((d) => !streamedTitles.has(d.title));
778
+ const totalCardsForWalk = cardCount
779
+ + remainingPromises.length
780
+ + remainingBlockers.length
781
+ + remainingBumps.length;
550
782
  // Helper: walk cards in a section with arrow-key TUI
551
783
  const walkCards = async (items, category) => {
552
- const remaining = items.filter(d => !streamedTitles.has(d.title));
553
- if (!remaining.length)
784
+ if (!items.length)
554
785
  return;
555
- for (let i = 0; i < remaining.length; i++) {
556
- const d = remaining[i];
786
+ for (let i = 0; i < items.length; i++) {
787
+ const d = items[i];
557
788
  const card = { ...d, category };
558
789
  cardCount++;
559
- const pick = await pickCard(card, cardCount);
790
+ const pick = await pickCard(card, cardCount, totalCardsForWalk);
560
791
  addTodo(d.title, pick, d.urgency || 'today', false);
561
792
  collectedDecisions.push({ title: d.title, picked: pick });
562
793
  }
563
794
  };
564
795
  // Walk the four sections
565
- if (analysis.promises?.length) {
796
+ if (remainingPromises.length) {
566
797
  renderSectionHeader('PROMISES', RED);
567
- await walkCards(analysis.promises, 'promise');
798
+ await walkCards(remainingPromises, 'promise');
568
799
  }
569
- if (analysis.blockers?.length) {
800
+ if (remainingBlockers.length) {
570
801
  renderSectionHeader('BLOCKED', AMB);
571
- await walkCards(analysis.blockers, 'blocker');
802
+ await walkCards(remainingBlockers, 'blocker');
572
803
  }
573
- if (analysis.bumps?.length) {
804
+ if (remainingBumps.length) {
574
805
  renderSectionHeader('BUMPS', CYN);
575
- await walkCards(analysis.bumps, 'bump');
806
+ await walkCards(remainingBumps, 'bump');
576
807
  }
577
808
  if (analysis.alpha?.length) {
578
809
  renderSection('ALPHA', analysis.alpha, '★', MAG.bold, MAG);
579
810
  }
580
- // Fallback: old decisions format (for legacy/transition)
581
- const oldRemaining = (analysis.decisions || []).filter(d => !streamedTitles.has(d.title));
582
- for (let i = 0; i < oldRemaining.length; i++) {
583
- const d = oldRemaining[i];
584
- cardCount++;
585
- const pick = await pickCard(d, cardCount);
586
- addTodo(d.title, pick, d.urgency || 'today', d.fyi || pick === 'noted');
587
- collectedDecisions.push({ title: d.title, picked: pick });
588
- }
589
811
  // Persist handled for delta context
590
812
  for (const h of (analysis.handled || [])) {
591
813
  addTodo(h, 'handled', 'today', true);
@@ -594,7 +816,6 @@ async function main() {
594
816
  ...(analysis.promises || []),
595
817
  ...(analysis.blockers || []),
596
818
  ...(analysis.bumps || []),
597
- ...(analysis.decisions || []),
598
819
  ].some((d) => d.options?.length);
599
820
  if (!hasActionable && !(analysis.alpha?.length)) {
600
821
  console.log(DIM(' All clear — nothing needs you right now.'));
@@ -605,6 +826,17 @@ async function main() {
605
826
  saveState(analysis);
606
827
  // ── Stay resident: message loop + transport layer ──
607
828
  const config = loadConfig();
829
+ // End-of-brief text so the plan follows you out of terminal.
830
+ if (config.briefSmsEnabled !== false) {
831
+ const targetPhone = resolveBriefSmsTarget(config);
832
+ if (targetPhone) {
833
+ const text = buildBriefingText(getUserName(), analysis);
834
+ const sent = await sendBriefingText(targetPhone, text);
835
+ if (!daemonMode && sent) {
836
+ console.log(chalk.dim(` texted your brief to ${targetPhone}`));
837
+ }
838
+ }
839
+ }
608
840
  // Checkpoint state before going resident (Anthropic pattern: save before risky ops)
609
841
  checkpoint('pre-resident');
610
842
  recordSuccess(); // We made it through the briefing — reset crash counter
@@ -662,15 +894,30 @@ async function main() {
662
894
  recordCrash(err.message, 'message-loop');
663
895
  },
664
896
  });
665
- if (!daemonMode) {
666
- console.log(chalk.dim(' listening'));
667
- console.log();
668
- }
669
- // Start health server for daemon mode
897
+ // Start NOX gateway in resident mode (daemon + interactive)
670
898
  let gw = null;
899
+ if (ANTHROPIC_KEY) {
900
+ gw = startGateway(ANTHROPIC_KEY);
901
+ }
902
+ let noxEndpoint = null;
903
+ if (hasTailscale()) {
904
+ noxEndpoint = startFunnel(19877);
905
+ if (!noxEndpoint) {
906
+ const host = getTailscaleHostname();
907
+ if (host)
908
+ noxEndpoint = `https://${host}`;
909
+ }
910
+ }
671
911
  if (daemonMode) {
672
912
  startHealthServer();
673
- gw = startGateway(ANTHROPIC_KEY);
913
+ }
914
+ if (!daemonMode) {
915
+ console.log(chalk.dim(' listening'));
916
+ console.log(chalk.dim(' nox number is live'));
917
+ if (noxEndpoint)
918
+ console.log(chalk.dim(` endpoint ${noxEndpoint}`));
919
+ console.log(chalk.green(' press ctrl+c to end demo'));
920
+ console.log();
674
921
  }
675
922
  // Generate knowledge bases from CRM on startup
676
923
  try {
@@ -705,79 +952,6 @@ async function main() {
705
952
  }
706
953
  await new Promise(() => { });
707
954
  }
708
- function renderAnalysis(analysis, sourceCount, generated) {
709
- console.log();
710
- console.log(chalk.bold(' LIFE PULSE'));
711
- console.log();
712
- console.log(` ${analysis.greeting}`);
713
- console.log();
714
- if (analysis.decisions?.length) {
715
- console.log(chalk.bold.red(' DECISIONS'));
716
- console.log();
717
- for (const d of analysis.decisions) {
718
- const tag = d.fyi ? chalk.dim('[fyi]') : `[${d.urgency}]`;
719
- console.log(` ${d.title} ${tag}`);
720
- if (d.options?.length) {
721
- for (const o of d.options)
722
- console.log(` · ${o.label} ${chalk.dim('— ' + (o.description || ''))}`);
723
- }
724
- console.log();
725
- }
726
- }
727
- if (analysis.upcoming?.length) {
728
- console.log(chalk.bold.cyan(' UPCOMING'));
729
- console.log();
730
- renderList(analysis.upcoming, chalk.cyan('▸'));
731
- console.log();
732
- }
733
- if (analysis.handled?.length) {
734
- console.log(chalk.bold.green(' HANDLED'));
735
- console.log();
736
- renderList(analysis.handled, chalk.green('✓'));
737
- console.log();
738
- }
739
- if (analysis.intel?.length) {
740
- console.log(chalk.bold.magenta(' INTEL'));
741
- console.log();
742
- renderList(analysis.intel, chalk.magenta('~'));
743
- console.log();
744
- }
745
- // Legacy format fallback
746
- if (analysis.right_now?.length) {
747
- console.log(chalk.bold.red(' RIGHT NOW'));
748
- console.log();
749
- renderList(analysis.right_now, chalk.red('→'));
750
- console.log();
751
- }
752
- if (analysis.today?.length) {
753
- console.log(chalk.bold.white(' TODAY'));
754
- console.log();
755
- renderList(analysis.today, chalk.dim('·'));
756
- console.log();
757
- }
758
- if (analysis.this_week?.length) {
759
- console.log(chalk.bold(' THIS WEEK'));
760
- console.log();
761
- renderList(analysis.this_week, chalk.dim('·'));
762
- console.log();
763
- }
764
- if (analysis.heads_up?.length) {
765
- console.log(chalk.bold.cyan(' HEADS UP'));
766
- console.log();
767
- renderList(analysis.heads_up, chalk.cyan('!'));
768
- console.log();
769
- }
770
- if (analysis.noticed?.length) {
771
- console.log(chalk.bold.magenta(' NOTICED'));
772
- console.log();
773
- renderList(analysis.noticed, chalk.magenta('~'));
774
- console.log();
775
- }
776
- if (sourceCount) {
777
- console.log(chalk.dim(` ${sourceCount} sources · ${generated}`));
778
- console.log();
779
- }
780
- }
781
955
  main().catch(e => {
782
956
  console.error(chalk.red('\n Error:'), e.message);
783
957
  process.exit(1);