persnally 2.3.2 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/build/src/cli.js +81 -25
- package/build/src/daemon.js +3 -0
- package/build/src/dashboard.html +101 -20
- package/build/src/importers/chatgpt.js +3 -3
- package/build/src/importers/claude-code.js +1 -10
- package/build/src/importers/claude.js +2 -2
- package/build/src/importers/extract.d.ts +13 -0
- package/build/src/importers/extract.js +26 -0
- package/build/src/store.d.ts +25 -0
- package/build/src/store.js +67 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -39,7 +39,7 @@ open http://127.0.0.1:4983 # see it, with evidence for every claim
|
|
|
39
39
|
## How it works
|
|
40
40
|
|
|
41
41
|
```
|
|
42
|
-
Your AI clients (Claude, Cursor, agents…)
|
|
42
|
+
Your AI clients (Claude, Cursor, agents…) Importers (claude · claude-code · chatgpt · git)
|
|
43
43
|
│ MCP: context out, signals in │ your history → events
|
|
44
44
|
▼ ▼
|
|
45
45
|
┌──────────────────────── persnallyd (local daemon) ────────────────────────┐
|
package/build/src/cli.js
CHANGED
|
@@ -18,6 +18,7 @@ import { extractChatGPTEvents, parseChatGPTExport } from "./importers/chatgpt.js
|
|
|
18
18
|
import { extractClaudeEvents, parseClaudeExport } from "./importers/claude.js";
|
|
19
19
|
import { DEFAULT_TRANSCRIPTS_DIR, extractClaudeCodeEvents, parseClaudeCodeTranscripts, } from "./importers/claude-code.js";
|
|
20
20
|
import { gitEvents, scanRepos } from "./importers/git.js";
|
|
21
|
+
import { freshConversations } from "./importers/extract.js";
|
|
21
22
|
import { autostartInstalled, installAutostart, LOG_FILE, removeAutostart, removePidFile, runningPid, startDetached, stopDaemon, writePidFile, } from "./lifecycle.js";
|
|
22
23
|
import { newEvent } from "./events.js";
|
|
23
24
|
import { proseLines } from "./prose.js";
|
|
@@ -46,6 +47,7 @@ Usage:
|
|
|
46
47
|
persnallyd forget --all Delete all data
|
|
47
48
|
persnallyd forget --batch <id> Undo one import batch
|
|
48
49
|
persnallyd status Store stats and daemon health
|
|
50
|
+
persnallyd activity Context-read engagement over time (retention pulse)
|
|
49
51
|
persnallyd start [--port N] Start the daemon in the background
|
|
50
52
|
persnallyd stop Stop the background daemon
|
|
51
53
|
persnallyd serve [--port N] Run the daemon in the foreground (127.0.0.1:${DEFAULT_PORT})
|
|
@@ -249,44 +251,77 @@ async function main() {
|
|
|
249
251
|
const usage = "usage: persnallyd import claude|claude-code|chatgpt|git <path>";
|
|
250
252
|
if (!kind)
|
|
251
253
|
return die(usage);
|
|
252
|
-
|
|
253
|
-
if (kind === "
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const { parsed, sessionsFound, sessionsDropped } = parseClaudeCodeTranscripts(root);
|
|
257
|
-
if (!parsed.conversations.length)
|
|
258
|
-
return die(`No usable sessions found at ${root}`);
|
|
259
|
-
console.error(`Found ${sessionsFound} session(s)${sessionsDropped ? ` — importing the ${parsed.conversations.length} most recent` : ""}. ` +
|
|
260
|
-
`Extracting with ${engine.label}...`);
|
|
261
|
-
({ events, batch } = await extractClaudeCodeEvents(parsed, engine.extract, engine.model, root));
|
|
262
|
-
}
|
|
263
|
-
else if (!path) {
|
|
264
|
-
return die(usage);
|
|
265
|
-
}
|
|
266
|
-
else if (kind === "git") {
|
|
254
|
+
// Git: offline, deterministic. Dedup by repo so a re-run never doubles the graph.
|
|
255
|
+
if (kind === "git") {
|
|
256
|
+
if (!path)
|
|
257
|
+
return die(usage);
|
|
267
258
|
const authorIdx = args.indexOf("--author");
|
|
268
259
|
const summaries = scanRepos(path, authorIdx > -1 ? args[authorIdx + 1] : undefined);
|
|
269
260
|
if (!summaries.length)
|
|
270
261
|
return die(`No git repos with your commits found at ${path}`);
|
|
271
|
-
|
|
272
|
-
|
|
262
|
+
const store = new EventStore();
|
|
263
|
+
const seen = store.importedGitRepos();
|
|
264
|
+
const fresh = summaries.filter((s) => !seen.has(s.repo));
|
|
265
|
+
const skipped = summaries.length - fresh.length;
|
|
266
|
+
if (!fresh.length) {
|
|
267
|
+
store.close();
|
|
268
|
+
console.log(`All ${summaries.length} repo(s) already imported — nothing new.`);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
console.error(`Found ${summaries.length} repo(s)${skipped ? ` (${skipped} already imported)` : ""} — importing ${fresh.map((s) => `${s.repo} (${s.commits} commits)`).join(", ")}`);
|
|
272
|
+
const { events, batch } = gitEvents(fresh);
|
|
273
|
+
store.append(events);
|
|
274
|
+
store.rebuild();
|
|
275
|
+
store.close();
|
|
276
|
+
console.log(`Imported ${events.length} events from ${fresh.length} repo(s) (batch ${batch}).`);
|
|
277
|
+
console.log(`Undo with: persnallyd forget --batch ${batch}`);
|
|
278
|
+
return;
|
|
273
279
|
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
280
|
+
// Conversation imports: dedup by conversation_uuid so re-import only adds new chats.
|
|
281
|
+
let parsed;
|
|
282
|
+
let file = "conversations.json";
|
|
283
|
+
let parseNote = "";
|
|
284
|
+
if (kind === "claude-code") {
|
|
285
|
+
const root = path ?? DEFAULT_TRANSCRIPTS_DIR;
|
|
286
|
+
file = root;
|
|
287
|
+
const r = parseClaudeCodeTranscripts(root);
|
|
288
|
+
if (!r.parsed.conversations.length)
|
|
289
|
+
return die(`No usable sessions found at ${root}`);
|
|
290
|
+
parsed = r.parsed;
|
|
291
|
+
if (r.sessionsDropped)
|
|
292
|
+
parseNote = ` (most recent ${r.parsed.conversations.length} of ${r.sessionsFound})`;
|
|
293
|
+
}
|
|
294
|
+
else if (kind === "claude") {
|
|
295
|
+
if (!path)
|
|
296
|
+
return die(usage);
|
|
297
|
+
parsed = parseClaudeExport(path);
|
|
298
|
+
}
|
|
299
|
+
else if (kind === "chatgpt") {
|
|
300
|
+
if (!path)
|
|
301
|
+
return die(usage);
|
|
302
|
+
parsed = parseChatGPTExport(path);
|
|
281
303
|
}
|
|
282
304
|
else {
|
|
283
305
|
return die(`unknown import source "${kind}" — use claude, claude-code, chatgpt, or git`);
|
|
284
306
|
}
|
|
285
307
|
const store = new EventStore();
|
|
308
|
+
const seen = store.importedConversationUuids(`import:${kind}`);
|
|
309
|
+
const { parsed: toExtract, skipped, firstImport } = freshConversations(parsed, seen);
|
|
310
|
+
if (!toExtract.conversations.length && !firstImport) {
|
|
311
|
+
store.close();
|
|
312
|
+
console.log(`Already up to date — all ${parsed.conversations.length} conversation(s) imported. Nothing new.`);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const engine = await chooseExtractor("extract");
|
|
316
|
+
console.error(`Parsed ${parsed.conversations.length} conversation(s)${parseNote}${skipped ? ` — ${skipped} already imported` : ""}. ` +
|
|
317
|
+
`Extracting ${toExtract.conversations.length} with ${engine.label}...`);
|
|
318
|
+
const { events, batch } = await (kind === "claude-code" ? extractClaudeCodeEvents(toExtract, engine.extract, engine.model, file)
|
|
319
|
+
: kind === "claude" ? extractClaudeEvents(toExtract, engine.extract, engine.model)
|
|
320
|
+
: extractChatGPTEvents(toExtract, engine.extract, engine.model));
|
|
286
321
|
store.append(events);
|
|
287
322
|
store.rebuild();
|
|
288
323
|
store.close();
|
|
289
|
-
console.log(`Imported ${events.length} events (batch ${batch}).`);
|
|
324
|
+
console.log(`Imported ${events.length} events from ${toExtract.conversations.length} conversation(s) (batch ${batch}).`);
|
|
290
325
|
console.log(`Undo with: persnallyd forget --batch ${batch}`);
|
|
291
326
|
return;
|
|
292
327
|
}
|
|
@@ -428,6 +463,22 @@ async function main() {
|
|
|
428
463
|
console.log(`Autostart: ${autostartInstalled() ? "installed" : "not installed"}`);
|
|
429
464
|
return;
|
|
430
465
|
}
|
|
466
|
+
case "activity": {
|
|
467
|
+
const store = new EventStore();
|
|
468
|
+
const a = store.activity();
|
|
469
|
+
store.close();
|
|
470
|
+
if (!a.firstEventAt) {
|
|
471
|
+
console.log("No activity yet — run an import or connect a client.");
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
const verdict = a.retainedWeek2 === null ? `in progress (day ${a.daysSinceFirst}/14)` : a.retainedWeek2 ? "active ✓" : "inactive ✗";
|
|
475
|
+
console.log(`Onboarded ${a.daysSinceFirst}d ago · ${a.totalReads} context read(s) total`);
|
|
476
|
+
console.log(`Reads: ${a.reads7d} this week · ${a.reads30d} this month`);
|
|
477
|
+
console.log(`Active: ${a.activeDays7d}/7 days · ${a.activeDays14d}/14 days`);
|
|
478
|
+
console.log(`Week-2 retention: ${verdict}`);
|
|
479
|
+
console.log(`Last 14 days: ${sparkline(a.daily.map((d) => d.reads))}`);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
431
482
|
case "start": {
|
|
432
483
|
const existing = runningPid();
|
|
433
484
|
if (existing)
|
|
@@ -493,6 +544,11 @@ async function main() {
|
|
|
493
544
|
process.exitCode = cmd ? 1 : 0;
|
|
494
545
|
}
|
|
495
546
|
}
|
|
547
|
+
function sparkline(values) {
|
|
548
|
+
const blocks = "▁▂▃▄▅▆▇█";
|
|
549
|
+
const max = Math.max(...values, 1);
|
|
550
|
+
return values.map((v) => (v === 0 ? "·" : blocks[Math.min(blocks.length - 1, Math.floor((v / max) * (blocks.length - 1)))])).join("");
|
|
551
|
+
}
|
|
496
552
|
function summarize(payload) {
|
|
497
553
|
const s = JSON.stringify(payload);
|
|
498
554
|
return s.length > 80 ? s.slice(0, 77) + "..." : s;
|
package/build/src/daemon.js
CHANGED
|
@@ -41,6 +41,9 @@ export function startDaemon(store, port = DEFAULT_PORT) {
|
|
|
41
41
|
if (req.method === "GET" && url.pathname === "/stats") {
|
|
42
42
|
return json(res, 200, store.stats());
|
|
43
43
|
}
|
|
44
|
+
if (req.method === "GET" && url.pathname === "/activity") {
|
|
45
|
+
return json(res, 200, store.activity());
|
|
46
|
+
}
|
|
44
47
|
if (req.method === "GET" && url.pathname === "/topics") {
|
|
45
48
|
const client = url.searchParams.get("client");
|
|
46
49
|
const allowed = client ? allowedCategories(client) : null;
|
package/build/src/dashboard.html
CHANGED
|
@@ -109,7 +109,7 @@
|
|
|
109
109
|
/* ── constellation ── */
|
|
110
110
|
.constellation-wrap { position: relative; }
|
|
111
111
|
.constellation { background: var(--panel); border: 1px solid var(--line); border-radius: var(--r); overflow: hidden; }
|
|
112
|
-
#graph { display: block; width: 100%; height: 520px; cursor: grab; }
|
|
112
|
+
#graph { display: block; width: 100%; height: 520px; cursor: grab; touch-action: none; }
|
|
113
113
|
#graph:active { cursor: grabbing; }
|
|
114
114
|
.ghint { position: absolute; top: 12px; right: 14px; font-size: 11px; color: var(--faint); pointer-events: none; }
|
|
115
115
|
.legend { position: absolute; bottom: 12px; left: 14px; display: flex; gap: 14px; flex-wrap: wrap; font-size: 11px; color: var(--dim); pointer-events: none; }
|
|
@@ -153,6 +153,11 @@
|
|
|
153
153
|
.read .what .l1 b { color: var(--text); } .read .what .l1 .p { color: var(--dim); }
|
|
154
154
|
.read .what .l2 { font-size: 11px; color: var(--faint); margin-top: 2px; }
|
|
155
155
|
.read .when { font-size: 11.5px; color: var(--dim); white-space: nowrap; }
|
|
156
|
+
.engage { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; font-size: 12.5px; color: var(--dim); margin: 0 0 16px; }
|
|
157
|
+
.engage b { color: var(--text); font-weight: 600; }
|
|
158
|
+
.engage .spark { display: inline-flex; align-items: flex-end; gap: 2px; height: 22px; }
|
|
159
|
+
.engage .spark i { width: 6px; min-height: 2px; border-radius: 1px; display: inline-block; }
|
|
160
|
+
.engage .rk { color: var(--faint); }
|
|
156
161
|
|
|
157
162
|
/* ── reflection ── */
|
|
158
163
|
.refl { border-left: 2px solid var(--line-2); padding: 4px 0 4px 16px; margin-bottom: 16px; }
|
|
@@ -173,6 +178,17 @@
|
|
|
173
178
|
.preview-ribbon code { color: var(--text); }
|
|
174
179
|
a { color: var(--text); }
|
|
175
180
|
:focus-visible { outline: 2px solid #FFFFFF; outline-offset: 3px; border-radius: 4px; }
|
|
181
|
+
@media (max-width: 640px) {
|
|
182
|
+
.wrap { padding: 18px 16px 96px; }
|
|
183
|
+
#graph { height: 380px; }
|
|
184
|
+
.archetype { font-size: clamp(22px, 7vw, 30px); }
|
|
185
|
+
header { margin-bottom: 24px; }
|
|
186
|
+
.value-num { font-size: 32px; }
|
|
187
|
+
#topicList { overflow-x: auto; } /* wide table scrolls inside its card — body never does */
|
|
188
|
+
.bar-track { width: 64px; }
|
|
189
|
+
td { padding: 8px 6px; font-size: 13px; }
|
|
190
|
+
.del { padding: 3px 7px; }
|
|
191
|
+
}
|
|
176
192
|
@media (prefers-reduced-motion: reduce) { .reveal { animation: none; opacity: 1; transform: none; } .read.fresh { animation: none; } }
|
|
177
193
|
</style>
|
|
178
194
|
</head>
|
|
@@ -215,7 +231,7 @@
|
|
|
215
231
|
<div class="view-toggle"><button id="vGraph" class="on">map</button><button id="vList">list</button></div>
|
|
216
232
|
</div>
|
|
217
233
|
<div class="constellation-wrap">
|
|
218
|
-
<div class="constellation" id="graphHost"><canvas id="graph"></canvas><div class="legend" id="legend"></div><div class="ghint">drag · scroll to zoom
|
|
234
|
+
<div class="constellation" id="graphHost"><canvas id="graph"></canvas><div class="legend" id="legend"></div><div class="ghint">drag · pinch or scroll to zoom</div></div>
|
|
219
235
|
<div class="card" id="topicList" style="display:none;padding:6px 14px"><table id="topics"></table></div>
|
|
220
236
|
<div class="node-pop" id="nodePop"></div>
|
|
221
237
|
</div>
|
|
@@ -227,6 +243,7 @@
|
|
|
227
243
|
<div class="value-num num" id="valueNum">0</div>
|
|
228
244
|
<div class="value-cap" id="valueCap"></div>
|
|
229
245
|
</div>
|
|
246
|
+
<div class="engage" id="engage"></div>
|
|
230
247
|
<div class="card" id="reads" style="padding:6px 16px"></div>
|
|
231
248
|
</section>
|
|
232
249
|
|
|
@@ -276,6 +293,20 @@ function clientOf(ev) {
|
|
|
276
293
|
}
|
|
277
294
|
const prettyClient = (c) => ({ "claude-code":"Claude Code","claude-desktop":"Claude Desktop","cursor":"Cursor","cli":"CLI" }[c] || c);
|
|
278
295
|
const clientInitials = (c) => prettyClient(c).split(/[\s-]/).map(w=>w[0]).join("").slice(0,2).toUpperCase();
|
|
296
|
+
const IMPORT_TOOL = { "import:claude":"Claude export", "import:chatgpt":"ChatGPT export", "import:claude-code":"Claude Code" };
|
|
297
|
+
// Human-readable provenance for the "why?" panel — where a fact actually came from.
|
|
298
|
+
function provLabel(e) {
|
|
299
|
+
const p = e.provenance || {};
|
|
300
|
+
if (p.kind === "import") {
|
|
301
|
+
const base = IMPORT_TOOL[e.source] || p.file || "import";
|
|
302
|
+
return p.conversation_uuid ? `${base} · conversation ${String(p.conversation_uuid).slice(0,8)}` : base;
|
|
303
|
+
}
|
|
304
|
+
if (p.kind === "mcp") return `${prettyClient(p.client)}${p.session ? " · session " + String(p.session).slice(0,8) : ""} · live`;
|
|
305
|
+
if (p.kind === "git") return `git: ${p.repo}${p.ref ? " @ " + p.ref : ""}`;
|
|
306
|
+
if (p.kind === "derived") return `inferred from ${(p.from||[]).length} signal${(p.from||[]).length===1?"":"s"}`;
|
|
307
|
+
if (p.kind === "local") return `you · ${p.surface || "local"}`;
|
|
308
|
+
return p.kind || "local";
|
|
309
|
+
}
|
|
279
310
|
|
|
280
311
|
const SNAP_KEY = "persnally.snapshot.v1";
|
|
281
312
|
let DEMO = false;
|
|
@@ -344,8 +375,7 @@ async function toggleEvidence(btn) {
|
|
|
344
375
|
box.innerHTML = events.map(e => {
|
|
345
376
|
const p = e.payload || {};
|
|
346
377
|
const summary = e.type === "signal.topic" ? `${p.topic} (${p.intent})` : (p.claim ?? p.topic ?? JSON.stringify(p).slice(0,90));
|
|
347
|
-
|
|
348
|
-
return `<div class="ev-item"><code>${esc(e.type)}</code> ${esc(summary)} — <i>${esc(origin)}, ${esc((e.ts||"").slice(0,10))}</i></div>`;
|
|
378
|
+
return `<div class="ev-item"><code>${esc(e.type)}</code> ${esc(summary)} — <i>${esc(provLabel(e))}, ${esc((e.ts||"").slice(0,10))}</i></div>`;
|
|
349
379
|
}).join("") || `<div class="ev-item">evidence not found (deleted?)</div>`;
|
|
350
380
|
box.dataset.loaded = "1";
|
|
351
381
|
}
|
|
@@ -393,6 +423,22 @@ function renderReads(reads, total) {
|
|
|
393
423
|
}).join("");
|
|
394
424
|
}
|
|
395
425
|
|
|
426
|
+
/* ── engagement (retention pulse) ── */
|
|
427
|
+
function renderEngage(a) {
|
|
428
|
+
const el = $("engage");
|
|
429
|
+
if (!a || !a.firstEventAt || !a.daily) { el.innerHTML = ""; return; }
|
|
430
|
+
const max = Math.max(...a.daily.map((d) => d.reads), 1);
|
|
431
|
+
const bars = a.daily.map((d, i) => {
|
|
432
|
+
const today = i === a.daily.length - 1;
|
|
433
|
+
const h = d.reads ? Math.max(3, Math.round((d.reads / max) * 22)) : 2;
|
|
434
|
+
const c = d.reads ? (today ? "var(--text)" : "var(--dim)") : "var(--line-2)";
|
|
435
|
+
return `<i style="height:${h}px;background:${c}" title="${esc(d.date)}: ${d.reads}"></i>`;
|
|
436
|
+
}).join("");
|
|
437
|
+
const ret = a.retainedWeek2 === null ? `day ${a.daysSinceFirst}/14` : (a.retainedWeek2 ? "active ✓" : "inactive ✗");
|
|
438
|
+
el.innerHTML = `<span><b>${a.reads7d}</b> this week</span><span>active <b>${a.activeDays14d}</b> of last 14 days</span>` +
|
|
439
|
+
`<span class="spark" title="context reads, last 14 days">${bars}</span><span class="rk">week-2: ${esc(ret)}</span>`;
|
|
440
|
+
}
|
|
441
|
+
|
|
396
442
|
/* ── reflections ── */
|
|
397
443
|
function renderReflections(assertions) {
|
|
398
444
|
const behav = assertions.filter(a => a.payload && (a.payload.kind==="behavior" || a.payload.kind==="preference"));
|
|
@@ -436,7 +482,7 @@ function renderMap(topics) {
|
|
|
436
482
|
const ctx = canvas.getContext("2d");
|
|
437
483
|
const dpr = Math.min(devicePixelRatio || 1, 2);
|
|
438
484
|
let w, h;
|
|
439
|
-
const resize = () => { w = host.clientWidth; h = 520; canvas.width = w*dpr; canvas.height = h*dpr; canvas.style.height = h+"px"; };
|
|
485
|
+
const resize = () => { w = host.clientWidth; h = window.innerWidth < 640 ? 380 : 520; canvas.width = w*dpr; canvas.height = h*dpr; canvas.style.height = h+"px"; };
|
|
440
486
|
resize();
|
|
441
487
|
|
|
442
488
|
const maxW = Math.max(...data.map(t=>t.weight), 0.01);
|
|
@@ -510,24 +556,55 @@ function renderMap(topics) {
|
|
|
510
556
|
$("legend").innerHTML = cats.map(c => `<span><i style="color:${catColor(c)}"></i><span style="color:var(--dim)">${esc(c)}</span></span>`).join("");
|
|
511
557
|
|
|
512
558
|
const pop=$("nodePop");
|
|
513
|
-
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+
|
|
559
|
+
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; };
|
|
514
560
|
const rel=(e)=>{ const r=canvas.getBoundingClientRect(); return [e.clientX-r.left, e.clientY-r.top]; };
|
|
515
|
-
|
|
516
|
-
|
|
561
|
+
function showPop(n) { const p=nodes[n].t;
|
|
562
|
+
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>`:""}`;
|
|
563
|
+
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"); }
|
|
564
|
+
|
|
565
|
+
// Pointer events unify mouse + touch + pen; a second pointer drives pinch-zoom, a tap inspects.
|
|
566
|
+
const pts=new Map();
|
|
567
|
+
let downX=0, downY=0, moved=false, pinchD=0, pinchX=0, pinchY=0;
|
|
568
|
+
canvas.onpointerdown=(e)=>{
|
|
569
|
+
const [mx,my]=rel(e); pts.set(e.pointerId,{x:mx,y:my});
|
|
570
|
+
try { canvas.setPointerCapture(e.pointerId); } catch { /* capture unsupported — degrade to no-capture */ }
|
|
571
|
+
if (pts.size>=2) { dragNode=-1; panning=false; pop.classList.remove("show"); hover=-1;
|
|
572
|
+
const [a,b]=[...pts.values()]; pinchD=Math.hypot(a.x-b.x,a.y-b.y)||1; pinchX=(a.x+b.x)/2; pinchY=(a.y+b.y)/2; return; }
|
|
573
|
+
downX=mx; downY=my; moved=false; lastX=mx; lastY=my;
|
|
574
|
+
const n=nodeAt(mx,my); if (n>=0) dragNode=n; else panning=true;
|
|
575
|
+
};
|
|
576
|
+
canvas.onpointermove=(e)=>{
|
|
577
|
+
const [mx,my]=rel(e); if (pts.has(e.pointerId)) pts.set(e.pointerId,{x:mx,y:my});
|
|
578
|
+
if (pts.size>=2) { const [a,b]=[...pts.values()];
|
|
579
|
+
const d=Math.hypot(a.x-b.x,a.y-b.y)||1, cx=(a.x+b.x)/2, cy=(a.y+b.y)/2, wx=S2Wx(cx), wy=S2Wy(cy);
|
|
580
|
+
cam.k=Math.max(0.45,Math.min(3,cam.k*(d/pinchD)));
|
|
581
|
+
cam.x=cx-wx*cam.k+(cx-pinchX); cam.y=cy-wy*cam.k+(cy-pinchY);
|
|
582
|
+
pinchD=d; pinchX=cx; pinchY=cy; if(!alive)draw(); return; }
|
|
583
|
+
if ((dragNode>=0||panning) && (Math.abs(mx-downX)>4||Math.abs(my-downY)>4)) moved=true;
|
|
517
584
|
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; }
|
|
518
585
|
if (panning) { cam.x+=mx-lastX; cam.y+=my-lastY; lastX=mx; lastY=my; if(!alive)draw(); return; }
|
|
586
|
+
if (e.pointerType==="touch") return; // no hover on touch — a tap inspects instead
|
|
519
587
|
if (mx<0||my<0||mx>w||my>h) { if (hover!==-1){hover=-1;pop.classList.remove("show");if(!alive)draw();} return; }
|
|
520
588
|
const n=nodeAt(mx,my);
|
|
521
|
-
if (n!==hover) { hover=n; if(!alive)draw();
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
589
|
+
if (n!==hover) { hover=n; if(!alive)draw(); if (n>=0) showPop(n); else pop.classList.remove("show"); }
|
|
590
|
+
};
|
|
591
|
+
const endPointer=(e)=>{
|
|
592
|
+
const tapped = (!moved && pts.size<=1 && e.pointerType==="touch") ? nodeAt(...rel(e)) : -1;
|
|
593
|
+
pts.delete(e.pointerId);
|
|
594
|
+
if (dragNode>=0) { dragNode=-1; wake(); }
|
|
595
|
+
panning=false;
|
|
596
|
+
if (tapped>=0) { if (hover===tapped && pop.classList.contains("show")) { hover=-1; pop.classList.remove("show"); } else { hover=tapped; showPop(tapped); } if(!alive)draw(); }
|
|
597
|
+
else if (!moved && e.pointerType==="touch") { hover=-1; pop.classList.remove("show"); if(!alive)draw(); }
|
|
598
|
+
if (pts.size===1) { const [p]=[...pts.values()]; lastX=p.x; lastY=p.y; panning=true; moved=true; } // pinch → one finger: resume pan
|
|
599
|
+
};
|
|
600
|
+
canvas.onpointerup=endPointer; canvas.onpointercancel=endPointer;
|
|
526
601
|
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(); };
|
|
527
602
|
canvas.ondblclick=()=>{ cam.k=1; cam.x=0; cam.y=0; if(!alive)draw(); };
|
|
528
603
|
let rt; const onWinResize=()=>{ clearTimeout(rt); rt=setTimeout(()=>{ resize(); if(!alive)draw(); },150); };
|
|
529
|
-
window.addEventListener("
|
|
530
|
-
mapCleanup=()=>{ window.removeEventListener("
|
|
604
|
+
window.addEventListener("resize", onWinResize, { passive:true });
|
|
605
|
+
mapCleanup=()=>{ window.removeEventListener("resize",onWinResize); };
|
|
606
|
+
|
|
607
|
+
if (window.innerWidth < 640) switchView("list"); // phones land on the list; the map is one tap away
|
|
531
608
|
}
|
|
532
609
|
function switchView(v) {
|
|
533
610
|
const g = v==="graph" || v==="map";
|
|
@@ -539,21 +616,22 @@ $("vList").onclick = () => switchView("list");
|
|
|
539
616
|
|
|
540
617
|
/* ── load ── */
|
|
541
618
|
async function loadAll() {
|
|
542
|
-
let stats, profile, topics, reads, assertions, voice;
|
|
619
|
+
let stats, profile, topics, reads, assertions, voice, activity;
|
|
543
620
|
try {
|
|
544
|
-
[stats, profile, topics, reads, assertions, voice] = await Promise.all([
|
|
621
|
+
[stats, profile, topics, reads, assertions, voice, activity] = await Promise.all([
|
|
545
622
|
get("/stats"), get("/profile"), get("/topics?limit=40"),
|
|
546
|
-
get("/events?type=context.read&limit=60"), get("/events?type=signal.assertion&limit=20"), get("/voice"),
|
|
623
|
+
get("/events?type=context.read&limit=60"), get("/events?type=signal.assertion&limit=20"), get("/voice"), get("/activity"),
|
|
547
624
|
]);
|
|
548
625
|
if (!stats) throw new Error("no daemon");
|
|
549
626
|
} catch (e) {
|
|
550
627
|
DEMO = true; $("ribbon").classList.add("show");
|
|
551
|
-
({ stats, profile, topics, reads, assertions, voice } = DEMO_DATA());
|
|
628
|
+
({ stats, profile, topics, reads, assertions, voice, activity } = DEMO_DATA());
|
|
552
629
|
}
|
|
553
630
|
topics = topics||[]; reads = reads||[]; assertions = assertions||[];
|
|
554
631
|
const st = $("status");
|
|
555
632
|
st.querySelector(".dot").classList.toggle("off", DEMO);
|
|
556
633
|
st.querySelector(".lbl").textContent = DEMO ? "preview" : "active";
|
|
634
|
+
if (!DEMO) get("/health").then(h => { if (h && h.version) $("ver").textContent = `· v${h.version}`; });
|
|
557
635
|
const total = (stats.byType && stats.byType["context.read"]) || reads.length;
|
|
558
636
|
renderDelta(topics, reads, assertions);
|
|
559
637
|
renderScaleBeat(stats);
|
|
@@ -562,6 +640,7 @@ async function loadAll() {
|
|
|
562
640
|
renderTopicList(topics);
|
|
563
641
|
renderMap(topics);
|
|
564
642
|
renderReads(reads, total);
|
|
643
|
+
renderEngage(activity);
|
|
565
644
|
renderReflections(assertions);
|
|
566
645
|
saveSnapshot(topics);
|
|
567
646
|
}
|
|
@@ -579,9 +658,10 @@ $("reflect").onclick = async () => {
|
|
|
579
658
|
};
|
|
580
659
|
setInterval(async () => {
|
|
581
660
|
if (DEMO) return;
|
|
582
|
-
const [reads, stats, voice] = await Promise.all([get("/events?type=context.read&limit=60"), get("/stats"), get("/voice")]);
|
|
661
|
+
const [reads, stats, voice, activity] = await Promise.all([get("/events?type=context.read&limit=60"), get("/stats"), get("/voice"), get("/activity")]);
|
|
583
662
|
if (reads && stats) renderReads(reads, (stats.byType && stats.byType["context.read"]) || reads.length);
|
|
584
663
|
if (voice) renderVoice(voice); // live: voice signals captured mid-session show up without a reload
|
|
664
|
+
if (activity) renderEngage(activity);
|
|
585
665
|
}, 25000);
|
|
586
666
|
|
|
587
667
|
loadAll();
|
|
@@ -627,6 +707,7 @@ function DEMO_DATA() {
|
|
|
627
707
|
{ ts:iso(86400000*2), payload:{ claim:"You consistently choose local-first, auditable tools over cloud convenience.", kind:"preference", confidence:0.86 }, provenance:{ kind:"derived" } },
|
|
628
708
|
{ 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" } },
|
|
629
709
|
],
|
|
710
|
+
activity: { firstEventAt: iso(86400000*214), lastReadAt: iso(3600000*2), daysSinceFirst: 214, totalReads: 1247, reads7d: 33, reads30d: 142, activeDays7d: 5, activeDays14d: 12, retainedWeek2: true, daily: [2,0,3,5,1,4,6,2,0,3,7,5,8,4].map((r,i)=>({ date:new Date(now-(13-i)*86400000).toISOString().slice(0,10), reads:r })) },
|
|
630
711
|
voice: {
|
|
631
712
|
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”.",
|
|
632
713
|
items: [
|
|
@@ -3,16 +3,16 @@
|
|
|
3
3
|
* conversation as a node tree ("mapping"); user text lives in author.role
|
|
4
4
|
* === "user" nodes with content.parts. Multimodal parts are skipped.
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
6
|
+
import { existsSync, statSync } from "node:fs";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import { safeIso } from "../events.js";
|
|
9
9
|
import { anthropicExtract, DEFAULT_EXTRACT_MODEL } from "../llm.js";
|
|
10
|
-
import { extractEvents } from "./extract.js";
|
|
10
|
+
import { extractEvents, readImportFile } from "./extract.js";
|
|
11
11
|
export function parseChatGPTExport(path) {
|
|
12
12
|
const file = statSync(path).isDirectory() ? join(path, "conversations.json") : path;
|
|
13
13
|
if (!existsSync(file))
|
|
14
14
|
throw new Error(`No conversations.json at ${path}`);
|
|
15
|
-
const raw = JSON.parse(
|
|
15
|
+
const raw = JSON.parse(readImportFile(file));
|
|
16
16
|
const conversations = raw.map((c) => {
|
|
17
17
|
const nodes = Object.values(c.mapping ?? {})
|
|
18
18
|
.filter((n) => n.message?.author?.role === "user")
|
|
@@ -108,7 +108,7 @@ export async function importNewClaudeCodeSessions(store, extract, model = DEFAUL
|
|
|
108
108
|
if (!existsSync(root))
|
|
109
109
|
return { newSessions: 0, events: 0, skipped: 0 };
|
|
110
110
|
const { parsed } = parseClaudeCodeTranscripts(root);
|
|
111
|
-
const seen = importedConversationUuids(
|
|
111
|
+
const seen = store.importedConversationUuids("import:claude-code");
|
|
112
112
|
const fresh = parsed.conversations.filter((c) => !seen.has(c.uuid));
|
|
113
113
|
const skipped = parsed.conversations.length - fresh.length;
|
|
114
114
|
if (!fresh.length)
|
|
@@ -117,12 +117,3 @@ export async function importNewClaudeCodeSessions(store, extract, model = DEFAUL
|
|
|
117
117
|
store.append(events);
|
|
118
118
|
return { newSessions: fresh.length, events: events.length, skipped };
|
|
119
119
|
}
|
|
120
|
-
function importedConversationUuids(store) {
|
|
121
|
-
const uuids = new Set();
|
|
122
|
-
for (const e of store.query({ source: "import:claude-code", limit: 1_000_000 })) {
|
|
123
|
-
const uuid = e.provenance.conversation_uuid;
|
|
124
|
-
if (uuid)
|
|
125
|
-
uuids.add(uuid);
|
|
126
|
-
}
|
|
127
|
-
return uuids;
|
|
128
|
-
}
|
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { anthropicExtract, DEFAULT_EXTRACT_MODEL } from "../llm.js";
|
|
8
|
-
import { extractEvents } from "./extract.js";
|
|
8
|
+
import { extractEvents, readImportFile } from "./extract.js";
|
|
9
9
|
export function parseClaudeExport(dir) {
|
|
10
10
|
const convPath = join(dir, "conversations.json");
|
|
11
11
|
if (!existsSync(convPath))
|
|
12
12
|
throw new Error(`No conversations.json in ${dir}`);
|
|
13
|
-
const raw = JSON.parse(
|
|
13
|
+
const raw = JSON.parse(readImportFile(convPath));
|
|
14
14
|
const conversations = raw.map((c) => ({
|
|
15
15
|
uuid: String(c.uuid ?? ""),
|
|
16
16
|
name: String(c.name ?? ""),
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { type PersnallyEvent } from "../events.js";
|
|
6
6
|
import { type LlmExtract } from "../llm.js";
|
|
7
|
+
/** Reads an export file, refusing oversized ones with a clear message instead of an opaque OOM/crash. */
|
|
8
|
+
export declare function readImportFile(path: string, maxBytes?: number): string;
|
|
7
9
|
export interface ParsedConversation {
|
|
8
10
|
uuid: string;
|
|
9
11
|
name: string;
|
|
@@ -24,6 +26,17 @@ export interface ImportResult {
|
|
|
24
26
|
batch: string;
|
|
25
27
|
conversationsProcessed: number;
|
|
26
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Filters a parsed export to the conversations not already imported (matched by
|
|
31
|
+
* uuid), so a re-import only adds new chats instead of doubling the graph. The
|
|
32
|
+
* one-time memory/projects snapshot carries no per-conversation id, so it's kept
|
|
33
|
+
* only on the first import of a source.
|
|
34
|
+
*/
|
|
35
|
+
export declare function freshConversations(parsed: ParsedExport, seen: Set<string>): {
|
|
36
|
+
parsed: ParsedExport;
|
|
37
|
+
skipped: number;
|
|
38
|
+
firstImport: boolean;
|
|
39
|
+
};
|
|
27
40
|
export declare function extractEvents(parsed: ParsedExport, opts: {
|
|
28
41
|
source: string;
|
|
29
42
|
importer: string;
|
|
@@ -2,12 +2,38 @@
|
|
|
2
2
|
* Shared extraction pipeline for conversation-export importers.
|
|
3
3
|
* Parsers produce a ParsedExport; this turns it into provenance-linked events.
|
|
4
4
|
*/
|
|
5
|
+
import { readFileSync, statSync } from "node:fs";
|
|
5
6
|
import { z } from "zod";
|
|
6
7
|
import { newEvent, safeIso, uuidv7, PAYLOAD_SCHEMAS } from "../events.js";
|
|
7
8
|
import { anthropicExtract, DEFAULT_EXTRACT_MODEL } from "../llm.js";
|
|
8
9
|
import { proseLines, stripNoise } from "../prose.js";
|
|
9
10
|
import { analyzeVoice } from "../stylometry.js";
|
|
10
11
|
const MAX_CONVO_CHARS = 30_000;
|
|
12
|
+
const MAX_IMPORT_FILE_BYTES = 400 * 1024 * 1024; // ~400 MB — under Node's ~512 MB string cap; larger needs streaming
|
|
13
|
+
/** Reads an export file, refusing oversized ones with a clear message instead of an opaque OOM/crash. */
|
|
14
|
+
export function readImportFile(path, maxBytes = MAX_IMPORT_FILE_BYTES) {
|
|
15
|
+
const { size } = statSync(path);
|
|
16
|
+
if (size > maxBytes) {
|
|
17
|
+
throw new Error(`${path} is ${Math.round(size / 1e6)} MB, over the ${Math.round(maxBytes / 1e6)} MB import limit. ` +
|
|
18
|
+
`Very large exports aren't supported yet — import a smaller export or split conversations.json.`);
|
|
19
|
+
}
|
|
20
|
+
return readFileSync(path, "utf-8");
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Filters a parsed export to the conversations not already imported (matched by
|
|
24
|
+
* uuid), so a re-import only adds new chats instead of doubling the graph. The
|
|
25
|
+
* one-time memory/projects snapshot carries no per-conversation id, so it's kept
|
|
26
|
+
* only on the first import of a source.
|
|
27
|
+
*/
|
|
28
|
+
export function freshConversations(parsed, seen) {
|
|
29
|
+
const firstImport = seen.size === 0;
|
|
30
|
+
const conversations = parsed.conversations.filter((c) => !c.uuid || !seen.has(c.uuid));
|
|
31
|
+
const skipped = parsed.conversations.length - conversations.length;
|
|
32
|
+
const next = firstImport
|
|
33
|
+
? { ...parsed, conversations }
|
|
34
|
+
: { ...parsed, conversations, memoryText: "", projects: [] };
|
|
35
|
+
return { parsed: next, skipped, firstImport };
|
|
36
|
+
}
|
|
11
37
|
const topicsExtraction = z.object({ topics: z.array(PAYLOAD_SCHEMAS["signal.topic"]) });
|
|
12
38
|
const assertionsExtraction = z.object({ assertions: z.array(PAYLOAD_SCHEMAS["signal.assertion"]) });
|
|
13
39
|
export async function extractEvents(parsed, opts, extract = anthropicExtract, model = DEFAULT_EXTRACT_MODEL) {
|
package/build/src/store.d.ts
CHANGED
|
@@ -35,6 +35,21 @@ export interface StoredProfile {
|
|
|
35
35
|
generated_at: string;
|
|
36
36
|
model: string;
|
|
37
37
|
}
|
|
38
|
+
export interface Activity {
|
|
39
|
+
firstEventAt: string | null;
|
|
40
|
+
lastReadAt: string | null;
|
|
41
|
+
daysSinceFirst: number;
|
|
42
|
+
totalReads: number;
|
|
43
|
+
reads7d: number;
|
|
44
|
+
reads30d: number;
|
|
45
|
+
activeDays7d: number;
|
|
46
|
+
activeDays14d: number;
|
|
47
|
+
retainedWeek2: boolean | null;
|
|
48
|
+
daily: {
|
|
49
|
+
date: string;
|
|
50
|
+
reads: number;
|
|
51
|
+
}[];
|
|
52
|
+
}
|
|
38
53
|
export declare class EventStore {
|
|
39
54
|
private db;
|
|
40
55
|
constructor(path?: string);
|
|
@@ -42,6 +57,10 @@ export declare class EventStore {
|
|
|
42
57
|
append(events: PersnallyEvent[]): number;
|
|
43
58
|
query(opts?: QueryOpts): PersnallyEvent[];
|
|
44
59
|
getEvents(ids: string[]): PersnallyEvent[];
|
|
60
|
+
/** conversation_uuids already imported from a source — lets a re-import top up only new chats, never double. */
|
|
61
|
+
importedConversationUuids(source: string): Set<string>;
|
|
62
|
+
/** repo names already imported via `import git` — lets a re-import skip repos already on file. */
|
|
63
|
+
importedGitRepos(): Set<string>;
|
|
45
64
|
stats(): {
|
|
46
65
|
total: number;
|
|
47
66
|
byType: Record<string, number>;
|
|
@@ -49,6 +68,12 @@ export declare class EventStore {
|
|
|
49
68
|
first: string | null;
|
|
50
69
|
last: string | null;
|
|
51
70
|
};
|
|
71
|
+
/**
|
|
72
|
+
* Engagement over time from context.read events — the retention pulse.
|
|
73
|
+
* Local/per-install only (this machine); aggregate cohort retention would
|
|
74
|
+
* require opt-in telemetry. `now` is injectable for deterministic tests.
|
|
75
|
+
*/
|
|
76
|
+
activity(now?: number): Activity;
|
|
52
77
|
topics(limit?: number): TopicRow[];
|
|
53
78
|
/** Re-derive view_topics from signal.topic events using decayed per-signal weighting. */
|
|
54
79
|
rebuild(now?: number): void;
|
package/build/src/store.js
CHANGED
|
@@ -123,6 +123,26 @@ export class EventStore {
|
|
|
123
123
|
.all(...ids)
|
|
124
124
|
.map(rowToEvent);
|
|
125
125
|
}
|
|
126
|
+
/** conversation_uuids already imported from a source — lets a re-import top up only new chats, never double. */
|
|
127
|
+
importedConversationUuids(source) {
|
|
128
|
+
const uuids = new Set();
|
|
129
|
+
for (const e of this.query({ source, limit: 1_000_000 })) {
|
|
130
|
+
const uuid = e.provenance.conversation_uuid;
|
|
131
|
+
if (uuid)
|
|
132
|
+
uuids.add(uuid);
|
|
133
|
+
}
|
|
134
|
+
return uuids;
|
|
135
|
+
}
|
|
136
|
+
/** repo names already imported via `import git` — lets a re-import skip repos already on file. */
|
|
137
|
+
importedGitRepos() {
|
|
138
|
+
const repos = new Set();
|
|
139
|
+
for (const e of this.query({ source: "import:git", limit: 1_000_000 })) {
|
|
140
|
+
const repo = e.provenance.repo;
|
|
141
|
+
if (repo)
|
|
142
|
+
repos.add(repo);
|
|
143
|
+
}
|
|
144
|
+
return repos;
|
|
145
|
+
}
|
|
126
146
|
stats() {
|
|
127
147
|
const total = this.db.prepare("SELECT COUNT(*) n FROM events").get().n;
|
|
128
148
|
const group = (col) => Object.fromEntries(this.db.prepare(`SELECT ${col} k, COUNT(*) n FROM events GROUP BY ${col}`).all()
|
|
@@ -130,6 +150,53 @@ export class EventStore {
|
|
|
130
150
|
const span = this.db.prepare("SELECT MIN(ts) first, MAX(ts) last FROM events").get();
|
|
131
151
|
return { total, byType: group("type"), bySource: group("source"), ...span };
|
|
132
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* Engagement over time from context.read events — the retention pulse.
|
|
155
|
+
* Local/per-install only (this machine); aggregate cohort retention would
|
|
156
|
+
* require opt-in telemetry. `now` is injectable for deterministic tests.
|
|
157
|
+
*/
|
|
158
|
+
activity(now = Date.now()) {
|
|
159
|
+
const DAY = 86_400_000;
|
|
160
|
+
const dayKey = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
161
|
+
const reads = this.query({ type: "context.read", limit: 1_000_000 }); // ts DESC
|
|
162
|
+
const firstEventAt = this.db.prepare("SELECT MIN(ts) m FROM events").get().m;
|
|
163
|
+
const firstMs = firstEventAt ? new Date(firstEventAt).getTime() : null;
|
|
164
|
+
const daily = new Map();
|
|
165
|
+
for (let i = 13; i >= 0; i--)
|
|
166
|
+
daily.set(dayKey(now - i * DAY), 0);
|
|
167
|
+
let reads7d = 0, reads30d = 0, week2Read = false;
|
|
168
|
+
const days7 = new Set(), days14 = new Set();
|
|
169
|
+
for (const e of reads) {
|
|
170
|
+
const t = new Date(e.ts).getTime();
|
|
171
|
+
if (!Number.isFinite(t))
|
|
172
|
+
continue;
|
|
173
|
+
const age = now - t, k = dayKey(t);
|
|
174
|
+
if (age <= 7 * DAY) {
|
|
175
|
+
reads7d++;
|
|
176
|
+
days7.add(k);
|
|
177
|
+
}
|
|
178
|
+
if (age <= 14 * DAY)
|
|
179
|
+
days14.add(k);
|
|
180
|
+
if (age <= 30 * DAY)
|
|
181
|
+
reads30d++;
|
|
182
|
+
if (daily.has(k))
|
|
183
|
+
daily.set(k, (daily.get(k) ?? 0) + 1);
|
|
184
|
+
if (firstMs !== null && t >= firstMs + 7 * DAY && t < firstMs + 14 * DAY)
|
|
185
|
+
week2Read = true;
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
firstEventAt,
|
|
189
|
+
lastReadAt: reads.length ? reads[0].ts : null,
|
|
190
|
+
daysSinceFirst: firstMs !== null ? Math.max(0, Math.floor((now - firstMs) / DAY)) : 0,
|
|
191
|
+
totalReads: reads.length,
|
|
192
|
+
reads7d,
|
|
193
|
+
reads30d,
|
|
194
|
+
activeDays7d: days7.size,
|
|
195
|
+
activeDays14d: days14.size,
|
|
196
|
+
retainedWeek2: firstMs !== null && now >= firstMs + 14 * DAY ? week2Read : null,
|
|
197
|
+
daily: [...daily.entries()].map(([date, r]) => ({ date, reads: r })),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
133
200
|
topics(limit = 50) {
|
|
134
201
|
const rows = this.db
|
|
135
202
|
.prepare("SELECT * FROM view_topics ORDER BY weight DESC LIMIT ?")
|