persnally 2.5.2 → 2.6.0

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/build/src/cli.js CHANGED
@@ -19,7 +19,7 @@ import { extractClaudeEvents, parseClaudeExport } from "./importers/claude.js";
19
19
  import { DEFAULT_TRANSCRIPTS_DIR, extractClaudeCodeEvents, parseClaudeCodeTranscripts, } from "./importers/claude-code.js";
20
20
  import { gitEvents, scanRepos } from "./importers/git.js";
21
21
  import { freshConversations } from "./importers/extract.js";
22
- import { autostartInstalled, installAutostart, LOG_FILE, removeAutostart, removePidFile, runningPid, startDetached, stopDaemon, writePidFile, } from "./lifecycle.js";
22
+ import { autostartInstalled, installAutostart, LOG_FILE, reloadAutostart, removeAutostart, removePidFile, runningPid, startDetached, stopDaemon, writePidFile, } from "./lifecycle.js";
23
23
  import { newEvent } from "./events.js";
24
24
  import { proseLines } from "./prose.js";
25
25
  import { analyzeVoice } from "./stylometry.js";
@@ -50,6 +50,7 @@ Usage:
50
50
  persnallyd activity Context-read engagement over time (retention pulse)
51
51
  persnallyd start [--port N] Start the daemon in the background
52
52
  persnallyd stop Stop the background daemon
53
+ persnallyd restart Restart the daemon (correctly handles autostart/launchd)
53
54
  persnallyd serve [--port N] Run the daemon in the foreground (127.0.0.1:${DEFAULT_PORT})
54
55
  persnallyd autostart [--remove] Start the daemon at login and keep it alive (macOS)
55
56
  persnallyd config set-key <key> Store the Anthropic API key (owner-only file) for the daemon
@@ -171,13 +172,8 @@ async function main() {
171
172
  console.error(`· Context hook skipped: ${e instanceof Error ? e.message : e}`);
172
173
  }
173
174
  }
174
- console.log(`\nDone${imported ? ` — ${imported} events imported` : ""}. Dashboard: http://127.0.0.1:${port}`);
175
- if (process.platform === "darwin" && process.stdout.isTTY) {
176
- try {
177
- execFileSync("open", [`http://127.0.0.1:${port}`]);
178
- }
179
- catch { /* non-fatal */ }
180
- }
175
+ console.log(`\nDone${imported ? ` — ${imported} events imported` : ""}.`);
176
+ announceDashboard(port);
181
177
  return;
182
178
  }
183
179
  case "scope": {
@@ -483,19 +479,43 @@ async function main() {
483
479
  const existing = runningPid();
484
480
  if (existing)
485
481
  return die(`daemon already running (pid ${existing})`);
486
- const pid = await startDetached(process.argv[1], parsePort(args));
487
- console.log(`persnallyd started (pid ${pid}). Dashboard: http://127.0.0.1:${parsePort(args)}`);
482
+ const port = parsePort(args);
483
+ const pid = await startDetached(process.argv[1], port);
484
+ console.log(`persnallyd started (pid ${pid}).`);
485
+ announceDashboard(port);
488
486
  console.log(`Logs: ${LOG_FILE}`);
489
487
  return;
490
488
  }
491
489
  case "stop": {
492
490
  if (autostartInstalled()) {
493
- console.error("Note: autostart is installed — launchd will restart the daemon. Use `persnallyd autostart --remove` to stop it permanently.");
491
+ console.error("Note: autostart is installed — launchd will respawn the daemon. To restart cleanly use `persnallyd restart`; to stop it for good use `persnallyd autostart --remove`.");
494
492
  }
495
493
  const pid = await stopDaemon();
496
494
  console.log(pid ? `Stopped daemon (pid ${pid}).` : "Daemon was not running.");
497
495
  return;
498
496
  }
497
+ case "restart": {
498
+ const port = parsePort(args);
499
+ if (autostartInstalled()) {
500
+ // launchd owns the lifecycle — a plain stop just gets respawned. Reload the
501
+ // job so it comes back on the current install (also heals a drifted plist path).
502
+ const health = await reloadAutostart(process.argv[1], port);
503
+ if (health) {
504
+ console.log(`Restarted via launchd — daemon up on v${health.version}.`);
505
+ announceDashboard(port);
506
+ }
507
+ else {
508
+ console.log("Reloaded autostart; daemon is still coming up — check: persnallyd status");
509
+ }
510
+ }
511
+ else {
512
+ await stopDaemon();
513
+ const pid = await startDetached(process.argv[1], port);
514
+ console.log(`persnallyd restarted (pid ${pid}).`);
515
+ announceDashboard(port);
516
+ }
517
+ return;
518
+ }
499
519
  case "autostart": {
500
520
  if (args[0] === "--remove") {
501
521
  console.log(removeAutostart() ? "Autostart removed; daemon stopped." : "Autostart was not installed.");
@@ -507,6 +527,7 @@ async function main() {
507
527
  console.log(`Stopped existing daemon (pid ${stopped}) — launchd takes over.`);
508
528
  const plist = installAutostart(process.argv[1], parsePort(args));
509
529
  console.log(`Autostart installed (${plist}). The daemon now runs at login and restarts if it exits.`);
530
+ announceDashboard(parsePort(args), false); // launchd brings it up async — show the link, don't open a not-yet-ready page
510
531
  return;
511
532
  }
512
533
  case "serve": {
@@ -553,6 +574,17 @@ function summarize(payload) {
553
574
  const s = JSON.stringify(payload);
554
575
  return s.length > 80 ? s.slice(0, 77) + "..." : s;
555
576
  }
577
+ /** Print the dashboard URL and, when run interactively on macOS, open it. */
578
+ function announceDashboard(port, open = true) {
579
+ const url = `http://127.0.0.1:${port}`;
580
+ console.log(`Dashboard: ${url}`);
581
+ if (open && process.platform === "darwin" && process.stdout.isTTY) {
582
+ try {
583
+ execFileSync("open", [url]);
584
+ }
585
+ catch { /* non-fatal — the link is printed above */ }
586
+ }
587
+ }
556
588
  function die(msg) {
557
589
  console.error(msg);
558
590
  process.exit(1);
@@ -193,6 +193,32 @@
193
193
  .delta .sep { display: none; } /* stacked on phones — separators only read inline */
194
194
  }
195
195
  @media (prefers-reduced-motion: reduce) { .reveal { animation: none; opacity: 1; transform: none; } .read.fresh { animation: none; } }
196
+
197
+ /* ── hero intro (plain-language "what am I looking at") ── */
198
+ .hero-sub { font-size: 14.5px; color: var(--dim); margin-top: 14px; max-width: 64ch; line-height: 1.65; }
199
+ .hero-sub b { color: var(--text); font-weight: 600; }
200
+
201
+ /* ── shareable portrait card ── */
202
+ .modal { position: fixed; inset: 0; z-index: 50; display: none; align-items: center; justify-content: center;
203
+ padding: 24px; background: rgba(5,6,8,0.80); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); }
204
+ .modal.open { display: flex; }
205
+ .modal-inner { background: var(--panel); border: 1px solid var(--line-2); border-radius: 16px; width: 100%;
206
+ max-width: 860px; max-height: 92vh; overflow: auto; padding: 22px 24px; display: grid; gap: 20px; grid-template-columns: 1fr; }
207
+ @media (min-width: 760px) { .modal-inner { grid-template-columns: minmax(0,330px) 1fr; align-items: start; } }
208
+ .modal-head { grid-column: 1 / -1; display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
209
+ .modal-head h3 { font-size: 16px; font-weight: 600; }
210
+ .modal-head .x { background: none; border: none; color: var(--dim); font-size: 24px; cursor: pointer; line-height: 1; }
211
+ .modal-head .x:hover { color: var(--text); }
212
+ .card-preview { border-radius: 12px; overflow: hidden; border: 1px solid var(--line); background: #000; align-self: start; }
213
+ .card-preview canvas { display: block; width: 100%; height: auto; }
214
+ .card-ctrls { display: flex; flex-direction: column; gap: 18px; }
215
+ .card-ctrls .sub { font-size: 13px; color: var(--dim); line-height: 1.6; }
216
+ .toggles { display: flex; flex-direction: column; gap: 11px; }
217
+ .tog { display: flex; align-items: center; gap: 10px; font-size: 14px; color: var(--text); cursor: pointer; user-select: none; }
218
+ .tog input { width: 16px; height: 16px; accent-color: #FFFFFF; cursor: pointer; }
219
+ .card-actions { display: flex; gap: 10px; flex-wrap: wrap; }
220
+ .privacy-note { font-size: 11.5px; color: var(--faint); line-height: 1.65; }
221
+ .privacy-note b { color: var(--dim); }
196
222
  </style>
197
223
  </head>
198
224
  <body>
@@ -206,7 +232,8 @@
206
232
  <a class="btn ghost" href="https://github.com/sidpan2011/persnally" target="_blank" rel="noopener">GitHub <span class="arr">↗</span></a>
207
233
  <a class="btn ghost" href="https://persnally.com" target="_blank" rel="noopener">persnally.com <span class="arr">↗</span></a>
208
234
  <button class="btn ghost" id="reflect">Reflect</button>
209
- <button class="btn" id="synthesize">Re-synthesize</button>
235
+ <button class="btn ghost" id="synthesize">Re-synthesize</button>
236
+ <button class="btn" id="shareBtn">Share portrait <span class="arr" style="opacity:.75">✦</span></button>
210
237
  </div>
211
238
  </header>
212
239
 
@@ -215,6 +242,7 @@
215
242
  <div class="hero reveal" style="animation-delay:.05s">
216
243
  <div class="scale-beat" id="scaleBeat"></div>
217
244
  <div class="archetype" id="archetype">Reading your context…</div>
245
+ <div class="hero-sub">Built from <b>your own AI history</b> — your tools read this so they stop treating you like a stranger, and <b>every byte stays on your machine</b>.</div>
218
246
  <div class="gen-meta" id="genMeta"></div>
219
247
  </div>
220
248
 
@@ -234,7 +262,7 @@
234
262
  <div class="view-toggle"><button id="vGraph" class="on">map</button><button id="vList">list</button></div>
235
263
  </div>
236
264
  <div class="constellation-wrap">
237
- <div class="constellation" id="graphHost"><canvas id="graph"></canvas><div class="legend" id="legend"></div><div class="ghint">drag · pinch or scroll to zoom</div></div>
265
+ <div class="constellation" id="graphHost"><canvas id="graph"></canvas><div class="legend" id="legend"></div><div class="ghint">interests by strength · drag · zoom</div></div>
238
266
  <div class="card" id="topicList" style="display:none;padding:6px 14px"><table id="topics"></table></div>
239
267
  <div class="node-pop" id="nodePop"></div>
240
268
  </div>
@@ -262,6 +290,30 @@
262
290
  </div>
263
291
  <div class="preview-ribbon" id="ribbon">preview data — start <code>persnallyd</code> and reload to see your own</div>
264
292
 
293
+ <div class="modal" id="shareModal" aria-hidden="true">
294
+ <div class="modal-inner" role="dialog" aria-label="Share your portrait">
295
+ <div class="modal-head">
296
+ <h3>Share your portrait</h3>
297
+ <button class="x" id="shareClose" aria-label="Close">×</button>
298
+ </div>
299
+ <div class="card-preview"><canvas id="cardCanvas"></canvas></div>
300
+ <div class="card-ctrls">
301
+ <div class="sub">A snapshot of who your AI knows you to be. Pick what's on it — drawn here on your machine, never uploaded.</div>
302
+ <div class="toggles">
303
+ <label class="tog"><input type="checkbox" id="tHead" checked> Headline — your archetype</label>
304
+ <label class="tog"><input type="checkbox" id="tInt" checked> Top interests</label>
305
+ <label class="tog"><input type="checkbox" id="tVoice" checked> How you write</label>
306
+ <label class="tog"><input type="checkbox" id="tStats" checked> Stats</label>
307
+ </div>
308
+ <div class="card-actions">
309
+ <button class="btn" id="cardDownload">Download PNG</button>
310
+ <button class="btn ghost" id="cardCopy">Copy image</button>
311
+ </div>
312
+ <div class="privacy-note"><b>Nothing is uploaded</b> — the image is rendered locally and saved by you. The footer credits persnally.com so others can find it.</div>
313
+ </div>
314
+ </div>
315
+ </div>
316
+
265
317
  <script>
266
318
  "use strict";
267
319
  const $ = (id) => document.getElementById(id);
@@ -488,16 +540,37 @@ function renderMap(topics) {
488
540
  const resize = () => { w = host.clientWidth; h = window.innerWidth < 640 ? 380 : 520; canvas.width = w*dpr; canvas.height = h*dpr; canvas.style.height = h+"px"; };
489
541
  resize();
490
542
 
491
- const maxW = Math.max(...data.map(t=>t.weight), 0.01);
492
- const cats = [...new Set(data.map(t=>t.category))];
493
- const cx = w/2, cy = h/2;
494
- // each category gets an anchor on a ring → nodes cluster by color (readable + useful)
495
- const anchor = {};
496
- cats.forEach((c,i) => { const a=(i/cats.length)*Math.PI*2 - Math.PI/2; anchor[c]={ x:cx+Math.cos(a)*Math.min(w,h)*0.30, y:cy+Math.sin(a)*Math.min(w,h)*0.30 }; });
497
- const nodes = data.map((t,i) => { const an=anchor[t.category]; return { t, i, color:catColor(t.category),
498
- x:an.x+(Math.random()-0.5)*70, y:an.y+(Math.random()-0.5)*70, vx:0, vy:0, r:8+Math.sqrt(t.weight/maxW)*18 }; });
543
+ const INTERESTS = data.slice(0, 12);
544
+ const maxW = Math.max(...INTERESTS.map(t=>t.weight), 0.01);
545
+ const cx = w/2, cy = h/2, ring = Math.min(w,h)*0.27;
546
+ const lerp = (a,b,t)=>a+(b-a)*t;
547
+ const warmRGB = (t)=>[Math.round(lerp(255,200,t)),Math.round(lerp(198,140,t)),Math.round(lerp(120,70,t))]; // curated amber: bright gold (strongest) → bronze (lighter), one warm family
548
+ const rgba = (c,a)=>`rgba(${c[0]},${c[1]},${c[2]},${a})`;
549
+ const lighten = (c,a)=>[Math.min(255,c[0]+a),Math.min(255,c[1]+a),Math.min(255,c[2]+a)];
550
+ // radial portrait: YOU at the center, interests radiating by strength, their entities as leaf nodes
551
+ const center = { kind:"you", x:cx, y:cy, vx:0, vy:0, r:23, rgb:[255,209,130], fixed:true };
552
+ const interests = INTERESTS.map((t,i) => {
553
+ const ang = (i/INTERESTS.length)*Math.PI*2 - Math.PI/2;
554
+ return { kind:"topic", t, ang, x:cx+Math.cos(ang)*ring, y:cy+Math.sin(ang)*ring, vx:0, vy:0,
555
+ r:9+Math.sqrt(t.weight/maxW)*15, rgb:warmRGB(INTERESTS.length>1 ? i/(INTERESTS.length-1) : 0) };
556
+ });
557
+ const leaves = [];
558
+ interests.forEach((nd, ii) => {
559
+ (nd.t.entities||[]).slice(0,2).forEach((e,k,arr) => {
560
+ const a = nd.ang + (k-(arr.length-1)/2)*0.55;
561
+ leaves.push({ kind:"entity", label:e, x:cx+Math.cos(a)*ring*1.65, y:cy+Math.sin(a)*ring*1.65, vx:0, vy:0, r:4, rgb:[176,196,224], parent:1+ii });
562
+ });
563
+ });
564
+ const nodes = [center, ...interests, ...leaves];
565
+ const I0 = 1, L0 = 1 + interests.length;
499
566
  const edges = [];
500
- for (let i=0;i<nodes.length;i++) for (let j=i+1;j<nodes.length;j++) { const ei=nodes[i].t.entities||[], ej=nodes[j].t.entities||[]; const s=ei.filter(e=>ej.includes(e)).length; if (s) edges.push([i,j,s]); }
567
+ interests.forEach((_,ii) => edges.push([0, I0+ii, "trunk"]));
568
+ // synapses: interests that share an entity wire together → a neural web, not just a tree
569
+ for (let i=0;i<interests.length;i++) for (let j=i+1;j<interests.length;j++) {
570
+ const ei=interests[i].t.entities||[], ej=interests[j].t.entities||[];
571
+ if (ei.length && ej.length && ei.some(e=>ej.includes(e))) edges.push([I0+i, I0+j, "synapse"]);
572
+ }
573
+ leaves.forEach((lf,li) => edges.push([lf.parent, L0+li, "branch"]));
501
574
  const neighbors = nodes.map(()=>new Set());
502
575
  edges.forEach(([a,b])=>{ neighbors[a].add(b); neighbors[b].add(a); });
503
576
 
@@ -507,63 +580,87 @@ function renderMap(topics) {
507
580
 
508
581
  function tick() {
509
582
  let energy = 0;
510
- for (let i=0;i<nodes.length;i++) {
511
- const A=nodes[i]; if (i===dragNode) continue;
583
+ for (let i=1;i<nodes.length;i++) {
584
+ const A=nodes[i]; if (i===dragNode || A.fixed) continue;
512
585
  for (let j=0;j<nodes.length;j++) { if (i===j) continue; const B=nodes[j];
513
586
  let dx=A.x-B.x, dy=A.y-B.y, d2=dx*dx+dy*dy||1, d=Math.sqrt(d2);
514
- const rep=Math.min(6200/d2, 7); dx/=d; dy/=d; A.vx+=dx*rep; A.vy+=dy*rep;
515
- const minD=A.r+B.r+22; if (d<minD) { const p=(minD-d)/2; A.x+=dx*p; A.y+=dy*p; }
587
+ const rep=Math.min(5000/d2, 6); dx/=d; dy/=d; A.vx+=dx*rep; A.vy+=dy*rep;
588
+ const minD=A.r+B.r+14; if (d<minD) { const p=(minD-d)/2; A.x+=dx*p; A.y+=dy*p; }
516
589
  }
517
- const an=anchor[A.t.category]; A.vx+=(an.x-A.x)*0.014; A.vy+=(an.y-A.y)*0.014; // cluster pull
518
590
  }
519
- edges.forEach(([a,b]) => { const A=nodes[a],B=nodes[b]; let dx=B.x-A.x,dy=B.y-A.y,d=Math.hypot(dx,dy)||1; const f=(d-120)*0.008; dx/=d;dy/=d;
520
- if (a!==dragNode){A.vx+=dx*f;A.vy+=dy*f;} if (b!==dragNode){B.vx-=dx*f;B.vy-=dy*f;} });
521
- nodes.forEach((n,i) => { if (i===dragNode) return; n.vx*=0.85; n.vy*=0.85; n.x+=n.vx; n.y+=n.vy; energy+=n.vx*n.vx+n.vy*n.vy; });
591
+ interests.forEach((nd) => { if (nd===nodes[dragNode]) return; const tx=cx+Math.cos(nd.ang)*ring, ty=cy+Math.sin(nd.ang)*ring; nd.vx+=(tx-nd.x)*0.022; nd.vy+=(ty-nd.y)*0.022; });
592
+ leaves.forEach((lf) => { if (lf===nodes[dragNode]) return; const p=nodes[lf.parent]; const a=Math.atan2(p.y-cy,p.x-cx)||0; const tx=p.x+Math.cos(a)*44, ty=p.y+Math.sin(a)*44; lf.vx+=(tx-lf.x)*0.05; lf.vy+=(ty-lf.y)*0.05; });
593
+ for (let i=1;i<nodes.length;i++) { const n=nodes[i]; if (i===dragNode || n.fixed) continue; n.vx*=0.84; n.vy*=0.84; n.x+=n.vx; n.y+=n.vy; energy+=n.vx*n.vx+n.vy*n.vy; }
522
594
  return energy;
523
595
  }
524
- function draw() {
596
+ function draw(now) {
597
+ now = now || performance.now(); const T = now * 0.001;
525
598
  ctx.setTransform(dpr,0,0,dpr,0,0); ctx.clearRect(0,0,w,h);
526
- const vg=ctx.createRadialGradient(w/2,h*0.42,0,w/2,h*0.42,Math.max(w,h)*0.6); vg.addColorStop(0,"rgba(255,255,255,0.03)"); vg.addColorStop(1,"rgba(0,0,0,0)"); ctx.fillStyle=vg; ctx.fillRect(0,0,w,h);
599
+ // backdrop: faint warm core light, cool fall-off — depth, not flat black
600
+ const bg=ctx.createRadialGradient(w/2,h*0.46,0,w/2,h*0.46,Math.max(w,h)*0.72);
601
+ bg.addColorStop(0,"rgba(255,176,96,0.05)"); bg.addColorStop(0.55,"rgba(16,18,22,0)"); bg.addColorStop(1,"rgba(6,7,9,0.55)");
602
+ ctx.fillStyle=bg; ctx.fillRect(0,0,w,h);
603
+ // gentle perpetual drift — alive without bouncing; used everywhere so nothing desyncs
604
+ const amp = reduced?0:2.4;
605
+ const P = nodes.map((n,i)=>({ x:n.x+((n.fixed||i===dragNode)?0:Math.sin(T*0.5+i*1.1)*amp), y:n.y+((n.fixed||i===dragNode)?0:Math.cos(T*0.42+i*1.7)*amp) }));
527
606
  ctx.save(); ctx.translate(cam.x,cam.y); ctx.scale(cam.k,cam.k);
528
- // curved gradient edges
529
- edges.forEach(([a,b]) => { const A=nodes[a],B=nodes[b]; const active=hover===-1||hover===a||hover===b;
530
- let dx=B.x-A.x,dy=B.y-A.y,d=Math.hypot(dx,dy)||1; const off=d*0.12, mx=(A.x+B.x)/2+(-dy/d)*off, my=(A.y+B.y)/2+(dx/d)*off;
531
- const al = hover===-1 ? "33" : (active ? "aa" : "10");
532
- const g=ctx.createLinearGradient(A.x,A.y,B.x,B.y); g.addColorStop(0,A.color+al); g.addColorStop(1,B.color+al);
533
- ctx.strokeStyle=g; ctx.lineWidth=(active&&hover!==-1?1.8:1)/cam.k; ctx.beginPath(); ctx.moveTo(A.x,A.y); ctx.quadraticCurveTo(mx,my,B.x,B.y); ctx.stroke(); });
534
- // glowing nodes
535
- nodes.forEach((n,i) => { const active=hover===-1||hover===i||neighbors[hover].has(i); ctx.globalAlpha=active?1:0.18;
536
- const halo=ctx.createRadialGradient(n.x,n.y,0,n.x,n.y,n.r*2.6); halo.addColorStop(0,n.color+"bb"); halo.addColorStop(0.45,n.color+"33"); halo.addColorStop(1,n.color+"00");
537
- ctx.fillStyle=halo; ctx.beginPath(); ctx.arc(n.x,n.y,n.r*2.6,0,Math.PI*2); ctx.fill();
538
- ctx.fillStyle=n.color; ctx.shadowColor=n.color; ctx.shadowBlur=i===hover?22:9; ctx.beginPath(); ctx.arc(n.x,n.y,n.r,0,Math.PI*2); ctx.fill(); ctx.shadowBlur=0;
539
- ctx.fillStyle="rgba(255,255,255,0.9)"; ctx.beginPath(); ctx.arc(n.x-n.r*0.28,n.y-n.r*0.28,n.r*0.26,0,Math.PI*2); ctx.fill(); });
607
+ // connections thin, elegant; warm trunks, synapses, faint dendrites
608
+ edges.forEach(([a,b,kind]) => { const A=P[a],B=P[b]; const active=hover===-1||hover===a||hover===b||neighbors[hover].has(a)||neighbors[hover].has(b);
609
+ let dx=B.x-A.x,dy=B.y-A.y,d=Math.hypot(dx,dy)||1; const off=d*0.09, mx=(A.x+B.x)/2+(-dy/d)*off, my=(A.y+B.y)/2+(dx/d)*off;
610
+ if (kind==="trunk") { const al=hover===-1?0.4:(active?0.8:0.06); const g=ctx.createLinearGradient(A.x,A.y,B.x,B.y); g.addColorStop(0,rgba([255,150,80],al)); g.addColorStop(1,rgba(nodes[b].rgb,al*0.7)); ctx.strokeStyle=g; ctx.lineWidth=(active&&hover!==-1?2:1.1)/cam.k; }
611
+ else if (kind==="synapse") { const al=hover===-1?0.12:(active?0.42:0.03); const g=ctx.createLinearGradient(A.x,A.y,B.x,B.y); g.addColorStop(0,rgba(nodes[a].rgb,al)); g.addColorStop(1,rgba(nodes[b].rgb,al)); ctx.strokeStyle=g; ctx.lineWidth=0.9/cam.k; }
612
+ else { const al=hover===-1?0.16:(active?0.45:0.03); ctx.strokeStyle=rgba(nodes[a].rgb,al); ctx.lineWidth=0.8/cam.k; }
613
+ ctx.beginPath(); ctx.moveTo(A.x,A.y); ctx.quadraticCurveTo(mx,my,B.x,B.y); ctx.stroke(); });
614
+ // restrained, steady glow
615
+ ctx.globalCompositeOperation="lighter";
616
+ nodes.forEach((n,i) => { const active=hover===-1||hover===i||neighbors[hover].has(i); const k=active?1:0.1; const R=n.r*2.2;
617
+ const halo=ctx.createRadialGradient(P[i].x,P[i].y,0,P[i].x,P[i].y,R); halo.addColorStop(0,rgba(n.rgb,0.4*k)); halo.addColorStop(0.5,rgba(n.rgb,0.1*k)); halo.addColorStop(1,rgba(n.rgb,0));
618
+ ctx.fillStyle=halo; ctx.beginPath(); ctx.arc(P[i].x,P[i].y,R,0,Math.PI*2); ctx.fill(); });
619
+ ctx.globalCompositeOperation="source-over";
620
+ // refined cores — soft inner light + thin rim, no glossy bead
621
+ nodes.forEach((n,i) => { const active=hover===-1||hover===i||neighbors[hover].has(i); ctx.globalAlpha=active?1:0.22;
622
+ const x=P[i].x, y=P[i].y, r=n.r;
623
+ const cg=ctx.createRadialGradient(x-r*0.3,y-r*0.35,r*0.1,x,y,r); cg.addColorStop(0,rgba(lighten(n.rgb,45),1)); cg.addColorStop(1,rgba(n.rgb,1));
624
+ ctx.fillStyle=cg; ctx.beginPath(); ctx.arc(x,y,r,0,Math.PI*2); ctx.fill();
625
+ ctx.lineWidth=1/cam.k; ctx.strokeStyle=rgba(lighten(n.rgb,70),i===hover?0.9:0.4); ctx.stroke(); });
540
626
  ctx.globalAlpha=1; ctx.restore();
541
- // labels in screen space (constant size, collision-free, biggest first)
542
- ctx.font="600 12px "+FONT; ctx.textAlign="center"; const placed=[];
627
+ // labels clean, tracking the drifted node; entities only on hover
628
+ ctx.textAlign="center"; const placed=[];
543
629
  nodes.map((_,i)=>i).sort((a,b)=>nodes[b].r-nodes[a].r).forEach(i => { const n=nodes[i];
544
630
  const active = hover===-1 || hover===i || neighbors[hover].has(i);
545
- const force = i===hover || (hover!==-1 && neighbors[hover].has(i));
631
+ if (n.kind==="entity" && hover===-1) return;
546
632
  if (!active && hover!==-1) return;
547
- const sx=W2Sx(n.x), sy=W2Sy(n.y), sr=n.r*cam.k, tw=ctx.measureText(n.t.topic).width;
633
+ const label = n.kind==="you" ? "you" : (n.kind==="topic" ? n.t.topic : n.label);
634
+ ctx.font = (n.kind==="you" ? "600 12.5px " : "500 11.5px ") + FONT;
635
+ const sx=W2Sx(P[i].x), sy=W2Sy(P[i].y), sr=n.r*cam.k, tw=ctx.measureText(label).width;
636
+ const force = i===hover || n.kind==="you" || (hover!==-1 && neighbors[hover].has(i));
548
637
  const rect={x:sx-tw/2-3,y:sy+sr+3,w:tw+6,h:15};
549
638
  const hit=placed.some(p=>!(rect.x+rect.w<p.x||rect.x>p.x+p.w||rect.y+rect.h<p.y||rect.y>p.y+p.h));
550
639
  if (hit && !force) return; placed.push(rect);
551
- ctx.globalAlpha = hover===-1?0.92:(active?1:0.2); ctx.fillStyle=i===hover?"#FFFFFF":"#CFD2D8"; ctx.fillText(n.t.topic, sx, sy+sr+15);
640
+ ctx.globalAlpha = hover===-1?(n.kind==="you"?1:0.82):(active?1:0.2); ctx.fillStyle = (i===hover||n.kind==="you")?"#FFFFFF":"#C7CBD2"; ctx.fillText(label, sx, sy+sr+15);
552
641
  });
553
642
  ctx.globalAlpha=1;
554
643
  }
555
- function loop() { if (gen!==mapGen) return; const e=tick(); frames++; draw(); if ((e<0.02 && frames>40) || frames>360) alive=false; if (alive) requestAnimationFrame(loop); }
556
- if (reduced) { for (let s=0;s<200;s++) tick(); draw(); alive=false; } else requestAnimationFrame(loop);
557
- function wake() { if (!alive && gen===mapGen) { alive=true; frames=320; requestAnimationFrame(loop); } }
644
+ let physicsSettled=false;
645
+ function loop(now) { if (gen!==mapGen) return;
646
+ if (!physicsSettled) { const e=tick(); frames++; if ((e<0.02 && frames>40) || frames>360) physicsSettled=true; }
647
+ draw(now);
648
+ if (!reduced || !physicsSettled) requestAnimationFrame(loop); else alive=false; // keep firing once settled (paused automatically when the tab is hidden)
649
+ }
650
+ if (reduced) { for (let s=0;s<200;s++) tick(); physicsSettled=true; draw(performance.now()); alive=false; } else { alive=true; requestAnimationFrame(loop); }
651
+ function wake() { if (!alive && gen===mapGen) { alive=true; physicsSettled=false; frames=320; requestAnimationFrame(loop); } }
558
652
 
559
- $("legend").innerHTML = cats.map(c => `<span><i style="color:${catColor(c)}"></i><span style="color:var(--dim)">${esc(c)}</span></span>`).join("");
653
+ $("legend").innerHTML = `<span><i style="color:#FF9637"></i><span style="color:var(--dim)">you</span></span><span><i style="color:#FFB454"></i><span style="color:var(--dim)">strongest</span></span><span><i style="color:#6EAAFF"></i><span style="color:var(--dim)">lighter</span></span><span><i style="color:#BCD4FF"></i><span style="color:var(--dim)">the specifics</span></span>`;
560
654
 
561
655
  const pop=$("nodePop");
562
656
  const nodeAt=(mx,my)=>{ const wx=S2Wx(mx),wy=S2Wy(my); for (let i=nodes.length-1;i>=0;i--) if (Math.hypot(wx-nodes[i].x,wy-nodes[i].y)<=nodes[i].r+8/cam.k) return i; return -1; };
563
657
  const rel=(e)=>{ const r=canvas.getBoundingClientRect(); return [e.clientX-r.left, e.clientY-r.top]; };
564
- function showPop(n) { const p=nodes[n].t;
565
- pop.innerHTML=`<div class="npt">${esc(p.topic)}</div><div class="npm">${esc(p.category)} · ${esc(p.dominant_intent)} · weight ${p.weight.toFixed(2)} · ${p.signals}×</div>${(p.entities||[]).length?`<div class="npe">${esc(p.entities.slice(0,4).join(", "))}</div>`:""}`;
566
- pop.style.left=Math.min(W2Sx(nodes[n].x)+14,w-250)+"px"; pop.style.top=(W2Sy(nodes[n].y)+14)+"px"; pop.classList.add("show"); }
658
+ function showPop(n) { const nd=nodes[n];
659
+ if (nd.kind==="topic") { const p=nd.t;
660
+ pop.innerHTML=`<div class="npt">${esc(p.topic)}</div><div class="npm">${esc(p.category)} · ${esc(p.dominant_intent)} · weight ${p.weight.toFixed(2)} · ${p.signals}×</div>${(p.entities||[]).length?`<div class="npe">${esc(p.entities.slice(0,4).join(", "))}</div>`:""}`;
661
+ } else if (nd.kind==="entity") { pop.innerHTML=`<div class="npt">${esc(nd.label)}</div><div class="npm">a specific within ${esc((nodes[nd.parent].t||{}).topic||"this interest")}</div>`;
662
+ } else { pop.innerHTML=`<div class="npt">you</div><div class="npm">${interests.length} interests · ${leaves.length} specifics, sized by strength</div>`; }
663
+ pop.style.left=Math.min(W2Sx(nd.x)+14,w-250)+"px"; pop.style.top=(W2Sy(nd.y)+14)+"px"; pop.classList.add("show"); }
567
664
 
568
665
  // Pointer events unify mouse + touch + pen; a second pointer drives pinch-zoom, a tap inspects.
569
666
  const pts=new Map();
@@ -645,6 +742,7 @@ async function loadAll() {
645
742
  renderReads(reads, total);
646
743
  renderEngage(activity);
647
744
  renderReflections(assertions);
745
+ setCardData(profile, topics, voice, stats);
648
746
  saveSnapshot(topics);
649
747
  }
650
748
 
@@ -667,6 +765,146 @@ setInterval(async () => {
667
765
  if (activity) renderEngage(activity);
668
766
  }, 25000);
669
767
 
768
+ /* ── shareable portrait card (drawn locally on a canvas; nothing uploaded) ── */
769
+ let CARD = null;
770
+ const CARD_W = 1080, CARD_H = 1350;
771
+ function rrect(ctx, x, y, w, h, r) {
772
+ ctx.beginPath(); ctx.moveTo(x+r,y); ctx.arcTo(x+w,y,x+w,y+h,r); ctx.arcTo(x+w,y+h,x,y+h,r);
773
+ ctx.arcTo(x,y+h,x,y,r); ctx.arcTo(x,y,x+w,y,r); ctx.closePath();
774
+ }
775
+ function wrapLines(ctx, text, maxW) {
776
+ const out = []; let line = "";
777
+ for (const word of String(text).split(/\s+/)) {
778
+ const test = line ? line + " " + word : word;
779
+ if (ctx.measureText(test).width > maxW && line) { out.push(line); line = word; } else line = test;
780
+ }
781
+ if (line) out.push(line); return out;
782
+ }
783
+ function condenseVoice(voice) {
784
+ if (!voice || !voice.items) return [];
785
+ const picks = voice.items.filter((it) => ["voice","format","emphasis"].includes(it.dimension))
786
+ .sort((a,b) => (b.confidence||0) - (a.confidence||0))
787
+ .map((it) => it.pattern.split("—")[0].split(";")[0].split("(")[0].trim());
788
+ return [...new Set(picks)].slice(0, 4);
789
+ }
790
+ function setCardData(profile, topics, voice, stats) {
791
+ const s = stats || {};
792
+ CARD = {
793
+ headline: (profile && profile.headline) || "A portrait in progress.",
794
+ interests: (topics||[]).slice(0,6).map((t) => ({ label: t.topic, color: catColor(t.category) })),
795
+ voice: condenseVoice(voice),
796
+ signals: s.total || 0,
797
+ days: s.first ? Math.max(1, Math.round((new Date(s.last||Date.now()) - new Date(s.first)) / 86400000)) : 0,
798
+ };
799
+ }
800
+ function buildCard(canvas, opts) {
801
+ const c = CARD || { headline:"", interests:[], voice:[], signals:0, days:0 };
802
+ const dpr = 2, W = CARD_W, H = CARD_H, PAD = 84;
803
+ canvas.width = W*dpr; canvas.height = H*dpr; canvas.style.aspectRatio = (W/H).toFixed(4);
804
+ const ctx = canvas.getContext("2d"); ctx.setTransform(dpr,0,0,dpr,0,0);
805
+ ctx.textBaseline = "alphabetic"; ctx.textAlign = "left";
806
+ ctx.fillStyle = "#0B0C0E"; ctx.fillRect(0,0,W,H);
807
+ // ambient glows tinted by the user's own interest colors — on-brand, "looks good on camera"
808
+ const cols = c.interests.map((i) => i.color);
809
+ [[W*0.20,H*0.15,cols[0]||"#5EC8FF"],[W*0.86,H*0.30,cols[1]||"#FFB454"],[W*0.5,H*0.95,cols[2]||"#B388FF"]]
810
+ .forEach(([x,y,col]) => { const g=ctx.createRadialGradient(x,y,0,x,y,W*0.55); g.addColorStop(0,col+"26"); g.addColorStop(1,col+"00"); ctx.fillStyle=g; ctx.fillRect(0,0,W,H); });
811
+ ctx.strokeStyle = "rgba(255,255,255,0.10)"; ctx.lineWidth = 2; rrect(ctx,28,28,W-56,H-56,20); ctx.stroke();
812
+
813
+ let y = PAD + 6;
814
+ ctx.fillStyle = "#F1F2F4"; ctx.font = "600 30px "+FONT; ctx.fillText("persnally", PAD, y);
815
+ ctx.fillStyle = "#35D07F"; ctx.beginPath(); ctx.arc(PAD + ctx.measureText("persnally").width + 15, y-9, 5, 0, Math.PI*2); ctx.fill();
816
+ ctx.textAlign = "right"; ctx.fillStyle = "#9A9DA5"; ctx.font = "500 22px "+FONT; ctx.fillText("a portrait of me", W-PAD, y); ctx.textAlign = "left";
817
+ y += 50;
818
+
819
+ if (opts.head && c.headline) {
820
+ let fs = 54, lines;
821
+ do { ctx.font = "700 "+fs+"px "+FONT; lines = wrapLines(ctx, c.headline, W-PAD*2); fs -= 3; } while (lines.length > 4 && fs > 30);
822
+ fs += 3; ctx.font = "700 "+fs+"px "+FONT; ctx.fillStyle = "#FFFFFF";
823
+ const lh = fs*1.18;
824
+ for (const ln of lines.slice(0,4)) { y += lh; ctx.fillText(ln, PAD, y); }
825
+ y += 24;
826
+ }
827
+
828
+ if (opts.int && c.interests.length) {
829
+ y += 30; ctx.fillStyle = "#9A9DA5"; ctx.font = "600 19px "+FONT; ctx.fillText("WHAT I'M INTO", PAD, y); y += 36;
830
+ let x = PAD; const ch = 52, gap = 13; ctx.font = "600 24px "+FONT;
831
+ for (const it of c.interests) {
832
+ const cw = ctx.measureText(it.label).width + 50;
833
+ if (x + cw > W-PAD) { x = PAD; y += ch + gap; }
834
+ ctx.fillStyle = it.color+"22"; ctx.strokeStyle = it.color+"99"; ctx.lineWidth = 1.5; rrect(ctx,x,y,cw,ch,ch/2); ctx.fill(); ctx.stroke();
835
+ ctx.fillStyle = it.color; ctx.beginPath(); ctx.arc(x+22, y+ch/2, 5, 0, Math.PI*2); ctx.fill();
836
+ ctx.fillStyle = "#F1F2F4"; ctx.fillText(it.label, x+38, y+ch/2+8);
837
+ x += cw + gap;
838
+ }
839
+ y += ch + 26;
840
+ }
841
+
842
+ if (opts.voice && c.voice.length) {
843
+ y += 24; ctx.fillStyle = "#9A9DA5"; ctx.font = "600 19px "+FONT; ctx.fillText("HOW I WRITE", PAD, y); y += 38;
844
+ ctx.fillStyle = "#C7CACF"; ctx.font = "400 26px "+FONT;
845
+ for (const ln of wrapLines(ctx, c.voice.join(" · "), W-PAD*2).slice(0,2)) { ctx.fillText(ln, PAD, y); y += 38; }
846
+ }
847
+
848
+ // constellation — the brand's signature visual, drawn from the same interest colors;
849
+ // fills the lower band so the card reads full in every toggle state.
850
+ if (c.interests.length >= 2) {
851
+ const bandTop = y + 30, bandBottom = H - PAD - 104;
852
+ if (bandBottom - bandTop > 130) {
853
+ const cxb = W/2, cyb = (bandTop + bandBottom) / 2;
854
+ const spread = Math.min(W - PAD*2, bandBottom - bandTop) * 0.46;
855
+ const ns = c.interests.map((it, i) => {
856
+ const a = i * 2.39996323, rad = spread * Math.sqrt((i + 0.5) / c.interests.length);
857
+ return { x: cxb + Math.cos(a)*rad, y: cyb + Math.sin(a)*rad*0.62, r: 30 - i*3.2, color: it.color };
858
+ });
859
+ for (let i = 0; i < ns.length; i++) for (let j = i+1; j < ns.length; j++) {
860
+ const A = ns[i], B = ns[j], d = Math.hypot(A.x-B.x, A.y-B.y);
861
+ if (d > spread*1.05) continue;
862
+ const g = ctx.createLinearGradient(A.x,A.y,B.x,B.y); g.addColorStop(0,A.color+"40"); g.addColorStop(1,B.color+"40");
863
+ const off = d*0.14, mx = (A.x+B.x)/2 + (-(B.y-A.y)/d)*off, my = (A.y+B.y)/2 + ((B.x-A.x)/d)*off;
864
+ ctx.strokeStyle = g; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(A.x,A.y); ctx.quadraticCurveTo(mx,my,B.x,B.y); ctx.stroke();
865
+ }
866
+ for (const n of ns) {
867
+ const halo = ctx.createRadialGradient(n.x,n.y,0,n.x,n.y,n.r*2.8);
868
+ halo.addColorStop(0,n.color+"cc"); halo.addColorStop(0.45,n.color+"33"); halo.addColorStop(1,n.color+"00");
869
+ ctx.fillStyle = halo; ctx.beginPath(); ctx.arc(n.x,n.y,n.r*2.8,0,Math.PI*2); ctx.fill();
870
+ ctx.fillStyle = n.color; ctx.beginPath(); ctx.arc(n.x,n.y,n.r,0,Math.PI*2); ctx.fill();
871
+ ctx.fillStyle = "rgba(255,255,255,0.9)"; ctx.beginPath(); ctx.arc(n.x-n.r*0.3, n.y-n.r*0.3, n.r*0.28, 0, Math.PI*2); ctx.fill();
872
+ }
873
+ }
874
+ }
875
+
876
+ if (opts.stats) {
877
+ const parts = [];
878
+ if (c.signals) parts.push(fmtN(c.signals)+" signals");
879
+ if (c.days) parts.push(fmtN(c.days)+" days");
880
+ parts.push("100% on my machine");
881
+ ctx.fillStyle = "#62656D"; ctx.font = "500 22px "+FONT; ctx.fillText(parts.join(" · "), PAD, H-PAD-58);
882
+ }
883
+ ctx.fillStyle = "#9A9DA5"; ctx.font = "500 22px "+FONT; ctx.fillText("made with persnally — your own context engine", PAD, H-PAD);
884
+ ctx.textAlign = "right"; ctx.fillStyle = "#62656D"; ctx.fillText("persnally.com", W-PAD, H-PAD); ctx.textAlign = "left";
885
+ }
886
+ const _modal = $("shareModal");
887
+ function renderCard() { buildCard($("cardCanvas"), { head:$("tHead").checked, int:$("tInt").checked, voice:$("tVoice").checked, stats:$("tStats").checked }); }
888
+ function openShare() { renderCard(); _modal.classList.add("open"); _modal.setAttribute("aria-hidden","false"); }
889
+ function closeShare() { _modal.classList.remove("open"); _modal.setAttribute("aria-hidden","true"); }
890
+ $("shareBtn").onclick = openShare;
891
+ $("shareClose").onclick = closeShare;
892
+ _modal.addEventListener("click", (e) => { if (e.target === _modal) closeShare(); });
893
+ ["tHead","tInt","tVoice","tStats"].forEach((id) => $(id).addEventListener("change", renderCard));
894
+ addEventListener("keydown", (e) => { if (e.key === "Escape" && _modal.classList.contains("open")) closeShare(); });
895
+ $("cardDownload").onclick = () => $("cardCanvas").toBlob((b) => {
896
+ if (!b) return; const u = URL.createObjectURL(b), a = document.createElement("a");
897
+ a.href = u; a.download = "persnally-portrait.png"; a.click(); setTimeout(() => URL.revokeObjectURL(u), 1000);
898
+ }, "image/png");
899
+ $("cardCopy").onclick = async () => {
900
+ const btn = $("cardCopy");
901
+ try {
902
+ const b = await new Promise((r) => $("cardCanvas").toBlob(r, "image/png"));
903
+ await navigator.clipboard.write([new ClipboardItem({ "image/png": b })]);
904
+ btn.textContent = "Copied ✓"; setTimeout(() => btn.textContent = "Copy image", 1600);
905
+ } catch { btn.textContent = "Copy unsupported — Download"; setTimeout(() => btn.textContent = "Copy image", 2400); }
906
+ };
907
+
670
908
  loadAll();
671
909
 
672
910
  /* ── demo data ── */
@@ -12,3 +12,13 @@ export declare function stopDaemon(): Promise<number | null>;
12
12
  export declare function autostartInstalled(): boolean;
13
13
  export declare function installAutostart(cliPath: string, port: number): string;
14
14
  export declare function removeAutostart(): boolean;
15
+ /**
16
+ * Reload the launchd job so the daemon restarts on the currently-installed build.
17
+ * `unload` then `load` — a plain `load` can't replace an already-loaded job, which
18
+ * is how a plist path silently drifts from the running process. Rewriting from the
19
+ * caller's cliPath also heals that drift. Returns the new daemon's /health once it
20
+ * answers, or null if it didn't come up in time.
21
+ */
22
+ export declare function reloadAutostart(cliPath: string, port: number): Promise<{
23
+ version: string;
24
+ } | null>;
@@ -118,6 +118,27 @@ export function removeAutostart() {
118
118
  rmSync(PLIST_PATH);
119
119
  return true;
120
120
  }
121
+ /**
122
+ * Reload the launchd job so the daemon restarts on the currently-installed build.
123
+ * `unload` then `load` — a plain `load` can't replace an already-loaded job, which
124
+ * is how a plist path silently drifts from the running process. Rewriting from the
125
+ * caller's cliPath also heals that drift. Returns the new daemon's /health once it
126
+ * answers, or null if it didn't come up in time.
127
+ */
128
+ export async function reloadAutostart(cliPath, port) {
129
+ removeAutostart();
130
+ installAutostart(cliPath, port);
131
+ for (let i = 0; i < 30; i++) {
132
+ await sleep(100);
133
+ try {
134
+ const r = await fetch(`http://127.0.0.1:${port}/health`);
135
+ if (r.ok)
136
+ return (await r.json());
137
+ }
138
+ catch { /* launchd hasn't brought it up yet */ }
139
+ }
140
+ return null;
141
+ }
121
142
  function sleep(ms) {
122
143
  return new Promise((r) => setTimeout(r, ms));
123
144
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "persnally",
3
- "version": "2.5.2",
3
+ "version": "2.6.0",
4
4
  "license": "FSL-1.1-MIT",
5
5
  "description": "Your own context engine — local-first, across every AI. So every AI finally knows you.",
6
6
  "type": "module",