skalpel 4.0.14 → 4.0.16
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/incremental-ingest.mjs +54 -3
- package/package.json +1 -1
- package/skalpel-setup.mjs +14 -14
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/package.json
CHANGED
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) {
|