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.
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Local identity + memory injection (OpenClaw-style).
3
+ *
4
+ * Priority order:
5
+ * 1) Explicit env path
6
+ * 2) Current working directory
7
+ * 3) ~/.life-pulse
8
+ * 4) ~/.config/life-pulse
9
+ */
10
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
11
+ import { homedir } from 'os';
12
+ import { join } from 'path';
13
+ const MAX_SOUL_CHARS = 12_000;
14
+ const MAX_MEMORY_CHARS = 18_000;
15
+ const DEFAULT_SOUL = `# NOX Identity
16
+
17
+ - You are NOX, the user's always-on operator.
18
+ - Voice: direct, warm, sharp, no corporate language.
19
+ - Prefer concrete next actions over abstract advice.
20
+ - Never invent facts. If signal is weak, say less.
21
+ - Keep output compact unless explicitly asked for depth.
22
+ - In text replies, sound human and conversational.
23
+ - Prioritize reputation, promises, and momentum.
24
+ `;
25
+ const DEFAULT_MEMORY = `# Persistent Memory
26
+
27
+ ## Operator
28
+ - Name: <auto-detected at runtime>
29
+
30
+ ## Preferences
31
+ - Prefer terse, high-signal summaries.
32
+ - Focus on what needs action now.
33
+
34
+ ## Ongoing Context
35
+ - Keep this file updated with durable context that should persist across turns.
36
+ `;
37
+ function firstExisting(paths) {
38
+ for (const p of paths) {
39
+ try {
40
+ if (!p)
41
+ continue;
42
+ if (!existsSync(p))
43
+ continue;
44
+ return readFileSync(p, 'utf-8').trim();
45
+ }
46
+ catch { }
47
+ }
48
+ return null;
49
+ }
50
+ function trimTo(input, maxChars) {
51
+ if (input.length <= maxChars)
52
+ return input;
53
+ return input.slice(0, maxChars).trim();
54
+ }
55
+ export function loadPromptLayers() {
56
+ const home = homedir();
57
+ const cwd = process.cwd();
58
+ const soul = firstExisting([
59
+ process.env.LIFE_PULSE_SOUL_PATH || '',
60
+ join(cwd, 'SOUL.md'),
61
+ join(home, '.life-pulse', 'SOUL.md'),
62
+ join(home, '.config', 'life-pulse', 'SOUL.md'),
63
+ ]) || '';
64
+ const memory = firstExisting([
65
+ process.env.LIFE_PULSE_MEMORY_PATH || '',
66
+ join(cwd, 'MEMORY.md'),
67
+ join(home, '.life-pulse', 'MEMORY.md'),
68
+ join(home, '.config', 'life-pulse', 'MEMORY.md'),
69
+ ]) || '';
70
+ return {
71
+ soul: trimTo(soul, MAX_SOUL_CHARS),
72
+ memory: trimTo(memory, MAX_MEMORY_CHARS),
73
+ };
74
+ }
75
+ export function buildPromptLayersSection() {
76
+ const { soul, memory } = loadPromptLayers();
77
+ if (!soul && !memory)
78
+ return '';
79
+ const parts = [
80
+ '═══ IDENTITY + MEMORY ═══',
81
+ 'Treat this as persistent ground truth unless the current conversation explicitly updates it.',
82
+ ];
83
+ if (soul) {
84
+ parts.push('\nSOUL.md');
85
+ parts.push(soul);
86
+ }
87
+ if (memory) {
88
+ parts.push('\nMEMORY.md');
89
+ parts.push(memory);
90
+ }
91
+ return `\n${parts.join('\n')}`;
92
+ }
93
+ /** First-run bootstrap: create default SOUL.md + MEMORY.md if missing. */
94
+ export function ensurePromptLayerFiles() {
95
+ const home = homedir();
96
+ const base = join(home, '.life-pulse');
97
+ const soulPath = join(base, 'SOUL.md');
98
+ const memoryPath = join(base, 'MEMORY.md');
99
+ const created = [];
100
+ try {
101
+ mkdirSync(base, { recursive: true });
102
+ if (!existsSync(soulPath)) {
103
+ writeFileSync(soulPath, DEFAULT_SOUL, 'utf-8');
104
+ created.push(soulPath);
105
+ }
106
+ if (!existsSync(memoryPath)) {
107
+ writeFileSync(memoryPath, DEFAULT_MEMORY, 'utf-8');
108
+ created.push(memoryPath);
109
+ }
110
+ }
111
+ catch { }
112
+ return created;
113
+ }
package/dist/router.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Message Router — runs on YOUR Mac, routes to client Mac Minis.
2
+ * Message Router — legacy bridge mode.
3
3
  *
4
- * This is the bridge between rply-mac-server and life-pulse instances.
4
+ * This is only needed for multi-machine relay setups.
5
+ * Default deployment is direct NOX -> user's Mac gateway (:19877), no router.
5
6
  *
6
7
  * Flow:
7
8
  * Client texts NOX number
package/dist/router.js CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Message Router — runs on YOUR Mac, routes to client Mac Minis.
2
+ * Message Router — legacy bridge mode.
3
3
  *
4
- * This is the bridge between rply-mac-server and life-pulse instances.
4
+ * This is only needed for multi-machine relay setups.
5
+ * Default deployment is direct NOX -> user's Mac gateway (:19877), no router.
5
6
  *
6
7
  * Flow:
7
8
  * Client texts NOX number
@@ -19,7 +19,7 @@ export interface ScanRecord {
19
19
  export interface SurfacedItem {
20
20
  id: string;
21
21
  title: string;
22
- category: 'promise' | 'blocker' | 'bump' | 'handled' | 'intel';
22
+ category: 'promise' | 'blocker' | 'bump' | 'handled';
23
23
  surfacedAt: string;
24
24
  resolvedAt?: string;
25
25
  }
@@ -54,7 +54,7 @@ export interface SessionProgress {
54
54
  mood?: string;
55
55
  };
56
56
  }
57
- /** Load session progress, migrating from v1 if needed */
57
+ /** Load session progress */
58
58
  export declare function loadProgress(): SessionProgress;
59
59
  /** Save session progress */
60
60
  export declare function saveProgress(progress: SessionProgress): void;
@@ -33,21 +33,12 @@ function defaultProgress() {
33
33
  lastContext: {},
34
34
  };
35
35
  }
36
- /** Load session progress, migrating from v1 if needed */
36
+ /** Load session progress */
37
37
  export function loadProgress() {
38
38
  try {
39
39
  if (!existsSync(PROGRESS_FILE))
40
40
  return defaultProgress();
41
- const data = JSON.parse(readFileSync(PROGRESS_FILE, 'utf-8'));
42
- // Migration from v1 (or no version)
43
- if (!data.version || data.version < 2) {
44
- const migrated = defaultProgress();
45
- migrated.firstRun = data.firstRun || migrated.firstRun;
46
- migrated.lastSessionStart = data.lastSessionStart || migrated.lastSessionStart;
47
- migrated.totalSessions = data.totalSessions || 0;
48
- return migrated;
49
- }
50
- return data;
41
+ return JSON.parse(readFileSync(PROGRESS_FILE, 'utf-8'));
51
42
  }
52
43
  catch {
53
44
  return defaultProgress();
@@ -7,7 +7,7 @@
7
7
  * 2. Loadable (npm packages installed on demand)
8
8
  * 3. Local (scripts/tools found on the machine)
9
9
  *
10
- * The idea: `npx life-pulse` on a fresh Mac Mini scans iCloud,
10
+ * The idea: `npx life-pulse` on a fresh user Mac scans iCloud,
11
11
  * sees you use Obsidian + Things + Slack, and activates those skills.
12
12
  */
13
13
  export interface Skill {
@@ -7,7 +7,7 @@
7
7
  * 2. Loadable (npm packages installed on demand)
8
8
  * 3. Local (scripts/tools found on the machine)
9
9
  *
10
- * The idea: `npx life-pulse` on a fresh Mac Mini scans iCloud,
10
+ * The idea: `npx life-pulse` on a fresh user Mac scans iCloud,
11
11
  * sees you use Obsidian + Things + Slack, and activates those skills.
12
12
  */
13
13
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
@@ -1,19 +1,14 @@
1
1
  /**
2
- * SMS Gateway — inbound HTTP server on the CLIENT's Mac Mini.
2
+ * SMS Gateway — inbound HTTP server on the user's Mac.
3
3
  *
4
- * Architecture:
5
- * Client texts NOX phone number
6
- * → rply-mac-server (YOUR Mac, imdaemon/imagentbridge)
7
- * → Tailscale route to CLIENT's Mac Mini
8
- * → POST http://<tailscale-ip>:19877/inbound {phone, message}
4
+ * Architecture (default deployment):
5
+ * User texts NOX number
6
+ * → NOX backend POSTs to this Mac over Tailscale
7
+ * → POST http://<tailscale-endpoint>:19877/inbound {phone, message}
9
8
  * → life-pulse conversation agent (local data: iMessage, calendar, etc.)
10
9
  * → response JSON {response: "..."}
11
- * → rply-mac-server sends iMessage reply from NOX number
12
10
  *
13
- * This file runs on the CLIENT's Mac Mini. It does NOT send messages —
14
- * rply-mac-server does that. This just processes inbound and returns responses.
15
- *
16
- * Port 19877 exposed via Tailscale (same tailnet as rply-mac-server).
11
+ * Port 19877 is exposed through Tailscale/Funnel.
17
12
  */
18
13
  declare const stats: {
19
14
  received: number;
@@ -1,19 +1,14 @@
1
1
  /**
2
- * SMS Gateway — inbound HTTP server on the CLIENT's Mac Mini.
2
+ * SMS Gateway — inbound HTTP server on the user's Mac.
3
3
  *
4
- * Architecture:
5
- * Client texts NOX phone number
6
- * → rply-mac-server (YOUR Mac, imdaemon/imagentbridge)
7
- * → Tailscale route to CLIENT's Mac Mini
8
- * → POST http://<tailscale-ip>:19877/inbound {phone, message}
4
+ * Architecture (default deployment):
5
+ * User texts NOX number
6
+ * → NOX backend POSTs to this Mac over Tailscale
7
+ * → POST http://<tailscale-endpoint>:19877/inbound {phone, message}
9
8
  * → life-pulse conversation agent (local data: iMessage, calendar, etc.)
10
9
  * → response JSON {response: "..."}
11
- * → rply-mac-server sends iMessage reply from NOX number
12
10
  *
13
- * This file runs on the CLIENT's Mac Mini. It does NOT send messages —
14
- * rply-mac-server does that. This just processes inbound and returns responses.
15
- *
16
- * Port 19877 exposed via Tailscale (same tailnet as rply-mac-server).
11
+ * Port 19877 is exposed through Tailscale/Funnel.
17
12
  */
18
13
  import { createServer } from 'http';
19
14
  import { resolveName } from './contacts.js';
@@ -124,6 +119,14 @@ export function startGateway(apiKey) {
124
119
  server.listen(GATEWAY_PORT, '0.0.0.0', () => {
125
120
  // silent startup — no infrastructure noise
126
121
  });
122
+ server.on('error', (err) => {
123
+ if (err.code === 'EADDRINUSE') {
124
+ // already running in another process — don't crash this one
125
+ server = null;
126
+ return;
127
+ }
128
+ server = null;
129
+ });
127
130
  return {
128
131
  port: GATEWAY_PORT,
129
132
  stop: () => { server?.close(); server = null; },
package/dist/state.d.ts CHANGED
@@ -33,4 +33,4 @@ export declare function buildDeltaContext(): string;
33
33
  /** Check if an item should be surfaced (not recently shown) */
34
34
  export declare function shouldSurface(title: string): boolean;
35
35
  /** Record that an item was surfaced */
36
- export declare function markSurfaced(title: string, category: 'promise' | 'blocker' | 'bump' | 'handled' | 'intel'): void;
36
+ export declare function markSurfaced(title: string, category: 'promise' | 'blocker' | 'bump' | 'handled'): void;
package/dist/state.js CHANGED
@@ -38,23 +38,19 @@ export function saveState(analysis) {
38
38
  const now = dayjs().format('YYYY-MM-DD HH:mm:ss');
39
39
  // Extract key observations for history (keep last 50)
40
40
  const newEntries = [];
41
- const decisions = analysis.decisions;
42
- if (decisions) {
43
- for (const d of decisions) {
41
+ const promises = analysis.promises;
42
+ if (promises) {
43
+ for (const d of promises) {
44
44
  if (d.title)
45
- newEntries.push({ date: now, key: 'decision', value: d.title });
45
+ newEntries.push({ date: now, key: 'promise', value: d.title });
46
46
  }
47
47
  }
48
- const intel = analysis.intel;
49
- if (intel) {
50
- for (const v of intel)
51
- newEntries.push({ date: now, key: 'intel', value: v });
52
- }
53
- // Legacy format support
54
- const right_now = analysis.right_now;
55
- if (right_now) {
56
- for (const v of right_now)
57
- newEntries.push({ date: now, key: 'right_now', value: v });
48
+ const blockers = analysis.blockers;
49
+ if (blockers) {
50
+ for (const d of blockers) {
51
+ if (d.title)
52
+ newEntries.push({ date: now, key: 'blocker', value: d.title });
53
+ }
58
54
  }
59
55
  const history = [...prev.history, ...newEntries].slice(-50);
60
56
  const state = { lastRun: now, lastAnalysis: analysis, history, userDecisions: prev.userDecisions || [] };
@@ -111,9 +107,9 @@ export function buildDeltaContext() {
111
107
  if (state.lastRun) {
112
108
  lines.push('LAST SESSION PRESENTED:');
113
109
  const a = state.lastAnalysis;
114
- const decisions = a.decisions;
115
- if (decisions?.length) {
116
- lines.push(' Decisions: ' + decisions.map(d => d.title || '').filter(Boolean).slice(0, 5).join(' | '));
110
+ const promises = a.promises;
111
+ if (promises?.length) {
112
+ lines.push(' Promises: ' + promises.map(d => d.title || '').filter(Boolean).slice(0, 5).join(' | '));
117
113
  }
118
114
  const userDecs = state.userDecisions || [];
119
115
  if (userDecs.length) {
package/dist/tools.js CHANGED
@@ -13,9 +13,7 @@ import * as rply from './rply-client.js';
13
13
  import dayjs from 'dayjs';
14
14
  import { APPLE_EPOCH, CHROME_EPOCH } from './types.js';
15
15
  const home = homedir();
16
- const CAL_DB_NEW = join(home, 'Library/Group Containers/group.com.apple.calendar/Calendar.sqlitedb');
17
- const CAL_DB_LEGACY = join(home, 'Library/Calendars/Calendar.sqlitedb');
18
- const CAL_DB = existsSync(CAL_DB_NEW) ? CAL_DB_NEW : CAL_DB_LEGACY;
16
+ const CAL_DB = join(home, 'Library/Group Containers/group.com.apple.calendar/Calendar.sqlitedb');
19
17
  // ─── Helpers ─────────────────────────────────────────────────────
20
18
  function daysAgoApple(days) {
21
19
  return dayjs().subtract(days, 'day').unix() - APPLE_EPOCH;
@@ -5,7 +5,7 @@
5
5
  * Supports multiple transports simultaneously:
6
6
  * 1. iMessage (local) — reads Mac Messages DB, sends via AppleScript
7
7
  * 2. Telegram bot — receives via long-poll, sends via Bot API
8
- * 3. RPLY agentroutes through NOX phone number (future)
8
+ * 3. RPLY/NOX webhookoptional external ingress path
9
9
  *
10
10
  * Each transport implements the same interface so the message loop
11
11
  * doesn't care where messages come from.
package/dist/transport.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * Supports multiple transports simultaneously:
6
6
  * 1. iMessage (local) — reads Mac Messages DB, sends via AppleScript
7
7
  * 2. Telegram bot — receives via long-poll, sends via Bot API
8
- * 3. RPLY agentroutes through NOX phone number (future)
8
+ * 3. RPLY/NOX webhookoptional external ingress path
9
9
  *
10
10
  * Each transport implements the same interface so the message loop
11
11
  * doesn't care where messages come from.
package/dist/tui.d.ts CHANGED
@@ -15,7 +15,6 @@ 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
17
  export declare function renderIntro(name?: string): Promise<void>;
18
- export declare function renderGreeting(name?: string): void;
19
18
  export declare function renderContextLine(): void;
20
19
  export declare function renderCRMList(contacts: {
21
20
  name: string;
@@ -36,7 +35,7 @@ export interface Card {
36
35
  category?: string;
37
36
  who?: string;
38
37
  }
39
- export declare function pickCard(card: Card, num: number, _total?: number): Promise<string>;
38
+ export declare function pickCard(card: Card, num: number, total?: number): Promise<string>;
40
39
  export declare function renderSectionHeader(label: string, color: (s: string) => string): void;
41
40
  export declare function renderSection(title: string, items: string[], bullet: string, colorBold: (s: string) => string, colorNorm: (s: string) => string): void;
42
41
  export declare function renderHandled(items: string[], max?: number): void;
@@ -68,6 +67,8 @@ interface InsightItem {
68
67
  label: string;
69
68
  text: string;
70
69
  }
71
- export declare function revealInsights(source: AsyncIterable<InsightItem>): Promise<void>;
70
+ export declare function revealInsights(source: AsyncIterable<InsightItem>, opts?: {
71
+ maxWaitMs?: number;
72
+ }): Promise<void>;
72
73
  export declare function destroyUI(): void;
73
74
  export {};
package/dist/tui.js CHANGED
@@ -6,6 +6,10 @@
6
6
  */
7
7
  import * as ink from './ui/app.js';
8
8
  import { C, W } from './ui/theme.js';
9
+ import fs from 'fs';
10
+ import path from 'path';
11
+ import { fileURLToPath } from 'url';
12
+ import { createRequire } from 'module';
9
13
  // ─── Palette (backward-compat exports) ───
10
14
  export const B = C.faint;
11
15
  export const DIM = C.dim;
@@ -26,6 +30,13 @@ function out(text) {
26
30
  else
27
31
  console.log(text);
28
32
  }
33
+ // Pinned output stays visible at the top in Ink mode.
34
+ function outPinned(text) {
35
+ if (USE_INK)
36
+ ink.pinLine(text);
37
+ else
38
+ console.log(text);
39
+ }
29
40
  // ─── Intro ───
30
41
  import chalk from 'chalk';
31
42
  const _sleep = (ms) => new Promise(r => setTimeout(r, ms));
@@ -94,42 +105,34 @@ function weatherRemark(weather) {
94
105
  return `${weather}. Stay inside.`;
95
106
  return `${weather}.`;
96
107
  }
97
- // ─── Geometric animation ───
98
- function renderScanField(cols, rows) {
99
- const cy = Math.floor(rows / 2);
100
- const shades = [
101
- chalk.hex('#1a1b26'),
102
- chalk.hex('#1a1b26'),
103
- chalk.hex('#292e42'),
104
- chalk.hex('#292e42'),
105
- chalk.hex('#3b4261'),
106
- ];
107
- const lines = [];
108
- for (let y = 0; y < rows; y++) {
109
- const d = Math.abs(y - cy) / Math.max(cy, 1);
110
- const brightness = 1 - d;
111
- const segW = Math.max(0, Math.floor(cols * brightness * 0.85));
112
- const pad = Math.floor((cols - segW) / 2);
113
- if (segW < 4) {
114
- lines.push(' '.repeat(cols));
115
- continue;
116
- }
117
- const si = Math.min(Math.floor(brightness * shades.length), shades.length - 1);
118
- const shade = shades[si];
119
- // Thin lines with periodic breaks — creates a lens/field shape
120
- let seg = '';
121
- for (let x = 0; x < segW; x++) {
122
- if (x === 0 || x === segW - 1)
123
- seg += shade('·');
124
- else if (x % 12 === 0)
125
- seg += shade('·');
126
- else
127
- seg += shade('─');
128
- }
129
- lines.push(' '.repeat(pad) + seg + ' '.repeat(Math.max(0, cols - pad - segW)));
108
+ // ─── Ghostty animation ───
109
+ const GHOST_COLOR = '\x1b[38;5;75m'; // deep electric blue
110
+ const GHOST_RESET = '\x1b[0m';
111
+ function loadGhosttyFrames() {
112
+ try {
113
+ const dir = path.dirname(fileURLToPath(import.meta.url));
114
+ const raw = fs.readFileSync(path.join(dir, 'ghostty-frames.json'), 'utf8');
115
+ return JSON.parse(raw);
116
+ }
117
+ catch {
118
+ return [];
130
119
  }
131
- return lines;
132
120
  }
121
+ function renderGhosttyLine(line) {
122
+ return line.replaceAll('<c>', GHOST_COLOR).replaceAll('</c>', GHOST_RESET);
123
+ }
124
+ // Big figlet banner via CJS require (no types shipped)
125
+ const _require = createRequire(import.meta.url);
126
+ function figletBanner(text, font = 'Doom') {
127
+ try {
128
+ const figlet = _require('figlet');
129
+ return figlet.textSync(text, { font }).split('\n');
130
+ }
131
+ catch {
132
+ return [text];
133
+ }
134
+ }
135
+ const _ghostFrames = loadGhosttyFrames();
133
136
  export async function renderIntro(name) {
134
137
  const firstName = name?.split(' ')[0] || name;
135
138
  // Fire IP-based location + weather fetch while animation runs
@@ -140,17 +143,48 @@ export async function renderIntro(name) {
140
143
  process.stdout.write('\n'.repeat(rows * 2));
141
144
  process.stdout.write('\x1B[2J\x1B[H');
142
145
  process.stdout.write('\x1B[?25l');
143
- // === Phase 1: Geometric scan field ===
144
- const field = renderScanField(cols, rows);
145
- for (let y = 0; y < field.length; y++) {
146
- process.stdout.write(`\x1B[${y + 1};1H${field[y]}`);
147
- await _sleep(12);
146
+ // === Phase 1: Ghostty animation with big welcome banner ===
147
+ if (_ghostFrames.length > 0) {
148
+ const frameCount = Math.min(_ghostFrames.length, 150); // ~7.5s at 50ms/frame
149
+ const lineCount = _ghostFrames[0].length; // 41
150
+ const imageWidth = 77;
151
+ const padTop = Math.max(0, Math.floor((rows - lineCount) / 2));
152
+ const padLeft = Math.max(0, Math.floor((cols - imageWidth) / 2));
153
+ const leftPad = ' '.repeat(padLeft);
154
+ // Generate figlet welcome banner
155
+ const displayName = name || 'friend';
156
+ const bannerLines = figletBanner(`welcome\n${displayName}`);
157
+ const bannerWidth = Math.max(...bannerLines.map(l => l.length));
158
+ const bannerStartRow = padTop + lineCount + 2;
159
+ const bannerStyle = chalk.hex('#d4a574').bold;
160
+ const renderFrame = (frame, showBanner) => {
161
+ for (let y = 0; y < frame.length; y++) {
162
+ process.stdout.write(`\x1b[${padTop + y + 1};1H${leftPad}${renderGhosttyLine(frame[y])}`);
163
+ }
164
+ if (showBanner) {
165
+ for (let b = 0; b < bannerLines.length; b++) {
166
+ const row = bannerStartRow + b;
167
+ if (row > rows)
168
+ break;
169
+ const bCol = Math.max(1, Math.floor((cols - bannerWidth) / 2) + 1);
170
+ process.stdout.write(`\x1b[${row};${bCol}H${bannerStyle(bannerLines[b])}`);
171
+ }
172
+ }
173
+ };
174
+ // First 40%: animation only. Then overlay the figlet banner.
175
+ const welcomeStart = Math.floor(frameCount * 0.4);
176
+ renderFrame(_ghostFrames[0], false);
177
+ await _sleep(50);
178
+ for (let i = 1; i < frameCount; i++) {
179
+ renderFrame(_ghostFrames[i], i >= welcomeStart);
180
+ await _sleep(50);
181
+ }
148
182
  }
149
- await _sleep(350);
183
+ await _sleep(300);
150
184
  // === Phase 2: Greeting ===
151
185
  process.stdout.write('\x1B[2J\x1B[H');
152
186
  process.stdout.write('\n');
153
- // "Hey Molly."
187
+ // "Hey <FirstName>."
154
188
  const hey = firstName ? `Hey ${firstName}.` : 'Hey.';
155
189
  process.stdout.write(' ');
156
190
  await typewrite(hey, G1, 35);
@@ -183,19 +217,6 @@ export async function renderIntro(name) {
183
217
  if (USE_INK)
184
218
  ink.initInk(greetLines);
185
219
  }
186
- // ─── Greeting (legacy / non-interactive fallback) ───
187
- export function renderGreeting(name) {
188
- if (USE_INK) {
189
- ink.initInk();
190
- ink.log('');
191
- ink.log(` ${C.hd(name ? `Hey ${name}` : 'Hey')}`);
192
- }
193
- else {
194
- process.stdout.write('\x1B[2J\x1B[H');
195
- console.log();
196
- console.log(` ${HD(name ? `Hey ${name}` : 'Hey')}`);
197
- }
198
- }
199
220
  // ─── Context line ───
200
221
  export function renderContextLine() {
201
222
  const d = new Date();
@@ -208,14 +229,15 @@ export function renderContextLine() {
208
229
  }
209
230
  // ─── CRM List ───
210
231
  export function renderCRMList(contacts) {
232
+ const emit = USE_INK ? outPinned : out;
211
233
  for (const t of contacts.slice(0, 12)) {
212
- out(` ${C.hd(t.name)}`);
234
+ emit(` ${C.hd(t.name)}`);
213
235
  }
214
- out('');
236
+ emit('');
215
237
  }
216
- export async function pickCard(card, num, _total) {
238
+ export async function pickCard(card, num, total) {
217
239
  if (USE_INK) {
218
- return ink.pickCard(card, num);
240
+ return ink.pickCard(card, num, total);
219
241
  }
220
242
  // Non-interactive: auto-pick first option
221
243
  if (card.fyi || !card.options?.length) {
@@ -302,8 +324,13 @@ export async function revealContacts(source) {
302
324
  buffer.push(t);
303
325
  done = true;
304
326
  })();
305
- if (USE_INK)
327
+ if (USE_INK) {
328
+ outPinned('');
329
+ outPinned(` ${C.faint('─'.repeat(W() - 4))}`);
330
+ outPinned(` ${C.dim('relationship map')}`);
331
+ outPinned('');
306
332
  ink.showSpinner('reading the room');
333
+ }
307
334
  while (!done || buffer.length > 0) {
308
335
  if (buffer.length === 0) {
309
336
  await _sleep(80);
@@ -317,8 +344,9 @@ export async function revealContacts(source) {
317
344
  const name = t.name.slice(0, 24);
318
345
  const ago = shortAgo(t.lastMsg?.ago || '');
319
346
  const gap = Math.max(2, w - 4 - name.length - ago.length);
320
- out('');
321
- out(` ${C.hd(name)}${' '.repeat(gap)}${C.dim(ago)}`);
347
+ const emit = USE_INK ? outPinned : out;
348
+ emit('');
349
+ emit(` ${C.hd(name)}${' '.repeat(gap)}${C.dim(ago)}`);
322
350
  if (t.relationship) {
323
351
  // Word-wrap relationship across lines
324
352
  const maxW = w - 8;
@@ -326,7 +354,7 @@ export async function revealContacts(source) {
326
354
  let line = '';
327
355
  for (const word of words) {
328
356
  if (line.length + word.length + 1 > maxW && line) {
329
- out(` ${C.mid(line)}`);
357
+ emit(` ${C.mid(line)}`);
330
358
  line = word;
331
359
  }
332
360
  else {
@@ -334,7 +362,7 @@ export async function revealContacts(source) {
334
362
  }
335
363
  }
336
364
  if (line)
337
- out(` ${C.mid(line)}`);
365
+ emit(` ${C.mid(line)}`);
338
366
  }
339
367
  // Wait for Enter before showing next (interactive only)
340
368
  if (USE_INK && (!done || buffer.length > 0)) {
@@ -344,12 +372,44 @@ export async function revealContacts(source) {
344
372
  await drain;
345
373
  if (USE_INK)
346
374
  ink.hideSpinner();
347
- out('');
375
+ if (USE_INK)
376
+ outPinned('');
377
+ else
378
+ out('');
348
379
  return revealed;
349
380
  }
350
- export async function revealInsights(source) {
381
+ export async function revealInsights(source, opts) {
351
382
  const w = W();
352
- for await (const insight of source) {
383
+ const maxWaitMs = opts?.maxWaitMs ?? 0;
384
+ const deadline = maxWaitMs > 0 ? Date.now() + maxWaitMs : 0;
385
+ const it = source[Symbol.asyncIterator]();
386
+ while (true) {
387
+ const nextPromise = it.next();
388
+ let next;
389
+ if (deadline > 0) {
390
+ const remaining = deadline - Date.now();
391
+ if (remaining <= 0) {
392
+ if (typeof it.return === 'function')
393
+ await it.return();
394
+ return;
395
+ }
396
+ const timed = await Promise.race([
397
+ nextPromise.then(v => ({ kind: 'value', v })),
398
+ new Promise(resolve => setTimeout(() => resolve({ kind: 'timeout' }), remaining)),
399
+ ]);
400
+ if (timed.kind === 'timeout') {
401
+ if (typeof it.return === 'function')
402
+ await it.return();
403
+ return;
404
+ }
405
+ next = timed.v;
406
+ }
407
+ else {
408
+ next = await nextPromise;
409
+ }
410
+ if (next.done)
411
+ return;
412
+ const insight = next.value;
353
413
  // Word-wrap the insight text
354
414
  const maxW = w - 6;
355
415
  const words = insight.text.split(' ');