skalpel 3.4.18 → 4.0.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.
Files changed (55) hide show
  1. package/README.md +29 -37
  2. package/auth.mjs +179 -0
  3. package/{prosumer-hooks/bootstrap.mjs → bootstrap.mjs} +14 -8
  4. package/{prosumer-hooks/incremental-ingest.mjs → incremental-ingest.mjs} +5 -10
  5. package/{prosumer-hooks/install.mjs → install.mjs} +6 -9
  6. package/login.mjs +164 -0
  7. package/package.json +17 -57
  8. package/postinstall.mjs +68 -0
  9. package/skalpel-setup.mjs +896 -0
  10. package/{prosumer-hooks/skalpel-statusline.mjs → skalpel-statusline.mjs} +1 -2
  11. package/INSTALL.md +0 -250
  12. package/LICENSE +0 -201
  13. package/design-tokens.json +0 -52
  14. package/npm-bin/colors.js +0 -125
  15. package/npm-bin/skalpel.bug-0039.test.js +0 -232
  16. package/npm-bin/skalpel.js +0 -517
  17. package/npm-bin/skalpeld.js +0 -20
  18. package/postinstall/index.js +0 -277
  19. package/postinstall/index.test.js +0 -147
  20. package/postinstall/launchd/com.skalpel.skalpeld.plist.tmpl +0 -46
  21. package/postinstall/lib/ca-install.js +0 -268
  22. package/postinstall/lib/ca-install.test.js +0 -327
  23. package/postinstall/lib/detect-prior.js +0 -62
  24. package/postinstall/lib/env-inject.js +0 -105
  25. package/postinstall/lib/integrations.js +0 -207
  26. package/postinstall/lib/launch.js +0 -38
  27. package/postinstall/lib/log.js +0 -31
  28. package/postinstall/lib/paths.js +0 -236
  29. package/postinstall/lib/prosumer-hooks.js +0 -146
  30. package/postinstall/lib/prosumer-hooks.test.js +0 -278
  31. package/postinstall/lib/rc-edit.js +0 -168
  32. package/postinstall/lib/rc-edit.test.js +0 -243
  33. package/postinstall/lib/run-tests.js +0 -59
  34. package/postinstall/lib/service-register.js +0 -248
  35. package/postinstall/lib/sign-in.js +0 -80
  36. package/postinstall/lib/template.js +0 -36
  37. package/postinstall/preinstall.js +0 -111
  38. package/postinstall/preinstall.test.js +0 -47
  39. package/postinstall/snippets/bash.sh.tmpl +0 -12
  40. package/postinstall/snippets/fish.fish.tmpl +0 -11
  41. package/postinstall/snippets/powershell.ps1.tmpl +0 -12
  42. package/postinstall/snippets/zsh.sh.tmpl +0 -13
  43. package/postinstall/systemd/skalpeld.service.tmpl +0 -34
  44. package/postinstall/uninstall.js +0 -98
  45. package/postinstall/windows/Task.xml.tmpl +0 -42
  46. package/postinstall/windows/register-task.ps1.tmpl +0 -45
  47. package/prosumer-hooks/auth.mjs +0 -93
  48. package/prosumer-hooks/bootstrap.test.mjs +0 -93
  49. package/prosumer-hooks/incremental-ingest.test.mjs +0 -320
  50. /package/{prosumer-hooks/insights.mjs → insights.mjs} +0 -0
  51. /package/{prosumer-hooks/metrics.mjs → metrics.mjs} +0 -0
  52. /package/{prosumer-hooks/skalpel-hook-session-end.mjs → skalpel-hook-session-end.mjs} +0 -0
  53. /package/{prosumer-hooks/skalpel-hook-session.mjs → skalpel-hook-session.mjs} +0 -0
  54. /package/{prosumer-hooks/skalpel-hook.mjs → skalpel-hook.mjs} +0 -0
  55. /package/{prosumer-hooks/stats.mjs → stats.mjs} +0 -0
@@ -0,0 +1,896 @@
1
+ #!/usr/bin/env node
2
+ // setup — the automatic install flow (`skalpel-prosumer setup`, also npm postinstall). Prints the
3
+ // banner, signs you in with Google (once), reads your last 30 days of Claude Code / Codex history,
4
+ // UPLOADS it to the hosted graph (the server judges + builds — the client stays engine-free), wires
5
+ // the two hooks into Claude Code + Codex, and reports "you're live". Skips in CI / non-TTY (override:
6
+ // SKALPEL_FORCE_BANNER=1). Demo timing: SKALPEL_DEMO=1.
7
+ import {
8
+ createReadStream,
9
+ readdirSync,
10
+ accessSync,
11
+ statSync,
12
+ mkdirSync,
13
+ readFileSync,
14
+ writeFileSync,
15
+ realpathSync,
16
+ constants,
17
+ } from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import { join, dirname, delimiter, sep as pathSep } from "node:path";
20
+ import { createInterface, emitKeypressEvents } from "node:readline";
21
+ import { fileURLToPath } from "node:url";
22
+ import { spawn, spawnSync } from "node:child_process";
23
+ import { identity as authIdentity } from "./auth.mjs";
24
+
25
+ // CLI (the `skalpel-prosumer` bin): `skalpel-prosumer setup [--api https://graph.skalpel.ai]
26
+ // [--user <id>]`, plus `skalpel-prosumer uninstall` and `skalpel-prosumer login`. A bare invocation
27
+ // (npm postinstall) keeps the TTY-gated flow below.
28
+ const argv = process.argv.slice(2);
29
+ const sub = argv[0] && !argv[0].startsWith("-") ? argv[0] : null;
30
+ const flagOf = (name) => {
31
+ const i = argv.indexOf(name);
32
+ return i >= 0 && argv[i + 1] ? argv[i + 1] : null;
33
+ };
34
+ const apiFlag = flagOf("--api");
35
+ const userFlag = flagOf("--user");
36
+ const explicitCli = sub !== null || apiFlag !== null || userFlag !== null;
37
+ const __filename = fileURLToPath(import.meta.url);
38
+ const __dir = dirname(__filename);
39
+ const isMain =
40
+ Boolean(process.argv[1]) &&
41
+ (() => {
42
+ try {
43
+ return realpathSync(process.argv[1]) === realpathSync(__filename);
44
+ } catch {
45
+ return false;
46
+ }
47
+ })();
48
+
49
+ // Argument validation. `sub` above is null for ANY dash-prefixed token, so before this existed
50
+ // `npx @skalpelai/prosumer-hook --version` fell straight through to the full install flow: it signed
51
+ // you in and wired hooks into Claude Code and Codex, having been asked only to print a version.
52
+ // Unknown input must never install. Guarded on isMain so importing this module (install.test.mjs
53
+ // pulls in userTurnCount) can never exit the test runner.
54
+ const KNOWN_SUBS = new Set(["setup", "login", "logout", "uninstall", "__build"]);
55
+ const VALUE_FLAGS = new Set(["--api", "--user"]);
56
+ const USAGE = `skalpel-prosumer — connect your coding history to your behavioral graph
57
+
58
+ usage:
59
+ skalpel-prosumer [setup] [--api <url>] [--user <id>] sign in, learn your history, wire the hooks
60
+ skalpel-prosumer login (re-)run the Google sign-in
61
+ skalpel-prosumer logout clear the saved session
62
+ skalpel-prosumer uninstall remove the hooks from Claude Code + Codex
63
+
64
+ options:
65
+ --api <url> point at a different graph server
66
+ --user <id> dev-only identity override
67
+ -v, --version print the version and exit
68
+ -h, --help print this help and exit`;
69
+
70
+ function pkgVersion() {
71
+ try {
72
+ return JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")).version;
73
+ } catch {
74
+ return "unknown";
75
+ }
76
+ }
77
+
78
+ function validateArgv() {
79
+ for (let i = 0; i < argv.length; i++) {
80
+ const a = argv[i];
81
+ if (VALUE_FLAGS.has(a)) {
82
+ i++; // its value is not a token to validate
83
+ continue;
84
+ }
85
+ if (a === "-v" || a === "--version") {
86
+ console.log(pkgVersion());
87
+ process.exit(0);
88
+ }
89
+ if (a === "-h" || a === "--help") {
90
+ console.log(USAGE);
91
+ process.exit(0);
92
+ }
93
+ if (a.startsWith("-")) {
94
+ console.error(`skalpel: unknown option \`${a}\`\n\n${USAGE}`);
95
+ process.exit(2);
96
+ }
97
+ if (i === 0 && !KNOWN_SUBS.has(a)) {
98
+ console.error(`skalpel: unknown command \`${a}\`\n\n${USAGE}`);
99
+ process.exit(2);
100
+ }
101
+ }
102
+ }
103
+ if (isMain) validateArgv();
104
+
105
+ const isTTY = process.stdout.isTTY && !process.env.CI;
106
+ if (isMain && !isTTY && !process.env.SKALPEL_FORCE_BANNER && !explicitCli) process.exit(0);
107
+
108
+ const R = "\x1b[38;2;233;38;31m",
109
+ G = "\x1b[38;2;124;184;108m";
110
+ const D = "\x1b[2m",
111
+ B = "\x1b[1m",
112
+ X = "\x1b[0m";
113
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
114
+
115
+ // The hosted graph. env > ~/.skalpel/client.json > the public host default. The `--api` flag
116
+ // (persisted to client.json) points a dev/self-host build at a different server.
117
+ const DEFAULT_API = "https://graph.skalpel.ai";
118
+ function fileCfg() {
119
+ try {
120
+ return JSON.parse(readFileSync(join(homedir(), ".skalpel", "client.json"), "utf8"));
121
+ } catch {
122
+ return {};
123
+ }
124
+ }
125
+ const API = process.env.SKALPEL_API || apiFlag || fileCfg().api || DEFAULT_API;
126
+
127
+ // who's signed in — the session saved by the login flow (~/.skalpel/auth.json). Its `uid` keys the
128
+ // hosted graph; `access_token` authenticates the upload. SKALPEL_USER overrides for dev (no token).
129
+ // Uses auth.mjs's REFRESH-CAPABLE identity() so a token that expired since login (Supabase tokens
130
+ // live ~1h) is silently refreshed before the upload — otherwise a build long after login hits a 401
131
+ // and silently produces no graph. name/email come from auth.json (auth.mjs surfaces only uid+token).
132
+ async function identity() {
133
+ if (process.env.SKALPEL_USER) return { uid: process.env.SKALPEL_USER, access_token: null };
134
+ let email = null;
135
+ let name = null;
136
+ try {
137
+ const a = JSON.parse(readFileSync(join(homedir(), ".skalpel", "auth.json"), "utf8"));
138
+ email = a?.email ?? null;
139
+ name = a?.name ?? null;
140
+ } catch {
141
+ /* no file yet */
142
+ }
143
+ const { uid, token } = await authIdentity(); // refreshes if near/past expiry
144
+ return uid ? { uid, access_token: token, email, name } : null;
145
+ }
146
+
147
+ function banner() {
148
+ console.log(`
149
+ ${R} ███████╗██╗ ██╗ █████╗ ██╗ ██████╗ ███████╗██╗
150
+ ██╔════╝██║ ██╔╝██╔══██╗██║ ██╔══██╗██╔════╝██║
151
+ ███████╗█████╔╝ ███████║██║ ██████╔╝█████╗ ██║
152
+ ╚════██║██╔═██╗ ██╔══██║██║ ██╔═══╝ ██╔══╝ ██║
153
+ ███████║██║ ██╗██║ ██║███████╗██║ ███████╗███████╗
154
+ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚══════╝╚══════╝${X}`);
155
+ console.log(`\n ${D}your AI coding sessions, learned — and steered in real time${X}\n`);
156
+ }
157
+
158
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
159
+ function spinner(label) {
160
+ const S = { label };
161
+ if (!isTTY) {
162
+ // non-TTY: just print the step, no animation
163
+ process.stdout.write(` ${D}·${X} ${label}\n`);
164
+ S.text = () => {};
165
+ S.succeed = (m) => process.stdout.write(` ${G}✓${X} ${m || S.label}\n`);
166
+ S.fail = (m) => process.stdout.write(` ${R}✗${X} ${m || S.label}\n`);
167
+ return S;
168
+ }
169
+ let i = 0;
170
+ process.stdout.write("\x1b[?25l"); // hide cursor
171
+ const id = setInterval(() => {
172
+ process.stdout.write(`\r\x1b[2K ${R}${FRAMES[i++ % FRAMES.length]}${X} ${S.label}`);
173
+ }, 80);
174
+ S.text = (t) => {
175
+ S.label = t;
176
+ };
177
+ S.succeed = (m) => {
178
+ clearInterval(id);
179
+ process.stdout.write(`\r\x1b[2K ${G}✓${X} ${m || S.label}\n\x1b[?25h`);
180
+ };
181
+ S.fail = (m) => {
182
+ clearInterval(id);
183
+ process.stdout.write(`\r\x1b[2K ${R}✗${X} ${m || S.label}\n\x1b[?25h`);
184
+ };
185
+ return S;
186
+ }
187
+
188
+ function walk(dir) {
189
+ let out = [];
190
+ let ents;
191
+ try {
192
+ ents = readdirSync(dir, { withFileTypes: true });
193
+ } catch {
194
+ return out;
195
+ }
196
+ for (const e of ents) {
197
+ const p = join(dir, e.name);
198
+ if (e.isDirectory()) out = out.concat(walk(p));
199
+ else if (p.endsWith(".jsonl")) out.push(p);
200
+ }
201
+ return out;
202
+ }
203
+
204
+ const LOOKBACK_DAYS = Math.max(1, Number.parseInt(process.env.SKALPEL_LOOKBACK_DAYS || "30", 10));
205
+
206
+ const HAS_WINDOWS = process.platform === "win32";
207
+ const DEFAULT_EXTS = HAS_WINDOWS ? [".com", ".exe", ".bat", ".cmd"] : [""];
208
+
209
+ function isExecutable(p) {
210
+ try {
211
+ accessSync(p, constants.X_OK);
212
+ return true;
213
+ } catch {
214
+ return false;
215
+ }
216
+ }
217
+
218
+ function resolveExecutable(cmd) {
219
+ if (!cmd) return null;
220
+
221
+ const hasPath = cmd.includes(pathSep) || cmd.includes("/") || cmd.includes("\\");
222
+ if (hasPath) {
223
+ return isExecutable(cmd) ? cmd : null;
224
+ }
225
+
226
+ const extList = HAS_WINDOWS
227
+ ? [
228
+ ...new Set(
229
+ (process.env.PATHEXT || DEFAULT_EXTS.join(";")).split(";").map((ext) => {
230
+ const e = ext.trim();
231
+ return e ? (e.startsWith(".") ? e : `.${e}`) : "";
232
+ }),
233
+ ),
234
+ ]
235
+ : [""];
236
+
237
+ const hasExplicitExt = cmd.includes(".");
238
+ const candidates = hasExplicitExt ? [cmd] : [cmd];
239
+ const seen = new Set(candidates.map((c) => c.toLowerCase()));
240
+ if (!hasExplicitExt) {
241
+ for (const ext of extList) {
242
+ if (!ext) continue;
243
+ const candidate = `${cmd}${ext}`;
244
+ if (!seen.has(candidate.toLowerCase())) {
245
+ seen.add(candidate.toLowerCase());
246
+ candidates.push(candidate);
247
+ }
248
+ }
249
+ }
250
+
251
+ const paths = (process.env.PATH || "").split(delimiter).filter(Boolean);
252
+ for (const dir of paths) {
253
+ for (const candidate of candidates) {
254
+ const p = join(dir, candidate);
255
+ if (isExecutable(p)) return p;
256
+ }
257
+ }
258
+ return null;
259
+ }
260
+
261
+ function detectLaunchers() {
262
+ const candidates = [
263
+ { key: "claude", label: "Claude Code", command: "claude" },
264
+ { key: "codex", label: "Codex", command: "codex" },
265
+ ];
266
+
267
+ return candidates
268
+ .map((entry) => {
269
+ const command = resolveExecutable(entry.command);
270
+ return command ? { ...entry, command } : null;
271
+ })
272
+ .filter(Boolean);
273
+ }
274
+
275
+ function renderLaunchMenu(options, selected) {
276
+ return [
277
+ ` ${B}Launch your first session${X}`,
278
+ ` ${D}Use arrow keys or j/k to move. Press Enter to launch, Esc to skip.${X}`,
279
+ "",
280
+ ...options.map((entry, i) => {
281
+ const active = i === selected;
282
+ const pointer = active ? `${G}>${X}` : `${D} ${X}`;
283
+ const label = active ? `${B}${entry.label}${X}` : entry.label;
284
+ return ` ${pointer} ${label} ${D}${entry.command}${X}`;
285
+ }),
286
+ ];
287
+ }
288
+
289
+ function promptLaunchFallback(options) {
290
+ console.log("\n Pick your first session:");
291
+ console.log(" 0) Skip for now");
292
+ options.forEach((entry, i) => {
293
+ const n = i + 1;
294
+ console.log(` ${n}) ${entry.label} (${entry.command})`);
295
+ });
296
+
297
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
298
+ return new Promise((resolve) => {
299
+ const ask = () => {
300
+ rl.question(" Selection [0=skip]: ", (ans) => {
301
+ const trimmed = String(ans ?? "").trim();
302
+ const n = trimmed === "" ? 0 : Number.parseInt(trimmed, 10);
303
+ if (!Number.isInteger(n) || n < 0 || n > options.length) {
304
+ console.log(` ${R}Please enter a number between 0 and ${options.length}.${X}`);
305
+ ask();
306
+ return;
307
+ }
308
+ rl.close();
309
+ resolve(n === 0 ? null : options[n - 1]);
310
+ });
311
+ };
312
+ ask();
313
+ });
314
+ }
315
+
316
+ function promptLaunch(targets) {
317
+ if (!process.stdin.isTTY || !process.stdout.isTTY || process.env.CI) return Promise.resolve();
318
+ if (process.env.SKALPEL_SKIP_LAUNCH_PROMPT === "1") return Promise.resolve();
319
+
320
+ const launchers = (targets || []).filter((entry) => !!entry?.command);
321
+ if (!launchers.length) {
322
+ console.log(`
323
+ ${D}No AI launcher was found on PATH. Re-run ${X}skalpel-prosumer setup${D} after installing Claude Code or Codex.${X}
324
+ `);
325
+ return Promise.resolve();
326
+ }
327
+
328
+ const options = [
329
+ ...launchers,
330
+ { key: "skip", label: "Skip for now", command: "Return to shell" },
331
+ ];
332
+ const interactive = typeof process.stdin.setRawMode === "function";
333
+
334
+ return new Promise((resolve) => {
335
+ const launch = (picked) => {
336
+ if (!picked || picked.key === "skip") {
337
+ console.log(` ${D}Skipped launch. Start your session anytime from the shell.${X}`);
338
+ resolve();
339
+ return;
340
+ }
341
+ console.log(` ${B}Launching ${picked.label}...${X}`);
342
+ const child = spawnSync(picked.command, { stdio: "inherit", shell: HAS_WINDOWS });
343
+ if (child.error) {
344
+ console.log(` ${R}Failed to launch ${picked.label}: ${child.error.message}${X}`);
345
+ } else if (child.status !== 0) {
346
+ console.log(` ${R}Session ended with status ${child.status}${X}`);
347
+ }
348
+ resolve();
349
+ };
350
+
351
+ if (!interactive) {
352
+ promptLaunchFallback(launchers).then(launch);
353
+ return;
354
+ }
355
+
356
+ let selected = 0;
357
+ let renderedLines = 0;
358
+
359
+ const redraw = () => {
360
+ if (renderedLines) process.stdout.write(`\x1b[${renderedLines}F`);
361
+ const lines = renderLaunchMenu(options, selected);
362
+ renderedLines = lines.length + 1;
363
+ process.stdout.write(`\n${lines.join("\n")}\n`);
364
+ };
365
+
366
+ const cleanup = () => {
367
+ process.stdin.off("keypress", onKeypress);
368
+ process.stdin.setRawMode(false);
369
+ process.stdin.pause();
370
+ process.stdout.write("\x1b[?25h");
371
+ };
372
+
373
+ const move = (delta) => {
374
+ selected = (selected + delta + options.length) % options.length;
375
+ redraw();
376
+ };
377
+
378
+ const finish = (picked) => {
379
+ cleanup();
380
+ launch(picked);
381
+ };
382
+
383
+ const onKeypress = (_, key = {}) => {
384
+ if (key.ctrl && key.name === "c") {
385
+ cleanup();
386
+ process.kill(process.pid, "SIGINT");
387
+ return;
388
+ }
389
+ if (key.name === "up" || key.name === "k") {
390
+ move(-1);
391
+ return;
392
+ }
393
+ if (key.name === "down" || key.name === "j") {
394
+ move(1);
395
+ return;
396
+ }
397
+ if (key.name === "return") {
398
+ finish(options[selected]);
399
+ return;
400
+ }
401
+ if (key.name === "escape" || key.name === "q") {
402
+ finish(null);
403
+ }
404
+ };
405
+
406
+ emitKeypressEvents(process.stdin);
407
+ process.stdin.setRawMode(true);
408
+ process.stdin.resume();
409
+ process.stdout.write("\x1b[?25l");
410
+ process.stdin.on("keypress", onKeypress);
411
+ redraw();
412
+ });
413
+ }
414
+
415
+ async function reportLiveAndLaunch(message) {
416
+ console.log(message);
417
+ await promptLaunch(detectLaunchers());
418
+ }
419
+
420
+ // A REAL coding session, not one of the ~16k .jsonl fragment files the walk finds. Mirrors the
421
+ // server's filter_real_sessions (engine.py) so we upload the ~15 genuine sessions, not 100 raw noise
422
+ // files: (1) not agent-*.jsonl (spawned sub-agents = the AI talking to itself), (2) >= MIN_USER_TURNS
423
+ // real human turns (a back-and-forth, not a 1-2 line fragment). This is the natural bound — no
424
+ // arbitrary "cap to 100" that could upload fragments and MISS the real sessions.
425
+ const MIN_USER_TURNS = Math.max(1, Number.parseInt(process.env.SKALPEL_MIN_USER_TURNS || "5", 10));
426
+
427
+ export async function userTurnCount(path, cap = 5) {
428
+ let n = 0;
429
+ const input = createReadStream(path, { encoding: "utf8" });
430
+ const rl = createInterface({ input, crlfDelay: Infinity });
431
+ try {
432
+ for await (const line of rl) {
433
+ if (!line.includes('"user"')) continue;
434
+ let e;
435
+ try {
436
+ e = JSON.parse(line);
437
+ } catch {
438
+ continue;
439
+ }
440
+ if (e?.type === "user" || e?.role === "user") {
441
+ n++;
442
+ if (n >= cap) {
443
+ rl.close();
444
+ input.destroy();
445
+ return n;
446
+ }
447
+ }
448
+ }
449
+ } catch {
450
+ return 0;
451
+ }
452
+ return n;
453
+ }
454
+
455
+ export async function scanHistory(days = LOOKBACK_DAYS) {
456
+ const cutoff = Date.now() - days * 864e5;
457
+ const files = walk(join(homedir(), ".claude", "projects"))
458
+ .concat(walk(join(homedir(), ".codex", "sessions")))
459
+ .filter((f) => {
460
+ if (f.split("/").pop().startsWith("agent-")) return false; // sub-agent transcripts, not the user
461
+ try {
462
+ return statSync(f).mtimeMs >= cutoff;
463
+ } catch {
464
+ return false;
465
+ }
466
+ });
467
+ const out = [];
468
+ const workers = Math.max(1, Number.parseInt(process.env.SKALPEL_SCAN_WORKERS || "16", 10));
469
+ let i = 0;
470
+ async function worker() {
471
+ while (i < files.length) {
472
+ const f = files[i++];
473
+ if ((await userTurnCount(f, MIN_USER_TURNS)) >= MIN_USER_TURNS) out.push(f);
474
+ }
475
+ }
476
+ await Promise.all(Array.from({ length: Math.min(workers, files.length || 1) }, worker));
477
+ return out;
478
+ }
479
+
480
+ // HOSTED model: the client is thin — it just UPLOADS the raw transcripts; the SERVER filters
481
+ // (real-session detection) + judges (Gemini) + builds the Amplitude dash + aggregate DAG into the
482
+ // hosted store under the user's uid. No Python, no engine, no graph-building on the user's machine.
483
+ async function buildGraph(sp, files, id) {
484
+ if (process.env.SKALPEL_DEMO || !files.length) {
485
+ const n = files.length || (process.env.SKALPEL_DEMO ? 9 : 0);
486
+ for (let k = 1; k <= n; k++) {
487
+ sp.text(`Judging session ${k}/${n} → ⟨intent · trajectory · resolution⟩`);
488
+ await sleep(180);
489
+ }
490
+ sp.text("Assembling causal DAG + your insight layer…");
491
+ await sleep(600);
492
+ return n;
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.
499
+ const MAX_SESSIONS = Math.max(
500
+ 1,
501
+ Number.parseInt(process.env.SKALPEL_MAX_UPLOAD_SESSIONS || "20", 10),
502
+ );
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
506
+ const ranked = files
507
+ .map((f) => {
508
+ try {
509
+ const s = statSync(f);
510
+ return { f, m: s.mtimeMs, size: s.size };
511
+ } catch {
512
+ return null;
513
+ }
514
+ })
515
+ .filter(Boolean)
516
+ .sort((a, b) => b.m - a.m);
517
+ const raw = {};
518
+ let bytes = 0;
519
+ let dropped = 0;
520
+ 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;
527
+ }
528
+ try {
529
+ raw[f.split("/").pop()] = readFileSync(f, "utf8");
530
+ bytes += size;
531
+ } catch {
532
+ /* skip unreadable */
533
+ }
534
+ }
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) =>
552
+ fetch(`${API}/ingest`, {
553
+ method: "POST",
554
+ headers: {
555
+ "content-type": "application/json",
556
+ ...(gzip ? { "content-encoding": "gzip" } : {}),
557
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
558
+ },
559
+ body: gzip ? body : plain,
560
+ });
561
+
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
607
+ }
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}`);
612
+ }
613
+ }
614
+ throw new Error("build timed out");
615
+ }
616
+
617
+ function startBackgroundBuild() {
618
+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url), "__build"], {
619
+ detached: true,
620
+ stdio: "ignore",
621
+ env: {
622
+ ...process.env,
623
+ SKALPEL_FORCE_BANNER: "0",
624
+ },
625
+ });
626
+ child.unref();
627
+ }
628
+
629
+ // The causal Profile (mine.build_profile) as a structured report — so the FIRST RUN can reveal, in
630
+ // natural language, what skalpel just learned (the waste number + the top patterns). This is the
631
+ // trust moment: the user SEES it knows them before they've typed a single prompt.
632
+ async function fetchProfile(id) {
633
+ try {
634
+ const r = await fetch(`${API}/profile?user_id=${encodeURIComponent(id.uid)}`, {
635
+ headers: id.access_token ? { authorization: `Bearer ${id.access_token}` } : {},
636
+ });
637
+ if (!r.ok) return null;
638
+ return await r.json();
639
+ } catch {
640
+ return null;
641
+ }
642
+ }
643
+
644
+ // A short "mining" flourish after the judging completes — names the suite as it assembles, so the wait
645
+ // reads as work being done ON their data, not a dead spinner. (The 12-lens causal suite, in plain terms.)
646
+ async function miningFlourish(sp) {
647
+ for (const p of [
648
+ "Assembling your causal DAG…",
649
+ "Mining behavioral patterns — bottlenecks, triggers, spirals, resolvers…",
650
+ "Ranking where your time actually goes…",
651
+ ]) {
652
+ sp.text(p);
653
+ await sleep(process.env.SKALPEL_DEMO ? 700 : 500);
654
+ }
655
+ }
656
+
657
+ // Realistic sample profile so `SKALPEL_DEMO=1` shows the full reveal (for demos / screenshots).
658
+ const DEMO_REPORT = {
659
+ headline: { sessions: 18, intents: 240, hours: 42.5, rework_pct: 45 },
660
+ morphology: [{ archetype: "over-polish", sessions: 7, hours_wasted: 12.1 }],
661
+ discriminative: [{ seq: "refine→refine→refine", high: 8, clean: 0 }],
662
+ bottleneck: [{ type: "debug", pct_time: 31, pct_wasted: 60 }],
663
+ survival: [{ depth: 5, escape_pct: 96, n: 40 }],
664
+ };
665
+
666
+ // THE REVEAL — natural-language readout of what skalpel learned, led by the WASTE NUMBER. Every line
667
+ // is a real finding from the causal suite; degrades gracefully when a lens is empty (a light history).
668
+ async function revealInsights(report) {
669
+ if (!report || !report.headline) return;
670
+ const h = report.headline;
671
+ const line = async (s, d = 380) => {
672
+ console.log(s);
673
+ await sleep(process.env.SKALPEL_DEMO ? d + 220 : d);
674
+ };
675
+ console.log("");
676
+ await line(` ${B}Here's what I learned about how you work:${X}\n`, 480);
677
+
678
+ const rw = h.rework_pct || 0;
679
+ await line(
680
+ ` ${R}▸${X} You've spent ${B}${h.hours}h${X} with AI across ${h.sessions} sessions — and about ${B}${R}${rw}%${X} of it went to ${B}rework${X}:`,
681
+ );
682
+ await line(
683
+ ` ${D}circling back, re-explaining, re-fixing the same thing. That's the time skalpel goes after.${X}\n`,
684
+ 520,
685
+ );
686
+
687
+ const morph = (report.morphology || [])
688
+ .slice()
689
+ .sort((a, b) => (b.hours_wasted || 0) - (a.hours_wasted || 0))[0];
690
+ if (morph)
691
+ await line(
692
+ ` ${R}▸${X} Your waste is mostly ${B}${morph.archetype}${X} ${D}(~${morph.hours_wasted}h of it).${X}`,
693
+ );
694
+
695
+ const d0 = (report.discriminative || [])[0];
696
+ if (d0)
697
+ await line(
698
+ ` ${R}▸${X} Your signature trap: ${B}${d0.seq}${X} ${D}— shows up in your worst sessions, never your clean ones.${X}`,
699
+ );
700
+
701
+ const bn = (report.bottleneck || [])[0];
702
+ if (bn)
703
+ await line(
704
+ ` ${R}▸${X} Biggest time sink: ${B}${bn.type}${X} ${D}— ${bn.pct_time}% of your wall-clock.${X}`,
705
+ );
706
+
707
+ const surv = report.survival || [];
708
+ const deep = surv[surv.length - 1];
709
+ if (deep) {
710
+ const s =
711
+ deep.escape_pct >= 80
712
+ ? `you ${B}grind out${X} ${D}(${deep.escape_pct}% escape even deep spirals) — for you waste is time, not failure${X}`
713
+ : `you tend to ${B}bail${X} ${D}(only ${deep.escape_pct}% escape a deep spiral)${X}`;
714
+ await line(` ${R}▸${X} When you spiral, ${s}.`, 480);
715
+ }
716
+
717
+ console.log("");
718
+ await line(
719
+ ` ${G}skalpel now watches for these every session and steers you off them in real time.${X}`,
720
+ 260,
721
+ );
722
+ await line(
723
+ ` ${D}No dashboard to check — you'll feel it mid-session, right when it matters.${X}\n`,
724
+ 180,
725
+ );
726
+ }
727
+
728
+ async function main() {
729
+ if (sub === "uninstall") {
730
+ spawnSync("node", [join(__dir, "install.mjs"), "--uninstall"], { stdio: "inherit" });
731
+ return;
732
+ }
733
+ // `skalpel-prosumer login` — just run the sign-in flow (also invoked automatically by setup).
734
+ if (sub === "login") {
735
+ spawnSync("node", [join(__dir, "login.mjs")], { stdio: "inherit" });
736
+ return;
737
+ }
738
+ // `skalpel-prosumer logout` — clear the saved Google session so the next run signs in fresh.
739
+ // (Setup skips the sign-in when a valid ~/.skalpel/auth.json exists — this is how you force a new
740
+ // account / a real Google popup.)
741
+ if (sub === "logout") {
742
+ try {
743
+ const { rmSync } = await import("node:fs");
744
+ rmSync(join(homedir(), ".skalpel", "auth.json"), { force: true });
745
+ console.log(` ${G}✓${X} Signed out. Next run will prompt for Google sign-in.\n`);
746
+ } catch (e) {
747
+ console.log(` Could not sign out: ${String(e.message || e)}\n`);
748
+ }
749
+ return;
750
+ }
751
+ // Persist --api/--user to ~/.skalpel/client.json so the hooks can read them without shell-env
752
+ // plumbing (env vars SKALPEL_API / SKALPEL_USER still win at hook runtime).
753
+ if (apiFlag || userFlag) {
754
+ const cfgDir = join(homedir(), ".skalpel");
755
+ const cfgPath = join(cfgDir, "client.json");
756
+ let cfg = {};
757
+ try {
758
+ cfg = JSON.parse(readFileSync(cfgPath, "utf8"));
759
+ } catch {
760
+ /* fresh config */
761
+ }
762
+ if (apiFlag) cfg.api = apiFlag;
763
+ if (userFlag) cfg.user = userFlag;
764
+ mkdirSync(cfgDir, { recursive: true });
765
+ writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
766
+ }
767
+ banner();
768
+
769
+ // 1) SIGN IN with Google (once). SKALPEL_USER (dev) or an existing ~/.skalpel/auth.json skips it.
770
+ // identity() refreshes a stale token first, so a build long after login still authenticates.
771
+ let id = await identity();
772
+ if (!id && !process.env.SKALPEL_DEMO) {
773
+ console.log(` ${D}First, sign in with Google to create your account…${X}\n`);
774
+ spawnSync("node", [join(__dir, "login.mjs")], { stdio: "inherit" });
775
+ id = await identity();
776
+ if (!id) {
777
+ console.log(
778
+ `\n ${D}Not signed in — run ${X}skalpel-prosumer login${D} then re-run to build your graph.${X}\n`,
779
+ );
780
+ return;
781
+ }
782
+ }
783
+ if (id)
784
+ console.log(
785
+ ` ${G}✓${X} Signed in as ${D}${id.name ? `${id.name} · ${id.email}` : id.email || id.uid}${X}\n`,
786
+ );
787
+
788
+ // 2) find the real coding sessions in the last 30 days (the client reads; the server builds)
789
+ const s1 = spinner(`Looking through your last ${LOOKBACK_DAYS} days of Claude Code + Codex…`);
790
+ await sleep(500);
791
+ const files = await scanHistory();
792
+ const n = files.length;
793
+ s1.succeed(
794
+ n
795
+ ? `Found ${n} coding session${n === 1 ? "" : "s"} to learn from`
796
+ : "No sessions yet — your graph will grow as you work",
797
+ );
798
+
799
+ // 3) wire the hooks (instant). Never claim success we didn't verify: install.mjs exits non-zero if
800
+ // it could not stage the hook runtime, and swallowing that here is what let a broken install
801
+ // print a green checkmark while every hook crashed on import.
802
+ const s3 = spinner("Wiring Claude Code + Codex…");
803
+ const wired = spawnSync("node", [join(__dir, "install.mjs")], {
804
+ stdio: ["ignore", "ignore", "pipe"],
805
+ encoding: "utf8",
806
+ });
807
+ if (wired.status !== 0) {
808
+ s3.fail("Could not wire Claude Code + Codex");
809
+ if (wired.stderr) process.stderr.write(wired.stderr);
810
+ process.exit(1);
811
+ }
812
+ s3.succeed("Hooks wired into Claude Code + Codex");
813
+
814
+ // 4) build the graph + REVEAL the insights. First-run trust is won here: we mine in the foreground
815
+ // with a narrated animation, then read back the waste number + the top patterns in plain language,
816
+ // so the user SEES skalpel already knows them before typing a prompt. Escape hatches:
817
+ // SKALPEL_SETUP_BACKGROUND=1 keeps the old fast "warming in the background" path; non-TTY (CI)
818
+ // always goes background/silent so a pipeline never blocks on the reveal.
819
+ const wantReveal =
820
+ (isTTY || process.env.SKALPEL_SETUP_WAIT_FOR_GRAPH === "1") &&
821
+ process.env.SKALPEL_SETUP_BACKGROUND !== "1";
822
+
823
+ if (process.env.SKALPEL_DEMO) {
824
+ const s4 = spinner(`⛏ Mining your last ${LOOKBACK_DAYS} days of Claude Code + Codex…`);
825
+ await buildGraph(s4, files, id);
826
+ await miningFlourish(s4);
827
+ s4.succeed("Learned from your history");
828
+ await revealInsights(DEMO_REPORT);
829
+ await reportLiveAndLaunch(
830
+ `\n ${B}${G}You're live.${X} ${D}every prompt now pulls your history.${X}\n`,
831
+ );
832
+ } else if (files.length && wantReveal) {
833
+ const s4 = spinner(
834
+ `⛏ Mining ${files.length} session${files.length === 1 ? "" : "s"} — judging intent · trajectory · resolution…`,
835
+ );
836
+ let built = 0;
837
+ let err = null;
838
+ try {
839
+ built = await buildGraph(s4, files, id);
840
+ if (built) await miningFlourish(s4);
841
+ } catch (e) {
842
+ err = e;
843
+ }
844
+ if (built) {
845
+ s4.succeed(`Learned from ${built} session${built === 1 ? "" : "s"}`);
846
+ await revealInsights(await fetchProfile(id));
847
+ await reportLiveAndLaunch(
848
+ `\n ${B}${G}You're live.${X} ${D}every prompt now pulls your history.${X}\n`,
849
+ );
850
+ } else {
851
+ s4.fail(
852
+ `Couldn't build your graph${err ? ` (${String(err.message || err).slice(0, 40)})` : ""}`,
853
+ );
854
+ console.log(
855
+ `\n ${D}Hooks are wired and fail open. Re-run ${X}skalpel-prosumer setup${D} to try building again.${X}\n`,
856
+ );
857
+ }
858
+ } else if (files.length) {
859
+ // background/silent path (opt-out or CI): fast, no reveal
860
+ startBackgroundBuild();
861
+ const capped = Math.min(
862
+ files.length,
863
+ Math.max(1, Number.parseInt(process.env.SKALPEL_MAX_UPLOAD_SESSIONS || "20", 10)),
864
+ );
865
+ 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`,
867
+ );
868
+ } else {
869
+ await reportLiveAndLaunch(
870
+ `\n ${B}${G}You're live.${X} ${D}your graph grows as you work.${X}\n`,
871
+ );
872
+ }
873
+ }
874
+
875
+ // The detached background build (spawned by main): does ONLY the upload+judge, no banner/hooks.
876
+ async function backgroundBuild() {
877
+ const id = await identity();
878
+ if (!id) return; // not signed in — nothing to build under
879
+ const files = await scanHistory();
880
+ if (!files.length) return;
881
+ const sp = { text: () => {}, succeed: () => {} }; // silent — no TTY in the background
882
+ try {
883
+ await buildGraph(sp, files, id);
884
+ } catch {
885
+ /* fail-open: a failed background build never surfaces; hooks fail open until it succeeds */
886
+ }
887
+ }
888
+
889
+ // `__build` = the detached background graph build spawned by setup; skips the banner/hooks entirely.
890
+ if (isMain && sub === "__build") {
891
+ backgroundBuild()
892
+ .then(() => process.exit(0))
893
+ .catch(() => process.exit(0));
894
+ } else if (isMain) {
895
+ main().catch(() => process.exit(0));
896
+ }