@theronap/agnoclast-mcp 0.9.148 → 0.9.150

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/lib/capture.mjs CHANGED
@@ -4,10 +4,14 @@ import { homedir } from 'os'
4
4
  import { resolve, dirname, join } from 'path'
5
5
  import { fileURLToPath } from 'url'
6
6
  import { createHash } from 'crypto'
7
- import { fetchCortex, classify, resolveBase, resolveTokenSource } from './diagnose.mjs'
8
- import { extractSession } from './edge_extract.mjs'
7
+ import { fetchCortex, resolveBase, resolveTokenSource } from './diagnose.mjs'
8
+ import { extractSession, lastEdgeSkip } from './edge_extract.mjs'
9
9
  import { extractTyped } from './extract_typed.mjs'
10
10
  import { redactSecrets } from './redact.mjs'
11
+ import {
12
+ queueDir, entryKey, enqueueCapture, removeIfNotNewer, recordDropped, isFlagged, postIngest, drainCaptureQueue,
13
+ makeGen, cmpGen, genPayload, withLease, readPostedGen, raisePostedGen, LEASE_WAIT_MS,
14
+ } from './capture_queue.mjs'
11
15
 
12
16
  // Fetch the node-type registry (global + this org's custom types) — the catalog the typed extractor
13
17
  // produces against. Best-effort: null on any failure (typed extraction is then skipped, never blocks capture).
@@ -35,8 +39,8 @@ export function projectFrom(cwd) {
35
39
  return base ?? 'general'
36
40
  }
37
41
 
38
- // Claude Code Stop hook → POSTs a session digest to Agnoclast cloud, which
39
- // summarizes server-side and upserts ONE record per session. Node-native
42
+ // Claude Code Stop hook → summarizes the session LOCALLY and POSTs only the digest to Agnoclast cloud,
43
+ // which upserts ONE record per session; what cannot land waits in ~/.cortex/capture-queue. Node-native
40
44
  // SHR-01/T6 — parse a git remote URL into GitHub 'owner/name', or null.
41
45
  //
42
46
  // Handles the four remote forms git emits: scp-like ssh (git@github.com:o/n.git), ssh://, https://,
@@ -190,10 +194,10 @@ export function transcriptReadable(path) {
190
194
  try { accessSync(path, constants.R_OK); return true } catch { return false }
191
195
  }
192
196
 
193
- function transcriptTail(path) {
194
- let raw = ''
195
- try { raw = readFileSync(path, 'utf8') } catch { return '' }
196
- const jsonLines = raw.split('\n').filter(Boolean)
197
+ // Takes the transcript's RAW text (read once by the caller, whose character count is also this
198
+ // capture's generation — see captureTurn), not a path.
199
+ function transcriptTail(raw) {
200
+ const jsonLines = String(raw ?? '').split('\n').filter(Boolean)
197
201
  const textOf = (line) => {
198
202
  try {
199
203
  const obj = JSON.parse(line)
@@ -328,12 +332,115 @@ async function captureWork(stdinRaw) {
328
332
  })
329
333
  } catch { /* a parked retry must never cost a session its capture */ }
330
334
 
335
+ const dir = queueDir()
336
+ const signals = await captureTurn(hook, {
337
+ base, token, dir,
338
+ log: (line) => process.stderr.write(`cortex: ${stamp} ${sid} ${line}\n`),
339
+ })
340
+
341
+ // Retry earlier captures that did not land (capture_queue.mjs). ONLY as the detached worker, or when
342
+ // a person forced sync mode to watch it: the in-band fallback (spawn failed) holds the user's input
343
+ // box, and a queued `claude -p` retry can take minutes. The next detached run will drain instead.
344
+ if (process.env.CORTEX_CAPTURE_DETACHED || process.env.CORTEX_CAPTURE_SYNC) {
345
+ const qlog = (line) => process.stderr.write(`cortex: ${stamp} queue ${line}\n`)
346
+ await drainCaptureQueue({
347
+ dir,
348
+ log: qlog,
349
+ post: (body, _entry, opts) => postIngest({ base, token, body, repo: body?.project ?? 'general', budgetMs: opts?.budgetMs }),
350
+ summarize: (text, opts) => summarizeTurn(text, { timeoutMs: opts?.timeoutMs }),
351
+ buildBody: (common, extracted, text) => buildIngestBody(common, extracted, text, { log: qlog }),
352
+ skipKey: signals.key,
353
+ serverHealthy: signals.serverHealthy,
354
+ summarizerHealthy: signals.summarizerHealthy,
355
+ allowSummarize: true,
356
+ })
357
+ }
358
+ }
359
+
360
+ // Why the summarizer returned nothing when edge_extract recorded no reason — only one way that happens.
361
+ const noReason = () => (process.env.CORTEX_SUMMARIZE_DISABLED
362
+ ? { reason: 'disabled', detail: 'CORTEX_SUMMARIZE_DISABLED is set, so sessions are not summarized on this machine' }
363
+ : { reason: 'unknown', detail: 'the summarizer returned nothing and recorded no reason' })
364
+
365
+ /**
366
+ * Summarize one transcript tail locally. → { extracted } | { noop: true } | { unavailable: {reason, detail} }.
367
+ * Shared by the live capture and the queue drain so both read the same outcome the same way.
368
+ */
369
+ export function summarizeTurn(transcript, { extract = extractSession, lastSkip = lastEdgeSkip, timeoutMs } = {}) {
370
+ // `timeoutMs` is the CALLER's remaining time (the queue drain's budget). Without it the summarizer
371
+ // uses its own 300 s ceiling, which is what let a drain block for five times its stated cap.
372
+ const extracted = extract(transcript, timeoutMs === undefined ? undefined : { timeoutMs })
373
+ if (!extracted) return { unavailable: lastSkip() ?? noReason() }
374
+ if (/^NOOP\b/i.test(extracted.summary)) return { noop: true }
375
+ return { extracted }
376
+ }
377
+
378
+ /**
379
+ * The ingest body for a summarized turn — the DERIVED digest only. There is no `transcript` field and
380
+ * there is no longer any code path that builds one (D8); postIngest strips it as a backstop regardless.
381
+ */
382
+ export async function buildIngestBody(common, extracted, transcript, { log = () => {} } = {}) {
383
+ // ADR-0059 §5.3 — review obligation candidates before they leave the machine: drop what nobody owes,
384
+ // merge a deadline stated twice, reclassify whose it is. Never adds; fails open (obligation_review.mjs).
385
+ // Only when there are candidates, which most sessions never produce.
386
+ let obligations = extracted?.obligations ?? []
387
+ // The quotes the review retired (merged into a later statement, or not owed). The ONLY thing that lets
388
+ // the server delete an earlier read's proposal; a read that is merely silent about one deletes nothing.
389
+ let retiredObligations = []
390
+ if (obligations.length) {
391
+ const { reviewObligations, describeReview, retiredEvidence } = await import('./obligation_review.mjs')
392
+ const rev = reviewObligations(obligations, transcript)
393
+ log(`obligations ${describeReview(obligations.length, rev)}`)
394
+ retiredObligations = retiredEvidence(obligations, rev)
395
+ obligations = rev.obligations
396
+ }
397
+ // `pages` is the edge's PROPOSAL of where this record belongs (ADR-0055). Free-text names, resolved
398
+ // against real pages server-side and dropped when they match nothing — the same untrusted-edge
399
+ // contract people/entities use. Omitted entirely when empty so an older server sees no new field.
400
+ return {
401
+ ...common,
402
+ summary: extracted.summary,
403
+ people: extracted.people,
404
+ entities: extracted.namedEntities,
405
+ ...(extracted.pages?.length ? { pages: extracted.pages } : {}),
406
+ // ADR-0059. Obligation candidates the extraction found AND verified verbatim against the
407
+ // transcript it was shown — the server cannot re-check the quote (it never receives the
408
+ // transcript), so these arrive already filtered. Omitted when empty, same as `pages`.
409
+ ...(obligations.length ? { obligations } : {}),
410
+ // ADR-0059 §5.4: quotes the reviewer retired. Omitted when empty, so an older server sees nothing new.
411
+ ...(retiredObligations.length ? { retiredObligations } : {}),
412
+ }
413
+ }
414
+
415
+ /**
416
+ * Capture ONE turn: summarize locally, post the digest, and queue whatever did not land. Returns the
417
+ * health signals the queue drain uses. Dependencies are injectable so every branch is testable without
418
+ * spawning `claude`, touching the network, or writing under the real home directory.
419
+ *
420
+ * deps: { base, token, dir, log, fetchImpl?, extract?, lastSkip?, now?, random?, leaseWaitMs? }
421
+ */
422
+ export async function captureTurn(hook, deps) {
423
+ const {
424
+ base, token, dir, log = () => {}, fetchImpl = fetchCortex,
425
+ now = Date.now, random = Math.random, leaseWaitMs = LEASE_WAIT_MS,
426
+ } = deps
427
+ const key = hook.session_id ? entryKey(hook.session_id) : null
428
+ const signals = { key, summarizerHealthy: null, serverHealthy: null }
429
+ const capturedAt = now()
430
+
331
431
  const repo = projectFrom(hook.cwd)
332
- // Redact credential-shaped strings BEFORE anything leaves the machine this `transcript`
333
- // feeds local edge/typed extraction AND the fallback POST to the org. A secret in a
334
- // transcript (pasted key, a tool that read a config file, a token inlined in a hook cmd)
335
- // must never be transmitted or stored. See redact.mjs.
336
- const transcript = hook.transcript_path ? redactSecrets(transcriptTail(hook.transcript_path)) : ''
432
+ // Read the transcript ONCE: its character count is this capture's GENERATION (capture_queue.mjs), and
433
+ // reading it twice could straddle an append and order this turn against a file it never saw.
434
+ let raw = ''
435
+ if (hook.transcript_path) {
436
+ try { raw = readFileSync(hook.transcript_path, 'utf8') } catch { raw = '' }
437
+ }
438
+ const gen = makeGen(raw.length, capturedAt)
439
+ // Redact credential-shaped strings BEFORE anything else touches it. This `transcript` feeds local
440
+ // edge/typed extraction and, when that is unavailable, the LOCAL queue (mode 0600) — it is never
441
+ // posted (D8). A secret in a transcript (pasted key, a tool that read a config file, a token inlined
442
+ // in a hook cmd) must not be stored in plain text either. See redact.mjs.
443
+ const transcript = raw ? redactSecrets(transcriptTail(raw)) : ''
337
444
 
338
445
  // A transcript_path that was SUPPLIED but is unreadable means the session ran with transcript writes
339
446
  // disabled — `claude --print --no-session-persistence`, or the SDK's `persistSession:false`. Verified
@@ -351,11 +458,11 @@ async function captureWork(stdinRaw) {
351
458
  // unreadable for a moment is picked up by the next turn, so a transient miss self-heals; only a
352
459
  // genuinely persistence-disabled session is dropped, and that session has no content to capture.
353
460
  if (hook.transcript_path && !transcriptReadable(hook.transcript_path)) {
354
- process.stderr.write(`cortex: ${stamp} ${sid} NOT RECORDED — transcript_path unreadable (session persistence disabled?)\n`)
355
- return
461
+ log('NOT RECORDED — transcript_path unreadable (session persistence disabled?)')
462
+ return signals
356
463
  }
357
464
 
358
- if (!transcript && !hook.session_id) { process.stderr.write(`cortex: ${stamp} ${sid} NOT RECORDED — empty session\n`); return }
465
+ if (!transcript && !hook.session_id) { log('NOT RECORDED — empty session'); return signals }
359
466
 
360
467
  // T11: digest node refs surfaced to this session (stashed by the MCP server's my_context, keyed by
361
468
  // cwd). Forwarded as hydratedFrom so the materializer excludes this session from the digests it
@@ -367,11 +474,6 @@ async function captureWork(stdinRaw) {
367
474
  if (Array.isArray(parsed.refs) && Date.now() - (parsed.ts ?? 0) < 12 * 3600 * 1000) hydratedFrom = parsed.refs
368
475
  } catch { /* none — guard stays a no-op */ }
369
476
 
370
- // Extract LOCALLY on the subscription (claude -p): summary + people + non-person entities. The
371
- // cloud then receives only the derived digest, never the raw transcript or the metered API
372
- // summarizer (which has been the silent point of failure). Fall back to shipping the transcript
373
- // tail only if local extraction is unavailable (e.g. `claude` not on PATH) so we never drop a
374
- // session. The server re-validates people/entities — the edge is not trusted.
375
477
  // SHR-01/T6: the repo identifier is what lets a PEER read this session (see 0096). Omitted entirely
376
478
  // when the cwd is not a GitHub worktree — the record still lands, it just stays private (D2).
377
479
  const repoFullName = repoFullNameFrom(hook.cwd)
@@ -382,9 +484,7 @@ async function captureWork(stdinRaw) {
382
484
  // this raw read is transmitted — only the derived 'owner/name' strings leave the machine.
383
485
  let repoFullNames = repoFullName ? [repoFullName] : []
384
486
  try {
385
- if (hook.transcript_path) {
386
- repoFullNames = repoFullNamesFrom(readFileSync(hook.transcript_path, 'utf8'), hook.cwd)
387
- }
487
+ if (raw) repoFullNames = repoFullNamesFrom(raw, hook.cwd)
388
488
  } catch { /* best-effort — a session with no derivable repo is private, not broken */ }
389
489
 
390
490
  const common = {
@@ -392,131 +492,131 @@ async function captureWork(stdinRaw) {
392
492
  project: repo,
393
493
  sessionId: hook.session_id,
394
494
  title: `Worked in ${repo}`,
395
- payload: { session_id: hook.session_id, cwd: hook.cwd },
495
+ // capture_generation travels with the record so a later SERVER change can refuse a capture older
496
+ // than the one it already holds for this session (noted as a follow-up; no server change here).
497
+ payload: { session_id: hook.session_id, cwd: hook.cwd, capture_generation: genPayload(gen) },
396
498
  captureSource: 'hook', // T8: fallback writer — never clobbers a cortex-log ('skill') record
397
499
  ...(repoFullName ? { repoFullName } : {}), // back-compat: older servers read only this
398
500
  ...(repoFullNames.length ? { repoFullNames } : {}),
399
501
  ...(hydratedFrom.length ? { hydratedFrom } : {}),
400
502
  }
401
- const extracted = transcript ? extractSession(transcript) : null
402
- if (extracted && /^NOOP\b/i.test(extracted.summary)) { process.stderr.write(`cortex: ${stamp} ${sid} NOT RECORDED — summarizer classified it a no-op session\n`); return }
403
- // ADR-0059 §5.3 — review obligation candidates before they leave the machine: drop what nobody owes,
404
- // merge a deadline stated twice, reclassify whose it is. Never adds; fails open (obligation_review.mjs).
405
- // Only when there are candidates, which most sessions never produce.
406
- let obligations = extracted?.obligations ?? []
407
- // The quotes the review retired (merged into a later statement, or not owed). The ONLY thing that lets
408
- // the server delete an earlier read's proposal; a read that is merely silent about one deletes nothing.
409
- let retiredObligations = []
410
- if (obligations.length) {
411
- const { reviewObligations, describeReview, retiredEvidence } = await import('./obligation_review.mjs')
412
- const rev = reviewObligations(obligations, transcript)
413
- process.stderr.write(`cortex: ${stamp} ${sid} obligations ${describeReview(obligations.length, rev)}\n`)
414
- retiredObligations = retiredEvidence(obligations, rev)
415
- obligations = rev.obligations
416
- }
417
- // `pages` is the edge's PROPOSAL of where this record belongs (ADR-0055). Free-text names, resolved
418
- // against real pages server-side and dropped when they match nothing — the same untrusted-edge
419
- // contract people/entities use. Omitted entirely when empty so an older server sees no new field.
420
- const ingestBody = extracted
421
- ? {
422
- ...common,
423
- summary: extracted.summary,
424
- people: extracted.people,
425
- entities: extracted.namedEntities,
426
- ...(extracted.pages?.length ? { pages: extracted.pages } : {}),
427
- // ADR-0059. Obligation candidates the extraction found AND verified verbatim against the
428
- // transcript it was shown — the server cannot re-check the quote (it never receives the
429
- // transcript), so these arrive already filtered. Omitted when empty, same as `pages`.
430
- ...(obligations.length ? { obligations } : {}),
431
- // ADR-0059 §5.4: quotes the reviewer retired. Omitted when empty, so an older server sees nothing new.
432
- ...(retiredObligations.length ? { retiredObligations } : {}),
433
- }
434
- : { ...common, transcript }
435
503
 
436
- // Parallel typed extraction (OPT-IN via CORTEX_TYPED): registry-driven typed notes, sent ALONGSIDE the
437
- // people/entities above. The server's typedNotes receiver persists them additively. Off by default so it
438
- // never adds a 2nd `claude` call / latency until verified; flip to default once typed ≥ the blob path.
439
- if (process.env.CORTEX_TYPED && transcript) {
440
- try {
441
- const registry = await fetchRegistry(base, token)
442
- if (registry?.length) {
443
- const typed = extractTyped(transcript, registry)
444
- if (typed?.notes?.length) ingestBody.typedNotes = typed.notes
504
+ // Extract LOCALLY on the person's own subscription (claude -p): summary + people + entities + page
505
+ // proposals + obligations. The cloud receives only that derived digest, and re-validates
506
+ // people/entities the edge is not trusted.
507
+ //
508
+ // 🔴 D8 (eng review 2026-09-11): THERE IS NO SERVER FALLBACK ANY MORE. When local extraction was
509
+ // unavailable this used to post `{ ...common, transcript }` and the server summarized the raw tail
510
+ // with Agnoclast's own Anthropic key — so "the transcript never leaves your machine" was false exactly
511
+ // when the person's AI was broken, which is when nobody was looking. Now the turn is QUEUED ON THIS
512
+ // MACHINE (capture_queue.mjs), summarization is retried by later runs, and after FLAG_AFTER_ATTEMPTS
513
+ // failures the next session start says so. Nothing is dropped and nothing is sent.
514
+ // Summarizing and body-building both run OUTSIDE the session lease taken below: each can spawn
515
+ // `claude` and take minutes, and a lease held that long would serialize whole sessions.
516
+ let ingestBody
517
+ let unavailable = null
518
+ if (transcript) {
519
+ const s = summarizeTurn(transcript, { extract: deps.extract, lastSkip: deps.lastSkip })
520
+ if (s.unavailable) {
521
+ unavailable = s.unavailable
522
+ // A BUSY lock (another session summarizing) resolves itself and says nothing about this machine's
523
+ // summarizer, so it neither counts toward the flag nor marks the summarizer unhealthy.
524
+ signals.summarizerHealthy = s.unavailable.reason === 'busy' ? null : false
525
+ } else {
526
+ signals.summarizerHealthy = true
527
+ if (s.noop) { log('NOT RECORDED — summarizer classified it a no-op session'); return signals }
528
+ ingestBody = await buildIngestBody(common, s.extracted, transcript, { log })
529
+
530
+ // Parallel typed extraction (OPT-IN via CORTEX_TYPED): registry-driven typed notes, sent ALONGSIDE the
531
+ // people/entities above. The server's typedNotes receiver persists them additively. Off by default so it
532
+ // never adds a 2nd `claude` call / latency until verified; flip to default once typed ≥ the blob path.
533
+ if (process.env.CORTEX_TYPED) {
534
+ try {
535
+ const registry = await fetchRegistry(base, token)
536
+ if (registry?.length) {
537
+ const typed = extractTyped(transcript, registry)
538
+ if (typed?.notes?.length) ingestBody.typedNotes = typed.notes
539
+ }
540
+ } catch { /* best-effort — never block capture */ }
445
541
  }
446
- } catch { /* best-effort — never block capture */ }
542
+ }
543
+ } else {
544
+ // A readable transcript whose tail holds no prose (e.g. the last 60 lines are all tool calls).
545
+ // Nothing to summarize here — or on the server, where the old `transcript: ''` was falsy too — so
546
+ // this posts exactly what the server received before D8, minus the empty field.
547
+ ingestBody = { ...common }
447
548
  }
448
549
 
449
- let res
450
- try {
451
- // Best-effort background ingest: bound it tight and retry once so a hanging/504-ing server
452
- // makes a DETACHED worker self-terminate in ~20s instead of lingering on 3 unbounded attempts.
453
- res = await fetchCortex(`${base}/api/ingest`, {
454
- method: 'POST',
455
- headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
456
- body: JSON.stringify(ingestBody),
457
- timeoutMs: 10_000,
458
- }, { retries: 1 })
459
- } catch (e) {
460
- // Never break a session just report and move on. This is where a 10s timeout against a slow
461
- // /api/ingest lands, and with the old discarded stdio it was completely invisible: the session
462
- // simply never appeared and nothing anywhere said why. The endpoint is measurably flaky — a
463
- // SessionStart context fetch timed out at 09:54 and again at 11:59 on 2026-08-19 while direct
464
- // curls answered in ~150ms, so intermittent timeouts here are a live hypothesis for the
465
- // 2026-08-13 collapse, not a theoretical one.
466
- process.stderr.write(`cortex: ${stamp} ${sid} NOT RECORDED — ${e.message}\n`)
467
- return
468
- }
550
+ // ── this session's critical section ─────────────────────────────────────────────────────────────
551
+ // Everything left either uploads this session or writes its queue entry, and a sibling worker for the
552
+ // SAME session may be doing the same thing right now (the Stop hook fires per turn, and extraction
553
+ // above takes tens of seconds, so overlap is normal). Under the lease, a turn can ask "has a newer
554
+ // generation already been recorded?" and act on the answer without another poster slipping in between.
555
+ await withLease(dir, key ?? 'nosession', { waitMs: leaseWaitMs }, async (held) => {
556
+ if (key && !held) log(`the session lease stayed busy for ${Math.round(leaseWaitMs / 1000)}s — proceeding, so ordering is best-effort for this turn`)
557
+
558
+ // A newer turn of this session already recorded means this older one has nothing to add: its
559
+ // transcript is a prefix of what that turn summarized.
560
+ const posted = key ? readPostedGen(dir, key) : null
561
+ if (posted && cmpGen(posted, gen) > 0) {
562
+ log(`NOT POSTED a newer capture of this session is already recorded (${posted.chars} chars vs this turn's ${gen.chars})`)
563
+ return
564
+ }
469
565
 
470
- // A 2xx FROM /api/ingest DOES NOT MEAN A RECORD EXISTS. The route answers `ok: true` on at least
471
- // six outcomes and only one of them writes a session record:
472
- //
473
- // { ok: true, id, inserted, title } -> RECORDED (the only success)
474
- // { ok: true, staged: true, id, reason } -> held in staged_records, NOT recorded
475
- // { ok: true, skipped: '<why>' } -> no-op session / past the ingest horizon
476
- // { ok: false, skipped: '<why>' } -> connector excluded from this brain
477
- // { ok: true, discarded: true } -> tombstoned by private intake
478
- // { ok: true, queued: true } -> accepted for later work
479
- // { ok: true, via: 'private_intake', intakeItemId } -> an intake unit, not a session record
480
- //
481
- // The old line read `j.inserted ? 'captured' : 'updated'`, so EVERY one of the six non-writing
482
- // outcomes printed "updated" — the word for a successful upsert. Worse, `.json().catch(() => ({}))`
483
- // means an unparseable body also yields `{}` and therefore also printed "updated". Verified against
484
- // prod 2026-08-19: a capture printed `cortex: updated "general" → general` for a session that has no
485
- // row in `records` and none in `staged_records` either.
486
- //
487
- // NOTE `staged` CARRIES AN `id`, so testing for an id alone is not enough — that id is the
488
- // staged_records row, not a record. This is the same false-success defect already fixed once in
489
- // log_session ("`inserted` is merely falsy when nothing is recorded an agent reported a session as
490
- // saved when it was not"); the fix was applied there and not here. The server route already knew:
491
- // its own comment at the no_route_for_source branch says "`{ok: true}` reads as success to
492
- // everything that is not looking closely" and notes 84 rows accumulating behind that wording.
493
- if (res.ok || res.status === 200) {
494
- const raw = await res.text()
495
- let j
496
- try { j = JSON.parse(raw) } catch { j = null }
497
- if (j && j.id && !j.staged) {
498
- process.stderr.write(`cortex: ${stamp} ${sid} ${j.inserted ? 'captured' : 'updated'} "${j.title ?? repo}" → ${repo}\n`)
499
- } else if (j && j.staged) {
500
- process.stderr.write(`cortex: ${stamp} ${sid} NOT RECORDED staged (${j.reason ?? 'no reason given'}); staged session logs are not drainable by /api/staged/promote\n`)
501
- } else if (j && j.skipped) {
502
- process.stderr.write(`cortex: ${stamp} ${sid} NOT RECORDED server skipped: ${j.skipped}\n`)
503
- } else if (j && j.discarded) {
504
- process.stderr.write(`cortex: ${stamp} ${sid} NOT RECORDED — discarded by private intake\n`)
505
- } else if (j && j.queued) {
506
- process.stderr.write(`cortex: ${stamp} ${sid} NOT RECORDED queued for later processing\n`)
507
- } else if (j && j.intakeItemId) {
508
- process.stderr.write(`cortex: ${stamp} ${sid} not a session record — filed as private intake unit ${j.intakeItemId}\n`)
566
+ if (unavailable) {
567
+ const busy = unavailable.reason === 'busy'
568
+ const entry = enqueueCapture(dir, {
569
+ sessionId: hook.session_id, kind: 'summarize', gen,
570
+ reason: unavailable.reason, detail: unavailable.detail,
571
+ common, transcript, countsAsFailure: !busy,
572
+ }, { now: now(), random, log })
573
+ log(entry?.superseded
574
+ ? `NOT RECORDED YET — local summarization unavailable [${unavailable.reason}]; a newer turn of this session is already queued. The transcript was not sent.`
575
+ : entry
576
+ ? `NOT RECORDED YET — local summarization unavailable [${unavailable.reason}]; queued on this machine ` +
577
+ `(attempt ${entry.attempts}${isFlagged(entry) ? ', FLAGGED' : ''}). The transcript was not sent.`
578
+ : `NOT RECORDEDlocal summarization unavailable [${unavailable.reason}] and the turn could not be queued. The transcript was not sent.`)
579
+ return
580
+ }
581
+
582
+ // A 2xx FROM /api/ingest DOES NOT MEAN A RECORD EXISTS — the full table of what each answer means,
583
+ // and the prod incident behind it, lives with the classifier in capture_queue.mjs.
584
+ const out = await postIngest({ base, token, body: ingestBody, repo, fetchImpl })
585
+ if (out.outcome === 'recorded' || out.outcome === 'final') {
586
+ signals.serverHealthy = true
587
+ if (key) {
588
+ raisePostedGen(dir, key, gen)
589
+ // Compare-and-delete, NOT a blind remove: a queued entry for this session may be a NEWER turn
590
+ // that failed while this older one was in flight, and deleting it was how that turn was lost.
591
+ removeIfNotNewer(dir, key, gen)
592
+ }
593
+ log(out.line)
594
+ } else if (out.outcome === 'retry') {
595
+ // Never break a session — and never lose it either. A 10s timeout against a slow /api/ingest used to
596
+ // land here and simply vanish (the endpoint is measurably flaky: a SessionStart context fetch timed
597
+ // out at 09:54 and 11:59 on 2026-08-19 while direct curls answered in ~150ms). Now it waits locally.
598
+ // occurredAt pins the record to when the work happened, not to whenever the replay succeeds.
599
+ signals.serverHealthy = false
600
+ const entry = enqueueCapture(dir, {
601
+ sessionId: hook.session_id, kind: 'ingest', gen, reason: out.reason,
602
+ body: { ...ingestBody, occurredAt: new Date(capturedAt).toISOString() },
603
+ retryAfterMs: out.retryAfterMs,
604
+ }, { now: now(), random, log })
605
+ log(`${out.line}; ${entry?.superseded
606
+ ? 'a newer turn of this session is already queued'
607
+ : entry
608
+ ? `queued on this machine (attempt ${entry.attempts}${isFlagged(entry) ? ', FLAGGED' : ''})`
609
+ : 'and it could not be queued'}`)
509
610
  } else {
510
- // Unparseable or unrecognised 2xx. Deliberately NOT reported as success: an unknown shape is
511
- // exactly the case the old code laundered into "updated".
512
- process.stderr.write(`cortex: ${stamp} ${sid} NOT RECORDED unrecognised 2xx response: ${raw.slice(0, 200)}\n`)
611
+ // A verdict a replay cannot change. Reported through the drop ledger, so the next session start
612
+ // says so instead of only this log. An older queued turn of the session is KEPT: it may be valid.
613
+ recordDropped(dir, { sessionId: hook.session_id ?? null, reason: out.reason }, now())
614
+ log(`${out.line} — not retryable; reported at the next session start`)
513
615
  }
514
- } else {
515
- const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
516
- process.stderr.write(`cortex: ${stamp} ${sid} NOT RECORDED — ingest failed: ${d.message}\n`)
517
- }
616
+ })
518
617
 
519
618
  // The post-capture edge-materialize batch (CORTEX_MATERIALIZE → runMaterialize) was EXCISED
520
619
  // 2026-07-02 with the legacy materializer: its server pipeline deleted live-authored pages whose
521
620
  // record-hashes drifted. Pages come from live authoring (the `author` tool + /log sweep) now.
621
+ return signals
522
622
  }