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/README.md +19 -0
- package/dist/agent.js +14 -2
- package/dist/analyze.d.ts +1 -19
- package/dist/analyze.js +2 -128
- package/dist/cli.js +341 -167
- package/dist/collectors/calendar.js +1 -4
- package/dist/contacts.d.ts +2 -0
- package/dist/contacts.js +54 -0
- package/dist/conversation.js +3 -0
- package/dist/crm.d.ts +1 -0
- package/dist/crm.js +176 -86
- package/dist/ghostty-frames.json +1 -0
- package/dist/installer.d.ts +2 -2
- package/dist/installer.js +55 -24
- package/dist/profile.d.ts +6 -0
- package/dist/profile.js +230 -1
- package/dist/progress.d.ts +1 -0
- package/dist/progress.js +67 -31
- package/dist/prompt-layers.d.ts +17 -0
- package/dist/prompt-layers.js +113 -0
- package/dist/router.d.ts +3 -2
- package/dist/router.js +3 -2
- package/dist/session-progress.d.ts +2 -2
- package/dist/session-progress.js +2 -11
- package/dist/skill-loader.d.ts +1 -1
- package/dist/skill-loader.js +1 -1
- package/dist/sms-gateway.d.ts +6 -11
- package/dist/sms-gateway.js +14 -11
- package/dist/state.d.ts +1 -1
- package/dist/state.js +13 -17
- package/dist/tools.js +1 -3
- package/dist/transport.d.ts +1 -1
- package/dist/transport.js +1 -1
- package/dist/tui.d.ts +4 -3
- package/dist/tui.js +126 -66
- package/dist/tunnel.d.ts +1 -2
- package/dist/tunnel.js +1 -2
- package/dist/ui/app.d.ts +2 -1
- package/dist/ui/app.js +42 -25
- package/dist/ui/progress.d.ts +1 -0
- package/dist/ui/progress.js +75 -35
- package/package.json +4 -3
|
@@ -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 —
|
|
2
|
+
* Message Router — legacy bridge mode.
|
|
3
3
|
*
|
|
4
|
-
* This is
|
|
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 —
|
|
2
|
+
* Message Router — legacy bridge mode.
|
|
3
3
|
*
|
|
4
|
-
* This is
|
|
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'
|
|
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
|
|
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;
|
package/dist/session-progress.js
CHANGED
|
@@ -33,21 +33,12 @@ function defaultProgress() {
|
|
|
33
33
|
lastContext: {},
|
|
34
34
|
};
|
|
35
35
|
}
|
|
36
|
-
/** Load session progress
|
|
36
|
+
/** Load session progress */
|
|
37
37
|
export function loadProgress() {
|
|
38
38
|
try {
|
|
39
39
|
if (!existsSync(PROGRESS_FILE))
|
|
40
40
|
return defaultProgress();
|
|
41
|
-
|
|
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();
|
package/dist/skill-loader.d.ts
CHANGED
|
@@ -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
|
|
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 {
|
package/dist/skill-loader.js
CHANGED
|
@@ -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
|
|
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';
|
package/dist/sms-gateway.d.ts
CHANGED
|
@@ -1,19 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SMS Gateway — inbound HTTP server on the
|
|
2
|
+
* SMS Gateway — inbound HTTP server on the user's Mac.
|
|
3
3
|
*
|
|
4
|
-
* Architecture:
|
|
5
|
-
*
|
|
6
|
-
* →
|
|
7
|
-
* →
|
|
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
|
-
*
|
|
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;
|
package/dist/sms-gateway.js
CHANGED
|
@@ -1,19 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SMS Gateway — inbound HTTP server on the
|
|
2
|
+
* SMS Gateway — inbound HTTP server on the user's Mac.
|
|
3
3
|
*
|
|
4
|
-
* Architecture:
|
|
5
|
-
*
|
|
6
|
-
* →
|
|
7
|
-
* →
|
|
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
|
-
*
|
|
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'
|
|
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
|
|
42
|
-
if (
|
|
43
|
-
for (const d of
|
|
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: '
|
|
45
|
+
newEntries.push({ date: now, key: 'promise', value: d.title });
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
|
-
const
|
|
49
|
-
if (
|
|
50
|
-
for (const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
115
|
-
if (
|
|
116
|
-
lines.push('
|
|
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
|
|
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;
|
package/dist/transport.d.ts
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
|
|
8
|
+
* 3. RPLY/NOX webhook — optional 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
|
|
8
|
+
* 3. RPLY/NOX webhook — optional 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,
|
|
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
|
|
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
|
-
// ───
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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:
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
234
|
+
emit(` ${C.hd(t.name)}`);
|
|
213
235
|
}
|
|
214
|
-
|
|
236
|
+
emit('');
|
|
215
237
|
}
|
|
216
|
-
export async function pickCard(card, num,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(' ');
|