persnally 2.0.3 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,176 +6,632 @@
6
6
  <title>Persnally — your context engine</title>
7
7
  <style>
8
8
  :root {
9
- --bg: #0d0e12; --panel: #14161c; --panel-2: #1a1d25; --line: #262a35;
10
- --text: #e8eaf0; --dim: #8b92a5; --accent: #6ee7b7; --warn: #f87171;
11
- --intent: #93c5fd;
9
+ --bg: #0B0C0E; /* dark, default */
10
+ --panel: #141517; /* card surface */
11
+ --panel-2: #1B1C1F;
12
+ --line: rgba(255,255,255,0.08);
13
+ --line-2: rgba(255,255,255,0.16);
14
+ --text: #F1F2F4; /* near-white */
15
+ --dim: #9A9DA5; /* gray */
16
+ --faint: #62656D;
17
+ --active: #35D07F; /* the ONE color — active status dot only */
18
+ --r: 12px;
19
+ --font: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, Roboto, Helvetica, Arial, sans-serif;
20
+ --ease: cubic-bezier(0.22, 1, 0.36, 1);
12
21
  }
13
- * { box-sizing: border-box; margin: 0; }
22
+ * { box-sizing: border-box; margin: 0; padding: 0; }
14
23
  body {
15
24
  background: var(--bg); color: var(--text);
16
- font: 15px/1.6 -apple-system, "Segoe UI", system-ui, sans-serif;
17
- max-width: 880px; margin: 0 auto; padding: 40px 24px 80px;
25
+ font: 15px/1.6 var(--font); -webkit-font-smoothing: antialiased;
26
+ overflow-x: hidden;
18
27
  }
19
- header { display: flex; justify-content: space-between; align-items: baseline; flex-wrap: wrap; gap: 8px; }
20
- h1 { font-size: 22px; letter-spacing: -0.3px; }
21
- h1 span { color: var(--accent); }
22
- .tagline { color: var(--dim); font-size: 14px; }
23
- .stats { display: flex; gap: 24px; margin: 22px 0 34px; color: var(--dim); font-size: 13px; }
24
- .stats b { color: var(--text); font-size: 17px; display: block; }
25
- section { margin-bottom: 40px; }
26
- h2 { font-size: 13px; text-transform: uppercase; letter-spacing: 1.4px; color: var(--dim); margin-bottom: 14px; }
27
- .card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 18px 20px; margin-bottom: 12px; }
28
- .headline { font-size: 17px; font-weight: 600; line-height: 1.5; }
29
- .meta { color: var(--dim); font-size: 12px; margin-top: 8px; }
30
- .sec-title { font-weight: 600; margin-bottom: 6px; }
31
- .sec-body { color: #c6cad6; white-space: pre-wrap; }
32
- .evidence-btn {
33
- background: none; border: none; color: var(--accent); cursor: pointer;
34
- font-size: 12px; padding: 0; margin-top: 10px; font-family: inherit;
28
+ .num { font-variant-numeric: tabular-nums; }
29
+ .wrap { max-width: 1040px; margin: 0 auto; padding: 24px 28px 110px; }
30
+
31
+ /* ── header ── */
32
+ header { display: flex; align-items: center; justify-content: space-between; gap: 14px; flex-wrap: wrap; margin-bottom: 34px; }
33
+ .brand { display: flex; align-items: center; gap: 12px; }
34
+ .wordmark { font-weight: 600; font-size: 17px; letter-spacing: -0.2px; }
35
+ .status { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--dim); }
36
+ .status .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--active); box-shadow: 0 0 8px rgba(53,208,127,0.7); }
37
+ .status .dot.off { background: var(--faint); box-shadow: none; }
38
+ .head-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
39
+
40
+ .btn {
41
+ font: 600 13px var(--font); cursor: pointer; border-radius: 8px; padding: 7px 13px;
42
+ transition: all .16s var(--ease); border: 1px solid transparent;
43
+ background: #FFFFFF; color: #0B0C0E; text-decoration: none; display: inline-flex; align-items: center; gap: 6px;
44
+ }
45
+ .btn:hover { background: #E6E7EA; transform: translateY(-1px); }
46
+ .btn:disabled { opacity: 0.5; cursor: wait; transform: none; }
47
+ .btn.ghost { background: transparent; color: var(--text); border-color: var(--line-2); }
48
+ .btn.ghost:hover { background: rgba(255,255,255,0.05); border-color: rgba(255,255,255,0.4); }
49
+ .btn .arr { opacity: 0.5; font-size: 11px; }
50
+
51
+ /* ── reveal ── */
52
+ .reveal { opacity: 0; transform: translateY(12px); animation: rise .55s var(--ease) forwards; }
53
+ @keyframes rise { to { opacity: 1; transform: none; } }
54
+
55
+ /* ── delta strip ── */
56
+ .delta {
57
+ background: var(--panel); border: 1px solid var(--line); border-radius: var(--r);
58
+ padding: 14px 18px; margin-bottom: 32px; display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
59
+ }
60
+ .delta .lead { font-size: 11px; letter-spacing: 1.3px; text-transform: uppercase; color: var(--dim); white-space: nowrap; }
61
+ .delta .items { display: flex; gap: 22px; flex-wrap: wrap; flex: 1; }
62
+ .delta .di { font-size: 14px; color: var(--dim); }
63
+ .delta .di b { color: var(--text); font-weight: 600; }
64
+ .delta .di .arr { color: var(--text); font-weight: 700; }
65
+
66
+ /* ── hero ── */
67
+ .hero { margin-bottom: 8px; }
68
+ .scale-beat { font-size: 13px; color: var(--dim); margin-bottom: 16px; min-height: 18px; }
69
+ .scale-beat b { color: var(--text); }
70
+ .archetype {
71
+ font-weight: 600; letter-spacing: -0.6px; line-height: 1.15;
72
+ font-size: clamp(26px, 3.6vw, 40px); color: #FFFFFF; width: 100%; /* full width */
35
73
  }
36
- .evidence { margin-top: 10px; border-top: 1px dashed var(--line); padding-top: 10px; display: none; }
74
+ .gen-meta { font-size: 12px; color: var(--faint); margin-top: 14px; }
75
+
76
+ /* ── sections ── */
77
+ section { margin-top: 48px; }
78
+ .sec-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 16px; }
79
+ .sec-head h2 { font-size: 12px; text-transform: uppercase; letter-spacing: 1.6px; color: var(--dim); font-weight: 600; }
80
+ .sec-head .aside { font-size: 11px; color: var(--faint); }
81
+
82
+ .card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--r); padding: 18px 20px; }
83
+ .grid { display: grid; gap: 12px; }
84
+ .grid.two { grid-template-columns: 1fr 1fr; }
85
+ @media (max-width: 720px) { .grid.two { grid-template-columns: 1fr; } }
86
+ .claim-card { transition: border-color .18s var(--ease); }
87
+ .claim-card:hover { border-color: var(--line-2); }
88
+ .claim-title { font-weight: 600; font-size: 15px; margin-bottom: 7px; }
89
+ .claim-body { color: #C7CACF; font-size: 14.5px; line-height: 1.6; }
90
+ .claim-body.clamp { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
91
+ .row-actions { display: flex; gap: 16px; align-items: center; margin-top: 11px; }
92
+ .link-btn { background: none; border: none; color: var(--text); cursor: pointer; font: 600 12px var(--font); padding: 0; text-decoration: underline; text-underline-offset: 3px; text-decoration-color: var(--line-2); }
93
+ .link-btn:hover { text-decoration-color: var(--text); }
94
+ .link-btn.sub { color: var(--dim); font-weight: 500; }
95
+ .voice-pack { font-size: 15.5px; color: var(--text); line-height: 1.6; padding-bottom: 15px; border-bottom: 1px solid var(--line); margin-bottom: 15px; }
96
+ .voice-groups { display: flex; flex-direction: column; gap: 16px; }
97
+ .vglabel { font-size: 11px; text-transform: uppercase; letter-spacing: 1.3px; color: var(--dim); margin-bottom: 9px; }
98
+ .vchips { display: flex; flex-wrap: wrap; gap: 8px; }
99
+ .vchip { font-size: 13px; color: var(--text); background: var(--panel-2); border: 1px solid var(--line-2); border-radius: 999px; padding: 5px 13px; }
100
+ .vchip i { color: var(--faint); font-style: normal; font-size: 11px; margin-left: 5px; }
101
+ .evidence { margin-top: 11px; border-top: 1px solid var(--line); padding-top: 11px; display: none; }
37
102
  .evidence.open { display: block; }
38
- .ev-item { font-size: 12.5px; color: var(--dim); padding: 4px 0; }
39
- .ev-item code { color: var(--intent); background: var(--panel-2); padding: 1px 6px; border-radius: 4px; }
103
+ .ev-item { font-size: 12.5px; color: var(--dim); padding: 5px 0; line-height: 1.5; }
104
+ .ev-item code { color: var(--text); background: var(--panel-2); padding: 1px 6px; border-radius: 5px; }
105
+ .ev-item i { color: var(--faint); font-style: normal; }
106
+
107
+ /* ── constellation ── */
108
+ .constellation-wrap { position: relative; }
109
+ .constellation { background: var(--panel); border: 1px solid var(--line); border-radius: var(--r); overflow: hidden; }
110
+ #graph { display: block; width: 100%; height: 520px; cursor: grab; }
111
+ #graph:active { cursor: grabbing; }
112
+ .ghint { position: absolute; top: 12px; right: 14px; font-size: 11px; color: var(--faint); pointer-events: none; }
113
+ .legend { position: absolute; bottom: 12px; left: 14px; display: flex; gap: 14px; flex-wrap: wrap; font-size: 11px; color: var(--dim); pointer-events: none; }
114
+ .legend span { display: inline-flex; align-items: center; gap: 6px; }
115
+ .legend i { width: 8px; height: 8px; border-radius: 50%; display: inline-block; box-shadow: 0 0 6px currentColor; }
116
+ .node-pop {
117
+ position: absolute; pointer-events: none; opacity: 0; transform: translateY(4px);
118
+ transition: opacity .12s, transform .12s; background: var(--panel-2);
119
+ border: 1px solid var(--line-2); border-radius: 10px; padding: 9px 12px; max-width: 240px; z-index: 5;
120
+ box-shadow: 0 12px 36px rgba(0,0,0,0.55);
121
+ }
122
+ .node-pop.show { opacity: 1; transform: none; }
123
+ .node-pop .npt { font-weight: 600; font-size: 13.5px; }
124
+ .node-pop .npm { font-size: 11.5px; color: var(--dim); margin-top: 3px; }
125
+ .node-pop .npe { font-size: 11.5px; color: var(--faint); margin-top: 5px; }
126
+ .view-toggle { display: inline-flex; border: 1px solid var(--line-2); border-radius: 8px; overflow: hidden; }
127
+ .view-toggle button { font: 500 11px var(--font); background: transparent; color: var(--dim); border: none; padding: 5px 12px; cursor: pointer; }
128
+ .view-toggle button.on { background: #FFFFFF; color: #0B0C0E; }
129
+
40
130
  table { width: 100%; border-collapse: collapse; }
41
131
  td { padding: 9px 8px; border-bottom: 1px solid var(--line); vertical-align: middle; font-size: 14px; }
42
- .bar { height: 6px; border-radius: 3px; background: var(--accent); min-width: 2px; }
43
- .bar-cell { width: 120px; }
132
+ tr:last-child td { border-bottom: none; }
133
+ .bar-track { width: 130px; height: 6px; background: rgba(255,255,255,0.06); border-radius: 3px; overflow: hidden; }
134
+ .bar { height: 100%; background: #FFFFFF; opacity: 0.85; border-radius: 3px; }
44
135
  .cat { color: var(--dim); font-size: 12.5px; }
45
- .neg { color: var(--warn); }
46
- .del {
47
- background: none; border: 1px solid var(--line); color: var(--dim); border-radius: 6px;
48
- cursor: pointer; font-size: 11.5px; padding: 2px 9px;
49
- }
50
- .del:hover { color: var(--warn); border-color: var(--warn); }
51
- .btn {
52
- background: var(--accent); color: #07241a; border: none; border-radius: 8px;
53
- padding: 8px 16px; font-weight: 600; cursor: pointer; font-size: 13.5px;
136
+ .del { background: none; border: 1px solid var(--line); color: var(--faint); border-radius: 6px; cursor: pointer; font-size: 11px; padding: 3px 9px; transition: all .16s; }
137
+ .del:hover { color: var(--text); border-color: var(--line-2); }
138
+
139
+ /* ── receipts ── */
140
+ .value-row { display: flex; align-items: flex-end; gap: 14px; margin-bottom: 18px; flex-wrap: wrap; }
141
+ .value-num { font-size: 40px; font-weight: 600; color: #FFFFFF; letter-spacing: -1px; line-height: 1; }
142
+ .value-cap { color: var(--dim); font-size: 13.5px; max-width: 48ch; }
143
+ .value-cap b { color: var(--text); }
144
+ .read { display: flex; align-items: center; gap: 12px; padding: 11px 4px; border-bottom: 1px solid var(--line); }
145
+ .read:last-child { border-bottom: none; }
146
+ .read.fresh { animation: flash 1.6s var(--ease); }
147
+ @keyframes flash { 0% { background: rgba(255,255,255,0.07); } 100% { background: transparent; } }
148
+ .read .who { width: 30px; height: 30px; border-radius: 8px; flex: none; display: grid; place-items: center; font-size: 11px; font-weight: 700; background: var(--panel-2); color: var(--text); border: 1px solid var(--line); }
149
+ .read .what { flex: 1; min-width: 0; }
150
+ .read .what .l1 { font-size: 13.5px; }
151
+ .read .what .l1 b { color: var(--text); } .read .what .l1 .p { color: var(--dim); }
152
+ .read .what .l2 { font-size: 11px; color: var(--faint); margin-top: 2px; }
153
+ .read .when { font-size: 11.5px; color: var(--dim); white-space: nowrap; }
154
+
155
+ /* ── reflection ── */
156
+ .refl { border-left: 2px solid var(--line-2); padding: 4px 0 4px 16px; margin-bottom: 16px; }
157
+ .refl .rc { font-size: 14.5px; color: var(--text); line-height: 1.55; }
158
+ .refl .rm { font-size: 11.5px; color: var(--faint); margin-top: 5px; }
159
+
160
+ /* ── misc ── */
161
+ .empty { color: var(--dim); padding: 28px; text-align: center; font-size: 14px; }
162
+ .empty .big { font-size: 18px; color: var(--text); display: block; margin-bottom: 8px; font-weight: 600; }
163
+ .empty code { color: var(--text); background: var(--panel-2); padding: 2px 7px; border-radius: 5px; }
164
+ footer { margin-top: 64px; padding-top: 18px; border-top: 1px solid var(--line); font-size: 12px; color: var(--faint); line-height: 1.9; }
165
+ footer code { color: var(--dim); background: var(--panel-2); padding: 1px 6px; border-radius: 4px; }
166
+ .preview-ribbon {
167
+ position: fixed; bottom: 0; left: 0; right: 0; z-index: 20; text-align: center; font-size: 12px;
168
+ color: var(--dim); background: var(--panel); border-top: 1px solid var(--line); padding: 7px; display: none;
54
169
  }
55
- .btn:disabled { opacity: 0.5; cursor: wait; }
56
- .empty { color: var(--dim); padding: 24px; text-align: center; }
57
- footer { color: var(--dim); font-size: 12px; border-top: 1px solid var(--line); padding-top: 16px; }
170
+ .preview-ribbon.show { display: block; }
171
+ .preview-ribbon code { color: var(--text); }
172
+ a { color: var(--text); }
173
+ :focus-visible { outline: 2px solid #FFFFFF; outline-offset: 3px; border-radius: 4px; }
174
+ @media (prefers-reduced-motion: reduce) { .reveal { animation: none; opacity: 1; transform: none; } .read.fresh { animation: none; } }
58
175
  </style>
59
176
  </head>
60
177
  <body>
61
- <header>
62
- <div>
63
- <h1>persnally<span>d</span></h1>
64
- <div class="tagline">so every AI finally knows you · local-first, nothing leaves this machine</div>
178
+ <div class="wrap">
179
+ <header class="reveal">
180
+ <div class="brand">
181
+ <span class="wordmark">persnally</span>
182
+ <span class="status" id="status"><span class="dot"></span><span class="lbl">active</span></span>
183
+ </div>
184
+ <div class="head-actions">
185
+ <a class="btn ghost" href="https://github.com/sidpan2011/persnally" target="_blank" rel="noopener">GitHub <span class="arr">↗</span></a>
186
+ <a class="btn ghost" href="https://persnally.com" target="_blank" rel="noopener">persnally.com <span class="arr">↗</span></a>
187
+ <button class="btn ghost" id="reflect">Reflect</button>
188
+ <button class="btn" id="synthesize">Re-synthesize</button>
189
+ </div>
190
+ </header>
191
+
192
+ <div id="delta"></div>
193
+
194
+ <div class="hero reveal" style="animation-delay:.05s">
195
+ <div class="scale-beat" id="scaleBeat"></div>
196
+ <div class="archetype" id="archetype">Reading your context…</div>
197
+ <div class="gen-meta" id="genMeta"></div>
65
198
  </div>
66
- <button class="btn" id="synthesize">Re-synthesize profile</button>
67
- </header>
68
199
 
69
- <div class="stats" id="stats"></div>
200
+ <section class="reveal" style="animation-delay:.1s">
201
+ <div class="sec-head"><h2>The portrait</h2><span class="aside">every claim cites its evidence</span></div>
202
+ <div class="grid two" id="sections"><div class="card empty">Loading…</div></div>
203
+ </section>
204
+
205
+ <section class="reveal" style="animation-delay:.13s;display:none" id="voiceSection">
206
+ <div class="sec-head"><h2>How you write</h2><span class="aside">served to your tools so they answer like you</span></div>
207
+ <div class="card" id="voiceCard"></div>
208
+ </section>
70
209
 
71
- <section>
72
- <h2>Your profile — every claim traceable to evidence</h2>
73
- <div id="profile"><div class="empty">Loading…</div></div>
74
- </section>
210
+ <section class="reveal" style="animation-delay:.15s">
211
+ <div class="sec-head">
212
+ <h2>Your interest map</h2>
213
+ <div class="view-toggle"><button id="vGraph" class="on">map</button><button id="vList">list</button></div>
214
+ </div>
215
+ <div class="constellation-wrap">
216
+ <div class="constellation" id="graphHost"><canvas id="graph"></canvas><div class="legend" id="legend"></div><div class="ghint">drag · scroll to zoom · dbl-click resets</div></div>
217
+ <div class="card" id="topicList" style="display:none;padding:6px 14px"><table id="topics"></table></div>
218
+ <div class="node-pop" id="nodePop"></div>
219
+ </div>
220
+ </section>
75
221
 
76
- <section>
77
- <h2>Interest graph decayed, weighted, deletable</h2>
78
- <div class="card" style="padding:6px 14px"><table id="topics"></table></div>
79
- </section>
222
+ <section class="reveal" style="animation-delay:.2s">
223
+ <div class="sec-head"><h2>What your AIs read about you</h2><span class="aside">read locally · never sent anywhere</span></div>
224
+ <div class="value-row">
225
+ <div class="value-num num" id="valueNum">0</div>
226
+ <div class="value-cap" id="valueCap"></div>
227
+ </div>
228
+ <div class="card" id="reads" style="padding:6px 16px"></div>
229
+ </section>
80
230
 
81
- <footer>
82
- Stored at ~/.persnally/persnally.db · deleting a topic hard-deletes its events and everything derived from them ·
83
- <code>persnallyd forget --all</code> erases everything
84
- </footer>
231
+ <section class="reveal" style="animation-delay:.25s" id="reflSection">
232
+ <div class="sec-head"><h2>What changed about you</h2><span class="aside">noticed by the nightly reflection</span></div>
233
+ <div id="reflections"></div>
234
+ </section>
235
+
236
+ <footer>
237
+ stored at <code>~/.persnally/persnally.db</code> · forgetting a topic hard-deletes its events and everything derived ·
238
+ <code>persnallyd forget --all</code> erases everything <span id="ver"></span>
239
+ </footer>
240
+ </div>
241
+ <div class="preview-ribbon" id="ribbon">preview data — start <code>persnallyd</code> and reload to see your own</div>
85
242
 
86
243
  <script>
244
+ "use strict";
87
245
  const $ = (id) => document.getElementById(id);
88
- const esc = (s) => String(s).replace(/[&<>"']/g, (c) =>
89
- ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
246
+ const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;" }[c]));
247
+ const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
248
+ const FONT = '-apple-system, system-ui, "Segoe UI", Roboto, sans-serif';
90
249
 
91
250
  async function get(path) {
92
- const r = await fetch(path);
251
+ const r = await fetch(path, { headers: { "accept": "application/json" } });
93
252
  if (!r.ok) return null;
94
253
  return r.json();
95
254
  }
255
+ const fmtN = (n) => n.toLocaleString("en-US");
256
+ function timeAgo(iso) {
257
+ const s = (Date.now() - new Date(iso).getTime()) / 1000;
258
+ if (s < 60) return "just now";
259
+ if (s < 3600) return Math.floor(s/60) + "m ago";
260
+ if (s < 86400) return Math.floor(s/3600) + "h ago";
261
+ const d = Math.floor(s/86400); return d === 1 ? "yesterday" : d + "d ago";
262
+ }
263
+ function countUp(el, to) {
264
+ if (reduced || to === 0) { el.textContent = fmtN(to); return; }
265
+ const dur = 850, t0 = performance.now();
266
+ const step = (t) => { const k = Math.min(1,(t-t0)/dur), e = 1-Math.pow(1-k,3); el.textContent = fmtN(Math.round(to*e)); if (k<1) requestAnimationFrame(step); };
267
+ requestAnimationFrame(step);
268
+ }
269
+ function clientOf(ev) {
270
+ const c = ev.provenance && ev.provenance.client;
271
+ if (c) return c;
272
+ if (ev.source && ev.source.startsWith("mcp:")) return ev.source.slice(4);
273
+ return ev.source || "local";
274
+ }
275
+ const prettyClient = (c) => ({ "claude-code":"Claude Code","claude-desktop":"Claude Desktop","cursor":"Cursor","cli":"CLI" }[c] || c);
276
+ const clientInitials = (c) => prettyClient(c).split(/[\s-]/).map(w=>w[0]).join("").slice(0,2).toUpperCase();
277
+
278
+ const SNAP_KEY = "persnally.snapshot.v1";
279
+ let DEMO = false;
96
280
 
97
- async function loadStats() {
98
- const s = await get("/stats");
99
- if (!s) return;
100
- const span = s.first ? `${s.first.slice(0, 10)} → ${s.last.slice(0, 10)}` : "";
101
- $("stats").innerHTML = `
102
- <div><b>${s.total}</b>events</div>
103
- <div><b>${s.byType["signal.topic"] ?? 0}</b>topic signals</div>
104
- <div><b>${s.byType["signal.assertion"] ?? 0}</b>assertions</div>
105
- <div><b>${span}</b>covered</div>`;
281
+ /* ── delta ── */
282
+ function renderDelta(topics, reads, assertions) {
283
+ const prev = JSON.parse(localStorage.getItem(SNAP_KEY) || "null");
284
+ const el = $("delta");
285
+ if (!prev) { el.innerHTML = ""; return; }
286
+ const prevW = prev.weights || {}, now = prev.t, items = [];
287
+ const newTopics = topics.filter(t => !(t.topic_key in prevW));
288
+ if (newTopics.length) items.push(`<span class="di"><b>+${newTopics.length}</b> new ${newTopics.length>1?"topics":"topic"}: ${esc(newTopics.slice(0,3).map(t=>t.topic).join(", "))}</span>`);
289
+ let climbed = null, cd = 0;
290
+ topics.forEach(t => { const was = prevW[t.topic_key]; if (was > 0.02) { const d=(t.weight-was)/was; if (d>cd){cd=d;climbed=t;} } });
291
+ if (climbed && cd > 0.08) items.push(`<span class="di">focus on <b>${esc(climbed.topic)}</b> <span class="arr">↑</span> <b>${Math.round(cd*100)}%</b></span>`);
292
+ const nr = reads.filter(r => r.ts > now).length;
293
+ if (nr) items.push(`<span class="di"><b>${nr}</b> context ${nr>1?"reads":"read"} since</span>`);
294
+ const na = assertions.filter(a => a.ts > now).length;
295
+ if (na) items.push(`<span class="di"><b>${na}</b> new ${na>1?"patterns":"pattern"} noticed</span>`);
296
+ if (!items.length) { el.className = "delta reveal"; el.innerHTML = `<span class="lead">all caught up</span><div class="items"><span class="di">steady since ${timeAgo(now)} — the engine keeps it current while you work.</span></div>`; return; }
297
+ el.className = "delta reveal"; el.innerHTML = `<span class="lead">since you last looked</span><div class="items">${items.join("")}</div>`;
106
298
  }
299
+ function saveSnapshot(topics) { const w = {}; topics.forEach(t => w[t.topic_key]=t.weight); localStorage.setItem(SNAP_KEY, JSON.stringify({ t:new Date().toISOString(), weights:w })); }
107
300
 
108
- async function loadProfile() {
109
- const p = await get("/profile");
110
- if (!p) {
111
- $("profile").innerHTML = `<div class="card empty">No profile yet import data, then hit “Re-synthesize profile”.</div>`;
112
- return;
301
+ /* ── hero ── */
302
+ function renderScaleBeat(stats) {
303
+ const bt = stats.byType || {};
304
+ const span = stats.first ? Math.max(1, Math.round((new Date(stats.last)-new Date(stats.first))/86400000)) : 0;
305
+ const parts = [`<b>${fmtN(stats.total)}</b> signals read`];
306
+ if (bt["signal.topic"]) parts.push(`<b>${fmtN(bt["signal.topic"])}</b> moments of interest`);
307
+ if (span) parts.push(`across <b>${fmtN(span)}</b> days`);
308
+ $("scaleBeat").innerHTML = "synthesized from " + parts.join(" · ");
309
+ }
310
+ function renderPortrait(profile) {
311
+ if (!profile) {
312
+ $("archetype").textContent = "Your portrait is waiting.";
313
+ $("sections").innerHTML = `<div class="card empty"><span class="big">Nothing imported yet</span>Run <code>persnally setup</code> to import your AI history, then hit Re-synthesize.</div>`;
314
+ $("genMeta").textContent = ""; return;
113
315
  }
114
- const sections = p.sections.map((s, i) => `
115
- <div class="card">
116
- <div class="sec-title">${esc(s.title)}</div>
117
- <div class="sec-body">${esc(s.body)}</div>
118
- ${s.evidence_event_ids.length ? `
119
- <button class="evidence-btn" data-i="${i}">why does it think this? · ${s.evidence_event_ids.length} events</button>
120
- <div class="evidence" id="ev-${i}" data-ids="${esc(s.evidence_event_ids.join(","))}"></div>` : ""}
121
- </div>`).join("");
122
- $("profile").innerHTML = `
123
- <div class="card"><div class="headline">${esc(p.headline)}</div>
124
- <div class="meta">generated ${esc(p.generated_at)} · ${esc(p.model)} · synthesized from structured events only</div></div>
125
- ${sections}`;
126
-
127
- document.querySelectorAll(".evidence-btn").forEach((btn) =>
128
- btn.addEventListener("click", () => toggleEvidence(btn.dataset.i)));
129
- }
130
-
131
- async function toggleEvidence(i) {
132
- const box = $(`ev-${i}`);
133
- box.classList.toggle("open");
134
- if (box.dataset.loaded || !box.classList.contains("open")) return;
135
- const events = await get(`/events?ids=${box.dataset.ids}`) ?? [];
136
- box.innerHTML = events.map((e) => {
137
- const p = e.payload;
138
- const summary = e.type === "signal.topic"
139
- ? `${p.topic} (${p.intent}, ${p.sentiment})`
140
- : p.claim ?? JSON.stringify(p).slice(0, 120);
141
- const origin = e.provenance.kind === "import"
142
- ? `${e.provenance.file}${e.provenance.conversation_uuid ? " · convo " + e.provenance.conversation_uuid.slice(0, 8) : ""}`
143
- : e.provenance.kind;
144
- return `<div class="ev-item"><code>${esc(e.type)}</code> ${esc(summary)} — <i>${esc(origin)}, ${esc(e.ts.slice(0, 10))}</i></div>`;
145
- }).join("") || `<div class="ev-item">events not found (deleted?)</div>`;
316
+ $("archetype").textContent = profile.headline || "";
317
+ $("genMeta").textContent = `synthesized ${(profile.generated_at||"").slice(0,10)} · ${profile.model||""} · structured events only`;
318
+ $("sections").innerHTML = profile.sections.map((s, i) => {
319
+ const n = (s.evidence_event_ids||[]).length;
320
+ const why = n ? `<button class="link-btn sub" data-ev="${i}" data-ids="${esc((s.evidence_event_ids||[]).join(","))}">why? · ${n}</button>` : "";
321
+ return `<div class="card claim-card">
322
+ <div class="claim-title">${esc(s.title)}</div>
323
+ <div class="claim-body clamp" id="body-${i}">${esc(s.body)}</div>
324
+ <div class="row-actions"><button class="link-btn" data-more="${i}" style="display:none">Show more</button>${why}</div>
325
+ <div class="evidence" id="ev-${i}"></div></div>`;
326
+ }).join("");
327
+ // show "Show more" only where the text is actually clamped
328
+ profile.sections.forEach((_, i) => {
329
+ const body = $(`body-${i}`), btn = document.querySelector(`[data-more="${i}"]`);
330
+ if (body.scrollHeight > body.clientHeight + 4) btn.style.display = "";
331
+ });
332
+ document.querySelectorAll("[data-more]").forEach(b => b.addEventListener("click", () => {
333
+ const body = $(`body-${b.dataset.more}`), open = body.classList.toggle("clamp");
334
+ b.textContent = open ? "Show more" : "Show less";
335
+ }));
336
+ document.querySelectorAll("[data-ev]").forEach(b => b.addEventListener("click", () => toggleEvidence(b)));
337
+ }
338
+ async function toggleEvidence(btn) {
339
+ const box = $(`ev-${btn.dataset.ev}`), open = box.classList.toggle("open");
340
+ if (!open || box.dataset.loaded) return;
341
+ const events = DEMO ? demoEvents(btn.dataset.ids.split(",")) : (await get(`/events?ids=${btn.dataset.ids}`) ?? []);
342
+ box.innerHTML = events.map(e => {
343
+ const p = e.payload || {};
344
+ const summary = e.type === "signal.topic" ? `${p.topic} (${p.intent})` : (p.claim ?? p.topic ?? JSON.stringify(p).slice(0,90));
345
+ const origin = e.provenance && e.provenance.kind === "import" ? `${(e.provenance.file||"import")}` : (e.provenance ? e.provenance.kind : "local");
346
+ return `<div class="ev-item"><code>${esc(e.type)}</code> ${esc(summary)} — <i>${esc(origin)}, ${esc((e.ts||"").slice(0,10))}</i></div>`;
347
+ }).join("") || `<div class="ev-item">evidence not found (deleted?)</div>`;
146
348
  box.dataset.loaded = "1";
147
349
  }
148
350
 
149
- async function loadTopics() {
150
- const topics = await get("/topics?limit=40") ?? [];
151
- const max = Math.max(...topics.map((t) => t.weight), 0.01);
152
- $("topics").innerHTML = topics.map((t) => `
153
- <tr>
154
- <td class="bar-cell"><div class="bar" style="width:${Math.max((t.weight / max) * 100, 2)}%"></div></td>
155
- <td>${esc(t.topic)}${t.sentiment_balance < -0.2 ? ' <span class="neg">▾</span>' : ""}</td>
156
- <td class="cat">${esc(t.category)} · ${esc(t.dominant_intent)} · ${t.signals}×</td>
157
- <td class="cat">${esc(t.last_seen.slice(0, 10))}</td>
158
- <td><button class="del" data-topic="${esc(t.topic)}">forget</button></td>
159
- </tr>`).join("") || `<tr><td class="empty">No topics yet — run an import.</td></tr>`;
160
-
161
- document.querySelectorAll(".del").forEach((btn) =>
162
- btn.addEventListener("click", async () => {
163
- if (!confirm(`Hard-delete "${btn.dataset.topic}" and everything derived from it?`)) return;
164
- await fetch(`/topics/${encodeURIComponent(btn.dataset.topic)}`, { method: "DELETE" });
165
- loadTopics(); loadStats();
166
- }));
167
- }
168
-
169
- $("synthesize").addEventListener("click", async () => {
170
- const btn = $("synthesize");
171
- btn.disabled = true; btn.textContent = "Synthesizing… (can take a minute)";
172
- const r = await fetch("/synthesize", { method: "POST" });
173
- if (!r.ok) alert((await r.json()).error ?? "synthesis failed");
174
- btn.disabled = false; btn.textContent = "Re-synthesize profile";
175
- loadProfile();
176
- });
177
-
178
- loadStats(); loadProfile(); loadTopics();
351
+ /* ── voice & conventions (the prescriptive layer) ── */
352
+ function renderVoice(voice) {
353
+ const sec = $("voiceSection");
354
+ if (!voice || !voice.items || !voice.items.length) { sec.style.display = "none"; return; }
355
+ sec.style.display = "";
356
+ const order = ["voice", "emphasis", "convention", "format", "workflow"];
357
+ const label = { voice: "Voice", emphasis: "Emphasis & recurring phrasing", convention: "Conventions", format: "Format", workflow: "Workflow" };
358
+ const groups = {};
359
+ voice.items.forEach((it) => { (groups[it.dimension] = groups[it.dimension] || []).push(it); });
360
+ let html = voice.pack ? `<div class="voice-pack">${esc(voice.pack)}</div>` : "";
361
+ html += `<div class="voice-groups">`;
362
+ for (const d of order) {
363
+ if (!groups[d]) continue;
364
+ html += `<div><div class="vglabel">${esc(label[d])}</div><div class="vchips">` +
365
+ groups[d].map((it) => `<span class="vchip" title="${esc(it.evidence || "")}">${esc(it.pattern)}${it.dimension === "emphasis" && it.evidence ? ` <i>${esc(it.evidence)}</i>` : ""}</span>`).join("") +
366
+ `</div></div>`;
367
+ }
368
+ $("voiceCard").innerHTML = html + `</div>`;
369
+ }
370
+
371
+ /* ── receipts ── */
372
+ function renderReads(reads, total) {
373
+ countUp($("valueNum"), total);
374
+ $("valueCap").innerHTML = `times your tools knew you <b>without you re-explaining</b> — every read served from this machine.`;
375
+ const host = $("reads");
376
+ if (!reads.length) { host.innerHTML = `<div class="empty">No reads yet. Connect a client with <code>persnally connect</code> — then every AI session shows up here.</div>`; return; }
377
+ host.innerHTML = reads.slice(0,8).map(r => {
378
+ const c = clientOf(r), p = r.payload || {};
379
+ const purpose = p.client_purpose ? `<span class="p"> · ${esc(p.client_purpose)}</span>` : "";
380
+ return `<div class="read"><div class="who">${esc(clientInitials(c))}</div>
381
+ <div class="what"><div class="l1"><b>${esc(prettyClient(c))}</b> read ${esc(String(p.items ?? "your"))} item${p.items===1?"":"s"}${purpose}</div>
382
+ <div class="l2">${esc(p.scope || "context")}</div></div><div class="when">${esc(timeAgo(r.ts))}</div></div>`;
383
+ }).join("");
384
+ }
385
+
386
+ /* ── reflections ── */
387
+ function renderReflections(assertions) {
388
+ const behav = assertions.filter(a => a.payload && (a.payload.kind==="behavior" || a.payload.kind==="preference"));
389
+ if (!behav.length) { $("reflSection").style.display = "none"; return; }
390
+ $("reflSection").style.display = "";
391
+ $("reflections").innerHTML = behav.slice(0,4).map(a => {
392
+ const p = a.payload || {}, conf = typeof p.confidence==="number" ? ` · ${Math.round(p.confidence*100)}% confidence` : "";
393
+ return `<div class="refl"><div class="rc">${esc(p.claim)}</div><div class="rm">${esc(p.kind)} · noticed ${esc(timeAgo(a.ts))}${esc(conf)}</div></div>`;
394
+ }).join("");
395
+ }
396
+
397
+ /* ── topic list ── */
398
+ function renderTopicList(topics) {
399
+ const max = Math.max(...topics.map(t=>t.weight), 0.01);
400
+ $("topics").innerHTML = topics.map(t => `<tr>
401
+ <td style="width:130px"><div class="bar-track"><div class="bar" style="width:${Math.max((t.weight/max)*100,3)}%"></div></div></td>
402
+ <td>${esc(t.topic)}</td>
403
+ <td class="cat">${esc(t.category)} · ${esc(t.dominant_intent)} · ${t.signals}×</td>
404
+ <td><button class="del" data-topic="${esc(t.topic)}">forget</button></td></tr>`).join("");
405
+ document.querySelectorAll(".del").forEach(b => b.addEventListener("click", async () => {
406
+ if (!confirm(`Hard-delete "${b.dataset.topic}" and everything derived from it?`)) return;
407
+ if (!DEMO) await fetch(`/topics/${encodeURIComponent(b.dataset.topic)}`, { method:"DELETE" });
408
+ loadAll();
409
+ }));
410
+ }
411
+
412
+ /* ── interest map — colored constellation: glow, category clustering, drag + zoom ── */
413
+ const CAT_COLORS = {
414
+ technology:"#5EC8FF", business:"#FFB454", finance:"#5FE3A1", career:"#B388FF",
415
+ creative:"#FF7AB6", science:"#7DD3FC", education:"#FFD45E", health:"#6EE7B7",
416
+ lifestyle:"#FF9E80", news:"#9FB3C8", other:"#A0A4AD",
417
+ };
418
+ const catColor = (c) => CAT_COLORS[c] || CAT_COLORS.other;
419
+ let mapCleanup = null, mapGen = 0;
420
+ function renderMap(topics) {
421
+ if (mapCleanup) { mapCleanup(); mapCleanup = null; }
422
+ const gen = ++mapGen;
423
+ const canvas = $("graph"), host = $("graphHost");
424
+ if (!canvas.getContext || topics.length < 2) { switchView("list"); $("vGraph").style.display="none"; renderTopicList(topics); return; }
425
+ const data = topics.slice(0, 30);
426
+ const ctx = canvas.getContext("2d");
427
+ const dpr = Math.min(devicePixelRatio || 1, 2);
428
+ let w, h;
429
+ const resize = () => { w = host.clientWidth; h = 520; canvas.width = w*dpr; canvas.height = h*dpr; canvas.style.height = h+"px"; };
430
+ resize();
431
+
432
+ const maxW = Math.max(...data.map(t=>t.weight), 0.01);
433
+ const cats = [...new Set(data.map(t=>t.category))];
434
+ const cx = w/2, cy = h/2;
435
+ // each category gets an anchor on a ring → nodes cluster by color (readable + useful)
436
+ const anchor = {};
437
+ 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 }; });
438
+ const nodes = data.map((t,i) => { const an=anchor[t.category]; return { t, i, color:catColor(t.category),
439
+ 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 }; });
440
+ const edges = [];
441
+ 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]); }
442
+ const neighbors = nodes.map(()=>new Set());
443
+ edges.forEach(([a,b])=>{ neighbors[a].add(b); neighbors[b].add(a); });
444
+
445
+ const cam = { k:1, x:0, y:0 };
446
+ let hover=-1, dragNode=-1, panning=false, lastX=0, lastY=0, alive=true, frames=0;
447
+ const S2Wx=(sx)=>(sx-cam.x)/cam.k, S2Wy=(sy)=>(sy-cam.y)/cam.k, W2Sx=(wx)=>wx*cam.k+cam.x, W2Sy=(wy)=>wy*cam.k+cam.y;
448
+
449
+ function tick() {
450
+ let energy = 0;
451
+ for (let i=0;i<nodes.length;i++) {
452
+ const A=nodes[i]; if (i===dragNode) continue;
453
+ for (let j=0;j<nodes.length;j++) { if (i===j) continue; const B=nodes[j];
454
+ let dx=A.x-B.x, dy=A.y-B.y, d2=dx*dx+dy*dy||1, d=Math.sqrt(d2);
455
+ const rep=Math.min(6200/d2, 7); dx/=d; dy/=d; A.vx+=dx*rep; A.vy+=dy*rep;
456
+ const minD=A.r+B.r+22; if (d<minD) { const p=(minD-d)/2; A.x+=dx*p; A.y+=dy*p; }
457
+ }
458
+ const an=anchor[A.t.category]; A.vx+=(an.x-A.x)*0.014; A.vy+=(an.y-A.y)*0.014; // cluster pull
459
+ }
460
+ 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;
461
+ if (a!==dragNode){A.vx+=dx*f;A.vy+=dy*f;} if (b!==dragNode){B.vx-=dx*f;B.vy-=dy*f;} });
462
+ 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; });
463
+ return energy;
464
+ }
465
+ function draw() {
466
+ ctx.setTransform(dpr,0,0,dpr,0,0); ctx.clearRect(0,0,w,h);
467
+ 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);
468
+ ctx.save(); ctx.translate(cam.x,cam.y); ctx.scale(cam.k,cam.k);
469
+ // curved gradient edges
470
+ edges.forEach(([a,b]) => { const A=nodes[a],B=nodes[b]; const active=hover===-1||hover===a||hover===b;
471
+ 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;
472
+ const al = hover===-1 ? "33" : (active ? "aa" : "10");
473
+ const g=ctx.createLinearGradient(A.x,A.y,B.x,B.y); g.addColorStop(0,A.color+al); g.addColorStop(1,B.color+al);
474
+ 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(); });
475
+ // glowing nodes
476
+ nodes.forEach((n,i) => { const active=hover===-1||hover===i||neighbors[hover].has(i); ctx.globalAlpha=active?1:0.18;
477
+ 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");
478
+ ctx.fillStyle=halo; ctx.beginPath(); ctx.arc(n.x,n.y,n.r*2.6,0,Math.PI*2); ctx.fill();
479
+ 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;
480
+ 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(); });
481
+ ctx.globalAlpha=1; ctx.restore();
482
+ // labels in screen space (constant size, collision-free, biggest first)
483
+ ctx.font="600 12px "+FONT; ctx.textAlign="center"; const placed=[];
484
+ nodes.map((_,i)=>i).sort((a,b)=>nodes[b].r-nodes[a].r).forEach(i => { const n=nodes[i];
485
+ const active = hover===-1 || hover===i || neighbors[hover].has(i);
486
+ const force = i===hover || (hover!==-1 && neighbors[hover].has(i));
487
+ if (!active && hover!==-1) return;
488
+ const sx=W2Sx(n.x), sy=W2Sy(n.y), sr=n.r*cam.k, tw=ctx.measureText(n.t.topic).width;
489
+ const rect={x:sx-tw/2-3,y:sy+sr+3,w:tw+6,h:15};
490
+ 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));
491
+ if (hit && !force) return; placed.push(rect);
492
+ 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);
493
+ });
494
+ ctx.globalAlpha=1;
495
+ }
496
+ 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); }
497
+ if (reduced) { for (let s=0;s<200;s++) tick(); draw(); alive=false; } else requestAnimationFrame(loop);
498
+ function wake() { if (!alive && gen===mapGen) { alive=true; frames=320; requestAnimationFrame(loop); } }
499
+
500
+ $("legend").innerHTML = cats.map(c => `<span><i style="color:${catColor(c)}"></i><span style="color:var(--dim)">${esc(c)}</span></span>`).join("");
501
+
502
+ const pop=$("nodePop");
503
+ 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+5/cam.k) return i; return -1; };
504
+ const rel=(e)=>{ const r=canvas.getBoundingClientRect(); return [e.clientX-r.left, e.clientY-r.top]; };
505
+ canvas.onmousedown=(e)=>{ const [mx,my]=rel(e); const n=nodeAt(mx,my); if (n>=0) dragNode=n; else panning=true; lastX=mx; lastY=my; };
506
+ const onMove=(e)=>{ const [mx,my]=rel(e);
507
+ if (dragNode>=0) { nodes[dragNode].x=S2Wx(mx); nodes[dragNode].y=S2Wy(my); nodes[dragNode].vx=0; nodes[dragNode].vy=0; pop.classList.remove("show"); wake(); if(!alive)draw(); return; }
508
+ if (panning) { cam.x+=mx-lastX; cam.y+=my-lastY; lastX=mx; lastY=my; if(!alive)draw(); return; }
509
+ if (mx<0||my<0||mx>w||my>h) { if (hover!==-1){hover=-1;pop.classList.remove("show");if(!alive)draw();} return; }
510
+ const n=nodeAt(mx,my);
511
+ if (n!==hover) { hover=n; if(!alive)draw();
512
+ if (n>=0) { const p=nodes[n].t; 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>`:""}`;
513
+ 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"); }
514
+ else pop.classList.remove("show"); } };
515
+ const onUp=()=>{ if (dragNode>=0){ dragNode=-1; wake(); } panning=false; };
516
+ canvas.onwheel=(e)=>{ e.preventDefault(); const [mx,my]=rel(e); const wx=S2Wx(mx),wy=S2Wy(my); const f=e.deltaY<0?1.12:1/1.12; cam.k=Math.max(0.45,Math.min(3,cam.k*f)); cam.x=mx-wx*cam.k; cam.y=my-wy*cam.k; if(!alive)draw(); };
517
+ canvas.ondblclick=()=>{ cam.k=1; cam.x=0; cam.y=0; if(!alive)draw(); };
518
+ let rt; const onWinResize=()=>{ clearTimeout(rt); rt=setTimeout(()=>{ resize(); if(!alive)draw(); },150); };
519
+ window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); window.addEventListener("resize", onWinResize, { passive:true });
520
+ mapCleanup=()=>{ window.removeEventListener("mousemove",onMove); window.removeEventListener("mouseup",onUp); window.removeEventListener("resize",onWinResize); };
521
+ }
522
+ function switchView(v) {
523
+ const g = v==="graph" || v==="map";
524
+ $("graphHost").style.display = g?"":"none"; $("topicList").style.display = g?"none":"";
525
+ $("vGraph").classList.toggle("on", g); $("vList").classList.toggle("on", !g);
526
+ }
527
+ $("vGraph").onclick = () => switchView("map");
528
+ $("vList").onclick = () => switchView("list");
529
+
530
+ /* ── load ── */
531
+ async function loadAll() {
532
+ let stats, profile, topics, reads, assertions, voice;
533
+ try {
534
+ [stats, profile, topics, reads, assertions, voice] = await Promise.all([
535
+ get("/stats"), get("/profile"), get("/topics?limit=40"),
536
+ get("/events?type=context.read&limit=60"), get("/events?type=signal.assertion&limit=20"), get("/voice"),
537
+ ]);
538
+ if (!stats) throw new Error("no daemon");
539
+ } catch (e) {
540
+ DEMO = true; $("ribbon").classList.add("show");
541
+ ({ stats, profile, topics, reads, assertions, voice } = DEMO_DATA());
542
+ }
543
+ topics = topics||[]; reads = reads||[]; assertions = assertions||[];
544
+ const st = $("status");
545
+ st.querySelector(".dot").classList.toggle("off", DEMO);
546
+ st.querySelector(".lbl").textContent = DEMO ? "preview" : "active";
547
+ const total = (stats.byType && stats.byType["context.read"]) || reads.length;
548
+ renderDelta(topics, reads, assertions);
549
+ renderScaleBeat(stats);
550
+ renderPortrait(profile);
551
+ renderVoice(voice);
552
+ renderTopicList(topics);
553
+ renderMap(topics);
554
+ renderReads(reads, total);
555
+ renderReflections(assertions);
556
+ saveSnapshot(topics);
557
+ }
558
+
559
+ $("synthesize").onclick = async () => {
560
+ const b = $("synthesize"); if (DEMO) return alert("Preview mode — start persnallyd to synthesize for real.");
561
+ b.disabled = true; b.textContent = "Synthesizing…";
562
+ const r = await fetch("/synthesize", { method:"POST" }); if (!r.ok) alert("Synthesis failed — check the daemon log.");
563
+ b.disabled = false; b.textContent = "Re-synthesize"; loadAll();
564
+ };
565
+ $("reflect").onclick = async () => {
566
+ const b = $("reflect"); if (DEMO) return alert("Preview mode — start persnallyd to reflect for real.");
567
+ b.disabled = true; b.textContent = "Reflecting…";
568
+ await fetch("/consolidate", { method:"POST" }); b.disabled = false; b.textContent = "Reflect"; loadAll();
569
+ };
570
+ setInterval(async () => {
571
+ if (DEMO) return;
572
+ const [reads, stats, voice] = await Promise.all([get("/events?type=context.read&limit=60"), get("/stats"), get("/voice")]);
573
+ if (reads && stats) renderReads(reads, (stats.byType && stats.byType["context.read"]) || reads.length);
574
+ if (voice) renderVoice(voice); // live: voice signals captured mid-session show up without a reload
575
+ }, 25000);
576
+
577
+ loadAll();
578
+
579
+ /* ── demo data ── */
580
+ function demoEvents(ids) {
581
+ return ids.map((id,i) => ({ id, type: i%2?"signal.topic":"signal.assertion", ts:new Date(Date.now()-i*86400000*3).toISOString(),
582
+ payload: i%2 ? { topic:["SQLite WAL","async Rust","MCP servers"][i%3], intent:"building" } : { claim:"prefers deleting code to adding it", kind:"behavior" },
583
+ provenance:{ kind:"import", file:"claude-code/persnallyd" } }));
584
+ }
585
+ function DEMO_DATA() {
586
+ const now = Date.now(), iso = (d)=>new Date(now-d).toISOString();
587
+ return {
588
+ 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) },
589
+ profile: { headline:"A refactor-first systems thinker who ships hardest under a deadline.", model:"fable-5", generated_at:iso(3600000*5).slice(0,10),
590
+ sections:[
591
+ { 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"] },
592
+ { 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"] },
593
+ { title:"What you're avoiding", body:"You circle back to the same hard call — defend the mature bet or pivot to the bigger one — and keep your options open across both rather than committing. The hesitation is itself a signal.", evidence_event_ids:["e8","e9"] },
594
+ { title:"Your voice", body:"Terse, concrete, allergic to filler. You'd rather show the benchmark than describe it, and you write the way you commit: small, focused, honest about what's unfinished.", evidence_event_ids:["e10","e11","e12"] },
595
+ ] },
596
+ topics: [
597
+ { topic_key:"mcp", topic:"MCP servers", category:"technology", weight:0.91, signals:34, sentiment_balance:0.4, dominant_intent:"building", entities:["MCP","TypeScript","Claude"], last_seen:iso(3600000*2), first_seen:iso(86400000*40), event_ids:[] },
598
+ { topic_key:"local", topic:"local-first architecture", category:"technology", weight:0.84, signals:28, sentiment_balance:0.5, dominant_intent:"building", entities:["SQLite","TypeScript","privacy"], last_seen:iso(3600000*6), first_seen:iso(86400000*60), event_ids:[] },
599
+ { topic_key:"sqlite", topic:"SQLite", category:"technology", weight:0.72, signals:19, sentiment_balance:0.3, dominant_intent:"building", entities:["SQLite","WAL"], last_seen:iso(86400000), first_seen:iso(86400000*55), event_ids:[] },
600
+ { topic_key:"voiceai", topic:"voice AI", category:"technology", weight:0.61, signals:22, sentiment_balance:0.1, dominant_intent:"deciding", entities:["TTS","audio"], last_seen:iso(86400000*3), first_seen:iso(86400000*120), event_ids:[] },
601
+ { topic_key:"robotics", topic:"robotics hardware", category:"technology", weight:0.58, signals:17, sentiment_balance:0.6, dominant_intent:"researching", entities:["sensors","hardware"], last_seen:iso(86400000*2), first_seen:iso(86400000*30), event_ids:[] },
602
+ { topic_key:"fundraising", topic:"fundraising strategy", category:"business", weight:0.49, signals:12, sentiment_balance:0.0, dominant_intent:"deciding", entities:["Delaware","pre-seed"], last_seen:iso(86400000*5), first_seen:iso(86400000*70), event_ids:[] },
603
+ { topic_key:"pmf", topic:"product-market fit", category:"business", weight:0.55, signals:15, sentiment_balance:-0.1, dominant_intent:"deciding", entities:["retention","pre-seed"], last_seen:iso(86400000*4), first_seen:iso(86400000*90), event_ids:[] },
604
+ { topic_key:"positioning", topic:"competitive positioning", category:"business", weight:0.47, signals:11, sentiment_balance:0.0, dominant_intent:"researching", entities:["retention","MCP"], last_seen:iso(86400000*3), first_seen:iso(86400000*45), event_ids:[] },
605
+ { topic_key:"taxes", topic:"presumptive taxation", category:"finance", weight:0.34, signals:7, sentiment_balance:-0.2, dominant_intent:"researching", entities:["44ADA","USD"], last_seen:iso(86400000*12), first_seen:iso(86400000*100), event_ids:[] },
606
+ { topic_key:"rust", topic:"async Rust", category:"technology", weight:0.38, signals:9, sentiment_balance:-0.3, dominant_intent:"learning", entities:["Rust","async"], last_seen:iso(86400000*8), first_seen:iso(86400000*75), event_ids:[] },
607
+ { topic_key:"writing", topic:"build-in-public writing", category:"creative", weight:0.42, signals:13, sentiment_balance:0.4, dominant_intent:"building", entities:["X","essays"], last_seen:iso(86400000*2), first_seen:iso(86400000*50), event_ids:[] },
608
+ ],
609
+ reads: [
610
+ { ts:iso(3600000*2), source:"mcp:cursor", payload:{ scope:"project_context", client_purpose:"tailor a refactor", items:6 }, provenance:{ kind:"mcp", client:"cursor" } },
611
+ { ts:iso(3600000*5), source:"mcp:claude-desktop", payload:{ scope:"full", client_purpose:"draft in your voice", items:9 }, provenance:{ kind:"mcp", client:"claude-desktop" } },
612
+ { ts:iso(3600000*9), source:"mcp:claude-code", payload:{ scope:"brief", client_purpose:"session start", items:7 }, provenance:{ kind:"mcp", client:"claude-code" } },
613
+ { ts:iso(86400000), source:"mcp:cursor", payload:{ scope:"project_context", client_purpose:"pick the right stack", items:5 }, provenance:{ kind:"mcp", client:"cursor" } },
614
+ ],
615
+ assertions: [
616
+ { ts:iso(86400000), payload:{ claim:"Your attention shifted from voice AI toward robotics and the context engine over the last month.", kind:"behavior", confidence:0.78 }, provenance:{ kind:"derived" } },
617
+ { ts:iso(86400000*2), payload:{ claim:"You consistently choose local-first, auditable tools over cloud convenience.", kind:"preference", confidence:0.86 }, provenance:{ kind:"derived" } },
618
+ { ts:iso(86400000*6), payload:{ claim:"You generate new ideas fastest right when your main bet stalls.", kind:"behavior", confidence:0.71 }, provenance:{ kind:"derived" } },
619
+ ],
620
+ voice: {
621
+ pack: "Write like this user: terse — short, declarative sentences; leads with imperatives, minimal preamble; states things flatly; no emoji; casual register (lowercases “i”); recurring phrasing: “be 100% sure”, “industry best practices”, “validate whether it's valid or not”.",
622
+ items: [
623
+ { dimension:"voice", pattern:"terse — short, declarative sentences", polarity:"does", confidence:0.85, evidence:"median 10 words/sentence", basis:"stylometry" },
624
+ { dimension:"voice", pattern:"leads with imperatives, minimal preamble", polarity:"does", confidence:0.75, evidence:"18% of sentences open with a command verb", basis:"stylometry" },
625
+ { dimension:"voice", pattern:"states things flatly; rarely hedges", polarity:"does", confidence:0.8, evidence:"hedging in 1% of sentences", basis:"stylometry" },
626
+ { dimension:"format", pattern:"no emoji", polarity:"avoids", confidence:0.7, evidence:"0 emoji across 1,700 prompts", basis:"stylometry" },
627
+ { dimension:"format", pattern:"casual register — lowercases “i”", polarity:"does", confidence:0.7, evidence:"“i” 254× vs “I” 125×", basis:"stylometry" },
628
+ { dimension:"emphasis", pattern:"be 100% sure", polarity:"insists", confidence:0.9, evidence:"113×", basis:"stylometry" },
629
+ { dimension:"emphasis", pattern:"industry best practices", polarity:"insists", confidence:0.82, evidence:"85×", basis:"stylometry" },
630
+ { dimension:"emphasis", pattern:"validate whether it's valid or not", polarity:"insists", confidence:0.8, evidence:"88×", basis:"stylometry" },
631
+ ],
632
+ },
633
+ };
634
+ }
179
635
  </script>
180
636
  </body>
181
637
  </html>