life-pulse 2.3.7 → 2.3.9
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 +14 -13
- package/dist/crm.d.ts +6 -0
- package/dist/crm.js +217 -162
- package/dist/icloud-discovery.js +1 -1
- package/dist/tui.d.ts +6 -2
- package/dist/tui.js +101 -41
- package/dist/ui/app.d.ts +1 -0
- package/dist/ui/app.js +20 -5
- package/dist/ui/theme.js +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -6,11 +6,11 @@ import { saveState, saveDecisions } from './state.js';
|
|
|
6
6
|
// ProgressRenderer unused — removed
|
|
7
7
|
import { InkProgress } from './ui/progress.js';
|
|
8
8
|
import { addTodo, resolveTodos, pruneOld } from './todo.js';
|
|
9
|
-
import { renderIntro, renderCRMList,
|
|
9
|
+
import { renderIntro, renderCRMList, revealContacts, revealInsights, pickCard, renderSection, renderSectionHeader, renderHandled, renderDivider, renderBrief, renderArchetype, Spinner, destroyUI, MAG, CYN, RED, AMB, MID, DIM } from './tui.js';
|
|
10
10
|
import { needsDiscovery, discoverPlatforms, savePlatforms } from './platforms.js';
|
|
11
11
|
import { generateArchetype } from './archetype.js';
|
|
12
12
|
import { runPermissionFlow, getMissingPermissions } from './permissions.js';
|
|
13
|
-
import { buildCRM, streamEnrichedCRM } from './crm.js';
|
|
13
|
+
import { buildCRM, streamEnrichedCRM, generateInsights, isHumanContact } from './crm.js';
|
|
14
14
|
import { saveContactSummaries } from './intelligence.js';
|
|
15
15
|
import { getUserName } from './profile.js';
|
|
16
16
|
// Session progress tracking (Anthropic long-running agent pattern)
|
|
@@ -89,28 +89,29 @@ async function showCRM(apiKey, opts) {
|
|
|
89
89
|
return false;
|
|
90
90
|
}
|
|
91
91
|
opts?.spinner?.stop();
|
|
92
|
+
console.log();
|
|
93
|
+
// Fire insights in background — they'll resolve while user steps through contacts
|
|
94
|
+
const insightsGen = generateInsights(crm);
|
|
92
95
|
const calendarContext = opts?.calendarContext ?? '';
|
|
93
96
|
const hour = new Date().getHours();
|
|
94
97
|
const timeOfDay = hour < 12 ? 'morning' : hour < 17 ? 'afternoon' : 'evening';
|
|
95
|
-
// Stream contacts as they enrich — each name + relationship appears live
|
|
96
98
|
let brief = '';
|
|
97
|
-
const enriched =
|
|
98
|
-
const enrichedNames = new Set();
|
|
99
|
-
for await (const t of streamEnrichedCRM(crm, apiKey, {
|
|
99
|
+
const enriched = await revealContacts(streamEnrichedCRM(crm, apiKey, {
|
|
100
100
|
calendarContext,
|
|
101
101
|
timeOfDay,
|
|
102
102
|
onBrief: (b) => { brief = b; },
|
|
103
|
-
}))
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const remaining = crm.threads.filter(t => !enrichedNames.has(t.name)).slice(0, 5);
|
|
103
|
+
}));
|
|
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);
|
|
110
109
|
if (remaining.length)
|
|
111
110
|
renderCRMList(remaining);
|
|
112
111
|
if (brief)
|
|
113
112
|
await renderBrief(brief);
|
|
113
|
+
// Insights drip in after contacts
|
|
114
|
+
await revealInsights(insightsGen);
|
|
114
115
|
saveContactSummaries(enriched);
|
|
115
116
|
return true;
|
|
116
117
|
}
|
package/dist/crm.d.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Both paths produce the same CRM/ThreadState interface.
|
|
9
9
|
*/
|
|
10
|
+
export declare function isHumanContact(name: string): boolean;
|
|
10
11
|
export interface ThreadState {
|
|
11
12
|
name: string;
|
|
12
13
|
handle: string;
|
|
@@ -40,4 +41,9 @@ export declare function streamEnrichedCRM(crm: CRM, _apiKey?: string, opts?: {
|
|
|
40
41
|
timeOfDay?: string;
|
|
41
42
|
onBrief?: (brief: string) => void;
|
|
42
43
|
}): AsyncGenerator<ThreadState>;
|
|
44
|
+
export interface Insight {
|
|
45
|
+
label: string;
|
|
46
|
+
text: string;
|
|
47
|
+
}
|
|
48
|
+
export declare function generateInsights(crm: CRM): AsyncGenerator<Insight>;
|
|
43
49
|
export declare function buildCRMContext(crm: CRM): string;
|
package/dist/crm.js
CHANGED
|
@@ -12,13 +12,31 @@ import { join } from 'path';
|
|
|
12
12
|
import { openDb, safeQuery } from './db.js';
|
|
13
13
|
import { resolveName } from './contacts.js';
|
|
14
14
|
import * as rply from './rply-client.js';
|
|
15
|
-
|
|
16
|
-
const OPENROUTER_KEY = process.env.OPENROUTER_API_KEY || '';
|
|
15
|
+
const ENRICH_KEY = process.env.ANTHROPIC_API_KEY || '';
|
|
17
16
|
import dayjs from 'dayjs';
|
|
18
17
|
import relativeTime from 'dayjs/plugin/relativeTime.js';
|
|
19
18
|
dayjs.extend(relativeTime);
|
|
20
19
|
import { APPLE_EPOCH } from './types.js';
|
|
21
20
|
const NANO = BigInt(1e9);
|
|
21
|
+
// ─── Contact quality filter ─────────────────────────────────────
|
|
22
|
+
const BUSINESS_PATTERNS = [
|
|
23
|
+
/^apple\s/i, /\bstore\b/i, /\bsupport\b/i, /\bairlines?\b/i,
|
|
24
|
+
/\bbank\b/i, /\binsurance\b/i, /\bpharmacy\b/i, /\bclinic\b/i,
|
|
25
|
+
/\bhotel\b/i, /\bairport\b/i, /\bdelivery\b/i, /\bservice\b/i,
|
|
26
|
+
/\binc\.?\b/i, /\bllc\b/i, /\bcorp\b/i, /^doordash/i, /^uber/i,
|
|
27
|
+
/^lyft/i, /^venmo/i, /^cashapp/i, /^paypal/i, /^amazon/i,
|
|
28
|
+
];
|
|
29
|
+
export function isHumanContact(name) {
|
|
30
|
+
if (!name || name.length < 3)
|
|
31
|
+
return false;
|
|
32
|
+
// Raw phone numbers
|
|
33
|
+
if (/^\+?\d[\d\s()-]{6,}$/.test(name.trim()))
|
|
34
|
+
return false;
|
|
35
|
+
// Businesses
|
|
36
|
+
if (BUSINESS_PATTERNS.some(p => p.test(name)))
|
|
37
|
+
return false;
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
22
40
|
function nowNano() {
|
|
23
41
|
return (BigInt(dayjs().unix() - APPLE_EPOCH) * NANO).toString();
|
|
24
42
|
}
|
|
@@ -118,7 +136,14 @@ async function buildCRMFromRPLY() {
|
|
|
118
136
|
recentSnippets: recentSnippets.slice(0, 30),
|
|
119
137
|
});
|
|
120
138
|
}
|
|
121
|
-
|
|
139
|
+
// Sort by total message volume (all-time proxy: total fetched msgs, not just 30d)
|
|
140
|
+
threads.sort((a, b) => {
|
|
141
|
+
// Total messages fetched is the best all-time proxy from RPLY (200 limit per contact)
|
|
142
|
+
// Fall back to 30d if equal
|
|
143
|
+
const aTotal = (a.conversationPairs * 2) + a.msgs30d;
|
|
144
|
+
const bTotal = (b.conversationPairs * 2) + b.msgs30d;
|
|
145
|
+
return bTotal - aTotal;
|
|
146
|
+
});
|
|
122
147
|
const yourAvg = allResponseTimes.length > 0
|
|
123
148
|
? allResponseTimes.reduce((s, r) => s + r.avg * r.pairs, 0)
|
|
124
149
|
/ allResponseTimes.reduce((s, r) => s + r.pairs, 0)
|
|
@@ -190,7 +215,7 @@ function buildCRMFromSQLite() {
|
|
|
190
215
|
ORDER BY avg_response_seconds ASC LIMIT 50
|
|
191
216
|
`, [ago30, now]);
|
|
192
217
|
const responseMap = new Map(responseTimes.map(r => [r.handle_id, r]));
|
|
193
|
-
// ── 2. Top contacts by
|
|
218
|
+
// ── 2. Top contacts by ALL-TIME volume ──
|
|
194
219
|
const topContacts = safeQuery(db, `
|
|
195
220
|
WITH NonGroupChats AS (
|
|
196
221
|
SELECT chat_id FROM chat_handle_join GROUP BY chat_id HAVING COUNT(DISTINCT handle_id) = 1
|
|
@@ -199,9 +224,9 @@ function buildCRMFromSQLite() {
|
|
|
199
224
|
FROM message m JOIN handle h ON m.handle_id = h.ROWID
|
|
200
225
|
JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
|
|
201
226
|
JOIN NonGroupChats ngc ON ngc.chat_id = cmj.chat_id
|
|
202
|
-
WHERE m.
|
|
227
|
+
WHERE m.associated_message_type = 0 AND h.id NOT LIKE 'urn:biz:%'
|
|
203
228
|
GROUP BY h.id ORDER BY msg_count DESC LIMIT 30
|
|
204
|
-
|
|
229
|
+
`);
|
|
205
230
|
// ── 3. Last message per contact ──
|
|
206
231
|
const lastMessages = safeQuery(db, `
|
|
207
232
|
WITH NonGroupChats AS (
|
|
@@ -308,7 +333,7 @@ function buildCRMFromSQLite() {
|
|
|
308
333
|
}
|
|
309
334
|
threads.push({
|
|
310
335
|
name, handle: handleId,
|
|
311
|
-
msgs30d: contact?.msg_count || 0,
|
|
336
|
+
msgs30d: contact?.msg_count || 0, // now all-time volume from SQL
|
|
312
337
|
avgResponseSec: response?.avg_response_seconds ?? null,
|
|
313
338
|
conversationPairs: response?.conversation_pairs ?? 0,
|
|
314
339
|
lastMsg, waitingOnYou, waitingOnThem, waitingSince,
|
|
@@ -324,10 +349,66 @@ function buildCRMFromSQLite() {
|
|
|
324
349
|
: null;
|
|
325
350
|
return { threads: threads.slice(0, 25), yourAvgResponseSec: yourAvg, generatedAt: dayjs().toISOString() };
|
|
326
351
|
}
|
|
352
|
+
// ─── Per-contact enrichment ──────────────────────────────────────
|
|
353
|
+
async function enrichContact(t) {
|
|
354
|
+
const snippets = (t.recentSnippets || []).slice(0, 25).reverse().join('\n');
|
|
355
|
+
if (!snippets)
|
|
356
|
+
return null;
|
|
357
|
+
const tag = t.waitingOnYou ? ' [WAITING ON YOU]'
|
|
358
|
+
: t.waitingOnThem ? ` [WAITING ON THEM — ${t.waitingSince}]` : '';
|
|
359
|
+
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
360
|
+
method: 'POST',
|
|
361
|
+
headers: {
|
|
362
|
+
'x-api-key': ENRICH_KEY,
|
|
363
|
+
'anthropic-version': '2023-06-01',
|
|
364
|
+
'content-type': 'application/json',
|
|
365
|
+
},
|
|
366
|
+
body: JSON.stringify({
|
|
367
|
+
model: 'claude-sonnet-4-5-20250929',
|
|
368
|
+
max_tokens: 100,
|
|
369
|
+
messages: [{
|
|
370
|
+
role: 'user',
|
|
371
|
+
content: `You are briefing the person who sent the → messages. "${t.name}" sent the ← messages.${tag}
|
|
372
|
+
|
|
373
|
+
One sentence only. What's the ONE thing happening right now between them. Be terse — 15 words max. Use the specific detail from the messages (names, places, times). Don't explain the relationship. Don't say "you" or "they" — just state the situation like a ticker.
|
|
374
|
+
|
|
375
|
+
Good: "Sunday for Harrison in Woodside — 9pm fell through."
|
|
376
|
+
Good: "8:30 tonight, Lunar New Year party with NOX crew."
|
|
377
|
+
Good: "Waiting on FDM1 — he's shipping the post Monday then scaling up."
|
|
378
|
+
Bad: "You have a casual friendship where you coordinate plans." (too abstract)
|
|
379
|
+
Bad: "Molly wants to visit you in Woodside." (wrong voice — never address ${t.name})
|
|
380
|
+
|
|
381
|
+
Priority: "work", "personal", or "service" (businesses/bots/automated).
|
|
382
|
+
|
|
383
|
+
${snippets}
|
|
384
|
+
|
|
385
|
+
JSON only: {"summary": "...", "priority": "work|personal|service"}`,
|
|
386
|
+
}],
|
|
387
|
+
}),
|
|
388
|
+
});
|
|
389
|
+
if (!res.ok)
|
|
390
|
+
return null;
|
|
391
|
+
const data = await res.json();
|
|
392
|
+
// Strip markdown fences (```json ... ```) before parsing
|
|
393
|
+
const raw = data.content?.[0]?.text?.trim() || '';
|
|
394
|
+
const content = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '');
|
|
395
|
+
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
|
396
|
+
if (!jsonMatch)
|
|
397
|
+
return { relationship: content.slice(0, 300), priority: 'personal' };
|
|
398
|
+
try {
|
|
399
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
400
|
+
return { relationship: parsed.summary || '', priority: parsed.priority || 'personal' };
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
return { relationship: content.slice(0, 300), priority: 'personal' };
|
|
404
|
+
}
|
|
405
|
+
}
|
|
327
406
|
// ─── Stream-enrich CRM ──────────────────────────────────────────
|
|
328
407
|
export async function* streamEnrichedCRM(crm, _apiKey, opts) {
|
|
329
|
-
|
|
330
|
-
|
|
408
|
+
// Filter: only real people, not businesses or raw phone numbers
|
|
409
|
+
const humans = crm.threads.filter(t => isHumanContact(t.name));
|
|
410
|
+
const top = humans.slice(0, 10);
|
|
411
|
+
if (!top.length || !ENRICH_KEY) {
|
|
331
412
|
for (const t of top)
|
|
332
413
|
yield t;
|
|
333
414
|
return;
|
|
@@ -338,165 +419,139 @@ export async function* streamEnrichedCRM(crm, _apiKey, opts) {
|
|
|
338
419
|
yield t;
|
|
339
420
|
return;
|
|
340
421
|
}
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
}).join('\n\n---\n\n');
|
|
347
|
-
const calendar = opts?.calendarContext
|
|
348
|
-
? `\nTODAY'S CALENDAR:\n${opts.calendarContext}\n` : '';
|
|
349
|
-
const timeOfDay = opts?.timeOfDay || 'today';
|
|
350
|
-
const nameMap = new Map(top.map(t => [t.name.toLowerCase(), t]));
|
|
351
|
-
const yielded = new Set();
|
|
352
|
-
const ready = [];
|
|
353
|
-
let curName = null;
|
|
354
|
-
let curLines = [];
|
|
355
|
-
let curPriority = null;
|
|
356
|
-
const flushContact = () => {
|
|
357
|
-
if (!curName)
|
|
358
|
-
return;
|
|
359
|
-
const thread = nameMap.get(curName.toLowerCase());
|
|
360
|
-
if (thread && !yielded.has(thread.name)) {
|
|
361
|
-
thread.relationship = curLines.join(' ').trim();
|
|
362
|
-
thread.priority = curPriority || undefined;
|
|
363
|
-
yielded.add(thread.name);
|
|
364
|
-
ready.push(thread);
|
|
422
|
+
const indexed = withSnippets.map((t, i) => enrichContact(t)
|
|
423
|
+
.then(r => {
|
|
424
|
+
if (r) {
|
|
425
|
+
t.relationship = r.relationship;
|
|
426
|
+
t.priority = r.priority;
|
|
365
427
|
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
${
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
- BRIEF: synthesize across relationships. Mention names. Don't list — narrate.
|
|
412
|
-
- If someone has a calendar meeting today, weave that in naturally.
|
|
413
|
-
|
|
414
|
-
${contactBlocks}`,
|
|
415
|
-
}],
|
|
416
|
-
}),
|
|
417
|
-
});
|
|
418
|
-
if (!res.ok || !res.body)
|
|
419
|
-
throw new Error(`OpenRouter ${res.status}`);
|
|
420
|
-
// Stream SSE deltas — yield contacts the moment each block completes
|
|
421
|
-
let section = 'preamble';
|
|
422
|
-
const briefLines = [];
|
|
423
|
-
let lineBuf = '';
|
|
424
|
-
let sseBuf = '';
|
|
425
|
-
const processLine = (rawLine) => {
|
|
426
|
-
const line = rawLine.trim();
|
|
427
|
-
if (/^CONTACTS:?$/i.test(line)) {
|
|
428
|
-
section = 'contacts';
|
|
429
|
-
return;
|
|
430
|
-
}
|
|
431
|
-
if (/^BRIEF:?$/i.test(line)) {
|
|
432
|
-
flushContact();
|
|
433
|
-
section = 'brief';
|
|
434
|
-
return;
|
|
435
|
-
}
|
|
436
|
-
const bracketMatch = line.match(/^\[([^\]]+)\]/);
|
|
437
|
-
if (bracketMatch && (section === 'contacts' || section === 'preamble')) {
|
|
438
|
-
flushContact();
|
|
439
|
-
curName = bracketMatch[1].trim();
|
|
440
|
-
section = 'contacts';
|
|
441
|
-
return;
|
|
442
|
-
}
|
|
443
|
-
if (!line)
|
|
444
|
-
return;
|
|
445
|
-
if (section === 'contacts' || section === 'preamble') {
|
|
446
|
-
if (line.startsWith('Priority:')) {
|
|
447
|
-
curPriority = line.replace('Priority:', '').trim().toLowerCase();
|
|
448
|
-
}
|
|
449
|
-
else if (curName) {
|
|
450
|
-
curLines.push(line);
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
else if (section === 'brief') {
|
|
454
|
-
briefLines.push(line);
|
|
455
|
-
}
|
|
456
|
-
};
|
|
457
|
-
const decoder = new TextDecoder();
|
|
458
|
-
const reader = res.body.getReader();
|
|
459
|
-
while (true) {
|
|
460
|
-
const { done, value } = await reader.read();
|
|
461
|
-
if (done)
|
|
462
|
-
break;
|
|
463
|
-
sseBuf += decoder.decode(value, { stream: true });
|
|
464
|
-
const sseLines = sseBuf.split('\n');
|
|
465
|
-
sseBuf = sseLines.pop() || '';
|
|
466
|
-
for (const sseLine of sseLines) {
|
|
467
|
-
if (!sseLine.startsWith('data: ') || sseLine === 'data: [DONE]')
|
|
468
|
-
continue;
|
|
469
|
-
try {
|
|
470
|
-
const json = JSON.parse(sseLine.slice(6));
|
|
471
|
-
const delta = json.choices?.[0]?.delta?.content;
|
|
472
|
-
if (!delta)
|
|
473
|
-
continue;
|
|
474
|
-
lineBuf += delta;
|
|
475
|
-
const parts = lineBuf.split('\n');
|
|
476
|
-
lineBuf = parts.pop() || '';
|
|
477
|
-
for (const line of parts)
|
|
478
|
-
processLine(line);
|
|
479
|
-
while (ready.length)
|
|
480
|
-
yield ready.shift();
|
|
428
|
+
return { thread: t, idx: i };
|
|
429
|
+
})
|
|
430
|
+
.catch(() => ({ thread: t, idx: i })));
|
|
431
|
+
const pending = new Map(indexed.map((p, i) => [i, p]));
|
|
432
|
+
const yielded = new Set();
|
|
433
|
+
while (pending.size > 0) {
|
|
434
|
+
const resolved = await Promise.race(pending.values());
|
|
435
|
+
pending.delete(resolved.idx);
|
|
436
|
+
// Skip contacts classified as "service" (businesses, bots)
|
|
437
|
+
if (resolved.thread.priority === 'service')
|
|
438
|
+
continue;
|
|
439
|
+
yielded.add(resolved.thread.name);
|
|
440
|
+
yield resolved.thread;
|
|
441
|
+
}
|
|
442
|
+
// Yield remaining contacts without snippets (already filtered to humans)
|
|
443
|
+
for (const t of top) {
|
|
444
|
+
if (!yielded.has(t.name) && t.priority !== 'service')
|
|
445
|
+
yield t;
|
|
446
|
+
}
|
|
447
|
+
// Brief from collected summaries
|
|
448
|
+
if (opts?.onBrief) {
|
|
449
|
+
const summaries = top.filter(t => t.relationship).map(t => `${t.name}: ${t.relationship}`).join('\n');
|
|
450
|
+
if (summaries) {
|
|
451
|
+
try {
|
|
452
|
+
const briefRes = await fetch('https://api.anthropic.com/v1/messages', {
|
|
453
|
+
method: 'POST',
|
|
454
|
+
headers: {
|
|
455
|
+
'x-api-key': ENRICH_KEY,
|
|
456
|
+
'anthropic-version': '2023-06-01',
|
|
457
|
+
'content-type': 'application/json',
|
|
458
|
+
},
|
|
459
|
+
body: JSON.stringify({
|
|
460
|
+
model: 'claude-sonnet-4-5-20250929',
|
|
461
|
+
max_tokens: 150,
|
|
462
|
+
messages: [{
|
|
463
|
+
role: 'user',
|
|
464
|
+
content: `You're their trusted aide. Given these relationship summaries, write 2 short sentences about what matters most in their world right now. Address them as "you". Reference real names. No lists, no counting, no statistics.\n\n${summaries}`,
|
|
465
|
+
}],
|
|
466
|
+
}),
|
|
467
|
+
});
|
|
468
|
+
if (briefRes.ok) {
|
|
469
|
+
const data = await briefRes.json();
|
|
470
|
+
const brief = data.content?.[0]?.text?.trim() || '';
|
|
471
|
+
if (brief)
|
|
472
|
+
opts.onBrief(brief);
|
|
481
473
|
}
|
|
482
|
-
catch { }
|
|
483
474
|
}
|
|
475
|
+
catch { }
|
|
484
476
|
}
|
|
485
|
-
// Flush remaining buffer
|
|
486
|
-
if (lineBuf.trim())
|
|
487
|
-
processLine(lineBuf);
|
|
488
|
-
flushContact();
|
|
489
|
-
while (ready.length)
|
|
490
|
-
yield ready.shift();
|
|
491
|
-
if (briefLines.length && opts?.onBrief)
|
|
492
|
-
opts.onBrief(briefLines.join(' '));
|
|
493
477
|
}
|
|
494
|
-
|
|
495
|
-
|
|
478
|
+
}
|
|
479
|
+
function extractSignals(crm) {
|
|
480
|
+
const t = crm.threads;
|
|
481
|
+
const hot = t.filter(x => x.threadTemp === 'hot').length;
|
|
482
|
+
const warm = t.filter(x => x.threadTemp === 'warm').length;
|
|
483
|
+
const cooling = t.filter(x => x.threadTemp === 'cooling').length;
|
|
484
|
+
const cold = t.filter(x => x.threadTemp === 'cold').length;
|
|
485
|
+
const rising = t.filter(x => x.monthlyTrend === 'rising');
|
|
486
|
+
const declining = t.filter(x => x.monthlyTrend === 'declining');
|
|
487
|
+
const waiting = t.filter(x => x.waitingOnYou);
|
|
488
|
+
const totalMsgs = t.reduce((s, x) => s + x.msgs30d, 0);
|
|
489
|
+
const activeThreads = t.filter(x => x.msgs30d > 0).length;
|
|
490
|
+
// Response time buckets
|
|
491
|
+
const withResponse = t.filter(x => x.avgResponseSec != null && x.conversationPairs >= 3);
|
|
492
|
+
const fastResponders = withResponse.filter(x => x.avgResponseSec < 600); // <10 min
|
|
493
|
+
const slowResponders = withResponse.filter(x => x.avgResponseSec > 86400); // >1 day
|
|
494
|
+
// Message timing (hour buckets from lastMsg timestamps)
|
|
495
|
+
const hourBuckets = new Array(24).fill(0);
|
|
496
|
+
for (const thread of t) {
|
|
497
|
+
if (thread.lastMsg?.when) {
|
|
498
|
+
const h = new Date(thread.lastMsg.when).getHours();
|
|
499
|
+
hourBuckets[h] += thread.msgs30d;
|
|
500
|
+
}
|
|
496
501
|
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
502
|
+
return {
|
|
503
|
+
hot, warm, cooling, cold, rising, declining, waiting,
|
|
504
|
+
totalMsgs, activeThreads, withResponse, fastResponders, slowResponders,
|
|
505
|
+
hourBuckets, threads: t,
|
|
506
|
+
yourAvgSec: crm.yourAvgResponseSec,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
async function callInsight(prompt) {
|
|
510
|
+
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
511
|
+
method: 'POST',
|
|
512
|
+
headers: {
|
|
513
|
+
'x-api-key': ENRICH_KEY,
|
|
514
|
+
'anthropic-version': '2023-06-01',
|
|
515
|
+
'content-type': 'application/json',
|
|
516
|
+
},
|
|
517
|
+
body: JSON.stringify({
|
|
518
|
+
model: 'claude-sonnet-4-5-20250929',
|
|
519
|
+
max_tokens: 120,
|
|
520
|
+
messages: [{ role: 'user', content: prompt }],
|
|
521
|
+
}),
|
|
522
|
+
});
|
|
523
|
+
if (!res.ok)
|
|
524
|
+
throw new Error(`insight ${res.status}`);
|
|
525
|
+
const data = await res.json();
|
|
526
|
+
return data.content?.[0]?.text?.trim() || '';
|
|
527
|
+
}
|
|
528
|
+
export async function* generateInsights(crm) {
|
|
529
|
+
if (!ENRICH_KEY || crm.threads.length < 3)
|
|
530
|
+
return;
|
|
531
|
+
const s = extractSignals(crm);
|
|
532
|
+
// Determine chronotype from hour buckets without LLM
|
|
533
|
+
const peakHour = s.hourBuckets.indexOf(Math.max(...s.hourBuckets));
|
|
534
|
+
const chronotype = peakHour < 10 ? 'morning person' : peakHour < 16 ? 'afternoon person' : 'night owl';
|
|
535
|
+
const prompts = [
|
|
536
|
+
{
|
|
537
|
+
label: 'chronotype',
|
|
538
|
+
prompt: `This person is a ${chronotype} (peak messaging around ${peakHour > 12 ? peakHour - 12 : peakHour}${peakHour >= 12 ? 'pm' : 'am'}).
|
|
539
|
+
Top 3 people: ${s.threads.slice(0, 3).map(t => t.name).join(', ')}.
|
|
540
|
+
${s.fastResponders.length ? `Quick with: ${s.fastResponders.slice(0, 3).map(t => t.name).join(', ')}.` : ''}
|
|
541
|
+
${s.slowResponders.length ? `Slow with: ${s.slowResponders.slice(0, 2).map(t => t.name).join(', ')}.` : ''}
|
|
542
|
+
|
|
543
|
+
One sentence. Something an aide who knows them well would notice. No numbers, no statistics, no counts. Address as "you".`,
|
|
544
|
+
},
|
|
545
|
+
];
|
|
546
|
+
const indexed = prompts.map((p, i) => callInsight(p.prompt)
|
|
547
|
+
.then(text => ({ label: p.label, text, idx: i }))
|
|
548
|
+
.catch(() => ({ label: p.label, text: '', idx: i })));
|
|
549
|
+
const pending = new Map(indexed.map((p, i) => [i, p]));
|
|
550
|
+
while (pending.size > 0) {
|
|
551
|
+
const resolved = await Promise.race(pending.values());
|
|
552
|
+
pending.delete(resolved.idx);
|
|
553
|
+
if (resolved.text)
|
|
554
|
+
yield { label: resolved.label, text: resolved.text };
|
|
500
555
|
}
|
|
501
556
|
}
|
|
502
557
|
// ─── CRM context for agent prompt injection ─────────────────────
|
package/dist/icloud-discovery.js
CHANGED
|
@@ -373,7 +373,7 @@ export function formatDiscoveryReport(discoveries) {
|
|
|
373
373
|
if (!discoveries.length)
|
|
374
374
|
return '';
|
|
375
375
|
const lines = [];
|
|
376
|
-
lines.push('
|
|
376
|
+
lines.push('');
|
|
377
377
|
// Group by category
|
|
378
378
|
const noteApps = discoveries.filter(d => ['Obsidian', 'Bear', 'Logseq', 'Apple Notes', 'Craft', 'iA Writer', 'Ulysses'].includes(d.app));
|
|
379
379
|
const taskApps = discoveries.filter(d => ['Things 3', 'OmniFocus', 'Todoist'].includes(d.app));
|
package/dist/tui.d.ts
CHANGED
|
@@ -63,7 +63,11 @@ interface CRMThread {
|
|
|
63
63
|
} | null;
|
|
64
64
|
relationship?: string;
|
|
65
65
|
}
|
|
66
|
-
export declare function
|
|
67
|
-
|
|
66
|
+
export declare function revealContacts(source: AsyncIterable<CRMThread>): Promise<CRMThread[]>;
|
|
67
|
+
interface InsightItem {
|
|
68
|
+
label: string;
|
|
69
|
+
text: string;
|
|
70
|
+
}
|
|
71
|
+
export declare function revealInsights(source: AsyncIterable<InsightItem>): Promise<void>;
|
|
68
72
|
export declare function destroyUI(): void;
|
|
69
73
|
export {};
|
package/dist/tui.js
CHANGED
|
@@ -39,21 +39,31 @@ async function typewrite(text, style, delay = 30) {
|
|
|
39
39
|
await _sleep(delay);
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
|
+
// WMO weather code → short description
|
|
43
|
+
const WMO = {
|
|
44
|
+
0: 'clear', 1: 'mostly clear', 2: 'partly cloudy', 3: 'overcast',
|
|
45
|
+
45: 'foggy', 48: 'foggy', 51: 'light drizzle', 53: 'drizzle', 55: 'drizzle',
|
|
46
|
+
61: 'light rain', 63: 'rain', 65: 'heavy rain', 66: 'freezing rain', 67: 'freezing rain',
|
|
47
|
+
71: 'light snow', 73: 'snow', 75: 'heavy snow', 77: 'snow',
|
|
48
|
+
80: 'rain showers', 81: 'rain showers', 82: 'heavy showers',
|
|
49
|
+
85: 'snow showers', 86: 'heavy snow', 95: 'thunderstorm', 96: 'thunderstorm', 99: 'thunderstorm',
|
|
50
|
+
};
|
|
42
51
|
async function fetchLocationWeather() {
|
|
43
52
|
try {
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
if (!loc || !temp || !cond)
|
|
53
|
+
// 1. Actual city from IP geolocation (not timezone)
|
|
54
|
+
const ipR = await fetch('https://ipinfo.io/json', { signal: AbortSignal.timeout(3000) });
|
|
55
|
+
const ip = await ipR.json();
|
|
56
|
+
const city = ip.city;
|
|
57
|
+
const [lat, lon] = (ip.loc || '').split(',');
|
|
58
|
+
if (!city || !lat || !lon)
|
|
51
59
|
return null;
|
|
52
|
-
//
|
|
53
|
-
const
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
|
|
60
|
+
// 2. Current weather from real coordinates
|
|
61
|
+
const wxR = await fetch(`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}¤t=temperature_2m,weather_code&temperature_unit=fahrenheit`, { signal: AbortSignal.timeout(3000) });
|
|
62
|
+
const wx = await wxR.json();
|
|
63
|
+
const temp = Math.round(wx.current?.temperature_2m ?? 0);
|
|
64
|
+
const code = wx.current?.weather_code ?? -1;
|
|
65
|
+
const sky = WMO[code] || 'clear';
|
|
66
|
+
return { city, weather: `${temp}° and ${sky}` };
|
|
57
67
|
}
|
|
58
68
|
catch {
|
|
59
69
|
return null;
|
|
@@ -63,7 +73,7 @@ function timeGreeting(h) {
|
|
|
63
73
|
if (h < 5)
|
|
64
74
|
return "Don't stay up too late.";
|
|
65
75
|
if (h < 12)
|
|
66
|
-
return
|
|
76
|
+
return "Hope you're having an incredible morning.";
|
|
67
77
|
if (h < 17)
|
|
68
78
|
return "Hope you're having a wonderful afternoon.";
|
|
69
79
|
if (h < 21)
|
|
@@ -72,14 +82,16 @@ function timeGreeting(h) {
|
|
|
72
82
|
}
|
|
73
83
|
function weatherRemark(weather) {
|
|
74
84
|
const w = weather.toLowerCase();
|
|
75
|
-
if (w.includes('
|
|
85
|
+
if (w.includes('clear') || w.includes('sunny'))
|
|
76
86
|
return `${weather} out there.`;
|
|
77
|
-
if (w.includes('rain') || w.includes('drizzle'))
|
|
87
|
+
if (w.includes('rain') || w.includes('drizzle') || w.includes('shower'))
|
|
78
88
|
return `${weather} — good day to stay sharp.`;
|
|
79
|
-
if (w.includes('cloud') || w.includes('overcast'))
|
|
89
|
+
if (w.includes('cloud') || w.includes('overcast') || w.includes('fog'))
|
|
80
90
|
return `${weather}.`;
|
|
81
91
|
if (w.includes('snow'))
|
|
82
92
|
return `${weather}. Stay warm.`;
|
|
93
|
+
if (w.includes('thunder'))
|
|
94
|
+
return `${weather}. Stay inside.`;
|
|
83
95
|
return `${weather}.`;
|
|
84
96
|
}
|
|
85
97
|
// ─── Geometric animation ───
|
|
@@ -126,7 +138,7 @@ export async function renderIntro(name) {
|
|
|
126
138
|
const rows = process.stdout.rows || 24;
|
|
127
139
|
// Push old content out of scrollback
|
|
128
140
|
process.stdout.write('\n'.repeat(rows * 2));
|
|
129
|
-
process.stdout.write('\
|
|
141
|
+
process.stdout.write('\x1B[2J\x1B[H');
|
|
130
142
|
process.stdout.write('\x1B[?25l');
|
|
131
143
|
// === Phase 1: Geometric scan field ===
|
|
132
144
|
const field = renderScanField(cols, rows);
|
|
@@ -136,7 +148,7 @@ export async function renderIntro(name) {
|
|
|
136
148
|
}
|
|
137
149
|
await _sleep(350);
|
|
138
150
|
// === Phase 2: Greeting ===
|
|
139
|
-
process.stdout.write('\
|
|
151
|
+
process.stdout.write('\x1B[2J\x1B[H');
|
|
140
152
|
process.stdout.write('\n');
|
|
141
153
|
// "Hey Molly."
|
|
142
154
|
const hey = firstName ? `Hey ${firstName}.` : 'Hey.';
|
|
@@ -167,6 +179,7 @@ export async function renderIntro(name) {
|
|
|
167
179
|
greetLines.push(` ${G2(`I see you're in ${loc.city}. ${weatherRemark(loc.weather)}`)}`);
|
|
168
180
|
greetLines.push(` ${G3(greeting)}`);
|
|
169
181
|
greetLines.push('');
|
|
182
|
+
greetLines.push('');
|
|
170
183
|
if (USE_INK)
|
|
171
184
|
ink.initInk(greetLines);
|
|
172
185
|
}
|
|
@@ -178,7 +191,7 @@ export function renderGreeting(name) {
|
|
|
178
191
|
ink.log(` ${C.hd(name ? `Hey ${name}` : 'Hey')}`);
|
|
179
192
|
}
|
|
180
193
|
else {
|
|
181
|
-
process.stdout.write('\
|
|
194
|
+
process.stdout.write('\x1B[2J\x1B[H');
|
|
182
195
|
console.log();
|
|
183
196
|
console.log(` ${HD(name ? `Hey ${name}` : 'Hey')}`);
|
|
184
197
|
}
|
|
@@ -279,35 +292,82 @@ export class Spinner {
|
|
|
279
292
|
export function renderFYI(title, context) {
|
|
280
293
|
out(` ${C.dim('·')} ${title}${context ? ' ' + C.dim(context) : ''}`);
|
|
281
294
|
}
|
|
282
|
-
export async function
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
295
|
+
export async function revealContacts(source) {
|
|
296
|
+
const buffer = [];
|
|
297
|
+
let done = false;
|
|
298
|
+
const revealed = [];
|
|
299
|
+
// Background: drain async generator into buffer
|
|
300
|
+
const drain = (async () => {
|
|
301
|
+
for await (const t of source)
|
|
302
|
+
buffer.push(t);
|
|
303
|
+
done = true;
|
|
304
|
+
})();
|
|
305
|
+
if (USE_INK)
|
|
306
|
+
ink.showSpinner('reading the room');
|
|
307
|
+
while (!done || buffer.length > 0) {
|
|
308
|
+
if (buffer.length === 0) {
|
|
309
|
+
await _sleep(80);
|
|
310
|
+
continue;
|
|
292
311
|
}
|
|
293
|
-
|
|
312
|
+
if (USE_INK)
|
|
313
|
+
ink.hideSpinner();
|
|
314
|
+
const t = buffer.shift();
|
|
315
|
+
revealed.push(t);
|
|
316
|
+
const w = W();
|
|
317
|
+
const name = t.name.slice(0, 24);
|
|
318
|
+
const ago = shortAgo(t.lastMsg?.ago || '');
|
|
319
|
+
const gap = Math.max(2, w - 4 - name.length - ago.length);
|
|
320
|
+
out('');
|
|
321
|
+
out(` ${C.hd(name)}${' '.repeat(gap)}${C.dim(ago)}`);
|
|
294
322
|
if (t.relationship) {
|
|
295
|
-
|
|
323
|
+
// Word-wrap relationship across lines
|
|
324
|
+
const maxW = w - 8;
|
|
325
|
+
const words = t.relationship.split(' ');
|
|
326
|
+
let line = '';
|
|
327
|
+
for (const word of words) {
|
|
328
|
+
if (line.length + word.length + 1 > maxW && line) {
|
|
329
|
+
out(` ${C.mid(line)}`);
|
|
330
|
+
line = word;
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
line = line ? line + ' ' + word : word;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (line)
|
|
337
|
+
out(` ${C.mid(line)}`);
|
|
338
|
+
}
|
|
339
|
+
// Wait for Enter before showing next (interactive only)
|
|
340
|
+
if (USE_INK && (!done || buffer.length > 0)) {
|
|
341
|
+
await ink.waitForEnter();
|
|
296
342
|
}
|
|
297
|
-
rendered.push(t);
|
|
298
|
-
await _sleep(180);
|
|
299
343
|
}
|
|
300
|
-
|
|
301
|
-
|
|
344
|
+
await drain;
|
|
345
|
+
if (USE_INK)
|
|
346
|
+
ink.hideSpinner();
|
|
302
347
|
out('');
|
|
303
|
-
return
|
|
348
|
+
return revealed;
|
|
304
349
|
}
|
|
305
|
-
export async function
|
|
350
|
+
export async function revealInsights(source) {
|
|
306
351
|
const w = W();
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
352
|
+
for await (const insight of source) {
|
|
353
|
+
// Word-wrap the insight text
|
|
354
|
+
const maxW = w - 6;
|
|
355
|
+
const words = insight.text.split(' ');
|
|
356
|
+
let line = '';
|
|
357
|
+
let first = true;
|
|
358
|
+
for (const word of words) {
|
|
359
|
+
if (line.length + word.length + 1 > maxW && line) {
|
|
360
|
+
out(` ${first ? C.dim('·') : ' '} ${C.dim(line)}`);
|
|
361
|
+
first = false;
|
|
362
|
+
line = word;
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
line = line ? line + ' ' + word : word;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (line)
|
|
369
|
+
out(` ${first ? C.dim('·') : ' '} ${C.dim(line)}`);
|
|
370
|
+
}
|
|
311
371
|
}
|
|
312
372
|
// ─── Destroy (call at exit) ───
|
|
313
373
|
export function destroyUI() {
|
package/dist/ui/app.d.ts
CHANGED
package/dist/ui/app.js
CHANGED
|
@@ -7,17 +7,18 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
7
7
|
*/
|
|
8
8
|
import React, { useState, useEffect, useRef } from 'react';
|
|
9
9
|
import { render as inkRender, Box, Text, useInput, useApp } from 'ink';
|
|
10
|
-
import { C,
|
|
10
|
+
import { C, BAR_CHARS } from './theme.js';
|
|
11
11
|
// ─── Module bridge ────────────────────────────────
|
|
12
12
|
let _update = null;
|
|
13
13
|
let _cardResolve = null;
|
|
14
|
+
let _enterResolve = null;
|
|
14
15
|
const INIT = {
|
|
15
16
|
lines: [], spinner: null,
|
|
16
17
|
card: null, cardNum: 0, cardSel: 0,
|
|
17
18
|
progress: null,
|
|
18
19
|
};
|
|
19
20
|
// ─── Components ───────────────────────────────────
|
|
20
|
-
const SpinnerLine = ({ label, frame }) => (_jsxs(Text, { children: [' ', _jsx(Text, { color: "#565f89", children:
|
|
21
|
+
const SpinnerLine = ({ label, frame }) => (_jsxs(Text, { children: [' ', _jsx(Text, { color: "#565f89", children: BAR_CHARS[Math.floor(frame / 3) % BAR_CHARS.length] }), ' ', _jsx(Text, { color: "#565f89", children: label })] }));
|
|
21
22
|
const BAR_W = 20;
|
|
22
23
|
const MAX_VIS = 4;
|
|
23
24
|
const Bar = ({ done, total }) => {
|
|
@@ -51,11 +52,14 @@ const App = () => {
|
|
|
51
52
|
_update = (fn) => setState(fn);
|
|
52
53
|
return () => { _update = null; };
|
|
53
54
|
}, []);
|
|
54
|
-
//
|
|
55
|
+
// Only tick when something is animating (spinner, progress, card)
|
|
56
|
+
const needsAnim = !!(state.spinner || state.progress || state.card);
|
|
55
57
|
useEffect(() => {
|
|
58
|
+
if (!needsAnim)
|
|
59
|
+
return;
|
|
56
60
|
const t = setInterval(() => setFrame(f => f + 1), 80);
|
|
57
61
|
return () => clearInterval(t);
|
|
58
|
-
}, []);
|
|
62
|
+
}, [needsAnim]);
|
|
59
63
|
// Input
|
|
60
64
|
useInput((input, key) => {
|
|
61
65
|
const s = stateRef.current;
|
|
@@ -76,8 +80,14 @@ const App = () => {
|
|
|
76
80
|
_cardResolve = null;
|
|
77
81
|
}
|
|
78
82
|
}
|
|
83
|
+
if (key.return && !s.card && _enterResolve) {
|
|
84
|
+
const r = _enterResolve;
|
|
85
|
+
_enterResolve = null;
|
|
86
|
+
r();
|
|
87
|
+
}
|
|
79
88
|
if (key.ctrl && input === 'c') {
|
|
80
89
|
_cardResolve?.('skip');
|
|
90
|
+
_enterResolve?.();
|
|
81
91
|
exit();
|
|
82
92
|
process.stdout.write('\x1B[?25h');
|
|
83
93
|
process.exit(0);
|
|
@@ -94,7 +104,7 @@ function ensureStarted() {
|
|
|
94
104
|
if (_started)
|
|
95
105
|
return;
|
|
96
106
|
_started = true;
|
|
97
|
-
process.stdout.write('\
|
|
107
|
+
process.stdout.write('\x1B[2J\x1B[H'); // clear screen
|
|
98
108
|
process.stdout.write('\x1B[?25l'); // hide cursor
|
|
99
109
|
_inst = inkRender(React.createElement(App));
|
|
100
110
|
// Seed initial lines (e.g. greeting from intro)
|
|
@@ -176,3 +186,8 @@ export function showProgress(phase, done, total, items, thinking) {
|
|
|
176
186
|
export function hideProgress() {
|
|
177
187
|
_update?.(p => ({ ...p, progress: null }));
|
|
178
188
|
}
|
|
189
|
+
// ── Wait for Enter ──
|
|
190
|
+
export function waitForEnter() {
|
|
191
|
+
ensureStarted();
|
|
192
|
+
return new Promise(resolve => { _enterResolve = resolve; });
|
|
193
|
+
}
|
package/dist/ui/theme.js
CHANGED
|
@@ -51,7 +51,7 @@ export const W = () => Math.min(process.stdout.columns || 80, 80);
|
|
|
51
51
|
export const INNER = () => W() - 4;
|
|
52
52
|
// Spinner frames
|
|
53
53
|
export const SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
54
|
-
export const BAR_CHARS = ['✶', '*', '✢'];
|
|
54
|
+
export const BAR_CHARS = ['✶', '*', '✢', '·', '✦', '⊹', '✧'];
|
|
55
55
|
// Progress label map — personality, not developer console
|
|
56
56
|
export const TOOL_LABEL = {
|
|
57
57
|
search_all_messages: 'reading the room',
|