@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 +235 -135
- package/lib/capture_queue.mjs +876 -0
- package/lib/diagnose.mjs +176 -32
- package/lib/doctor.mjs +32 -4
- package/lib/edge_extract.mjs +95 -27
- package/lib/hydrate.mjs +40 -13
- package/lib/propose_obligation.mjs +99 -0
- package/lib/redact.mjs +2 -1
- package/lib/server.mjs +54 -4
- package/package.json +1 -1
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,
|
|
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 →
|
|
39
|
-
//
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
//
|
|
333
|
-
//
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
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) {
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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 RECORDED — local 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
|
-
//
|
|
511
|
-
//
|
|
512
|
-
|
|
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
|
-
}
|
|
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
|
}
|