@theronap/cortex-mcp 0.9.149 → 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/diagnose.mjs CHANGED
@@ -242,16 +242,6 @@ export function classify(status, contentType, bodyText, requestId) {
242
242
  }
243
243
  }
244
244
 
245
- // fetch with retry on TRANSIENT responses only (429, 5xx, and infra-style non-JSON 403).
246
- // App-level 401/403-with-JSON are returned immediately (retrying won't change the verdict).
247
- // Throws a clear network error if the host is unreachable after retries.
248
- //
249
- // PER-ATTEMPT TIMEOUT (added 2026-07-08): every attempt is bounded by an AbortSignal so a
250
- // HANGING server (not a fast 5xx — an ingest endpoint that just never responds, observed this day)
251
- // can't stall a hook indefinitely. Without it, `fetch` waits forever and the retry loop made it
252
- // WORSE — 3 unbounded attempts stacked. Now worst case = (retries+1) * timeoutMs + backoff, and a
253
- // timed-out attempt is treated as transient (retried, then surfaced as the reach error the callers
254
- // already swallow). Tunable via CORTEX_HTTP_TIMEOUT_MS; per-call override via opts.timeoutMs.
255
245
  // ADR-0020 Stage 2 — this process's session identity, stamped on every request so the server can
256
246
  // resolve THIS session's write pointer instead of the person-wide one. Set once by the MCP server at
257
247
  // startup (setSessionKey); left null in the one-shot hook processes (capture, context_log), which
@@ -280,39 +270,193 @@ export function setClientVersion(version) {
280
270
  CLIENT_ID = `cortex-mcp/${version || 'unknown'}`
281
271
  }
282
272
 
283
- export async function fetchCortex(url, opts = {}, { retries = 2, baseDelayMs = 400 } = {}) {
273
+ // ── Retries (D9, eng review 2026-09-11) ──────────────────────────────────────────────────────────
274
+ //
275
+ // 🔴 WHY THIS WAS REWRITTEN. The old loop retried 3× at a fixed 400/800 ms on ANY 429 or 5xx, POSTs
276
+ // included, with no jitter and no regard for Retry-After. Once the server started answering 429, every
277
+ // client retried immediately and in lockstep — tripling the load on a server that had just said it was
278
+ // overloaded — and after an outage the synchronized reconnects knocked it over again. A retry policy is
279
+ // load the CLIENT decides to add, so it has to be decided per failure, not per status range.
280
+ //
281
+ // What a failure MEANS decides whether it may be retried:
282
+ //
283
+ // refused 429, 503, or a non-JSON 403 (an infrastructure block in front of the app). The request
284
+ // was NOT processed, so retrying is safe for any method. Retry-After is honored.
285
+ // connect the connection was never made (refused, DNS). Nothing reached the server: safe for any
286
+ // method, and it costs the server nothing.
287
+ // server any other 5xx. The request MAY have been processed. Retried only when idempotent.
288
+ // network the connection broke mid-flight. Same ambiguity as `server`: idempotent only.
289
+ // timeout our per-attempt timer fired — the server is SLOW, not absent. Idempotent only, and at
290
+ // most ONCE per call, whatever `retries` says: re-asking a server that is too slow to answer
291
+ // is how a slow server becomes a down one. Callers can refuse even that one (hydrate does).
292
+ //
293
+ // "Idempotent" defaults to GET/HEAD. A POST the server dedupes may opt in (capture's /api/ingest does;
294
+ // its conclusion is written where it opts in, in capture.mjs).
295
+ //
296
+ // Waits: Retry-After when the server sends it (delay-seconds or HTTP-date), plus a small spread so a
297
+ // fleet told "10 s" does not come back in the same millisecond. A Retry-After beyond RETRY_AFTER_CAP_MS
298
+ // is NOT shortened — retrying before the server asked defeats the limit — the call simply stops and
299
+ // returns that response. Otherwise exponential backoff with FULL jitter (uniform in [0, ceiling)).
300
+ //
301
+ // And one overall budget per call, so a hook can never hang: no retry starts if its wait plus a minimum
302
+ // useful attempt would overrun it, and every attempt's timeout is clipped to what is left. The first
303
+ // attempt always gets its full timeout, so a caller's CORTEX_HTTP_TIMEOUT_MS is never cut short.
304
+ //
305
+ // PER-ATTEMPT TIMEOUT (added 2026-07-08, kept): every attempt is bounded by an AbortSignal so a
306
+ // HANGING server can't stall a hook indefinitely. Tunable via CORTEX_HTTP_TIMEOUT_MS; per-call override
307
+ // via opts.timeoutMs. A caller's own signal aborting is never retried — they asked to stop.
308
+
309
+ /** The longest Retry-After this client will wait out in-process. Longer asks are honored by NOT
310
+ * retrying (the capture queue then schedules its next attempt no earlier than the ask). */
311
+ export const RETRY_AFTER_CAP_MS = 30_000
312
+ /** Added on top of a Retry-After so clients told the same delay do not return in lockstep. */
313
+ export const RETRY_AFTER_SPREAD_MS = 1_000
314
+ /** Beyond the first attempt's own timeout, how long retries may add (default overall budget). */
315
+ export const DEFAULT_RETRY_BUDGET_MS = 30_000
316
+
317
+ const CONNECT_ERROR_CODES = new Set([
318
+ // node / undici — carried on err.cause.code (an AggregateError carries it too, when happy-eyeballs
319
+ // tried both address families; verified 2026-09-11 against a closed port on node 24)
320
+ 'ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN', 'EHOSTUNREACH', 'ENETUNREACH', 'ENETDOWN', 'EHOSTDOWN',
321
+ 'UND_ERR_CONNECT_TIMEOUT',
322
+ // bun — carried on err.code; bun reports a DNS failure as ConnectionRefused too (verified, bun 1.3)
323
+ 'ConnectionRefused', 'FailedToOpenSocket',
324
+ ])
325
+
326
+ /**
327
+ * PURE. A Retry-After header value → milliseconds to wait, or null when absent or unparseable.
328
+ * Accepts both RFC 9110 forms: delay-seconds ("120") and an HTTP-date. A date in the past is 0.
329
+ * Deliberately UNCAPPED — the cap is a decision about whether to retry, made by the caller.
330
+ */
331
+ export function parseRetryAfter(value, nowMs = Date.now()) {
332
+ if (value === null || value === undefined) return null
333
+ const s = String(value).trim()
334
+ if (!s) return null
335
+ if (/^\d+$/.test(s)) return Number(s) * 1000
336
+ if (/^[+-]?[\d.]+$/.test(s)) return null // "-5", "1.5": not a valid delay-seconds, not a date either
337
+ const at = Date.parse(s)
338
+ if (!Number.isFinite(at)) return null
339
+ return Math.max(0, at - nowMs)
340
+ }
341
+
342
+ /** PURE. Full-jitter exponential backoff: uniform in [0, min(maxDelayMs, baseDelayMs·2^attempt)). */
343
+ export function backoffDelayMs(attempt, { baseDelayMs = 400, maxDelayMs = 8_000, random = Math.random } = {}) {
344
+ const ceiling = Math.min(maxDelayMs, baseDelayMs * 2 ** Math.max(0, attempt))
345
+ return Math.floor(random() * ceiling)
346
+ }
347
+
348
+ /** PURE. The retry class of a response, or null when it is not a transient failure at all. */
349
+ export function responseRetryClass(status, contentType) {
350
+ if (status === 429 || status === 503) return 'refused'
351
+ if (status === 403 && !String(contentType ?? '').includes('application/json')) return 'refused'
352
+ if (status >= 500) return 'server'
353
+ return null
354
+ }
355
+
356
+ /** PURE. Did this error happen before a connection existed — i.e. nothing reached the server? */
357
+ export function isConnectError(err) {
358
+ const codes = [err?.code, err?.cause?.code, ...(Array.isArray(err?.cause?.errors) ? err.cause.errors.map((x) => x?.code) : [])]
359
+ return codes.some((c) => typeof c === 'string' && CONNECT_ERROR_CODES.has(c))
360
+ }
361
+
362
+ /**
363
+ * PURE. May a failure of this class be retried? `timeouts` counts the timeouts seen so far in this call,
364
+ * INCLUDING the one being decided — so the first timeout may be retried once and the second may not.
365
+ */
366
+ export function mayRetry(klass, { idempotent, retryOnTimeout = true, requireRetryAfter = false, retryAfterMs = null, timeouts = 0 }) {
367
+ switch (klass) {
368
+ case 'refused': return requireRetryAfter ? retryAfterMs !== null : true
369
+ case 'connect': return true
370
+ case 'server':
371
+ case 'network': return Boolean(idempotent)
372
+ case 'timeout': return Boolean(idempotent) && retryOnTimeout && timeouts <= 1
373
+ default: return false
374
+ }
375
+ }
376
+
377
+ /**
378
+ * fetch with the D9 retry policy. Returns the Response (possibly a non-OK one, after retries are
379
+ * exhausted or refused); throws a clear "Could not reach" error when no response was ever obtained.
380
+ *
381
+ * policy (third argument, all optional):
382
+ * retries max retries after the first attempt (default 2)
383
+ * idempotent allow retrying `server`/`network` failures and one timeout (default: GET/HEAD)
384
+ * retryOnTimeout set false to never retry a timeout, even when idempotent
385
+ * requireRetryAfter retry `refused` responses only when they carry a Retry-After
386
+ * budgetMs overall wall-clock cap (default: first attempt's timeout + DEFAULT_RETRY_BUDGET_MS)
387
+ * baseDelayMs, maxDelayMs full-jitter backoff parameters
388
+ * sleep, random, now injectable for tests
389
+ */
390
+ export async function fetchCortex(url, opts = {}, policy = {}) {
391
+ const {
392
+ retries = 2, baseDelayMs = 400, maxDelayMs = 8_000,
393
+ retryOnTimeout = true, requireRetryAfter = false,
394
+ sleep: sleepFn = sleep, random = Math.random, now = Date.now,
395
+ } = policy
284
396
  const { timeoutMs: optTimeout, signal: callerSignal, ...rest } = opts
285
397
  const baseHeaders = { ...(rest.headers ?? {}), 'x-cortex-client': CLIENT_ID }
286
398
  const fetchOpts = SESSION_KEY
287
399
  ? { ...rest, headers: { ...baseHeaders, 'x-cortex-session-key': SESSION_KEY } }
288
400
  : { ...rest, headers: baseHeaders }
401
+ const method = String(rest.method ?? 'GET').toUpperCase()
402
+ const idempotent = policy.idempotent ?? (method === 'GET' || method === 'HEAD')
289
403
  const timeoutMs = optTimeout ?? (Number(process.env.CORTEX_HTTP_TIMEOUT_MS) || 15_000)
290
- let lastErr
291
- for (let attempt = 0; attempt <= retries; attempt++) {
404
+ const budgetMs = policy.budgetMs ?? timeoutMs + DEFAULT_RETRY_BUDGET_MS
405
+ const minAttemptMs = Math.min(1_000, timeoutMs)
406
+ const startedAt = now()
407
+ let lastErr = null
408
+ let lastKlass = null
409
+ let lastAttemptMs = timeoutMs
410
+ let timeouts = 0
411
+
412
+ for (let attempt = 0; ; attempt++) {
413
+ const remaining = budgetMs - (now() - startedAt)
414
+ // Fresh timeout signal per attempt, clipped to the budget; composed with any caller signal.
415
+ lastAttemptMs = Math.max(1, Math.min(timeoutMs, remaining))
416
+ const timeoutSignal = AbortSignal.timeout(lastAttemptMs)
417
+ const signal = callerSignal ? AbortSignal.any([callerSignal, timeoutSignal]) : timeoutSignal
418
+ let res = null
419
+ let klass
420
+ let retryAfterMs = null
292
421
  try {
293
- // Fresh timeout signal per attempt; compose with any caller-supplied signal.
294
- const timeoutSignal = AbortSignal.timeout(timeoutMs)
295
- const signal = callerSignal ? AbortSignal.any([callerSignal, timeoutSignal]) : timeoutSignal
296
- const res = await fetch(url, { ...fetchOpts, signal })
297
- const ct = res.headers.get('content-type') ?? ''
298
- const transient =
299
- res.status === 429 ||
300
- res.status >= 500 ||
301
- (res.status === 403 && !ct.includes('application/json')) // infra block, not app authz
302
- if (transient && attempt < retries) {
303
- await sleep(baseDelayMs * 2 ** attempt)
304
- continue
305
- }
306
- return res
422
+ res = await fetch(url, { ...fetchOpts, signal })
423
+ klass = responseRetryClass(res.status, res.headers.get('content-type'))
424
+ if (!klass) return res
425
+ retryAfterMs = parseRetryAfter(res.headers.get('retry-after'), now())
307
426
  } catch (e) {
427
+ if (callerSignal?.aborted) throw e // the caller cancelled: never retry that
308
428
  lastErr = e
309
- if (attempt < retries) { await sleep(baseDelayMs * 2 ** attempt); continue }
429
+ klass = timeoutSignal.aborted ? 'timeout' : isConnectError(e) ? 'connect' : 'network'
430
+ if (klass === 'timeout') timeouts += 1
431
+ }
432
+ lastKlass = klass
433
+
434
+ let delay = null
435
+ if (attempt < retries && mayRetry(klass, { idempotent, retryOnTimeout, requireRetryAfter, retryAfterMs, timeouts })) {
436
+ if (retryAfterMs !== null) {
437
+ delay = retryAfterMs > RETRY_AFTER_CAP_MS ? null : retryAfterMs + Math.floor(random() * RETRY_AFTER_SPREAD_MS)
438
+ } else {
439
+ delay = backoffDelayMs(attempt, { baseDelayMs, maxDelayMs, random })
440
+ }
441
+ if (delay !== null && now() - startedAt + delay + minAttemptMs > budgetMs) delay = null
442
+ }
443
+
444
+ if (delay === null) {
445
+ if (res) return res
446
+ const why = lastKlass === 'timeout'
447
+ ? `timed out after ${lastAttemptMs} ms (${lastErr?.message ?? 'timeout'})`
448
+ : (lastErr?.message ?? 'network error')
449
+ // `retryClass` says whether the request could have reached the server ('connect' = it did not),
450
+ // which the capture queue needs in order to decide whether a later replay is safe.
451
+ throw Object.assign(new Error(
452
+ `Could not reach Agnoclast at ${url} — ${why}. ` +
453
+ `Check your connection (and CORTEX_URL if you set it).`,
454
+ ), { retryClass: lastKlass })
310
455
  }
456
+ // Release the discarded response's socket before waiting.
457
+ if (res) { try { await res.body?.cancel() } catch { /* already consumed or closed */ } }
458
+ await sleepFn(delay)
311
459
  }
312
- throw new Error(
313
- `Could not reach Agnoclast at ${url} — ${lastErr?.message ?? 'network error'}. ` +
314
- `Check your connection (and CORTEX_URL if you set it).`,
315
- )
316
460
  }
317
461
 
318
462
  // Live health probe: hit /api/mcp-context with the token and classify the result.
package/lib/doctor.mjs CHANGED
@@ -4,6 +4,12 @@ import { join } from 'path'
4
4
  import { checkToken, checkSkills, resolveBase, resolveTokenSource } from './diagnose.mjs'
5
5
  import { renderRenameNotice } from './rename_notice.mjs'
6
6
  import { detectSurface, renderSurface } from './surface.mjs'
7
+ import { queueDir, queueStatus, renderQueueNotice } from './capture_queue.mjs'
8
+
9
+ /** The capture-queue lines, or [] — local, synchronous, and never throws (a status line must not break). */
10
+ function queueNotice(dir) {
11
+ try { return renderQueueNotice(queueStatus(dir ?? queueDir())) } catch { return [] }
12
+ }
7
13
 
8
14
  // `npx @theronap/cortex-mcp doctor` — a live, one-command health check.
9
15
  //
@@ -26,18 +32,27 @@ export const resolveToken = resolveTokenSource
26
32
  // The surface line is printed from a `finally` so no branch below can skip it. That includes the
27
33
  // early returns and the "connected" line, which a person in Claude's Home tab also sees: the token is
28
34
  // fine there, and the app they are looking at still cannot use it. See surface.mjs.
29
- export async function runStatus() {
35
+ //
36
+ // The capture-queue lines (capture_queue.mjs) print from the same `finally`, for the same reason: a
37
+ // session this machine could not capture must be reported whether or not the network check below
38
+ // succeeds — most of all when it does not. They are read FIRST, locally, so the happy line can stop
39
+ // claiming "sessions on this machine are captured" while some demonstrably are not.
40
+ //
41
+ // `opts.queueDir` exists for tests only (bun's os.homedir() ignores HOME, so a test cannot redirect it).
42
+ export async function runStatus(opts = {}) {
30
43
  const out = (m) => process.stdout.write(m + '\n')
44
+ const queueLines = queueNotice(opts.queueDir)
31
45
  try {
32
- await statusLine(out)
46
+ await statusLine(out, { capturesStuck: queueLines.length > 0 })
33
47
  } finally {
48
+ for (const line of queueLines) out(line)
34
49
  const surface = renderSurface(detectSurface(process.env), 'status')
35
50
  if (surface) out(surface)
36
51
  }
37
52
  return 0
38
53
  }
39
54
 
40
- async function statusLine(out) {
55
+ async function statusLine(out, { capturesStuck = false } = {}) {
41
56
  const base = resolveBase(process.env.CORTEX_URL)
42
57
  const { token } = resolveToken()
43
58
  if (!token) {
@@ -58,7 +73,12 @@ async function statusLine(out) {
58
73
  return
59
74
  }
60
75
  const n = typeof r.projectCount === 'number' ? ` · ${r.projectCount} project${r.projectCount === 1 ? '' : 's'} visible` : ''
61
- out(`Agnoclast: connected sessions on this machine are captured to your org${n}.`)
76
+ // Same rule as captureNotice above, applied locally: with sessions stuck in the capture queue,
77
+ // "sessions on this machine are captured" is false for exactly those sessions. The token is
78
+ // still fine, so say that — and let the ⚠ lines printed after this one say the rest.
79
+ out(capturesStuck
80
+ ? `Agnoclast: connected${n}.`
81
+ : `Agnoclast: connected — sessions on this machine are captured to your org${n}.`)
62
82
  // TEMPORARY (ADR-0033 T7) — delete with lib/rename_notice.mjs a release after C2.
63
83
  // This one FOLLOWS the happy line rather than replacing it, unlike captureNotice above. The
64
84
  // rule there exists because captureNotice CONTRADICTS the reassurance ("connected" was true
@@ -97,6 +117,14 @@ async function doctorChecks(out) {
97
117
  const { token, source } = resolveToken()
98
118
  out(` token source: ${source ?? 'NONE FOUND'}`)
99
119
  out(` endpoint: ${base}`)
120
+ // Local: captures on this machine that have not landed yet (capture_queue.mjs). Informational — it
121
+ // does not change the verdict below, which is about whether each surface ANSWERS — but it is printed
122
+ // before that verdict so a PASS can never sit alone above sessions that were not captured.
123
+ const q = (() => { try { return queueStatus(queueDir()) } catch { return null } })()
124
+ if (q) {
125
+ out(` capture queue: ${q.pending ? `${q.pending} capture${q.pending === 1 ? '' : 's'} waiting on this machine (~/.cortex/capture-queue)` : 'empty'}`)
126
+ for (const line of renderQueueNotice(q)) out(` ${line.replace(/^Agnoclast: /, '')}`)
127
+ }
100
128
  out('')
101
129
 
102
130
  const r = await checkToken(token, base)
@@ -7,8 +7,8 @@ import { homedir } from 'os'
7
7
  // Without it, N concurrent Claude sessions all fire their Stop hook at once → N simultaneous headless
8
8
  // `claude --print` calls that contend on the same subscription-OAuth refresh and can deadlock (the
9
9
  // "prompts hang forever" incident, 2026-07-08). If the lock is already held by a live, recent holder,
10
- // the caller skips extraction and ships the transcript tail instead (the server summarizes async)
11
- // no session is dropped, just summarized server-side that turn. A stale lock (older than the summary
10
+ // the caller skips extraction and QUEUES the turn on this machine (capture_queue.mjs; D8 nothing is
11
+ // sent) — no session is dropped, just summarized by a later run. A stale lock (older than the summary
12
12
  // timeout + grace, or whose PID is dead) is reclaimed.
13
13
  const LOCK_PATH = join(homedir(), '.cortex', 'summarize.lock')
14
14
 
@@ -16,8 +16,8 @@ const LOCK_PATH = join(homedir(), '.cortex', 'summarize.lock')
16
16
  // timed 2026-09-10 on two real transcripts: 56, 68, 77, 86, 93, 102s — and one run that exceeded 150s
17
17
  // and was killed. The old 45s default was below nearly all of them; a first raise to 150s still sat
18
18
  // inside the tail. A killed call returns null, which skips EVERYTHING downstream: the summary, people,
19
- // entities, ADR-0055's `pages`, and ADR-0059's `obligations`. The fallback then ships the transcript
20
- // tail and the server writes a summary, so the record lands and nothing looks broken — a green outcome
19
+ // entities, ADR-0055's `pages`, and ADR-0059's `obligations`. Until D8 the fallback then shipped the
20
+ // transcript tail and the server wrote a summary, so the record landed and nothing looked broken — a green outcome
21
21
  // over a dead mechanism, which is this file's own recorded failure family (see edgeSkip's header).
22
22
  //
23
23
  // ⚠ LATENCY TRACKS OUTPUT, NOT INPUT — measured, and it is why length is a poor predictor. A plain
@@ -29,9 +29,9 @@ const LOCK_PATH = join(homedir(), '.cortex', 'summarize.lock')
29
29
  //
30
30
  // ⚠ RAISING IT COSTS THE USER NOTHING. capture.mjs re-invokes itself as a fully DETACHED child
31
31
  // (`detached: true`, `child.unref()`), so nobody waits on this call. The one real cost is the
32
- // single-flight lock held longer, during which a concurrent session ships its tail instead the
33
- // existing graceful fallback. Too short loses the whole extraction; too long only degrades a neighbour
34
- // to the fallback it already has. The stale-lock reclaim scales off this same value.
32
+ // single-flight lock held longer, during which a concurrent session's turn waits in the local queue
33
+ // instead. Too short loses the whole extraction; too long only delays a neighbour to a later run. The
34
+ // stale-lock reclaim scales off this same value.
35
35
  //
36
36
  // ⚠ Its effect on capture_route (1 attachment in its first two days) is a candidate, NOT proven.
37
37
  function summaryTimeoutMs() {
@@ -39,6 +39,18 @@ function summaryTimeoutMs() {
39
39
  return Number.isFinite(n) && n > 0 ? n : 300_000
40
40
  }
41
41
 
42
+ /**
43
+ * PURE. How long THIS call may spend, which is the configured ceiling unless the caller has less time
44
+ * than that. The queue drain does: it holds a wall-clock budget and must not be blocked for 300 s by a
45
+ * spawnSync inside it (the whole reason this parameter exists). A caller passing 0 or less is saying
46
+ * "no time left" — `extractSession` then skips without spawning anything.
47
+ */
48
+ export function effectiveSummaryTimeoutMs(opts = {}) {
49
+ const ceiling = summaryTimeoutMs()
50
+ const asked = Number(opts?.timeoutMs)
51
+ return Number.isFinite(asked) ? Math.min(ceiling, asked) : ceiling
52
+ }
53
+
42
54
  // Try to acquire the lock. Returns true if acquired (caller must call releaseSummaryLock in a finally),
43
55
  // false if another live holder has it. O_CREAT|O_EXCL is the atomic "create only if absent" primitive.
44
56
  function acquireSummaryLock(_retried = false) {
@@ -50,7 +62,7 @@ function acquireSummaryLock(_retried = false) {
50
62
  } catch (e) {
51
63
  if (e && e.code === 'EEXIST') {
52
64
  // Someone holds it — reclaim only if it's stale (older than timeout + 15s grace, or PID dead).
53
- if (_retried) return false // one reclaim attempt only; a live race means "ship the tail"
65
+ if (_retried) return false // one reclaim attempt only; a live race means "queue this turn"
54
66
  try {
55
67
  const age = Date.now() - statSync(LOCK_PATH).mtimeMs
56
68
  const holderDead = !pidAlive(readFileSync(LOCK_PATH, 'utf8').trim())
@@ -58,7 +70,7 @@ function acquireSummaryLock(_retried = false) {
58
70
  try { unlinkSync(LOCK_PATH) } catch { /* raced with another reclaimer */ }
59
71
  return acquireSummaryLock(true)
60
72
  }
61
- } catch { /* unreadable lock — treat as held; ship the tail this turn */ }
73
+ } catch { /* unreadable lock — treat as held; queue this turn */ }
62
74
  return false
63
75
  }
64
76
  // Any other error (e.g. ~/.cortex missing): don't block extraction, just run without the lock.
@@ -91,7 +103,8 @@ function releaseSummaryLock() {
91
103
  // CONTRACT: people[] / namedEntities[] shapes MUST stay compatible with the server validators
92
104
  // web/lib/engine/extract_people.ts (parsePeople) + extract_entities.ts (parseEntities). The prompt
93
105
  // fragments are copied from there; the SERVER validator is the enforced source of truth — the edge
94
- // is never trusted. Returns null on any failure → caller falls back to shipping the transcript tail.
106
+ // is never trusted. Returns null on any failure → the caller queues the turn locally and retries later
107
+ // (D8: the transcript is never sent to be summarized server-side).
95
108
 
96
109
  export function edgeSafeEnv(base = process.env, extra = {}) {
97
110
  const env = {}
@@ -427,10 +440,14 @@ const scheduledTaskFragment = (name) =>
427
440
  'INCLUDING one mentioned only in passing at the very end of an otherwise routine report. ' +
428
441
  'One genuine finding outweighs an otherwise unremarkable run; summarize the FINDING, not the run.'
429
442
 
430
- export function extractSession(transcript) {
443
+ export function extractSession(transcript, opts = {}) {
431
444
  LAST_SKIP = null
432
445
  const raw = (transcript ?? '').trim()
433
446
  if (!raw || process.env.CORTEX_SUMMARIZE_DISABLED) return null
447
+ // The caller's remaining time, if it has less than the configured ceiling. Checked BEFORE the lock so
448
+ // a caller with no time left neither spawns anything nor takes the machine-wide summarizer lock.
449
+ const timeoutMs = effectiveSummaryTimeoutMs(opts)
450
+ if (timeoutMs <= 0) return edgeSkip('timeout', 'the caller had no time left to summarize; left for a later run')
434
451
  const task = scheduledTaskName(raw)
435
452
  // Strip instructions only for scheduled runs; a human transcript has no such blocks and is passed
436
453
  // through untouched, so this cannot change how ordinary sessions are summarized.
@@ -466,9 +483,9 @@ export function extractSession(transcript) {
466
483
  OBLIGATIONS_FRAGMENT +
467
484
  (task ? scheduledTaskFragment(task) : '') +
468
485
  '\n\n--- SESSION ---\n' + shown + '\n--- END ---'
469
- // Single-flight: if another session already has a summarizer running, skip (caller ships the tail;
470
- // server summarizes). Prevents the concurrent-`claude -p` stampede that deadlocks the OAuth refresh.
471
- if (!acquireSummaryLock()) return edgeSkip('busy', 'another session is summarizing; the tail ships instead')
486
+ // Single-flight: if another session already has a summarizer running, skip (the caller queues this
487
+ // turn locally). Prevents the concurrent-`claude -p` stampede that deadlocks the OAuth refresh.
488
+ if (!acquireSummaryLock()) return edgeSkip('busy', 'another session is summarizing; this turn waits in the local queue')
472
489
  try {
473
490
  // ⚠ THE PROMPT GOES ON STDIN, NOT ARGV — AND THE WINDOW BELOW DEPENDS ON IT. A command-line
474
491
  // argument has an OS size limit: Linux caps a SINGLE argument at 131,072 bytes (MAX_ARG_STRLEN)
@@ -479,9 +496,9 @@ export function extractSession(transcript) {
479
496
  const r = spawnSync(
480
497
  'claude',
481
498
  ['--print', '--model', process.env.CORTEX_SUMMARY_MODEL ?? 'claude-haiku-4-5'],
482
- { input: prompt, env: edgeSafeEnv(process.env, { CORTEX_SUMMARIZING: '1' }), encoding: 'utf8', timeout: summaryTimeoutMs(), maxBuffer: 4 * 1024 * 1024 },
499
+ { input: prompt, env: edgeSafeEnv(process.env, { CORTEX_SUMMARIZING: '1' }), encoding: 'utf8', timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024 },
483
500
  )
484
- const why = classifyEdgeFailure(r)
501
+ const why = classifyEdgeFailure(r, timeoutMs)
485
502
  if (why) return edgeSkip(why.reason, why.detail)
486
503
  const parsed = parseEdgeJson(r.stdout.trim())
487
504
  if (!parsed) return edgeSkip('unparseable', 'the model returned output that is not the expected JSON')
@@ -497,9 +514,9 @@ export function extractSession(transcript) {
497
514
 
498
515
  // ── WHY THE EXTRACTION DID NOT RUN ───────────────────────────────────────────────────────────────
499
516
  //
500
- // Every failure path here returns null, and the caller correctly treats null as "ship the transcript
501
- // tail and let the server summarize". That fallback is right. What was wrong is that FIVE different
502
- // failures were indistinguishable, and one of them never fixes itself.
517
+ // Every failure path here returns null. The caller treated null as "ship the transcript tail and let
518
+ // the server summarize" until D8 (2026-09-11); it now queues the turn locally and retries. Either way,
519
+ // what was wrong is that FIVE different failures were indistinguishable, and one of them never fixes itself.
503
520
  //
504
521
  // 🔴 THE EVIDENCE. Found 2026-09-08: `claude --print` had been exiting 1 with
505
522
  // "Failed to authenticate: OAuth session expired and could not be refreshed" — so edge extraction
@@ -539,7 +556,7 @@ function edgeSkip(reason, detail) {
539
556
  * Exported so the failure taxonomy is testable without spawning anything — the auth case in
540
557
  * particular could not otherwise be covered, and it is the one that matters most.
541
558
  */
542
- export function classifyEdgeFailure(r) {
559
+ export function classifyEdgeFailure(r, timeoutMs = summaryTimeoutMs()) {
543
560
  if (!r) return { reason: 'no-result', detail: 'spawn returned nothing' }
544
561
  // spawn itself failed — almost always ENOENT, i.e. `claude` is not on the hook's PATH. A Stop hook
545
562
  // runs with a login shell's PATH, which is not always the interactive one.
@@ -552,12 +569,12 @@ export function classifyEdgeFailure(r) {
552
569
  // which reads as "the spawn broke" when the truth is "it ran and was too slow". Checking the
553
570
  // signal alone missed it, so both are checked; this classifier's own first outing found this.
554
571
  if (r.error.code === 'ETIMEDOUT') {
555
- return { reason: 'timeout', detail: `exceeded ${summaryTimeoutMs()}ms` }
572
+ return { reason: 'timeout', detail: `exceeded ${timeoutMs}ms` }
556
573
  }
557
574
  return { reason: 'spawn-failed', detail: r.error.message }
558
575
  }
559
576
  // The other shape of the same event: spawnSync killed the child, so status is null and signal set.
560
- if (r.signal) return { reason: 'timeout', detail: `killed by ${r.signal} after ${summaryTimeoutMs()}ms` }
577
+ if (r.signal) return { reason: 'timeout', detail: `killed by ${r.signal} after ${timeoutMs}ms` }
561
578
 
562
579
  // 🔴 EVERYTHING BELOW APPLIES ONLY TO A FAILED CALL. Exit 0 means the model answered; its answer
563
580
  // is CONTENT, not a status report, and must never be scanned for failure keywords.
@@ -582,8 +599,8 @@ export function classifyEdgeFailure(r) {
582
599
  reason: 'auth-expired',
583
600
  detail: 'the subscription login for headless `claude --print` is dead. Run `claude setup-token`, '
584
601
  + 'then set CLAUDE_CODE_OAUTH_TOKEN in ~/.claude/settings.json (env). '
585
- + 'Until then EVERY session ships its transcript tail to the server instead of a local digest, '
586
- + 'and no page proposals are made.',
602
+ + 'Until then EVERY session on this machine goes uncaptured: each one waits in the local queue '
603
+ + '(~/.cortex/capture-queue) and is reported at session start. Nothing is sent to be summarized elsewhere.',
587
604
  }
588
605
  }
589
606
  return { reason: 'exit', detail: `claude exited ${r.status}: ${firstLine(out)}` }
package/lib/hydrate.mjs CHANGED
@@ -15,11 +15,49 @@ import { writePresence } from './statusline.mjs'
15
15
  // hook can't cheaply judge a semantic topic change.
16
16
  //
17
17
  // It MUST be synchronous: the value is being in-context before the reply, so it cannot be detached like
18
- // capture. That cost is bounded — once per session, an 8s timeout, and FAIL-OPEN: any miss injects
18
+ // capture. That cost is bounded — once per session, an 8s timeout inside a 10s total budget (no retry
19
+ // of a timeout — see HYDRATE_RETRY_POLICY), and FAIL-OPEN: any miss injects
19
20
  // nothing and never holds up the user's first message. Always exits 0; hydration must never break a turn.
20
21
 
21
22
  const HYDRATE_TIMEOUT_MS = 8_000
22
23
  const MAX_ATTEMPTS = 2 // give up after N failed tries so a dead endpoint isn't re-hit every turn
24
+
25
+ // How long the user's first message can be held, in total, retries included. The first attempt gets
26
+ // its full 8 s; the remaining 2 s only ever admits a FAST retry (see the policy below).
27
+ export const HYDRATE_BUDGET_MS = 10_000
28
+
29
+ // D9. This call used to be `{ retries: 1 }` with the old any-429/5xx-or-timeout loop, so a session-context
30
+ // request that timed out BECAUSE THE SERVER WAS SLOW was immediately sent again — doubling the load on the
31
+ // thing that was already too slow to answer, while the person's first prompt waited up to ~16 s for it.
32
+ // Now it retries only where a retry cannot add load to a struggling server:
33
+ // · a connection that was never made (refused / DNS) — nothing reached the server;
34
+ // · a 429/503 that says WHEN (Retry-After), and only if that fits the budget.
35
+ // Never a timeout, never a 5xx, never a 429 without a Retry-After. Missing context is fail-open anyway:
36
+ // the turn proceeds without it and `attempts` lets one LATER turn try again.
37
+ export const HYDRATE_RETRY_POLICY = Object.freeze({
38
+ retries: 1,
39
+ idempotent: false, // no retry on 5xx or a broken connection mid-flight
40
+ retryOnTimeout: false, // never re-ask a server that was too slow to answer
41
+ requireRetryAfter: true, // a 429/503 is retried only when the server says when
42
+ budgetMs: HYDRATE_BUDGET_MS,
43
+ })
44
+
45
+ /** The hydration request itself. Exported so the retry policy is testable against a real fetch path. */
46
+ export function fetchSessionContext({ base, token, prompt, timeoutMs = HYDRATE_TIMEOUT_MS, policy = HYDRATE_RETRY_POLICY }) {
47
+ return fetchCortex(
48
+ `${base}/api/session-context`,
49
+ {
50
+ method: 'POST',
51
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
52
+ // Redact credential-shaped strings before the prompt leaves the machine (a pasted key must never
53
+ // be transmitted, same discipline as capture).
54
+ body: JSON.stringify({ question: redactSecrets(prompt) }),
55
+ timeoutMs,
56
+ },
57
+ policy,
58
+ )
59
+ }
60
+
23
61
  const MIN_SUBSTANTIVE_CHARS = 15
24
62
  // Bare greetings/affirmations are not a real opening query — skip WITHOUT marking done, so the first
25
63
  // substantive prompt still hydrates. Anchored to the whole string so "go" skips but "go build X" does not.
@@ -126,18 +164,7 @@ export async function runHydrate() {
126
164
 
127
165
  let res
128
166
  try {
129
- res = await fetchCortex(
130
- `${base}/api/session-context`,
131
- {
132
- method: 'POST',
133
- headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
134
- // Redact credential-shaped strings before the prompt leaves the machine (a pasted key must never
135
- // be transmitted, same discipline as capture).
136
- body: JSON.stringify({ question: redactSecrets(prompt) }),
137
- timeoutMs: HYDRATE_TIMEOUT_MS,
138
- },
139
- { retries: 1 },
140
- )
167
+ res = await fetchSessionContext({ base, token, prompt })
141
168
  } catch (e) {
142
169
  process.stderr.write(`cortex: hydrate skipped — ${e?.message ?? String(e)}\n`)
143
170
  bumpAttempt()
@@ -0,0 +1,99 @@
1
+ // ADR-0059 §5.6 — THE VERB A READING AGENT WAS MISSING.
2
+ //
3
+ // 🔴 WHY (Theron, 2026-09-11: *"Why isn't the agent just surfacing those as obligations?"*). Two emails he
4
+ // had been sent asked him for something — a 15-minute call "this week", and a mentee meeting to log that
5
+ // week. A session read both, filed them onto pages as knowledge, and proposed NOTHING. The asks evaporated.
6
+ //
7
+ // Nothing was broken. The proposal path — evidence + `state='proposed'` + the reviewer — existed and
8
+ // worked; it was reachable only by the detached capture worker, over HTTP with a bearer token. The one
9
+ // obligation verb an agent had was `track_obligation`, which records something YOU OWE: no evidence, no
10
+ // proposed state. So a reading agent's only options were to assert a commitment the person never made
11
+ // (ADR-0059 §3's defect: machine-proposed and user-created become indistinguishable) or to write a script.
12
+ // **A capability an agent can only reach by writing a script is a capability it does not have.**
13
+ //
14
+ // So this is the same write the extractor does, as one tool call: the record, what is owed, and the
15
+ // sentence that says so. It lands as a PROPOSAL the person confirms or dismisses — never as a commitment.
16
+ //
17
+ // ⚠ AND IT IS DELIBERATELY NOT A SUBSTITUTE FOR EXTRACTION. This repo has measured what asking agents to
18
+ // volunteer yields: `route_record` 10.9% (ADR-0051), `capture_route` one attachment in ten sessions
19
+ // (ADR-0055). Voluntary noticing supplements deterministic extraction; it cannot replace it.
20
+
21
+ /** The evidence floor, matching the edge's verbatim gate (`verbatimIn`): a three-word fragment is not a
22
+ * quote, and it is exactly what a hurried agent would pass. */
23
+ export const MIN_EVIDENCE_CHARS = 16
24
+ const PARTIES = new Set(['self', 'other', 'none'])
25
+ const norm = (s) => String(s ?? '').replace(/\s+/g, ' ').trim()
26
+
27
+ /**
28
+ * PURE. Validate one proposal locally and return `{ candidate }` or `{ error }`.
29
+ *
30
+ * The server drops a malformed candidate silently (it answers `malformed: 1` and writes nothing), which
31
+ * for an interactive caller is the wrong shape of failure: the agent believes it surfaced the ask. So the
32
+ * same rules are enforced here, loudly, with the reason.
33
+ *
34
+ * A `due_phrase` that is NOT inside the evidence is dropped rather than refused, and the caller is told:
35
+ * that is the edge's own rule (a phrase from some other sentence must not decide the date), and the
36
+ * candidate is still worth proposing without it.
37
+ */
38
+ export function buildProposal({ subject, evidence, due_phrase, due_at, obligated_party = 'self' } = {}) {
39
+ const s = norm(subject)
40
+ const e = norm(evidence)
41
+ if (!s) return { error: 'subject is required — what must be done, in one short phrase' }
42
+ if (!e) return { error: 'evidence is required — the VERBATIM sentence from the record that asks for it' }
43
+ if (e.length < MIN_EVIDENCE_CHARS) {
44
+ return { error: `evidence is too short to be a quote (${e.length} chars, need ${MIN_EVIDENCE_CHARS}) — pass the whole sentence` }
45
+ }
46
+ const party = norm(obligated_party) || 'self'
47
+ if (!PARTIES.has(party)) return { error: `obligated_party must be self, other or none (got "${party}")` }
48
+ const phrase = norm(due_phrase)
49
+ const phraseInside = phrase ? norm(e).toLowerCase().includes(phrase.toLowerCase()) : false
50
+ return {
51
+ candidate: {
52
+ subject: s.slice(0, 200),
53
+ due_at: norm(due_at) || null,
54
+ ...(phraseInside ? { due_phrase: phrase.slice(0, 120) } : {}),
55
+ evidence: e.slice(0, 500),
56
+ obligated_party: party,
57
+ },
58
+ ...(phrase && !phraseInside
59
+ ? { note: `the words "${phrase}" are not in the evidence sentence, so they were dropped — the date comes from due_at or from the quote itself` }
60
+ : {}),
61
+ }
62
+ }
63
+
64
+ /** PURE. What the caller reads back. Says it is a PROPOSAL, and says so when it landed with no date. */
65
+ export function renderProposal(body, candidate, note) {
66
+ const b = body ?? {}
67
+ const lines = []
68
+ const what = candidate?.subject ?? '(unknown)'
69
+ if (b.refreshed) lines.push(`Updated the existing proposal for that sentence: "${what}"`)
70
+ else if (b.proposed) lines.push(`Proposed (NOT yet something you owe): "${what}"`)
71
+ else lines.push(`Nothing was written for "${what}" — the record already carries a row for that sentence that you have acted on.`)
72
+ if (b.undated) lines.push('⚠ It has NO date. Nothing will nudge you about it until it has one.')
73
+ if (b.fromQuote) lines.push('Its date was read out of the quote itself.')
74
+ if (note) lines.push(`⚠ ${note}`)
75
+ if (b.proposed || b.refreshed) {
76
+ lines.push('See it with my_obligations. Confirm or dismiss it with resolve_obligation — a proposal you never answer stays a proposal.')
77
+ }
78
+ return lines.join('\n')
79
+ }
80
+
81
+ /**
82
+ * PURE. The one line a reading tool appends TO ITS OUTPUT, so an agent that just read an ask knows what to
83
+ * do with it.
84
+ *
85
+ * ⚠ IT LIVES IN THE OUTPUT, NOT IN THE TOOL DESCRIPTION, and that is not a style choice. `source-invariants`
86
+ * caps a tool description at 1,950 characters because a client silently truncates the tail — and `my_day`
87
+ * was already AT the ceiling, so appending there would have pushed the instruction into the invisible part
88
+ * (measured 2026-09-11: my_day 2,195 chars, 245 over). The output is also the better place on its own
89
+ * merits: it arrives with the content in view, at the moment there is something to notice, rather than in a
90
+ * tool catalogue read once at startup.
91
+ */
92
+ export const NOTICE_ASKS =
93
+ 'If something here ASKS THIS PERSON FOR SOMETHING — a reply, a call, a form, a deadline — propose it with ' +
94
+ '`propose_obligation` and the sentence that says so. Reading it and filing it is not surfacing it.'
95
+
96
+ /** PURE. Append the notice to what a reading tool returns. */
97
+ export function withAskNotice(text) {
98
+ return `${String(text ?? '')}\n\n${NOTICE_ASKS}`
99
+ }