persnally 2.3.1 → 2.4.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 +62 -26
- package/build/src/daemon.d.ts +7 -0
- package/build/src/daemon.js +24 -1
- package/build/src/dashboard.html +71 -15
- package/build/src/importers/chatgpt.js +3 -3
- package/build/src/importers/claude-code.d.ts +14 -0
- package/build/src/importers/claude-code.js +20 -0
- package/build/src/importers/claude.js +2 -2
- package/build/src/importers/extract.d.ts +13 -0
- package/build/src/importers/extract.js +59 -19
- package/build/src/store.d.ts +4 -0
- package/build/src/store.js +20 -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
|
@@ -13,11 +13,12 @@ import { runConsolidation } from "./consolidate.js";
|
|
|
13
13
|
import { chooseExtractor } from "./llm.js";
|
|
14
14
|
import { CATEGORIES, clearScope, loadScopes, setScope } from "./permissions.js";
|
|
15
15
|
import { alreadyImported, DENSITY_QUESTIONS, detectExports, eventsFromAnswers, isThin, markImported } from "./setup.js";
|
|
16
|
-
import { DEFAULT_PORT, startDaemon, VERSION } from "./daemon.js";
|
|
16
|
+
import { autoImportNewSessions, DEFAULT_PORT, startDaemon, VERSION } from "./daemon.js";
|
|
17
17
|
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";
|
|
@@ -249,44 +250,77 @@ async function main() {
|
|
|
249
250
|
const usage = "usage: persnallyd import claude|claude-code|chatgpt|git <path>";
|
|
250
251
|
if (!kind)
|
|
251
252
|
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") {
|
|
253
|
+
// Git: offline, deterministic. Dedup by repo so a re-run never doubles the graph.
|
|
254
|
+
if (kind === "git") {
|
|
255
|
+
if (!path)
|
|
256
|
+
return die(usage);
|
|
267
257
|
const authorIdx = args.indexOf("--author");
|
|
268
258
|
const summaries = scanRepos(path, authorIdx > -1 ? args[authorIdx + 1] : undefined);
|
|
269
259
|
if (!summaries.length)
|
|
270
260
|
return die(`No git repos with your commits found at ${path}`);
|
|
271
|
-
|
|
272
|
-
|
|
261
|
+
const store = new EventStore();
|
|
262
|
+
const seen = store.importedGitRepos();
|
|
263
|
+
const fresh = summaries.filter((s) => !seen.has(s.repo));
|
|
264
|
+
const skipped = summaries.length - fresh.length;
|
|
265
|
+
if (!fresh.length) {
|
|
266
|
+
store.close();
|
|
267
|
+
console.log(`All ${summaries.length} repo(s) already imported — nothing new.`);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
console.error(`Found ${summaries.length} repo(s)${skipped ? ` (${skipped} already imported)` : ""} — importing ${fresh.map((s) => `${s.repo} (${s.commits} commits)`).join(", ")}`);
|
|
271
|
+
const { events, batch } = gitEvents(fresh);
|
|
272
|
+
store.append(events);
|
|
273
|
+
store.rebuild();
|
|
274
|
+
store.close();
|
|
275
|
+
console.log(`Imported ${events.length} events from ${fresh.length} repo(s) (batch ${batch}).`);
|
|
276
|
+
console.log(`Undo with: persnallyd forget --batch ${batch}`);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
// Conversation imports: dedup by conversation_uuid so re-import only adds new chats.
|
|
280
|
+
let parsed;
|
|
281
|
+
let file = "conversations.json";
|
|
282
|
+
let parseNote = "";
|
|
283
|
+
if (kind === "claude-code") {
|
|
284
|
+
const root = path ?? DEFAULT_TRANSCRIPTS_DIR;
|
|
285
|
+
file = root;
|
|
286
|
+
const r = parseClaudeCodeTranscripts(root);
|
|
287
|
+
if (!r.parsed.conversations.length)
|
|
288
|
+
return die(`No usable sessions found at ${root}`);
|
|
289
|
+
parsed = r.parsed;
|
|
290
|
+
if (r.sessionsDropped)
|
|
291
|
+
parseNote = ` (most recent ${r.parsed.conversations.length} of ${r.sessionsFound})`;
|
|
273
292
|
}
|
|
274
|
-
else if (kind === "claude"
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
293
|
+
else if (kind === "claude") {
|
|
294
|
+
if (!path)
|
|
295
|
+
return die(usage);
|
|
296
|
+
parsed = parseClaudeExport(path);
|
|
297
|
+
}
|
|
298
|
+
else if (kind === "chatgpt") {
|
|
299
|
+
if (!path)
|
|
300
|
+
return die(usage);
|
|
301
|
+
parsed = parseChatGPTExport(path);
|
|
281
302
|
}
|
|
282
303
|
else {
|
|
283
304
|
return die(`unknown import source "${kind}" — use claude, claude-code, chatgpt, or git`);
|
|
284
305
|
}
|
|
285
306
|
const store = new EventStore();
|
|
307
|
+
const seen = store.importedConversationUuids(`import:${kind}`);
|
|
308
|
+
const { parsed: toExtract, skipped, firstImport } = freshConversations(parsed, seen);
|
|
309
|
+
if (!toExtract.conversations.length && !firstImport) {
|
|
310
|
+
store.close();
|
|
311
|
+
console.log(`Already up to date — all ${parsed.conversations.length} conversation(s) imported. Nothing new.`);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const engine = await chooseExtractor("extract");
|
|
315
|
+
console.error(`Parsed ${parsed.conversations.length} conversation(s)${parseNote}${skipped ? ` — ${skipped} already imported` : ""}. ` +
|
|
316
|
+
`Extracting ${toExtract.conversations.length} with ${engine.label}...`);
|
|
317
|
+
const { events, batch } = await (kind === "claude-code" ? extractClaudeCodeEvents(toExtract, engine.extract, engine.model, file)
|
|
318
|
+
: kind === "claude" ? extractClaudeEvents(toExtract, engine.extract, engine.model)
|
|
319
|
+
: extractChatGPTEvents(toExtract, engine.extract, engine.model));
|
|
286
320
|
store.append(events);
|
|
287
321
|
store.rebuild();
|
|
288
322
|
store.close();
|
|
289
|
-
console.log(`Imported ${events.length} events (batch ${batch}).`);
|
|
323
|
+
console.log(`Imported ${events.length} events from ${toExtract.conversations.length} conversation(s) (batch ${batch}).`);
|
|
290
324
|
console.log(`Undo with: persnallyd forget --batch ${batch}`);
|
|
291
325
|
return;
|
|
292
326
|
}
|
|
@@ -484,6 +518,8 @@ async function main() {
|
|
|
484
518
|
process.on("uncaughtException", (e) => { console.error("uncaughtException:", e); process.exit(1); });
|
|
485
519
|
console.error(`persnallyd v${VERSION} listening on 127.0.0.1:${port}`);
|
|
486
520
|
console.error(`Dashboard: http://127.0.0.1:${port}`);
|
|
521
|
+
// Catch up on chats since the daemon last ran; the timer takes it from here.
|
|
522
|
+
void autoImportNewSessions(store);
|
|
487
523
|
return;
|
|
488
524
|
}
|
|
489
525
|
default:
|
package/build/src/daemon.d.ts
CHANGED
|
@@ -7,3 +7,10 @@ import type { EventStore } from "./store.js";
|
|
|
7
7
|
export declare const DEFAULT_PORT = 4983;
|
|
8
8
|
export declare const VERSION: string;
|
|
9
9
|
export declare function startDaemon(store: EventStore, port?: number): http.Server;
|
|
10
|
+
/**
|
|
11
|
+
* Ingest Claude Code sessions created since the last pass — the daemon's
|
|
12
|
+
* automatic capture of new chats (no user action, no per-session hook). A
|
|
13
|
+
* key-less, Ollama-less machine has no extractor: skip rather than block.
|
|
14
|
+
* Never throws — capture must not take the daemon down.
|
|
15
|
+
*/
|
|
16
|
+
export declare function autoImportNewSessions(store: EventStore): Promise<void>;
|
package/build/src/daemon.js
CHANGED
|
@@ -8,6 +8,7 @@ import { loadConfig } from "./config.js";
|
|
|
8
8
|
import { runConsolidation, shouldRunNow } from "./consolidate.js";
|
|
9
9
|
import { allowedCategories, loadScopes } from "./permissions.js";
|
|
10
10
|
import { newEvent, validateEvent } from "./events.js";
|
|
11
|
+
import { importNewClaudeCodeSessions } from "./importers/claude-code.js";
|
|
11
12
|
import { chooseExtractor } from "./llm.js";
|
|
12
13
|
import { synthesizeProfile } from "./profile.js";
|
|
13
14
|
export const DEFAULT_PORT = 4983;
|
|
@@ -131,8 +132,9 @@ export function startDaemon(store, port = DEFAULT_PORT) {
|
|
|
131
132
|
}
|
|
132
133
|
});
|
|
133
134
|
server.listen(port, "127.0.0.1");
|
|
134
|
-
//
|
|
135
|
+
// Every 30 min: pick up new Claude Code chats, then run the once-a-day reflection.
|
|
135
136
|
const timer = setInterval(async () => {
|
|
137
|
+
await autoImportNewSessions(store);
|
|
136
138
|
const lastRun = loadConfig().last_consolidation;
|
|
137
139
|
if (!shouldRunNow(typeof lastRun === "string" ? lastRun : undefined, new Date()))
|
|
138
140
|
return;
|
|
@@ -149,6 +151,27 @@ export function startDaemon(store, port = DEFAULT_PORT) {
|
|
|
149
151
|
server.on("close", () => clearInterval(timer));
|
|
150
152
|
return server;
|
|
151
153
|
}
|
|
154
|
+
/**
|
|
155
|
+
* Ingest Claude Code sessions created since the last pass — the daemon's
|
|
156
|
+
* automatic capture of new chats (no user action, no per-session hook). A
|
|
157
|
+
* key-less, Ollama-less machine has no extractor: skip rather than block.
|
|
158
|
+
* Never throws — capture must not take the daemon down.
|
|
159
|
+
*/
|
|
160
|
+
export async function autoImportNewSessions(store) {
|
|
161
|
+
try {
|
|
162
|
+
const engine = await chooseExtractor("extract").catch(() => null);
|
|
163
|
+
if (!engine)
|
|
164
|
+
return;
|
|
165
|
+
const r = await importNewClaudeCodeSessions(store, engine.extract, engine.model);
|
|
166
|
+
if (r.events) {
|
|
167
|
+
store.rebuild();
|
|
168
|
+
console.error(`auto-import: ${r.newSessions} new Claude Code session(s) → ${r.events} events`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch (e) {
|
|
172
|
+
console.error("auto-import failed:", e instanceof Error ? e.message : e);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
152
175
|
let cachedHtml;
|
|
153
176
|
function dashboardHtml() {
|
|
154
177
|
cachedHtml ??= readFileSync(new URL("./dashboard.html", import.meta.url), "utf-8");
|
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; }
|
|
@@ -173,6 +173,17 @@
|
|
|
173
173
|
.preview-ribbon code { color: var(--text); }
|
|
174
174
|
a { color: var(--text); }
|
|
175
175
|
:focus-visible { outline: 2px solid #FFFFFF; outline-offset: 3px; border-radius: 4px; }
|
|
176
|
+
@media (max-width: 640px) {
|
|
177
|
+
.wrap { padding: 18px 16px 96px; }
|
|
178
|
+
#graph { height: 380px; }
|
|
179
|
+
.archetype { font-size: clamp(22px, 7vw, 30px); }
|
|
180
|
+
header { margin-bottom: 24px; }
|
|
181
|
+
.value-num { font-size: 32px; }
|
|
182
|
+
#topicList { overflow-x: auto; } /* wide table scrolls inside its card — body never does */
|
|
183
|
+
.bar-track { width: 64px; }
|
|
184
|
+
td { padding: 8px 6px; font-size: 13px; }
|
|
185
|
+
.del { padding: 3px 7px; }
|
|
186
|
+
}
|
|
176
187
|
@media (prefers-reduced-motion: reduce) { .reveal { animation: none; opacity: 1; transform: none; } .read.fresh { animation: none; } }
|
|
177
188
|
</style>
|
|
178
189
|
</head>
|
|
@@ -215,7 +226,7 @@
|
|
|
215
226
|
<div class="view-toggle"><button id="vGraph" class="on">map</button><button id="vList">list</button></div>
|
|
216
227
|
</div>
|
|
217
228
|
<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
|
|
229
|
+
<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
230
|
<div class="card" id="topicList" style="display:none;padding:6px 14px"><table id="topics"></table></div>
|
|
220
231
|
<div class="node-pop" id="nodePop"></div>
|
|
221
232
|
</div>
|
|
@@ -276,6 +287,20 @@ function clientOf(ev) {
|
|
|
276
287
|
}
|
|
277
288
|
const prettyClient = (c) => ({ "claude-code":"Claude Code","claude-desktop":"Claude Desktop","cursor":"Cursor","cli":"CLI" }[c] || c);
|
|
278
289
|
const clientInitials = (c) => prettyClient(c).split(/[\s-]/).map(w=>w[0]).join("").slice(0,2).toUpperCase();
|
|
290
|
+
const IMPORT_TOOL = { "import:claude":"Claude export", "import:chatgpt":"ChatGPT export", "import:claude-code":"Claude Code" };
|
|
291
|
+
// Human-readable provenance for the "why?" panel — where a fact actually came from.
|
|
292
|
+
function provLabel(e) {
|
|
293
|
+
const p = e.provenance || {};
|
|
294
|
+
if (p.kind === "import") {
|
|
295
|
+
const base = IMPORT_TOOL[e.source] || p.file || "import";
|
|
296
|
+
return p.conversation_uuid ? `${base} · conversation ${String(p.conversation_uuid).slice(0,8)}` : base;
|
|
297
|
+
}
|
|
298
|
+
if (p.kind === "mcp") return `${prettyClient(p.client)}${p.session ? " · session " + String(p.session).slice(0,8) : ""} · live`;
|
|
299
|
+
if (p.kind === "git") return `git: ${p.repo}${p.ref ? " @ " + p.ref : ""}`;
|
|
300
|
+
if (p.kind === "derived") return `inferred from ${(p.from||[]).length} signal${(p.from||[]).length===1?"":"s"}`;
|
|
301
|
+
if (p.kind === "local") return `you · ${p.surface || "local"}`;
|
|
302
|
+
return p.kind || "local";
|
|
303
|
+
}
|
|
279
304
|
|
|
280
305
|
const SNAP_KEY = "persnally.snapshot.v1";
|
|
281
306
|
let DEMO = false;
|
|
@@ -344,8 +369,7 @@ async function toggleEvidence(btn) {
|
|
|
344
369
|
box.innerHTML = events.map(e => {
|
|
345
370
|
const p = e.payload || {};
|
|
346
371
|
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>`;
|
|
372
|
+
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
373
|
}).join("") || `<div class="ev-item">evidence not found (deleted?)</div>`;
|
|
350
374
|
box.dataset.loaded = "1";
|
|
351
375
|
}
|
|
@@ -436,7 +460,7 @@ function renderMap(topics) {
|
|
|
436
460
|
const ctx = canvas.getContext("2d");
|
|
437
461
|
const dpr = Math.min(devicePixelRatio || 1, 2);
|
|
438
462
|
let w, h;
|
|
439
|
-
const resize = () => { w = host.clientWidth; h = 520; canvas.width = w*dpr; canvas.height = h*dpr; canvas.style.height = h+"px"; };
|
|
463
|
+
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
464
|
resize();
|
|
441
465
|
|
|
442
466
|
const maxW = Math.max(...data.map(t=>t.weight), 0.01);
|
|
@@ -510,24 +534,55 @@ function renderMap(topics) {
|
|
|
510
534
|
$("legend").innerHTML = cats.map(c => `<span><i style="color:${catColor(c)}"></i><span style="color:var(--dim)">${esc(c)}</span></span>`).join("");
|
|
511
535
|
|
|
512
536
|
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+
|
|
537
|
+
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
538
|
const rel=(e)=>{ const r=canvas.getBoundingClientRect(); return [e.clientX-r.left, e.clientY-r.top]; };
|
|
515
|
-
|
|
516
|
-
|
|
539
|
+
function showPop(n) { const p=nodes[n].t;
|
|
540
|
+
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>`:""}`;
|
|
541
|
+
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"); }
|
|
542
|
+
|
|
543
|
+
// Pointer events unify mouse + touch + pen; a second pointer drives pinch-zoom, a tap inspects.
|
|
544
|
+
const pts=new Map();
|
|
545
|
+
let downX=0, downY=0, moved=false, pinchD=0, pinchX=0, pinchY=0;
|
|
546
|
+
canvas.onpointerdown=(e)=>{
|
|
547
|
+
const [mx,my]=rel(e); pts.set(e.pointerId,{x:mx,y:my});
|
|
548
|
+
try { canvas.setPointerCapture(e.pointerId); } catch { /* capture unsupported — degrade to no-capture */ }
|
|
549
|
+
if (pts.size>=2) { dragNode=-1; panning=false; pop.classList.remove("show"); hover=-1;
|
|
550
|
+
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; }
|
|
551
|
+
downX=mx; downY=my; moved=false; lastX=mx; lastY=my;
|
|
552
|
+
const n=nodeAt(mx,my); if (n>=0) dragNode=n; else panning=true;
|
|
553
|
+
};
|
|
554
|
+
canvas.onpointermove=(e)=>{
|
|
555
|
+
const [mx,my]=rel(e); if (pts.has(e.pointerId)) pts.set(e.pointerId,{x:mx,y:my});
|
|
556
|
+
if (pts.size>=2) { const [a,b]=[...pts.values()];
|
|
557
|
+
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);
|
|
558
|
+
cam.k=Math.max(0.45,Math.min(3,cam.k*(d/pinchD)));
|
|
559
|
+
cam.x=cx-wx*cam.k+(cx-pinchX); cam.y=cy-wy*cam.k+(cy-pinchY);
|
|
560
|
+
pinchD=d; pinchX=cx; pinchY=cy; if(!alive)draw(); return; }
|
|
561
|
+
if ((dragNode>=0||panning) && (Math.abs(mx-downX)>4||Math.abs(my-downY)>4)) moved=true;
|
|
517
562
|
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
563
|
if (panning) { cam.x+=mx-lastX; cam.y+=my-lastY; lastX=mx; lastY=my; if(!alive)draw(); return; }
|
|
564
|
+
if (e.pointerType==="touch") return; // no hover on touch — a tap inspects instead
|
|
519
565
|
if (mx<0||my<0||mx>w||my>h) { if (hover!==-1){hover=-1;pop.classList.remove("show");if(!alive)draw();} return; }
|
|
520
566
|
const n=nodeAt(mx,my);
|
|
521
|
-
if (n!==hover) { hover=n; if(!alive)draw();
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
567
|
+
if (n!==hover) { hover=n; if(!alive)draw(); if (n>=0) showPop(n); else pop.classList.remove("show"); }
|
|
568
|
+
};
|
|
569
|
+
const endPointer=(e)=>{
|
|
570
|
+
const tapped = (!moved && pts.size<=1 && e.pointerType==="touch") ? nodeAt(...rel(e)) : -1;
|
|
571
|
+
pts.delete(e.pointerId);
|
|
572
|
+
if (dragNode>=0) { dragNode=-1; wake(); }
|
|
573
|
+
panning=false;
|
|
574
|
+
if (tapped>=0) { if (hover===tapped && pop.classList.contains("show")) { hover=-1; pop.classList.remove("show"); } else { hover=tapped; showPop(tapped); } if(!alive)draw(); }
|
|
575
|
+
else if (!moved && e.pointerType==="touch") { hover=-1; pop.classList.remove("show"); if(!alive)draw(); }
|
|
576
|
+
if (pts.size===1) { const [p]=[...pts.values()]; lastX=p.x; lastY=p.y; panning=true; moved=true; } // pinch → one finger: resume pan
|
|
577
|
+
};
|
|
578
|
+
canvas.onpointerup=endPointer; canvas.onpointercancel=endPointer;
|
|
526
579
|
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
580
|
canvas.ondblclick=()=>{ cam.k=1; cam.x=0; cam.y=0; if(!alive)draw(); };
|
|
528
581
|
let rt; const onWinResize=()=>{ clearTimeout(rt); rt=setTimeout(()=>{ resize(); if(!alive)draw(); },150); };
|
|
529
|
-
window.addEventListener("
|
|
530
|
-
mapCleanup=()=>{ window.removeEventListener("
|
|
582
|
+
window.addEventListener("resize", onWinResize, { passive:true });
|
|
583
|
+
mapCleanup=()=>{ window.removeEventListener("resize",onWinResize); };
|
|
584
|
+
|
|
585
|
+
if (window.innerWidth < 640) switchView("list"); // phones land on the list; the map is one tap away
|
|
531
586
|
}
|
|
532
587
|
function switchView(v) {
|
|
533
588
|
const g = v==="graph" || v==="map";
|
|
@@ -554,6 +609,7 @@ async function loadAll() {
|
|
|
554
609
|
const st = $("status");
|
|
555
610
|
st.querySelector(".dot").classList.toggle("off", DEMO);
|
|
556
611
|
st.querySelector(".lbl").textContent = DEMO ? "preview" : "active";
|
|
612
|
+
if (!DEMO) get("/health").then(h => { if (h && h.version) $("ver").textContent = `· v${h.version}`; });
|
|
557
613
|
const total = (stats.byType && stats.byType["context.read"]) || reads.length;
|
|
558
614
|
renderDelta(topics, reads, assertions);
|
|
559
615
|
renderScaleBeat(stats);
|
|
@@ -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")
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* (Phase 0 finding), and available immediately with no export wait.
|
|
5
5
|
*/
|
|
6
6
|
import { type LlmExtract } from "../llm.js";
|
|
7
|
+
import type { EventStore } from "../store.js";
|
|
7
8
|
import { type ImportResult, type ParsedExport } from "./extract.js";
|
|
8
9
|
export declare const DEFAULT_TRANSCRIPTS_DIR: string;
|
|
9
10
|
export declare const DEFAULT_MAX_SESSIONS = 200;
|
|
@@ -14,3 +15,16 @@ export interface ClaudeCodeParse {
|
|
|
14
15
|
}
|
|
15
16
|
export declare function parseClaudeCodeTranscripts(root?: string, maxSessions?: number): ClaudeCodeParse;
|
|
16
17
|
export declare function extractClaudeCodeEvents(parsed: ParsedExport, extract?: LlmExtract, model?: string, file?: string): Promise<ImportResult>;
|
|
18
|
+
export interface IncrementalImport {
|
|
19
|
+
newSessions: number;
|
|
20
|
+
events: number;
|
|
21
|
+
skipped: number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Import only the Claude Code sessions not already in the store — the path the
|
|
25
|
+
* daemon runs on its loop so new chats accrue without re-extracting old ones.
|
|
26
|
+
* Sessions are matched by the conversation_uuid recorded in each topic's
|
|
27
|
+
* provenance; a session that yields zero topics leaves no marker and may be
|
|
28
|
+
* retried, which is cheap and rare (a real session produces topics).
|
|
29
|
+
*/
|
|
30
|
+
export declare function importNewClaudeCodeSessions(store: EventStore, extract: LlmExtract, model?: string, root?: string): Promise<IncrementalImport>;
|
|
@@ -97,3 +97,23 @@ function humanText(content) {
|
|
|
97
97
|
export async function extractClaudeCodeEvents(parsed, extract = anthropicExtract, model = DEFAULT_EXTRACT_MODEL, file = DEFAULT_TRANSCRIPTS_DIR) {
|
|
98
98
|
return extractEvents(parsed, { source: "import:claude-code", importer: "claude-code", file }, extract, model);
|
|
99
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Import only the Claude Code sessions not already in the store — the path the
|
|
102
|
+
* daemon runs on its loop so new chats accrue without re-extracting old ones.
|
|
103
|
+
* Sessions are matched by the conversation_uuid recorded in each topic's
|
|
104
|
+
* provenance; a session that yields zero topics leaves no marker and may be
|
|
105
|
+
* retried, which is cheap and rare (a real session produces topics).
|
|
106
|
+
*/
|
|
107
|
+
export async function importNewClaudeCodeSessions(store, extract, model = DEFAULT_EXTRACT_MODEL, root = DEFAULT_TRANSCRIPTS_DIR) {
|
|
108
|
+
if (!existsSync(root))
|
|
109
|
+
return { newSessions: 0, events: 0, skipped: 0 };
|
|
110
|
+
const { parsed } = parseClaudeCodeTranscripts(root);
|
|
111
|
+
const seen = store.importedConversationUuids("import:claude-code");
|
|
112
|
+
const fresh = parsed.conversations.filter((c) => !seen.has(c.uuid));
|
|
113
|
+
const skipped = parsed.conversations.length - fresh.length;
|
|
114
|
+
if (!fresh.length)
|
|
115
|
+
return { newSessions: 0, events: 0, skipped };
|
|
116
|
+
const { events } = await extractClaudeCodeEvents({ ...parsed, conversations: fresh }, extract, model, root);
|
|
117
|
+
store.append(events);
|
|
118
|
+
return { newSessions: fresh.length, events: events.length, skipped };
|
|
119
|
+
}
|
|
@@ -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) {
|
|
@@ -18,19 +44,27 @@ export async function extractEvents(parsed, opts, extract = anthropicExtract, mo
|
|
|
18
44
|
if (!convo.userMessages.length)
|
|
19
45
|
continue;
|
|
20
46
|
const joined = convo.userMessages.join("\n");
|
|
21
|
-
voiceCorpus.push(...proseLines(joined));
|
|
47
|
+
voiceCorpus.push(...proseLines(joined)); // prose feeds the deterministic voice fingerprint even if topic extraction fails
|
|
22
48
|
const text = stripNoise(joined).slice(0, MAX_CONVO_CHARS); // strip pasted paths/URLs/logs before the LLM sees it
|
|
23
49
|
if (!text)
|
|
24
50
|
continue;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
51
|
+
try {
|
|
52
|
+
const result = await extract({
|
|
53
|
+
model,
|
|
54
|
+
instruction: "Extract 1-5 topic signals from this conversation's user messages. Weight = centrality, depth = engagement level, sentiment = user's attitude toward the topic. Capture decisions and rejected options as their own signals.",
|
|
55
|
+
schema: topicsExtraction,
|
|
56
|
+
content: `Conversation title: ${convo.name}\n\nUser messages:\n${text}`,
|
|
57
|
+
});
|
|
58
|
+
const { topics } = topicsExtraction.parse(result);
|
|
59
|
+
for (const t of topics) {
|
|
60
|
+
events.push(newEvent("signal.topic", opts.source, t, { kind: "import", batch, file: opts.file, conversation_uuid: convo.uuid }, safeIso(convo.created_at)));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
// One malformed extraction (e.g. the model returns an out-of-enum value)
|
|
65
|
+
// must not abort a whole multi-conversation import. Skip it — leaving no
|
|
66
|
+
// conversation_uuid marker, so the next pass retries it — and keep the rest.
|
|
67
|
+
console.error(`extract: skipped "${convo.name}" — ${(e instanceof Error ? e.message : String(e)).split("\n")[0]}`);
|
|
34
68
|
}
|
|
35
69
|
}
|
|
36
70
|
if (parsed.memoryText.trim() || parsed.projects.length) {
|
|
@@ -38,15 +72,21 @@ export async function extractEvents(parsed, opts, extract = anthropicExtract, mo
|
|
|
38
72
|
parsed.memoryText.trim() && `Assistant's accumulated memory of the user:\n${parsed.memoryText}`,
|
|
39
73
|
parsed.projects.length && `User-created projects:\n${parsed.projects.map((p) => `- ${p.name}: ${p.description}`).join("\n")}`,
|
|
40
74
|
].filter(Boolean).join("\n\n");
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
75
|
+
try {
|
|
76
|
+
const result = await extract({
|
|
77
|
+
model,
|
|
78
|
+
instruction: "Extract structured assertions about this person: facts, preferences, behaviors, skills, and context. Confidence reflects how directly the source supports the claim.",
|
|
79
|
+
schema: assertionsExtraction,
|
|
80
|
+
content: context,
|
|
81
|
+
});
|
|
82
|
+
const { assertions } = assertionsExtraction.parse(result);
|
|
83
|
+
for (const a of assertions) {
|
|
84
|
+
events.push(newEvent("signal.assertion", opts.source, a, { kind: "import", batch, file: opts.file }));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
// A malformed assertions response shouldn't discard the topics already gathered.
|
|
89
|
+
console.error(`extract: skipped memory/projects assertions — ${(e instanceof Error ? e.message : String(e)).split("\n")[0]}`);
|
|
50
90
|
}
|
|
51
91
|
}
|
|
52
92
|
// Deterministic voice fingerprint over the user's own prose — no LLM, no tokens.
|
package/build/src/store.d.ts
CHANGED
|
@@ -42,6 +42,10 @@ export declare class EventStore {
|
|
|
42
42
|
append(events: PersnallyEvent[]): number;
|
|
43
43
|
query(opts?: QueryOpts): PersnallyEvent[];
|
|
44
44
|
getEvents(ids: string[]): PersnallyEvent[];
|
|
45
|
+
/** conversation_uuids already imported from a source — lets a re-import top up only new chats, never double. */
|
|
46
|
+
importedConversationUuids(source: string): Set<string>;
|
|
47
|
+
/** repo names already imported via `import git` — lets a re-import skip repos already on file. */
|
|
48
|
+
importedGitRepos(): Set<string>;
|
|
45
49
|
stats(): {
|
|
46
50
|
total: number;
|
|
47
51
|
byType: Record<string, number>;
|
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()
|