skalpel 3.4.6 → 3.4.7

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.
@@ -0,0 +1,382 @@
1
+ #!/usr/bin/env node
2
+ // First-run hosted graph bootstrap for the skalpel TUI. The worker emits one JSON object per
3
+ // line so Bubble Tea can keep rendering while the server judges the user's coding history.
4
+ // Authentication comes exclusively from the canonical `skalpel login` Cognito session.
5
+ import {
6
+ accessSync,
7
+ closeSync,
8
+ constants,
9
+ createReadStream,
10
+ mkdirSync,
11
+ openSync,
12
+ readFileSync,
13
+ readdirSync,
14
+ renameSync,
15
+ statSync,
16
+ unlinkSync,
17
+ writeFileSync,
18
+ } from "node:fs";
19
+ import { homedir } from "node:os";
20
+ import { basename, dirname, join } from "node:path";
21
+ import { createInterface } from "node:readline";
22
+ import { fileURLToPath } from "node:url";
23
+ import { spawnSync } from "node:child_process";
24
+ import { gzipSync } from "node:zlib";
25
+ import { identity } from "./auth.mjs";
26
+ import { recordInsight } from "./insights.mjs";
27
+
28
+ const DIR = join(homedir(), ".skalpel");
29
+ const STATE_PATH = join(DIR, "bootstrap.json");
30
+ const LOCK_PATH = join(DIR, "bootstrap.lock");
31
+ const HERE = dirname(fileURLToPath(import.meta.url));
32
+ const FORCE = process.argv.includes("--force");
33
+ const LOOKBACK_DAYS = Math.max(1, Number.parseInt(process.env.SKALPEL_LOOKBACK_DAYS || "30", 10));
34
+ const MIN_USER_TURNS = Math.max(
35
+ 1,
36
+ Number.parseInt(process.env.SKALPEL_MIN_USER_TURNS || "5", 10),
37
+ );
38
+ const MAX_SESSIONS = Math.max(
39
+ 1,
40
+ Number.parseInt(process.env.SKALPEL_MAX_UPLOAD_SESSIONS || "20", 10),
41
+ );
42
+ const MAX_BYTES =
43
+ Math.max(1, Number.parseInt(process.env.SKALPEL_MAX_UPLOAD_MB || "90", 10)) * 1024 * 1024;
44
+ const HARD_CAP = 90 * 1024 * 1024;
45
+ const API = process.env.SKALPEL_API || readClientConfig().api || "https://graph.skalpel.ai";
46
+
47
+ process.stdout.on("error", () => {});
48
+
49
+ function emit(state, message, extra = {}) {
50
+ process.stdout.write(`${JSON.stringify({ state, message, ...extra })}\n`);
51
+ }
52
+
53
+ function readClientConfig() {
54
+ try {
55
+ return JSON.parse(readFileSync(join(DIR, "client.json"), "utf8"));
56
+ } catch {
57
+ return {};
58
+ }
59
+ }
60
+
61
+ function readState() {
62
+ try {
63
+ return JSON.parse(readFileSync(STATE_PATH, "utf8"));
64
+ } catch {
65
+ return {};
66
+ }
67
+ }
68
+
69
+ function writeState(state) {
70
+ mkdirSync(DIR, { recursive: true, mode: 0o700 });
71
+ const tmp = `${STATE_PATH}.tmp-${process.pid}`;
72
+ writeFileSync(tmp, JSON.stringify({ ...state, updated_at: new Date().toISOString() }, null, 2), {
73
+ mode: 0o600,
74
+ });
75
+ renameSync(tmp, STATE_PATH);
76
+ }
77
+
78
+ function acquireLock() {
79
+ mkdirSync(DIR, { recursive: true, mode: 0o700 });
80
+ try {
81
+ const fd = openSync(LOCK_PATH, "wx", 0o600);
82
+ writeFileSync(fd, String(process.pid));
83
+ closeSync(fd);
84
+ return true;
85
+ } catch {
86
+ try {
87
+ if (Date.now() - statSync(LOCK_PATH).mtimeMs > 25 * 60 * 1000) {
88
+ unlinkSync(LOCK_PATH);
89
+ return acquireLock();
90
+ }
91
+ } catch {
92
+ // A concurrent process may have released the lock between stat and read.
93
+ }
94
+ return false;
95
+ }
96
+ }
97
+
98
+ function releaseLock() {
99
+ try {
100
+ unlinkSync(LOCK_PATH);
101
+ } catch {
102
+ // Already released.
103
+ }
104
+ }
105
+
106
+ function walk(dir) {
107
+ let entries;
108
+ try {
109
+ entries = readdirSync(dir, { withFileTypes: true });
110
+ } catch {
111
+ return [];
112
+ }
113
+ const out = [];
114
+ for (const entry of entries) {
115
+ const path = join(dir, entry.name);
116
+ if (entry.isDirectory()) out.push(...walk(path));
117
+ else if (path.endsWith(".jsonl")) out.push(path);
118
+ }
119
+ return out;
120
+ }
121
+
122
+ async function userTurnCount(path, cap = MIN_USER_TURNS) {
123
+ let count = 0;
124
+ const input = createReadStream(path, { encoding: "utf8" });
125
+ const lines = createInterface({ input, crlfDelay: Infinity });
126
+ try {
127
+ for await (const line of lines) {
128
+ if (!line.includes('"user"')) continue;
129
+ let event;
130
+ try {
131
+ event = JSON.parse(line);
132
+ } catch {
133
+ continue;
134
+ }
135
+ if (event?.type === "user" || event?.role === "user") {
136
+ count += 1;
137
+ if (count >= cap) {
138
+ lines.close();
139
+ input.destroy();
140
+ return count;
141
+ }
142
+ }
143
+ }
144
+ } catch {
145
+ return 0;
146
+ }
147
+ return count;
148
+ }
149
+
150
+ async function scanHistory() {
151
+ const cutoff = Date.now() - LOOKBACK_DAYS * 864e5;
152
+ const candidates = [
153
+ ...walk(join(homedir(), ".claude", "projects")),
154
+ ...walk(join(homedir(), ".codex", "sessions")),
155
+ ].filter((path) => {
156
+ if (basename(path).startsWith("agent-")) return false;
157
+ try {
158
+ return statSync(path).mtimeMs >= cutoff;
159
+ } catch {
160
+ return false;
161
+ }
162
+ });
163
+ const sessions = [];
164
+ let cursor = 0;
165
+ const workers = Math.max(1, Number.parseInt(process.env.SKALPEL_SCAN_WORKERS || "16", 10));
166
+ async function worker() {
167
+ while (cursor < candidates.length) {
168
+ const path = candidates[cursor++];
169
+ if ((await userTurnCount(path)) >= MIN_USER_TURNS) sessions.push(path);
170
+ }
171
+ }
172
+ await Promise.all(Array.from({ length: Math.min(workers, candidates.length || 1) }, worker));
173
+ return sessions;
174
+ }
175
+
176
+ function buildPayload(files, uid) {
177
+ const ranked = files
178
+ .map((path) => {
179
+ try {
180
+ const stat = statSync(path);
181
+ return { path, modified: stat.mtimeMs, size: stat.size };
182
+ } catch {
183
+ return null;
184
+ }
185
+ })
186
+ .filter(Boolean)
187
+ .sort((a, b) => b.modified - a.modified);
188
+ const transcripts = {};
189
+ let bytes = 0;
190
+ for (const item of ranked) {
191
+ const count = Object.keys(transcripts).length;
192
+ if (count >= MAX_SESSIONS || (count > 0 && bytes + item.size > MAX_BYTES)) continue;
193
+ try {
194
+ let key = basename(item.path);
195
+ if (Object.hasOwn(transcripts, key)) key = `${count}-${key}`;
196
+ transcripts[key] = readFileSync(item.path, "utf8");
197
+ bytes += item.size;
198
+ } catch {
199
+ // Skip an unreadable transcript and continue with the next newest session.
200
+ }
201
+ }
202
+ let plain = JSON.stringify({ user_id: uid, raw_transcripts: transcripts });
203
+ let gzip = gzipSync(plain);
204
+ while (gzip.length > HARD_CAP && Object.keys(transcripts).length > 1) {
205
+ const keys = Object.keys(transcripts);
206
+ delete transcripts[keys[keys.length - 1]];
207
+ plain = JSON.stringify({ user_id: uid, raw_transcripts: transcripts });
208
+ gzip = gzipSync(plain);
209
+ }
210
+ if (gzip.length > HARD_CAP) throw new Error("coding history is too large to upload safely");
211
+ return { plain, gzip, sessions: Object.keys(transcripts).length };
212
+ }
213
+
214
+ async function postIngest(payload, token, compressed) {
215
+ return fetch(`${API}/ingest`, {
216
+ method: "POST",
217
+ headers: {
218
+ "content-type": "application/json",
219
+ ...(compressed ? { "content-encoding": "gzip" } : {}),
220
+ authorization: `Bearer ${token}`,
221
+ },
222
+ body: compressed ? payload.gzip : payload.plain,
223
+ });
224
+ }
225
+
226
+ async function pollJob(jobId, uid, token) {
227
+ const statusUrl = `${API}/ingest/status?job_id=${encodeURIComponent(jobId)}`;
228
+ let lastProgress = "";
229
+ for (let attempt = 0; attempt < 800; attempt += 1) {
230
+ await new Promise((resolve) => setTimeout(resolve, 1500));
231
+ let response;
232
+ try {
233
+ response = await fetch(statusUrl, { headers: { authorization: `Bearer ${token}` } });
234
+ } catch {
235
+ continue;
236
+ }
237
+ if (response.status === 401) throw new Error("session expired; run `skalpel login` and retry");
238
+ if (!response.ok) continue;
239
+ const status = await response.json().catch(() => ({}));
240
+ if (status.state === "done") return status.n_sessions ?? 0;
241
+ if (status.state === "error") {
242
+ writeState({ state: "failed", user_id: uid, error: "server graph build failed" });
243
+ throw new Error("server graph build failed");
244
+ }
245
+ const progress = `${status.done ?? 0}/${status.total ?? 0}`;
246
+ if (status.state === "judging" && progress !== lastProgress) {
247
+ lastProgress = progress;
248
+ writeState({ state: "judging", user_id: uid, job_id: jobId, ...status });
249
+ emit("judging", `Judging coding sessions ${progress}`, {
250
+ done: status.done ?? 0,
251
+ total: status.total ?? 0,
252
+ });
253
+ }
254
+ }
255
+ throw new Error("graph build is still running; retry to resume it");
256
+ }
257
+
258
+ async function fetchProfile(uid, token) {
259
+ try {
260
+ const response = await fetch(`${API}/profile?user_id=${encodeURIComponent(uid)}`, {
261
+ headers: { authorization: `Bearer ${token}` },
262
+ });
263
+ return response.ok ? response.json() : null;
264
+ } catch {
265
+ return null;
266
+ }
267
+ }
268
+
269
+ function recordProfile(profile, sessions) {
270
+ const headline = profile?.headline;
271
+ if (headline) {
272
+ recordInsight({
273
+ kind: "profile",
274
+ display: `learned ${headline.hours ?? 0}h across ${headline.sessions ?? sessions} sessions; ${headline.rework_pct ?? 0}% was rework`,
275
+ });
276
+ } else {
277
+ recordInsight({ kind: "profile", display: `standing profile built from ${sessions} sessions` });
278
+ }
279
+ const morphology = [...(profile?.morphology || [])].sort(
280
+ (a, b) => (b.hours_wasted || 0) - (a.hours_wasted || 0),
281
+ )[0];
282
+ if (morphology?.archetype) {
283
+ recordInsight({
284
+ kind: "profile",
285
+ display: `top recurring trap: ${morphology.archetype} (~${morphology.hours_wasted || 0}h)`,
286
+ });
287
+ }
288
+ const signature = profile?.discriminative?.[0];
289
+ if (signature?.seq) {
290
+ recordInsight({ kind: "profile", display: `signature loop: ${signature.seq}` });
291
+ }
292
+ }
293
+
294
+ async function main() {
295
+ const auth = await identity();
296
+ if (!auth.uid || !auth.token) throw new Error("sign in with `skalpel login` before learning history");
297
+
298
+ const prior = readState();
299
+ if (!FORCE && prior.state === "done" && prior.user_id === auth.uid) {
300
+ emit("skipped", "Coding history already learned", { terminal: true });
301
+ return;
302
+ }
303
+
304
+ emit("wiring", "Activating Claude Code and Codex hooks");
305
+ const wired = spawnSync(process.execPath, [join(HERE, "install.mjs")], { stdio: "ignore" });
306
+ if (wired.error || wired.status !== 0) throw new Error("could not activate coding-agent hooks");
307
+
308
+ if (prior.user_id === auth.uid && prior.job_id && prior.state !== "done") {
309
+ emit("resuming", "Resuming the existing graph build");
310
+ const sessions = await pollJob(prior.job_id, auth.uid, auth.token);
311
+ const profile = await fetchProfile(auth.uid, auth.token);
312
+ recordProfile(profile, sessions);
313
+ writeState({ state: "done", user_id: auth.uid, sessions });
314
+ emit("complete", `Learned from ${sessions} coding sessions`, { terminal: true, sessions });
315
+ return;
316
+ }
317
+
318
+ writeState({ state: "scanning", user_id: auth.uid });
319
+ emit("scanning", `Scanning the last ${LOOKBACK_DAYS} days of Claude Code and Codex`);
320
+ const files = await scanHistory();
321
+ if (!files.length) {
322
+ writeState({ state: "done", user_id: auth.uid, sessions: 0 });
323
+ emit("complete", "Hooks are active; your graph will grow as you work", {
324
+ terminal: true,
325
+ sessions: 0,
326
+ });
327
+ return;
328
+ }
329
+
330
+ const payload = buildPayload(files, auth.uid);
331
+ writeState({ state: "uploading", user_id: auth.uid, sessions: payload.sessions });
332
+ emit("uploading", `Uploading ${payload.sessions} coding sessions`);
333
+ let response = await postIngest(payload, auth.token, true);
334
+ if (response.status >= 500 && payload.plain.length < HARD_CAP) {
335
+ response = await postIngest(payload, auth.token, false);
336
+ }
337
+ if (!response.ok) {
338
+ if (response.status === 401) throw new Error("session expired; run `skalpel login` and retry");
339
+ throw new Error(`history ingest failed (${response.status})`);
340
+ }
341
+
342
+ const accepted = await response.json().catch(() => ({}));
343
+ let sessions;
344
+ if (typeof accepted.n_sessions === "number") {
345
+ sessions = accepted.n_sessions;
346
+ } else if (accepted.job_id) {
347
+ writeState({
348
+ state: "judging",
349
+ user_id: auth.uid,
350
+ job_id: accepted.job_id,
351
+ sessions: payload.sessions,
352
+ });
353
+ emit("judging", `Judging ${payload.sessions} coding sessions`, {
354
+ done: 0,
355
+ total: payload.sessions,
356
+ });
357
+ sessions = await pollJob(accepted.job_id, auth.uid, auth.token);
358
+ } else {
359
+ throw new Error("ingest response did not include a graph job");
360
+ }
361
+
362
+ const profile = await fetchProfile(auth.uid, auth.token);
363
+ recordProfile(profile, sessions);
364
+ writeState({ state: "done", user_id: auth.uid, sessions });
365
+ emit("complete", `Learned from ${sessions} coding sessions`, { terminal: true, sessions });
366
+ }
367
+
368
+ if (!acquireLock()) {
369
+ emit("skipped", "Coding history is already being learned in another skalpel window", {
370
+ terminal: true,
371
+ });
372
+ } else {
373
+ main()
374
+ .catch((error) => {
375
+ const prior = readState();
376
+ const message = String(error?.message || error).replace(/[\r\n\t]+/g, " ").slice(0, 240);
377
+ writeState({ ...prior, state: "failed", error: message });
378
+ emit("failed", message, { terminal: true, retryable: true });
379
+ process.exitCode = 1;
380
+ })
381
+ .finally(releaseLock);
382
+ }
@@ -0,0 +1,62 @@
1
+ // insights.mjs — local insight records, shared by both hooks. One NDJSON line per user-visible
2
+ // insight (steer/win/reset/ack/recap/profile), written in PLAIN ENGLISH for a local TUI to read —
3
+ // never the bracketed [skalpel — …] model directive.
4
+ // Append is atomic on POSIX for small writes, so concurrent hook double-fires never race or corrupt
5
+ // the file (a live-mutated counter file WOULD lose increments under that race). recordInsight()
6
+ // never throws and never blocks — a local record must not be able to break a fail-open hook.
7
+ import {
8
+ appendFileSync,
9
+ mkdirSync,
10
+ readFileSync,
11
+ renameSync,
12
+ statSync,
13
+ writeFileSync,
14
+ } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import { join } from "node:path";
17
+
18
+ const DIR = join(homedir(), ".skalpel");
19
+ export const INSIGHTS_PATH = join(DIR, "insights.ndjson");
20
+
21
+ // Unlike metrics.ndjson (write-only telemetry, uncapped), this file is READ by a TUI every render —
22
+ // cap it so it never grows unbounded. Over the byte cap, keep only the newest lines via tmp-file +
23
+ // atomic rename (same idiom as writeSteer), so a concurrent reader never sees a half-written file.
24
+ const MAX_BYTES = 512 * 1024;
25
+ const KEEP_LINES = 400;
26
+
27
+ // Strip terminal-control characters (C0 incl. ESC, DEL, C1) and cap length. The reader renders
28
+ // `display`/`label` into a live terminal; the server-provided steer_label flows in here, and the
29
+ // TUI treats this file as untrusted anyway — sanitizing at write time is defense-in-depth so a
30
+ // compromised upstream string can't smuggle OSC/CSI sequences (clipboard writes, title spoofing)
31
+ // toward anyone's terminal. Newlines collapse to spaces (one record = one NDJSON line of prose).
32
+ const MAX_TEXT = 2000;
33
+ function cleanText(v) {
34
+ if (typeof v !== "string") return v;
35
+ // eslint-disable-next-line no-control-regex
36
+ const flat = v.replace(/[\r\n\t]+/g, " ").replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
37
+ return flat.length > MAX_TEXT ? flat.slice(0, MAX_TEXT) + "…" : flat;
38
+ }
39
+
40
+ // rec: { kind, display, label?, mins?, harness?, session_id? } — `display` is the user-visible text.
41
+ export function recordInsight(rec) {
42
+ try {
43
+ if (rec && typeof rec === "object") {
44
+ if ("display" in rec) rec = { ...rec, display: cleanText(rec.display) };
45
+ if (rec.label != null) rec = { ...rec, label: cleanText(rec.label) };
46
+ }
47
+ mkdirSync(DIR, { recursive: true });
48
+ try {
49
+ if (statSync(INSIGHTS_PATH).size > MAX_BYTES) {
50
+ const lines = readFileSync(INSIGHTS_PATH, "utf8").trim().split("\n").filter(Boolean);
51
+ const tmp = `${INSIGHTS_PATH}.tmp`;
52
+ writeFileSync(tmp, lines.slice(-KEEP_LINES).join("\n") + "\n");
53
+ renameSync(tmp, INSIGHTS_PATH); // atomic swap
54
+ }
55
+ } catch {
56
+ /* no file yet, or the trim failed — either way, still try the append below */
57
+ }
58
+ appendFileSync(INSIGHTS_PATH, JSON.stringify({ ts: new Date().toISOString(), ...rec }) + "\n");
59
+ } catch {
60
+ /* never block a hook on a local insight record */
61
+ }
62
+ }