life-pulse 2.3.8 → 2.3.10

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 CHANGED
@@ -6,7 +6,7 @@ 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, revealContacts, revealInsights, pickCard, renderSection, renderSectionHeader, renderHandled, renderDivider, renderBrief, renderArchetype, Spinner, destroyUI, MAG, CYN, RED, AMB, MID, DIM } from './tui.js';
9
+ import { renderIntro, 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';
@@ -101,15 +101,13 @@ async function showCRM(apiKey, opts) {
101
101
  timeOfDay,
102
102
  onBrief: (b) => { brief = b; },
103
103
  }));
104
- const enrichedNames = new Set(enriched.map(t => t.name));
105
- // Show remaining contacts (not enriched) — just names
106
- const remaining = crm.threads.filter(t => !enrichedNames.has(t.name)).slice(0, 5);
107
- if (remaining.length)
108
- renderCRMList(remaining);
109
- if (brief)
104
+ if (brief) {
105
+ renderDivider();
110
106
  await renderBrief(brief);
107
+ }
111
108
  // Insights drip in after contacts
112
109
  await revealInsights(insightsGen);
110
+ renderDivider();
113
111
  saveContactSummaries(enriched);
114
112
  return true;
115
113
  }
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;
package/dist/crm.js CHANGED
@@ -26,7 +26,7 @@ const BUSINESS_PATTERNS = [
26
26
  /\binc\.?\b/i, /\bllc\b/i, /\bcorp\b/i, /^doordash/i, /^uber/i,
27
27
  /^lyft/i, /^venmo/i, /^cashapp/i, /^paypal/i, /^amazon/i,
28
28
  ];
29
- function isHumanContact(name) {
29
+ export function isHumanContact(name) {
30
30
  if (!name || name.length < 3)
31
31
  return false;
32
32
  // Raw phone numbers
@@ -368,7 +368,16 @@ async function enrichContact(t) {
368
368
  max_tokens: 100,
369
369
  messages: [{
370
370
  role: 'user',
371
- content: `${t.name}.${tag} Two sentences max. What's the vibe, what's live right now. Use names from the messages. No fluff.
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
+
372
381
  Priority: "work", "personal", or "service" (businesses/bots/automated).
373
382
 
374
383
  ${snippets}
@@ -380,7 +389,9 @@ JSON only: {"summary": "...", "priority": "work|personal|service"}`,
380
389
  if (!res.ok)
381
390
  return null;
382
391
  const data = await res.json();
383
- const content = data.content?.[0]?.text?.trim() || '';
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*$/, '');
384
395
  const jsonMatch = content.match(/\{[\s\S]*\}/);
385
396
  if (!jsonMatch)
386
397
  return { relationship: content.slice(0, 300), priority: 'personal' };
@@ -518,28 +529,18 @@ export async function* generateInsights(crm) {
518
529
  if (!ENRICH_KEY || crm.threads.length < 3)
519
530
  return;
520
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';
521
535
  const prompts = [
522
536
  {
523
537
  label: 'chronotype',
524
- prompt: `Based on this message volume by hour (index = hour 0-23): [${s.hourBuckets.join(',')}]
525
-
526
- Write ONE sentence about whether this person is a morning person, night owl, or peaks midday. Be specific about the hours. No hedging. Address them as "you".`,
527
- },
528
- {
529
- label: 'response personality',
530
- prompt: `This person has ${s.withResponse.length} contacts they regularly text.
531
- Fast replies (<10 min avg): ${s.fastResponders.map(t => t.name).join(', ') || 'none'}
532
- Slow replies (>1 day avg): ${s.slowResponders.map(t => t.name).join(', ') || 'none'}
533
- Overall avg response: ${s.yourAvgSec ? fmtTime(s.yourAvgSec) : 'unknown'}
534
-
535
- Write ONE sentence about their response patterns. Name names. Be direct. Address them as "you".`,
536
- },
537
- {
538
- label: 'inner circle',
539
- prompt: `This person's relationship map: ${s.hot} hot (daily), ${s.warm} warm (every few days), ${s.cooling} cooling (weekly), ${s.cold} cold (dormant).
540
- Top 3 by volume: ${s.threads.slice(0, 3).map(t => `${t.name} (${t.msgs30d} msgs/mo)`).join(', ')}
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(', ')}.` : ''}
541
542
 
542
- Write ONE sentence about the shape of their social world. Be specific. Address them as "you".`,
543
+ One sentence. Something an aide who knows them well would notice. No numbers, no statistics, no counts. Address as "you".`,
543
544
  },
544
545
  ];
545
546
  const indexed = prompts.map((p, i) => callInsight(p.prompt)
package/dist/tui.js CHANGED
@@ -50,23 +50,20 @@ const WMO = {
50
50
  };
51
51
  async function fetchLocationWeather() {
52
52
  try {
53
- // 1. System timezone → city name + coordinates via Open-Meteo geocoding
54
- const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
55
- const cityGuess = tz.split('/').pop()?.replace(/_/g, ' ');
56
- if (!cityGuess)
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)
57
59
  return null;
58
- const geoR = await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(cityGuess)}&count=1`, { signal: AbortSignal.timeout(3000) });
59
- const geo = await geoR.json();
60
- const place = geo.results?.[0];
61
- if (!place)
62
- return null;
63
- // 2. Current weather from coordinates — no API key
64
- const wxR = await fetch(`https://api.open-meteo.com/v1/forecast?latitude=${place.latitude}&longitude=${place.longitude}&current=temperature_2m,weather_code&temperature_unit=fahrenheit`, { signal: AbortSignal.timeout(3000) });
60
+ // 2. Current weather from real coordinates
61
+ const wxR = await fetch(`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&current=temperature_2m,weather_code&temperature_unit=fahrenheit`, { signal: AbortSignal.timeout(3000) });
65
62
  const wx = await wxR.json();
66
63
  const temp = Math.round(wx.current?.temperature_2m ?? 0);
67
64
  const code = wx.current?.weather_code ?? -1;
68
65
  const sky = WMO[code] || 'clear';
69
- return { city: place.name, weather: `${temp}° and ${sky}` };
66
+ return { city, weather: `${temp}° and ${sky}` };
70
67
  }
71
68
  catch {
72
69
  return null;
@@ -141,7 +138,7 @@ export async function renderIntro(name) {
141
138
  const rows = process.stdout.rows || 24;
142
139
  // Push old content out of scrollback
143
140
  process.stdout.write('\n'.repeat(rows * 2));
144
- process.stdout.write('\x1Bc');
141
+ process.stdout.write('\x1B[2J\x1B[H');
145
142
  process.stdout.write('\x1B[?25l');
146
143
  // === Phase 1: Geometric scan field ===
147
144
  const field = renderScanField(cols, rows);
@@ -151,7 +148,7 @@ export async function renderIntro(name) {
151
148
  }
152
149
  await _sleep(350);
153
150
  // === Phase 2: Greeting ===
154
- process.stdout.write('\x1Bc');
151
+ process.stdout.write('\x1B[2J\x1B[H');
155
152
  process.stdout.write('\n');
156
153
  // "Hey Molly."
157
154
  const hey = firstName ? `Hey ${firstName}.` : 'Hey.';
@@ -194,7 +191,7 @@ export function renderGreeting(name) {
194
191
  ink.log(` ${C.hd(name ? `Hey ${name}` : 'Hey')}`);
195
192
  }
196
193
  else {
197
- process.stdout.write('\x1Bc');
194
+ process.stdout.write('\x1B[2J\x1B[H');
198
195
  console.log();
199
196
  console.log(` ${HD(name ? `Hey ${name}` : 'Hey')}`);
200
197
  }
package/dist/ui/app.js CHANGED
@@ -52,11 +52,14 @@ const App = () => {
52
52
  _update = (fn) => setState(fn);
53
53
  return () => { _update = null; };
54
54
  }, []);
55
- // Global 80ms tick for all animations
55
+ // Only tick when something is animating (spinner, progress, card)
56
+ const needsAnim = !!(state.spinner || state.progress || state.card);
56
57
  useEffect(() => {
58
+ if (!needsAnim)
59
+ return;
57
60
  const t = setInterval(() => setFrame(f => f + 1), 80);
58
61
  return () => clearInterval(t);
59
- }, []);
62
+ }, [needsAnim]);
60
63
  // Input
61
64
  useInput((input, key) => {
62
65
  const s = stateRef.current;
@@ -101,7 +104,7 @@ function ensureStarted() {
101
104
  if (_started)
102
105
  return;
103
106
  _started = true;
104
- process.stdout.write('\x1Bc'); // clear screen
107
+ process.stdout.write('\x1B[2J\x1B[H'); // clear screen
105
108
  process.stdout.write('\x1B[?25l'); // hide cursor
106
109
  _inst = inkRender(React.createElement(App));
107
110
  // Seed initial lines (e.g. greeting from intro)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "life-pulse",
3
- "version": "2.3.8",
3
+ "version": "2.3.10",
4
4
  "description": "macOS life diagnostic — reads local data sources, generates actionable insights",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {