skalpel 4.0.13 → 4.0.15
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/auth.mjs +5 -1
- package/incremental-ingest.mjs +54 -3
- package/install.mjs +104 -7
- package/package.json +1 -1
- package/skalpel-setup.mjs +32 -4
package/auth.mjs
CHANGED
|
@@ -15,7 +15,11 @@ const COGNITO_TOKEN_URL =
|
|
|
15
15
|
const COGNITO_CLIENT_ID = process.env.SKALPEL_COGNITO_CLIENT_ID || "2gfil041fv9u58jemc7l5u2pht";
|
|
16
16
|
const GRAPH_API = process.env.SKALPEL_API || "https://graph.skalpel.ai";
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
// Exported so `skalpel logout` / `skalpel uninstall --purge` remove the session from the SAME
|
|
19
|
+
// platform path this module reads it from (macOS Application Support, XDG on Linux, APPDATA on
|
|
20
|
+
// Windows) — never a divergent guess. This is only a path resolver: it touches no token-exchange
|
|
21
|
+
// or verification logic.
|
|
22
|
+
export function cliAuthPath() {
|
|
19
23
|
if (process.env.SKALPEL_CONFIG_DIR) return join(process.env.SKALPEL_CONFIG_DIR, "auth.json");
|
|
20
24
|
if (process.platform === "win32") {
|
|
21
25
|
const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
|
package/incremental-ingest.mjs
CHANGED
|
@@ -32,6 +32,7 @@ const ITEM_VERSION = 1;
|
|
|
32
32
|
const MAX_TRANSCRIPT_BYTES = 90 * 1024 * 1024;
|
|
33
33
|
const MAX_DELIVERED_KEYS = 2048;
|
|
34
34
|
const MAX_TERMINAL_RETRIES = 3;
|
|
35
|
+
const MAX_VANISHED_RESUBMITS = 1;
|
|
35
36
|
const LOCK_STALE_MS = 2 * 60 * 1000;
|
|
36
37
|
const DEFAULT_REQUEST_TIMEOUT_MS = 8_000;
|
|
37
38
|
const DEFAULT_WORK_BUDGET_MS = 45_000;
|
|
@@ -125,8 +126,16 @@ function baseKey(sessionID, transcript) {
|
|
|
125
126
|
.digest("hex");
|
|
126
127
|
}
|
|
127
128
|
|
|
129
|
+
// The idempotency key is a STABLE CONTENT ADDRESS: item.key is the sha256 of (session id + full
|
|
130
|
+
// transcript), so the SAME content produces the SAME key across every retry and generation. A retry
|
|
131
|
+
// of an already-accepted transcript therefore dedups server-side and can never mint a duplicate
|
|
132
|
+
// job/session or re-judge at Gemini cost. The key rotates ONLY when the server EXPLICITLY discards
|
|
133
|
+
// the payload and asks for a fresh operation (requires_replay -> replay_epoch bump); ordinary retries
|
|
134
|
+
// (network, error, 404-after-accept) must keep the key fixed. Generation still drives retry backoff,
|
|
135
|
+
// but no longer leaks into the key.
|
|
128
136
|
function idempotencyKey(item) {
|
|
129
|
-
|
|
137
|
+
const epoch = item.replay_epoch || 0;
|
|
138
|
+
return epoch > 0 ? `session-end:${item.key}:replay${epoch}` : `session-end:${item.key}`;
|
|
130
139
|
}
|
|
131
140
|
|
|
132
141
|
function readLedger(home) {
|
|
@@ -227,6 +236,10 @@ function scheduleTerminalRetry(home, item, status, now, doctorFn) {
|
|
|
227
236
|
item.server_state = status.state || "error";
|
|
228
237
|
item.last_error = String(status.error || "graph job failed").slice(0, 240);
|
|
229
238
|
item.generation = nextGeneration;
|
|
239
|
+
// Only a server that EXPLICITLY discarded the payload needs a fresh operation key. Every other
|
|
240
|
+
// failure re-sends the identical content and must reuse the stable content-addressed key so the
|
|
241
|
+
// server can dedup it — otherwise a retry duplicates the session and re-judges at full cost.
|
|
242
|
+
if (status.requires_replay === true) item.replay_epoch = (item.replay_epoch || 0) + 1;
|
|
230
243
|
item.next_poll_at = null;
|
|
231
244
|
item.updated_at = new Date(now()).toISOString();
|
|
232
245
|
if (nextGeneration <= MAX_TERMINAL_RETRIES) {
|
|
@@ -254,6 +267,45 @@ function scheduleTerminalRetry(home, item, status, now, doctorFn) {
|
|
|
254
267
|
);
|
|
255
268
|
}
|
|
256
269
|
|
|
270
|
+
// A job that was ACCEPTED (the server issued a job_id) whose /ingest/status now 404s. Per the server
|
|
271
|
+
// contract a mid-poll 404 means the job most likely COMPLETED and was evicted from the ephemeral job
|
|
272
|
+
// store — the acceptance already gave us at-least-once delivery upstream. Because the idempotency key
|
|
273
|
+
// is now a stable content address, a bounded re-submit is guaranteed non-duplicating (the server
|
|
274
|
+
// dedups it on the same key), so we try ONE safe re-submit and then treat the content as delivered
|
|
275
|
+
// rather than re-judging forever at full Gemini cost or risking a duplicate. This path is reached
|
|
276
|
+
// ONLY for content that already reached the server; genuinely-never-accepted content has no job_id
|
|
277
|
+
// and never lands here, so at-least-once is preserved for it separately.
|
|
278
|
+
function handleVanishedJob(home, item, auth, now, doctorFn) {
|
|
279
|
+
const attempt = (item.not_found_count || 0) + 1;
|
|
280
|
+
item.not_found_count = attempt;
|
|
281
|
+
item.last_job_id = item.job_id || item.last_job_id || null;
|
|
282
|
+
item.user_id = item.user_id || auth.uid;
|
|
283
|
+
item.updated_at = new Date(now()).toISOString();
|
|
284
|
+
if (attempt > MAX_VANISHED_RESUBMITS) {
|
|
285
|
+
item.state = "delivered_assumed";
|
|
286
|
+
saveItem(home, item);
|
|
287
|
+
doctorFn(
|
|
288
|
+
home,
|
|
289
|
+
`job ${item.last_job_id} for key ${item.key} 404'd ${attempt}x after accept; ` +
|
|
290
|
+
"acceptance = at-least-once delivered, so recording as delivered and NOT re-sending",
|
|
291
|
+
);
|
|
292
|
+
markDelivered(home, item, now);
|
|
293
|
+
return "done";
|
|
294
|
+
}
|
|
295
|
+
item.job_id = null;
|
|
296
|
+
item.state = "resubmit_queued";
|
|
297
|
+
item.next_poll_at = null;
|
|
298
|
+
item.next_attempt_at = now() + retryDelayMs(attempt);
|
|
299
|
+
saveItem(home, item);
|
|
300
|
+
doctorFn(
|
|
301
|
+
home,
|
|
302
|
+
`job ${item.last_job_id} for key ${item.key} 404'd after accept ` +
|
|
303
|
+
`(attempt ${attempt}/${MAX_VANISHED_RESUBMITS}); one safe re-submit queued with ` +
|
|
304
|
+
`stable idempotency-key ${idempotencyKey(item)}`,
|
|
305
|
+
);
|
|
306
|
+
return "retry";
|
|
307
|
+
}
|
|
308
|
+
|
|
257
309
|
async function pollAcceptedJob(home, item, auth, options, deadline) {
|
|
258
310
|
const { api, fetchFn, doctorFn, now, pollIntervalMs, requestTimeoutMs, sleepFn } = options;
|
|
259
311
|
const statusURL = `${api}/ingest/status?job_id=${encodeURIComponent(item.job_id)}`;
|
|
@@ -278,8 +330,7 @@ async function pollAcceptedJob(home, item, auth, options, deadline) {
|
|
|
278
330
|
return "pending";
|
|
279
331
|
}
|
|
280
332
|
if (response.status === 404) {
|
|
281
|
-
|
|
282
|
-
return "retry";
|
|
333
|
+
return handleVanishedJob(home, item, auth, now, doctorFn);
|
|
283
334
|
}
|
|
284
335
|
if (!response.ok) {
|
|
285
336
|
item.state = `status_http_${response.status}`;
|
package/install.mjs
CHANGED
|
@@ -2,12 +2,25 @@
|
|
|
2
2
|
// install.mjs — wire the hosted skalpel behavior hook into Claude Code AND Codex. Pure Node,
|
|
3
3
|
// idempotent, and non-clobbering:
|
|
4
4
|
// preserves any existing hooks/settings, refreshes ours in place. Run: `node install.mjs` (or --uninstall).
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
readFileSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
existsSync,
|
|
10
|
+
copyFileSync,
|
|
11
|
+
rmSync,
|
|
12
|
+
readdirSync,
|
|
13
|
+
} from "node:fs";
|
|
6
14
|
import { homedir } from "node:os";
|
|
7
15
|
import { join, dirname } from "node:path";
|
|
8
16
|
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { cliAuthPath } from "./auth.mjs";
|
|
9
18
|
|
|
10
19
|
const uninstall = process.argv.includes("--uninstall");
|
|
20
|
+
// `--purge` (only meaningful with --uninstall): additionally delete the saved session + ALL local
|
|
21
|
+
// skalpel data so an uninstalled machine has nothing left — important because skalpel uploads
|
|
22
|
+
// transcripts and a user must be able to verify no residue remains.
|
|
23
|
+
const purge = process.argv.includes("--purge");
|
|
11
24
|
// npm postinstall uses this mode to make the local, read-only status indicator visible without
|
|
12
25
|
// opting the user into prompt/session hooks. A successful `skalpel login` runs the full installer;
|
|
13
26
|
// `skalpel setup` remains an explicit repair path.
|
|
@@ -141,7 +154,11 @@ function isOurs(c, marker) {
|
|
|
141
154
|
];
|
|
142
155
|
return markers.some((candidate) => {
|
|
143
156
|
const escaped = candidate.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
144
|
-
|
|
157
|
+
// Boundaries must accept a QUOTE too: the installer writes the Windows-safe quoted form
|
|
158
|
+
// `node "<path>/skalpel-hook.mjs"`, so the marker is bounded by `/` before and `"` after. The
|
|
159
|
+
// old class only allowed path-sep/whitespace/end, so it NEVER matched a quoted command — uninstall
|
|
160
|
+
// left the hooks wired and re-install appended duplicate groups. Allow `"` and `'` on both sides.
|
|
161
|
+
return new RegExp(`(^|[/\\\\"'\\s])${escaped}(\\.mjs|\\.js|\\.exe)?($|["'\\s])`).test(c);
|
|
145
162
|
});
|
|
146
163
|
}
|
|
147
164
|
|
|
@@ -380,6 +397,75 @@ function claudeMd() {
|
|
|
380
397
|
return had ? "refreshed" : "installed";
|
|
381
398
|
}
|
|
382
399
|
|
|
400
|
+
// After the hooks are UNWIRED (settings.json / CLAUDE.md / Codex — handled above), remove the local
|
|
401
|
+
// data skalpel staged and accumulated so an uninstall leaves the machine clean. Plain uninstall keeps
|
|
402
|
+
// the saved auth (so `--purge` is the deliberate "forget me entirely" step); --purge removes auth +
|
|
403
|
+
// the whole ~/.skalpel tree. Returns a short human summary of what was cleaned.
|
|
404
|
+
function cleanupLocalData() {
|
|
405
|
+
const SKALPEL_DIR = join(homedir(), ".skalpel");
|
|
406
|
+
const cleaned = [];
|
|
407
|
+
const rm = (target, label) => {
|
|
408
|
+
// Never delete a directory that contains the currently-running installer (defensive — install.mjs
|
|
409
|
+
// is never staged into ~/.skalpel, but guard anyway so a self-hosted layout can't self-destruct).
|
|
410
|
+
if (
|
|
411
|
+
PKG_DIR === target ||
|
|
412
|
+
PKG_DIR.startsWith(target + "/") ||
|
|
413
|
+
PKG_DIR.startsWith(target + "\\")
|
|
414
|
+
) {
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (!existsSync(target)) return;
|
|
418
|
+
try {
|
|
419
|
+
rmSync(target, { recursive: true, force: true });
|
|
420
|
+
cleaned.push(label);
|
|
421
|
+
} catch {
|
|
422
|
+
/* best-effort — a locked/again-absent path never fails the uninstall */
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
if (purge) {
|
|
427
|
+
// Forget-me-entirely: the saved session at the PLATFORM path (macOS Application Support / XDG /
|
|
428
|
+
// APPDATA — resolved by auth.mjs so it matches exactly where login wrote it) + the whole tree.
|
|
429
|
+
rm(cliAuthPath(), "auth (session token)");
|
|
430
|
+
rm(SKALPEL_DIR, "~/.skalpel (all local data)");
|
|
431
|
+
return cleaned;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Plain uninstall: remove staged hooks + the raw transcript snapshots + all local state/logs, but
|
|
435
|
+
// KEEP the saved auth (that's what `--purge` is for). List every file skalpel writes under ~/.skalpel
|
|
436
|
+
// so nothing is silently left behind; auth.json (legacy local location) is deliberately preserved.
|
|
437
|
+
rm(join(SKALPEL_DIR, "hooks"), "staged hooks");
|
|
438
|
+
rm(join(SKALPEL_DIR, "ingest-outbox"), "raw transcript snapshots (ingest-outbox)");
|
|
439
|
+
for (const f of [
|
|
440
|
+
"session.json",
|
|
441
|
+
"steer.json",
|
|
442
|
+
"traj.json",
|
|
443
|
+
"pending.json",
|
|
444
|
+
"prefs.json",
|
|
445
|
+
"insights.ndjson",
|
|
446
|
+
"insights-ready.json",
|
|
447
|
+
"metrics.ndjson",
|
|
448
|
+
"bootstrap.json",
|
|
449
|
+
"bootstrap.lock",
|
|
450
|
+
"client.json",
|
|
451
|
+
"doctor.log",
|
|
452
|
+
"hook.log",
|
|
453
|
+
]) {
|
|
454
|
+
rm(join(SKALPEL_DIR, f), f);
|
|
455
|
+
}
|
|
456
|
+
if (cleaned.length)
|
|
457
|
+
cleaned.push("(auth kept — run `skalpel uninstall --purge` to remove it too)");
|
|
458
|
+
// Tidy: if nothing but an empty shell remains, drop the directory. Preserve it if auth.json is still
|
|
459
|
+
// there (legacy local session) so the kept-auth promise holds.
|
|
460
|
+
try {
|
|
461
|
+
if (existsSync(SKALPEL_DIR) && readdirSync(SKALPEL_DIR).length === 0)
|
|
462
|
+
rmSync(SKALPEL_DIR, { force: true, recursive: true });
|
|
463
|
+
} catch {
|
|
464
|
+
/* leave the dir if we can't inspect it */
|
|
465
|
+
}
|
|
466
|
+
return cleaned;
|
|
467
|
+
}
|
|
468
|
+
|
|
383
469
|
const claudeResult = claude();
|
|
384
470
|
if (statuslineOnly) {
|
|
385
471
|
console.log(`skalpel statusline (${STATUSLINE_CMD}) → claude: ${claudeResult}`);
|
|
@@ -392,9 +478,20 @@ if (statuslineOnly) {
|
|
|
392
478
|
console.log(
|
|
393
479
|
`skalpel hook (${uninstall ? "uninstall" : CMD}) → claude: ${claudeResult} · claude.md: ${claudeMd()} · codex(config.toml): ${codexToml()} · codex(hooks.json): ${codexJson()}`,
|
|
394
480
|
);
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
481
|
+
if (uninstall) {
|
|
482
|
+
const cleaned = cleanupLocalData();
|
|
483
|
+
if (cleaned.length) console.log(`removed: ${cleaned.join(", ")}.`);
|
|
484
|
+
if (purge) {
|
|
485
|
+
// Honest note: there is no server-side data-deletion endpoint yet. Don't imply we called one.
|
|
486
|
+
console.log(
|
|
487
|
+
"note: local data removed. skalpel has no self-serve server-side deletion endpoint yet — " +
|
|
488
|
+
"to delete the transcripts already uploaded to the graph, email privacy@skalpel.ai.",
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
console.log("uninstalled.");
|
|
492
|
+
} else {
|
|
493
|
+
console.log(
|
|
494
|
+
`installed. Hooks run from ${HOOKS_DIR} and use the canonical skalpel Cognito login.`,
|
|
495
|
+
);
|
|
496
|
+
}
|
|
400
497
|
}
|
package/package.json
CHANGED
package/skalpel-setup.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import { join, dirname, delimiter, sep as pathSep } from "node:path";
|
|
|
20
20
|
import { createInterface, emitKeypressEvents } from "node:readline";
|
|
21
21
|
import { fileURLToPath } from "node:url";
|
|
22
22
|
import { spawn, spawnSync } from "node:child_process";
|
|
23
|
-
import { identity as authIdentity } from "./auth.mjs";
|
|
23
|
+
import { identity as authIdentity, cliAuthPath } from "./auth.mjs";
|
|
24
24
|
|
|
25
25
|
// CLI (the `skalpel-prosumer` bin): `skalpel-prosumer setup [--api https://graph.skalpel.ai]
|
|
26
26
|
// [--user <id>]`, plus `skalpel-prosumer uninstall` and `skalpel-prosumer login`. A bare invocation
|
|
@@ -53,6 +53,8 @@ const isMain =
|
|
|
53
53
|
// pulls in userTurnCount) can never exit the test runner.
|
|
54
54
|
const KNOWN_SUBS = new Set(["setup", "login", "logout", "uninstall", "autopsy", "__build"]);
|
|
55
55
|
const VALUE_FLAGS = new Set(["--api", "--user"]);
|
|
56
|
+
// Boolean flags that take no value. `--purge` on `uninstall` also removes auth + all local data.
|
|
57
|
+
const BOOL_FLAGS = new Set(["--purge"]);
|
|
56
58
|
const USAGE = `skalpel-prosumer — connect your coding history to your behavioral graph
|
|
57
59
|
|
|
58
60
|
usage:
|
|
@@ -60,11 +62,12 @@ usage:
|
|
|
60
62
|
skalpel-prosumer login (re-)run the Google sign-in
|
|
61
63
|
skalpel-prosumer logout clear the saved session
|
|
62
64
|
skalpel-prosumer autopsy local, read-only receipt of your verified patterns
|
|
63
|
-
skalpel-prosumer uninstall
|
|
65
|
+
skalpel-prosumer uninstall [--purge] remove the hooks + local data from this machine
|
|
64
66
|
|
|
65
67
|
options:
|
|
66
68
|
--api <url> point at a different graph server
|
|
67
69
|
--user <id> dev-only identity override
|
|
70
|
+
--purge on uninstall, also delete the saved session + ALL local skalpel data (~/.skalpel)
|
|
68
71
|
-v, --version print the version and exit
|
|
69
72
|
-h, --help print this help and exit`;
|
|
70
73
|
|
|
@@ -91,6 +94,7 @@ function validateArgv() {
|
|
|
91
94
|
console.log(USAGE);
|
|
92
95
|
process.exit(0);
|
|
93
96
|
}
|
|
97
|
+
if (BOOL_FLAGS.has(a)) continue; // valueless flag (e.g. `uninstall --purge`)
|
|
94
98
|
if (a.startsWith("-")) {
|
|
95
99
|
console.error(`skalpel: unknown option \`${a}\`\n\n${USAGE}`);
|
|
96
100
|
process.exit(2);
|
|
@@ -802,7 +806,8 @@ function preflight() {
|
|
|
802
806
|
|
|
803
807
|
async function main() {
|
|
804
808
|
if (sub === "uninstall") {
|
|
805
|
-
|
|
809
|
+
const passthrough = argv.includes("--purge") ? ["--uninstall", "--purge"] : ["--uninstall"];
|
|
810
|
+
spawnSync("node", [join(__dir, "install.mjs"), ...passthrough], { stdio: "inherit" });
|
|
806
811
|
return;
|
|
807
812
|
}
|
|
808
813
|
// `skalpel-prosumer login` — just run the sign-in flow (also invoked automatically by setup).
|
|
@@ -816,7 +821,30 @@ async function main() {
|
|
|
816
821
|
if (sub === "logout") {
|
|
817
822
|
try {
|
|
818
823
|
const { rmSync } = await import("node:fs");
|
|
819
|
-
|
|
824
|
+
// The real session written by `skalpel login` / read by auth.mjs lives at the PLATFORM path
|
|
825
|
+
// (macOS Application Support, XDG on Linux, APPDATA on Windows) — NOT ~/.skalpel/auth.json.
|
|
826
|
+
// Delete it via auth.mjs's own resolver so we hit exactly where the session lives; the old
|
|
827
|
+
// code only unlinked the legacy ~/.skalpel/auth.json, so the next run silently re-authed.
|
|
828
|
+
const platformAuth = cliAuthPath();
|
|
829
|
+
rmSync(platformAuth, { force: true });
|
|
830
|
+
rmSync(join(homedir(), ".skalpel", "auth.json"), { force: true }); // legacy location, if present
|
|
831
|
+
// Clear the live per-session steering state + first-run bootstrap state so a subsequent login
|
|
832
|
+
// (possibly a different account) starts clean. Historical telemetry (metrics/insights ndjson)
|
|
833
|
+
// and config/prefs are left alone — `skalpel uninstall` owns those.
|
|
834
|
+
const skalpelDir = join(homedir(), ".skalpel");
|
|
835
|
+
for (const f of [
|
|
836
|
+
"session.json",
|
|
837
|
+
"steer.json",
|
|
838
|
+
"traj.json",
|
|
839
|
+
"pending.json",
|
|
840
|
+
"bootstrap.json",
|
|
841
|
+
"bootstrap.lock",
|
|
842
|
+
"insights-ready.json",
|
|
843
|
+
]) {
|
|
844
|
+
rmSync(join(skalpelDir, f), { force: true });
|
|
845
|
+
}
|
|
846
|
+
// No persistent local proxy/daemon ships with this client (the legacy Go `skalpeld` is retired
|
|
847
|
+
// by postinstall.mjs). Clearing bootstrap.lock above releases any stuck first-run build lock.
|
|
820
848
|
console.log(` ${G}✓${X} Signed out. Next run will prompt for Google sign-in.\n`);
|
|
821
849
|
} catch (e) {
|
|
822
850
|
console.log(` Could not sign out: ${String(e.message || e)}\n`);
|