@theronap/cortex-mcp 0.9.48 → 0.9.50
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 +29 -3
- package/lib/red_link_triage.mjs +30 -0
- package/lib/server.mjs +46 -20
- package/package.json +1 -1
package/lib/diagnose.mjs
CHANGED
|
@@ -69,7 +69,19 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
|
69
69
|
export function classify(status, contentType, bodyText, requestId) {
|
|
70
70
|
const isJson = (contentType ?? '').includes('application/json')
|
|
71
71
|
let appError = null
|
|
72
|
-
|
|
72
|
+
let appHint = null
|
|
73
|
+
if (isJson) {
|
|
74
|
+
try {
|
|
75
|
+
const parsed = JSON.parse(bodyText)
|
|
76
|
+
appError = parsed?.error ?? null
|
|
77
|
+
// `hint` carries the RECOVERY instruction for the errors an agent is meant to act on, not
|
|
78
|
+
// just report: 409 would_drop ("re-author including the dropped sections…") and 413 too_large
|
|
79
|
+
// ("split the section…"). It used to be dropped here — only `error` survived — so the agent
|
|
80
|
+
// saw a bare code like "would_drop_sections" and had no idea what to do with it. The routes
|
|
81
|
+
// were writing careful self-heal guidance that never reached anyone.
|
|
82
|
+
appHint = typeof parsed?.hint === 'string' && parsed.hint.trim() ? parsed.hint.trim() : null
|
|
83
|
+
} catch { /* not json after all */ }
|
|
84
|
+
}
|
|
73
85
|
const rid = requestId ? ` [request id: ${requestId}]` : ''
|
|
74
86
|
|
|
75
87
|
if (status === 401 || (isJson && appError === 'invalid token')) {
|
|
@@ -90,7 +102,7 @@ export function classify(status, contentType, bodyText, requestId) {
|
|
|
90
102
|
}
|
|
91
103
|
return {
|
|
92
104
|
kind: 'app', retriable: status >= 500,
|
|
93
|
-
message: `Cortex API ${status}: ${appError ?? 'unknown error'}.${rid}`,
|
|
105
|
+
message: `Cortex API ${status}: ${appError ?? 'unknown error'}.${appHint ? ` ${appHint}` : ''}${rid}`,
|
|
94
106
|
}
|
|
95
107
|
}
|
|
96
108
|
|
|
@@ -104,8 +116,22 @@ export function classify(status, contentType, bodyText, requestId) {
|
|
|
104
116
|
// WORSE — 3 unbounded attempts stacked. Now worst case = (retries+1) * timeoutMs + backoff, and a
|
|
105
117
|
// timed-out attempt is treated as transient (retried, then surfaced as the reach error the callers
|
|
106
118
|
// already swallow). Tunable via CORTEX_HTTP_TIMEOUT_MS; per-call override via opts.timeoutMs.
|
|
119
|
+
// ADR-0020 Stage 2 — this process's session identity, stamped on every request so the server can
|
|
120
|
+
// resolve THIS session's write pointer instead of the person-wide one. Set once by the MCP server at
|
|
121
|
+
// startup (setSessionKey); left null in the one-shot hook processes (capture, context_log), which
|
|
122
|
+
// have no session of their own and correctly fall back to the account pointer.
|
|
123
|
+
//
|
|
124
|
+
// Injected HERE rather than at each call site because fetchCortex is the single chokepoint every
|
|
125
|
+
// caller already funnels through — ~30 call sites pass their own `headers` object, and a header this
|
|
126
|
+
// load-bearing must not depend on remembering it at each one.
|
|
127
|
+
let SESSION_KEY = null
|
|
128
|
+
export function setSessionKey(key) { SESSION_KEY = key || null }
|
|
129
|
+
|
|
107
130
|
export async function fetchCortex(url, opts = {}, { retries = 2, baseDelayMs = 400 } = {}) {
|
|
108
|
-
const { timeoutMs: optTimeout, signal: callerSignal, ...
|
|
131
|
+
const { timeoutMs: optTimeout, signal: callerSignal, ...rest } = opts
|
|
132
|
+
const fetchOpts = SESSION_KEY
|
|
133
|
+
? { ...rest, headers: { ...(rest.headers ?? {}), 'x-cortex-session-key': SESSION_KEY } }
|
|
134
|
+
: rest
|
|
109
135
|
const timeoutMs = optTimeout ?? (Number(process.env.CORTEX_HTTP_TIMEOUT_MS) || 15_000)
|
|
110
136
|
let lastErr
|
|
111
137
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Red-link triage rendering (Mechanism 2) — the text an agent sees on a read_page miss.
|
|
2
|
+
//
|
|
3
|
+
// Standalone + dependency-free (like grep_cli.mjs) so it unit-tests without pulling the MCP SDK in.
|
|
4
|
+
// server.mjs owns the fetch; this owns the wording.
|
|
5
|
+
//
|
|
6
|
+
// The demoted arm is the one that matters. A superseded/historical page stays greppable
|
|
7
|
+
// (grep_brain_sections applies no validity filter) but read_page won't serve it (authored_page_tiers
|
|
8
|
+
// filters validity='current'), so an agent that greps a hit and then read_page's it lands here — and
|
|
9
|
+
// used to be told "no page yet, author it now". Authoring is pre-authorized, so the compliant next step
|
|
10
|
+
// was to overwrite a page a human deliberately retired, with nothing to catch it. Absence invites
|
|
11
|
+
// authoring; a demotion forbids it.
|
|
12
|
+
//
|
|
13
|
+
// Keyed off the ADDITIVE `t.demoted` flag, checked BEFORE category — never off a new category value. A
|
|
14
|
+
// server predating the flag omits it and every arm behaves exactly as before, so client and server can
|
|
15
|
+
// ship in either order.
|
|
16
|
+
|
|
17
|
+
// PURE: triage payload → miss text.
|
|
18
|
+
export function renderTriage(t, name) {
|
|
19
|
+
const refs = t.tracked
|
|
20
|
+
? ` It's referenced by ${t.refCount} page${t.refCount === 1 ? '' : 's'}${t.isSteward ? ' and is routed to YOU as its most-likely steward' : ''}.`
|
|
21
|
+
: ''
|
|
22
|
+
const aliasHint = `if it's really an existing page under another title, \`grep "${name}"\` to find it, then \`alias_page name="${name}" target_name="<that page>"\``
|
|
23
|
+
if (t.demoted) {
|
|
24
|
+
return `\n\n[[${name}]] EXISTS but is marked ${t.validity ?? 'superseded'} — it was authored and then deliberately retired, so read_page (which serves only current pages) will not show it. It is NOT missing.${refs} Do NOT author over it: that would silently overwrite a decision someone made on purpose. Read it with \`page_history "${name}"\` then \`read_page "${name}"\` with a version. If it genuinely should be live again, revive it deliberately with \`set_page_validity\`.`
|
|
25
|
+
}
|
|
26
|
+
if (t.category === 'node') {
|
|
27
|
+
return `\n\n[[${name}]] is a wanted page — a ${t.isPerson ? 'person' : 'node'} exists but has no page yet.${refs} Either author it now with \`author\`, or ${aliasHint}.`
|
|
28
|
+
}
|
|
29
|
+
return `\n\n"${name}" isn't authored anywhere yet.${refs} Either author a new page with \`author\`, or ${aliasHint}.`
|
|
30
|
+
}
|
package/lib/server.mjs
CHANGED
|
@@ -5,29 +5,23 @@ import { writeFileSync, mkdirSync } 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 } from './diagnose.mjs'
|
|
8
|
+
import { fetchCortex, classify, resolveBase, setSessionKey } from './diagnose.mjs'
|
|
9
9
|
import { runSendImessage } from './imessage_send.mjs'
|
|
10
10
|
import { formatGrepHits } from './grep_cli.mjs'
|
|
11
|
+
import { renderTriage } from './red_link_triage.mjs'
|
|
11
12
|
import { runCodeGraphQuery } from './code_graph_cli.mjs'
|
|
12
13
|
|
|
13
14
|
// Reactive red-link triage (Mechanism 2). On a read_page miss, ask the server whether the name is a
|
|
14
|
-
// tracked wanted page
|
|
15
|
-
//
|
|
15
|
+
// tracked wanted page, whether a bare node exists for it, and whether it's a deliberately demoted page,
|
|
16
|
+
// and turn that into an actionable prompt. Wording lives in ./red_link_triage.mjs (pure + tested).
|
|
17
|
+
// Returns '' on any error so a miss never gets worse.
|
|
16
18
|
async function redLinkTriage(BASE, TOKEN, name) {
|
|
17
19
|
try {
|
|
18
20
|
const r = await fetchCortex(`${BASE}/api/brain/red-link?name=${encodeURIComponent(name)}`, {
|
|
19
21
|
headers: { Authorization: `Bearer ${TOKEN}` },
|
|
20
22
|
})
|
|
21
23
|
if (!r.ok) return ''
|
|
22
|
-
|
|
23
|
-
const refs = t.tracked
|
|
24
|
-
? ` It's referenced by ${t.refCount} page${t.refCount === 1 ? '' : 's'}${t.isSteward ? ' and is routed to YOU as its most-likely steward' : ''}.`
|
|
25
|
-
: ''
|
|
26
|
-
const aliasHint = `if it's really an existing page under another title, \`grep "${name}"\` to find it, then \`alias_page name="${name}" target_name="<that page>"\``
|
|
27
|
-
if (t.category === 'node') {
|
|
28
|
-
return `\n\n[[${name}]] is a wanted page — a ${t.isPerson ? 'person' : 'node'} exists but has no page yet.${refs} Either author it now with \`author\`, or ${aliasHint}.`
|
|
29
|
-
}
|
|
30
|
-
return `\n\n"${name}" isn't authored anywhere yet.${refs} Either author a new page with \`author\`, or ${aliasHint}.`
|
|
24
|
+
return renderTriage(await r.json(), name)
|
|
31
25
|
} catch {
|
|
32
26
|
return ''
|
|
33
27
|
}
|
|
@@ -49,6 +43,10 @@ export async function runServer(version) {
|
|
|
49
43
|
// working dir identify this session; we heartbeat /api/session-ping while alive so the user can see
|
|
50
44
|
// all THEIR sessions via my_sessions. Self-only on the server; best-effort (never breaks serving).
|
|
51
45
|
const SESSION_KEY = randomUUID()
|
|
46
|
+
// ADR-0020 Stage 2: stamp this key on every outbound request (fetchCortex injects it) so writes
|
|
47
|
+
// resolve THIS session's brain pointer. Must be set BEFORE the first fetchCortex call below —
|
|
48
|
+
// otherwise the opening requests of a session would silently resolve by the account pointer.
|
|
49
|
+
setSessionKey(SESSION_KEY)
|
|
52
50
|
async function pingSession() {
|
|
53
51
|
try {
|
|
54
52
|
await fetchCortex(`${BASE}/api/session-ping`, {
|
|
@@ -652,10 +650,26 @@ export async function runServer(version) {
|
|
|
652
650
|
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
653
651
|
return { content: [{ type: 'text', text: `Could not list brains: ${d.message}` }] }
|
|
654
652
|
}
|
|
655
|
-
const { brains, activeIsExplicit } = await res.json()
|
|
653
|
+
const { brains, activeIsExplicit, activeSource, sessionOrgId, accountOrgId } = await res.json()
|
|
656
654
|
if (!brains?.length) return { content: [{ type: 'text', text: 'You have no brains.' }] }
|
|
657
|
-
|
|
658
|
-
|
|
655
|
+
// Show CONTENTS, not just the name (ADR-0020 §7 corollary): a brain named TTO that holds 4
|
|
656
|
+
// pages while the real TTO inventory sits in Personal reads as correct and is exactly
|
|
657
|
+
// backwards. The page count and a couple of titles make that visible at the moment of choosing.
|
|
658
|
+
const lines = brains.map((b) => {
|
|
659
|
+
const inv = b.pageCount === 0
|
|
660
|
+
? 'EMPTY'
|
|
661
|
+
: `${b.pageCount} page${b.pageCount === 1 ? '' : 's'}${b.sampleTitles?.length ? `: ${b.sampleTitles.slice(0, 2).join(', ')}` : ''}`
|
|
662
|
+
return `${b.isActive ? '▶' : ' '} ${b.name} (${b.role}${b.status !== 'active' ? `, ${b.status}` : ''}) — ${inv} [${b.orgId}]`
|
|
663
|
+
})
|
|
664
|
+
// Which layer is deciding, and what clearing it would fall back to — otherwise the pointer
|
|
665
|
+
// confusion simply reappears one level down.
|
|
666
|
+
const note = !activeIsExplicit
|
|
667
|
+
? '\n(active brain is the default — you have one brain; set_active_brain once you hold more.)'
|
|
668
|
+
: activeSource === 'session'
|
|
669
|
+
? `\n(this SESSION's override — your other sessions are unaffected${accountOrgId && accountOrgId !== sessionOrgId ? `; clearing it falls back to ${accountOrgId}` : ''}.)`
|
|
670
|
+
: activeSource === 'account'
|
|
671
|
+
? '\n(account-wide pointer — shared by every session that has not set its own.)'
|
|
672
|
+
: ''
|
|
659
673
|
return { content: [{ type: 'text', text: `Your brains (▶ = writes land here):\n${lines.join('\n')}${note}` }] }
|
|
660
674
|
},
|
|
661
675
|
)
|
|
@@ -664,16 +678,20 @@ export async function runServer(version) {
|
|
|
664
678
|
'set_active_brain',
|
|
665
679
|
{
|
|
666
680
|
title: 'Choose the brain your writes go to',
|
|
667
|
-
description:
|
|
668
|
-
inputSchema: {
|
|
681
|
+
description: "Point your writes (author / log_session / capture) at one of your brains, by its org id (from my_brains). Pass org_id = null to clear it. You can only select a brain you are a member of. SCOPE: 'session' (default) changes ONLY this session — other windows you have open keep writing where they were, which is almost always what you want. 'account' changes the sticky person-wide pointer, which redirects every other session that has not set its own.",
|
|
682
|
+
inputSchema: {
|
|
683
|
+
org_id: z.string().nullable().describe('the org id of the brain to write to (from my_brains), or null to clear the pointer'),
|
|
684
|
+
scope: z.enum(['session', 'account']).optional()
|
|
685
|
+
.describe("'session' (default) = this window only; 'account' = person-wide, affects your other open sessions too"),
|
|
686
|
+
},
|
|
669
687
|
},
|
|
670
|
-
async ({ org_id }) => {
|
|
688
|
+
async ({ org_id, scope }) => {
|
|
671
689
|
let res
|
|
672
690
|
try {
|
|
673
691
|
res = await fetchCortex(`${BASE}/api/brains`, {
|
|
674
692
|
method: 'PUT',
|
|
675
693
|
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
676
|
-
body: JSON.stringify({ orgId: org_id ?? null }),
|
|
694
|
+
body: JSON.stringify({ orgId: org_id ?? null, scope: scope ?? 'session' }),
|
|
677
695
|
})
|
|
678
696
|
} catch (e) {
|
|
679
697
|
return { content: [{ type: 'text', text: `Could not set active brain: ${e.message}` }] }
|
|
@@ -683,7 +701,15 @@ export async function runServer(version) {
|
|
|
683
701
|
return { content: [{ type: 'text', text: `Could not set active brain: ${d.message}` }] }
|
|
684
702
|
}
|
|
685
703
|
const r = await res.json()
|
|
686
|
-
|
|
704
|
+
// Always say WHICH scope changed. The default is session-only, so a caller expecting the old
|
|
705
|
+
// person-wide stickiness must be able to see that it did NOT change their other windows.
|
|
706
|
+
const where = r.scope === 'session' ? 'this session only' : 'ALL your sessions (person-wide)'
|
|
707
|
+
if (!r.activeOrgId) {
|
|
708
|
+
return { content: [{ type: 'text', text: r.scope === 'session'
|
|
709
|
+
? 'Cleared this session\'s brain override — writes fall back to your account pointer.'
|
|
710
|
+
: 'Cleared your account-wide active-brain pointer (writes fall back to your default brain).' }] }
|
|
711
|
+
}
|
|
712
|
+
return { content: [{ type: 'text', text: `Writes now land in brain ${r.activeOrgId} — ${where}.` }] }
|
|
687
713
|
},
|
|
688
714
|
)
|
|
689
715
|
|