@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/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 +41 -24
- 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/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
|
-
|
|
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
|
-
|
|
291
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
package/lib/edge_extract.mjs
CHANGED
|
@@ -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
|
|
11
|
-
// no session is dropped, just summarized
|
|
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`.
|
|
20
|
-
// tail and the server
|
|
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
|
|
33
|
-
//
|
|
34
|
-
//
|
|
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 "
|
|
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;
|
|
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
|
|
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
|
|
470
|
-
//
|
|
471
|
-
if (!acquireSummaryLock()) return edgeSkip('busy', 'another session is summarizing; the
|
|
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:
|
|
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
|
|
501
|
-
//
|
|
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 ${
|
|
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 ${
|
|
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
|
|
586
|
-
+ 'and
|
|
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
|
|
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
|
|
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
|
+
}
|