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
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
import { homedir } from 'os';
|
|
2
2
|
import { join } from 'path';
|
|
3
|
-
import { existsSync } from 'fs';
|
|
4
3
|
import { openDb, safeQuery } from '../db.js';
|
|
5
4
|
import { APPLE_EPOCH } from '../types.js';
|
|
6
5
|
import dayjs from 'dayjs';
|
|
7
6
|
const home = homedir();
|
|
8
|
-
const
|
|
9
|
-
const DB_LEGACY = join(home, 'Library/Calendars/Calendar.sqlitedb');
|
|
10
|
-
const DB_PATH = existsSync(DB_NEW) ? DB_NEW : DB_LEGACY;
|
|
7
|
+
const DB_PATH = join(home, 'Library/Group Containers/group.com.apple.calendar/Calendar.sqlitedb');
|
|
11
8
|
export async function collect() {
|
|
12
9
|
const db = openDb(DB_PATH);
|
|
13
10
|
if (!db) {
|
package/dist/contacts.d.ts
CHANGED
|
@@ -5,3 +5,5 @@
|
|
|
5
5
|
export declare function buildContactMap(): Map<string, string>;
|
|
6
6
|
/** Resolve a phone number or email to a contact name */
|
|
7
7
|
export declare function resolveName(handle: string | null | undefined): string;
|
|
8
|
+
/** Reverse lookup: find phone/email handles for an exact contact name. */
|
|
9
|
+
export declare function findHandlesForName(name: string): string[];
|
package/dist/contacts.js
CHANGED
|
@@ -11,6 +11,17 @@ function normalizePhone(raw) {
|
|
|
11
11
|
const digits = raw.replace(/\D/g, '');
|
|
12
12
|
return digits.length >= 10 ? digits.slice(-10) : digits;
|
|
13
13
|
}
|
|
14
|
+
function looksLikePhone(raw) {
|
|
15
|
+
const digits = raw.replace(/\D/g, '');
|
|
16
|
+
return digits.length >= 7;
|
|
17
|
+
}
|
|
18
|
+
function canonicalPhone(raw) {
|
|
19
|
+
const trimmed = raw.trim();
|
|
20
|
+
if (trimmed.startsWith('+'))
|
|
21
|
+
return '+' + trimmed.slice(1).replace(/\D/g, '');
|
|
22
|
+
const digits = trimmed.replace(/\D/g, '');
|
|
23
|
+
return digits;
|
|
24
|
+
}
|
|
14
25
|
export function buildContactMap() {
|
|
15
26
|
if (_cache)
|
|
16
27
|
return _cache;
|
|
@@ -86,3 +97,46 @@ export function resolveName(handle) {
|
|
|
86
97
|
}
|
|
87
98
|
return handle;
|
|
88
99
|
}
|
|
100
|
+
/** Reverse lookup: find phone/email handles for an exact contact name. */
|
|
101
|
+
export function findHandlesForName(name) {
|
|
102
|
+
const target = name.trim().toLowerCase();
|
|
103
|
+
if (!target)
|
|
104
|
+
return [];
|
|
105
|
+
const map = buildContactMap();
|
|
106
|
+
const out = [];
|
|
107
|
+
const seen = new Set();
|
|
108
|
+
for (const [handle, contactName] of map.entries()) {
|
|
109
|
+
if (contactName.trim().toLowerCase() !== target)
|
|
110
|
+
continue;
|
|
111
|
+
const trimmed = handle.trim();
|
|
112
|
+
if (!trimmed)
|
|
113
|
+
continue;
|
|
114
|
+
if (trimmed.includes('@')) {
|
|
115
|
+
const email = trimmed.toLowerCase();
|
|
116
|
+
if (!seen.has(email)) {
|
|
117
|
+
seen.add(email);
|
|
118
|
+
out.push(email);
|
|
119
|
+
}
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (!looksLikePhone(trimmed))
|
|
123
|
+
continue;
|
|
124
|
+
const phone = canonicalPhone(trimmed);
|
|
125
|
+
if (!phone || seen.has(phone))
|
|
126
|
+
continue;
|
|
127
|
+
seen.add(phone);
|
|
128
|
+
out.push(phone);
|
|
129
|
+
}
|
|
130
|
+
// Prefer E.164-ish numbers first, then longer digit strings, then emails.
|
|
131
|
+
return out.sort((a, b) => {
|
|
132
|
+
const aPhone = a.includes('@') ? 0 : 1;
|
|
133
|
+
const bPhone = b.includes('@') ? 0 : 1;
|
|
134
|
+
if (aPhone !== bPhone)
|
|
135
|
+
return bPhone - aPhone;
|
|
136
|
+
const aPlus = a.startsWith('+') ? 1 : 0;
|
|
137
|
+
const bPlus = b.startsWith('+') ? 1 : 0;
|
|
138
|
+
if (aPlus !== bPlus)
|
|
139
|
+
return bPlus - aPlus;
|
|
140
|
+
return b.length - a.length;
|
|
141
|
+
});
|
|
142
|
+
}
|
package/dist/conversation.js
CHANGED
|
@@ -11,6 +11,7 @@ import Anthropic from '@anthropic-ai/sdk';
|
|
|
11
11
|
import { TOOLS, executeTool } from './tools.js';
|
|
12
12
|
import { loadKnowledge, loadConversation, appendConversation, buildConversationContext } from './knowledge.js';
|
|
13
13
|
import { getUserName } from './profile.js';
|
|
14
|
+
import { buildPromptLayersSection } from './prompt-layers.js';
|
|
14
15
|
const MODEL = 'claude-sonnet-4-5-20250929';
|
|
15
16
|
const MAX_RESPONSE_CHARS = 320;
|
|
16
17
|
const MAX_TOKENS = 400;
|
|
@@ -56,12 +57,14 @@ export async function converse(contactName, incomingMessage, apiKey, opts) {
|
|
|
56
57
|
// Build context
|
|
57
58
|
const knowledge = loadKnowledge(contactName);
|
|
58
59
|
const conversationCtx = buildConversationContext(contactName, MAX_HISTORY_TURNS);
|
|
60
|
+
const identityMemory = buildPromptLayersSection();
|
|
59
61
|
// System prompt — keep it tight for speed
|
|
60
62
|
const system = `you are ${name}'s AI assistant. ${contactName} is texting you.
|
|
61
63
|
you have access to ${name}'s full life: messages, calendar, email, contacts, screen time, files.
|
|
62
64
|
use tools to look things up when needed. be accurate.
|
|
63
65
|
${knowledge}
|
|
64
66
|
${conversationCtx ? `\nrecent conversation:\n${conversationCtx}` : ''}
|
|
67
|
+
${identityMemory}
|
|
65
68
|
${STYLE_SUFFIX}`;
|
|
66
69
|
// Persist the incoming message
|
|
67
70
|
appendConversation(contactName, 'user', incomingMessage);
|
package/dist/crm.d.ts
CHANGED
|
@@ -40,6 +40,7 @@ export declare function streamEnrichedCRM(crm: CRM, _apiKey?: string, opts?: {
|
|
|
40
40
|
calendarContext?: string;
|
|
41
41
|
timeOfDay?: string;
|
|
42
42
|
onBrief?: (brief: string) => void;
|
|
43
|
+
briefAsync?: boolean;
|
|
43
44
|
}): AsyncGenerator<ThreadState>;
|
|
44
45
|
export interface Insight {
|
|
45
46
|
label: string;
|
package/dist/crm.js
CHANGED
|
@@ -18,6 +18,7 @@ import relativeTime from 'dayjs/plugin/relativeTime.js';
|
|
|
18
18
|
dayjs.extend(relativeTime);
|
|
19
19
|
import { APPLE_EPOCH } from './types.js';
|
|
20
20
|
const NANO = BigInt(1e9);
|
|
21
|
+
const LOOKBACK_DAYS = 365;
|
|
21
22
|
// ─── Contact quality filter ─────────────────────────────────────
|
|
22
23
|
const BUSINESS_PATTERNS = [
|
|
23
24
|
/^apple\s/i, /\bstore\b/i, /\bsupport\b/i, /\bairlines?\b/i,
|
|
@@ -66,15 +67,16 @@ export async function buildCRM() {
|
|
|
66
67
|
}
|
|
67
68
|
// ─── RPLY Path ──────────────────────────────────────────────────
|
|
68
69
|
async function buildCRMFromRPLY() {
|
|
69
|
-
// Fetch
|
|
70
|
-
const result = await rply.listConversations({ platform: 'system', limit:
|
|
70
|
+
// Fetch a wider recency pool, then rank by last-30d activity.
|
|
71
|
+
const result = await rply.listConversations({ platform: 'system', limit: 80 });
|
|
71
72
|
if (!result?.data.length)
|
|
72
73
|
return emptyCRM();
|
|
73
74
|
const convos = result.data.filter(c => !c.is_group_chat && c.title.length > 2);
|
|
74
|
-
//
|
|
75
|
-
const top = convos.slice(0,
|
|
76
|
-
const msgResults = await Promise.all(top.map(c => rply.listMessages(c.id, { limit:
|
|
75
|
+
// Pull 1-year history for each candidate so enrichment has dense context.
|
|
76
|
+
const top = convos.slice(0, 40);
|
|
77
|
+
const msgResults = await Promise.all(top.map(c => rply.listMessages(c.id, { limit: 5000 })));
|
|
77
78
|
const now = dayjs();
|
|
79
|
+
const agoYear = now.subtract(LOOKBACK_DAYS, 'day');
|
|
78
80
|
const ago30 = now.subtract(30, 'day');
|
|
79
81
|
const ago60 = now.subtract(60, 'day');
|
|
80
82
|
const threads = [];
|
|
@@ -82,20 +84,21 @@ async function buildCRMFromRPLY() {
|
|
|
82
84
|
for (let i = 0; i < top.length; i++) {
|
|
83
85
|
const conv = top[i];
|
|
84
86
|
// Messages: index 0 = newest, index N = oldest
|
|
85
|
-
const msgs = (msgResults[i]?.data || []).filter(m => m.content?.type === 'text' && m.content.text);
|
|
87
|
+
const msgs = (msgResults[i]?.data || []).filter(m => m.content?.type === 'text' && m.content.text && dayjs(m.date).isAfter(agoYear));
|
|
86
88
|
if (!msgs.length)
|
|
87
89
|
continue;
|
|
88
|
-
// ──
|
|
90
|
+
// ── Last-year volume + 30d volume
|
|
91
|
+
const msgsYear = msgs.length;
|
|
89
92
|
const msgs30d = msgs.filter(m => dayjs(m.date).isAfter(ago30)).length;
|
|
90
|
-
|
|
93
|
+
if (msgs30d <= 0)
|
|
94
|
+
continue;
|
|
95
|
+
// ── Response time pairs (all-time, not just 30d)
|
|
91
96
|
let totalResponseSec = 0;
|
|
92
97
|
let pairs = 0;
|
|
93
98
|
for (let j = msgs.length - 1; j >= 0; j--) {
|
|
94
99
|
if (msgs[j].direction !== 'received')
|
|
95
100
|
continue;
|
|
96
101
|
const received = dayjs(msgs[j].date);
|
|
97
|
-
if (received.isBefore(ago30))
|
|
98
|
-
continue;
|
|
99
102
|
for (let k = j - 1; k >= 0; k--) {
|
|
100
103
|
if (msgs[k].direction === 'sent') {
|
|
101
104
|
const gap = dayjs(msgs[k].date).diff(received, 'second');
|
|
@@ -116,33 +119,33 @@ async function buildCRMFromRPLY() {
|
|
|
116
119
|
const { waitingOnYou, waitingOnThem, waitingSince, silentDays } = buildGhostState(msgs[0], now);
|
|
117
120
|
// ── Thread temperature
|
|
118
121
|
const threadTemp = silentDays <= 1 ? 'hot' : silentDays <= 3 ? 'warm' : silentDays <= 7 ? 'cooling' : 'cold';
|
|
119
|
-
// ── Monthly trend
|
|
122
|
+
// ── Monthly trend (recent vs prior 30d)
|
|
120
123
|
const recent = msgs.filter(m => dayjs(m.date).isAfter(ago30)).length;
|
|
121
124
|
const prior = msgs.filter(m => { const d = dayjs(m.date); return d.isAfter(ago60) && !d.isAfter(ago30); }).length;
|
|
122
125
|
let monthlyTrend = null;
|
|
123
126
|
const ratio = prior > 0 ? recent / prior : recent > 0 ? 2 : 0;
|
|
124
127
|
if (ratio > 0)
|
|
125
128
|
monthlyTrend = ratio > 1.3 ? 'rising' : ratio < 0.7 ? 'declining' : 'stable';
|
|
126
|
-
// ──
|
|
129
|
+
// ── Snippets from last-year message pool for enrichment quality
|
|
127
130
|
const recentSnippets = msgs
|
|
128
|
-
.
|
|
129
|
-
.slice(0,
|
|
130
|
-
.map(m => `${m.direction === 'sent' ? '→' : '←'} ${m.content.text.slice(0, 100)}`);
|
|
131
|
+
.slice(0, 120)
|
|
132
|
+
.map(m => `${m.direction === 'sent' ? '→' : '←'} ${m.content.text.slice(0, 160)}`);
|
|
131
133
|
threads.push({
|
|
132
134
|
name: conv.title, handle: conv.id,
|
|
133
135
|
msgs30d, avgResponseSec, conversationPairs: pairs,
|
|
134
136
|
lastMsg, waitingOnYou, waitingOnThem, waitingSince,
|
|
135
137
|
silentDays, threadTemp, monthlyTrend,
|
|
136
|
-
recentSnippets: recentSnippets.slice(0,
|
|
138
|
+
recentSnippets: recentSnippets.slice(0, 80),
|
|
137
139
|
});
|
|
140
|
+
threads[threads.length - 1]._yearVol = msgsYear;
|
|
138
141
|
}
|
|
139
|
-
//
|
|
142
|
+
// Show: prioritize who is active THIS month; use year volume as tie-breaker.
|
|
140
143
|
threads.sort((a, b) => {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
const
|
|
145
|
-
return
|
|
144
|
+
if (b.msgs30d !== a.msgs30d)
|
|
145
|
+
return b.msgs30d - a.msgs30d;
|
|
146
|
+
const ay = a._yearVol || 0;
|
|
147
|
+
const by = b._yearVol || 0;
|
|
148
|
+
return by - ay;
|
|
146
149
|
});
|
|
147
150
|
const yourAvg = allResponseTimes.length > 0
|
|
148
151
|
? allResponseTimes.reduce((s, r) => s + r.avg * r.pairs, 0)
|
|
@@ -190,9 +193,9 @@ function buildCRMFromSQLite() {
|
|
|
190
193
|
if (!db)
|
|
191
194
|
return emptyCRM();
|
|
192
195
|
const now = nowNano();
|
|
196
|
+
const ago365 = agoNano(365);
|
|
193
197
|
const ago30 = agoNano(30);
|
|
194
|
-
|
|
195
|
-
// ── 1. Response time per contact (ConversationPairs CTE) ──
|
|
198
|
+
// ── 1. Response time per contact (ConversationPairs CTE, 1-year source) ──
|
|
196
199
|
const responseTimes = safeQuery(db, `
|
|
197
200
|
WITH NonGroupChats AS (
|
|
198
201
|
SELECT chat_id FROM chat_handle_join GROUP BY chat_id HAVING COUNT(DISTINCT handle_id) = 1
|
|
@@ -213,20 +216,46 @@ function buildCRMFromSQLite() {
|
|
|
213
216
|
FROM ConversationPairs cp JOIN handle h ON h.ROWID = cp.contact_id
|
|
214
217
|
GROUP BY h.id HAVING conversation_pairs >= 3 AND avg_response_seconds < 604800
|
|
215
218
|
ORDER BY avg_response_seconds ASC LIMIT 50
|
|
216
|
-
`, [
|
|
219
|
+
`, [ago365, now]);
|
|
217
220
|
const responseMap = new Map(responseTimes.map(r => [r.handle_id, r]));
|
|
218
|
-
// ── 2.
|
|
221
|
+
// ── 2. Contacts to show: rank by last 30 days ──
|
|
219
222
|
const topContacts = safeQuery(db, `
|
|
220
223
|
WITH NonGroupChats AS (
|
|
221
224
|
SELECT chat_id FROM chat_handle_join GROUP BY chat_id HAVING COUNT(DISTINCT handle_id) = 1
|
|
222
225
|
)
|
|
223
|
-
SELECT h.id as handle_id, COUNT(*) as
|
|
226
|
+
SELECT h.id as handle_id, COUNT(*) as msg_count_30
|
|
227
|
+
FROM message m JOIN handle h ON m.handle_id = h.ROWID
|
|
228
|
+
JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
|
|
229
|
+
JOIN NonGroupChats ngc ON ngc.chat_id = cmj.chat_id
|
|
230
|
+
WHERE m.date >= ? AND m.date <= ? AND m.associated_message_type = 0 AND h.id NOT LIKE 'urn:biz:%'
|
|
231
|
+
GROUP BY h.id ORDER BY msg_count_30 DESC LIMIT 60
|
|
232
|
+
`, [ago30, now]);
|
|
233
|
+
// ── 2b. Last-30d volume for display/tiering fields ──
|
|
234
|
+
const count30 = safeQuery(db, `
|
|
235
|
+
WITH NonGroupChats AS (
|
|
236
|
+
SELECT chat_id FROM chat_handle_join GROUP BY chat_id HAVING COUNT(DISTINCT handle_id) = 1
|
|
237
|
+
)
|
|
238
|
+
SELECT h.id as handle_id, COUNT(*) as msg_count_30
|
|
239
|
+
FROM message m JOIN handle h ON m.handle_id = h.ROWID
|
|
240
|
+
JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
|
|
241
|
+
JOIN NonGroupChats ngc ON ngc.chat_id = cmj.chat_id
|
|
242
|
+
WHERE m.date >= ? AND m.date <= ? AND m.associated_message_type = 0 AND h.id NOT LIKE 'urn:biz:%'
|
|
243
|
+
GROUP BY h.id
|
|
244
|
+
`, [ago30, now]);
|
|
245
|
+
const count30Map = new Map(count30.map(r => [r.handle_id, r.msg_count_30]));
|
|
246
|
+
// ── 2c. Last-365d volume for tie-breaks/context density ──
|
|
247
|
+
const count365 = safeQuery(db, `
|
|
248
|
+
WITH NonGroupChats AS (
|
|
249
|
+
SELECT chat_id FROM chat_handle_join GROUP BY chat_id HAVING COUNT(DISTINCT handle_id) = 1
|
|
250
|
+
)
|
|
251
|
+
SELECT h.id as handle_id, COUNT(*) as msg_count_365
|
|
224
252
|
FROM message m JOIN handle h ON m.handle_id = h.ROWID
|
|
225
253
|
JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
|
|
226
254
|
JOIN NonGroupChats ngc ON ngc.chat_id = cmj.chat_id
|
|
227
|
-
WHERE m.associated_message_type = 0 AND h.id NOT LIKE 'urn:biz:%'
|
|
228
|
-
GROUP BY h.id
|
|
229
|
-
|
|
255
|
+
WHERE m.date >= ? AND m.date <= ? AND m.associated_message_type = 0 AND h.id NOT LIKE 'urn:biz:%'
|
|
256
|
+
GROUP BY h.id
|
|
257
|
+
`, [ago365, now]);
|
|
258
|
+
const count365Map = new Map(count365.map(r => [r.handle_id, r.msg_count_365]));
|
|
230
259
|
// ── 3. Last message per contact ──
|
|
231
260
|
const lastMessages = safeQuery(db, `
|
|
232
261
|
WITH NonGroupChats AS (
|
|
@@ -241,7 +270,7 @@ function buildCRMFromSQLite() {
|
|
|
241
270
|
WHERE m.text IS NOT NULL AND m.text != '' AND m.associated_message_type = 0 AND m.date >= ?
|
|
242
271
|
)
|
|
243
272
|
SELECT handle_id, text, is_from_me, date FROM RankedMessages WHERE rn = 1
|
|
244
|
-
`, [
|
|
273
|
+
`, [ago365]);
|
|
245
274
|
const lastMsgMap = new Map(lastMessages.map(m => [m.handle_id, m]));
|
|
246
275
|
// ── 4. Monthly trend ──
|
|
247
276
|
const ago60 = agoNano(60);
|
|
@@ -274,21 +303,18 @@ function buildCRMFromSQLite() {
|
|
|
274
303
|
)
|
|
275
304
|
SELECT handle_id, text, is_from_me, date FROM RankedRecent WHERE rn <= 40
|
|
276
305
|
ORDER BY handle_id, date DESC
|
|
277
|
-
`, [
|
|
306
|
+
`, [ago365]);
|
|
278
307
|
const snippetMap = new Map();
|
|
279
308
|
for (const m of recentMessages) {
|
|
280
309
|
const arr = snippetMap.get(m.handle_id) || [];
|
|
281
|
-
arr.push(`${m.is_from_me ? '→' : '←'} ${(m.text || '').slice(0,
|
|
310
|
+
arr.push(`${m.is_from_me ? '→' : '←'} ${(m.text || '').slice(0, 160)}`);
|
|
282
311
|
snippetMap.set(m.handle_id, arr);
|
|
283
312
|
}
|
|
284
313
|
db.close();
|
|
285
314
|
// ── Assemble threads ──
|
|
286
315
|
const seen = new Set();
|
|
287
316
|
const threads = [];
|
|
288
|
-
const allHandles = new Set(
|
|
289
|
-
...topContacts.map(c => c.handle_id),
|
|
290
|
-
...responseTimes.map(r => r.handle_id),
|
|
291
|
-
]);
|
|
317
|
+
const allHandles = new Set(topContacts.map(c => c.handle_id));
|
|
292
318
|
for (const handleId of allHandles) {
|
|
293
319
|
const name = resolveName(handleId);
|
|
294
320
|
if (name === 'Unknown' || name === handleId || seen.has(name))
|
|
@@ -296,7 +322,9 @@ function buildCRMFromSQLite() {
|
|
|
296
322
|
if (name.length <= 2)
|
|
297
323
|
continue;
|
|
298
324
|
seen.add(name);
|
|
299
|
-
const
|
|
325
|
+
const msgs30 = count30Map.get(handleId) || 0;
|
|
326
|
+
if (msgs30 <= 0)
|
|
327
|
+
continue;
|
|
300
328
|
const response = responseMap.get(handleId);
|
|
301
329
|
const last = lastMsgMap.get(handleId);
|
|
302
330
|
const trend = trendMap.get(handleId);
|
|
@@ -333,15 +361,22 @@ function buildCRMFromSQLite() {
|
|
|
333
361
|
}
|
|
334
362
|
threads.push({
|
|
335
363
|
name, handle: handleId,
|
|
336
|
-
msgs30d:
|
|
364
|
+
msgs30d: msgs30,
|
|
337
365
|
avgResponseSec: response?.avg_response_seconds ?? null,
|
|
338
366
|
conversationPairs: response?.conversation_pairs ?? 0,
|
|
339
367
|
lastMsg, waitingOnYou, waitingOnThem, waitingSince,
|
|
340
368
|
silentDays, threadTemp, monthlyTrend,
|
|
341
|
-
recentSnippets: snippetMap.get(handleId)?.slice(0,
|
|
369
|
+
recentSnippets: snippetMap.get(handleId)?.slice(0, 80),
|
|
342
370
|
});
|
|
371
|
+
threads[threads.length - 1]._yearVol = count365Map.get(handleId) || 0;
|
|
343
372
|
}
|
|
344
|
-
threads.sort((a, b) =>
|
|
373
|
+
threads.sort((a, b) => {
|
|
374
|
+
if (b.msgs30d !== a.msgs30d)
|
|
375
|
+
return b.msgs30d - a.msgs30d;
|
|
376
|
+
const ay = a._yearVol || 0;
|
|
377
|
+
const by = b._yearVol || 0;
|
|
378
|
+
return by - ay;
|
|
379
|
+
});
|
|
345
380
|
const allPairs = responseTimes.filter(r => r.avg_response_seconds < 604800);
|
|
346
381
|
const yourAvg = allPairs.length > 0
|
|
347
382
|
? allPairs.reduce((sum, r) => sum + r.avg_response_seconds * r.conversation_pairs, 0)
|
|
@@ -351,7 +386,7 @@ function buildCRMFromSQLite() {
|
|
|
351
386
|
}
|
|
352
387
|
// ─── Per-contact enrichment ──────────────────────────────────────
|
|
353
388
|
async function enrichContact(t) {
|
|
354
|
-
const snippets = (t.recentSnippets || []).slice(0,
|
|
389
|
+
const snippets = (t.recentSnippets || []).slice(0, 60).reverse().join('\n');
|
|
355
390
|
if (!snippets)
|
|
356
391
|
return null;
|
|
357
392
|
const tag = t.waitingOnYou ? ' [WAITING ON YOU]'
|
|
@@ -365,18 +400,18 @@ async function enrichContact(t) {
|
|
|
365
400
|
},
|
|
366
401
|
body: JSON.stringify({
|
|
367
402
|
model: 'claude-sonnet-4-5-20250929',
|
|
368
|
-
max_tokens:
|
|
403
|
+
max_tokens: 140,
|
|
369
404
|
messages: [{
|
|
370
405
|
role: 'user',
|
|
371
406
|
content: `You are briefing the person who sent the → messages. "${t.name}" sent the ← messages.${tag}
|
|
372
407
|
|
|
373
|
-
|
|
408
|
+
Write ONE dense sentence (max 28 words). Pack in as much concrete signal as possible from this 1-year thread: names, places, dates, plans, money, travel, commitments. Don't explain the relationship. Don't say "you" or "they" — state the situation like a ticker.
|
|
374
409
|
|
|
375
410
|
Good: "Sunday for Harrison in Woodside — 9pm fell through."
|
|
376
411
|
Good: "8:30 tonight, Lunar New Year party with NOX crew."
|
|
377
412
|
Good: "Waiting on FDM1 — he's shipping the post Monday then scaling up."
|
|
378
413
|
Bad: "You have a casual friendship where you coordinate plans." (too abstract)
|
|
379
|
-
Bad: "
|
|
414
|
+
Bad: "You want to visit them in Woodside." (wrong voice — never address ${t.name})
|
|
380
415
|
|
|
381
416
|
Priority: "work", "personal", or "service" (businesses/bots/automated).
|
|
382
417
|
|
|
@@ -394,32 +429,28 @@ JSON only: {"summary": "...", "priority": "work|personal|service"}`,
|
|
|
394
429
|
const content = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '');
|
|
395
430
|
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
|
396
431
|
if (!jsonMatch)
|
|
397
|
-
return
|
|
432
|
+
return null;
|
|
398
433
|
try {
|
|
399
434
|
const parsed = JSON.parse(jsonMatch[0]);
|
|
400
|
-
|
|
435
|
+
const summary = normalizeSummary(String(parsed.summary || ''));
|
|
436
|
+
if (!isValidSummary(summary))
|
|
437
|
+
return null;
|
|
438
|
+
const priority = String(parsed.priority || 'personal').toLowerCase();
|
|
439
|
+
return { relationship: summary, priority: priority || 'personal' };
|
|
401
440
|
}
|
|
402
441
|
catch {
|
|
403
|
-
return
|
|
442
|
+
return null;
|
|
404
443
|
}
|
|
405
444
|
}
|
|
406
445
|
// ─── Stream-enrich CRM ──────────────────────────────────────────
|
|
407
446
|
export async function* streamEnrichedCRM(crm, _apiKey, opts) {
|
|
408
447
|
// Filter: only real people, not businesses or raw phone numbers
|
|
409
448
|
const humans = crm.threads.filter(t => isHumanContact(t.name));
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
yield t;
|
|
449
|
+
const targetCount = Math.min(10, humans.length);
|
|
450
|
+
const top = humans.slice(0, 30); // widen candidate pool so we can fill 10 reliably
|
|
451
|
+
if (!top.length || !ENRICH_KEY)
|
|
414
452
|
return;
|
|
415
|
-
|
|
416
|
-
const withSnippets = top.filter(t => t.recentSnippets?.length);
|
|
417
|
-
if (!withSnippets.length) {
|
|
418
|
-
for (const t of top)
|
|
419
|
-
yield t;
|
|
420
|
-
return;
|
|
421
|
-
}
|
|
422
|
-
const indexed = withSnippets.map((t, i) => enrichContact(t)
|
|
453
|
+
const indexed = top.map((t, i) => (t.recentSnippets?.length ? enrichContact(t) : Promise.resolve(null))
|
|
423
454
|
.then(r => {
|
|
424
455
|
if (r) {
|
|
425
456
|
t.relationship = r.relationship;
|
|
@@ -430,52 +461,111 @@ export async function* streamEnrichedCRM(crm, _apiKey, opts) {
|
|
|
430
461
|
.catch(() => ({ thread: t, idx: i })));
|
|
431
462
|
const pending = new Map(indexed.map((p, i) => [i, p]));
|
|
432
463
|
const yielded = new Set();
|
|
433
|
-
while (pending.size > 0) {
|
|
464
|
+
while (pending.size > 0 && yielded.size < targetCount) {
|
|
434
465
|
const resolved = await Promise.race(pending.values());
|
|
435
466
|
pending.delete(resolved.idx);
|
|
436
467
|
// Skip contacts classified as "service" (businesses, bots)
|
|
437
468
|
if (resolved.thread.priority === 'service')
|
|
438
469
|
continue;
|
|
470
|
+
if (!isValidSummary(resolved.thread.relationship || '')) {
|
|
471
|
+
const fallback = fallbackSummaryFromSnippets(resolved.thread) || fallbackSummaryFromLastMsg(resolved.thread);
|
|
472
|
+
if (!fallback)
|
|
473
|
+
continue;
|
|
474
|
+
resolved.thread.relationship = fallback;
|
|
475
|
+
resolved.thread.priority = resolved.thread.priority || 'personal';
|
|
476
|
+
}
|
|
439
477
|
yielded.add(resolved.thread.name);
|
|
440
478
|
yield resolved.thread;
|
|
441
479
|
}
|
|
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
480
|
// Brief from collected summaries
|
|
448
481
|
if (opts?.onBrief) {
|
|
482
|
+
const onBrief = opts.onBrief;
|
|
449
483
|
const summaries = top.filter(t => t.relationship).map(t => `${t.name}: ${t.relationship}`).join('\n');
|
|
450
484
|
if (summaries) {
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
485
|
+
const runBrief = async () => {
|
|
486
|
+
try {
|
|
487
|
+
const briefRes = await fetch('https://api.anthropic.com/v1/messages', {
|
|
488
|
+
method: 'POST',
|
|
489
|
+
headers: {
|
|
490
|
+
'x-api-key': ENRICH_KEY,
|
|
491
|
+
'anthropic-version': '2023-06-01',
|
|
492
|
+
'content-type': 'application/json',
|
|
493
|
+
},
|
|
494
|
+
body: JSON.stringify({
|
|
495
|
+
model: 'claude-sonnet-4-5-20250929',
|
|
496
|
+
max_tokens: 150,
|
|
497
|
+
messages: [{
|
|
498
|
+
role: 'user',
|
|
499
|
+
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}`,
|
|
500
|
+
}],
|
|
501
|
+
}),
|
|
502
|
+
});
|
|
503
|
+
if (!briefRes.ok)
|
|
504
|
+
return;
|
|
469
505
|
const data = await briefRes.json();
|
|
470
506
|
const brief = data.content?.[0]?.text?.trim() || '';
|
|
471
507
|
if (brief)
|
|
472
|
-
|
|
508
|
+
onBrief(brief);
|
|
473
509
|
}
|
|
510
|
+
catch { }
|
|
511
|
+
};
|
|
512
|
+
if (opts.briefAsync) {
|
|
513
|
+
void runBrief();
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
await runBrief();
|
|
474
517
|
}
|
|
475
|
-
catch { }
|
|
476
518
|
}
|
|
477
519
|
}
|
|
478
520
|
}
|
|
521
|
+
function normalizeSummary(input) {
|
|
522
|
+
return input.replace(/\s+/g, ' ').replace(/^[-•]\s*/, '').trim().slice(0, 220);
|
|
523
|
+
}
|
|
524
|
+
function isValidSummary(summary) {
|
|
525
|
+
const s = normalizeSummary(summary);
|
|
526
|
+
if (!s)
|
|
527
|
+
return false;
|
|
528
|
+
if (s.length < 12 || s.length > 220)
|
|
529
|
+
return false;
|
|
530
|
+
if (s.split(/\s+/).length < 3)
|
|
531
|
+
return false;
|
|
532
|
+
if (/^(none|n\/a|null|unknown)$/i.test(s))
|
|
533
|
+
return false;
|
|
534
|
+
if (/summary|priority|json|relationship/i.test(s))
|
|
535
|
+
return false;
|
|
536
|
+
if (/^[\d\W_]+$/.test(s))
|
|
537
|
+
return false;
|
|
538
|
+
return true;
|
|
539
|
+
}
|
|
540
|
+
function fallbackSummaryFromSnippets(t) {
|
|
541
|
+
const raw = (t.recentSnippets || [])
|
|
542
|
+
.map(s => s.replace(/^[→←]\s*/, '').trim())
|
|
543
|
+
.filter(Boolean);
|
|
544
|
+
if (!raw.length)
|
|
545
|
+
return null;
|
|
546
|
+
const base = raw.find(s => s.length >= 12) || raw[0];
|
|
547
|
+
if (!base)
|
|
548
|
+
return null;
|
|
549
|
+
let summary = base;
|
|
550
|
+
if (t.waitingOnYou)
|
|
551
|
+
summary = `waiting on you — ${summary}`;
|
|
552
|
+
else if (t.waitingOnThem)
|
|
553
|
+
summary = `you left off here — ${summary}`;
|
|
554
|
+
const normalized = normalizeSummary(summary);
|
|
555
|
+
return isValidSummary(normalized) ? normalized : null;
|
|
556
|
+
}
|
|
557
|
+
function fallbackSummaryFromLastMsg(t) {
|
|
558
|
+
const msg = t.lastMsg?.text?.trim();
|
|
559
|
+
if (!msg)
|
|
560
|
+
return null;
|
|
561
|
+
let summary = msg;
|
|
562
|
+
if (t.waitingOnYou)
|
|
563
|
+
summary = `waiting on you — ${summary}`;
|
|
564
|
+
else if (t.waitingOnThem)
|
|
565
|
+
summary = `you left off here — ${summary}`;
|
|
566
|
+
const normalized = normalizeSummary(summary);
|
|
567
|
+
return isValidSummary(normalized) ? normalized : null;
|
|
568
|
+
}
|
|
479
569
|
function extractSignals(crm) {
|
|
480
570
|
const t = crm.threads;
|
|
481
571
|
const hot = t.filter(x => x.threadTemp === 'hot').length;
|