skalpel 3.4.5 → 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,355 @@
1
+ #!/usr/bin/env node
2
+ // install.mjs — wire the hosted skalpel behavior hook into Claude Code AND Codex. Pure Node,
3
+ // idempotent, and non-clobbering:
4
+ // preserves any existing hooks/settings, refreshes ours in place. Run: `node install.mjs` (or --uninstall).
5
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { join, dirname } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ const uninstall = process.argv.includes("--uninstall");
11
+
12
+ // Where this installer's own files live — the globally-installed package, a source checkout, OR the
13
+ // transient `npx` cache. All three must produce ONE working command, so we can't rely on a bin being
14
+ // on PATH (only `npm i -g` puts it there; `npx` and a source run don't). Instead we copy the three
15
+ // self-contained hook files into a STABLE home and wire an absolute `node <path>` command that works
16
+ // offline, survives npx cache GC, and needs no global install — so `npx @skalpelai/prosumer-hook` is
17
+ // the single command that leaves genuinely-working hooks.
18
+ const PKG_DIR = dirname(fileURLToPath(import.meta.url));
19
+ const HOOKS_DIR = join(homedir(), ".skalpel", "hooks");
20
+ // Keep the staged basenames == the historical bin names so the idempotency/uninstall MARKER is
21
+ // unchanged: an older bare-bin install (command "skalpel-prosumer-hook") is still recognized and
22
+ // gets healed to the absolute command on the next run.
23
+ const HOOK_FILE = join(HOOKS_DIR, "skalpel-hook.mjs");
24
+ const SESSION_FILE = join(HOOKS_DIR, "skalpel-hook-session.mjs");
25
+ const STATUSLINE_FILE = join(HOOKS_DIR, "skalpel-statusline.mjs");
26
+
27
+ // Copy the hook runtime (the two entrypoints + their shared auth.mjs) into ~/.skalpel/hooks so the
28
+ // wired absolute path is always valid. Overwrites on every install so upgrades refresh the code.
29
+ function stageHookRuntime() {
30
+ // `skalpel setup` invokes the already-staged copy. Avoid copying a file onto itself.
31
+ if (PKG_DIR === HOOKS_DIR) return;
32
+ mkdirSync(HOOKS_DIR, { recursive: true });
33
+ // insights.mjs is staged BEFORE the entrypoints that import it — a mid-copy ENOENT
34
+ // must not leave a wired hook missing its import (auth/metrics staging order predates
35
+ // this rule and is guarded by the all-or-nothing check below instead).
36
+ // a hook entrypoint staged without a module it imports — the exact 0.3.12–0.3.16 bug shape.
37
+ copyFileSync(join(PKG_DIR, "insights.mjs"), join(HOOKS_DIR, "insights.mjs")); // hooks import ./insights.mjs
38
+ copyFileSync(join(PKG_DIR, "skalpel-hook.mjs"), HOOK_FILE);
39
+ copyFileSync(join(PKG_DIR, "skalpel-hook-session.mjs"), SESSION_FILE);
40
+ copyFileSync(join(PKG_DIR, "skalpel-statusline.mjs"), STATUSLINE_FILE);
41
+ copyFileSync(join(PKG_DIR, "auth.mjs"), join(HOOKS_DIR, "auth.mjs")); // hooks import ./auth.mjs
42
+ copyFileSync(join(PKG_DIR, "metrics.mjs"), join(HOOKS_DIR, "metrics.mjs")); // hooks import ./metrics.mjs
43
+ copyFileSync(join(PKG_DIR, "stats.mjs"), join(HOOKS_DIR, "stats.mjs")); // `node ~/.skalpel/hooks/stats.mjs`
44
+ }
45
+ if (!uninstall && !process.env.SKALPEL_HOOK_BIN) {
46
+ try {
47
+ stageHookRuntime();
48
+ } catch (e) {
49
+ // Staging is all-or-nothing. A missing source file means the tarball is incomplete (a file this
50
+ // installer copies was left out of package.json "files"), and copyFileSync throws part-way
51
+ // through — so the *later* copies never run and the hooks are staged without the auth.mjs /
52
+ // metrics.mjs they import. Wiring absolute paths at that point yields hooks that crash on every
53
+ // prompt. Refuse to wire instead; a loud failure beats a silent no-op.
54
+ console.error(`skalpel: could not stage hook runtime: ${e.message}`);
55
+ console.error("skalpel: hooks were NOT wired.");
56
+ process.exit(1);
57
+ }
58
+ }
59
+
60
+ // The wired command. Defaults to the staged absolute `node <path>`; SKALPEL_HOOK_BIN /
61
+ // SKALPEL_HOOK_SESSION_BIN still override (dev, or a deliberate global-bin install).
62
+ const CMD = process.env.SKALPEL_HOOK_BIN || `node ${HOOK_FILE}`;
63
+ const SESSION_CMD = process.env.SKALPEL_HOOK_SESSION_BIN || `node ${SESSION_FILE}`;
64
+ const STATUSLINE_CMD = process.env.SKALPEL_STATUSLINE_BIN || `node ${STATUSLINE_FILE}`;
65
+ const CLAUDE = join(homedir(), ".claude", "settings.json");
66
+ const CODEX_DIR = join(homedir(), ".codex");
67
+ const CODEX = join(CODEX_DIR, "hooks.json");
68
+ const CODEX_TOML = join(CODEX_DIR, "config.toml"); // Codex reads hooks from config.toml [hooks]
69
+ // The idempotency/uninstall marker is the command's basename — always matches exactly what this
70
+ // installer wired, including bare-bin names and absolute `node <path>` commands.
71
+ const markerOf = (cmd) =>
72
+ cmd
73
+ .split(/[/\\]/)
74
+ .pop()
75
+ .replace(/\.(mjs|js|exe)$/, "");
76
+ const HOOKS = [
77
+ ["UserPromptSubmit", markerOf(CMD), CMD],
78
+ ["SessionStart", markerOf(SESSION_CMD), SESSION_CMD],
79
+ ];
80
+
81
+ function readJson(p) {
82
+ let raw;
83
+ try {
84
+ raw = readFileSync(p, "utf8");
85
+ } catch {
86
+ return {}; // missing file — a fresh {} is correct
87
+ }
88
+ if (!raw.trim()) return {}; // empty file
89
+ try {
90
+ return JSON.parse(raw);
91
+ } catch {
92
+ // The file EXISTS but is corrupt JSON. Returning {} here would make writeJson OVERWRITE and
93
+ // wipe the user's real settings. Never do that: back the broken file up and skip touching it.
94
+ try {
95
+ copyFileSync(p, `${p}.skalpel-bak-${Date.now()}`);
96
+ } catch {
97
+ /* best-effort backup */
98
+ }
99
+ return null; // signal "do not write" to callers
100
+ }
101
+ }
102
+ function writeJson(p, o) {
103
+ mkdirSync(dirname(p), { recursive: true });
104
+ writeFileSync(p, JSON.stringify(o, null, 2));
105
+ }
106
+ function isOurs(c, marker) {
107
+ if (typeof c !== "string") return false;
108
+ const markers = [
109
+ marker,
110
+ "skalpel-hook",
111
+ "skalpel-hook-session",
112
+ "skalpel-statusline",
113
+ "skalpel-prosumer-hook",
114
+ "skalpel-prosumer-hook-session",
115
+ "skalpel-prosumer-statusline",
116
+ ];
117
+ return markers.some((candidate) => {
118
+ const escaped = candidate.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
119
+ return new RegExp(`(^|[/\\\\\\s])${escaped}(\\.mjs|\\.js|\\.exe)?($|\\s)`).test(c);
120
+ });
121
+ }
122
+
123
+ // Claude Code: hooks.<event>[] = [{matcher?, hooks:[{type:"command", command:"…"}]}]. Each array
124
+ // entry MUST be a matcher-GROUP with a `hooks` array — a bare {type,command} is invalid and makes
125
+ // Claude Code reject the whole settings file ("Expected array, but received undefined"). Install
126
+ // REFRESHES our entry in place (strip any prior ours — whether it's a correct group OR a legacy
127
+ // bare-command entry a past buggy install wrote — then append a proper group). Idempotent AND
128
+ // self-healing: re-running repairs a file broken by the old writer.
129
+ function claude() {
130
+ const d = existsSync(CLAUDE) ? readJson(CLAUDE) : {};
131
+ if (d === null) return "skipped-corrupt"; // corrupt settings backed up; never overwrite blindly
132
+ d.hooks ??= {};
133
+ let installed = 0;
134
+ let refreshed = 0;
135
+ let removed = 0;
136
+ for (const [event, marker, command] of HOOKS) {
137
+ d.hooks[event] ??= [];
138
+ const arr = d.hooks[event];
139
+ const isMine = (e) => {
140
+ if (isOurs(e?.command, marker)) return true; // legacy bare {type,command} entry
141
+ const inner = Array.isArray(e?.hooks) ? e.hooks : [];
142
+ return inner.some((h) => isOurs(h?.command, marker));
143
+ };
144
+ const had = arr.some(isMine);
145
+ const kept = arr.filter((e) => !isMine(e)); // drop ours (both shapes) AND repair
146
+ // 8s, not 5s: the hosted backend can cold-start after idle, and the hook's own internal
147
+ // DEADLINE_MS (4.5s) needs headroom under this outer harness timeout or it gets killed first.
148
+ const group = { hooks: [{ type: "command", command, timeout: 8 }] };
149
+ d.hooks[event] = uninstall ? kept : [...kept, group];
150
+ if (uninstall) {
151
+ if (had) removed += 1;
152
+ } else if (had) {
153
+ refreshed += 1;
154
+ } else {
155
+ installed += 1;
156
+ }
157
+ }
158
+ // statusLine: not a hooks.<event> array, a single top-level key — only touch it if it's unset or
159
+ // already ours, so we never clobber a genuinely custom statusline someone configured themselves.
160
+ const statuslineIsOurs = isOurs(d.statusLine?.command, markerOf(STATUSLINE_CMD));
161
+ if (uninstall) {
162
+ if (statuslineIsOurs) {
163
+ delete d.statusLine;
164
+ removed += 1;
165
+ }
166
+ } else if (!d.statusLine || statuslineIsOurs) {
167
+ if (statuslineIsOurs) refreshed += 1;
168
+ else installed += 1;
169
+ d.statusLine = { type: "command", command: STATUSLINE_CMD };
170
+ }
171
+ writeJson(CLAUDE, d);
172
+ if (uninstall) return removed ? "removed" : "absent";
173
+ return installed ? "installed" : refreshed ? "refreshed" : "absent";
174
+ }
175
+
176
+ // Codex (per developers.openai.com/codex/hooks) reads UserPromptSubmit hooks from config.toml
177
+ // under [[hooks.UserPromptSubmit.hooks]] — TOML, same schema as hooks.json. This Codex ships a
178
+ // config.toml and no hooks.json, so config.toml is the reliable target. We write BOTH (docs accept
179
+ // either) but config.toml is what actually fires. Install strips any prior ours then appends fresh.
180
+ const TOML_MARK = "# skalpel-hook (managed)";
181
+ const TOML_MARKERS = [TOML_MARK, "# skalpel-prosumer-hook (managed)"];
182
+
183
+ function tomlMark(event, marker) {
184
+ return `${TOML_MARK} ${event} ${marker}`;
185
+ }
186
+
187
+ function tomlBlock(event, marker, command) {
188
+ return `\n${tomlMark(event, marker)}\n[[hooks.${event}]]\n[[hooks.${event}.hooks]]\ntype = "command"\ncommand = "${command}"\ntimeout = 8\n`;
189
+ }
190
+
191
+ function stripManagedTomlBlocks(txt) {
192
+ const lines = txt.split("\n");
193
+ const out = [];
194
+ let removed = 0;
195
+
196
+ for (let i = 0; i < lines.length; ) {
197
+ if (TOML_MARKERS.some((marker) => lines[i].includes(marker))) {
198
+ i += 6;
199
+ removed += 1;
200
+ continue;
201
+ }
202
+
203
+ const section = lines[i].match(/^\[\[hooks\.(UserPromptSubmit|SessionStart)\]\]\s*$/);
204
+ if (!section) {
205
+ out.push(lines[i]);
206
+ i += 1;
207
+ continue;
208
+ }
209
+
210
+ const event = section[1];
211
+ const marker = HOOKS.find(([hookEvent]) => hookEvent === event)?.[1];
212
+ const block = [lines[i]];
213
+ let j = i + 1;
214
+ while (
215
+ j < lines.length &&
216
+ !/^\[\[hooks\.(UserPromptSubmit|SessionStart)\]\]\s*$/.test(lines[j])
217
+ ) {
218
+ j += 1;
219
+ block.push(lines[j - 1]);
220
+ }
221
+
222
+ const command = block
223
+ .map((line) => line.match(/^\s*command\s*=\s*"([^"]*)"\s*$/)?.[1])
224
+ .find(Boolean);
225
+ if (marker && isOurs(command, marker)) {
226
+ removed += 1;
227
+ } else {
228
+ out.push(...block);
229
+ }
230
+ i = j;
231
+ }
232
+
233
+ return { text: out.join("\n"), removed };
234
+ }
235
+
236
+ function codexToml() {
237
+ if (!existsSync(CODEX_DIR)) return "no-codex";
238
+ const txt = existsSync(CODEX_TOML) ? readFileSync(CODEX_TOML, "utf8") : "";
239
+ // Strip current managed blocks and older unmanaged skalpel blocks, so setup heals stale Codex
240
+ // configs that still point at bare PATH commands from pre-staged installs.
241
+ const stripped = stripManagedTomlBlocks(txt);
242
+ let next = stripped.text.replace(/\n{3,}/g, "\n\n").trimEnd();
243
+ if (uninstall) {
244
+ writeFileSync(CODEX_TOML, next + "\n");
245
+ return stripped.removed ? "removed" : "absent";
246
+ }
247
+ mkdirSync(CODEX_DIR, { recursive: true });
248
+ for (const [event, marker, command] of HOOKS) {
249
+ next += "\n" + tomlBlock(event, marker, command);
250
+ }
251
+ writeFileSync(CODEX_TOML, next + "\n");
252
+ return stripped.removed ? "refreshed" : "installed";
253
+ }
254
+
255
+ // Also write hooks.json (docs accept either; harmless belt-and-suspenders for Codex builds that read it)
256
+ function codexJson() {
257
+ if (!existsSync(CODEX_DIR)) return "no-codex";
258
+ const d = existsSync(CODEX) ? readJson(CODEX) : {};
259
+ if (d === null) return "skipped-corrupt"; // corrupt file backed up; don't overwrite
260
+ d.hooks ??= {};
261
+ let installed = 0;
262
+ let refreshed = 0;
263
+ let removed = 0;
264
+ for (const [event, marker, command] of HOOKS) {
265
+ d.hooks[event] ??= [];
266
+ if (!d.hooks[event].length) d.hooks[event].push({ hooks: [] });
267
+ const grp = d.hooks[event][0];
268
+ grp.hooks ??= [];
269
+ const had = grp.hooks.some((h) => isOurs(h?.command, marker));
270
+ const kept = grp.hooks.filter((h) => !isOurs(h?.command, marker));
271
+ grp.hooks = uninstall ? kept : [...kept, { type: "command", command, timeout: 8 }];
272
+ if (uninstall) {
273
+ if (had) removed += 1;
274
+ } else if (had) {
275
+ refreshed += 1;
276
+ } else {
277
+ installed += 1;
278
+ }
279
+ }
280
+ writeJson(CODEX, d);
281
+ if (uninstall) return removed ? "removed" : "absent";
282
+ return installed ? "installed" : refreshed ? "refreshed" : "absent";
283
+ }
284
+
285
+ // Global CLAUDE.md — a STANDING attribution rule, on disk, loaded every session regardless of
286
+ // whether the per-turn hook's network call succeeds. The per-turn hook injects the DATA (the
287
+ // walked pattern + the number for THIS occurrence) but its own copy of the attribution rule only
288
+ // exists in that one injected block — miss the fetch (cold backend, timeout) and the rule never
289
+ // even reaches the model that turn. This block is the fallback that can't be dropped by a network
290
+ // hiccup: it's read from disk at session start, same as any other CLAUDE.md instruction.
291
+ const CLAUDE_MD = join(homedir(), ".claude", "CLAUDE.md");
292
+ const CLAUDE_MD_MARK_START = "<!-- skalpel-hook:attribution (managed) -->";
293
+ const CLAUDE_MD_MARK_END = "<!-- /skalpel-hook:attribution (managed) -->";
294
+ const CLAUDE_MD_MARK_PAIRS = [
295
+ [CLAUDE_MD_MARK_START, CLAUDE_MD_MARK_END],
296
+ [
297
+ "<!-- skalpel-prosumer-hook:attribution (managed) -->",
298
+ "<!-- /skalpel-prosumer-hook:attribution (managed) -->",
299
+ ],
300
+ ];
301
+ const CLAUDE_MD_BLOCK = `${CLAUDE_MD_MARK_START}
302
+ ## skalpel
303
+
304
+ A \`skalpel\` behavioral-graph hook may inject a per-turn context block (starts \`[skalpel — signals
305
+ for THIS turn ...]\`) with behavioral facts about how this user works. The block itself carries the
306
+ surfacing rule for that turn — follow it. There are two tiers:
307
+
308
+ - STRONG (a TELL or PLAN signal fired — a rare, high-confidence finding like "you're on a shape that
309
+ went bad 8× and never landed clean"): you MUST make the user feel it. Open with a \`🔬 skalpel ·\`
310
+ line that SPEAKS THE ACTUAL STAT (the shape, the count, the ~minutes) in plain words, name the loop
311
+ out loud, and you MAY briefly own your part ("I kept refining right there with you"). Then give the
312
+ genuinely-different answer the steer implies.
313
+ - SOFT (only sink/funnel/trigger-type signals): earned-only. Surface a \`🔬 skalpel ·\` line ONLY if a
314
+ signal genuinely changes your answer; otherwise a QUIET closing \`🔬 skalpel · told the model to:
315
+ <terse verbs>\` for an invisible tune, or nothing at all. Silence is the default here.
316
+
317
+ The 🔬 line is skalpel's FINDING from the signals, never your own advice. One 🔬 line per turn at most.
318
+ Never theater, never explain the mechanism.
319
+ ${CLAUDE_MD_MARK_END}`;
320
+
321
+ function claudeMd() {
322
+ const raw = existsSync(CLAUDE_MD) ? readFileSync(CLAUDE_MD, "utf8") : "";
323
+ let stripped = raw;
324
+ let had = false;
325
+ for (const [start, end] of CLAUDE_MD_MARK_PAIRS) {
326
+ const startIdx = stripped.indexOf(start);
327
+ const endIdx = stripped.indexOf(end);
328
+ if (startIdx !== -1 && endIdx !== -1 && endIdx >= startIdx) {
329
+ stripped = (
330
+ stripped.slice(0, startIdx) + stripped.slice(endIdx + end.length)
331
+ ).replace(/\n{3,}/g, "\n\n");
332
+ had = true;
333
+ }
334
+ }
335
+ if (uninstall) {
336
+ if (!had) return "absent";
337
+ writeFileSync(CLAUDE_MD, stripped.trimEnd() + "\n");
338
+ return "removed";
339
+ }
340
+ const next = stripped.trimEnd()
341
+ ? `${stripped.trimEnd()}\n\n${CLAUDE_MD_BLOCK}\n`
342
+ : `${CLAUDE_MD_BLOCK}\n`;
343
+ mkdirSync(dirname(CLAUDE_MD), { recursive: true });
344
+ writeFileSync(CLAUDE_MD, next);
345
+ return had ? "refreshed" : "installed";
346
+ }
347
+
348
+ console.log(
349
+ `skalpel hook (${uninstall ? "uninstall" : CMD}) → claude: ${claude()} · claude.md: ${claudeMd()} · codex(config.toml): ${codexToml()} · codex(hooks.json): ${codexJson()}`,
350
+ );
351
+ console.log(
352
+ uninstall
353
+ ? "uninstalled."
354
+ : `installed. Hooks run from ${HOOKS_DIR} and use the canonical skalpel Cognito login.`,
355
+ );
@@ -0,0 +1,32 @@
1
+ // metrics.mjs — injection telemetry, shared by both hooks. One NDJSON line per hook invocation.
2
+ // Append is atomic on POSIX for small writes, so concurrent hook double-fires never race or corrupt
3
+ // the file (a live-mutated counter file WOULD lose increments under that race). record() never throws
4
+ // and never blocks — telemetry must not be able to break a fail-open hook.
5
+ //
6
+ // Read it back with: node ~/.skalpel/hooks/stats.mjs
7
+ import { appendFileSync, mkdirSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { join } from "node:path";
10
+
11
+ const DIR = join(homedir(), ".skalpel");
12
+ export const METRICS_PATH = join(DIR, "metrics.ndjson");
13
+
14
+ // outcome ∈ inject | silent | server_error | abort | fetch_error | skipped | budget_spent
15
+ // `abort` == the fetch hit our deadline == a graph cold-start hit (the whole diagnosis).
16
+ // ms == wall-clock of the network call (null for skipped / pre-network exits).
17
+ export function record(event, outcome, ms) {
18
+ try {
19
+ mkdirSync(DIR, { recursive: true });
20
+ appendFileSync(
21
+ METRICS_PATH,
22
+ JSON.stringify({
23
+ ts: new Date().toISOString(),
24
+ event, // "prompt" | "session"
25
+ outcome,
26
+ ms: ms == null ? null : Math.round(ms),
27
+ }) + "\n",
28
+ );
29
+ } catch {
30
+ /* never block a hook on telemetry */
31
+ }
32
+ }
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env node
2
+ // skalpel SessionStart hook — fires ONCE when a Claude Code / Codex session opens. Injects the
3
+ // user's standing Amplitude profile (recurring patterns, outcome rates, response habits) so the
4
+ // model starts the session already knowing how they work. The per-turn UserPromptSubmit hook
5
+ // handles the specific query (the graph-walk fork); this handles the standing context. Fail-open.
6
+ //
7
+ // Wire it (Claude ~/.claude/settings.json):
8
+ // {"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"node …/skalpel-hook-session.mjs"}]}]}}
9
+ import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { identity } from "./auth.mjs";
13
+ import { record } from "./metrics.mjs";
14
+ import { recordInsight } from "./insights.mjs";
15
+
16
+ // The statusline counter is PER-SESSION: it shows the estimated rework skalpel flagged during THIS
17
+ // session, not a forever-accumulating lifetime total (which reads as a bragging trophy and is
18
+ // unprovable). SessionStart zeroes it so each new session starts the count fresh. Fail-open — a
19
+ // stats reset must never block the profile injection.
20
+ // RECAP — the felt wins, surfaced at a break point (peak-end rule). Two break points:
21
+ // • "compact" → mid-session, right AFTER compaction (a natural chapter break, user present). The
22
+ // recap is CUMULATIVE ("so far this session") and the tally is NOT reset — work goes on.
23
+ // • else → a fresh session start; recap the PREVIOUS session's tally (re-engagement), then reset.
24
+ // PreCompact/SessionEnd stdout are silent in Claude Code; the post-compact SessionStart CAN inject, so
25
+ // that's where the compaction recap rides. Read BEFORE resetStats wipes it.
26
+ const SESSION_PATH = join(homedir(), ".skalpel", "session.json");
27
+ // Returns { note, mins } — the injected directive AND the minutes it cites, so the local insight
28
+ // record (below) can reuse the exact same number instead of re-deriving it.
29
+ function buildRecap(source) {
30
+ try {
31
+ const s = JSON.parse(readFileSync(SESSION_PATH, "utf8"));
32
+ const mins = Math.round(s.mins || 0);
33
+ const steers = Array.isArray(s.steers) ? s.steers : [];
34
+ // Recap only when there's real saved time to report — the honest, felt number. No "caught" count.
35
+ if (mins < 1) return { note: null, mins: 0 };
36
+ const byWt = {};
37
+ for (const st of steers) if (st.wt) byWt[st.wt] = (byWt[st.wt] || 0) + 1;
38
+ const top = Object.entries(byWt).sort((a, b) => b[1] - a[1])[0];
39
+ const trapTail = top ? ` — ${top[0]} kept coming up` : "";
40
+ const compact = source === "compact";
41
+ const when = compact ? "in the stretch before this compaction" : "last session";
42
+ const line = compact
43
+ ? `“🔬 skalpel · in that last stretch I saved you ~${mins}min${trapTail} — fresh context now.”`
44
+ : `“🔬 skalpel · last session I saved you ~${mins}min${trapTail}.”`;
45
+ const note =
46
+ `[skalpel — RECAP (${when}): saved ~${mins}min` +
47
+ (top ? `; the recurring trap was ${top[0]} (${top[1]}×)` : "") +
48
+ `. Open THIS message with ONE natural line so they see it landed — ${line} ` +
49
+ "Then continue normally. Skip it only if it doesn't fit.]";
50
+ return { note, mins };
51
+ } catch {
52
+ return { note: null, mins: 0 }; // no prior session / fresh install
53
+ }
54
+ }
55
+
56
+ function resetStats() {
57
+ try {
58
+ const dir = join(homedir(), ".skalpel");
59
+ mkdirSync(dir, { recursive: true });
60
+ const p = join(dir, "stats.json");
61
+ // Reset the SESSION counters to zero but PRESERVE the lifetime total across sessions — so the
62
+ // statusline can show both "saved this session" and "total saved". Read current first.
63
+ let s = {
64
+ session_hours: 0,
65
+ session_cost: 0,
66
+ session_events: 0,
67
+ total_hours: 0,
68
+ total_cost: 0,
69
+ total_events: 0,
70
+ };
71
+ try {
72
+ s = { ...s, ...JSON.parse(readFileSync(p, "utf8")) };
73
+ } catch {
74
+ /* fresh */
75
+ }
76
+ s.session_hours = 0;
77
+ s.session_cost = 0;
78
+ s.session_events = 0;
79
+ const tmp = `${p}.tmp`;
80
+ writeFileSync(tmp, JSON.stringify(s));
81
+ renameSync(tmp, p);
82
+ // also clear any pending n+1 prediction from the prior session — a stale prediction must never
83
+ // false-credit an avoided detour across a session boundary.
84
+ writeFileSync(join(dir, "pending.json"), "{}");
85
+ writeFileSync(join(dir, "steer.json"), "{}"); // no live steer until the first prompt this session
86
+ writeFileSync(join(dir, "traj.json"), "[]"); // fresh work-type trajectory each session
87
+ writeFileSync(join(dir, "session.json"), JSON.stringify({ steers: [], caught: 0, mins: 0 })); // recap tally
88
+ // clear the SESSION-scoped mute (a shush lasts one session), but PRESERVE persistent prefs
89
+ // (per-trap "fewer"/"more", usefulness scores) — those are the learned personalization.
90
+ try {
91
+ const pp = join(dir, "prefs.json");
92
+ const pr = JSON.parse(readFileSync(pp, "utf8"));
93
+ if (pr.muted_session) {
94
+ pr.muted_session = false;
95
+ writeFileSync(pp, JSON.stringify(pr));
96
+ }
97
+ } catch {
98
+ /* no prefs yet */
99
+ }
100
+ } catch {
101
+ /* never block on stats */
102
+ }
103
+ }
104
+
105
+ // Config precedence: env > ~/.skalpel/client.json (written by `skalpel-prosumer setup`) > default.
106
+ // Identity: the uid from the Google sign-in (~/.skalpel/auth.json); SKALPEL_USER overrides for dev.
107
+ function fileCfg() {
108
+ try {
109
+ return JSON.parse(readFileSync(join(homedir(), ".skalpel", "client.json"), "utf8"));
110
+ } catch {
111
+ return {};
112
+ }
113
+ }
114
+ const CFG = fileCfg();
115
+ const API = process.env.SKALPEL_API || CFG.api || "https://graph.skalpel.ai";
116
+ // Shared wall-clock budget for the whole hook (auth-refresh + insights fetch): cap total time so it
117
+ // self-aborts before the harness kill (settings timeout is 8s), and record every outcome. This is
118
+ // MORE generous than the per-turn hook's 4.5s on purpose: /retrieve is kept warm (~1.8s) but /insights
119
+ // is NOT, and its cold-start loads the full graph (corpus + index + agg_dag). At 4.5s a cold /insights
120
+ // aborted -> the whole SessionStart profile silently vanished (no error, no injection). 7s fits a cold
121
+ // load with ~1s margin under the 8s backstop; warm it still returns in ~300ms, so no cost when hot.
122
+ const DEADLINE_MS = 7000;
123
+
124
+ async function main() {
125
+ let payload = {};
126
+ try {
127
+ payload = JSON.parse(readFileSync(0, "utf8") || "{}");
128
+ } catch {
129
+ /* drain stdin, ignore */
130
+ }
131
+ // source distinguishes a real new session (startup/resume/clear) from a mid-session compaction.
132
+ const source = payload.source || payload.hookSpecificOutput?.source || "startup";
133
+ const { note: recapNote, mins: recapMins } = buildRecap(source); // read the tally BEFORE resetStats wipes it
134
+ // Compaction CONTINUES the session — keep the counter + tally running; only a real start zeroes them.
135
+ if (source !== "compact") resetStats();
136
+ // identity from the Google sign-in (refreshed if near expiry); the token authenticates the read so
137
+ // the server keys the profile by the VERIFIED uid. No token (dev/not signed in) -> server fallback.
138
+ const deadline = Date.now() + DEADLINE_MS;
139
+ const { uid, token } = await identity();
140
+ const user = uid || CFG.user || "anon";
141
+ const remaining = deadline - Date.now();
142
+ if (remaining <= 100) {
143
+ record("session", "budget_spent", DEADLINE_MS);
144
+ return;
145
+ }
146
+ const ctrl = new AbortController();
147
+ const timer = setTimeout(() => ctrl.abort(), remaining);
148
+ const t0 = Date.now();
149
+ let context = null;
150
+ let nSessions = null; // sessions of history behind the standing profile — for the insight record
151
+ let outcome = "fetch_error";
152
+ try {
153
+ const r = await fetch(`${API}/insights?user_id=${encodeURIComponent(user)}`, {
154
+ headers: token ? { authorization: `Bearer ${token}` } : {},
155
+ signal: ctrl.signal,
156
+ });
157
+ if (r.ok) {
158
+ const j = await r.json();
159
+ context = j?.context;
160
+ nSessions = typeof j?.n_sessions === "number" ? j.n_sessions : null;
161
+ outcome = context ? "inject" : "silent";
162
+ } else {
163
+ outcome = "server_error";
164
+ }
165
+ } catch (e) {
166
+ outcome = e?.name === "AbortError" ? "abort" : "fetch_error";
167
+ } finally {
168
+ clearTimeout(timer);
169
+ record("session", outcome, Date.now() - t0);
170
+ }
171
+
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");
175
+
176
+ // LOCAL INSIGHT RECORDS (~/.skalpel/insights.ndjson) — plain-English rows for the TUI, one per
177
+ // note that fired. recordInsight swallows everything, so these never affect the hook output.
178
+ if (recapNote)
179
+ recordInsight({
180
+ kind: "recap",
181
+ display: `last session skalpel saved you ~${recapMins}min`,
182
+ mins: recapMins,
183
+ });
184
+ if (context)
185
+ recordInsight({
186
+ kind: "profile",
187
+ display:
188
+ nSessions != null
189
+ ? `standing profile loaded — ${nSessions} sessions of history`
190
+ : "standing profile loaded",
191
+ });
192
+
193
+ if (additionalContext) {
194
+ process.stdout.write(
195
+ JSON.stringify({
196
+ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext },
197
+ }),
198
+ );
199
+ }
200
+ }
201
+
202
+ main()
203
+ .then(() => process.exit(0))
204
+ .catch(() => process.exit(0));