skalpel 4.0.15 → 4.0.17
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/insights.mjs +1 -1
- package/package.json +1 -1
- package/skalpel-hook.mjs +19 -4
- package/skalpel-setup.mjs +14 -14
- package/skalpel-statusline.mjs +8 -33
- package/transcript.mjs +39 -0
package/insights.mjs
CHANGED
|
@@ -30,7 +30,7 @@ const KEEP_LINES = 400;
|
|
|
30
30
|
// compromised upstream string can't smuggle OSC/CSI sequences (clipboard writes, title spoofing)
|
|
31
31
|
// toward anyone's terminal. Newlines collapse to spaces (one record = one NDJSON line of prose).
|
|
32
32
|
const MAX_TEXT = 2000;
|
|
33
|
-
function cleanText(v) {
|
|
33
|
+
export function cleanText(v) {
|
|
34
34
|
if (typeof v !== "string") return v;
|
|
35
35
|
// eslint-disable-next-line no-control-regex
|
|
36
36
|
const flat = v.replace(/[\r\n\t]+/g, " ").replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
|
package/package.json
CHANGED
package/skalpel-hook.mjs
CHANGED
|
@@ -13,8 +13,14 @@ import { homedir } from "node:os";
|
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
import { identity } from "./auth.mjs";
|
|
15
15
|
import { record } from "./metrics.mjs";
|
|
16
|
-
import { recordInsight } from "./insights.mjs";
|
|
16
|
+
import { recordInsight, cleanText } from "./insights.mjs";
|
|
17
17
|
import { graphReadyNote } from "./reveal.mjs";
|
|
18
|
+
import { tailLines } from "./transcript.mjs";
|
|
19
|
+
|
|
20
|
+
// Tail budget for the two transcript scans below (last-prompt + cascade). Both only ever look at the
|
|
21
|
+
// final handful of user turns, so 256 KiB of tail is ample — and it keeps the read constant-ish instead
|
|
22
|
+
// of unbounded, so it fits inside the hook's single fail-open deadline even on a multi-GB transcript.
|
|
23
|
+
const TRANSCRIPT_TAIL_BYTES = 256 * 1024;
|
|
18
24
|
|
|
19
25
|
// Config precedence: env > ~/.skalpel/client.json (written by `skalpel-prosumer setup`) > default.
|
|
20
26
|
function fileCfg() {
|
|
@@ -38,7 +44,9 @@ const DEADLINE_MS = 4500;
|
|
|
38
44
|
function lastUserPrompt(payload) {
|
|
39
45
|
if (payload.prompt) return String(payload.prompt);
|
|
40
46
|
try {
|
|
41
|
-
|
|
47
|
+
// Bounded tail read: the last user turn lives at the end of the transcript, so scanning the tail
|
|
48
|
+
// finds it without slurping a multi-GB file (no size/time ceiling would violate the hook deadline).
|
|
49
|
+
const lines = tailLines(payload.transcript_path, TRANSCRIPT_TAIL_BYTES);
|
|
42
50
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
43
51
|
const e = JSON.parse(lines[i]);
|
|
44
52
|
if (e.type === "user" || e.role === "user") {
|
|
@@ -76,7 +84,10 @@ const _CORRECTION =
|
|
|
76
84
|
function detectCascade(payload) {
|
|
77
85
|
try {
|
|
78
86
|
if (!payload.transcript_path) return 0;
|
|
79
|
-
|
|
87
|
+
// Bounded tail read (256 KiB) — this runs EVERY turn, BEFORE the fetch deadline is armed, so an
|
|
88
|
+
// unbounded slurp here had zero time budget and could stall the prompt for seconds on a big/synced
|
|
89
|
+
// transcript. The cascade scan only needs the last 6 user turns, which live in the tail.
|
|
90
|
+
const lines = tailLines(payload.transcript_path, TRANSCRIPT_TAIL_BYTES);
|
|
80
91
|
let signals = 0;
|
|
81
92
|
let userTurns = 0;
|
|
82
93
|
for (let i = lines.length - 1; i >= 0 && userTurns < 6; i--) {
|
|
@@ -155,10 +166,14 @@ function writeSteer(label, type, wt, id) {
|
|
|
155
166
|
try {
|
|
156
167
|
mkdirSync(join(homedir(), ".skalpel"), { recursive: true });
|
|
157
168
|
const tmp = `${STEER_PATH}.tmp`;
|
|
169
|
+
// `label` is the hosted graph's server-controlled string; the statusline writes it RAW to the live
|
|
170
|
+
// terminal. Strip C0/C1/ESC/DEL and cap length at write time (same cleanText the insights path uses)
|
|
171
|
+
// so a compromised/hostile upstream can't smuggle OSC/CSI escapes (title spoof, screen clear) here.
|
|
172
|
+
const safeLabel = label ? cleanText(String(label)) : null;
|
|
158
173
|
writeFileSync(
|
|
159
174
|
tmp,
|
|
160
175
|
JSON.stringify({
|
|
161
|
-
label:
|
|
176
|
+
label: safeLabel || null,
|
|
162
177
|
type: type || null,
|
|
163
178
|
wt: wt || null,
|
|
164
179
|
id: id || null,
|
package/skalpel-setup.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// setup — the automatic install flow (`skalpel
|
|
2
|
+
// setup — the automatic install flow (`skalpel setup`, also npm postinstall). Prints the
|
|
3
3
|
// banner, signs you in with Google (once), reads your last 30 days of Claude Code / Codex history,
|
|
4
4
|
// UPLOADS it to the hosted graph (the server judges + builds — the client stays engine-free), wires
|
|
5
5
|
// the two hooks into Claude Code + Codex, and reports "you're live". Skips in CI / non-TTY (override:
|
|
@@ -22,8 +22,8 @@ import { fileURLToPath } from "node:url";
|
|
|
22
22
|
import { spawn, spawnSync } from "node:child_process";
|
|
23
23
|
import { identity as authIdentity, cliAuthPath } from "./auth.mjs";
|
|
24
24
|
|
|
25
|
-
// CLI (the `skalpel
|
|
26
|
-
// [--user <id>]`, plus `skalpel
|
|
25
|
+
// CLI (the `skalpel` bin): `skalpel setup [--api https://graph.skalpel.ai]
|
|
26
|
+
// [--user <id>]`, plus `skalpel uninstall` and `skalpel login`. A bare invocation
|
|
27
27
|
// (npm postinstall) keeps the TTY-gated flow below.
|
|
28
28
|
const argv = process.argv.slice(2);
|
|
29
29
|
const sub = argv[0] && !argv[0].startsWith("-") ? argv[0] : null;
|
|
@@ -55,14 +55,14 @@ const KNOWN_SUBS = new Set(["setup", "login", "logout", "uninstall", "autopsy",
|
|
|
55
55
|
const VALUE_FLAGS = new Set(["--api", "--user"]);
|
|
56
56
|
// Boolean flags that take no value. `--purge` on `uninstall` also removes auth + all local data.
|
|
57
57
|
const BOOL_FLAGS = new Set(["--purge"]);
|
|
58
|
-
const USAGE = `skalpel
|
|
58
|
+
const USAGE = `skalpel — connect your coding history to your behavioral graph
|
|
59
59
|
|
|
60
60
|
usage:
|
|
61
|
-
skalpel
|
|
62
|
-
skalpel
|
|
63
|
-
skalpel
|
|
64
|
-
skalpel
|
|
65
|
-
skalpel
|
|
61
|
+
skalpel [setup] [--api <url>] [--user <id>] sign in, learn your history, wire the hooks
|
|
62
|
+
skalpel login (re-)run the Google sign-in
|
|
63
|
+
skalpel logout clear the saved session
|
|
64
|
+
skalpel autopsy local, read-only receipt of your verified patterns
|
|
65
|
+
skalpel uninstall [--purge] remove the hooks + local data from this machine
|
|
66
66
|
|
|
67
67
|
options:
|
|
68
68
|
--api <url> point at a different graph server
|
|
@@ -325,7 +325,7 @@ function promptLaunch(targets) {
|
|
|
325
325
|
const launchers = (targets || []).filter((entry) => !!entry?.command);
|
|
326
326
|
if (!launchers.length) {
|
|
327
327
|
console.log(`
|
|
328
|
-
${D}No AI launcher was found on PATH. Re-run ${X}skalpel
|
|
328
|
+
${D}No AI launcher was found on PATH. Re-run ${X}skalpel setup${D} after installing Claude Code or Codex.${X}
|
|
329
329
|
`);
|
|
330
330
|
return Promise.resolve();
|
|
331
331
|
}
|
|
@@ -810,12 +810,12 @@ async function main() {
|
|
|
810
810
|
spawnSync("node", [join(__dir, "install.mjs"), ...passthrough], { stdio: "inherit" });
|
|
811
811
|
return;
|
|
812
812
|
}
|
|
813
|
-
// `skalpel
|
|
813
|
+
// `skalpel login` — just run the sign-in flow (also invoked automatically by setup).
|
|
814
814
|
if (sub === "login") {
|
|
815
815
|
spawnSync("node", [join(__dir, "login.mjs")], { stdio: "inherit" });
|
|
816
816
|
return;
|
|
817
817
|
}
|
|
818
|
-
// `skalpel
|
|
818
|
+
// `skalpel logout` — clear the saved Google session so the next run signs in fresh.
|
|
819
819
|
// (Setup skips the sign-in when a valid ~/.skalpel/auth.json exists — this is how you force a new
|
|
820
820
|
// account / a real Google popup.)
|
|
821
821
|
if (sub === "logout") {
|
|
@@ -886,7 +886,7 @@ async function main() {
|
|
|
886
886
|
id = await identity();
|
|
887
887
|
if (!id) {
|
|
888
888
|
console.log(
|
|
889
|
-
`\n ${D}Not signed in — run ${X}skalpel
|
|
889
|
+
`\n ${D}Not signed in — run ${X}skalpel login${D} then re-run to build your graph.${X}\n`,
|
|
890
890
|
);
|
|
891
891
|
return;
|
|
892
892
|
}
|
|
@@ -967,7 +967,7 @@ async function main() {
|
|
|
967
967
|
`Couldn't build your graph${err ? ` (${String(err.message || err).slice(0, 40)})` : ""}`,
|
|
968
968
|
);
|
|
969
969
|
console.log(
|
|
970
|
-
`\n ${D}Hooks are wired and fail open. Re-run ${X}skalpel
|
|
970
|
+
`\n ${D}Hooks are wired and fail open. Re-run ${X}skalpel setup${D} to try building again.${X}\n`,
|
|
971
971
|
);
|
|
972
972
|
}
|
|
973
973
|
} else if (files.length) {
|
package/skalpel-statusline.mjs
CHANGED
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
// that verifiably happened, or
|
|
8
8
|
// • the current steer's plain-English label (what skalpel is doing now) — a description, not a number.
|
|
9
9
|
// A clean session prints NOTHING. Fail-open: any error → print nothing, exit 0. Never blocks the bar.
|
|
10
|
-
import {
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
11
|
import { homedir } from "node:os";
|
|
12
12
|
import { join } from "node:path";
|
|
13
|
+
import { tailLines } from "./transcript.mjs";
|
|
14
|
+
import { cleanText } from "./insights.mjs";
|
|
13
15
|
|
|
14
16
|
const CYAN = "\x1b[36m";
|
|
15
17
|
const BOLD = "\x1b[1m";
|
|
@@ -30,37 +32,6 @@ function readPayload() {
|
|
|
30
32
|
}
|
|
31
33
|
}
|
|
32
34
|
|
|
33
|
-
// Read only the TAIL of a (possibly large) transcript — the status bar re-renders often, so we never
|
|
34
|
-
// slurp a multi-MB file. Drop the partial first line so JSON.parse only ever sees whole records.
|
|
35
|
-
function tailLines(path, maxBytes) {
|
|
36
|
-
let fd;
|
|
37
|
-
try {
|
|
38
|
-
fd = openSync(path, "r");
|
|
39
|
-
const size = fstatSync(fd).size;
|
|
40
|
-
const start = Math.max(0, size - maxBytes);
|
|
41
|
-
const len = size - start;
|
|
42
|
-
if (len <= 0) return [];
|
|
43
|
-
const buf = Buffer.allocUnsafe(len);
|
|
44
|
-
readSync(fd, buf, 0, len, start);
|
|
45
|
-
let text = buf.toString("utf8");
|
|
46
|
-
if (start > 0) {
|
|
47
|
-
const nl = text.indexOf("\n");
|
|
48
|
-
if (nl >= 0) text = text.slice(nl + 1);
|
|
49
|
-
}
|
|
50
|
-
return text.split("\n").filter(Boolean);
|
|
51
|
-
} catch {
|
|
52
|
-
return [];
|
|
53
|
-
} finally {
|
|
54
|
-
if (fd !== undefined) {
|
|
55
|
-
try {
|
|
56
|
-
closeSync(fd);
|
|
57
|
-
} catch {
|
|
58
|
-
/* already closed */
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
35
|
// The re-ask signal, counted from disk: over the last few user turns of THIS session's transcript, how
|
|
65
36
|
// many times did the user INTERRUPT ("[Request interrupted by user]") or fire a short CORRECTION ("no",
|
|
66
37
|
// "still wrong", "that's not it"). Mirrors the per-turn hook's own cascade detector so the number the
|
|
@@ -106,7 +77,11 @@ function steerLabel() {
|
|
|
106
77
|
try {
|
|
107
78
|
const st = JSON.parse(readFileSync(join(homedir(), ".skalpel", "steer.json"), "utf8"));
|
|
108
79
|
const label = st && typeof st === "object" ? st.label : null;
|
|
109
|
-
|
|
80
|
+
// steer.json's label originates from the hosted graph (server-controlled). The hook sanitizes it at
|
|
81
|
+
// write time, but this string is about to be written RAW to the live terminal (not JSON-escaped like
|
|
82
|
+
// the hook's additionalContext), so strip any C0/C1/ESC/DEL + cap length here too — last-line defense.
|
|
83
|
+
const clean = typeof label === "string" ? cleanText(label).trim() : null;
|
|
84
|
+
return clean ? clean : null;
|
|
110
85
|
} catch {
|
|
111
86
|
return null;
|
|
112
87
|
}
|
package/transcript.mjs
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// transcript.mjs — shared bounded transcript read. Both the per-turn hook and the statusline scan the
|
|
2
|
+
// last few user turns of a (possibly multi-GB) JSONL transcript. A full `readFileSync` has no size or
|
|
3
|
+
// time ceiling — on a long session, a big pasted diff, or a cloud-synced home dir (iCloud/Dropbox
|
|
4
|
+
// placeholder hydration) it can stall for seconds, blowing the hook's single fail-open deadline. This
|
|
5
|
+
// reads ONLY the tail: seek to the last `maxBytes`, drop the partial first line so JSON.parse only ever
|
|
6
|
+
// sees whole records. Constant-ish read time regardless of transcript size. Never throws.
|
|
7
|
+
import { closeSync, fstatSync, openSync, readSync } from "node:fs";
|
|
8
|
+
|
|
9
|
+
// Read only the TAIL of a (possibly large) transcript. Returns whole lines (partial first line dropped
|
|
10
|
+
// when we didn't start at byte 0), newest content last. On any error → [] (fail-open, same as a slurp
|
|
11
|
+
// that threw). 256 KiB is proven sufficient for the last-6-user-turns scan the callers do.
|
|
12
|
+
export function tailLines(path, maxBytes) {
|
|
13
|
+
let fd;
|
|
14
|
+
try {
|
|
15
|
+
fd = openSync(path, "r");
|
|
16
|
+
const size = fstatSync(fd).size;
|
|
17
|
+
const start = Math.max(0, size - maxBytes);
|
|
18
|
+
const len = size - start;
|
|
19
|
+
if (len <= 0) return [];
|
|
20
|
+
const buf = Buffer.allocUnsafe(len);
|
|
21
|
+
readSync(fd, buf, 0, len, start);
|
|
22
|
+
let text = buf.toString("utf8");
|
|
23
|
+
if (start > 0) {
|
|
24
|
+
const nl = text.indexOf("\n");
|
|
25
|
+
if (nl >= 0) text = text.slice(nl + 1);
|
|
26
|
+
}
|
|
27
|
+
return text.split("\n").filter(Boolean);
|
|
28
|
+
} catch {
|
|
29
|
+
return [];
|
|
30
|
+
} finally {
|
|
31
|
+
if (fd !== undefined) {
|
|
32
|
+
try {
|
|
33
|
+
closeSync(fd);
|
|
34
|
+
} catch {
|
|
35
|
+
/* already closed */
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|