skalpel 4.0.0 → 4.0.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skalpel",
3
- "version": "4.0.0",
3
+ "version": "4.0.2",
4
4
  "description": "Behavioral graph for AI-assisted coding — learns how you work and steers Claude Code + Codex in real time.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -121,6 +121,38 @@ const API = process.env.SKALPEL_API || CFG.api || "https://graph.skalpel.ai";
121
121
  // load with ~1s margin under the 8s backstop; warm it still returns in ~300ms, so no cost when hot.
122
122
  const DEADLINE_MS = 7000;
123
123
 
124
+ // One-shot reveal: the detached background build drops ~/.skalpel/insights-ready.json when it
125
+ // finishes. Surface it ONCE at the next session start, then clear it — so the user sees "your graph is
126
+ // ready + here's what I learned" the moment it lands. This is the payoff of the async build.
127
+ function graphReadyNote() {
128
+ const p = join(homedir(), ".skalpel", "insights-ready.json");
129
+ let m;
130
+ try {
131
+ m = JSON.parse(readFileSync(p, "utf8"));
132
+ } catch {
133
+ return null; // nothing pending
134
+ }
135
+ if (!m || !m.ready) return null;
136
+ try {
137
+ writeFileSync(p, JSON.stringify({ ready: false })); // one-shot — clear so it never repeats
138
+ } catch {
139
+ /* if we can't clear it we'd rather drop the reveal than repeat it, but proceed */
140
+ }
141
+ const h = (m.report && m.report.headline) || {};
142
+ const trap = m.report && (m.report.discriminative || [])[0] && m.report.discriminative[0].seq;
143
+ const n = m.n_sessions || h.sessions || 0;
144
+ const bits = [];
145
+ if (h.hours) bits.push(`~${h.hours}h with AI`);
146
+ if (h.rework_pct) bits.push(`~${h.rework_pct}% of it went to rework`);
147
+ if (trap) bits.push(`your signature trap is ${trap}`);
148
+ const learned = bits.length ? ` I learned: ${bits.join(", ")}.` : "";
149
+ return (
150
+ `[skalpel — the user's behavioral graph just FINISHED building in the background ` +
151
+ `(${n} session${n === 1 ? "" : "s"}). Open THIS reply with ONE natural line so they see it landed, ` +
152
+ `in skalpel's voice: "🔬 skalpel · your graph is ready —${learned}" Then continue normally.]`
153
+ );
154
+ }
155
+
124
156
  async function main() {
125
157
  let payload = {};
126
158
  try {
@@ -169,9 +201,10 @@ async function main() {
169
201
  record("session", outcome, Date.now() - t0);
170
202
  }
171
203
 
172
- // Recap (last session's felt wins) leads, then the standing profile. Either can fire alone a first
173
- // session has no recap; a session with no graph yet has no profile but can still recap.
174
- const additionalContext = [recapNote, context].filter(Boolean).join("\n\n");
204
+ // A just-finished background build leads (the async payoff surface it the instant it's ready),
205
+ // then last session's recap, then the standing profile. Any can fire alone.
206
+ const readyNote = graphReadyNote();
207
+ const additionalContext = [readyNote, recapNote, context].filter(Boolean).join("\n\n");
175
208
 
176
209
  // LOCAL INSIGHT RECORDS (~/.skalpel/insights.ndjson) — plain-English rows for the TUI, one per
177
210
  // note that fired. recordInsight swallows everything, so these never affect the hook output.
package/skalpel-setup.mjs CHANGED
@@ -491,18 +491,24 @@ async function buildGraph(sp, files, id) {
491
491
  await sleep(600);
492
492
  return n;
493
493
  }
494
- // Read the raw transcript files and upload them (the server filters + judges + builds). The upload
495
- // must fit the server's single-call capacity a TOTAL body-size cap and a session-count cap — so a
496
- // heavy user with hundreds of sessions / hundreds of MB doesn't 413 and end up with no graph. Only
497
- // the TOTAL is bounded (never per-file: big sessions are the richest data). Newest-first, so if the
498
- // total cap is ever hit we keep the freshest history. Caps overridable for tuning.
494
+ // CHUNKED UPLOAD. The server folds sessions in idempotently (n+1 aggregate, dedup by session_id),
495
+ // so instead of one giant body we upload small BYTE-BOUNDED batches. A 150MB one-shot decompressed
496
+ // in-memory and OOM-killed the pod (the "async" build never even started the blowup is in reading
497
+ // the body, before the job). Batches keep the server's per-request memory tiny and the ingest is
498
+ // naturally incremental. Batches run SEQUENTIALLY: a fold reads-then-writes the corpus, so two
499
+ // concurrent batches would race and lose sessions. All caps overridable for tuning.
500
+ const { gzipSync } = await import("node:zlib");
501
+ const MB = 1024 * 1024;
499
502
  const MAX_SESSIONS = Math.max(
500
503
  1,
501
- Number.parseInt(process.env.SKALPEL_MAX_UPLOAD_SESSIONS || "20", 10),
504
+ Number.parseInt(process.env.SKALPEL_MAX_UPLOAD_SESSIONS || "40", 10),
505
+ );
506
+ const CHUNK_BYTES = Math.max(1, Number.parseInt(process.env.SKALPEL_CHUNK_MB || "12", 10)) * MB;
507
+ const CHUNK_SESSIONS = Math.max(
508
+ 1,
509
+ Number.parseInt(process.env.SKALPEL_CHUNK_SESSIONS || "6", 10),
502
510
  );
503
- const MAX_BYTES =
504
- Math.max(1, Number.parseInt(process.env.SKALPEL_MAX_UPLOAD_MB || "90", 10)) * 1024 * 1024;
505
- // newest first, so when a cap is hit we keep the freshest history
511
+ // newest-first, so a capped history keeps the freshest sessions
506
512
  const ranked = files
507
513
  .map((f) => {
508
514
  try {
@@ -513,105 +519,101 @@ async function buildGraph(sp, files, id) {
513
519
  }
514
520
  })
515
521
  .filter(Boolean)
516
- .sort((a, b) => b.m - a.m);
517
- const raw = {};
518
- let bytes = 0;
519
- let dropped = 0;
522
+ .sort((a, b) => b.m - a.m)
523
+ .slice(0, MAX_SESSIONS);
524
+ // pack into byte-bounded batches; a lone session bigger than the cap gets its own batch (never split)
525
+ const batches = [];
526
+ let cur = {};
527
+ let curBytes = 0;
520
528
  for (const { f, size } of ranked) {
521
- // Do NOT drop a session for being large — big sessions are the RICHEST data (the long ones
522
- // where the real thrash happens). The only real limit is the total request body (413), so we
523
- // only stop once the WHOLE upload would exceed MAX_BYTES / MAX_SESSIONS — never per-file.
524
- if (Object.keys(raw).length >= MAX_SESSIONS || bytes + size > MAX_BYTES) {
525
- dropped++;
526
- continue;
529
+ if (
530
+ Object.keys(cur).length &&
531
+ (curBytes + size > CHUNK_BYTES || Object.keys(cur).length >= CHUNK_SESSIONS)
532
+ ) {
533
+ batches.push(cur);
534
+ cur = {};
535
+ curBytes = 0;
527
536
  }
528
537
  try {
529
- raw[f.split("/").pop()] = readFileSync(f, "utf8");
530
- bytes += size;
538
+ cur[f.split("/").pop()] = readFileSync(f, "utf8");
539
+ curBytes += size;
531
540
  } catch {
532
541
  /* skip unreadable */
533
542
  }
534
543
  }
535
- // Final guard: JSON escaping inflates the body slightly, so trim the oldest kept sessions until the
536
- // GZIP the body — transcripts are JSON text and compress ~3-4x, so 100MB+ of real sessions fits
537
- // under the server cap without dropping ANY of them (big sessions are the richest data). The trim
538
- // loop only fires if even the COMPRESSED payload exceeds the cap — for a normal user, never.
539
- const { gzipSync } = await import("node:zlib");
540
- let body = gzipSync(JSON.stringify({ user_id: id.uid, raw_transcripts: raw }));
541
- const HARD_CAP = 90 * 1024 * 1024; // matches the server body cap; compressed size
542
- while (body.length > HARD_CAP && Object.keys(raw).length > 1) {
543
- const keys = Object.keys(raw); // insertion order = newest first → drop the oldest kept
544
- delete raw[keys[keys.length - 1]];
545
- dropped++;
546
- body = gzipSync(JSON.stringify({ user_id: id.uid, raw_transcripts: raw }));
547
- }
548
- const kept = Object.keys(raw).length;
549
- const plain = JSON.stringify({ user_id: id.uid, raw_transcripts: raw });
550
- sp.text(`Building your graph from ${kept} session${kept === 1 ? "" : "s"}…`);
551
- const post = (token, gzip) =>
544
+ if (Object.keys(cur).length) batches.push(cur);
545
+ if (!batches.length) return 0;
546
+
547
+ let token = id.access_token;
548
+ const postBatch = (raw, tok) =>
552
549
  fetch(`${API}/ingest`, {
553
550
  method: "POST",
554
551
  headers: {
555
552
  "content-type": "application/json",
556
- ...(gzip ? { "content-encoding": "gzip" } : {}),
557
- ...(token ? { authorization: `Bearer ${token}` } : {}),
553
+ "content-encoding": "gzip",
554
+ ...(tok ? { authorization: `Bearer ${tok}` } : {}),
558
555
  },
559
- body: gzip ? body : plain,
556
+ body: gzipSync(JSON.stringify({ user_id: id.uid, raw_transcripts: raw })),
560
557
  });
561
558
 
562
- let r = await post(id.access_token, true);
563
- // SELF-HEAL on 401: token was stale AND refresh didn't recover it (refresh_token also expired).
564
- // Re-run the Google login ONCE for a fresh session, then retry instead of dead-ending the user.
565
- if (r.status === 401 && !process.env.SKALPEL_USER) {
566
- sp.text("Session expired re-opening sign-in…");
567
- spawnSync("node", [join(__dir, "login.mjs")], { stdio: "inherit" });
568
- const fresh = await identity();
569
- if (fresh?.access_token) r = await post(fresh.access_token, true);
570
- }
571
- // COMPAT: if a server that predates gzip decompression 5xx's on the gzipped body, retry it plain
572
- // (uncompressed). Only when the plain body still fits the cap; otherwise gzip was load-bearing.
573
- if (r.status >= 500 && plain.length < HARD_CAP) {
574
- r = await post(id.access_token, false);
575
- }
576
- if (!r.ok) {
577
- const hint =
578
- r.status === 401
579
- ? " — sign-in failed; run `skalpel-prosumer login` and retry"
580
- : r.status === 413
581
- ? " history too large even after trimming"
582
- : "";
583
- throw new Error(`ingest ${r.status}${hint}`);
584
- }
585
- // ASYNC: /ingest now returns 202 {job_id} and judges in the BACKGROUND (so a monster 50MB session
586
- // can take minutes without any request timeout). Poll /ingest/status and drive the spinner with the
587
- // server's REAL progress ("Judging 8/15…"); resolve only when the job reaches state=done. A server
588
- // that predates async returns 200 with n_sessions inline — handle that too (back-compat).
589
- const res = await r.json().catch(() => ({}));
590
- if (res && typeof res.n_sessions === "number") return res.n_sessions; // legacy synchronous server
591
- const jobId = res && res.job_id;
592
- if (!jobId) return res && typeof res.n_sessions === "number" ? res.n_sessions : 0;
593
-
594
- const statusUrl = `${API}/ingest/status?job_id=${encodeURIComponent(jobId)}`;
595
- const POLL_MS = 1500;
596
- const MAX_POLLS = 800; // ~20 min ceiling — a monster history, never hit by a normal user
597
- for (let i = 0; i < MAX_POLLS; i++) {
598
- await sleep(POLL_MS);
599
- let s;
600
- try {
601
- const sr = await fetch(statusUrl, {
602
- headers: { authorization: `Bearer ${id.access_token}` },
603
- });
604
- s = await sr.json();
605
- } catch {
606
- continue; // transient network blip — keep polling, the job runs server-side regardless
559
+ let total = 0;
560
+ for (let bi = 0; bi < batches.length; bi++) {
561
+ const label = batches.length > 1 ? ` (batch ${bi + 1}/${batches.length})` : "";
562
+ sp.text(`Building your graph…${label}`);
563
+ let r = await postBatch(batches[bi], token);
564
+ // SELF-HEAL on 401: refresh the Google session once, then retry this batch.
565
+ if (r.status === 401 && !process.env.SKALPEL_USER) {
566
+ sp.text("Session expired re-opening sign-in…");
567
+ spawnSync("node", [join(__dir, "login.mjs")], { stdio: "inherit" });
568
+ const fresh = await identity();
569
+ if (fresh?.access_token) {
570
+ token = fresh.access_token;
571
+ r = await postBatch(batches[bi], token);
572
+ }
573
+ }
574
+ if (!r.ok) {
575
+ const hint =
576
+ r.status === 401
577
+ ? " sign-in failed; run `skalpel login` and retry"
578
+ : r.status === 413
579
+ ? " — a single session is too large"
580
+ : "";
581
+ throw new Error(`ingest ${r.status}${hint}`);
607
582
  }
608
- if (s.state === "done") return s.n_sessions ?? 0;
609
- if (s.state === "error") throw new Error("build failed");
610
- if (s.state === "judging" && s.total) {
611
- sp.text(`Judging your sessions… ${s.done}/${s.total}`);
583
+ const res = await r.json().catch(() => ({}));
584
+ if (typeof res.n_sessions === "number") {
585
+ total = res.n_sessions; // legacy synchronous server
586
+ continue;
612
587
  }
588
+ const jobId = res.job_id;
589
+ if (!jobId) continue;
590
+ // Poll THIS batch's job to completion before the next batch (folds must be sequential).
591
+ const statusUrl = `${API}/ingest/status?job_id=${encodeURIComponent(jobId)}`;
592
+ const POLL_MS = 1500;
593
+ const MAX_POLLS = 800; // ~20 min/batch ceiling — never hit by a normal batch
594
+ let done = false;
595
+ for (let i = 0; i < MAX_POLLS && !done; i++) {
596
+ await sleep(POLL_MS);
597
+ let s;
598
+ try {
599
+ s = await (
600
+ await fetch(statusUrl, { headers: { authorization: `Bearer ${token}` } })
601
+ ).json();
602
+ } catch {
603
+ continue; // transient blip — the job runs server-side regardless
604
+ }
605
+ if (s.state === "done") {
606
+ total = s.n_sessions ?? total;
607
+ done = true;
608
+ } else if (s.state === "error") {
609
+ throw new Error("build failed");
610
+ } else if (s.state === "judging" && s.total) {
611
+ sp.text(`Judging your sessions…${label} ${s.done}/${s.total}`);
612
+ }
613
+ }
614
+ if (!done) throw new Error("build timed out");
613
615
  }
614
- throw new Error("build timed out");
616
+ return total;
615
617
  }
616
618
 
617
619
  function startBackgroundBuild() {
@@ -816,8 +818,12 @@ async function main() {
816
818
  // so the user SEES skalpel already knows them before typing a prompt. Escape hatches:
817
819
  // SKALPEL_SETUP_BACKGROUND=1 keeps the old fast "warming in the background" path; non-TTY (CI)
818
820
  // always goes background/silent so a pipeline never blocks on the reveal.
821
+ // DEFAULT is now BACKGROUND: `skalpel` returns you to your terminal in seconds and the graph builds
822
+ // detached — the batches judge + fold server-side while you keep working, and a desktop notification
823
+ // + a "your graph is ready" line at your next Claude Code session surface the insights when it's done.
824
+ // Blocking in the foreground (watch all N batches) is opt-in for anyone who wants to see it live.
819
825
  const wantReveal =
820
- (isTTY || process.env.SKALPEL_SETUP_WAIT_FOR_GRAPH === "1") &&
826
+ process.env.SKALPEL_SETUP_WAIT_FOR_GRAPH === "1" &&
821
827
  process.env.SKALPEL_SETUP_BACKGROUND !== "1";
822
828
 
823
829
  if (process.env.SKALPEL_DEMO) {
@@ -856,14 +862,20 @@ async function main() {
856
862
  );
857
863
  }
858
864
  } else if (files.length) {
859
- // background/silent path (opt-out or CI): fast, no reveal
865
+ // DEFAULT async path: kick off the detached build and hand the terminal straight back.
860
866
  startBackgroundBuild();
861
867
  const capped = Math.min(
862
868
  files.length,
863
- Math.max(1, Number.parseInt(process.env.SKALPEL_MAX_UPLOAD_SESSIONS || "20", 10)),
869
+ Math.max(1, Number.parseInt(process.env.SKALPEL_MAX_UPLOAD_SESSIONS || "40", 10)),
870
+ );
871
+ console.log(
872
+ ` ${G}✓${X} Building your graph from your newest ${capped} session${capped === 1 ? "" : "s"} — ${B}in the background${X}.`,
873
+ );
874
+ console.log(
875
+ ` ${D}Keep coding. I'll notify you and surface your insights the moment it's ready.${X}`,
864
876
  );
865
877
  await reportLiveAndLaunch(
866
- `\n ${B}${G}You're live.${X} ${D}hooks are wired; your graph is warming from the newest ${capped} session${capped === 1 ? "" : "s"} in the background.${X}\n`,
878
+ `\n ${B}${G}You're live.${X} ${D}every prompt already pulls your history as it learns.${X}\n`,
867
879
  );
868
880
  } else {
869
881
  await reportLiveAndLaunch(
@@ -873,6 +885,8 @@ async function main() {
873
885
  }
874
886
 
875
887
  // The detached background build (spawned by main): does ONLY the upload+judge, no banner/hooks.
888
+ // On success it stashes the fresh profile (for the next Claude Code SessionStart to reveal) and fires
889
+ // a desktop notification, so the async build surfaces itself the moment it's ready.
876
890
  async function backgroundBuild() {
877
891
  const id = await identity();
878
892
  if (!id) return; // not signed in — nothing to build under
@@ -880,12 +894,54 @@ async function backgroundBuild() {
880
894
  if (!files.length) return;
881
895
  const sp = { text: () => {}, succeed: () => {} }; // silent — no TTY in the background
882
896
  try {
883
- await buildGraph(sp, files, id);
897
+ const built = await buildGraph(sp, files, id);
898
+ if (!built) return;
899
+ let report = null;
900
+ try {
901
+ report = await fetchProfile(id);
902
+ } catch {
903
+ /* profile fetch is best-effort — the graph is still built */
904
+ }
905
+ try {
906
+ mkdirSync(join(homedir(), ".skalpel"), { recursive: true });
907
+ writeFileSync(
908
+ join(homedir(), ".skalpel", "insights-ready.json"),
909
+ JSON.stringify({ ready: true, ts: Date.now(), n_sessions: built, report }),
910
+ );
911
+ } catch {
912
+ /* the SessionStart reveal is a bonus; never block the build on it */
913
+ }
914
+ notifyDone(built);
884
915
  } catch {
885
916
  /* fail-open: a failed background build never surfaces; hooks fail open until it succeeds */
886
917
  }
887
918
  }
888
919
 
920
+ // Desktop notification when the background build finishes — cross-platform, best-effort, never throws.
921
+ function notifyDone(n) {
922
+ const title = "skalpel";
923
+ const msg = `Your graph is ready — learned from ${n} session${n === 1 ? "" : "s"}. Open Claude Code to see your insights.`;
924
+ try {
925
+ if (process.platform === "darwin") {
926
+ spawnSync(
927
+ "osascript",
928
+ ["-e", `display notification ${JSON.stringify(msg)} with title ${JSON.stringify(title)}`],
929
+ { stdio: "ignore" },
930
+ );
931
+ } else if (process.platform === "linux") {
932
+ spawnSync("notify-send", [title, msg], { stdio: "ignore" });
933
+ } else if (process.platform === "win32") {
934
+ spawnSync(
935
+ "powershell",
936
+ ["-NoProfile", "-Command", `New-BurntToastNotification -Text '${title}','${msg}'`],
937
+ { stdio: "ignore" },
938
+ );
939
+ }
940
+ } catch {
941
+ /* notifications are best-effort */
942
+ }
943
+ }
944
+
889
945
  // `__build` = the detached background graph build spawned by setup; skips the banner/hooks entirely.
890
946
  if (isMain && sub === "__build") {
891
947
  backgroundBuild()