skalpel 3.4.17 → 3.4.19

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,530 @@
1
+ // Durable hosted context-graph updates for Claude Code SessionEnd.
2
+ //
3
+ // The transcript snapshot is written locally before any network call. A stable idempotency key
4
+ // follows that snapshot through POST /ingest and every retry, while the returned job_id is polled
5
+ // directly instead of blindly reposting. This is deliberately a local outbox: a network outage,
6
+ // process kill, or accepted-response loss cannot lose the completed session or duplicate paid work.
7
+ import { createHash } from "node:crypto";
8
+ import {
9
+ closeSync,
10
+ existsSync,
11
+ mkdirSync,
12
+ openSync,
13
+ readFileSync,
14
+ readdirSync,
15
+ realpathSync,
16
+ renameSync,
17
+ statSync,
18
+ unlinkSync,
19
+ writeFileSync,
20
+ } from "node:fs";
21
+ import { homedir } from "node:os";
22
+ import { basename, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
23
+ import { gzipSync } from "node:zlib";
24
+
25
+ import { identity } from "./auth.mjs";
26
+ import { recordInsight } from "./insights.mjs";
27
+
28
+ const OUTBOX_DIR = "ingest-outbox";
29
+ const LEDGER_FILE = "delivered.json";
30
+ const DRAIN_LOCK = ".drain.lock";
31
+ const ITEM_VERSION = 1;
32
+ const MAX_TRANSCRIPT_BYTES = 90 * 1024 * 1024;
33
+ const MAX_DELIVERED_KEYS = 2048;
34
+ const MAX_TERMINAL_RETRIES = 3;
35
+ const LOCK_STALE_MS = 2 * 60 * 1000;
36
+ const DEFAULT_REQUEST_TIMEOUT_MS = 8_000;
37
+ const DEFAULT_WORK_BUDGET_MS = 45_000;
38
+ const DEFAULT_POLL_INTERVAL_MS = 1_500;
39
+
40
+ function skalpelDir(home) {
41
+ return join(home, ".skalpel");
42
+ }
43
+
44
+ function outboxDir(home) {
45
+ return join(skalpelDir(home), OUTBOX_DIR);
46
+ }
47
+
48
+ function itemPath(home, key) {
49
+ return join(outboxDir(home), `${key}.json`);
50
+ }
51
+
52
+ function snapshotPath(home, key) {
53
+ return join(outboxDir(home), `${key}.jsonl`);
54
+ }
55
+
56
+ function ensureOutbox(home) {
57
+ mkdirSync(outboxDir(home), { recursive: true, mode: 0o700 });
58
+ }
59
+
60
+ function readJson(path, fallback = null) {
61
+ try {
62
+ return JSON.parse(readFileSync(path, "utf8"));
63
+ } catch {
64
+ return fallback;
65
+ }
66
+ }
67
+
68
+ function writeAtomic(path, value) {
69
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
70
+ writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 });
71
+ renameSync(tmp, path);
72
+ }
73
+
74
+ function writeSnapshotAtomic(path, transcript) {
75
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
76
+ writeFileSync(tmp, transcript, { mode: 0o600 });
77
+ renameSync(tmp, path);
78
+ }
79
+
80
+ function remove(path) {
81
+ try {
82
+ unlinkSync(path);
83
+ } catch {
84
+ // Already absent.
85
+ }
86
+ }
87
+
88
+ function clearStaleLock(path, now) {
89
+ try {
90
+ if (now() - statSync(path).mtimeMs > LOCK_STALE_MS) unlinkSync(path);
91
+ } catch {
92
+ // Missing or concurrently released.
93
+ }
94
+ }
95
+
96
+ function acquireLock(path, now) {
97
+ clearStaleLock(path, now);
98
+ try {
99
+ const fd = openSync(path, "wx", 0o600);
100
+ writeFileSync(fd, String(process.pid));
101
+ closeSync(fd);
102
+ return true;
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ function isWithin(root, candidate) {
109
+ const rel = relative(resolve(root), resolve(candidate));
110
+ return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
111
+ }
112
+
113
+ function cleanSessionID(value, transcriptPath) {
114
+ const fallback = basename(transcriptPath, extname(transcriptPath));
115
+ const raw = typeof value === "string" && value.trim() ? value.trim() : fallback;
116
+ return raw.replace(/[\u0000-\u001f/\\]/g, "-").slice(0, 200) || fallback;
117
+ }
118
+
119
+ function baseKey(sessionID, transcript) {
120
+ return createHash("sha256")
121
+ .update("skalpel-session-end-v1\0")
122
+ .update(sessionID)
123
+ .update("\0")
124
+ .update(transcript)
125
+ .digest("hex");
126
+ }
127
+
128
+ function idempotencyKey(item) {
129
+ return `session-end:${item.key}:${item.generation || 0}`;
130
+ }
131
+
132
+ function readLedger(home) {
133
+ return readJson(join(outboxDir(home), LEDGER_FILE), { version: 1, keys: {} });
134
+ }
135
+
136
+ function writeLedger(home, ledger) {
137
+ const entries = Object.entries(ledger.keys || {})
138
+ .sort((a, b) => String(b[1]).localeCompare(String(a[1])))
139
+ .slice(0, MAX_DELIVERED_KEYS);
140
+ writeAtomic(join(outboxDir(home), LEDGER_FILE), { version: 1, keys: Object.fromEntries(entries) });
141
+ }
142
+
143
+ function markDelivered(home, item, now) {
144
+ const ledger = readLedger(home);
145
+ ledger.keys[`${item.user_id}:${item.key}`] = new Date(now()).toISOString();
146
+ writeLedger(home, ledger);
147
+ removeItem(home, item);
148
+ }
149
+
150
+ function removeItem(home, item) {
151
+ remove(itemPath(home, item.key));
152
+ remove(snapshotPath(home, item.key));
153
+ }
154
+
155
+ function saveItem(home, item) {
156
+ writeAtomic(itemPath(home, item.key), item);
157
+ }
158
+
159
+ function notifyOnce(home, item, state, display, insightFn) {
160
+ if (item.notified_state === state) return;
161
+ item.notified_state = state;
162
+ saveItem(home, item);
163
+ insightFn({ kind: "profile", display });
164
+ }
165
+
166
+ function listItems(home, preferredKey) {
167
+ ensureOutbox(home);
168
+ const items = readdirSync(outboxDir(home))
169
+ .filter((name) => /^[a-f0-9]{64}\.json$/.test(name))
170
+ .map((name) => readJson(join(outboxDir(home), name)))
171
+ .filter((item) => item?.version === ITEM_VERSION && typeof item.key === "string")
172
+ .sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at));
173
+ if (!preferredKey) return items;
174
+ return items.sort((a, b) => Number(b.key === preferredKey) - Number(a.key === preferredKey));
175
+ }
176
+
177
+ function clientAPI(home) {
178
+ const cfg = readJson(join(skalpelDir(home), "client.json"), {});
179
+ return process.env.SKALPEL_API || cfg.api || "https://graph.skalpel.ai";
180
+ }
181
+
182
+ async function sleep(ms) {
183
+ await new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
184
+ }
185
+
186
+ async function request(fetchFn, url, init, timeoutMs) {
187
+ const ctrl = new AbortController();
188
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
189
+ try {
190
+ return await fetchFn(url, { ...init, signal: ctrl.signal });
191
+ } finally {
192
+ clearTimeout(timer);
193
+ }
194
+ }
195
+
196
+ function retryDelayMs(generation) {
197
+ return Math.min(60 * 60 * 1000, 5 * 60 * 1000 * 2 ** Math.max(0, generation - 1));
198
+ }
199
+
200
+ function scheduleTerminalRetry(home, item, status, now, insightFn) {
201
+ const nextGeneration = (item.generation || 0) + 1;
202
+ item.last_job_id = item.job_id || null;
203
+ item.job_id = null;
204
+ item.server_state = status.state || "error";
205
+ item.last_error = String(status.error || "graph job failed").slice(0, 240);
206
+ item.generation = nextGeneration;
207
+ item.next_poll_at = null;
208
+ item.updated_at = new Date(now()).toISOString();
209
+ if (nextGeneration <= MAX_TERMINAL_RETRIES) {
210
+ item.state = "retry_queued";
211
+ item.next_attempt_at = now() + retryDelayMs(nextGeneration);
212
+ saveItem(home, item);
213
+ notifyOnce(
214
+ home,
215
+ item,
216
+ `retry-${nextGeneration}`,
217
+ "context graph update hit an error; a safe retry is queued",
218
+ insightFn,
219
+ );
220
+ return;
221
+ }
222
+ item.state = "fallback_required";
223
+ item.next_attempt_at = null;
224
+ saveItem(home, item);
225
+ notifyOnce(
226
+ home,
227
+ item,
228
+ "fallback-required",
229
+ "context graph update needs a retry; reopen Skalpel to run the history catch-up",
230
+ insightFn,
231
+ );
232
+ }
233
+
234
+ async function pollAcceptedJob(home, item, auth, options, deadline) {
235
+ const {
236
+ api,
237
+ fetchFn,
238
+ insightFn,
239
+ now,
240
+ pollIntervalMs,
241
+ requestTimeoutMs,
242
+ sleepFn,
243
+ } = options;
244
+ const statusURL = `${api}/ingest/status?job_id=${encodeURIComponent(item.job_id)}`;
245
+ for (;;) {
246
+ let response;
247
+ try {
248
+ response = await request(
249
+ fetchFn,
250
+ statusURL,
251
+ { headers: { authorization: `Bearer ${auth.token}` } },
252
+ requestTimeoutMs,
253
+ );
254
+ } catch {
255
+ item.state = "status_unreachable";
256
+ item.updated_at = new Date(now()).toISOString();
257
+ saveItem(home, item);
258
+ return "pending";
259
+ }
260
+ if (response.status === 401) {
261
+ item.state = "auth_required";
262
+ saveItem(home, item);
263
+ return "pending";
264
+ }
265
+ if (response.status === 404) {
266
+ scheduleTerminalRetry(home, item, { state: "error", error: "job not found" }, now, insightFn);
267
+ return "retry";
268
+ }
269
+ if (!response.ok) {
270
+ item.state = `status_http_${response.status}`;
271
+ saveItem(home, item);
272
+ return "pending";
273
+ }
274
+ const status = await response.json().catch(() => ({}));
275
+ item.state = typeof status.state === "string" ? status.state : "running";
276
+ item.phase = typeof status.phase === "string" ? status.phase : item.phase || null;
277
+ item.done = Number.isFinite(status.done) ? status.done : item.done || 0;
278
+ item.total = Number.isFinite(status.total) ? status.total : item.total || 1;
279
+ item.errors = Number.isFinite(status.errors) ? status.errors : item.errors || 0;
280
+ item.heartbeat = status.heartbeat_at || status.updated_at || item.heartbeat || null;
281
+ item.terminal = status.terminal === true;
282
+ item.requires_replay = status.requires_replay === true;
283
+ item.retry_at = typeof status.retry_at === "string" ? status.retry_at : null;
284
+ if (item.state === "waiting_quota" && !item.terminal && !item.requires_replay) {
285
+ const retryAt = Date.parse(item.retry_at || "");
286
+ item.next_poll_at = Number.isFinite(retryAt) ? retryAt : now() + 60_000;
287
+ } else {
288
+ item.next_poll_at = null;
289
+ }
290
+ item.updated_at = new Date(now()).toISOString();
291
+ saveItem(home, item);
292
+
293
+ if (item.state === "done") {
294
+ markDelivered(home, item, now);
295
+ return "done";
296
+ }
297
+ if (item.state === "waiting_quota") {
298
+ // Transitional server: payload is not durable, so this exact job is terminal and the server
299
+ // explicitly requests a new operation key. Final durable server: same state is nonterminal,
300
+ // carries retry_at, and resumes the SAME job. Pin both contracts; never infer one from the
301
+ // state string alone.
302
+ if (item.terminal && item.requires_replay) {
303
+ scheduleTerminalRetry(home, item, status, now, insightFn);
304
+ return "retry";
305
+ }
306
+ notifyOnce(
307
+ home,
308
+ item,
309
+ "waiting-quota",
310
+ "context graph update is safely queued while provider capacity recovers",
311
+ insightFn,
312
+ );
313
+ return "pending";
314
+ }
315
+ if (item.terminal && item.requires_replay) {
316
+ scheduleTerminalRetry(home, item, status, now, insightFn);
317
+ return "retry";
318
+ }
319
+ if (item.state === "error" || item.state === "failed") {
320
+ scheduleTerminalRetry(home, item, status, now, insightFn);
321
+ return "retry";
322
+ }
323
+ if (now() + pollIntervalMs >= deadline) return "pending";
324
+ await sleepFn(pollIntervalMs);
325
+ }
326
+ }
327
+
328
+ async function submitItem(home, item, auth, options, deadline) {
329
+ const { api, fetchFn, insightFn, now, requestTimeoutMs } = options;
330
+ if (!item.job_id) {
331
+ let transcript;
332
+ try {
333
+ transcript = readFileSync(snapshotPath(home, item.key), "utf8");
334
+ } catch {
335
+ item.state = "snapshot_missing";
336
+ saveItem(home, item);
337
+ notifyOnce(
338
+ home,
339
+ item,
340
+ "snapshot-missing",
341
+ "context graph update needs a retry; reopen Skalpel to run the history catch-up",
342
+ insightFn,
343
+ );
344
+ return "pending";
345
+ }
346
+ const body = JSON.stringify({
347
+ user_id: auth.uid,
348
+ raw_transcripts: { [`${item.session_id}.jsonl`]: transcript },
349
+ });
350
+ let response;
351
+ try {
352
+ response = await request(
353
+ fetchFn,
354
+ `${api}/ingest`,
355
+ {
356
+ method: "POST",
357
+ headers: {
358
+ authorization: `Bearer ${auth.token}`,
359
+ "content-type": "application/json",
360
+ "content-encoding": "gzip",
361
+ "idempotency-key": idempotencyKey(item),
362
+ },
363
+ body: gzipSync(body),
364
+ },
365
+ requestTimeoutMs,
366
+ );
367
+ } catch {
368
+ item.state = "submit_unreachable";
369
+ item.updated_at = new Date(now()).toISOString();
370
+ saveItem(home, item);
371
+ return "pending";
372
+ }
373
+ if (response.status === 401) {
374
+ item.state = "auth_required";
375
+ saveItem(home, item);
376
+ return "pending";
377
+ }
378
+ if (response.status === 402 || response.status === 403) {
379
+ item.state = "membership_required";
380
+ saveItem(home, item);
381
+ notifyOnce(
382
+ home,
383
+ item,
384
+ "membership-required",
385
+ "context graph update is queued until membership access is available",
386
+ insightFn,
387
+ );
388
+ return "pending";
389
+ }
390
+ if (!response.ok) {
391
+ item.state = `submit_http_${response.status}`;
392
+ saveItem(home, item);
393
+ return "pending";
394
+ }
395
+ const accepted = await response.json().catch(() => ({}));
396
+ item.user_id = auth.uid;
397
+ item.replayed = accepted.replayed === true;
398
+ item.updated_at = new Date(now()).toISOString();
399
+ if (typeof accepted.n_sessions === "number") {
400
+ markDelivered(home, item, now);
401
+ return "done";
402
+ }
403
+ if (typeof accepted.job_id !== "string" || !accepted.job_id) {
404
+ item.state = "invalid_response";
405
+ saveItem(home, item);
406
+ return "pending";
407
+ }
408
+ item.job_id = accepted.job_id;
409
+ item.state = "queued";
410
+ saveItem(home, item);
411
+ }
412
+ return pollAcceptedJob(home, item, auth, options, deadline);
413
+ }
414
+
415
+ // Snapshot exactly the completed Claude transcript. The only accepted source path is Claude's
416
+ // transcript directory; a forged hook payload cannot turn this into an arbitrary local-file upload.
417
+ export function enqueueCompletedSession(payload, options = {}) {
418
+ const home = options.home || homedir();
419
+ const now = options.now || Date.now;
420
+ const transcriptPath = payload?.transcript_path;
421
+ if (payload?.hook_event_name !== "SessionEnd" || typeof transcriptPath !== "string") return null;
422
+ if (extname(transcriptPath).toLowerCase() !== ".jsonl") return null;
423
+ let canonicalPath;
424
+ try {
425
+ canonicalPath = realpathSync(transcriptPath);
426
+ if (!isWithin(realpathSync(join(home, ".claude", "projects")), canonicalPath)) return null;
427
+ } catch {
428
+ return null;
429
+ }
430
+ let stat;
431
+ let transcript;
432
+ try {
433
+ stat = statSync(canonicalPath);
434
+ if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_TRANSCRIPT_BYTES) return null;
435
+ transcript = readFileSync(canonicalPath, "utf8");
436
+ } catch {
437
+ return null;
438
+ }
439
+ if (!transcript.trim()) return null;
440
+ const sessionID = cleanSessionID(payload.session_id, canonicalPath);
441
+ const key = baseKey(sessionID, transcript);
442
+ ensureOutbox(home);
443
+ const path = itemPath(home, key);
444
+ const existing = readJson(path);
445
+ if (existing?.version === ITEM_VERSION && existsSync(snapshotPath(home, key))) return existing;
446
+
447
+ const lockPath = `${path}.lock`;
448
+ if (!acquireLock(lockPath, now)) return readJson(path);
449
+ try {
450
+ const item = {
451
+ version: ITEM_VERSION,
452
+ key,
453
+ generation: 0,
454
+ session_id: sessionID,
455
+ source: "claude_code",
456
+ transcript_path: canonicalPath,
457
+ transcript_bytes: stat.size,
458
+ transcript_mtime_ms: stat.mtimeMs,
459
+ reason: typeof payload.reason === "string" ? payload.reason : "other",
460
+ state: "queued",
461
+ job_id: null,
462
+ user_id: null,
463
+ created_at: new Date(now()).toISOString(),
464
+ updated_at: new Date(now()).toISOString(),
465
+ };
466
+ writeSnapshotAtomic(snapshotPath(home, key), transcript);
467
+ writeAtomic(path, item);
468
+ return item;
469
+ } finally {
470
+ remove(lockPath);
471
+ }
472
+ }
473
+
474
+ export async function drainIncrementalOutbox(options = {}) {
475
+ const home = options.home || homedir();
476
+ const now = options.now || Date.now;
477
+ const insightFn = options.insightFn || recordInsight;
478
+ ensureOutbox(home);
479
+ const lockPath = join(outboxDir(home), DRAIN_LOCK);
480
+ if (!acquireLock(lockPath, now)) return { locked: true, pending: listItems(home).length };
481
+ try {
482
+ const items = listItems(home, options.preferredKey);
483
+ if (!items.length) return { pending: 0 };
484
+
485
+ const identityFn = options.identityFn || identity;
486
+ const auth = await identityFn();
487
+ if (!auth?.uid || !auth?.token) return { pending: items.length, authRequired: true };
488
+ const delivered = readLedger(home);
489
+ for (const item of items) {
490
+ if (delivered.keys?.[`${auth.uid}:${item.key}`]) removeItem(home, item);
491
+ }
492
+ const pending = listItems(home, options.preferredKey);
493
+ if (!pending.length) return { pending: 0 };
494
+ const deadline = now() + (options.workBudgetMs ?? DEFAULT_WORK_BUDGET_MS);
495
+ const common = {
496
+ api: options.api || clientAPI(home),
497
+ fetchFn: options.fetchFn || fetch,
498
+ insightFn,
499
+ now,
500
+ pollIntervalMs: options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
501
+ requestTimeoutMs: options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
502
+ sleepFn: options.sleepFn || sleep,
503
+ };
504
+ let processed = 0;
505
+ for (const item of pending.slice(0, options.maxItems ?? 3)) {
506
+ if (item.user_id && item.user_id !== auth.uid) continue;
507
+ if (item.state === "fallback_required") continue;
508
+ if (Number.isFinite(item.next_attempt_at) && item.next_attempt_at > now()) continue;
509
+ if (item.job_id && Number.isFinite(item.next_poll_at) && item.next_poll_at > now()) continue;
510
+ await submitItem(home, item, auth, common, deadline);
511
+ processed += 1;
512
+ if (now() >= deadline) break;
513
+ }
514
+ return { processed, pending: listItems(home).length };
515
+ } finally {
516
+ remove(lockPath);
517
+ }
518
+ }
519
+
520
+ export async function captureAndDrainSession(payload, options = {}) {
521
+ const item = enqueueCompletedSession(payload, options);
522
+ if (!item || item.delivered) return { queued: false, delivered: item?.delivered === true };
523
+ const result = await drainIncrementalOutbox({ ...options, preferredKey: item.key });
524
+ return { queued: true, key: item.key, ...result };
525
+ }
526
+
527
+ // Test-only visibility without exporting transcript bodies or mutable paths.
528
+ export function pendingItems(home) {
529
+ return listItems(home);
530
+ }