persnally 2.5.3 → 2.6.1

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.
@@ -5,7 +5,7 @@
5
5
  * and re-synthesizes the profile. Last-run state lives in config, not
6
6
  * the event log — it's operational state, not user data.
7
7
  */
8
- import type { ChosenExtractor } from "./llm.js";
8
+ import { type ChosenExtractor } from "./llm.js";
9
9
  import type { EventStore } from "./store.js";
10
10
  export declare const CONSOLIDATION_HOUR = 3;
11
11
  export interface ConsolidationResult {
@@ -8,6 +8,7 @@
8
8
  import { z } from "zod";
9
9
  import { loadConfig, saveConfig } from "./config.js";
10
10
  import { newEvent, PAYLOAD_SCHEMAS } from "./events.js";
11
+ import { chooseExtractor } from "./llm.js";
11
12
  import { synthesizeProfile } from "./profile.js";
12
13
  const ASSERTION_MIN_SIGNALS = 5;
13
14
  const PROFILE_MIN_SIGNALS = 10;
@@ -61,9 +62,12 @@ export async function runConsolidation(store, engine, now = new Date()) {
61
62
  store.rebuild(now.getTime());
62
63
  }
63
64
  }
65
+ // The profile is the centerpiece artifact — always synthesize it with the
66
+ // profile model, not the cheaper assertions engine passed in as `engine`.
64
67
  let profileRefreshed = false;
65
68
  if (engine && newSignals.length >= PROFILE_MIN_SIGNALS) {
66
- await synthesizeProfile(store, engine.extract, engine.model);
69
+ const profileEngine = await chooseExtractor("profile");
70
+ await synthesizeProfile(store, profileEngine.extract, profileEngine.model);
67
71
  profileRefreshed = true;
68
72
  }
69
73
  saveConfig({ last_consolidation: now.toISOString() });
@@ -4,12 +4,12 @@
4
4
  */
5
5
  import http from "node:http";
6
6
  import { readFileSync } from "node:fs";
7
- import { loadConfig } from "./config.js";
7
+ import { loadConfig, saveConfig } from "./config.js";
8
8
  import { runConsolidation, shouldRunNow } from "./consolidate.js";
9
9
  import { allowedCategories, loadScopes } from "./permissions.js";
10
10
  import { newEvent, validateEvent } from "./events.js";
11
11
  import { importNewClaudeCodeSessions } from "./importers/claude-code.js";
12
- import { chooseExtractor } from "./llm.js";
12
+ import { chooseExtractor, ollamaTags, pullOllamaModel, RECOMMENDED_LOCAL_MODEL } from "./llm.js";
13
13
  import { synthesizeProfile } from "./profile.js";
14
14
  export const DEFAULT_PORT = 4983;
15
15
  const MAX_BODY_BYTES = 25 * 1024 * 1024; // generous for import batches; bounds memory
@@ -17,6 +17,7 @@ const MAX_QUERY_LIMIT = 10_000; // ceiling for public ?limit= params
17
17
  // Single source of truth for the user-visible version: package.json.
18
18
  const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf-8"));
19
19
  export const VERSION = pkg.version;
20
+ let pull = { state: "idle", model: "", percent: 0, status: "", error: "" };
20
21
  export function startDaemon(store, port = DEFAULT_PORT) {
21
22
  const localHosts = [`127.0.0.1:${port}`, `localhost:${port}`];
22
23
  const server = http.createServer(async (req, res) => {
@@ -83,6 +84,52 @@ export function startDaemon(store, port = DEFAULT_PORT) {
83
84
  const engine = await chooseExtractor("extract").catch(() => null);
84
85
  return json(res, 200, await runConsolidation(store, engine));
85
86
  }
87
+ // Engine onboarding: status + live key-save + one-click local-model pull.
88
+ if (req.method === "GET" && url.pathname === "/engine") {
89
+ const tags = await ollamaTags();
90
+ const cfgKey = loadConfig().anthropic_api_key;
91
+ const key = process.env.ANTHROPIC_API_KEY || (typeof cfgKey === "string" ? cfgKey : "");
92
+ return json(res, 200, {
93
+ hasKey: key.startsWith("sk-ant-"),
94
+ keyMasked: key ? `${key.slice(0, 12)}…${key.slice(-4)}` : "",
95
+ hasProfile: !!store.getProfile(),
96
+ ollama: { reachable: tags !== null, models: tags ?? [], hasModel: (tags?.length ?? 0) > 0 },
97
+ recommended: RECOMMENDED_LOCAL_MODEL,
98
+ pull,
99
+ });
100
+ }
101
+ if (req.method === "POST" && url.pathname === "/engine/key") {
102
+ if (!(req.headers["content-type"] ?? "").includes("application/json")) {
103
+ return json(res, 415, { error: "Content-Type must be application/json" });
104
+ }
105
+ const body = (await readBody(req));
106
+ const key = typeof body.key === "string" ? body.key.trim() : "";
107
+ if (!key.startsWith("sk-ant-"))
108
+ return json(res, 400, { error: "expected an Anthropic key (sk-ant-…)" });
109
+ saveConfig({ anthropic_api_key: key });
110
+ process.env.ANTHROPIC_API_KEY = key; // apply to the running daemon — no restart needed
111
+ return json(res, 200, { ok: true, keyMasked: `${key.slice(0, 12)}…${key.slice(-4)}` });
112
+ }
113
+ if (req.method === "POST" && url.pathname === "/engine/pull") {
114
+ if (!(req.headers["content-type"] ?? "").includes("application/json")) {
115
+ return json(res, 415, { error: "Content-Type must be application/json" });
116
+ }
117
+ if (pull.state === "pulling")
118
+ return json(res, 200, { started: false, ...pull });
119
+ const body = (await readBody(req).catch(() => ({})));
120
+ const model = typeof body.model === "string" && body.model ? body.model : RECOMMENDED_LOCAL_MODEL;
121
+ if ((await ollamaTags()) === null) {
122
+ return json(res, 400, { error: "Ollama isn't running. Install it from ollama.com, then try again." });
123
+ }
124
+ pull = { state: "pulling", model, percent: 0, status: "starting", error: "" };
125
+ pullOllamaModel(model, (p) => { pull.percent = p.percent; pull.status = p.status; })
126
+ .then(() => { pull = { ...pull, state: "done", percent: 100, status: "ready" }; })
127
+ .catch((e) => { pull = { ...pull, state: "error", error: e instanceof Error ? e.message : String(e) }; });
128
+ return json(res, 200, { started: true, model });
129
+ }
130
+ if (req.method === "GET" && url.pathname === "/engine/pull") {
131
+ return json(res, 200, pull);
132
+ }
86
133
  if (req.method === "GET" && url.pathname === "/events") {
87
134
  const ids = url.searchParams.get("ids");
88
135
  if (ids)
@@ -4,6 +4,7 @@
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>Persnally — your context engine</title>
7
+ <link rel="icon" type="image/png" href="data:image/png;base64,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" />
7
8
  <style>
8
9
  :root {
9
10
  --bg: #0B0C0E; /* dark, default */
@@ -193,6 +194,32 @@
193
194
  .delta .sep { display: none; } /* stacked on phones — separators only read inline */
194
195
  }
195
196
  @media (prefers-reduced-motion: reduce) { .reveal { animation: none; opacity: 1; transform: none; } .read.fresh { animation: none; } }
197
+
198
+ /* ── hero intro (plain-language "what am I looking at") ── */
199
+ .hero-sub { font-size: 14.5px; color: var(--dim); margin-top: 14px; max-width: 64ch; line-height: 1.65; }
200
+ .hero-sub b { color: var(--text); font-weight: 600; }
201
+
202
+ /* ── shareable portrait card ── */
203
+ .modal { position: fixed; inset: 0; z-index: 50; display: none; align-items: center; justify-content: center;
204
+ padding: 24px; background: rgba(5,6,8,0.80); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); }
205
+ .modal.open { display: flex; }
206
+ .modal-inner { background: var(--panel); border: 1px solid var(--line-2); border-radius: 16px; width: 100%;
207
+ max-width: 860px; max-height: 92vh; overflow: auto; padding: 22px 24px; display: grid; gap: 20px; grid-template-columns: 1fr; }
208
+ @media (min-width: 760px) { .modal-inner { grid-template-columns: minmax(0,330px) 1fr; align-items: start; } }
209
+ .modal-head { grid-column: 1 / -1; display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
210
+ .modal-head h3 { font-size: 16px; font-weight: 600; }
211
+ .modal-head .x { background: none; border: none; color: var(--dim); font-size: 24px; cursor: pointer; line-height: 1; }
212
+ .modal-head .x:hover { color: var(--text); }
213
+ .card-preview { border-radius: 12px; overflow: hidden; border: 1px solid var(--line); background: #000; align-self: start; }
214
+ .card-preview canvas { display: block; width: 100%; height: auto; }
215
+ .card-ctrls { display: flex; flex-direction: column; gap: 18px; }
216
+ .card-ctrls .sub { font-size: 13px; color: var(--dim); line-height: 1.6; }
217
+ .toggles { display: flex; flex-direction: column; gap: 11px; }
218
+ .tog { display: flex; align-items: center; gap: 10px; font-size: 14px; color: var(--text); cursor: pointer; user-select: none; }
219
+ .tog input { width: 16px; height: 16px; accent-color: #FFFFFF; cursor: pointer; }
220
+ .card-actions { display: flex; gap: 10px; flex-wrap: wrap; }
221
+ .privacy-note { font-size: 11.5px; color: var(--faint); line-height: 1.65; }
222
+ .privacy-note b { color: var(--dim); }
196
223
  </style>
197
224
  </head>
198
225
  <body>
@@ -206,15 +233,28 @@
206
233
  <a class="btn ghost" href="https://github.com/sidpan2011/persnally" target="_blank" rel="noopener">GitHub <span class="arr">↗</span></a>
207
234
  <a class="btn ghost" href="https://persnally.com" target="_blank" rel="noopener">persnally.com <span class="arr">↗</span></a>
208
235
  <button class="btn ghost" id="reflect">Reflect</button>
209
- <button class="btn" id="synthesize">Re-synthesize</button>
236
+ <button class="btn ghost" id="synthesize">Re-synthesize</button>
237
+ <button class="btn" id="shareBtn">Share portrait <span class="arr" style="opacity:.75">✦</span></button>
210
238
  </div>
211
239
  </header>
212
240
 
213
241
  <div id="delta"></div>
214
242
 
243
+ <style>
244
+ .onboard{margin:0 0 22px}
245
+ .onboard .ob-card{border:1px solid #2c67ff55;box-shadow:0 0 0 1px #2c67ff14}
246
+ .onboard .ob-or{margin:16px 0 8px;color:#8b90a0;font-size:13px}
247
+ .onboard .ob-row{display:flex;gap:8px;max-width:540px}
248
+ .onboard .ob-bar{height:8px;border-radius:6px;background:#ffffff14;overflow:hidden;margin-top:10px}
249
+ .onboard .ob-fill{height:100%;width:0;background:#2c67ff;transition:width .35s ease}
250
+ .onboard input{flex:1;background:#0e1016;border:1px solid #1b1e26;border-radius:8px;color:#f5f6f8;padding:9px 12px;font:inherit;min-width:0}
251
+ </style>
252
+ <div id="onboard" class="onboard" style="display:none"></div>
253
+
215
254
  <div class="hero reveal" style="animation-delay:.05s">
216
255
  <div class="scale-beat" id="scaleBeat"></div>
217
256
  <div class="archetype" id="archetype">Reading your context…</div>
257
+ <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
258
  <div class="gen-meta" id="genMeta"></div>
219
259
  </div>
220
260
 
@@ -234,7 +274,7 @@
234
274
  <div class="view-toggle"><button id="vGraph" class="on">map</button><button id="vList">list</button></div>
235
275
  </div>
236
276
  <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>
277
+ <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
278
  <div class="card" id="topicList" style="display:none;padding:6px 14px"><table id="topics"></table></div>
239
279
  <div class="node-pop" id="nodePop"></div>
240
280
  </div>
@@ -262,6 +302,30 @@
262
302
  </div>
263
303
  <div class="preview-ribbon" id="ribbon">preview data — start <code>persnallyd</code> and reload to see your own</div>
264
304
 
305
+ <div class="modal" id="shareModal" aria-hidden="true">
306
+ <div class="modal-inner" role="dialog" aria-label="Share your portrait">
307
+ <div class="modal-head">
308
+ <h3>Share your portrait</h3>
309
+ <button class="x" id="shareClose" aria-label="Close">×</button>
310
+ </div>
311
+ <div class="card-preview"><canvas id="cardCanvas"></canvas></div>
312
+ <div class="card-ctrls">
313
+ <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>
314
+ <div class="toggles">
315
+ <label class="tog"><input type="checkbox" id="tHead" checked> Headline — your archetype</label>
316
+ <label class="tog"><input type="checkbox" id="tInt" checked> Top interests</label>
317
+ <label class="tog"><input type="checkbox" id="tVoice" checked> How you write</label>
318
+ <label class="tog"><input type="checkbox" id="tStats" checked> Stats</label>
319
+ </div>
320
+ <div class="card-actions">
321
+ <button class="btn" id="cardDownload">Download PNG</button>
322
+ <button class="btn ghost" id="cardCopy">Copy image</button>
323
+ </div>
324
+ <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>
325
+ </div>
326
+ </div>
327
+ </div>
328
+
265
329
  <script>
266
330
  "use strict";
267
331
  const $ = (id) => document.getElementById(id);
@@ -488,16 +552,37 @@ function renderMap(topics) {
488
552
  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
553
  resize();
490
554
 
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 }; });
555
+ const INTERESTS = data.slice(0, 12);
556
+ const maxW = Math.max(...INTERESTS.map(t=>t.weight), 0.01);
557
+ const cx = w/2, cy = h/2, ring = Math.min(w,h)*0.27;
558
+ const lerp = (a,b,t)=>a+(b-a)*t;
559
+ 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
560
+ const rgba = (c,a)=>`rgba(${c[0]},${c[1]},${c[2]},${a})`;
561
+ const lighten = (c,a)=>[Math.min(255,c[0]+a),Math.min(255,c[1]+a),Math.min(255,c[2]+a)];
562
+ // radial portrait: YOU at the center, interests radiating by strength, their entities as leaf nodes
563
+ const center = { kind:"you", x:cx, y:cy, vx:0, vy:0, r:23, rgb:[255,209,130], fixed:true };
564
+ const interests = INTERESTS.map((t,i) => {
565
+ const ang = (i/INTERESTS.length)*Math.PI*2 - Math.PI/2;
566
+ return { kind:"topic", t, ang, x:cx+Math.cos(ang)*ring, y:cy+Math.sin(ang)*ring, vx:0, vy:0,
567
+ r:9+Math.sqrt(t.weight/maxW)*15, rgb:warmRGB(INTERESTS.length>1 ? i/(INTERESTS.length-1) : 0) };
568
+ });
569
+ const leaves = [];
570
+ interests.forEach((nd, ii) => {
571
+ (nd.t.entities||[]).slice(0,2).forEach((e,k,arr) => {
572
+ const a = nd.ang + (k-(arr.length-1)/2)*0.55;
573
+ 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 });
574
+ });
575
+ });
576
+ const nodes = [center, ...interests, ...leaves];
577
+ const I0 = 1, L0 = 1 + interests.length;
499
578
  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]); }
579
+ interests.forEach((_,ii) => edges.push([0, I0+ii, "trunk"]));
580
+ // synapses: interests that share an entity wire together → a neural web, not just a tree
581
+ for (let i=0;i<interests.length;i++) for (let j=i+1;j<interests.length;j++) {
582
+ const ei=interests[i].t.entities||[], ej=interests[j].t.entities||[];
583
+ if (ei.length && ej.length && ei.some(e=>ej.includes(e))) edges.push([I0+i, I0+j, "synapse"]);
584
+ }
585
+ leaves.forEach((lf,li) => edges.push([lf.parent, L0+li, "branch"]));
501
586
  const neighbors = nodes.map(()=>new Set());
502
587
  edges.forEach(([a,b])=>{ neighbors[a].add(b); neighbors[b].add(a); });
503
588
 
@@ -507,63 +592,87 @@ function renderMap(topics) {
507
592
 
508
593
  function tick() {
509
594
  let energy = 0;
510
- for (let i=0;i<nodes.length;i++) {
511
- const A=nodes[i]; if (i===dragNode) continue;
595
+ for (let i=1;i<nodes.length;i++) {
596
+ const A=nodes[i]; if (i===dragNode || A.fixed) continue;
512
597
  for (let j=0;j<nodes.length;j++) { if (i===j) continue; const B=nodes[j];
513
598
  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; }
599
+ const rep=Math.min(5000/d2, 6); dx/=d; dy/=d; A.vx+=dx*rep; A.vy+=dy*rep;
600
+ const minD=A.r+B.r+14; if (d<minD) { const p=(minD-d)/2; A.x+=dx*p; A.y+=dy*p; }
516
601
  }
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
602
  }
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; });
603
+ 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; });
604
+ 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; });
605
+ 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
606
  return energy;
523
607
  }
524
- function draw() {
608
+ function draw(now) {
609
+ now = now || performance.now(); const T = now * 0.001;
525
610
  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);
611
+ // backdrop: faint warm core light, cool fall-off — depth, not flat black
612
+ const bg=ctx.createRadialGradient(w/2,h*0.46,0,w/2,h*0.46,Math.max(w,h)*0.72);
613
+ 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)");
614
+ ctx.fillStyle=bg; ctx.fillRect(0,0,w,h);
615
+ // gentle perpetual drift — alive without bouncing; used everywhere so nothing desyncs
616
+ const amp = reduced?0:2.4;
617
+ 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
618
  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(); });
619
+ // connections thin, elegant; warm trunks, synapses, faint dendrites
620
+ 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);
621
+ 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;
622
+ 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; }
623
+ 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; }
624
+ 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; }
625
+ ctx.beginPath(); ctx.moveTo(A.x,A.y); ctx.quadraticCurveTo(mx,my,B.x,B.y); ctx.stroke(); });
626
+ // restrained, steady glow
627
+ ctx.globalCompositeOperation="lighter";
628
+ 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;
629
+ 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));
630
+ ctx.fillStyle=halo; ctx.beginPath(); ctx.arc(P[i].x,P[i].y,R,0,Math.PI*2); ctx.fill(); });
631
+ ctx.globalCompositeOperation="source-over";
632
+ // refined cores — soft inner light + thin rim, no glossy bead
633
+ nodes.forEach((n,i) => { const active=hover===-1||hover===i||neighbors[hover].has(i); ctx.globalAlpha=active?1:0.22;
634
+ const x=P[i].x, y=P[i].y, r=n.r;
635
+ 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));
636
+ ctx.fillStyle=cg; ctx.beginPath(); ctx.arc(x,y,r,0,Math.PI*2); ctx.fill();
637
+ ctx.lineWidth=1/cam.k; ctx.strokeStyle=rgba(lighten(n.rgb,70),i===hover?0.9:0.4); ctx.stroke(); });
540
638
  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=[];
639
+ // labels clean, tracking the drifted node; entities only on hover
640
+ ctx.textAlign="center"; const placed=[];
543
641
  nodes.map((_,i)=>i).sort((a,b)=>nodes[b].r-nodes[a].r).forEach(i => { const n=nodes[i];
544
642
  const active = hover===-1 || hover===i || neighbors[hover].has(i);
545
- const force = i===hover || (hover!==-1 && neighbors[hover].has(i));
643
+ if (n.kind==="entity" && hover===-1) return;
546
644
  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;
645
+ const label = n.kind==="you" ? "you" : (n.kind==="topic" ? n.t.topic : n.label);
646
+ ctx.font = (n.kind==="you" ? "600 12.5px " : "500 11.5px ") + FONT;
647
+ const sx=W2Sx(P[i].x), sy=W2Sy(P[i].y), sr=n.r*cam.k, tw=ctx.measureText(label).width;
648
+ const force = i===hover || n.kind==="you" || (hover!==-1 && neighbors[hover].has(i));
548
649
  const rect={x:sx-tw/2-3,y:sy+sr+3,w:tw+6,h:15};
549
650
  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
651
  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);
652
+ 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
653
  });
553
654
  ctx.globalAlpha=1;
554
655
  }
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); } }
656
+ let physicsSettled=false;
657
+ function loop(now) { if (gen!==mapGen) return;
658
+ if (!physicsSettled) { const e=tick(); frames++; if ((e<0.02 && frames>40) || frames>360) physicsSettled=true; }
659
+ draw(now);
660
+ if (!reduced || !physicsSettled) requestAnimationFrame(loop); else alive=false; // keep firing once settled (paused automatically when the tab is hidden)
661
+ }
662
+ if (reduced) { for (let s=0;s<200;s++) tick(); physicsSettled=true; draw(performance.now()); alive=false; } else { alive=true; requestAnimationFrame(loop); }
663
+ function wake() { if (!alive && gen===mapGen) { alive=true; physicsSettled=false; frames=320; requestAnimationFrame(loop); } }
558
664
 
559
- $("legend").innerHTML = cats.map(c => `<span><i style="color:${catColor(c)}"></i><span style="color:var(--dim)">${esc(c)}</span></span>`).join("");
665
+ $("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
666
 
561
667
  const pop=$("nodePop");
562
668
  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
669
  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"); }
670
+ function showPop(n) { const nd=nodes[n];
671
+ if (nd.kind==="topic") { const p=nd.t;
672
+ 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>`:""}`;
673
+ } 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>`;
674
+ } else { pop.innerHTML=`<div class="npt">you</div><div class="npm">${interests.length} interests · ${leaves.length} specifics, sized by strength</div>`; }
675
+ 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
676
 
568
677
  // Pointer events unify mouse + touch + pen; a second pointer drives pinch-zoom, a tap inspects.
569
678
  const pts=new Map();
@@ -617,6 +726,62 @@ function switchView(v) {
617
726
  $("vGraph").onclick = () => switchView("map");
618
727
  $("vList").onclick = () => switchView("list");
619
728
 
729
+ /* ── engine onboarding (local-first: Ollama one-click, key optional) ── */
730
+ function renderOnboard(engine, profile) {
731
+ const el = $("onboard");
732
+ // Show only when there's no engine at all and no portrait yet — once either
733
+ // exists the normal dashboard (and the Re-synthesize button) take over.
734
+ const ready = engine && (engine.hasKey || (engine.ollama && engine.ollama.hasModel));
735
+ if (DEMO || !engine || ready || profile) { el.style.display = "none"; el.innerHTML = ""; return; }
736
+ el.style.display = "";
737
+ const oll = engine.ollama || { reachable:false };
738
+ const local = oll.reachable
739
+ ? `<button class="btn" id="obPull">Set up local AI — download model (~2GB)</button>
740
+ <div class="sub" style="margin-top:6px">Runs entirely on your machine. Free. Nothing leaves your computer.</div>
741
+ <div id="obProg" style="display:none"><div class="ob-bar"><div class="ob-fill" id="obFill"></div></div><div class="sub" id="obStat" style="margin-top:6px"></div></div>`
742
+ : `<a class="btn" href="https://ollama.com/download" target="_blank" rel="noopener">Install Ollama — local &amp; free <span class="arr">↗</span></a>
743
+ <div class="sub" style="margin-top:6px">One install and your data stays on this machine. <button class="link-btn" id="obRecheck">Recheck</button> once it's running.</div>`;
744
+ el.innerHTML = `<div class="card ob-card">
745
+ <div class="claim-title">Set up your AI engine to see your portrait</div>
746
+ <div class="sub" style="margin:4px 0 14px">Persnally needs a model to read your history and write your self-portrait. Choose local (private, free) — or bring your own key.</div>
747
+ <div style="display:flex;flex-direction:column;gap:2px;max-width:540px">${local}</div>
748
+ <div class="ob-or">— or use your own Anthropic key —</div>
749
+ <div class="ob-row"><input id="obKey" type="password" placeholder="sk-ant-…" autocomplete="off" spellcheck="false"><button class="btn ghost" id="obSaveKey">Save &amp; use</button></div>
750
+ <div class="sub" id="obKeyMsg" style="margin-top:6px"></div>
751
+ <div class="sub" style="margin-top:14px">No model yet? You can still import code offline — <code>persnallyd import git &lt;path&gt;</code> (no AI needed).</div>
752
+ </div>`;
753
+ if (oll.reachable) $("obPull").onclick = () => startPull(engine.recommended);
754
+ else $("obRecheck").onclick = () => loadAll();
755
+ $("obSaveKey").onclick = saveKey;
756
+ $("obKey").addEventListener("keydown", (e) => { if (e.key === "Enter") saveKey(); });
757
+ }
758
+
759
+ async function startPull(model) {
760
+ $("obProg").style.display = ""; $("obPull").disabled = true;
761
+ $("obStat").textContent = "Starting…";
762
+ const r = await fetch("/engine/pull", { method:"POST", headers:{ "content-type":"application/json" }, body: JSON.stringify({ model }) });
763
+ if (!r.ok) { const e = await r.json().catch(()=>({})); $("obStat").textContent = e.error || ("Couldn't start (" + r.status + ")"); $("obPull").disabled=false; return; }
764
+ (async function poll() {
765
+ const p = await get("/engine/pull");
766
+ if (!p) return void setTimeout(poll, 1500);
767
+ $("obFill").style.width = (p.percent||0) + "%";
768
+ if (p.state === "pulling") { $("obStat").textContent = (p.status||"downloading") + " · " + (p.percent||0) + "%"; return void setTimeout(poll, 1500); }
769
+ if (p.state === "error") { $("obStat").textContent = "Failed: " + (p.error||"unknown"); $("obPull").disabled=false; return; }
770
+ if (p.state === "done") { $("obStat").textContent = "Model ready — writing your portrait…"; await fetch("/synthesize", { method:"POST" }); return void loadAll(); }
771
+ setTimeout(poll, 1500);
772
+ })();
773
+ }
774
+
775
+ async function saveKey() {
776
+ const key = $("obKey").value.trim();
777
+ if (!key.startsWith("sk-ant-")) { $("obKeyMsg").textContent = "Enter a key starting with sk-ant-"; return; }
778
+ $("obSaveKey").disabled = true; $("obKeyMsg").textContent = "Saving…";
779
+ const r = await fetch("/engine/key", { method:"POST", headers:{ "content-type":"application/json" }, body: JSON.stringify({ key }) });
780
+ if (!r.ok) { const e = await r.json().catch(()=>({})); $("obKeyMsg").textContent = e.error || ("Failed (" + r.status + ")"); $("obSaveKey").disabled=false; return; }
781
+ $("obKeyMsg").textContent = "Saved — writing your portrait…";
782
+ await fetch("/synthesize", { method:"POST" }); loadAll();
783
+ }
784
+
620
785
  /* ── load ── */
621
786
  async function loadAll() {
622
787
  let stats, profile, topics, reads, assertions, voice, activity;
@@ -631,6 +796,7 @@ async function loadAll() {
631
796
  ({ stats, profile, topics, reads, assertions, voice, activity } = DEMO_DATA());
632
797
  }
633
798
  topics = topics||[]; reads = reads||[]; assertions = assertions||[];
799
+ const engine = DEMO ? null : await get("/engine");
634
800
  const st = $("status");
635
801
  st.querySelector(".dot").classList.toggle("off", DEMO);
636
802
  st.querySelector(".lbl").textContent = DEMO ? "preview" : "active";
@@ -639,19 +805,22 @@ async function loadAll() {
639
805
  renderDelta(topics, reads, assertions);
640
806
  renderScaleBeat(stats);
641
807
  renderPortrait(profile);
808
+ renderOnboard(engine, profile);
642
809
  renderVoice(voice);
643
810
  renderTopicList(topics);
644
811
  renderMap(topics);
645
812
  renderReads(reads, total);
646
813
  renderEngage(activity);
647
814
  renderReflections(assertions);
815
+ setCardData(profile, topics, voice, stats);
648
816
  saveSnapshot(topics);
649
817
  }
650
818
 
651
819
  $("synthesize").onclick = async () => {
652
820
  const b = $("synthesize"); if (DEMO) return alert("Preview mode — start persnallyd to synthesize for real.");
653
821
  b.disabled = true; b.textContent = "Synthesizing…";
654
- const r = await fetch("/synthesize", { method:"POST" }); if (!r.ok) alert("Synthesis failed — check the daemon log.");
822
+ const r = await fetch("/synthesize", { method:"POST" });
823
+ if (!r.ok) { const e = await r.json().catch(()=>({})); alert(e.error || "Synthesis failed — check the daemon log."); }
655
824
  b.disabled = false; b.textContent = "Re-synthesize"; loadAll();
656
825
  };
657
826
  $("reflect").onclick = async () => {
@@ -667,6 +836,146 @@ setInterval(async () => {
667
836
  if (activity) renderEngage(activity);
668
837
  }, 25000);
669
838
 
839
+ /* ── shareable portrait card (drawn locally on a canvas; nothing uploaded) ── */
840
+ let CARD = null;
841
+ const CARD_W = 1080, CARD_H = 1350;
842
+ function rrect(ctx, x, y, w, h, r) {
843
+ 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);
844
+ ctx.arcTo(x,y+h,x,y,r); ctx.arcTo(x,y,x+w,y,r); ctx.closePath();
845
+ }
846
+ function wrapLines(ctx, text, maxW) {
847
+ const out = []; let line = "";
848
+ for (const word of String(text).split(/\s+/)) {
849
+ const test = line ? line + " " + word : word;
850
+ if (ctx.measureText(test).width > maxW && line) { out.push(line); line = word; } else line = test;
851
+ }
852
+ if (line) out.push(line); return out;
853
+ }
854
+ function condenseVoice(voice) {
855
+ if (!voice || !voice.items) return [];
856
+ const picks = voice.items.filter((it) => ["voice","format","emphasis"].includes(it.dimension))
857
+ .sort((a,b) => (b.confidence||0) - (a.confidence||0))
858
+ .map((it) => it.pattern.split("—")[0].split(";")[0].split("(")[0].trim());
859
+ return [...new Set(picks)].slice(0, 4);
860
+ }
861
+ function setCardData(profile, topics, voice, stats) {
862
+ const s = stats || {};
863
+ CARD = {
864
+ headline: (profile && profile.headline) || "A portrait in progress.",
865
+ interests: (topics||[]).slice(0,6).map((t) => ({ label: t.topic, color: catColor(t.category) })),
866
+ voice: condenseVoice(voice),
867
+ signals: s.total || 0,
868
+ days: s.first ? Math.max(1, Math.round((new Date(s.last||Date.now()) - new Date(s.first)) / 86400000)) : 0,
869
+ };
870
+ }
871
+ function buildCard(canvas, opts) {
872
+ const c = CARD || { headline:"", interests:[], voice:[], signals:0, days:0 };
873
+ const dpr = 2, W = CARD_W, H = CARD_H, PAD = 84;
874
+ canvas.width = W*dpr; canvas.height = H*dpr; canvas.style.aspectRatio = (W/H).toFixed(4);
875
+ const ctx = canvas.getContext("2d"); ctx.setTransform(dpr,0,0,dpr,0,0);
876
+ ctx.textBaseline = "alphabetic"; ctx.textAlign = "left";
877
+ ctx.fillStyle = "#0B0C0E"; ctx.fillRect(0,0,W,H);
878
+ // ambient glows tinted by the user's own interest colors — on-brand, "looks good on camera"
879
+ const cols = c.interests.map((i) => i.color);
880
+ [[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"]]
881
+ .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); });
882
+ ctx.strokeStyle = "rgba(255,255,255,0.10)"; ctx.lineWidth = 2; rrect(ctx,28,28,W-56,H-56,20); ctx.stroke();
883
+
884
+ let y = PAD + 6;
885
+ ctx.fillStyle = "#F1F2F4"; ctx.font = "600 30px "+FONT; ctx.fillText("persnally", PAD, y);
886
+ ctx.fillStyle = "#35D07F"; ctx.beginPath(); ctx.arc(PAD + ctx.measureText("persnally").width + 15, y-9, 5, 0, Math.PI*2); ctx.fill();
887
+ ctx.textAlign = "right"; ctx.fillStyle = "#9A9DA5"; ctx.font = "500 22px "+FONT; ctx.fillText("a portrait of me", W-PAD, y); ctx.textAlign = "left";
888
+ y += 50;
889
+
890
+ if (opts.head && c.headline) {
891
+ let fs = 54, lines;
892
+ do { ctx.font = "700 "+fs+"px "+FONT; lines = wrapLines(ctx, c.headline, W-PAD*2); fs -= 3; } while (lines.length > 4 && fs > 30);
893
+ fs += 3; ctx.font = "700 "+fs+"px "+FONT; ctx.fillStyle = "#FFFFFF";
894
+ const lh = fs*1.18;
895
+ for (const ln of lines.slice(0,4)) { y += lh; ctx.fillText(ln, PAD, y); }
896
+ y += 24;
897
+ }
898
+
899
+ if (opts.int && c.interests.length) {
900
+ y += 30; ctx.fillStyle = "#9A9DA5"; ctx.font = "600 19px "+FONT; ctx.fillText("WHAT I'M INTO", PAD, y); y += 36;
901
+ let x = PAD; const ch = 52, gap = 13; ctx.font = "600 24px "+FONT;
902
+ for (const it of c.interests) {
903
+ const cw = ctx.measureText(it.label).width + 50;
904
+ if (x + cw > W-PAD) { x = PAD; y += ch + gap; }
905
+ 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();
906
+ ctx.fillStyle = it.color; ctx.beginPath(); ctx.arc(x+22, y+ch/2, 5, 0, Math.PI*2); ctx.fill();
907
+ ctx.fillStyle = "#F1F2F4"; ctx.fillText(it.label, x+38, y+ch/2+8);
908
+ x += cw + gap;
909
+ }
910
+ y += ch + 26;
911
+ }
912
+
913
+ if (opts.voice && c.voice.length) {
914
+ y += 24; ctx.fillStyle = "#9A9DA5"; ctx.font = "600 19px "+FONT; ctx.fillText("HOW I WRITE", PAD, y); y += 38;
915
+ ctx.fillStyle = "#C7CACF"; ctx.font = "400 26px "+FONT;
916
+ for (const ln of wrapLines(ctx, c.voice.join(" · "), W-PAD*2).slice(0,2)) { ctx.fillText(ln, PAD, y); y += 38; }
917
+ }
918
+
919
+ // constellation — the brand's signature visual, drawn from the same interest colors;
920
+ // fills the lower band so the card reads full in every toggle state.
921
+ if (c.interests.length >= 2) {
922
+ const bandTop = y + 30, bandBottom = H - PAD - 104;
923
+ if (bandBottom - bandTop > 130) {
924
+ const cxb = W/2, cyb = (bandTop + bandBottom) / 2;
925
+ const spread = Math.min(W - PAD*2, bandBottom - bandTop) * 0.46;
926
+ const ns = c.interests.map((it, i) => {
927
+ const a = i * 2.39996323, rad = spread * Math.sqrt((i + 0.5) / c.interests.length);
928
+ return { x: cxb + Math.cos(a)*rad, y: cyb + Math.sin(a)*rad*0.62, r: 30 - i*3.2, color: it.color };
929
+ });
930
+ for (let i = 0; i < ns.length; i++) for (let j = i+1; j < ns.length; j++) {
931
+ const A = ns[i], B = ns[j], d = Math.hypot(A.x-B.x, A.y-B.y);
932
+ if (d > spread*1.05) continue;
933
+ const g = ctx.createLinearGradient(A.x,A.y,B.x,B.y); g.addColorStop(0,A.color+"40"); g.addColorStop(1,B.color+"40");
934
+ 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;
935
+ 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();
936
+ }
937
+ for (const n of ns) {
938
+ const halo = ctx.createRadialGradient(n.x,n.y,0,n.x,n.y,n.r*2.8);
939
+ halo.addColorStop(0,n.color+"cc"); halo.addColorStop(0.45,n.color+"33"); halo.addColorStop(1,n.color+"00");
940
+ ctx.fillStyle = halo; ctx.beginPath(); ctx.arc(n.x,n.y,n.r*2.8,0,Math.PI*2); ctx.fill();
941
+ ctx.fillStyle = n.color; ctx.beginPath(); ctx.arc(n.x,n.y,n.r,0,Math.PI*2); ctx.fill();
942
+ 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();
943
+ }
944
+ }
945
+ }
946
+
947
+ if (opts.stats) {
948
+ const parts = [];
949
+ if (c.signals) parts.push(fmtN(c.signals)+" signals");
950
+ if (c.days) parts.push(fmtN(c.days)+" days");
951
+ parts.push("100% on my machine");
952
+ ctx.fillStyle = "#62656D"; ctx.font = "500 22px "+FONT; ctx.fillText(parts.join(" · "), PAD, H-PAD-58);
953
+ }
954
+ ctx.fillStyle = "#9A9DA5"; ctx.font = "500 22px "+FONT; ctx.fillText("made with persnally — your own context engine", PAD, H-PAD);
955
+ ctx.textAlign = "right"; ctx.fillStyle = "#62656D"; ctx.fillText("persnally.com", W-PAD, H-PAD); ctx.textAlign = "left";
956
+ }
957
+ const _modal = $("shareModal");
958
+ function renderCard() { buildCard($("cardCanvas"), { head:$("tHead").checked, int:$("tInt").checked, voice:$("tVoice").checked, stats:$("tStats").checked }); }
959
+ function openShare() { renderCard(); _modal.classList.add("open"); _modal.setAttribute("aria-hidden","false"); }
960
+ function closeShare() { _modal.classList.remove("open"); _modal.setAttribute("aria-hidden","true"); }
961
+ $("shareBtn").onclick = openShare;
962
+ $("shareClose").onclick = closeShare;
963
+ _modal.addEventListener("click", (e) => { if (e.target === _modal) closeShare(); });
964
+ ["tHead","tInt","tVoice","tStats"].forEach((id) => $(id).addEventListener("change", renderCard));
965
+ addEventListener("keydown", (e) => { if (e.key === "Escape" && _modal.classList.contains("open")) closeShare(); });
966
+ $("cardDownload").onclick = () => $("cardCanvas").toBlob((b) => {
967
+ if (!b) return; const u = URL.createObjectURL(b), a = document.createElement("a");
968
+ a.href = u; a.download = "persnally-portrait.png"; a.click(); setTimeout(() => URL.revokeObjectURL(u), 1000);
969
+ }, "image/png");
970
+ $("cardCopy").onclick = async () => {
971
+ const btn = $("cardCopy");
972
+ try {
973
+ const b = await new Promise((r) => $("cardCanvas").toBlob(r, "image/png"));
974
+ await navigator.clipboard.write([new ClipboardItem({ "image/png": b })]);
975
+ btn.textContent = "Copied ✓"; setTimeout(() => btn.textContent = "Copy image", 1600);
976
+ } catch { btn.textContent = "Copy unsupported — Download"; setTimeout(() => btn.textContent = "Copy image", 2400); }
977
+ };
978
+
670
979
  loadAll();
671
980
 
672
981
  /* ── demo data ── */
@@ -679,7 +988,7 @@ function DEMO_DATA() {
679
988
  const now = Date.now(), iso = (d)=>new Date(now-d).toISOString();
680
989
  return {
681
990
  stats: { total:4127, byType:{ "signal.topic":312, "signal.assertion":14, "context.read":1247 }, bySource:{ "git:persnallyd":58 }, first:iso(86400000*214), last:iso(3600000*2) },
682
- profile: { headline:"A refactor-first systems thinker who ships hardest under a deadline.", model:"fable-5", generated_at:iso(3600000*5).slice(0,10),
991
+ profile: { headline:"A refactor-first systems thinker who ships hardest under a deadline.", model:"opus-4-8", generated_at:iso(3600000*5).slice(0,10),
683
992
  sections:[
684
993
  { title:"How you work", body:"You build in tight, evidence-driven loops — prototype, measure, delete. You reach for SQLite and local-first architectures before reaching for a cloud, and you distrust abstractions you can't audit. Your commits are small and frequent, and you keep a working build at every step rather than big-bang merges.", evidence_event_ids:["e1","e2","e3","e4"] },
685
994
  { title:"How you decide", body:"You kill ideas fast and out loud. You actively seek the case against your own plans, and you change course when the evidence turns — rare for someone who generates this many ideas. You ask for the falsification first.", evidence_event_ids:["e5","e6","e7"] },
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * Structured extraction shared by importers and profile synthesis.
3
- * Uses output_config structured outputs (works on every current model,
4
- * including Fable 5 where forced tool_choice is not supported).
3
+ * Uses output_config structured outputs (broadly supported; no forced tool_choice).
5
4
  */
6
5
  import { z } from "zod";
7
6
  export declare const DEFAULT_EXTRACT_MODEL: string;
@@ -15,6 +14,14 @@ export type LlmExtract = (opts: {
15
14
  }) => Promise<unknown>;
16
15
  /** Default extractor backed by the Anthropic API; injectable for tests. */
17
16
  export declare const anthropicExtract: LlmExtract;
17
+ export declare const RECOMMENDED_LOCAL_MODEL = "llama3.2";
18
+ /** Installed Ollama models, or null if Ollama isn't reachable (not installed / not running). */
19
+ export declare function ollamaTags(): Promise<string[] | null>;
20
+ /** Streams an `ollama pull`, reporting download progress (0–100). Throws on failure. */
21
+ export declare function pullOllamaModel(model: string, onProgress: (p: {
22
+ status: string;
23
+ percent: number;
24
+ }) => void): Promise<void>;
18
25
  export declare const ollamaExtract: LlmExtract;
19
26
  export interface ChosenExtractor {
20
27
  extract: LlmExtract;
package/build/src/llm.js CHANGED
@@ -1,13 +1,12 @@
1
1
  /**
2
2
  * Structured extraction shared by importers and profile synthesis.
3
- * Uses output_config structured outputs (works on every current model,
4
- * including Fable 5 where forced tool_choice is not supported).
3
+ * Uses output_config structured outputs (broadly supported; no forced tool_choice).
5
4
  */
6
5
  import Anthropic from "@anthropic-ai/sdk";
7
6
  import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
8
7
  import { z } from "zod";
9
8
  export const DEFAULT_EXTRACT_MODEL = process.env.PERSNALLY_MODEL ?? "claude-haiku-4-5";
10
- export const DEFAULT_PROFILE_MODEL = process.env.PERSNALLY_PROFILE_MODEL ?? "claude-fable-5";
9
+ export const DEFAULT_PROFILE_MODEL = process.env.PERSNALLY_PROFILE_MODEL ?? "claude-opus-4-8";
11
10
  /** Default extractor backed by the Anthropic API; injectable for tests. */
12
11
  export const anthropicExtract = async ({ model, instruction, schema, content, maxTokens }) => {
13
12
  const client = new Anthropic();
@@ -29,6 +28,56 @@ export const anthropicExtract = async ({ model, instruction, schema, content, ma
29
28
  // ── Local extraction via Ollama: zero key, zero cloud ───────
30
29
  const OLLAMA_URL = process.env.PERSNALLY_OLLAMA_URL ?? "http://127.0.0.1:11434";
31
30
  const LOCAL_MODEL_PREFERENCE = ["qwen2.5:14b", "qwen2.5:7b", "llama3.1:8b", "llama3.2", "qwen2.5:1.5b"];
31
+ // Smallest model that still yields a usable profile — the one-click local default.
32
+ // (~2GB; bigger models in LOCAL_MODEL_PREFERENCE win automatically once present.)
33
+ export const RECOMMENDED_LOCAL_MODEL = "llama3.2";
34
+ /** Installed Ollama models, or null if Ollama isn't reachable (not installed / not running). */
35
+ export async function ollamaTags() {
36
+ try {
37
+ const r = await fetch(`${OLLAMA_URL}/api/tags`, { signal: AbortSignal.timeout(1500) });
38
+ if (!r.ok)
39
+ return null;
40
+ return (await r.json()).models?.map((m) => m.name) ?? [];
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ /** Streams an `ollama pull`, reporting download progress (0–100). Throws on failure. */
47
+ export async function pullOllamaModel(model, onProgress) {
48
+ const r = await fetch(`${OLLAMA_URL}/api/pull`, {
49
+ method: "POST",
50
+ body: JSON.stringify({ name: model, stream: true }),
51
+ });
52
+ if (!r.ok || !r.body)
53
+ throw new Error(`ollama pull: ${r.status} ${(await r.text().catch(() => "")).slice(0, 200)}`);
54
+ const reader = r.body.getReader();
55
+ const decoder = new TextDecoder();
56
+ let buf = "";
57
+ for (;;) {
58
+ const { done, value } = await reader.read();
59
+ if (done)
60
+ break;
61
+ buf += decoder.decode(value, { stream: true });
62
+ const lines = buf.split("\n");
63
+ buf = lines.pop() ?? ""; // keep the partial last line for the next chunk
64
+ for (const line of lines) {
65
+ if (!line.trim())
66
+ continue;
67
+ let obj;
68
+ try {
69
+ obj = JSON.parse(line);
70
+ }
71
+ catch {
72
+ continue;
73
+ }
74
+ if (obj.error)
75
+ throw new Error(`ollama pull: ${obj.error}`);
76
+ const percent = obj.total ? Math.round(((obj.completed ?? 0) / obj.total) * 100) : 0;
77
+ onProgress({ status: obj.status ?? "", percent });
78
+ }
79
+ }
80
+ }
32
81
  export const ollamaExtract = async ({ model, instruction, schema, content }) => {
33
82
  const r = await fetch(`${OLLAMA_URL}/api/chat`, {
34
83
  method: "POST",
@@ -50,14 +99,10 @@ export const ollamaExtract = async ({ model, instruction, schema, content }) =>
50
99
  async function localModel() {
51
100
  if (process.env.PERSNALLY_LOCAL_MODEL)
52
101
  return process.env.PERSNALLY_LOCAL_MODEL;
53
- try {
54
- const r = await fetch(`${OLLAMA_URL}/api/tags`, { signal: AbortSignal.timeout(1500) });
55
- const tags = (await r.json()).models?.map((m) => m.name) ?? [];
56
- return LOCAL_MODEL_PREFERENCE.find((p) => tags.some((t) => t === p || t === `${p}:latest`)) ?? tags[0] ?? null;
57
- }
58
- catch {
102
+ const tags = await ollamaTags();
103
+ if (!tags)
59
104
  return null;
60
- }
105
+ return LOCAL_MODEL_PREFERENCE.find((p) => tags.some((t) => t === p || t === `${p}:latest`)) ?? tags[0] ?? null;
61
106
  }
62
107
  /** Anthropic key wins (quality); otherwise local Ollama (privacy, zero setup); otherwise guide the user. */
63
108
  export async function chooseExtractor(purpose = "extract") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "persnally",
3
- "version": "2.5.3",
3
+ "version": "2.6.1",
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",