@theronap/agnoclast-mcp 0.9.172 → 0.9.173
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 +147 -1
- package/lib/doctor.mjs +12 -1
- package/lib/report_issue.mjs +71 -0
- package/lib/report_spool.mjs +176 -0
- package/lib/server.mjs +47 -7
- package/package.json +1 -1
package/lib/diagnose.mjs
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
import { readFileSync } from 'fs'
|
|
13
13
|
import { homedir } from 'os'
|
|
14
14
|
import { join } from 'path'
|
|
15
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
16
|
+
import { spoolReport, flush as flushReportSpool, readEntries as readReportSpool, spoolDir as reportSpoolDir } from './report_spool.mjs'
|
|
15
17
|
|
|
16
18
|
export const isUuid = (s) =>
|
|
17
19
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s ?? '')
|
|
@@ -281,9 +283,12 @@ export function setSessionKey(key) { SESSION_KEY = key || null }
|
|
|
281
283
|
// blind. A one-shot hook process legitimately has no session and IS still cortex-mcp — it should
|
|
282
284
|
// say so, and be visible as one, not disguise itself as a script.
|
|
283
285
|
let CLIENT_ID = 'cortex-mcp/unknown'
|
|
286
|
+
let CLIENT_VERSION = null
|
|
284
287
|
export function setClientVersion(version) {
|
|
285
288
|
CLIENT_ID = `cortex-mcp/${version || 'unknown'}`
|
|
289
|
+
CLIENT_VERSION = version || null
|
|
286
290
|
}
|
|
291
|
+
export const clientVersion = () => CLIENT_VERSION
|
|
287
292
|
|
|
288
293
|
// ── Retries (D9, eng review 2026-09-11) ──────────────────────────────────────────────────────────
|
|
289
294
|
//
|
|
@@ -402,7 +407,7 @@ export function mayRetry(klass, { idempotent, retryOnTimeout = true, requireRetr
|
|
|
402
407
|
* baseDelayMs, maxDelayMs full-jitter backoff parameters
|
|
403
408
|
* sleep, random, now injectable for tests
|
|
404
409
|
*/
|
|
405
|
-
|
|
410
|
+
async function fetchCortexOnce(url, opts = {}, policy = {}) {
|
|
406
411
|
const {
|
|
407
412
|
retries = 2, baseDelayMs = 400, maxDelayMs = 8_000,
|
|
408
413
|
retryOnTimeout = true, requireRetryAfter = false,
|
|
@@ -474,6 +479,147 @@ export async function fetchCortex(url, opts = {}, policy = {}) {
|
|
|
474
479
|
}
|
|
475
480
|
}
|
|
476
481
|
|
|
482
|
+
// ── ADR-0070 §1/§5: outage auto-file ─────────────────────────────────────────────────────────────
|
|
483
|
+
//
|
|
484
|
+
// An agent files only what the SERVER CANNOT SEE: failures that never reached the app. Everything the
|
|
485
|
+
// app answered in JSON, the server already knows about (Sentry, §7), and a 429 is our own limiter
|
|
486
|
+
// telling this seat to slow down — filing about it would be the seat arguing with the limiter.
|
|
487
|
+
//
|
|
488
|
+
// connect / DNS / timeout → outage (failure_class 'connect')
|
|
489
|
+
// non-JSON 4xx/5xx (not 401, not 429) → outage (failure_class 'infra' — firewall / protection)
|
|
490
|
+
// 503 → outage (failure_class 'refused', or 'infra' when non-JSON)
|
|
491
|
+
// 429, JSON app errors, 401 → never
|
|
492
|
+
//
|
|
493
|
+
// ⚠ STRUCTURED FIELDS ONLY. classify()'s message carries brain names and sample page titles, so it is
|
|
494
|
+
// never read here. The one body field taken is the machine `error` code of a JSON 503, and only when it
|
|
495
|
+
// is a bare snake_case token.
|
|
496
|
+
//
|
|
497
|
+
// The report POST itself passes `policy.skipAutofile`, so a failed report never files a report about
|
|
498
|
+
// itself; the spool flush POSTs the same way.
|
|
499
|
+
|
|
500
|
+
const toolContext = new AsyncLocalStorage()
|
|
501
|
+
/** Run `fn` with `tool` as the name any outage filed during it is attributed to (server.mjs wraps every tool). */
|
|
502
|
+
export const runWithTool = (tool, fn) => toolContext.run(tool, fn)
|
|
503
|
+
|
|
504
|
+
// For callers outside a tool call (hooks, doctor). Unknown paths are 'other' — the server coerces too.
|
|
505
|
+
const PATH_TOOLS = {
|
|
506
|
+
'mcp-context': 'my_context', 'session-context': 'session_context', 'grep': 'grep', 'search': 'search_org',
|
|
507
|
+
'brain/page': 'read_page', 'brain/author': 'author', 'brain/edit-page': 'edit_page', 'records/capture': 'capture_record',
|
|
508
|
+
'intake/claim': 'intake_claim', 'intake/materialize': 'intake_materialize', 'staged': 'staged_records', 'brains': 'my_brains',
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** PURE. The tool an outage on `url` belongs to: the running tool if known, else a path mapping, else 'other'. */
|
|
512
|
+
export function toolForUrl(url, running = toolContext.getStore()) {
|
|
513
|
+
if (running) return running
|
|
514
|
+
let path = ''
|
|
515
|
+
try { path = new URL(url).pathname } catch { return 'other' }
|
|
516
|
+
const m = path.match(/\/api\/(.+?)\/?$/)
|
|
517
|
+
return (m && PATH_TOOLS[m[1]]) || 'other'
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const ERROR_CODE_RE = /^[a-z_]{1,40}$/
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* PURE. Should this outcome be auto-filed, and with which fields? `outcome` is either
|
|
524
|
+
* { status, contentType, errorCode } for a response, or { thrownClass } for a thrown failure.
|
|
525
|
+
* Returns the structured report, or null.
|
|
526
|
+
*/
|
|
527
|
+
export function outageReport(outcome, { tool, requestId = null, version = CLIENT_VERSION } = {}) {
|
|
528
|
+
const base = { kind: 'outage', tool: tool ?? 'other', request_id: requestId ?? null, client_version: version ?? null }
|
|
529
|
+
if (outcome.thrownClass) {
|
|
530
|
+
if (outcome.thrownClass === 'connect') return { ...base, status: null, error_code: null, failure_class: 'connect' }
|
|
531
|
+
if (outcome.thrownClass === 'timeout') return { ...base, status: null, error_code: 'timeout', failure_class: 'connect' }
|
|
532
|
+
return null
|
|
533
|
+
}
|
|
534
|
+
const { status, contentType } = outcome
|
|
535
|
+
if (!(status >= 400) || status === 429 || status === 401) return null
|
|
536
|
+
const isJson = String(contentType ?? '').includes('application/json')
|
|
537
|
+
if (!isJson) return { ...base, status, error_code: null, failure_class: 'infra' }
|
|
538
|
+
if (status === 503) {
|
|
539
|
+
const code = typeof outcome.errorCode === 'string' && ERROR_CODE_RE.test(outcome.errorCode) ? outcome.errorCode : null
|
|
540
|
+
return { ...base, status, error_code: code, failure_class: 'refused' }
|
|
541
|
+
}
|
|
542
|
+
return null
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Auto-filing is off under `bun test` unless the test points the spool somewhere (never the real home). */
|
|
546
|
+
function autofileEnabled(env = process.env) {
|
|
547
|
+
if (env.CORTEX_AUTOFILE === '0') return false
|
|
548
|
+
return env.NODE_ENV !== 'test' || Boolean(env.CORTEX_REPORT_SPOOL_DIR)
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const authOf = (opts) => {
|
|
552
|
+
const h = opts?.headers ?? {}
|
|
553
|
+
return h.Authorization ?? h.authorization ?? null
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** The API base a URL was sent to — everything before `/api/`. */
|
|
557
|
+
const baseOf = (url) => { const i = String(url).indexOf('/api/'); return i > 0 ? String(url).slice(0, i) : null }
|
|
558
|
+
|
|
559
|
+
export const BUG_REPORTS_PATH = '/api/ops/bug-reports'
|
|
560
|
+
|
|
561
|
+
/** POST one report body. Never auto-files, never retries a non-idempotent write it cannot see land. */
|
|
562
|
+
export function postBugReport(base, authorization, body) {
|
|
563
|
+
return fetchCortex(`${base}${BUG_REPORTS_PATH}`, {
|
|
564
|
+
method: 'POST',
|
|
565
|
+
headers: { Authorization: authorization, 'Content-Type': 'application/json' },
|
|
566
|
+
body: JSON.stringify(body),
|
|
567
|
+
}, { skipAutofile: true, retries: 0 })
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
let flushing = null
|
|
571
|
+
/** Non-blocking flush of the report spool after a 2xx. At most one at a time per process. */
|
|
572
|
+
function triggerFlush(url, authorization) {
|
|
573
|
+
if (flushing) return flushing
|
|
574
|
+
const base = baseOf(url)
|
|
575
|
+
if (!base) return null
|
|
576
|
+
try { if (!readReportSpool(reportSpoolDir()).length) return null } catch { return null }
|
|
577
|
+
flushing = flushReportSpool(async (body) => {
|
|
578
|
+
const r = await postBugReport(base, authorization, body)
|
|
579
|
+
try { await r.body?.cancel() } catch { /* already consumed */ }
|
|
580
|
+
return r
|
|
581
|
+
})
|
|
582
|
+
.catch(() => null)
|
|
583
|
+
.finally(() => { flushing = null })
|
|
584
|
+
return flushing
|
|
585
|
+
}
|
|
586
|
+
/** Tests: await the in-flight flush, if any. */
|
|
587
|
+
export const pendingFlush = () => flushing
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* fetch with the D9 retry policy (see fetchCortexOnce), plus the ADR-0070 outage hook. Returns the
|
|
591
|
+
* Response (possibly a non-OK one, after retries are exhausted or refused); throws a clear "Could not
|
|
592
|
+
* reach" error when no response was ever obtained.
|
|
593
|
+
*
|
|
594
|
+
* policy.skipAutofile never auto-file this call's failure, and never flush from it (the report POSTs).
|
|
595
|
+
*/
|
|
596
|
+
export async function fetchCortex(url, opts = {}, policy = {}) {
|
|
597
|
+
const hook = !policy.skipAutofile && autofileEnabled() && authOf(opts)
|
|
598
|
+
let res
|
|
599
|
+
try {
|
|
600
|
+
res = await fetchCortexOnce(url, opts, policy)
|
|
601
|
+
} catch (e) {
|
|
602
|
+
if (hook && e?.retryClass) {
|
|
603
|
+
const r = outageReport({ thrownClass: e.retryClass }, { tool: toolForUrl(url) })
|
|
604
|
+
if (r) spoolReport(r)
|
|
605
|
+
}
|
|
606
|
+
throw e
|
|
607
|
+
}
|
|
608
|
+
if (!hook) return res
|
|
609
|
+
if (res.ok) { void triggerFlush(url, authOf(opts)); return res }
|
|
610
|
+
try {
|
|
611
|
+
const contentType = res.headers.get('content-type')
|
|
612
|
+
let errorCode = null
|
|
613
|
+
if (res.status === 503 && String(contentType ?? '').includes('application/json')) {
|
|
614
|
+
try { errorCode = JSON.parse(await res.clone().text())?.error ?? null } catch { /* not json after all */ }
|
|
615
|
+
}
|
|
616
|
+
const r = outageReport({ status: res.status, contentType, errorCode },
|
|
617
|
+
{ tool: toolForUrl(url), requestId: res.headers.get('x-vercel-id') })
|
|
618
|
+
if (r) spoolReport(r)
|
|
619
|
+
} catch { /* filing must never change what the caller gets */ }
|
|
620
|
+
return res
|
|
621
|
+
}
|
|
622
|
+
|
|
477
623
|
// Live health probe: hit /api/mcp-context with the token and classify the result.
|
|
478
624
|
// Returns { ok, status, projectCount?, requestId?, diagnosis? }.
|
|
479
625
|
export async function checkToken(token, base) {
|
package/lib/doctor.mjs
CHANGED
|
@@ -5,6 +5,14 @@ import { checkToken, checkSkills, resolveBase, resolveTokenSource } from './diag
|
|
|
5
5
|
import { renderRenameNotice } from './rename_notice.mjs'
|
|
6
6
|
import { detectSurface, renderSurface } from './surface.mjs'
|
|
7
7
|
import { queueDir, queueStatus, renderQueueNotice } from './capture_queue.mjs'
|
|
8
|
+
import { pendingNotice, renderReportNotice } from './report_spool.mjs'
|
|
9
|
+
|
|
10
|
+
/** ADR-0070 §5: issue reports filed in the BACKGROUND since the last session start. A background
|
|
11
|
+
* auto-file had no turn to tell anyone in, so this is where the person hears about it. Consumes the
|
|
12
|
+
* notice (each filing is reported once). Local, synchronous, never throws. */
|
|
13
|
+
function reportNotice(dir) {
|
|
14
|
+
try { return renderReportNotice(pendingNotice(dir ? { dir } : {})) } catch { return [] }
|
|
15
|
+
}
|
|
8
16
|
|
|
9
17
|
/** The capture-queue lines, or [] — local, synchronous, and never throws (a status line must not break). */
|
|
10
18
|
function queueNotice(dir) {
|
|
@@ -38,14 +46,17 @@ export const resolveToken = resolveTokenSource
|
|
|
38
46
|
// succeeds — most of all when it does not. They are read FIRST, locally, so the happy line can stop
|
|
39
47
|
// claiming "sessions on this machine are captured" while some demonstrably are not.
|
|
40
48
|
//
|
|
41
|
-
// `opts.queueDir`
|
|
49
|
+
// `opts.queueDir` / `opts.reportSpoolDir` exist for tests only (bun's os.homedir() ignores HOME, so a
|
|
50
|
+
// test cannot redirect it).
|
|
42
51
|
export async function runStatus(opts = {}) {
|
|
43
52
|
const out = (m) => process.stdout.write(m + '\n')
|
|
44
53
|
const queueLines = queueNotice(opts.queueDir)
|
|
54
|
+
const reportLines = reportNotice(opts.reportSpoolDir)
|
|
45
55
|
try {
|
|
46
56
|
await statusLine(out, { capturesStuck: queueLines.length > 0 })
|
|
47
57
|
} finally {
|
|
48
58
|
for (const line of queueLines) out(line)
|
|
59
|
+
for (const line of reportLines) out(line)
|
|
49
60
|
const surface = renderSurface(detectSurface(process.env), 'status')
|
|
50
61
|
if (surface) out(surface)
|
|
51
62
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// ADR-0070 — the `report_issue` tool's logic, kept out of server.mjs so it can be exercised directly.
|
|
2
|
+
//
|
|
3
|
+
// The agent is the right reporter: it saw the tool, the status and the error code, where the person
|
|
4
|
+
// saw "it didn't work". But two of the four kinds are JUDGMENTS about the person's own data or
|
|
5
|
+
// experience, so they need the person's yes (Theron, 2026-09-24) — enforced here, not only asked for
|
|
6
|
+
// in the description.
|
|
7
|
+
|
|
8
|
+
import { postBugReport, clientVersion } from './diagnose.mjs'
|
|
9
|
+
import { spoolReport } from './report_spool.mjs'
|
|
10
|
+
|
|
11
|
+
export const REPORT_KINDS = ['outage', 'bug', 'data_wrong', 'confusing']
|
|
12
|
+
export const ASK_FIRST_KINDS = new Set(['bug', 'data_wrong', 'confusing'])
|
|
13
|
+
export const FREE_TEXT_MAX = 500
|
|
14
|
+
|
|
15
|
+
export const REPORT_ISSUE_DESCRIPTION =
|
|
16
|
+
'File a structured issue report with the Agnoclast team. ' +
|
|
17
|
+
'WHEN: an Agnoclast tool failed in a way that looks like the product\'s fault, returned data the user says is wrong, or behaved confusingly. ' +
|
|
18
|
+
'OUTAGES (Agnoclast unreachable, timing out, 503, or blocked by infrastructure): file WITHOUT asking — most are already filed automatically. ' +
|
|
19
|
+
'For data_wrong, bug or confusing: ASK THE USER FIRST, and set user_approved=true ONLY if they agreed; without it the report is refused. ' +
|
|
20
|
+
'ALWAYS tell the user you filed it, in the same turn. ' +
|
|
21
|
+
'NEVER put page content, record content, names, or error message text in ANY field — use the tool name, HTTP status and the machine error code (e.g. `brain_required`) only. `summary`/`expected` describe the behaviour in generic words ("search returned a page from the wrong brain"), never the data itself.'
|
|
22
|
+
|
|
23
|
+
/** PURE. Validate + shape the report. Returns { report } or { error }. */
|
|
24
|
+
export function buildReport(args, version = clientVersion()) {
|
|
25
|
+
const kind = args?.kind
|
|
26
|
+
if (!REPORT_KINDS.includes(kind)) return { error: `kind must be one of ${REPORT_KINDS.join(', ')}` }
|
|
27
|
+
const approved = args?.user_approved === true
|
|
28
|
+
if (ASK_FIRST_KINDS.has(kind) && !approved) {
|
|
29
|
+
return { error: `NOT FILED. A ${kind} report needs the user's agreement first: ask them, and call again with user_approved=true only if they said yes.` }
|
|
30
|
+
}
|
|
31
|
+
const status = Number.isInteger(args?.status) ? args.status : null
|
|
32
|
+
const code = typeof args?.error_code === 'string' && /^[a-z_]{1,40}$/.test(args.error_code) ? args.error_code : (args?.error_code ? 'other' : null)
|
|
33
|
+
const report = {
|
|
34
|
+
kind, tool: typeof args?.tool === 'string' && /^[a-z0-9_]{1,64}$/.test(args.tool) ? args.tool : 'other',
|
|
35
|
+
status, error_code: code, failure_class: null, request_id: null, client_version: version ?? null,
|
|
36
|
+
}
|
|
37
|
+
// Free text travels only on a user-approved judged kind. An outage is fields, never prose.
|
|
38
|
+
if (ASK_FIRST_KINDS.has(kind) && approved) {
|
|
39
|
+
report.user_approved = true
|
|
40
|
+
const cap = (s) => (typeof s === 'string' && s.trim() ? s.trim().slice(0, FREE_TEXT_MAX) : undefined)
|
|
41
|
+
if (cap(args.summary)) report.summary = cap(args.summary)
|
|
42
|
+
if (cap(args.expected)) report.expected = cap(args.expected)
|
|
43
|
+
}
|
|
44
|
+
return { report }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* File it now; on failure spool it (it is then sent after the next successful call). Returns the tool
|
|
49
|
+
* result text and whether it is an error. `post` is injectable for tests.
|
|
50
|
+
*/
|
|
51
|
+
export async function fileIssue(args, { base, token, version, post = postBugReport, spool = spoolReport } = {}) {
|
|
52
|
+
const built = buildReport(args, version)
|
|
53
|
+
if (built.error) return { isError: true, text: built.error }
|
|
54
|
+
const { report } = built
|
|
55
|
+
const body = { surface: 'agent', ...report }
|
|
56
|
+
let res = null
|
|
57
|
+
try { res = await post(base, `Bearer ${token}`, body) } catch { res = null }
|
|
58
|
+
if (res && res.status >= 200 && res.status < 300) {
|
|
59
|
+
let j = {}
|
|
60
|
+
try { j = await res.json() } catch { /* shape drift — still filed */ }
|
|
61
|
+
const count = Number(j?.count) > 1 ? ` It has now been reported ${j.count} times.` : ''
|
|
62
|
+
return { isError: false, text: `✓ Filed ${report.kind} report${j?.id ? ` ${j.id}` : ''} for \`${report.tool}\`.${count} Tell the user you filed it, now.` }
|
|
63
|
+
}
|
|
64
|
+
if (res && res.status >= 400 && res.status < 500 && res.status !== 429) {
|
|
65
|
+
return { isError: true, text: `NOT FILED — Agnoclast rejected the report (HTTP ${res.status}). Tell the user it could not be filed.` }
|
|
66
|
+
}
|
|
67
|
+
const saved = spool(report)
|
|
68
|
+
return saved
|
|
69
|
+
? { isError: false, text: `Agnoclast could not take the report right now (${res ? `HTTP ${res.status}` : 'unreachable'}), so it was SAVED on this machine and will be sent automatically after the next successful call. Tell the user it is saved, not yet filed.` }
|
|
70
|
+
: { isError: true, text: 'NOT FILED — Agnoclast was unreachable and the report could not be saved locally either. Tell the user.' }
|
|
71
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// ADR-0070 §5 — the local spool for bug reports an agent files, most of all the OUTAGE reports that
|
|
2
|
+
// fetchCortex files on its own when Agnoclast cannot be reached at all.
|
|
3
|
+
//
|
|
4
|
+
// 🔴 WHY A SPOOL. An outage report is, by construction, filed at the moment the API is unreachable, so
|
|
5
|
+
// the POST that would carry it fails for the same reason. It waits here and is sent after the next 2xx
|
|
6
|
+
// from any call (fetchCortex triggers flush()).
|
|
7
|
+
//
|
|
8
|
+
// STANDALONE ON PURPOSE. It borrows capture_queue.mjs's RULES — dir 0700, files 0600, every write
|
|
9
|
+
// tmp+rename, a drop ledger, nothing leaves silently — but not its code, which is bound to session
|
|
10
|
+
// generations and protects session capture. A defect here must never be able to cost a capture.
|
|
11
|
+
//
|
|
12
|
+
// Layout — ~/.cortex/report-spool/:
|
|
13
|
+
// <dedup key>.json one entry per failure SIGNATURE (kind, tool, status, error_code). A repeat
|
|
14
|
+
// increments `count` and moves `last_seen`, so a flapping API is one report.
|
|
15
|
+
// _dropped.json what the cap or a final server rejection forced out, for 7 days.
|
|
16
|
+
// _filed.json what flush() sent in the background, until the next session start reports it.
|
|
17
|
+
// _notice.json when that report was last made (pendingNotice's watermark).
|
|
18
|
+
//
|
|
19
|
+
// ⚠ STRUCTURED FIELDS ONLY. Nothing here ever holds an error MESSAGE — classify()'s text carries brain
|
|
20
|
+
// names and page titles. Only an interactive, user-approved report_issue may carry summary/expected.
|
|
21
|
+
|
|
22
|
+
import { mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs'
|
|
23
|
+
import { homedir, tmpdir } from 'node:os'
|
|
24
|
+
import { join } from 'node:path'
|
|
25
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
26
|
+
|
|
27
|
+
export const SPOOL_MAX_ENTRIES = 50
|
|
28
|
+
export const DROPPED_WINDOW_DAYS = 7
|
|
29
|
+
const DAY_MS = 24 * 60 * 60_000
|
|
30
|
+
const LEDGER_MAX = 100
|
|
31
|
+
|
|
32
|
+
/** The spool directory. CORTEX_REPORT_SPOOL_DIR exists for tests (bun's os.homedir() ignores HOME), and
|
|
33
|
+
* under `bun test` without it the real home is never touched — a test must not consume a real notice. */
|
|
34
|
+
export function spoolDir(env = process.env) {
|
|
35
|
+
if (env.CORTEX_REPORT_SPOOL_DIR) return env.CORTEX_REPORT_SPOOL_DIR
|
|
36
|
+
if (env.NODE_ENV === 'test') return join(tmpdir(), `cortex-report-spool-test-${process.pid}`)
|
|
37
|
+
return join(homedir(), '.cortex', 'report-spool')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** PURE. The dedup key: kind, tool, status, error_code — NOT version (one outage spans releases). */
|
|
41
|
+
export function reportKey({ kind, tool, status, error_code }) {
|
|
42
|
+
const sig = JSON.stringify([kind ?? null, tool ?? null, status ?? null, error_code ?? null])
|
|
43
|
+
return createHash('sha256').update(sig).digest('hex').slice(0, 32)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function atomicWrite(dir, name, value) {
|
|
47
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
48
|
+
const file = join(dir, name)
|
|
49
|
+
const tmp = `${file}.${process.pid}.${randomBytes(3).toString('hex')}.tmp`
|
|
50
|
+
writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 })
|
|
51
|
+
renameSync(tmp, file) // a concurrent reader never sees half a file
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const readJson = (file, fallback) => { try { return JSON.parse(readFileSync(file, 'utf8')) } catch { return fallback } }
|
|
55
|
+
|
|
56
|
+
/** Every readable entry. An unreadable one is removed AND recorded — never skipped forever. */
|
|
57
|
+
export function readEntries(dir = spoolDir()) {
|
|
58
|
+
let names
|
|
59
|
+
try { names = readdirSync(dir) } catch { return [] }
|
|
60
|
+
const out = []
|
|
61
|
+
for (const n of names) {
|
|
62
|
+
if (!n.endsWith('.json') || n.startsWith('_')) continue
|
|
63
|
+
const e = readJson(join(dir, n), null)
|
|
64
|
+
if (e && typeof e === 'object' && e.key) { out.push(e); continue }
|
|
65
|
+
try { unlinkSync(join(dir, n)) } catch { /* raced */ }
|
|
66
|
+
ledgerAppend(dir, '_dropped.json', { at: Date.now(), kind: null, tool: null, reason: 'unreadable spool file' })
|
|
67
|
+
}
|
|
68
|
+
return out
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function ledgerRead(dir, name, now = Date.now()) {
|
|
72
|
+
const list = readJson(join(dir, name), [])
|
|
73
|
+
return Array.isArray(list) ? list.filter((d) => now - (d?.at ?? 0) <= DROPPED_WINDOW_DAYS * DAY_MS) : []
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function ledgerAppend(dir, name, item, now = Date.now()) {
|
|
77
|
+
try { atomicWrite(dir, name, [...ledgerRead(dir, name, now), item].slice(-LEDGER_MAX)) } catch { /* never throw from a ledger */ }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const readDropped = (dir = spoolDir(), now = Date.now()) => ledgerRead(dir, '_dropped.json', now)
|
|
81
|
+
|
|
82
|
+
const dropRow = (e, reason, at) => ({ at, kind: e.kind, tool: e.tool, status: e.status ?? null, count: e.count ?? 1, reason })
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Spool one report. A repeat of the same signature increments `count`; a NEW signature past the cap
|
|
86
|
+
* evicts the oldest (by last_seen) into the drop ledger. Returns the entry written, or null. Never throws.
|
|
87
|
+
*/
|
|
88
|
+
export function spoolReport(report, { dir = spoolDir(), now = Date.now() } = {}) {
|
|
89
|
+
try {
|
|
90
|
+
const key = reportKey(report)
|
|
91
|
+
const cur = readJson(join(dir, `${key}.json`), null)
|
|
92
|
+
const entry = cur?.key
|
|
93
|
+
? { ...cur, ...report, key, rev: randomBytes(6).toString('hex'), count: (cur.count ?? 1) + (report.count ?? 1), last_seen: now }
|
|
94
|
+
: { ...report, key, rev: randomBytes(6).toString('hex'), count: report.count ?? 1, first_seen: now, last_seen: now, attempts: 0 }
|
|
95
|
+
if (!cur?.key) {
|
|
96
|
+
const others = readEntries(dir).sort((a, b) => (a.last_seen ?? 0) - (b.last_seen ?? 0))
|
|
97
|
+
while (others.length >= SPOOL_MAX_ENTRIES) {
|
|
98
|
+
const old = others.shift()
|
|
99
|
+
try { unlinkSync(join(dir, `${old.key}.json`)) } catch { /* raced */ }
|
|
100
|
+
ledgerAppend(dir, '_dropped.json', dropRow(old, 'spool full', now), now)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
atomicWrite(dir, `${key}.json`, entry)
|
|
104
|
+
return entry
|
|
105
|
+
} catch { return null }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** PURE. The wire body for one entry — the §2 contract, and nothing else. */
|
|
109
|
+
export function wireBody(e) {
|
|
110
|
+
const body = {
|
|
111
|
+
surface: 'agent', kind: e.kind, tool: e.tool ?? 'other', status: e.status ?? null, error_code: e.error_code ?? null,
|
|
112
|
+
failure_class: e.failure_class ?? null, request_id: e.request_id ?? null, client_version: e.client_version ?? null,
|
|
113
|
+
count: e.count ?? 1,
|
|
114
|
+
}
|
|
115
|
+
if (e.user_approved === true) {
|
|
116
|
+
body.user_approved = true
|
|
117
|
+
if (e.summary) body.summary = e.summary
|
|
118
|
+
if (e.expected) body.expected = e.expected
|
|
119
|
+
}
|
|
120
|
+
return body
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Send every entry via `postFn(body)` → { status } (or throws). 2xx removes it (any repeats spooled
|
|
125
|
+
* meanwhile stay); a final 4xx (not 429) moves it to the drop ledger; 429/5xx/network keep it with
|
|
126
|
+
* attempts+1. Returns { sent, kept, dropped }.
|
|
127
|
+
*/
|
|
128
|
+
export async function flush(postFn, { dir = spoolDir(), now = Date.now } = {}) {
|
|
129
|
+
const out = { sent: 0, kept: 0, dropped: 0 }
|
|
130
|
+
for (const e of readEntries(dir)) {
|
|
131
|
+
let status = 0
|
|
132
|
+
try { status = Number((await postFn(wireBody(e)))?.status) || 0 } catch { status = 0 }
|
|
133
|
+
const file = join(dir, `${e.key}.json`)
|
|
134
|
+
const cur = readJson(file, null)
|
|
135
|
+
if (status >= 200 && status < 300) {
|
|
136
|
+
out.sent++
|
|
137
|
+
ledgerAppend(dir, '_filed.json', { at: now(), kind: e.kind, tool: e.tool, status: e.status ?? null, count: e.count ?? 1 }, now())
|
|
138
|
+
if (cur?.rev && cur.rev !== e.rev && (cur.count ?? 1) > (e.count ?? 1)) {
|
|
139
|
+
atomicWrite(dir, `${e.key}.json`, { ...cur, count: cur.count - (e.count ?? 1) })
|
|
140
|
+
} else { try { unlinkSync(file) } catch { /* raced */ } }
|
|
141
|
+
} else if (status >= 400 && status < 500 && status !== 429) {
|
|
142
|
+
out.dropped++
|
|
143
|
+
try { unlinkSync(file) } catch { /* raced */ }
|
|
144
|
+
ledgerAppend(dir, '_dropped.json', dropRow(e, `rejected ${status}`, now()), now())
|
|
145
|
+
} else {
|
|
146
|
+
out.kept++
|
|
147
|
+
if (cur?.key) atomicWrite(dir, `${e.key}.json`, { ...cur, attempts: (cur.attempts ?? 0) + 1 })
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return out
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* What was filed in the BACKGROUND (and what was dropped) since the last notice, then move the
|
|
155
|
+
* watermark — so each is reported at exactly one session start. Returns { filed, dropped }.
|
|
156
|
+
*/
|
|
157
|
+
export function pendingNotice({ dir = spoolDir(), now = Date.now() } = {}) {
|
|
158
|
+
try {
|
|
159
|
+
const since = Number(readJson(join(dir, '_notice.json'), {})?.at) || 0
|
|
160
|
+
const filed = ledgerRead(dir, '_filed.json', now).filter((x) => x.at > since)
|
|
161
|
+
const dropped = ledgerRead(dir, '_dropped.json', now).filter((x) => x.at > since)
|
|
162
|
+
if (filed.length || dropped.length) atomicWrite(dir, '_notice.json', { at: now })
|
|
163
|
+
return { filed, dropped }
|
|
164
|
+
} catch { return { filed: [], dropped: [] } }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** PURE. The session-start lines for pendingNotice()'s result — empty when there is nothing to say. */
|
|
168
|
+
export function renderReportNotice({ filed = [], dropped = [] } = {}) {
|
|
169
|
+
const lines = []
|
|
170
|
+
const outages = filed.filter((x) => x.kind === 'outage').length
|
|
171
|
+
const other = filed.length - outages
|
|
172
|
+
if (outages) lines.push(`Agnoclast: ${outages} outage report(s) were filed automatically while you were away — tell the user.`)
|
|
173
|
+
if (other) lines.push(`Agnoclast: ${other} saved issue report(s) were sent once Agnoclast was reachable again — tell the user.`)
|
|
174
|
+
if (dropped.length) lines.push(`Agnoclast: ⚠ ${dropped.length} issue report(s) could not be sent and were dropped (${dropped[0].reason}).`)
|
|
175
|
+
return lines
|
|
176
|
+
}
|
package/lib/server.mjs
CHANGED
|
@@ -5,7 +5,8 @@ import { writeFileSync, mkdirSync, readFileSync, statSync } from 'fs'
|
|
|
5
5
|
import { homedir } from 'os'
|
|
6
6
|
import { join } from 'path'
|
|
7
7
|
import { createHash, randomUUID } from 'crypto'
|
|
8
|
-
import { fetchCortex, classify, resolveBase, resolveEnvToken, setSessionKey, setClientVersion } from './diagnose.mjs'
|
|
8
|
+
import { fetchCortex, classify, resolveBase, resolveEnvToken, setSessionKey, setClientVersion, runWithTool } from './diagnose.mjs'
|
|
9
|
+
import { REPORT_KINDS, REPORT_ISSUE_DESCRIPTION, fileIssue } from './report_issue.mjs'
|
|
9
10
|
import { folderClaimLine } from './folder_claims.mjs'
|
|
10
11
|
import { resolveSessionKey, resolveLogSessionId } from './session_key.mjs'
|
|
11
12
|
import { runSendImessage } from './imessage_send.mjs'
|
|
@@ -247,6 +248,11 @@ export async function runServer(version) {
|
|
|
247
248
|
}
|
|
248
249
|
|
|
249
250
|
const server = new McpServer({ name: 'cortex', version })
|
|
251
|
+
// ADR-0070: every tool runs with its NAME in async context, so an outage fetchCortex auto-files is
|
|
252
|
+
// attributed to the tool that hit it rather than guessed from a URL shared by several tools.
|
|
253
|
+
const registerTool = server.registerTool.bind(server)
|
|
254
|
+
server.registerTool = (name, config, handler) =>
|
|
255
|
+
registerTool(name, config, (...args) => runWithTool(name, () => handler(...args)))
|
|
250
256
|
|
|
251
257
|
|
|
252
258
|
// ── THE PRE-CLAIM NUDGE, rendered ───────────────────────────────────────────────────────────────
|
|
@@ -362,8 +368,12 @@ function renderNudge(payload) {
|
|
|
362
368
|
const body = await res.text()
|
|
363
369
|
throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
|
|
364
370
|
}
|
|
365
|
-
const
|
|
366
|
-
|
|
371
|
+
const ctx = await res.json()
|
|
372
|
+
// ⭐ RENDER THE ARRIVALS DELTA HERE TOO. It was wired to grep / read_page / list_records only,
|
|
373
|
+
// so a session doing pipeline work never saw it — measured 2026-09-24, eighteen hours with a
|
|
374
|
+
// stale watermark while three arrivals landed unannounced. This is the tool an agent calls at
|
|
375
|
+
// session start and on every topic shift, which is when "mail arrived" is worth knowing.
|
|
376
|
+
return { content: [{ type: 'text', text: (ctx?.context ?? '') + renderArrival(ctx) }] }
|
|
367
377
|
},
|
|
368
378
|
)
|
|
369
379
|
|
|
@@ -855,8 +865,12 @@ function renderNudge(payload) {
|
|
|
855
865
|
const body = await res.text()
|
|
856
866
|
throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
|
|
857
867
|
}
|
|
858
|
-
const
|
|
859
|
-
|
|
868
|
+
const ctx = await res.json()
|
|
869
|
+
// ⭐ RENDER THE ARRIVALS DELTA HERE TOO. It was wired to grep / read_page / list_records only,
|
|
870
|
+
// so a session doing pipeline work never saw it — measured 2026-09-24, eighteen hours with a
|
|
871
|
+
// stale watermark while three arrivals landed unannounced. This is the tool an agent calls at
|
|
872
|
+
// session start and on every topic shift, which is when "mail arrived" is worth knowing.
|
|
873
|
+
return { content: [{ type: 'text', text: (ctx?.context ?? '') + renderArrival(ctx) }] }
|
|
860
874
|
},
|
|
861
875
|
)
|
|
862
876
|
|
|
@@ -4259,7 +4273,10 @@ function renderNudge(payload) {
|
|
|
4259
4273
|
|
|
4260
4274
|
const records = out?.records ?? []
|
|
4261
4275
|
if (records.length === 0) {
|
|
4262
|
-
|
|
4276
|
+
// ⚠ THE DELTA STILL SHIPS ON THE EMPTY PATH. "Nothing waiting for a home" and "nothing has
|
|
4277
|
+
// arrived" are different facts, and an empty digest is exactly when a silent counter reads
|
|
4278
|
+
// as all-clear.
|
|
4279
|
+
return { content: [{ type: 'text', text: 'Nothing waiting for a home.' + renderArrival(out) }] }
|
|
4263
4280
|
}
|
|
4264
4281
|
const lines = records.map((r) => {
|
|
4265
4282
|
const homes = r.currentHomes?.length ? r.currentHomes.join(', ') : 'nothing'
|
|
@@ -4269,7 +4286,7 @@ function renderNudge(payload) {
|
|
|
4269
4286
|
return {
|
|
4270
4287
|
content: [{
|
|
4271
4288
|
type: 'text',
|
|
4272
|
-
text: `${records.length} record(s) waiting for a home:\n\n${lines.join('\n\n')}\n\nRecognize any as your own work? claim_record it now, then route_record once you know where it belongs
|
|
4289
|
+
text: `${records.length} record(s) waiting for a home:\n\n${lines.join('\n\n')}\n\nRecognize any as your own work? claim_record it now, then route_record once you know where it belongs.` + renderArrival(out),
|
|
4273
4290
|
}],
|
|
4274
4291
|
}
|
|
4275
4292
|
},
|
|
@@ -5177,5 +5194,28 @@ function renderNudge(payload) {
|
|
|
5177
5194
|
},
|
|
5178
5195
|
)
|
|
5179
5196
|
|
|
5197
|
+
// ADR-0070 — the agent files the bug report. Outages are filed without asking (and most already are,
|
|
5198
|
+
// by fetchCortex); the three JUDGED kinds need the user's yes, enforced in report_issue.mjs.
|
|
5199
|
+
server.registerTool(
|
|
5200
|
+
'report_issue',
|
|
5201
|
+
{
|
|
5202
|
+
title: 'Report an Agnoclast issue',
|
|
5203
|
+
description: REPORT_ISSUE_DESCRIPTION,
|
|
5204
|
+
inputSchema: {
|
|
5205
|
+
kind: z.enum(REPORT_KINDS).describe('outage = Agnoclast unreachable/timing out/503/blocked; bug = a tool misbehaved; data_wrong = the user says the data shown is wrong; confusing = a tool\'s behaviour or wording confused you or the user'),
|
|
5206
|
+
tool: z.string().describe('the Agnoclast tool name involved, e.g. `grep` or `read_page`; `other` if none'),
|
|
5207
|
+
status: z.number().int().optional().describe('the HTTP status you saw, if any'),
|
|
5208
|
+
error_code: z.string().optional().describe('the machine error code only (e.g. `brain_required`) — NEVER the error message text'),
|
|
5209
|
+
summary: z.string().optional().describe('bug/data_wrong/confusing only, ≤500 chars: the behaviour in generic words. NO page content, record content, names or error text'),
|
|
5210
|
+
expected: z.string().optional().describe('bug/data_wrong/confusing only, ≤500 chars: what should have happened, in generic words'),
|
|
5211
|
+
user_approved: z.boolean().describe('true ONLY if you asked the user and they agreed to file this. Required true for bug, data_wrong and confusing; ignored for outage'),
|
|
5212
|
+
},
|
|
5213
|
+
},
|
|
5214
|
+
async (args) => {
|
|
5215
|
+
const r = await fileIssue(args, { base: BASE, token: TOKEN, version })
|
|
5216
|
+
return r.isError ? toolError(r.text) : { content: [{ type: 'text', text: r.text }] }
|
|
5217
|
+
},
|
|
5218
|
+
)
|
|
5219
|
+
|
|
5180
5220
|
await server.connect(new StdioServerTransport())
|
|
5181
5221
|
}
|
package/package.json
CHANGED