@theronap/cortex-mcp 0.9.149 โ†’ 0.9.151

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,876 @@
1
+ // The local capture queue โ€” D9 + the client half of D8 (eng review 2026-09-11).
2
+ //
3
+ // ๐Ÿ”ด WHY THIS EXISTS. Two ways a session capture used to vanish:
4
+ //
5
+ // 1. A failed /api/ingest was logged to ~/.cortex/capture.log and dropped. A transient 503, a timeout,
6
+ // a rate-limit 429 โ€” each one lost that turn's capture, and if it was the session's LAST turn, the
7
+ // session. Nothing retried, and nothing anyone reads said so.
8
+ //
9
+ // 2. When the person's own AI could not summarize the session locally (`claude -p` missing, its login
10
+ // expired, a subscription limit), the client shipped the raw transcript tail in `transcript` and
11
+ // the SERVER summarized it with Agnoclast's Anthropic key. D8 ends that: the transcript stays on the
12
+ // machine. So a turn that cannot be summarized now has to WAIT somewhere โ€” here โ€” until it can.
13
+ //
14
+ // This repo's most-repeated failure is a green status over work that did not happen. So the queue has
15
+ // one rule above the others: NOTHING LEAVES IT SILENTLY. An entry leaves because it was recorded,
16
+ // because the server made a final decision about it, or because a bound forced it out โ€” and the last
17
+ // two of those are written to a drop ledger that the next session start reports, alongside every entry
18
+ // that has failed FLAG_AFTER_ATTEMPTS times.
19
+ //
20
+ // Layout โ€” ~/.cortex/capture-queue/ (dir 0700, files 0600, every write tmp+rename so a concurrent
21
+ // reader never sees half a file):
22
+ // <session key>.json one entry per SESSION. The Stop hook fires every turn and the server keeps one
23
+ // record per session, so a newer turn REPLACES the older entry (keeping its
24
+ // attempt count), and a recorded turn removes it.
25
+ // _dropped.json the drop ledger: what a bound or a server rejection forced out, for 7 days.
26
+ //
27
+ // Two kinds of entry:
28
+ // summarize the local summarizer was unavailable. Holds the redacted transcript TAIL (โ‰ค ~7 KB, the
29
+ // same text `claude -p` would have been shown) plus the ingest envelope. LOCAL ONLY โ€” the
30
+ // tail is never sent anywhere; it is summarized here, later, and only the digest leaves.
31
+ // ingest summarized fine, the POST failed. Holds the finished body, re-posted later. A summarize
32
+ // entry becomes an ingest entry the moment its summary exists, so a failed POST never
33
+ // costs a second `claude -p` run โ€” and the transcript is deleted from the entry right then.
34
+ //
35
+ // Bounds: QUEUE_MAX_ENTRIES entries, QUEUE_MAX_AGE_DAYS days, QUEUE_MAX_ENTRY_BYTES per entry. Drained
36
+ // opportunistically by later Stop-hook runs (the detached capture worker, which blocks nobody), with
37
+ // equal-jittered exponential backoff per entry and a Retry-After floor.
38
+
39
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, statSync, openSync, closeSync } from 'node:fs'
40
+ import { homedir } from 'node:os'
41
+ import { join } from 'node:path'
42
+ import { createHash, randomBytes } from 'node:crypto'
43
+ import { fetchCortex, classify, responseRetryClass, parseRetryAfter } from './diagnose.mjs'
44
+
45
+ export const QUEUE_MAX_ENTRIES = 50
46
+ export const QUEUE_MAX_AGE_DAYS = 14
47
+ export const QUEUE_MAX_ENTRY_BYTES = 256 * 1024
48
+ /** After this many failed attempts an entry is FLAGGED: kept, retried, and reported at session start. */
49
+ export const FLAG_AFTER_ATTEMPTS = 3
50
+ export const QUEUE_FIRST_RETRY_MS = 2 * 60_000
51
+ export const QUEUE_MAX_RETRY_MS = 6 * 60 * 60_000
52
+ /** How long a drop stays in the ledger (and so in the session-start line). */
53
+ export const DROPPED_WINDOW_DAYS = 7
54
+ const DROPPED_FILE = '_dropped.json'
55
+ const DROPPED_MAX = 100
56
+ const STALE_TMP_MS = 60 * 60_000
57
+
58
+ /** One drain, per Stop-hook run: a few cheap POSTs, at most one `claude -p` retry, and a time cap.
59
+ *
60
+ * ๐Ÿ”ด THE BUDGET BOUNDS EVERY BLOCKING STEP, NOT JUST THE GAPS BETWEEN THEM. It first only wrapped the
61
+ * loop, so a due summarize entry could block for the full `summaryTimeoutMs()` (300 s by default, a
62
+ * SYNCHRONOUS spawnSync) and then still post โ€” about five times the stated cap, and under
63
+ * CORTEX_CAPTURE_SYNC that is the hook itself waiting. Now a summarize is attempted only when the
64
+ * remaining budget can actually cover one, runs with `timeoutMs` set to what is left minus a reserve
65
+ * for its POST, and every POST is bounded by what remains after that.
66
+ *
67
+ * Sized so ONE summarization fits: a Stop-hook tail is โ‰ค ~7 KB (far smaller than the 50โ€“147 KB lecture
68
+ * transcripts whose 56โ€“150 s timings edge_extract.mjs records), and a session that cannot be summarized
69
+ * inside DRAIN_MIN_SUMMARY_MS is left for a later run rather than half-done. */
70
+ export const DRAIN_MAX_POSTS = 5
71
+ export const DRAIN_MAX_SUMMARIES = 1
72
+ export const DRAIN_BUDGET_MS = 180_000
73
+ /** Don't start a summarize unless this much budget remains (plus the post reserve). */
74
+ export const DRAIN_MIN_SUMMARY_MS = 90_000
75
+ /** Held back from a summarize's timeout so the digest it produces can still be posted. */
76
+ export const DRAIN_POST_RESERVE_MS = 15_000
77
+ /** Below this, don't start a POST at all โ€” leave the entry for the next run. */
78
+ export const DRAIN_MIN_POST_MS = 2_000
79
+ /** Per-POST ceiling (also the live capture's). A detached worker self-terminates in ~25 s. */
80
+ export const INGEST_BUDGET_MS = 25_000
81
+
82
+ // โ”€โ”€ Ordering: a per-session lease, and a generation per capture โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
83
+ //
84
+ // ๐Ÿ”ด WHY (adversarial review, 2026-09-11). The Stop hook fires per TURN and each turn runs in its own
85
+ // detached worker, so two workers for one session overlap routinely โ€” extraction alone takes tens of
86
+ // seconds. They finished in any order, and nothing ordered their effects:
87
+ //
88
+ // ยท turn N+1 queues after a transient 503; turn N finishes later, posts fine, and its
89
+ // `removeEntry(session)` deletes N+1's queued capture. The newer turn is silently lost.
90
+ // ยท turn N, failing later, overwrites N+1's queued entry with its older content.
91
+ // ยท the drain posts a digest it read minutes earlier while a live capture posts a newer one;
92
+ // /api/ingest dedupes by sessionId and takes the last write, so the stale replay wins.
93
+ //
94
+ // Two mechanisms, because the problem has two halves. A GENERATION orders the captures of one session;
95
+ // a LEASE stops two of them from racing at the server.
96
+ //
97
+ // GENERATION = [transcript characters read, capture time]. The transcript is append-only per session, so
98
+ // a later turn always reads at least as many characters; the timestamp only breaks ties (the Stop hook
99
+ // firing twice with nothing new in between). Carried in the queue entry AND in the posted payload, so a
100
+ // later server PR can reject an older generation for a session it already holds โ€” see the follow-up note
101
+ // in the PR. Every queue write is compare-and-set (never replace a newer generation) and every removal
102
+ // is compare-and-delete (only delete what the caller superseded).
103
+ //
104
+ // LEASE = one file per session under `_locks/`, held across the check-then-POST-then-update critical
105
+ // section by whoever is posting โ€” live capture or drain. It makes a session's uploads serial, so a
106
+ // poster can check "has a newer generation already been recorded?" (`_posted/`) and act on the answer
107
+ // without another poster slipping in between. Never held across a summarize: that is minutes, and the
108
+ // lease must stay short. Stale by pid-death or TTL, because a worker can be killed mid-post.
109
+ export const LEASE_TTL_MS = 60_000
110
+ /** A live capture will wait this long for another worker's POST of the same session to finish. */
111
+ export const LEASE_WAIT_MS = 30_000
112
+ /** A drain that has spent a `claude -p` run waits at most this long for the lease before giving up. */
113
+ export const DRAIN_LEASE_WAIT_MS = 5_000
114
+ /** A claim is abandoned after this, or as soon as the claiming process is gone (crash mid-claim). */
115
+ export const CLAIM_TTL_MS = 10 * 60_000
116
+ /** A summarize entry touched this recently belongs to a session that is probably still running โ€” its
117
+ * own next turn will capture it. A drain `claude -p` run holds the machine-wide summarizer lock for
118
+ * ~1โ€“2 min (edge_extract.mjs), which turns every concurrent session's capture into `busy`; spending
119
+ * that on a session about to capture itself is pure cost. */
120
+ export const SUMMARIZE_SETTLE_MS = 5 * 60_000
121
+
122
+ const DAY_MS = 24 * 60 * 60_000
123
+
124
+ export function queueDir(home = homedir()) {
125
+ return join(home, '.cortex', 'capture-queue')
126
+ }
127
+
128
+ /** A filesystem-safe, stable key per session. Claude and Codex session ids are UUIDs and pass as-is. */
129
+ export function entryKey(sessionId) {
130
+ if (typeof sessionId === 'string' && /^[A-Za-z0-9_-]{1,100}$/.test(sessionId)) return sessionId
131
+ if (typeof sessionId === 'string' && sessionId) return `h-${createHash('sha1').update(sessionId).digest('hex').slice(0, 24)}`
132
+ return `nosession-${Date.now()}-${randomBytes(4).toString('hex')}`
133
+ }
134
+
135
+ /**
136
+ * PURE. The generation of one capture: how much transcript it read, and when it read it.
137
+ * `chars` is the ordering key; `at` only breaks ties.
138
+ */
139
+ export function makeGen(chars, at) {
140
+ return { chars: Number.isFinite(chars) ? Math.max(0, Math.trunc(chars)) : 0, at: at ?? 0 }
141
+ }
142
+
143
+ /** PURE. Order two generations. A missing generation is older than any real one. */
144
+ export function cmpGen(a, b) {
145
+ const ac = a ? a.chars ?? 0 : -1
146
+ const bc = b ? b.chars ?? 0 : -1
147
+ if (ac !== bc) return ac < bc ? -1 : 1
148
+ const aa = a?.at ?? 0
149
+ const ba = b?.at ?? 0
150
+ return aa === ba ? 0 : aa < ba ? -1 : 1
151
+ }
152
+
153
+ /** The generation as it travels in the posted payload (snake_case, like its neighbours there). */
154
+ export function genPayload(gen) {
155
+ return { transcript_chars: gen?.chars ?? 0, captured_at: new Date(gen?.at ?? Date.now()).toISOString() }
156
+ }
157
+
158
+ /** PURE. Per-entry retry delay: 2 min doubling to 6 h, EQUAL jitter (half fixed, half random) so a
159
+ * fleet that failed together does not come back together, while no entry retries sooner than half. */
160
+ export function queueBackoffMs(attempts, random = Math.random) {
161
+ const ceiling = Math.min(QUEUE_FIRST_RETRY_MS * 2 ** Math.max(0, attempts - 1), QUEUE_MAX_RETRY_MS)
162
+ return Math.floor(ceiling / 2 + random() * (ceiling / 2))
163
+ }
164
+
165
+ export const isFlagged = (entry) => (entry?.attempts ?? 0) >= FLAG_AFTER_ATTEMPTS
166
+
167
+ // โ”€โ”€ storage โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
168
+
169
+ function ensureDir(dir) {
170
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
171
+ }
172
+
173
+ function atomicWrite(dir, name, text) {
174
+ ensureDir(dir)
175
+ const file = join(dir, name)
176
+ const tmp = `${file}.${process.pid}.${randomBytes(3).toString('hex')}.tmp`
177
+ writeFileSync(tmp, text, { mode: 0o600 })
178
+ renameSync(tmp, file) // atomic: a concurrent reader never sees half a file
179
+ }
180
+
181
+ export function readEntry(dir, key) {
182
+ try { return JSON.parse(readFileSync(join(dir, `${key}.json`), 'utf8')) } catch { return null }
183
+ }
184
+
185
+ /** Every readable entry. An unreadable one is removed AND recorded โ€” never silently skipped forever. */
186
+ export function readEntries(dir, { log = () => {}, now = Date.now } = {}) {
187
+ let names
188
+ try { names = readdirSync(dir) } catch { return [] }
189
+ const out = []
190
+ for (const name of names) {
191
+ if (!name.endsWith('.json') || name.startsWith('_')) continue
192
+ try {
193
+ const entry = JSON.parse(readFileSync(join(dir, name), 'utf8'))
194
+ if (entry && typeof entry === 'object' && entry.key) out.push(entry)
195
+ else throw new Error('not an entry')
196
+ } catch {
197
+ try { unlinkSync(join(dir, name)) } catch { /* raced with another reader */ }
198
+ recordDropped(dir, { sessionId: name.replace(/\.json$/, ''), reason: 'unreadable queue file' }, now())
199
+ log(`DROPPED unreadable queue file ${name}`)
200
+ }
201
+ }
202
+ return out
203
+ }
204
+
205
+ function writeEntry(dir, entry) {
206
+ const text = JSON.stringify(entry)
207
+ if (Buffer.byteLength(text) > QUEUE_MAX_ENTRY_BYTES) return false
208
+ atomicWrite(dir, `${entry.key}.json`, text)
209
+ return true
210
+ }
211
+
212
+ /** Remove `key` โ€” but only if it is still the revision we hold (a newer turn may have replaced it). */
213
+ export function removeEntry(dir, key, rev = null) {
214
+ if (rev) {
215
+ const cur = readEntry(dir, key)
216
+ if (cur && cur.rev !== rev) return false
217
+ }
218
+ try { unlinkSync(join(dir, `${key}.json`)); return true } catch { return false }
219
+ }
220
+
221
+ /**
222
+ * Compare-and-delete: drop this session's entry only if it is NOT newer than the generation the caller
223
+ * just recorded. This is what a successful post calls, and it is the whole fix for "turn N's success
224
+ * deleted turn N+1's queued capture" โ€” N+1 is newer, so it stays.
225
+ */
226
+ export function removeIfNotNewer(dir, key, gen) {
227
+ const cur = readEntry(dir, key)
228
+ if (!cur) return false
229
+ if (cmpGen(cur.gen, gen) > 0) return false
230
+ return removeEntry(dir, key, cur.rev)
231
+ }
232
+
233
+ const newRev = () => randomBytes(6).toString('hex')
234
+
235
+ // โ”€โ”€ the session lease โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
236
+
237
+ const leaseDir = (dir) => join(dir, '_locks')
238
+
239
+ function pidAlive(pid) {
240
+ const n = Number(pid)
241
+ if (!Number.isInteger(n) || n <= 0) return false
242
+ try { process.kill(n, 0); return true } catch (e) { return e?.code === 'EPERM' } // EPERM = alive, not ours
243
+ }
244
+
245
+ /** Is the lock file abandoned? Its holder is gone, or it has outlived the TTL. */
246
+ function leaseIsStale(file) {
247
+ let text
248
+ try { text = readFileSync(file, 'utf8') } catch (e) { return e?.code === 'ENOENT' }
249
+ const [pid, at] = text.trim().split(/\s+/)
250
+ // An EMPTY file is a lock caught between create and write โ€” young, and not ours to break. Judge those
251
+ // by mtime only, or we would steal a lease microseconds after a neighbour created it.
252
+ if (!pid || !Number.isFinite(Number(at))) {
253
+ try { return Date.now() - statSync(file).mtimeMs > LEASE_TTL_MS } catch { return false }
254
+ }
255
+ return !pidAlive(pid) || Date.now() - Number(at) > LEASE_TTL_MS
256
+ }
257
+
258
+ /**
259
+ * Take this session's lease. Returns a release function, or null when someone else holds it and
260
+ * `waitMs` ran out. Timestamps are REAL time (a lock file outlives any injected clock).
261
+ *
262
+ * Crash recovery: a lease whose holder is dead, or older than LEASE_TTL_MS, is broken and retaken. The
263
+ * release only unlinks a file that still carries OUR token, so a lease broken underneath us is never
264
+ * deleted from under its new holder.
265
+ */
266
+ export async function acquireLease(dir, key, { waitMs = 0, pollMs = 25 } = {}) {
267
+ const file = join(leaseDir(dir), `${key}.lock`)
268
+ try { mkdirSync(leaseDir(dir), { recursive: true, mode: 0o700 }) } catch { return null }
269
+ const token = `${process.pid} ${Date.now()} ${randomBytes(4).toString('hex')}`
270
+ const deadline = Date.now() + waitMs
271
+ let breaks = 0
272
+ for (;;) {
273
+ try {
274
+ const fd = openSync(file, 'wx', 0o600)
275
+ try { writeFileSync(fd, token) } finally { closeSync(fd) }
276
+ return () => { try { if (readFileSync(file, 'utf8') === token) unlinkSync(file) } catch { /* broken or gone */ } }
277
+ } catch (e) {
278
+ if (e?.code !== 'EEXIST') return null
279
+ }
280
+ if (leaseIsStale(file) && breaks < 3) {
281
+ breaks += 1
282
+ try { unlinkSync(file) } catch { /* raced with another breaker */ }
283
+ continue
284
+ }
285
+ if (Date.now() >= deadline) return null
286
+ await new Promise((r) => setTimeout(r, pollMs))
287
+ }
288
+ }
289
+
290
+ /** Run `fn` under the session lease. `fn` receives true if the lease was actually held. */
291
+ export async function withLease(dir, key, opts, fn) {
292
+ const release = await acquireLease(dir, key, opts)
293
+ try { return await fn(Boolean(release)) } finally { release?.() }
294
+ }
295
+
296
+ // โ”€โ”€ what has already been recorded for a session โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
297
+ //
298
+ // The high-water mark of generations this machine has successfully posted for a session, kept after the
299
+ // queue entry is gone. It is how a poster knows its own capture is already superseded, and it is checked
300
+ // under the lease so the answer cannot go stale between the check and the POST.
301
+
302
+ const postedDir = (dir) => join(dir, '_posted')
303
+
304
+ export function readPostedGen(dir, key) {
305
+ try { return JSON.parse(readFileSync(join(postedDir(dir), `${key}.json`), 'utf8')).gen ?? null } catch { return null }
306
+ }
307
+
308
+ /** Raise the mark. Monotonic: an older generation never lowers it. Call under the lease. */
309
+ export function raisePostedGen(dir, key, gen) {
310
+ if (!key || !gen) return
311
+ const cur = readPostedGen(dir, key)
312
+ if (cur && cmpGen(cur, gen) >= 0) return
313
+ try { atomicWrite(postedDir(dir), `${key}.json`, JSON.stringify({ gen, at: Date.now() })) } catch { /* best-effort */ }
314
+ }
315
+
316
+ /** Has a NEWER capture of this session already been recorded? Then this entry is finished, not pending. */
317
+ export function supersededByPosted(dir, entry) {
318
+ const posted = readPostedGen(dir, entry.key)
319
+ if (!posted || cmpGen(posted, entry.gen) <= 0) return false
320
+ removeEntry(dir, entry.key, entry.rev)
321
+ return true
322
+ }
323
+
324
+ /** A claim is live while the process that made it is alive and it has not outlived CLAIM_TTL_MS. */
325
+ export function claimIsLive(claim) {
326
+ if (!claim) return false
327
+ if (!pidAlive(claim.pid)) return false
328
+ return Date.now() - (claim.at ?? 0) < CLAIM_TTL_MS
329
+ }
330
+
331
+ // โ”€โ”€ the drop ledger โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
332
+
333
+ export function readDropped(dir, now = Date.now()) {
334
+ try {
335
+ const list = JSON.parse(readFileSync(join(dir, DROPPED_FILE), 'utf8'))
336
+ return Array.isArray(list) ? list.filter((d) => now - (d?.at ?? 0) <= DROPPED_WINDOW_DAYS * DAY_MS) : []
337
+ } catch {
338
+ return []
339
+ }
340
+ }
341
+
342
+ export function recordDropped(dir, { sessionId, reason }, now = Date.now()) {
343
+ try {
344
+ const list = [...readDropped(dir, now), { at: now, sessionId: sessionId ?? null, reason: String(reason ?? 'unknown') }]
345
+ atomicWrite(dir, DROPPED_FILE, JSON.stringify(list.slice(-DROPPED_MAX)))
346
+ } catch { /* a ledger write must never cost a capture */ }
347
+ }
348
+
349
+ // โ”€โ”€ enqueue + bounds โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
350
+
351
+ /**
352
+ * Queue (or re-queue) a capture that did not land. Returns the entry written, or null if it could not
353
+ * be stored โ€” in which case it is in the drop ledger, so it is still reported.
354
+ *
355
+ * COMPARE-AND-SET on the generation: if a NEWER turn of this session is already queued, this older one
356
+ * is not written over it, and the newer entry is returned carrying `superseded: true`. That is the fix
357
+ * for an out-of-order worker replacing a newer queued capture with its own staler content. Nothing is
358
+ * lost by refusing: the newer turn read a superset of this transcript.
359
+ *
360
+ * `countsAsFailure: false` is for a summarizer that was merely BUSY (another session held the lock):
361
+ * that resolves itself and is not evidence of anything wrong, so it must not march an entry toward
362
+ * being flagged.
363
+ *
364
+ * Call under the session lease where one is available; the read-compare-write is re-checked here, so a
365
+ * best-effort call without the lease is still ordered against whatever is on disk at this moment.
366
+ */
367
+ export function enqueueCapture(dir, item, { now = Date.now(), random = Math.random, log = () => {} } = {}) {
368
+ const { sessionId, kind, reason, detail = null, common, transcript, body, retryAfterMs = null, countsAsFailure = true } = item
369
+ const key = entryKey(sessionId)
370
+ const gen = item.gen ?? makeGen(0, now)
371
+ const prev = readEntry(dir, key)
372
+ if (prev && cmpGen(prev.gen, gen) > 0) {
373
+ log(`${sessionId ?? key}: a NEWER turn of this session is already queued (${prev.kind}) โ€” this older capture is not written over it`)
374
+ return { ...prev, superseded: true }
375
+ }
376
+ const attempts = (prev?.attempts ?? 0) + (countsAsFailure ? 1 : 0)
377
+ const entry = {
378
+ v: 1,
379
+ key,
380
+ rev: newRev(),
381
+ gen,
382
+ sessionId: sessionId ?? null,
383
+ kind,
384
+ reason: String(reason ?? 'unknown'),
385
+ detail: detail ? String(detail).slice(0, 300) : null,
386
+ attempts,
387
+ firstQueuedAt: prev?.firstQueuedAt ?? now,
388
+ capturedAt: now,
389
+ updatedAt: now,
390
+ nextAttemptAt: now + Math.max(queueBackoffMs(Math.max(1, attempts), random), retryAfterMs ?? 0),
391
+ ...(kind === 'summarize' ? { common, transcript } : { body }),
392
+ }
393
+ try {
394
+ if (!writeEntry(dir, entry)) {
395
+ recordDropped(dir, { sessionId, reason: 'too large to queue' }, now)
396
+ log(`DROPPED ${sessionId ?? key} โ€” the capture is larger than ${QUEUE_MAX_ENTRY_BYTES} bytes and cannot be queued`)
397
+ return null
398
+ }
399
+ } catch (e) {
400
+ recordDropped(dir, { sessionId, reason: 'queue write failed' }, now)
401
+ log(`DROPPED ${sessionId ?? key} โ€” could not write the local queue: ${e instanceof Error ? e.message : String(e)}`)
402
+ return null
403
+ }
404
+ if (isFlagged(entry) && !isFlagged(prev)) {
405
+ log(`FLAGGED ${sessionId ?? key} after ${attempts} failed attempts [${entry.reason}] โ€” kept, retried, and reported at the next session start`)
406
+ }
407
+ enforceBounds(dir, { now: () => now, log, keep: key })
408
+ return entry
409
+ }
410
+
411
+ /** Re-schedule an entry after another failed attempt. No-op if a newer turn replaced it meanwhile.
412
+ * Also clears any claim: the attempt that held it is over. */
413
+ function bumpEntry(dir, entry, { reason, detail, retryAfterMs = null, patch = {} }, { now, random, log }) {
414
+ const cur = readEntry(dir, entry.key)
415
+ if (!cur || cur.rev !== entry.rev) return null
416
+ const attempts = (cur.attempts ?? 0) + 1
417
+ const next = {
418
+ ...cur,
419
+ ...patch,
420
+ claim: undefined,
421
+ rev: newRev(),
422
+ reason: String(reason ?? cur.reason),
423
+ detail: detail ? String(detail).slice(0, 300) : cur.detail,
424
+ attempts,
425
+ updatedAt: now,
426
+ nextAttemptAt: now + Math.max(queueBackoffMs(attempts, random), retryAfterMs ?? 0),
427
+ }
428
+ try { writeEntry(dir, next) } catch { return null }
429
+ if (isFlagged(next) && !isFlagged(cur)) {
430
+ log(`FLAGGED ${cur.sessionId ?? cur.key} after ${attempts} failed attempts [${next.reason}] โ€” kept, retried, and reported at the next session start`)
431
+ }
432
+ return next
433
+ }
434
+
435
+ /**
436
+ * Enforce age and count. Everything forced out is RECORDED in the drop ledger and logged โ€” a bound is
437
+ * the one place this queue loses anything, so it is exactly where it must say so.
438
+ */
439
+ export function enforceBounds(dir, { now = Date.now, log = () => {}, keep = null } = {}) {
440
+ const t = now()
441
+ // Stale temp files from a process killed mid-write.
442
+ try {
443
+ for (const name of readdirSync(dir)) {
444
+ if (!name.endsWith('.tmp')) continue
445
+ try { if (t - statSync(join(dir, name)).mtimeMs > STALE_TMP_MS) unlinkSync(join(dir, name)) } catch { /* raced */ }
446
+ }
447
+ } catch { return }
448
+ pruneSidecars(dir)
449
+ let entries = readEntries(dir, { log, now })
450
+ for (const e of entries) {
451
+ if (t - (e.capturedAt ?? e.firstQueuedAt ?? t) > QUEUE_MAX_AGE_DAYS * DAY_MS) {
452
+ // rev-guarded: if a live capture rewrote this entry since we listed it, leave the newer one alone.
453
+ if (!removeEntry(dir, e.key, e.rev)) continue
454
+ recordDropped(dir, { sessionId: e.sessionId, reason: `older than ${QUEUE_MAX_AGE_DAYS} days (last: ${e.reason})` }, t)
455
+ log(`DROPPED ${e.sessionId ?? e.key} โ€” queued ${QUEUE_MAX_AGE_DAYS}+ days without being recorded (last reason: ${e.reason})`)
456
+ }
457
+ }
458
+ entries = readEntries(dir, { log, now })
459
+ if (entries.length <= QUEUE_MAX_ENTRIES) return
460
+ const evictable = entries.filter((e) => e.key !== keep).sort((a, b) => (a.firstQueuedAt ?? 0) - (b.firstQueuedAt ?? 0))
461
+ for (const e of evictable.slice(0, entries.length - QUEUE_MAX_ENTRIES)) {
462
+ if (!removeEntry(dir, e.key, e.rev)) continue
463
+ recordDropped(dir, { sessionId: e.sessionId, reason: `queue full (${QUEUE_MAX_ENTRIES} entries; last: ${e.reason})` }, t)
464
+ log(`DROPPED ${e.sessionId ?? e.key} โ€” the local queue is full (${QUEUE_MAX_ENTRIES}); oldest evicted (last reason: ${e.reason})`)
465
+ }
466
+ }
467
+
468
+ /**
469
+ * The two sidecar directories, pruned on REAL time (they are filesystem artifacts, not scheduled work):
470
+ * _posted/ one tiny mark per session, kept as long as an entry could still be queued for it
471
+ * _locks/ released on exit, so anything this old is a lease whose process died
472
+ */
473
+ function pruneSidecars(dir) {
474
+ const real = Date.now()
475
+ for (const [sub, maxAge] of [[postedDir(dir), QUEUE_MAX_AGE_DAYS * DAY_MS], [leaseDir(dir), 10 * LEASE_TTL_MS]]) {
476
+ try {
477
+ for (const name of readdirSync(sub)) {
478
+ try { if (real - statSync(join(sub, name)).mtimeMs > maxAge) unlinkSync(join(sub, name)) } catch { /* raced */ }
479
+ }
480
+ } catch { /* no sidecar dir yet */ }
481
+ }
482
+ }
483
+
484
+ // โ”€โ”€ /api/ingest: what an answer means โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
485
+ //
486
+ // IDEMPOTENCY โ€” CONCLUSION (read against web/app/api/ingest/route.ts, 2026-09-11):
487
+ // /api/ingest DEDUPES a session capture on its sessionId, on every path it can take:
488
+ // ยท records `insert โ€ฆ on conflict (org_id, dedupe_key) do update`, dedupe_key = `${source}:${sessionId}`.
489
+ // A replay rewrites the same row. The post-commit projectโ†’actor edge strengthens only
490
+ // when `inserted`, so a replay does not double-count it either.
491
+ // ยท private the intake unit is keyed by intakeUnitKey(), which is the sessionId when present;
492
+ // intake a re-push answers `duplicate` / `updated` against the same unit.
493
+ // ยท staged unrouted captures stage under the same `${source}:${sessionId}` key.
494
+ // And the Stop hook has ALWAYS re-posted the same session on every turn, relying on exactly this.
495
+ // So a capture WITH a sessionId is idempotent: it may be retried after a 5xx, a broken connection or
496
+ // one timeout, in-process or from this queue.
497
+ //
498
+ // โš  WITHOUT a sessionId it is NOT: the records key falls back to occurred_at (stable only if sent) and
499
+ // intakeUnitKey falls back to a key containing Date.now() โ€” a replay mints a second unit. Such a capture
500
+ // is replayed only after failures where the request provably never reached the server (a refused
501
+ // connection, 429/503, an infrastructure 403, a 401). Claude Code and Codex always send a session id,
502
+ // so this is the rare path, and it fails toward a REPORTED drop rather than a silent duplicate.
503
+
504
+ /** The policy for every capture POST. Worst case ~25 s in a DETACHED worker that nobody waits on โ€”
505
+ * less when the caller has less left (the drain passes its remaining budget). */
506
+ export function ingestPolicy(body, budgetMs = INGEST_BUDGET_MS) {
507
+ const idempotent = typeof body?.sessionId === 'string' && body.sessionId.length > 0
508
+ const budget = Math.max(1, Math.min(INGEST_BUDGET_MS, Number.isFinite(budgetMs) ? budgetMs : INGEST_BUDGET_MS))
509
+ return { retries: 1, idempotent, budgetMs: budget }
510
+ }
511
+
512
+ /**
513
+ * PURE. Classify an /api/ingest HTTP answer:
514
+ * recorded a session record exists (the only success)
515
+ * final the server decided โ€” staged, skipped, discarded, filed as an intake unit. Retrying cannot
516
+ * change it; any older queued turn of the same session is superseded.
517
+ * retry not recorded, and a later replay is safe and may succeed
518
+ * rejected not recorded, and a replay is unsafe or pointless (a 4xx verdict). Reported as a drop.
519
+ */
520
+ export function classifyIngestResponse(status, contentType, raw, { repo = 'general', requestId = null, idempotent = false } = {}) {
521
+ if (status >= 200 && status < 300) {
522
+ let j = null
523
+ try { j = JSON.parse(raw) } catch { j = null }
524
+ // A 2xx FROM /api/ingest DOES NOT MEAN A RECORD EXISTS. The route answers `ok: true` on at least
525
+ // six outcomes and only one of them writes a session record:
526
+ //
527
+ // { ok: true, id, inserted, title } -> RECORDED (the only success)
528
+ // { ok: true, staged: true, id, reason } -> held in staged_records, NOT recorded
529
+ // { ok: true, skipped: '<why>' } -> no-op session / past the ingest horizon
530
+ // { ok: false, skipped: '<why>' } -> connector excluded from this brain
531
+ // { ok: true, discarded: true } -> tombstoned by private intake
532
+ // { ok: true, queued: true } -> accepted for later work
533
+ // { ok: true, via: 'private_intake', intakeItemId } -> an intake unit, not a session record
534
+ //
535
+ // The old line read `j.inserted ? 'captured' : 'updated'`, so EVERY one of the six non-writing
536
+ // outcomes printed "updated" โ€” the word for a successful upsert. Worse, `.json().catch(() => ({}))`
537
+ // means an unparseable body also yields `{}` and therefore also printed "updated". Verified against
538
+ // prod 2026-08-19: a capture printed `cortex: updated "general" โ†’ general` for a session that has no
539
+ // row in `records` and none in `staged_records` either.
540
+ //
541
+ // NOTE `staged` CARRIES AN `id`, so testing for an id alone is not enough โ€” that id is the
542
+ // staged_records row, not a record. This is the same false-success defect already fixed once in
543
+ // log_session ("`inserted` is merely falsy when nothing is recorded โ€” an agent reported a session as
544
+ // saved when it was not"); the fix was applied there and not here. The server route already knew:
545
+ // its own comment at the no_route_for_source branch says "`{ok: true}` reads as success to
546
+ // everything that is not looking closely" and notes 84 rows accumulating behind that wording.
547
+ // (Moved here from capture.mjs with the classifier, 2026-09-11.)
548
+ if (j && j.id && !j.staged) return { outcome: 'recorded', line: `${j.inserted ? 'captured' : 'updated'} "${j.title ?? repo}" โ†’ ${repo}` }
549
+ if (j && j.staged) return { outcome: 'final', line: `NOT RECORDED โ€” staged (${j.reason ?? 'no reason given'}); staged session logs are not drainable by /api/staged/promote` }
550
+ if (j && j.skipped) return { outcome: 'final', line: `NOT RECORDED โ€” server skipped: ${j.skipped}` }
551
+ if (j && j.discarded) return { outcome: 'final', line: 'NOT RECORDED โ€” discarded by private intake' }
552
+ // Accepted before anything was written, with no record id to prove it ever was. Replaying the
553
+ // same session is harmless (idempotent) and is the only way to find out.
554
+ if (j && j.queued) {
555
+ return idempotent
556
+ ? { outcome: 'retry', reason: 'accepted without a record (queued)', line: 'NOT RECORDED โ€” queued for later processing; kept in the local queue until a record exists' }
557
+ : { outcome: 'final', line: 'NOT RECORDED โ€” queued for later processing' }
558
+ }
559
+ if (j && j.intakeItemId) return { outcome: 'final', line: `not a session record โ€” filed as private intake unit ${j.intakeItemId}` }
560
+ // Unparseable or unrecognised 2xx. Deliberately NOT success โ€” that is what the old code laundered
561
+ // into "updated".
562
+ return {
563
+ outcome: idempotent ? 'retry' : 'rejected',
564
+ reason: 'unrecognised response',
565
+ line: `NOT RECORDED โ€” unrecognised 2xx response: ${String(raw).slice(0, 200)}`,
566
+ }
567
+ }
568
+ const d = classify(status, contentType, raw, requestId)
569
+ const klass = responseRetryClass(status, contentType)
570
+ const line = `NOT RECORDED โ€” ingest failed: ${d.message}`
571
+ // Refused (429/503/infra 403) and 401 were never processed: always safe to replay. A 401 is kept
572
+ // rather than dropped because an expired token is fixed by logging in again โ€” and the capture
573
+ // should still be here when that happens (the same call obligations_worker makes).
574
+ if (klass === 'refused') return { outcome: 'retry', reason: `HTTP ${status}`, line }
575
+ if (status === 401) return { outcome: 'retry', reason: 'token rejected (HTTP 401)', line }
576
+ if (klass === 'server') return idempotent ? { outcome: 'retry', reason: `HTTP ${status}`, line } : { outcome: 'rejected', reason: `HTTP ${status}, no session id to replay safely`, line }
577
+ return { outcome: 'rejected', reason: `rejected (HTTP ${status})`, line }
578
+ }
579
+
580
+ /**
581
+ * POST one capture. Never throws. The single exit through which every capture body leaves the machine,
582
+ * which is why D8's rule is enforced HERE and not only at the call sites: whatever a caller passes, a
583
+ * `transcript` field is stripped before it is serialized.
584
+ */
585
+ export async function postIngest({ base, token, body, repo = 'general', fetchImpl = fetchCortex, budgetMs = INGEST_BUDGET_MS }) {
586
+ const { transcript: _neverSent, ...safe } = body ?? {} // D8: the transcript never leaves this machine
587
+ const policy = ingestPolicy(safe, budgetMs)
588
+ let res
589
+ try {
590
+ res = await fetchImpl(`${base}/api/ingest`, {
591
+ method: 'POST',
592
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
593
+ body: JSON.stringify(safe),
594
+ timeoutMs: Math.min(10_000, policy.budgetMs),
595
+ }, policy)
596
+ } catch (e) {
597
+ // No response at all. Safe to replay if nothing reached the server, or if the body dedupes.
598
+ const neverSent = e?.retryClass === 'connect'
599
+ const reason = e?.retryClass === 'timeout' ? 'timed out' : neverSent ? 'unreachable' : 'connection failed'
600
+ const line = `NOT RECORDED โ€” ${e instanceof Error ? e.message : String(e)}`
601
+ return neverSent || policy.idempotent
602
+ ? { outcome: 'retry', reason, line, retryAfterMs: null }
603
+ : { outcome: 'rejected', reason: `${reason}, no session id to replay safely`, line }
604
+ }
605
+ const raw = await res.text().catch(() => '')
606
+ const c = classifyIngestResponse(res.status, res.headers.get('content-type'), raw, {
607
+ repo, requestId: res.headers.get('x-vercel-id'), idempotent: policy.idempotent,
608
+ })
609
+ return c.outcome === 'retry' ? { ...c, retryAfterMs: parseRetryAfter(res.headers.get('retry-after')) } : c
610
+ }
611
+
612
+ // โ”€โ”€ drain โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
613
+
614
+ /**
615
+ * Retry what is due. Called after every detached capture, so it must be cheap when the queue is empty
616
+ * (one readdir) and must never throw. Health signals from the capture that just ran decide what is
617
+ * worth trying: if the server just failed, no POSTs; if the summarizer just failed, no `claude -p`.
618
+ * A summarizer that just WORKED is also why a summarize entry may skip its schedule โ€” the thing it was
619
+ * waiting for is demonstrably back.
620
+ *
621
+ * ๐Ÿ”ด NOTHING IS POSTED FROM THE LISTING. The listing is a snapshot, and a summarize can take minutes, so
622
+ * by upload time a live capture of that session may have posted something newer and rewritten or removed
623
+ * the entry. Every upload therefore happens under this session's LEASE, against a FRESH read, and only
624
+ * after checking `_posted/` โ€” so a digest read minutes ago can never overwrite a newer record. A
625
+ * summarize is additionally CLAIMED first, so two drains don't spend two `claude -p` runs on one entry.
626
+ *
627
+ * deps: { dir, now, random, log, post(body, entry, {budgetMs}) โ†’ outcome,
628
+ * summarize(transcript, {timeoutMs}) โ†’ {extracted}|{noop}|{unavailable},
629
+ * buildBody(common, extracted, transcript) โ†’ body, skipKey, serverHealthy, summarizerHealthy,
630
+ * allowSummarize, maxPosts, maxSummaries, budgetMs }
631
+ */
632
+ export async function drainCaptureQueue(deps) {
633
+ const {
634
+ dir, now = Date.now, random = Math.random, log = () => {},
635
+ post, summarize, buildBody,
636
+ skipKey = null, serverHealthy = null, summarizerHealthy = null, allowSummarize = false,
637
+ maxPosts = DRAIN_MAX_POSTS, maxSummaries = DRAIN_MAX_SUMMARIES, budgetMs = DRAIN_BUDGET_MS,
638
+ leaseWaitMs = DRAIN_LEASE_WAIT_MS,
639
+ } = deps
640
+ const result = { recorded: 0, final: 0, dropped: 0, failed: 0, summarized: 0, superseded: 0, deferred: 0 }
641
+ try {
642
+ enforceBounds(dir, { now, log })
643
+ const startedAt = now()
644
+ const remaining = () => budgetMs - (now() - startedAt)
645
+ const entries = readEntries(dir, { log, now })
646
+ .filter((e) => e.key !== skipKey)
647
+ .sort((a, b) => (a.firstQueuedAt ?? 0) - (b.firstQueuedAt ?? 0))
648
+ let serverOk = serverHealthy !== false
649
+ let summarizerOk = summarizerHealthy !== false
650
+ let posts = 0
651
+ let summaries = 0
652
+ const ctx = () => ({ now: now(), random, log })
653
+
654
+ /** Act on a POST outcome. Runs under the lease, against the entry that was actually posted. */
655
+ const settle = (out, entry, who) => {
656
+ if (out.outcome === 'recorded' || out.outcome === 'final') {
657
+ raisePostedGen(dir, entry.key, entry.gen)
658
+ removeIfNotNewer(dir, entry.key, entry.gen)
659
+ if (out.outcome === 'recorded') {
660
+ result.recorded += 1
661
+ log(`queued ${who} recorded on attempt ${(entry.attempts ?? 0) + 1}: ${out.line}`)
662
+ } else {
663
+ result.final += 1
664
+ log(`queued ${who} ${out.line}`)
665
+ }
666
+ } else if (out.outcome === 'rejected') {
667
+ removeEntry(dir, entry.key, entry.rev)
668
+ recordDropped(dir, { sessionId: entry.sessionId, reason: out.reason }, now())
669
+ result.dropped += 1
670
+ log(`DROPPED queued ${who} โ€” ${out.line}`)
671
+ } else {
672
+ serverOk = false // the server just failed: leave everything else for a later run
673
+ const next = bumpEntry(dir, entry, { reason: out.reason, retryAfterMs: out.retryAfterMs }, ctx())
674
+ result.failed += 1
675
+ log(`queued ${who} still ${out.line} (attempt ${next?.attempts ?? '?'})`)
676
+ }
677
+ }
678
+
679
+ for (const listed of entries) {
680
+ if (remaining() < DRAIN_MIN_POST_MS) break
681
+ if (!serverOk || posts >= maxPosts) break // every remaining path needs a POST
682
+
683
+ const who = listed.sessionId ?? listed.key
684
+
685
+ if (listed.kind === 'summarize') {
686
+ if (!allowSummarize || !summarizerOk || summaries >= maxSummaries) continue
687
+ if (now() - (listed.updatedAt ?? 0) < SUMMARIZE_SETTLE_MS) continue // session likely still live
688
+ if (summarizerHealthy !== true && (listed.nextAttemptAt ?? 0) > now()) continue
689
+ // A summarize is the one step this loop cannot interrupt, so it is only STARTED when the budget
690
+ // can pay for it AND for the upload that follows. Otherwise it waits for the next run.
691
+ if (remaining() < DRAIN_MIN_SUMMARY_MS + DRAIN_POST_RESERVE_MS) {
692
+ result.deferred += 1
693
+ log(`queued ${who}: ${Math.round(remaining() / 1000)}s of drain budget left, not enough to summarize โ€” left for a later run`)
694
+ continue
695
+ }
696
+ // CLAIM it (under the lease) so a second drain does not summarize the same entry.
697
+ const claimed = await claimForSummary(dir, listed, { log, now })
698
+ if (!claimed) continue
699
+ summaries += 1
700
+ const timeoutMs = Math.max(0, remaining() - DRAIN_POST_RESERVE_MS)
701
+ const s = await summarize(claimed.transcript, { timeoutMs })
702
+ // The summary (if any) is built OUTSIDE the lease: obligation review can spawn `claude` too.
703
+ const body = s?.extracted
704
+ ? { ...(await buildBody(claimed.common, s.extracted, claimed.transcript)), occurredAt: new Date(claimed.capturedAt ?? now()).toISOString() }
705
+ : null
706
+ await withLease(dir, listed.key, { waitMs: Math.min(leaseWaitMs, Math.max(0, remaining())) }, async (held) => {
707
+ if (!held) { log(`queued ${who}: summarized, but this session's lease stayed busy โ€” left for a later run`); return }
708
+ // REVALIDATE against a fresh read: anything that touched this entry while we summarized wins.
709
+ const cur = readEntry(dir, listed.key)
710
+ if (!cur || cur.rev !== claimed.rev) {
711
+ result.superseded += 1
712
+ log(`queued ${who}: replaced by a newer capture while it was being summarized โ€” the stale digest was NOT posted`)
713
+ return
714
+ }
715
+ if (s?.unavailable) {
716
+ if (s.unavailable.reason === 'busy') {
717
+ writeEntry(dir, { ...cur, claim: undefined, rev: newRev() }) // release the claim, no attempt spent
718
+ log(`queued ${who}: summarizer busy, will retry`)
719
+ return
720
+ }
721
+ summarizerOk = false
722
+ const next = bumpEntry(dir, cur, { reason: s.unavailable.reason, detail: s.unavailable.detail }, ctx())
723
+ result.failed += 1
724
+ log(`queued ${who} still NOT RECORDED โ€” local summarization unavailable [${s.unavailable.reason}] (attempt ${next?.attempts ?? '?'})`)
725
+ return
726
+ }
727
+ if (s?.noop) {
728
+ removeEntry(dir, cur.key, cur.rev)
729
+ result.final += 1
730
+ log(`queued ${who} NOT RECORDED โ€” summarizer classified it a no-op session`)
731
+ return
732
+ }
733
+ if (supersededByPosted(dir, cur)) {
734
+ result.superseded += 1
735
+ log(`queued ${who}: a newer capture of this session was recorded while it was being summarized โ€” dropped without posting`)
736
+ return
737
+ }
738
+ result.summarized += 1
739
+ // Become an ingest entry BEFORE posting: the summary exists now, so a failed POST must never
740
+ // cost another `claude -p` run โ€” and the transcript tail is no longer needed, so it goes.
741
+ const converted = { ...cur, kind: 'ingest', body, transcript: undefined, common: undefined, claim: undefined, rev: newRev(), updatedAt: now() }
742
+ writeEntry(dir, converted)
743
+ if (remaining() < DRAIN_MIN_POST_MS) {
744
+ result.deferred += 1
745
+ log(`queued ${who}: summarized, but the drain budget is spent โ€” the upload is left for a later run`)
746
+ return
747
+ }
748
+ posts += 1
749
+ settle(await post(body, converted, { budgetMs: Math.min(INGEST_BUDGET_MS, remaining()) }), converted, who)
750
+ })
751
+ continue
752
+ }
753
+
754
+ if (listed.kind !== 'ingest') continue
755
+ if ((listed.nextAttemptAt ?? 0) > now()) continue
756
+ // try-only: a held lease means a live capture of this session is mid-upload. It will handle its
757
+ // own entry, and whatever it records is newer than what we hold.
758
+ await withLease(dir, listed.key, { waitMs: 0 }, async (held) => {
759
+ if (!held) return
760
+ const cur = readEntry(dir, listed.key)
761
+ if (!cur || cur.kind !== 'ingest' || (cur.nextAttemptAt ?? 0) > now()) return
762
+ if (claimIsLive(cur.claim)) return
763
+ if (supersededByPosted(dir, cur)) {
764
+ result.superseded += 1
765
+ log(`queued ${who}: a newer capture of this session is already recorded โ€” dropped without posting`)
766
+ return
767
+ }
768
+ if (remaining() < DRAIN_MIN_POST_MS) return
769
+ posts += 1
770
+ settle(await post(cur.body, cur, { budgetMs: Math.min(INGEST_BUDGET_MS, remaining()) }), cur, who)
771
+ })
772
+ }
773
+ } catch (e) {
774
+ log(`queue drain stopped: ${e instanceof Error ? e.message : String(e)}`)
775
+ }
776
+ return result
777
+ }
778
+
779
+ /**
780
+ * Take an entry for summarizing: under the lease, confirm it is still the one we listed, unclaimed, and
781
+ * not already superseded, then stamp our claim on it. Returns the claimed entry (whose `rev` the drain
782
+ * revalidates after summarizing) or null to skip it.
783
+ *
784
+ * Crash mid-claim recovers by itself: `claimIsLive` is false as soon as the claiming process is gone,
785
+ * and in any case after CLAIM_TTL_MS, so the entry becomes claimable again with nothing to clean up.
786
+ */
787
+ async function claimForSummary(dir, listed, { log, now }) {
788
+ return withLease(dir, listed.key, { waitMs: 0 }, async (held) => {
789
+ if (!held) return null
790
+ const cur = readEntry(dir, listed.key)
791
+ if (!cur || cur.kind !== 'summarize' || cur.rev !== listed.rev) return null
792
+ if (claimIsLive(cur.claim)) return null
793
+ if (supersededByPosted(dir, cur)) {
794
+ log(`queued ${cur.sessionId ?? cur.key}: a newer capture of this session is already recorded โ€” dropped without summarizing`)
795
+ return null
796
+ }
797
+ const claimed = { ...cur, claim: { pid: process.pid, at: Date.now() }, rev: newRev() }
798
+ return writeEntry(dir, claimed) ? claimed : null
799
+ })
800
+ }
801
+
802
+ // โ”€โ”€ what the next session start says โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
803
+
804
+ const SUMMARIZE_REASONS = {
805
+ 'auth-expired': 'its Claude login expired โ€” run `claude setup-token`',
806
+ 'no-claude': '`claude` is not on PATH for the capture hook',
807
+ timeout: 'summarizing timed out',
808
+ busy: 'another session was summarizing',
809
+ exit: 'the `claude` summarizer exited with an error',
810
+ unparseable: 'the summary came back unreadable',
811
+ 'no-output': 'the summarizer returned nothing',
812
+ disabled: 'CORTEX_SUMMARIZE_DISABLED is set',
813
+ 'spawn-failed': 'the summarizer could not start',
814
+ threw: 'the summarizer crashed',
815
+ 'no-result': 'the summarizer could not start',
816
+ }
817
+
818
+ /** The most common reason among `items`, as a human label, noting if others exist too. */
819
+ function dominantReason(items, labels = {}) {
820
+ const counts = new Map()
821
+ for (const it of items) counts.set(it.reason, (counts.get(it.reason) ?? 0) + 1)
822
+ const [top] = [...counts.entries()].sort((a, b) => b[1] - a[1])
823
+ const label = labels[top?.[0]] ?? top?.[0] ?? 'unknown'
824
+ return counts.size > 1 ? `${label}; and ${counts.size - 1} other reason${counts.size - 1 === 1 ? '' : 's'}` : label
825
+ }
826
+
827
+ /** Read-only: what the queue holds, for the session-start line and `doctor`. Never throws. */
828
+ export function queueStatus(dir = queueDir(), now = Date.now()) {
829
+ let entries = []
830
+ try {
831
+ entries = readdirSync(dir)
832
+ .filter((n) => n.endsWith('.json') && !n.startsWith('_'))
833
+ .map((n) => { try { return JSON.parse(readFileSync(join(dir, n), 'utf8')) } catch { return null } })
834
+ .filter((e) => e && e.key)
835
+ } catch { /* no queue yet */ }
836
+ const flagged = entries.filter(isFlagged)
837
+ return {
838
+ pending: entries.length,
839
+ flaggedSummarize: flagged.filter((e) => e.kind === 'summarize'),
840
+ flaggedIngest: flagged.filter((e) => e.kind === 'ingest'),
841
+ dropped: readDropped(dir, now),
842
+ }
843
+ }
844
+
845
+ /**
846
+ * PURE. The session-start lines โ€” empty when there is nothing to report. Only FLAGGED entries and
847
+ * DROPS are reported: an entry on its first or second try is a retry in flight, not news.
848
+ */
849
+ export function renderQueueNotice(status) {
850
+ const lines = []
851
+ const plural = (n, one, many) => (n === 1 ? one : many)
852
+ const s = status?.flaggedSummarize ?? []
853
+ if (s.length) {
854
+ lines.push(
855
+ `Agnoclast: โš  ${s.length} ${plural(s.length, 'session', 'sessions')} not captured yet: your AI couldn't summarize ` +
856
+ `${plural(s.length, 'it', 'them')} (${dominantReason(s, SUMMARIZE_REASONS)}). ` +
857
+ `${plural(s.length, "It'll", "They'll")} retry automatically.`,
858
+ )
859
+ }
860
+ const i = status?.flaggedIngest ?? []
861
+ if (i.length) {
862
+ lines.push(
863
+ `Agnoclast: โš  ${i.length} ${plural(i.length, 'session', 'sessions')} not captured yet: the upload to Agnoclast kept failing ` +
864
+ `(${dominantReason(i)}). ${plural(i.length, "It'll", "They'll")} retry automatically.`,
865
+ )
866
+ }
867
+ const d = status?.dropped ?? []
868
+ if (d.length) {
869
+ lines.push(
870
+ `Agnoclast: โš  ${d.length} session ${plural(d.length, 'capture', 'captures')} could not be saved and ` +
871
+ `${plural(d.length, 'was', 'were')} dropped in the last ${DROPPED_WINDOW_DAYS} days (${dominantReason(d)}). ` +
872
+ `Details: ~/.cortex/capture.log`,
873
+ )
874
+ }
875
+ return lines
876
+ }