@theronap/agnoclast-mcp 0.9.171 → 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 +122 -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
|
|
|
@@ -4106,6 +4120,81 @@ function renderNudge(payload) {
|
|
|
4106
4120
|
},
|
|
4107
4121
|
)
|
|
4108
4122
|
|
|
4123
|
+
// ⭐ THE OTHER HALF OF set_routing_identifier (2026-09-24). A claim was ONE CALL to create and,
|
|
4124
|
+
// through this server, impossible to remove — so a wrong brain decision was permanent. That
|
|
4125
|
+
// contradicts the thing ADR-0066 is built on: filing is a judgment, and judgments get revised.
|
|
4126
|
+
//
|
|
4127
|
+
// ⚠ THE ENGINE AND THE ENDPOINT BOTH ALREADY EXISTED. `clearPageRoutingIdentifier` and the DELETE
|
|
4128
|
+
// on /api/brain/routing-identifiers have been there the whole time; only the agent-facing verb was
|
|
4129
|
+
// missing, so the capability was real and unreachable. Ninth instance of that shape this week.
|
|
4130
|
+
server.registerTool(
|
|
4131
|
+
'unclaim_routing_identifier',
|
|
4132
|
+
{
|
|
4133
|
+
title: 'Remove a routing claim from a page',
|
|
4134
|
+
description:
|
|
4135
|
+
'Remove an identifier claim from a page — the counterpart to `set_routing_identifier`. Use it when a claim sends mail to the wrong brain, when a page was claimed by mistake, or when TWO brains claim the same key and you want one of them to stop (that is the case where nothing routes at all, because a split stages rather than picking). ' +
|
|
4136
|
+
'⭐ THE RESPONSE TELLS YOU WHAT ROUTING DOES NOW, which is the reason to call it: removing a claim moves the identifier between "routes", "stages because nothing claims it" and "stages because several do", and the delete count alone cannot tell you which. Computed with the router\'s own logic. ' +
|
|
4137
|
+
'⚠ HISTORY IS NOT UNDONE BY DEFAULT. `set_routing_identifier` HEALS history — it attaches every record already carrying the identifier — but removing the claim leaves those attachments in place, and the response says how many. That asymmetry is deliberate: a claim is a ROUTING RULE, an attachment is a RECORD\'S PLACEMENT, and one such claim was measured accounting for 1,271 attachments on a single page. Pass `detach: true` only when you mean to unfile them too; it never touches a governing attachment, or one a person or an LLM made rather than this claim. ' +
|
|
4138
|
+
'⚠ REVERSIBLE BY CONSTRUCTION: `set_routing_identifier` puts it back in one call and re-heals history. No content is destroyed here, which is why this needs no dry run.',
|
|
4139
|
+
inputSchema: {
|
|
4140
|
+
kind: z.enum(['project', 'person', 'org', 'user']).describe('the page kind'),
|
|
4141
|
+
name: z.string().optional().describe('page title (or pass ref)'),
|
|
4142
|
+
ref: z.string().optional().describe('node ref from read_page — prefer over name when available'),
|
|
4143
|
+
brain: z.string().optional().describe('brain label when the title is ambiguous across brains — REQUIRED in practice when two brains hold a page of the same name, which is the usual reason you are here'),
|
|
4144
|
+
identifier: z.string().describe('the claim to remove, e.g. email:someone@example.com or repo:owner/name. See `my_routing_claims` for what is claimed where'),
|
|
4145
|
+
detach: z.boolean().optional().describe('also remove the attachments this claim made (default false). Leaves governing attachments and anything a person or LLM attached'),
|
|
4146
|
+
},
|
|
4147
|
+
},
|
|
4148
|
+
async ({ kind, name, ref, brain, identifier, detach }) => {
|
|
4149
|
+
if (!name && !ref) return toolError('Pass name or ref')
|
|
4150
|
+
let res
|
|
4151
|
+
try {
|
|
4152
|
+
res = await fetchCortex(`${BASE}/api/brain/routing-identifiers`, {
|
|
4153
|
+
method: 'DELETE',
|
|
4154
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
4155
|
+
body: JSON.stringify({ kind, name, ref, brain, identifier, detach: detach === true }),
|
|
4156
|
+
})
|
|
4157
|
+
} catch (e) {
|
|
4158
|
+
return toolError(`Could not remove the routing claim: ${e.message}`)
|
|
4159
|
+
}
|
|
4160
|
+
const out = await res.json().catch(() => null)
|
|
4161
|
+
if (!res.ok) {
|
|
4162
|
+
if (out?.candidates?.length) {
|
|
4163
|
+
return toolError(`That page title is ambiguous across brains — pass \`brain\`. Candidates: ${out.candidates.join(', ')}`)
|
|
4164
|
+
}
|
|
4165
|
+
return toolError(`Could not remove the routing claim: ${out?.error ?? res.status}`)
|
|
4166
|
+
}
|
|
4167
|
+
// A ZERO IS A VERDICT, NOT AN ERROR — the same convention my_routing_claims states. "No such
|
|
4168
|
+
// claim" is worth saying plainly, because the usual cause is that the claim is on a DIFFERENT
|
|
4169
|
+
// page (or in a different brain) than the caller assumed, and retrying the same call will not
|
|
4170
|
+
// find it either.
|
|
4171
|
+
if (!out?.removed) {
|
|
4172
|
+
return {
|
|
4173
|
+
content: [{
|
|
4174
|
+
type: 'text',
|
|
4175
|
+
text: `No claim on ${identifier} was found on that page, so nothing changed. That usually means the claim lives on a different page or in another brain — \`my_routing_claims ${identifier}\` shows every page that claims it.`,
|
|
4176
|
+
}],
|
|
4177
|
+
}
|
|
4178
|
+
}
|
|
4179
|
+
const left = typeof out?.attachmentsLeft === 'number' ? out.attachmentsLeft : null
|
|
4180
|
+
const historyLine = out?.detached
|
|
4181
|
+
? `\n ${out.detached} attachment(s) this claim had made were also removed; governing attachments and anything a person or an LLM attached were left alone.`
|
|
4182
|
+
: left
|
|
4183
|
+
? `\n ⚠ ${left} record(s) this claim previously attached to that page are STILL attached — removing the rule does not unfile history. Re-run with \`detach: true\` if they should come off too.`
|
|
4184
|
+
: ''
|
|
4185
|
+
return {
|
|
4186
|
+
content: [{
|
|
4187
|
+
type: 'text',
|
|
4188
|
+
text:
|
|
4189
|
+
`Removed the claim on ${out?.identifier ?? identifier}.\n` +
|
|
4190
|
+
` ➜ ${out?.routing?.verdict ?? '(routing outcome unavailable)'}` +
|
|
4191
|
+
historyLine +
|
|
4192
|
+
`\n Reversible: \`set_routing_identifier\` puts it back and re-attaches history.`,
|
|
4193
|
+
}],
|
|
4194
|
+
}
|
|
4195
|
+
},
|
|
4196
|
+
)
|
|
4197
|
+
|
|
4109
4198
|
// ── Gate 4 record triage, over timeline_claims ────────────────────────────────────────────
|
|
4110
4199
|
// A connector event materializes in seconds and has no idea what the work WAS. The session that
|
|
4111
4200
|
// did the work knows exactly, and arrives later. These three tools are that handoff: look at what
|
|
@@ -4184,7 +4273,10 @@ function renderNudge(payload) {
|
|
|
4184
4273
|
|
|
4185
4274
|
const records = out?.records ?? []
|
|
4186
4275
|
if (records.length === 0) {
|
|
4187
|
-
|
|
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) }] }
|
|
4188
4280
|
}
|
|
4189
4281
|
const lines = records.map((r) => {
|
|
4190
4282
|
const homes = r.currentHomes?.length ? r.currentHomes.join(', ') : 'nothing'
|
|
@@ -4194,7 +4286,7 @@ function renderNudge(payload) {
|
|
|
4194
4286
|
return {
|
|
4195
4287
|
content: [{
|
|
4196
4288
|
type: 'text',
|
|
4197
|
-
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),
|
|
4198
4290
|
}],
|
|
4199
4291
|
}
|
|
4200
4292
|
},
|
|
@@ -5102,5 +5194,28 @@ function renderNudge(payload) {
|
|
|
5102
5194
|
},
|
|
5103
5195
|
)
|
|
5104
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
|
+
|
|
5105
5220
|
await server.connect(new StdioServerTransport())
|
|
5106
5221
|
}
|
package/package.json
CHANGED