persnally 2.3.2 → 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 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…) Importers (claude · chatgpt · git)
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";
@@ -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
- let events, batch;
253
- if (kind === "claude-code") {
254
- const engine = await chooseExtractor("extract");
255
- const root = path ?? DEFAULT_TRANSCRIPTS_DIR;
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
- console.error(`Found ${summaries.length} repo(s): ${summaries.map((s) => `${s.repo} (${s.commits} commits)`).join(", ")}`);
272
- ({ events, batch } = gitEvents(summaries));
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" || kind === "chatgpt") {
275
- const engine = await chooseExtractor("extract");
276
- const parsed = kind === "claude" ? parseClaudeExport(path) : parseChatGPTExport(path);
277
- console.error(`Parsed ${parsed.conversations.length} conversations. Extracting with ${engine.label}...`);
278
- ({ events, batch } = kind === "claude"
279
- ? await extractClaudeEvents(parsed, engine.extract, engine.model)
280
- : await extractChatGPTEvents(parsed, engine.extract, engine.model));
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
  }
@@ -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 · dbl-click resets</div></div>
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
- const origin = e.provenance && e.provenance.kind === "import" ? `${(e.provenance.file||"import")}` : (e.provenance ? e.provenance.kind : "local");
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+5/cam.k) return i; return -1; };
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
- 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; };
516
- const onMove=(e)=>{ const [mx,my]=rel(e);
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
- 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>`:""}`;
523
- 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"); }
524
- else pop.classList.remove("show"); } };
525
- const onUp=()=>{ if (dragNode>=0){ dragNode=-1; wake(); } panning=false; };
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("mousemove", onMove); window.addEventListener("mouseup", onUp); window.addEventListener("resize", onWinResize, { passive:true });
530
- mapCleanup=()=>{ window.removeEventListener("mousemove",onMove); window.removeEventListener("mouseup",onUp); window.removeEventListener("resize",onWinResize); };
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 { readFileSync, existsSync, statSync } from "node:fs";
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(readFileSync(file, "utf-8"));
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(store);
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(readFileSync(convPath, "utf-8"));
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) {
@@ -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>;
@@ -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()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "persnally",
3
- "version": "2.3.2",
3
+ "version": "2.4.0",
4
4
  "license": "FSL-1.1-MIT",
5
5
  "description": "Your own context engine — local-first, across every AI. So every AI finally knows you.",
6
6
  "type": "module",