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.
- package/build/src/consolidate.d.ts +1 -1
- package/build/src/consolidate.js +5 -1
- package/build/src/daemon.js +49 -2
- package/build/src/dashboard.html +356 -47
- package/build/src/llm.d.ts +9 -2
- package/build/src/llm.js +55 -10
- package/package.json +1 -1
|
@@ -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
|
|
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 {
|
package/build/src/consolidate.js
CHANGED
|
@@ -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
|
|
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() });
|
package/build/src/daemon.js
CHANGED
|
@@ -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)
|
package/build/src/dashboard.html
CHANGED
|
@@ -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">
|
|
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
|
|
492
|
-
const
|
|
493
|
-
const cx = w/2, cy = h/2;
|
|
494
|
-
|
|
495
|
-
const
|
|
496
|
-
|
|
497
|
-
const
|
|
498
|
-
|
|
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
|
-
|
|
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=
|
|
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(
|
|
515
|
-
const minD=A.r+B.r+
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
nodes.
|
|
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
|
-
|
|
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
|
-
//
|
|
529
|
-
edges.forEach(([a,b]) => { const A=
|
|
530
|
-
let dx=B.x-A.x,dy=B.y-A.y,d=Math.hypot(dx,dy)||1; const off=d*0.
|
|
531
|
-
const al
|
|
532
|
-
const g=ctx.createLinearGradient(A.x,A.y,B.x,B.y); g.addColorStop(0,
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
ctx.
|
|
539
|
-
ctx.fillStyle=
|
|
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
|
|
542
|
-
ctx.
|
|
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
|
-
|
|
643
|
+
if (n.kind==="entity" && hover===-1) return;
|
|
546
644
|
if (!active && hover!==-1) return;
|
|
547
|
-
const
|
|
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.
|
|
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
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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 =
|
|
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
|
|
565
|
-
|
|
566
|
-
|
|
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 & 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 & 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 <path></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" });
|
|
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:"
|
|
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"] },
|
package/build/src/llm.d.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Structured extraction shared by importers and profile synthesis.
|
|
3
|
-
* Uses output_config structured outputs (
|
|
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 (
|
|
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-
|
|
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
|
-
|
|
54
|
-
|
|
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") {
|