life-pulse 2.3.10 → 2.4.0

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,40 +1,39 @@
1
1
  #!/usr/bin/env node
2
- import { collectAll } from './index.js';
3
2
  import { runAgent } from './agent.js';
4
- import { analyzeWithLLM } from './analyze.js';
5
3
  import { saveState, saveDecisions } from './state.js';
6
4
  // ProgressRenderer unused — removed
7
5
  import { InkProgress } from './ui/progress.js';
8
6
  import { addTodo, resolveTodos, pruneOld } from './todo.js';
9
- import { renderIntro, revealContacts, revealInsights, pickCard, renderSection, renderSectionHeader, renderHandled, renderDivider, renderBrief, renderArchetype, Spinner, destroyUI, MAG, CYN, RED, AMB, MID, DIM } from './tui.js';
7
+ import { renderIntro, revealContacts, revealInsights, pickCard, renderSection, renderSectionHeader, renderHandled, renderDivider, renderBrief, renderArchetype, Spinner, destroyUI, MAG, CYN, HD, RED, AMB, MID, DIM } from './tui.js';
10
8
  import { needsDiscovery, discoverPlatforms, savePlatforms } from './platforms.js';
11
9
  import { generateArchetype } from './archetype.js';
12
- import { runPermissionFlow, getMissingPermissions } from './permissions.js';
10
+ import { runPermissionFlow, hasRequiredPermissions, getMissingPermissions } from './permissions.js';
13
11
  import { buildCRM, streamEnrichedCRM, generateInsights } from './crm.js';
14
12
  import { saveContactSummaries } from './intelligence.js';
15
- import { getUserName } from './profile.js';
13
+ import { buildPersonalSummary, getUserName } from './profile.js';
16
14
  // Session progress tracking (Anthropic long-running agent pattern)
17
15
  import { startSession, endSession, loadProgress, recordDecision, recordSurfaced, getTimeSinceLastSession, getNewDiscoveries } from './session-progress.js';
18
16
  import { runDiscovery, formatDiscoveryReport } from './icloud-discovery.js';
19
17
  import { getSourcesNeedingScan, getChangeSummary } from './incremental.js';
20
- import { startHealthServer, stopHealthServer, isDaemonRunning, getHealthStatus, touchActivity } from './health.js';
21
- // New: long-running agent harness modules
22
- import { runInitCheck } from './init-check.js';
18
+ import { startHealthServer, stopHealthServer, isDaemonRunning, touchActivity } from './health.js';
23
19
  import { installCrashHandlers, recordSuccess, recordCrash, checkpoint, getCrashStats } from './watchdog.js';
24
- import { startMessageLoop } from './message-loop.js';
20
+ import { startMessageLoop, sendMessage } from './message-loop.js';
25
21
  import { scanForSkills } from './skill-loader.js';
26
22
  import { TransportManager, IMessageTransport, TelegramTransport } from './transport.js';
27
23
  import { runInstaller } from './installer.js';
28
24
  import { converse } from './conversation.js';
29
- import { startGateway, isGatewayUp } from './sms-gateway.js';
25
+ import { startGateway } from './sms-gateway.js';
30
26
  import { updateAutoKnowledge } from './knowledge.js';
31
- import { startRouter, initClientRegistry } from './router.js';
27
+ import { findHandlesForName } from './contacts.js';
28
+ import { ensurePromptLayerFiles } from './prompt-layers.js';
29
+ import { hasTailscale, startFunnel, getHostname as getTailscaleHostname } from './tunnel.js';
32
30
  import chalk from 'chalk';
33
31
  import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'fs';
34
32
  import { join, dirname } from 'path';
35
33
  import { fileURLToPath } from 'url';
36
34
  import { homedir } from 'os';
37
35
  import { execSync, execFileSync } from 'child_process';
36
+ import { createInterface } from 'readline';
38
37
  import dayjs from 'dayjs';
39
38
  const collectedDecisions = [];
40
39
  const DEFAULT_CONFIG = {
@@ -42,6 +41,7 @@ const DEFAULT_CONFIG = {
42
41
  monitorIntervalSec: 30,
43
42
  notifyTiers: ['T1', 'T2'],
44
43
  quietHours: { start: '22:00', end: '07:00' },
44
+ briefSmsEnabled: true,
45
45
  };
46
46
  function loadConfig() {
47
47
  const p = join(homedir(), 'Library/Application Support/life-pulse/config.json');
@@ -64,12 +64,9 @@ function loadEnvFile(path) {
64
64
  // Project-local .env first (dev), then ~/.config/life-pulse/.env (npm users)
65
65
  loadEnvFile(join(dirname(fileURLToPath(import.meta.url)), '..', '.env'));
66
66
  loadEnvFile(join(homedir(), '.config', 'life-pulse', '.env'));
67
- const API_KEY = process.env.OPENROUTER_API_KEY || process.env.LLM_API_KEY || '';
68
67
  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
- }
68
+ function sleep(ms) {
69
+ return new Promise(resolve => setTimeout(resolve, ms));
73
70
  }
74
71
  async function fetchCalendarContext() {
75
72
  try {
@@ -82,120 +79,236 @@ async function fetchCalendarContext() {
82
79
  catch { }
83
80
  return '';
84
81
  }
82
+ function normalizePhoneCandidate(raw) {
83
+ const t = raw.trim();
84
+ if (!t)
85
+ return '';
86
+ if (t.startsWith('+'))
87
+ return '+' + t.slice(1).replace(/\D/g, '');
88
+ return t.replace(/\D/g, '');
89
+ }
90
+ async function promptLine(question, fallback = '') {
91
+ if (!process.stdin.isTTY)
92
+ return fallback;
93
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
94
+ const suffix = fallback ? ` [${fallback}]` : '';
95
+ return new Promise(resolve => {
96
+ rl.question(` ${question}${suffix}: `, answer => {
97
+ rl.close();
98
+ const value = answer.trim();
99
+ resolve(value || fallback);
100
+ });
101
+ });
102
+ }
103
+ function detectOpenClawToken() {
104
+ const keys = [
105
+ 'gateway.http.auth.token',
106
+ 'gateway.auth.token',
107
+ 'gateway.http.token',
108
+ ];
109
+ for (const key of keys) {
110
+ try {
111
+ const out = execSync(`openclaw config get ${key}`, { stdio: 'pipe', timeout: 5000, encoding: 'utf-8' }).trim();
112
+ if (!out)
113
+ continue;
114
+ const low = out.toLowerCase();
115
+ if (low.includes('not set') || low.includes('unknown') || low.includes('error'))
116
+ continue;
117
+ return out;
118
+ }
119
+ catch { }
120
+ }
121
+ return '';
122
+ }
123
+ function writeNoxRouteJson(payload) {
124
+ const desktop = join(homedir(), 'Desktop');
125
+ mkdirSync(desktop, { recursive: true });
126
+ const path = join(desktop, 'nox-route.json');
127
+ writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, 'utf-8');
128
+ try {
129
+ execSync(`cat ${JSON.stringify(path)} | pbcopy`, { stdio: 'pipe', timeout: 5000 });
130
+ }
131
+ catch { }
132
+ try {
133
+ execSync(`open -R ${JSON.stringify(path)}`, { stdio: 'pipe', timeout: 5000 });
134
+ }
135
+ catch { }
136
+ return path;
137
+ }
138
+ function loadPhoneFromClientsRegistry() {
139
+ const p = join(homedir(), '.life-pulse', 'clients.json');
140
+ if (!existsSync(p))
141
+ return null;
142
+ try {
143
+ const json = JSON.parse(readFileSync(p, 'utf-8'));
144
+ const enabled = (json.clients || []).filter(c => c.enabled !== false && c.phone);
145
+ if (!enabled.length)
146
+ return null;
147
+ const first = normalizePhoneCandidate(enabled[0].phone || '');
148
+ return first || null;
149
+ }
150
+ catch {
151
+ return null;
152
+ }
153
+ }
154
+ function resolveBriefSmsTarget(config) {
155
+ const explicit = [
156
+ process.env.LIFE_PULSE_BRIEF_SMS_PHONE,
157
+ process.env.LIFE_PULSE_SELF_PHONE,
158
+ process.env.NOX_OWNER_PHONE,
159
+ config.briefSmsPhone,
160
+ ].map(v => normalizePhoneCandidate(v || '')).find(Boolean);
161
+ if (explicit)
162
+ return explicit;
163
+ const fromName = findHandlesForName(getUserName())
164
+ .map(h => normalizePhoneCandidate(h))
165
+ .find(h => /^\+?\d{10,15}$/.test(h));
166
+ if (fromName)
167
+ return fromName;
168
+ return loadPhoneFromClientsRegistry();
169
+ }
170
+ function buildBriefingText(name, analysis) {
171
+ const first = name.split(' ')[0] || name;
172
+ const lines = [`hey ${first}, quick life-pulse brief:`];
173
+ const addItems = (label, items, max) => {
174
+ if (!items?.length)
175
+ return;
176
+ let added = 0;
177
+ for (const item of items) {
178
+ if (added >= max)
179
+ break;
180
+ const title = String(item?.title || '').trim();
181
+ if (!title)
182
+ continue;
183
+ const rec = item?.options?.[0]?.label ? ` -> ${item.options[0].label}` : '';
184
+ lines.push(`${label} ${title}${rec}`);
185
+ added++;
186
+ }
187
+ };
188
+ addItems('PROMISE:', analysis.promises, 2);
189
+ addItems('BLOCKER:', analysis.blockers, 2);
190
+ addItems('BUMP:', analysis.bumps, 1);
191
+ if (analysis.alpha?.length) {
192
+ const alpha = String(analysis.alpha[0] || '').trim();
193
+ if (alpha)
194
+ lines.push(`ALPHA: ${alpha}`);
195
+ }
196
+ if (lines.length === 1)
197
+ lines.push('all clear. nothing urgent right now.');
198
+ return lines.join('\n').slice(0, 1400);
199
+ }
200
+ async function sendViaImwebserver(phone, message) {
201
+ try {
202
+ const resp = await fetch('http://127.0.0.1:8888/sendMessage', {
203
+ method: 'POST',
204
+ headers: { 'Content-Type': 'application/json' },
205
+ body: JSON.stringify({ phone, message }),
206
+ signal: AbortSignal.timeout(5_000),
207
+ });
208
+ return resp.ok;
209
+ }
210
+ catch {
211
+ return false;
212
+ }
213
+ }
214
+ async function sendBriefingText(phone, message) {
215
+ if (await sendViaImwebserver(phone, message))
216
+ return true;
217
+ return sendMessage(phone, message, false);
218
+ }
85
219
  async function showCRM(apiKey, opts) {
86
- const crm = await buildCRM();
220
+ const crm = opts?.crmPromise ? await opts.crmPromise : await buildCRM();
87
221
  if (!crm.threads.length || !apiKey) {
88
222
  opts?.spinner?.stop();
89
223
  return false;
90
224
  }
91
225
  opts?.spinner?.stop();
92
226
  console.log();
227
+ console.log(` ${HD('your inner circle')}`);
228
+ console.log();
93
229
  // Fire insights in background — they'll resolve while user steps through contacts
94
230
  const insightsGen = generateInsights(crm);
95
231
  const calendarContext = opts?.calendarContext ?? '';
96
232
  const hour = new Date().getHours();
97
233
  const timeOfDay = hour < 12 ? 'morning' : hour < 17 ? 'afternoon' : 'evening';
98
234
  let brief = '';
235
+ let resolveBrief = null;
236
+ const briefReady = new Promise(resolve => { resolveBrief = resolve; });
99
237
  const enriched = await revealContacts(streamEnrichedCRM(crm, apiKey, {
100
238
  calendarContext,
101
239
  timeOfDay,
102
- onBrief: (b) => { brief = b; },
240
+ onBrief: (b) => {
241
+ brief = b;
242
+ resolveBrief?.();
243
+ },
244
+ briefAsync: true,
103
245
  }));
246
+ // Optimistic brief: wait a beat, then move on.
247
+ await Promise.race([briefReady, sleep(300)]);
104
248
  if (brief) {
105
249
  renderDivider();
106
250
  await renderBrief(brief);
107
251
  }
108
- // Insights drip in after contacts
109
- await revealInsights(insightsGen);
252
+ // Optimistic insights: show if they land quickly, don't block the flow.
253
+ await revealInsights(insightsGen, { maxWaitMs: 900 });
110
254
  renderDivider();
111
255
  saveContactSummaries(enriched);
112
256
  return true;
113
257
  }
114
258
  async function main() {
259
+ const createdPromptFiles = ensurePromptLayerFiles();
115
260
  const jsonMode = process.argv.includes('--json');
116
- const rawMode = process.argv.includes('--raw');
117
- const legacyMode = process.argv.includes('--legacy');
118
261
  const statusMode = process.argv.includes('--status');
119
262
  const daemonMode = process.argv.includes('--daemon');
120
- const healthMode = process.argv.includes('--health');
121
- const initCheckMode = process.argv.includes('--check');
122
- const phoneSetupMode = process.argv.includes('--phone-setup');
123
- const testSmsMode = process.argv.includes('--test-sms');
124
- const routerMode = process.argv.includes('--router');
125
- const initClientsMode = process.argv.includes('--init-clients');
126
- const key = process.argv.find(a => a.startsWith('--key='))?.split('=')[1] || API_KEY;
263
+ const pairMode = process.argv.includes('--pair');
127
264
  // Install crash handlers early (Anthropic pattern: recover from failures)
128
265
  installCrashHandlers((err) => {
129
266
  console.error(chalk.red(` crash: ${err.message}`));
130
267
  });
131
- // --check: pre-flight verification (Anthropic pattern: verify state before work)
132
- if (initCheckMode) {
133
- const status = runInitCheck();
268
+ // --pair: generate Desktop/nox-route.json for NOX routing
269
+ if (pairMode) {
270
+ const defaultName = getUserName() || '';
271
+ const detectedHost = getTailscaleHostname() || '';
272
+ const detectedToken = detectOpenClawToken();
273
+ const envPhone = normalizePhoneCandidate(process.env.LIFE_PULSE_SELF_PHONE
274
+ || process.env.NOX_OWNER_PHONE
275
+ || process.env.LIFE_PULSE_BRIEF_SMS_PHONE
276
+ || '');
134
277
  console.log();
135
- for (const c of status.checks) {
136
- const icon = c.passed ? chalk.green('✓') : chalk.red('✗');
137
- console.log(` ${icon} ${c.check}`);
138
- }
139
- console.log();
140
- console.log(chalk.dim(` ${status.recommendation}`));
141
- console.log();
142
- return;
143
- }
144
- // --router: run message router on YOUR Mac (routes to client Mac Minis)
145
- if (routerMode) {
146
- console.log(chalk.bold.hex('#c0caf5')(' life-pulse'));
147
- console.log(chalk.dim(' routing messages'));
278
+ console.log(chalk.bold.hex('#c0caf5')(' pair with nox'));
279
+ console.log(chalk.dim(' we will create Desktop/nox-route.json'));
148
280
  console.log();
149
- const router = startRouter();
150
- process.on('SIGINT', () => { router.stop(); process.exit(0); });
151
- process.on('SIGTERM', () => { router.stop(); process.exit(0); });
152
- await new Promise(() => { });
153
- return;
154
- }
155
- // --init-clients: create template clients.json
156
- if (initClientsMode) {
157
- initClientRegistry();
158
- return;
159
- }
160
- // --phone-setup: run phone number setup from installer
161
- if (phoneSetupMode) {
162
- await runInstaller(ANTHROPIC_KEY);
163
- return;
164
- }
165
- // --test-sms: test the gateway by posting a fake inbound message
166
- if (testSmsMode) {
167
- const up = await isGatewayUp();
168
- if (!up) {
169
- console.log(chalk.red(' not running — start life-pulse first'));
281
+ const name = await promptLine('name', defaultName);
282
+ const phoneRaw = await promptLine('iphone number (+1...)', envPhone);
283
+ const phone = normalizePhoneCandidate(phoneRaw);
284
+ if (!phone) {
285
+ console.log(chalk.red(' missing phone number'));
170
286
  return;
171
287
  }
172
- const phone = process.argv[process.argv.indexOf('--test-sms') + 1] || '+1234567890';
173
- try {
174
- const resp = await fetch('http://127.0.0.1:19877/inbound', {
175
- method: 'POST',
176
- headers: { 'Content-Type': 'application/json' },
177
- body: JSON.stringify({ phone, message: 'test message from life-pulse' }),
178
- });
179
- const data = await resp.json();
180
- if (data.response)
181
- console.log(chalk.green(` replied: ${data.response}`));
182
- else
183
- console.log(chalk.red(` failed: ${data.error}`));
184
- }
185
- catch (err) {
186
- console.log(chalk.red(` couldn't connect — is life-pulse running?`));
187
- }
188
- return;
189
- }
190
- // --health: check if daemon is running and get status
191
- if (healthMode) {
192
- if (isDaemonRunning()) {
193
- const status = getHealthStatus();
194
- console.log(JSON.stringify(status, null, 2));
288
+ const host = await promptLine('tailscale host/ip', detectedHost);
289
+ if (!host) {
290
+ console.log(chalk.red(' missing tailscale host/ip'));
291
+ return;
195
292
  }
196
- else {
197
- console.log(JSON.stringify({ status: 'not_running' }));
293
+ const token = await promptLine('openclaw token', detectedToken);
294
+ if (!token) {
295
+ console.log(chalk.red(' missing openclaw token'));
296
+ return;
198
297
  }
298
+ const payload = {
299
+ name: name || 'User',
300
+ phone,
301
+ openclaw_url: `http://${host}:18789`,
302
+ openclaw_token: token,
303
+ openclaw_agent_id: 'main',
304
+ openclaw_model: 'openclaw:main',
305
+ enabled: true,
306
+ };
307
+ const out = writeNoxRouteJson(payload);
308
+ console.log();
309
+ console.log(chalk.green(` saved ${out}`));
310
+ console.log(chalk.dim(' send this file to your operator'));
311
+ console.log();
199
312
  return;
200
313
  }
201
314
  // --daemon: check if already running
@@ -232,11 +345,6 @@ async function main() {
232
345
  console.log();
233
346
  return;
234
347
  }
235
- if (rawMode) {
236
- const collected = await collectAll();
237
- console.log(JSON.stringify(collected, null, 2));
238
- return;
239
- }
240
348
  // ── Start session tracking ──
241
349
  const sessionProgress = startSession();
242
350
  const timeSinceLastSession = getTimeSinceLastSession();
@@ -250,7 +358,8 @@ async function main() {
250
358
  activeContacts,
251
359
  pendingFollowUps,
252
360
  });
253
- console.log(chalk.dim('\n progress saved'));
361
+ console.log(chalk.green('\n demo wrapped cleanly'));
362
+ console.log(chalk.dim(' progress saved'));
254
363
  process.exit(0);
255
364
  };
256
365
  // Auto-detect installer mode: first run with no state → white-glove installer
@@ -258,102 +367,20 @@ async function main() {
258
367
  const installStateExists = existsSync(join(stateDir, 'install-state.json'));
259
368
  const noSessionHistory = sessionProgress.totalSessions <= 1;
260
369
  const isSetupFlag = process.argv.includes('--setup');
261
- if ((noSessionHistory && !installStateExists && !rawMode && !jsonMode && !legacyMode && !daemonMode && !process.argv.includes('--install'))
370
+ if ((noSessionHistory && !installStateExists && !jsonMode && !daemonMode)
262
371
  || isSetupFlag) {
263
372
  // First run: launch full installer
264
373
  await runInstaller(ANTHROPIC_KEY);
265
374
  return;
266
375
  }
267
- if (process.argv.includes('--install')) {
268
- const config = loadConfig();
269
- const [hour, min] = config.briefTime.split(':').map(Number);
270
- const logDir = join(homedir(), 'Library/Logs/life-pulse');
271
- const agentsDir = join(homedir(), 'Library/LaunchAgents');
272
- const envDir = join(homedir(), '.config/life-pulse');
273
- try {
274
- mkdirSync(logDir, { recursive: true });
275
- }
276
- catch { }
277
- try {
278
- mkdirSync(agentsDir, { recursive: true });
279
- }
280
- catch { }
281
- try {
282
- mkdirSync(envDir, { recursive: true });
283
- }
284
- catch { }
285
- // Detect npx path for the installed package
286
- let npxPath;
287
- try {
288
- npxPath = execSync('which npx', { encoding: 'utf-8', timeout: 5000 }).trim();
289
- }
290
- catch {
291
- npxPath = '/opt/homebrew/bin/npx';
292
- }
293
- // Env file hint
294
- const envPath = join(envDir, '.env');
295
- if (!existsSync(envPath)) {
296
- writeFileSync(envPath, '# life-pulse environment\n# ANTHROPIC_API_KEY=sk-ant-...\n# TELEGRAM_BOT_TOKEN=...\n');
297
- console.log(chalk.dim(` Created ${envPath} — add your API key`));
298
- }
299
- // 1. Morning brief plist (scheduled, one-shot)
300
- const morningPlist = `<?xml version="1.0" encoding="UTF-8"?>
301
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
302
- <plist version="1.0">
303
- <dict>
304
- <key>Label</key><string>com.life-pulse.morning</string>
305
- <key>ProgramArguments</key>
306
- <array>
307
- <string>${npxPath}</string><string>life-pulse</string>
308
- </array>
309
- <key>StartCalendarInterval</key>
310
- <dict><key>Hour</key><integer>${hour}</integer><key>Minute</key><integer>${min}</integer></dict>
311
- <key>StandardOutPath</key><string>${join(logDir, 'morning.log')}</string>
312
- <key>StandardErrorPath</key><string>${join(logDir, 'morning-err.log')}</string>
313
- <key>EnvironmentVariables</key>
314
- <dict>
315
- <key>PATH</key><string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
316
- <key>HOME</key><string>${homedir()}</string>
317
- </dict>
318
- </dict>
319
- </plist>`;
320
- // 2. Daemon plist (KeepAlive — auto-restart on crash)
321
- const daemonPlist = `<?xml version="1.0" encoding="UTF-8"?>
322
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
323
- <plist version="1.0">
324
- <dict>
325
- <key>Label</key><string>com.life-pulse.daemon</string>
326
- <key>ProgramArguments</key>
327
- <array>
328
- <string>${npxPath}</string><string>life-pulse</string><string>--daemon</string>
329
- </array>
330
- <key>RunAtLoad</key><true/>
331
- <key>KeepAlive</key>
332
- <dict>
333
- <key>SuccessfulExit</key><false/>
334
- </dict>
335
- <key>ThrottleInterval</key><integer>30</integer>
336
- <key>StandardOutPath</key><string>${join(logDir, 'daemon.log')}</string>
337
- <key>StandardErrorPath</key><string>${join(logDir, 'daemon-err.log')}</string>
338
- <key>EnvironmentVariables</key>
339
- <dict>
340
- <key>PATH</key><string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
341
- <key>HOME</key><string>${homedir()}</string>
342
- </dict>
343
- </dict>
344
- </plist>`;
345
- writeFileSync(join(agentsDir, 'com.life-pulse.morning.plist'), morningPlist);
346
- writeFileSync(join(agentsDir, 'com.life-pulse.daemon.plist'), daemonPlist);
347
- console.log(chalk.dim(' running in the background'));
348
- console.log(chalk.dim(` morning brief at ${config.briefTime}`));
349
- console.log();
350
- return;
351
- }
352
- if (!key && !ANTHROPIC_KEY) {
376
+ if (!ANTHROPIC_KEY) {
353
377
  console.log(chalk.red('\n\n missing API key\n'));
354
378
  process.exit(1);
355
379
  }
356
- const interactive = process.stdin.isTTY && !jsonMode && !legacyMode;
380
+ const interactive = process.stdin.isTTY && !jsonMode;
381
+ if (interactive && createdPromptFiles.length) {
382
+ console.log(chalk.dim(' initialized identity files'));
383
+ }
357
384
  // ── Fire calendar fetch early ──
358
385
  const calendarP = interactive ? fetchCalendarContext() : Promise.resolve('');
359
386
  // ── Instant greeting + context line ──
@@ -366,11 +393,19 @@ async function main() {
366
393
  await renderIntro(userName);
367
394
  }
368
395
  const spinner = interactive ? new Spinner() : undefined;
396
+ let crmWarmupP = null;
397
+ // Optimistic prewarm: kick off heavy profile context in the background.
398
+ if (interactive && hasRequiredPermissions()) {
399
+ crmWarmupP = buildCRM();
400
+ }
401
+ if (interactive && ANTHROPIC_KEY) {
402
+ void buildPersonalSummary();
403
+ }
369
404
  // ── First-run: permissions → discovery → archetype ──
370
405
  let crmShown = false;
371
406
  const setupMode = process.argv.includes('--setup');
372
407
  const isFirstRun = sessionProgress.totalSessions === 1;
373
- if ((needsDiscovery() || setupMode || isFirstRun) && !rawMode && !jsonMode) {
408
+ if ((needsDiscovery() || setupMode || isFirstRun) && !jsonMode) {
374
409
  // Welcome message for first run
375
410
  if (isFirstRun && interactive) {
376
411
  console.log();
@@ -382,6 +417,9 @@ async function main() {
382
417
  }
383
418
  if (process.stdin.isTTY)
384
419
  await runPermissionFlow();
420
+ if (interactive && !crmWarmupP && hasRequiredPermissions()) {
421
+ crmWarmupP = buildCRM();
422
+ }
385
423
  spinner?.start('learning your world');
386
424
  const platformProfile = discoverPlatforms();
387
425
  // iCloud + local app discovery
@@ -415,7 +453,13 @@ async function main() {
415
453
  if (interactive) {
416
454
  spinner?.update('getting to know you');
417
455
  const calCtx = await calendarP;
418
- crmShown = await showCRM(ANTHROPIC_KEY, { calendarContext: calCtx, spinner });
456
+ if (!crmWarmupP)
457
+ crmWarmupP = buildCRM();
458
+ crmShown = await showCRM(ANTHROPIC_KEY, {
459
+ calendarContext: calCtx,
460
+ spinner,
461
+ crmPromise: crmWarmupP,
462
+ });
419
463
  }
420
464
  if (ANTHROPIC_KEY) {
421
465
  spinner?.start('figuring out who you are');
@@ -440,50 +484,27 @@ async function main() {
440
484
  console.log();
441
485
  }
442
486
  }
443
- // Legacy mode: single-shot LLM call (old behavior)
444
- if (legacyMode) {
445
- spinner?.stop();
446
- if (!jsonMode)
447
- process.stdout.write(chalk.dim('\n pulling threads...'));
448
- const collected = await collectAll();
449
- if (!jsonMode)
450
- process.stdout.write(chalk.dim(` ${collected.sources.length} sources... thinking`));
451
- const analysis = await analyzeWithLLM(collected.data, key);
452
- if (jsonMode) {
453
- console.log(JSON.stringify({ collected, analysis }, null, 2));
454
- return;
455
- }
456
- renderAnalysis(analysis, collected.sources.length, collected.generated);
457
- saveState(analysis);
458
- return;
459
- }
460
- // Agent mode (default): multi-turn investigation with Anthropic
487
+ // Agent mode: multi-turn investigation with Anthropic
461
488
  if (!ANTHROPIC_KEY) {
462
489
  spinner?.stop();
463
490
  console.log(chalk.red('\n\n missing API key\n'));
464
491
  process.exit(1);
465
492
  }
466
- // ── CRM: show relationship map before agent runs ──
467
- if (interactive && !crmShown) {
468
- spinner?.start('getting to know you');
469
- const calCtx = await calendarP;
470
- await showCRM(ANTHROPIC_KEY, { calendarContext: calCtx, spinner });
471
- }
472
- const renderer = interactive ? new InkProgress() : undefined;
473
- renderer?.start();
474
493
  // Track streamed cards so we don't double-show from final output
475
494
  const streamedTitles = new Set();
476
495
  let cardCount = 0;
477
- const onCard = interactive ? async (card) => {
496
+ // ── Start agent in background immediately cards generate while CRM shows ──
497
+ const cardBuffer = [];
498
+ let cardsLive = false;
499
+ const renderer = interactive ? new InkProgress() : undefined;
500
+ const processCard = async (card) => {
478
501
  cardCount++;
479
502
  streamedTitles.add(card.title);
480
503
  const pick = await pickCard(card, cardCount);
481
504
  addTodo(card.title, pick, card.urgency || 'today', card.fyi || pick === 'noted');
482
505
  collectedDecisions.push({ title: card.title, picked: pick });
483
- // Record in session progress (Anthropic pattern: track decisions)
484
506
  recordDecision(card.title, pick);
485
507
  recordSurfaced(card.title, card.category || 'bump');
486
- // Track active contacts for follow-ups
487
508
  if (card.contact && !activeContacts.includes(card.contact)) {
488
509
  activeContacts.push(card.contact);
489
510
  }
@@ -491,8 +512,35 @@ async function main() {
491
512
  pendingFollowUps.push(card.title);
492
513
  }
493
514
  return pick;
515
+ };
516
+ const onCard = interactive ? async (card) => {
517
+ if (cardsLive)
518
+ return processCard(card);
519
+ return new Promise((resolve) => {
520
+ cardBuffer.push({ card, resolve });
521
+ });
494
522
  } : undefined;
495
- const analysis = await runAgent(ANTHROPIC_KEY, renderer, onCard);
523
+ const agentPromise = runAgent(ANTHROPIC_KEY, renderer, onCard);
524
+ // ── CRM: show relationship map while agent scans in background ──
525
+ if (interactive && !crmShown) {
526
+ spinner?.start('getting to know you');
527
+ const calCtx = await calendarP;
528
+ if (!crmWarmupP)
529
+ crmWarmupP = buildCRM();
530
+ await showCRM(ANTHROPIC_KEY, {
531
+ calendarContext: calCtx,
532
+ spinner,
533
+ crmPromise: crmWarmupP,
534
+ });
535
+ }
536
+ renderer?.start();
537
+ cardsLive = true;
538
+ // Drain any cards that arrived during CRM display
539
+ for (const { card, resolve } of cardBuffer) {
540
+ resolve(await processCard(card));
541
+ }
542
+ cardBuffer.length = 0;
543
+ const analysis = await agentPromise;
496
544
  renderer?.stop();
497
545
  renderer?.clear();
498
546
  if (jsonMode) {
@@ -523,12 +571,6 @@ async function main() {
523
571
  for (const a of analysis.alpha)
524
572
  console.log(` ★ ${a}`);
525
573
  }
526
- // Fallback: old format
527
- for (const d of (analysis.decisions || [])) {
528
- const rec = d.options?.[0];
529
- const tag = d.fyi ? '[fyi]' : `[${d.urgency}]`;
530
- console.log(d.fyi ? ` ${tag} ${d.title}` : ` ${tag} ${d.title} → ${rec?.label || 'no rec'}`);
531
- }
532
574
  saveState(analysis);
533
575
  return;
534
576
  }
@@ -543,45 +585,42 @@ async function main() {
543
585
  // Handled (compact)
544
586
  if (analysis.handled?.length)
545
587
  renderHandled(analysis.handled, 5);
588
+ const remainingPromises = (analysis.promises || []).filter((d) => !streamedTitles.has(d.title));
589
+ const remainingBlockers = (analysis.blockers || []).filter((d) => !streamedTitles.has(d.title));
590
+ const remainingBumps = (analysis.bumps || []).filter((d) => !streamedTitles.has(d.title));
591
+ const totalCardsForWalk = cardCount
592
+ + remainingPromises.length
593
+ + remainingBlockers.length
594
+ + remainingBumps.length;
546
595
  // Helper: walk cards in a section with arrow-key TUI
547
596
  const walkCards = async (items, category) => {
548
- const remaining = items.filter(d => !streamedTitles.has(d.title));
549
- if (!remaining.length)
597
+ if (!items.length)
550
598
  return;
551
- for (let i = 0; i < remaining.length; i++) {
552
- const d = remaining[i];
599
+ for (let i = 0; i < items.length; i++) {
600
+ const d = items[i];
553
601
  const card = { ...d, category };
554
602
  cardCount++;
555
- const pick = await pickCard(card, cardCount);
603
+ const pick = await pickCard(card, cardCount, totalCardsForWalk);
556
604
  addTodo(d.title, pick, d.urgency || 'today', false);
557
605
  collectedDecisions.push({ title: d.title, picked: pick });
558
606
  }
559
607
  };
560
608
  // Walk the four sections
561
- if (analysis.promises?.length) {
609
+ if (remainingPromises.length) {
562
610
  renderSectionHeader('PROMISES', RED);
563
- await walkCards(analysis.promises, 'promise');
611
+ await walkCards(remainingPromises, 'promise');
564
612
  }
565
- if (analysis.blockers?.length) {
613
+ if (remainingBlockers.length) {
566
614
  renderSectionHeader('BLOCKED', AMB);
567
- await walkCards(analysis.blockers, 'blocker');
615
+ await walkCards(remainingBlockers, 'blocker');
568
616
  }
569
- if (analysis.bumps?.length) {
617
+ if (remainingBumps.length) {
570
618
  renderSectionHeader('BUMPS', CYN);
571
- await walkCards(analysis.bumps, 'bump');
619
+ await walkCards(remainingBumps, 'bump');
572
620
  }
573
621
  if (analysis.alpha?.length) {
574
622
  renderSection('ALPHA', analysis.alpha, '★', MAG.bold, MAG);
575
623
  }
576
- // Fallback: old decisions format (for legacy/transition)
577
- const oldRemaining = (analysis.decisions || []).filter(d => !streamedTitles.has(d.title));
578
- for (let i = 0; i < oldRemaining.length; i++) {
579
- const d = oldRemaining[i];
580
- cardCount++;
581
- const pick = await pickCard(d, cardCount);
582
- addTodo(d.title, pick, d.urgency || 'today', d.fyi || pick === 'noted');
583
- collectedDecisions.push({ title: d.title, picked: pick });
584
- }
585
624
  // Persist handled for delta context
586
625
  for (const h of (analysis.handled || [])) {
587
626
  addTodo(h, 'handled', 'today', true);
@@ -590,7 +629,6 @@ async function main() {
590
629
  ...(analysis.promises || []),
591
630
  ...(analysis.blockers || []),
592
631
  ...(analysis.bumps || []),
593
- ...(analysis.decisions || []),
594
632
  ].some((d) => d.options?.length);
595
633
  if (!hasActionable && !(analysis.alpha?.length)) {
596
634
  console.log(DIM(' All clear — nothing needs you right now.'));
@@ -601,6 +639,17 @@ async function main() {
601
639
  saveState(analysis);
602
640
  // ── Stay resident: message loop + transport layer ──
603
641
  const config = loadConfig();
642
+ // End-of-brief text so the plan follows you out of terminal.
643
+ if (config.briefSmsEnabled !== false) {
644
+ const targetPhone = resolveBriefSmsTarget(config);
645
+ if (targetPhone) {
646
+ const text = buildBriefingText(getUserName(), analysis);
647
+ const sent = await sendBriefingText(targetPhone, text);
648
+ if (!daemonMode && sent) {
649
+ console.log(chalk.dim(` texted your brief to ${targetPhone}`));
650
+ }
651
+ }
652
+ }
604
653
  // Checkpoint state before going resident (Anthropic pattern: save before risky ops)
605
654
  checkpoint('pre-resident');
606
655
  recordSuccess(); // We made it through the briefing — reset crash counter
@@ -658,15 +707,30 @@ async function main() {
658
707
  recordCrash(err.message, 'message-loop');
659
708
  },
660
709
  });
661
- if (!daemonMode) {
662
- console.log(chalk.dim(' listening'));
663
- console.log();
664
- }
665
- // Start health server for daemon mode
710
+ // Start NOX gateway in resident mode (daemon + interactive)
666
711
  let gw = null;
712
+ if (ANTHROPIC_KEY) {
713
+ gw = startGateway(ANTHROPIC_KEY);
714
+ }
715
+ let noxEndpoint = null;
716
+ if (hasTailscale()) {
717
+ noxEndpoint = startFunnel(19877);
718
+ if (!noxEndpoint) {
719
+ const host = getTailscaleHostname();
720
+ if (host)
721
+ noxEndpoint = `https://${host}`;
722
+ }
723
+ }
667
724
  if (daemonMode) {
668
725
  startHealthServer();
669
- gw = startGateway(ANTHROPIC_KEY);
726
+ }
727
+ if (!daemonMode) {
728
+ console.log(chalk.dim(' listening'));
729
+ console.log(chalk.dim(' nox number is live'));
730
+ if (noxEndpoint)
731
+ console.log(chalk.dim(` endpoint ${noxEndpoint}`));
732
+ console.log(chalk.green(' press ctrl+c to end demo'));
733
+ console.log();
670
734
  }
671
735
  // Generate knowledge bases from CRM on startup
672
736
  try {
@@ -701,79 +765,6 @@ async function main() {
701
765
  }
702
766
  await new Promise(() => { });
703
767
  }
704
- function renderAnalysis(analysis, sourceCount, generated) {
705
- console.log();
706
- console.log(chalk.bold(' LIFE PULSE'));
707
- console.log();
708
- console.log(` ${analysis.greeting}`);
709
- console.log();
710
- if (analysis.decisions?.length) {
711
- console.log(chalk.bold.red(' DECISIONS'));
712
- console.log();
713
- for (const d of analysis.decisions) {
714
- const tag = d.fyi ? chalk.dim('[fyi]') : `[${d.urgency}]`;
715
- console.log(` ${d.title} ${tag}`);
716
- if (d.options?.length) {
717
- for (const o of d.options)
718
- console.log(` · ${o.label} ${chalk.dim('— ' + (o.description || ''))}`);
719
- }
720
- console.log();
721
- }
722
- }
723
- if (analysis.upcoming?.length) {
724
- console.log(chalk.bold.cyan(' UPCOMING'));
725
- console.log();
726
- renderList(analysis.upcoming, chalk.cyan('▸'));
727
- console.log();
728
- }
729
- if (analysis.handled?.length) {
730
- console.log(chalk.bold.green(' HANDLED'));
731
- console.log();
732
- renderList(analysis.handled, chalk.green('✓'));
733
- console.log();
734
- }
735
- if (analysis.intel?.length) {
736
- console.log(chalk.bold.magenta(' INTEL'));
737
- console.log();
738
- renderList(analysis.intel, chalk.magenta('~'));
739
- console.log();
740
- }
741
- // Legacy format fallback
742
- if (analysis.right_now?.length) {
743
- console.log(chalk.bold.red(' RIGHT NOW'));
744
- console.log();
745
- renderList(analysis.right_now, chalk.red('→'));
746
- console.log();
747
- }
748
- if (analysis.today?.length) {
749
- console.log(chalk.bold.white(' TODAY'));
750
- console.log();
751
- renderList(analysis.today, chalk.dim('·'));
752
- console.log();
753
- }
754
- if (analysis.this_week?.length) {
755
- console.log(chalk.bold(' THIS WEEK'));
756
- console.log();
757
- renderList(analysis.this_week, chalk.dim('·'));
758
- console.log();
759
- }
760
- if (analysis.heads_up?.length) {
761
- console.log(chalk.bold.cyan(' HEADS UP'));
762
- console.log();
763
- renderList(analysis.heads_up, chalk.cyan('!'));
764
- console.log();
765
- }
766
- if (analysis.noticed?.length) {
767
- console.log(chalk.bold.magenta(' NOTICED'));
768
- console.log();
769
- renderList(analysis.noticed, chalk.magenta('~'));
770
- console.log();
771
- }
772
- if (sourceCount) {
773
- console.log(chalk.dim(` ${sourceCount} sources · ${generated}`));
774
- console.log();
775
- }
776
- }
777
768
  main().catch(e => {
778
769
  console.error(chalk.red('\n Error:'), e.message);
779
770
  process.exit(1);