@theronap/cortex-mcp 0.9.49 → 0.9.51
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/server.mjs +76 -9
- package/package.json +9 -3
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++) {
|
package/lib/server.mjs
CHANGED
|
@@ -5,7 +5,7 @@ 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
11
|
import { renderTriage } from './red_link_triage.mjs'
|
|
@@ -43,6 +43,10 @@ export async function runServer(version) {
|
|
|
43
43
|
// working dir identify this session; we heartbeat /api/session-ping while alive so the user can see
|
|
44
44
|
// all THEIR sessions via my_sessions. Self-only on the server; best-effort (never breaks serving).
|
|
45
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)
|
|
46
50
|
async function pingSession() {
|
|
47
51
|
try {
|
|
48
52
|
await fetchCortex(`${BASE}/api/session-ping`, {
|
|
@@ -646,28 +650,83 @@ export async function runServer(version) {
|
|
|
646
650
|
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
647
651
|
return { content: [{ type: 'text', text: `Could not list brains: ${d.message}` }] }
|
|
648
652
|
}
|
|
649
|
-
const { brains, activeIsExplicit } = await res.json()
|
|
653
|
+
const { brains, activeIsExplicit, activeSource, sessionOrgId, accountOrgId } = await res.json()
|
|
650
654
|
if (!brains?.length) return { content: [{ type: 'text', text: 'You have no brains.' }] }
|
|
651
|
-
|
|
652
|
-
|
|
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
|
+
: ''
|
|
653
673
|
return { content: [{ type: 'text', text: `Your brains (▶ = writes land here):\n${lines.join('\n')}${note}` }] }
|
|
654
674
|
},
|
|
655
675
|
)
|
|
656
676
|
|
|
677
|
+
server.registerTool(
|
|
678
|
+
'list_brain_pages',
|
|
679
|
+
{
|
|
680
|
+
title: 'List every authored page in one brain',
|
|
681
|
+
description: 'Enumerate ALL authored pages in ONE brain, by its org id (from my_brains). Unlike my_brains — which ships only a count plus a few sample titles — this returns the FULL page list: one row per node with its kind, validity, tier(s), last-updated date and content hash. Use it to audit a brain, or to VERIFY a brain-to-brain migration — list BOTH brains, diff the page sets, and compare freshness before retiring any original. You can only list a brain you are a member of; reads never widen past your own brains.',
|
|
682
|
+
inputSchema: {
|
|
683
|
+
org_id: z.string().describe('the org id of the brain to enumerate (from my_brains)'),
|
|
684
|
+
},
|
|
685
|
+
},
|
|
686
|
+
async ({ org_id }) => {
|
|
687
|
+
let res
|
|
688
|
+
try {
|
|
689
|
+
res = await fetchCortex(`${BASE}/api/brains/pages?orgId=${encodeURIComponent(org_id)}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
690
|
+
} catch (e) {
|
|
691
|
+
return { content: [{ type: 'text', text: `Could not list pages: ${e.message}` }] }
|
|
692
|
+
}
|
|
693
|
+
if (!res.ok) {
|
|
694
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
695
|
+
return { content: [{ type: 'text', text: `Could not list pages: ${d.message}` }] }
|
|
696
|
+
}
|
|
697
|
+
const { nodeCount, rowCount, pages } = await res.json()
|
|
698
|
+
if (!pages?.length) return { content: [{ type: 'text', text: `Brain ${org_id} has no authored pages.` }] }
|
|
699
|
+
// One terse line per node: name ·kind· date [NON-CURRENT validity] {non-default tiers}. A migration
|
|
700
|
+
// diff needs the name, the freshness date, and whether a page is already retired or multi-tier.
|
|
701
|
+
const lines = pages.map((p) => {
|
|
702
|
+
const flag = p.validity && p.validity !== 'current' ? ` [${String(p.validity).toUpperCase()}]` : ''
|
|
703
|
+
const tiers = p.tiers?.length && !(p.tiers.length === 1 && p.tiers[0] === 'accessible') ? ` {${p.tiers.join('+')}}` : ''
|
|
704
|
+
const day = p.updatedAt ? String(p.updatedAt).slice(0, 10) : '????-??-??'
|
|
705
|
+
return `${p.name ?? '(unnamed)'} ·${p.kind}· ${day}${flag}${tiers}`
|
|
706
|
+
})
|
|
707
|
+
const header = `${nodeCount} page${nodeCount === 1 ? '' : 's'} in brain ${org_id} (${rowCount} digest rows across tiers):`
|
|
708
|
+
return { content: [{ type: 'text', text: `${header}\n${lines.join('\n')}` }] }
|
|
709
|
+
},
|
|
710
|
+
)
|
|
711
|
+
|
|
657
712
|
server.registerTool(
|
|
658
713
|
'set_active_brain',
|
|
659
714
|
{
|
|
660
715
|
title: 'Choose the brain your writes go to',
|
|
661
|
-
description:
|
|
662
|
-
inputSchema: {
|
|
716
|
+
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.",
|
|
717
|
+
inputSchema: {
|
|
718
|
+
org_id: z.string().nullable().describe('the org id of the brain to write to (from my_brains), or null to clear the pointer'),
|
|
719
|
+
scope: z.enum(['session', 'account']).optional()
|
|
720
|
+
.describe("'session' (default) = this window only; 'account' = person-wide, affects your other open sessions too"),
|
|
721
|
+
},
|
|
663
722
|
},
|
|
664
|
-
async ({ org_id }) => {
|
|
723
|
+
async ({ org_id, scope }) => {
|
|
665
724
|
let res
|
|
666
725
|
try {
|
|
667
726
|
res = await fetchCortex(`${BASE}/api/brains`, {
|
|
668
727
|
method: 'PUT',
|
|
669
728
|
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
670
|
-
body: JSON.stringify({ orgId: org_id ?? null }),
|
|
729
|
+
body: JSON.stringify({ orgId: org_id ?? null, scope: scope ?? 'session' }),
|
|
671
730
|
})
|
|
672
731
|
} catch (e) {
|
|
673
732
|
return { content: [{ type: 'text', text: `Could not set active brain: ${e.message}` }] }
|
|
@@ -677,7 +736,15 @@ export async function runServer(version) {
|
|
|
677
736
|
return { content: [{ type: 'text', text: `Could not set active brain: ${d.message}` }] }
|
|
678
737
|
}
|
|
679
738
|
const r = await res.json()
|
|
680
|
-
|
|
739
|
+
// Always say WHICH scope changed. The default is session-only, so a caller expecting the old
|
|
740
|
+
// person-wide stickiness must be able to see that it did NOT change their other windows.
|
|
741
|
+
const where = r.scope === 'session' ? 'this session only' : 'ALL your sessions (person-wide)'
|
|
742
|
+
if (!r.activeOrgId) {
|
|
743
|
+
return { content: [{ type: 'text', text: r.scope === 'session'
|
|
744
|
+
? 'Cleared this session\'s brain override — writes fall back to your account pointer.'
|
|
745
|
+
: 'Cleared your account-wide active-brain pointer (writes fall back to your default brain).' }] }
|
|
746
|
+
}
|
|
747
|
+
return { content: [{ type: 'text', text: `Writes now land in brain ${r.activeOrgId} — ${where}.` }] }
|
|
681
748
|
},
|
|
682
749
|
)
|
|
683
750
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@theronap/cortex-mcp",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.51",
|
|
4
4
|
"description": "Connect your AI assistant to Cortex — your org's projects, activity, gaps, and directives, scoped to you.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
"bin",
|
|
11
11
|
"lib",
|
|
12
12
|
"skills",
|
|
13
|
-
"!lib/**/*.test.mjs"
|
|
13
|
+
"!lib/**/*.test.mjs",
|
|
14
|
+
"!scripts"
|
|
14
15
|
],
|
|
15
16
|
"engines": {
|
|
16
17
|
"node": ">=18"
|
|
@@ -26,5 +27,10 @@
|
|
|
26
27
|
"ai",
|
|
27
28
|
"org-intelligence"
|
|
28
29
|
],
|
|
29
|
-
"license": "MIT"
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"scripts": {
|
|
32
|
+
"release": "node scripts/release.mjs release",
|
|
33
|
+
"promote": "node scripts/release.mjs promote",
|
|
34
|
+
"rollback": "node scripts/release.mjs rollback"
|
|
35
|
+
}
|
|
30
36
|
}
|