skalpel 4.0.16 → 4.0.18

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 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/install.mjs CHANGED
@@ -54,6 +54,7 @@ function stageHookRuntime() {
54
54
  // a hook entrypoint staged without a module it imports — the exact 0.3.12–0.3.16 bug shape.
55
55
  copyFileSync(join(PKG_DIR, "insights.mjs"), join(HOOKS_DIR, "insights.mjs")); // hooks import ./insights.mjs
56
56
  copyFileSync(join(PKG_DIR, "reveal.mjs"), join(HOOKS_DIR, "reveal.mjs")); // both hooks import ./reveal.mjs
57
+ copyFileSync(join(PKG_DIR, "transcript.mjs"), join(HOOKS_DIR, "transcript.mjs")); // skalpel-hook.mjs + skalpel-statusline.mjs import ./transcript.mjs (tailLines)
57
58
  copyFileSync(join(PKG_DIR, "skalpel-hook.mjs"), HOOK_FILE);
58
59
  copyFileSync(join(PKG_DIR, "skalpel-hook-session.mjs"), SESSION_FILE);
59
60
  copyFileSync(join(PKG_DIR, "incremental-ingest.mjs"), join(HOOKS_DIR, "incremental-ingest.mjs"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skalpel",
3
- "version": "4.0.16",
3
+ "version": "4.0.18",
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": {
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
- const lines = readFileSync(payload.transcript_path, "utf8").trim().split("\n");
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
- const lines = readFileSync(payload.transcript_path, "utf8").trim().split("\n");
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: label || null,
176
+ label: safeLabel || null,
162
177
  type: type || null,
163
178
  wt: wt || null,
164
179
  id: id || null,
@@ -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 { closeSync, fstatSync, openSync, readFileSync, readSync } from "node:fs";
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
- return typeof label === "string" && label.trim() ? label.trim() : null;
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
+ }