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.
@@ -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 DB_NEW = join(home, 'Library/Group Containers/group.com.apple.calendar/Calendar.sqlitedb');
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) {
@@ -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
+ }
@@ -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 top 50 iMessage conversations (non-group, sorted by recency)
70
- const result = await rply.listConversations({ platform: 'system', limit: 50 });
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
- // Fetch messages for top 30 in parallel
75
- const top = convos.slice(0, 30);
76
- const msgResults = await Promise.all(top.map(c => rply.listMessages(c.id, { limit: 200 })));
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
- // ── msgs30d
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
- // ── Response time pairs: each received → first sent after it
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
- // ── Recent snippets (for LLM enrichment)
129
+ // ── Snippets from last-year message pool for enrichment quality
127
130
  const recentSnippets = msgs
128
- .filter(m => dayjs(m.date).isAfter(ago30))
129
- .slice(0, 40)
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, 30),
138
+ recentSnippets: recentSnippets.slice(0, 80),
137
139
  });
140
+ threads[threads.length - 1]._yearVol = msgsYear;
138
141
  }
139
- // Sort by total message volume (all-time proxy: total fetched msgs, not just 30d)
142
+ // Show: prioritize who is active THIS month; use year volume as tie-breaker.
140
143
  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;
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
- const ago90 = agoNano(90);
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
- `, [ago30, now]);
219
+ `, [ago365, now]);
217
220
  const responseMap = new Map(responseTimes.map(r => [r.handle_id, r]));
218
- // ── 2. Top contacts by ALL-TIME volume ──
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 msg_count
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 ORDER BY msg_count DESC LIMIT 30
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
- `, [ago90]);
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
- `, [ago30]);
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, 100)}`);
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 contact = topContacts.find(c => c.handle_id === handleId);
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: contact?.msg_count || 0, // now all-time volume from SQL
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, 30),
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) => b.msgs30d - a.msgs30d);
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, 25).reverse().join('\n');
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: 100,
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
- 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.
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: "Molly wants to visit you in Woodside." (wrong voice — never address ${t.name})
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 { relationship: content.slice(0, 300), priority: 'personal' };
432
+ return null;
398
433
  try {
399
434
  const parsed = JSON.parse(jsonMatch[0]);
400
- return { relationship: parsed.summary || '', priority: parsed.priority || 'personal' };
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 { relationship: content.slice(0, 300), priority: 'personal' };
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 top = humans.slice(0, 10);
411
- if (!top.length || !ENRICH_KEY) {
412
- for (const t of top)
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
- 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) {
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
- opts.onBrief(brief);
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;