@theronap/cortex-mcp 0.4.4 → 0.4.6
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/bin/cortex-mcp.mjs +8 -1
- package/lib/capture.mjs +45 -11
- package/lib/diagnose.mjs +8 -21
- package/lib/doctor.mjs +26 -0
- package/lib/server.mjs +31 -3
- package/lib/setup.mjs +16 -1
- package/package.json +1 -1
package/bin/cortex-mcp.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* Get your token from the Cortex console → Connect your AI.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
const VERSION = '0.4.
|
|
19
|
+
const VERSION = '0.4.6'
|
|
20
20
|
const cmd = process.argv[2]
|
|
21
21
|
const rest = process.argv.slice(3)
|
|
22
22
|
|
|
@@ -35,6 +35,7 @@ if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
|
35
35
|
`Subcommands:\n` +
|
|
36
36
|
` setup <token> wire MCP server + capture hook into ~/.claude config\n` +
|
|
37
37
|
` doctor live health check — confirm your token works (no restart needed)\n` +
|
|
38
|
+
` status one-line connected/not-connected check (used by the SessionStart hook)\n` +
|
|
38
39
|
` capture Stop-hook capturer (invoked by Claude Code)\n` +
|
|
39
40
|
` (no args) run the MCP server (used by your Claude config)\n\n` +
|
|
40
41
|
`Get your token from the Cortex console → Connect your AI.\n`,
|
|
@@ -59,6 +60,12 @@ if (cmd === 'setup') {
|
|
|
59
60
|
process.exitCode = await runDoctor()
|
|
60
61
|
const { closeFetch } = await import('../lib/diagnose.mjs')
|
|
61
62
|
await closeFetch()
|
|
63
|
+
} else if (cmd === 'status') {
|
|
64
|
+
// One-line SessionStart health signal (wired by setup). Always exit 0.
|
|
65
|
+
const { runStatus } = await import('../lib/doctor.mjs')
|
|
66
|
+
await runStatus()
|
|
67
|
+
const { closeFetch } = await import('../lib/diagnose.mjs')
|
|
68
|
+
await closeFetch()
|
|
62
69
|
} else if (cmd === 'capture') {
|
|
63
70
|
const { runCapture } = await import('../lib/capture.mjs')
|
|
64
71
|
await runCapture()
|
package/lib/capture.mjs
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
import { readFileSync } from 'fs'
|
|
2
|
+
import { homedir } from 'os'
|
|
3
|
+
import { resolve } from 'path'
|
|
2
4
|
import { fetchCortex, classify, resolveBase } from './diagnose.mjs'
|
|
3
5
|
|
|
6
|
+
// Project name from the hook cwd — cross-platform. Windows hooks send backslash paths,
|
|
7
|
+
// and splitting on '/' alone turned the WHOLE path into one garbage project slug
|
|
8
|
+
// ("c-users-webst-onedrive-…", three-machine dry-run finding 2026-06-09). A session run
|
|
9
|
+
// from the home directory itself is 'general', not a project named after the user.
|
|
10
|
+
export function projectFrom(cwd) {
|
|
11
|
+
if (!cwd) return 'general'
|
|
12
|
+
try {
|
|
13
|
+
if (resolve(String(cwd)) === homedir()) return 'general'
|
|
14
|
+
} catch { /* unresolvable path — fall through to basename */ }
|
|
15
|
+
const base = String(cwd).split(/[\\/]+/).filter((s) => s && !/^[A-Za-z]:$/.test(s)).pop()
|
|
16
|
+
return base ?? 'general'
|
|
17
|
+
}
|
|
18
|
+
|
|
4
19
|
// Claude Code Stop hook → POSTs a session digest to Cortex cloud, which
|
|
5
20
|
// summarizes server-side and upserts ONE record per session. Node-native
|
|
6
21
|
// (no bun, no repo clone). Always exits 0 — capture must never break a session.
|
|
@@ -10,23 +25,42 @@ function readStdin() {
|
|
|
10
25
|
}
|
|
11
26
|
|
|
12
27
|
// Claude transcripts are JSONL; pull human-readable text from the tail so the
|
|
13
|
-
// server has real content to summarize (not raw tool JSON).
|
|
28
|
+
// server has real content to summarize (not raw tool JSON). ALSO scan the FULL
|
|
29
|
+
// file for lifecycle-verb lines (§4b status recall — a "we're pausing X" said at
|
|
30
|
+
// minute 5 of a 2-hour session must reach the server's status prefilter even
|
|
31
|
+
// though only the tail ships) and append the flagged spans.
|
|
32
|
+
const LIFECYCLE_RE = /\b(paus\w*|resum\w*|shipp?\w*|kill\w*|cancel\w*|on hold|sunset\w*)\b/i
|
|
33
|
+
|
|
14
34
|
function transcriptTail(path) {
|
|
15
35
|
let raw = ''
|
|
16
36
|
try { raw = readFileSync(path, 'utf8') } catch { return '' }
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
for (const line of lines) {
|
|
37
|
+
const jsonLines = raw.split('\n').filter(Boolean)
|
|
38
|
+
const textOf = (line) => {
|
|
20
39
|
try {
|
|
21
40
|
const obj = JSON.parse(line)
|
|
22
41
|
const content = obj?.message?.content ?? obj?.content
|
|
23
|
-
if (typeof content === 'string')
|
|
24
|
-
|
|
25
|
-
for (const c of content) if (c?.type === 'text' && c.text) texts.push(c.text)
|
|
26
|
-
}
|
|
42
|
+
if (typeof content === 'string') return [content]
|
|
43
|
+
if (Array.isArray(content)) return content.filter((c) => c?.type === 'text' && c.text).map((c) => c.text)
|
|
27
44
|
} catch { /* skip non-JSON */ }
|
|
45
|
+
return []
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const texts = jsonLines.slice(-60).flatMap(textOf)
|
|
49
|
+
const tail = texts.join('\n').slice(-6000)
|
|
50
|
+
|
|
51
|
+
// Full-session status-candidate spans (last 12 matching lines, capped).
|
|
52
|
+
const flagged = []
|
|
53
|
+
for (const line of jsonLines) {
|
|
54
|
+
for (const t of textOf(line)) {
|
|
55
|
+
for (const tl of t.split('\n')) {
|
|
56
|
+
if (LIFECYCLE_RE.test(tl)) flagged.push(tl.trim().slice(0, 200))
|
|
57
|
+
}
|
|
58
|
+
}
|
|
28
59
|
}
|
|
29
|
-
|
|
60
|
+
const spans = flagged.slice(-12).join('\n').slice(0, 1200)
|
|
61
|
+
return spans && !tail.includes(spans)
|
|
62
|
+
? `${tail}\n\n[status-candidate lines from the full session]\n${spans}`
|
|
63
|
+
: tail
|
|
30
64
|
}
|
|
31
65
|
|
|
32
66
|
export async function runCapture() {
|
|
@@ -36,7 +70,7 @@ export async function runCapture() {
|
|
|
36
70
|
|
|
37
71
|
let hook = {}
|
|
38
72
|
try { hook = JSON.parse(readStdin()) } catch { /* no/invalid stdin */ }
|
|
39
|
-
const repo = hook.cwd
|
|
73
|
+
const repo = projectFrom(hook.cwd)
|
|
40
74
|
const transcript = hook.transcript_path ? transcriptTail(hook.transcript_path) : ''
|
|
41
75
|
|
|
42
76
|
if (!transcript && !hook.session_id) { process.stderr.write('cortex: empty session, skipping\n'); return }
|
|
@@ -65,7 +99,7 @@ export async function runCapture() {
|
|
|
65
99
|
const j = await res.json().catch(() => ({}))
|
|
66
100
|
process.stderr.write(`cortex: ${j.inserted ? 'captured' : 'updated'} "${j.title ?? repo}" → ${repo}\n`)
|
|
67
101
|
} else {
|
|
68
|
-
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id')
|
|
102
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
69
103
|
process.stderr.write(`cortex: ingest failed — ${d.message}\n`)
|
|
70
104
|
}
|
|
71
105
|
}
|
package/lib/diagnose.mjs
CHANGED
|
@@ -6,10 +6,8 @@
|
|
|
6
6
|
// to tell the truth about WHAT failed.
|
|
7
7
|
//
|
|
8
8
|
// KEY SIGNAL: the Cortex app ALWAYS returns JSON ({ error: ... }). So a NON-JSON body on a
|
|
9
|
-
// 4xx/5xx means
|
|
10
|
-
// regenerating the token will not help
|
|
11
|
-
// (x-deny-reason: host_not_allowed) — NOT retriable, the host must be added to the allowlist;
|
|
12
|
-
// (2) a transient infrastructure hiccup — retriable.
|
|
9
|
+
// 4xx/5xx means infrastructure handled the request, not Cortex auth — re-running setup or
|
|
10
|
+
// regenerating the token will not help; it is usually transient and worth a retry.
|
|
13
11
|
|
|
14
12
|
export const isUuid = (s) =>
|
|
15
13
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s ?? '')
|
|
@@ -41,11 +39,10 @@ export function resolveBase(rawUrl) {
|
|
|
41
39
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
42
40
|
|
|
43
41
|
// A non-OK response → an actionable diagnosis: { kind, retriable, message }.
|
|
44
|
-
// kind: 'auth'
|
|
45
|
-
// '
|
|
46
|
-
// '
|
|
47
|
-
|
|
48
|
-
export function classify(status, contentType, bodyText, requestId, denyReason) {
|
|
42
|
+
// kind: 'auth' → token bad/revoked/wrong-deployment (NOT retriable)
|
|
43
|
+
// 'infra' → blocked by infrastructure, non-JSON body (retriable, usually transient)
|
|
44
|
+
// 'app' → a real Cortex API error with a JSON message (retriable only if 5xx)
|
|
45
|
+
export function classify(status, contentType, bodyText, requestId) {
|
|
49
46
|
const isJson = (contentType ?? '').includes('application/json')
|
|
50
47
|
let appError = null
|
|
51
48
|
if (isJson) { try { appError = JSON.parse(bodyText)?.error ?? null } catch { /* not json after all */ } }
|
|
@@ -59,15 +56,6 @@ export function classify(status, contentType, bodyText, requestId, denyReason) {
|
|
|
59
56
|
`the Cortex console → Connect your AI, then re-run setup.${rid}`,
|
|
60
57
|
}
|
|
61
58
|
}
|
|
62
|
-
if (denyReason === 'host_not_allowed' || /host (not in allowlist|not allowed)/i.test(bodyText ?? '')) {
|
|
63
|
-
return {
|
|
64
|
-
kind: 'egress', retriable: false,
|
|
65
|
-
message: `Blocked by your network's egress allowlist (HTTP ${status}: ${denyReason ?? 'host_not_allowed'}). ` +
|
|
66
|
-
`The request never left this environment — this is NOT a Cortex, token, or Vercel issue. ` +
|
|
67
|
-
`Add ${CANONICAL_BASE} to your environment's outbound allowlist, or run from a machine with open egress. ` +
|
|
68
|
-
`Re-running setup or regenerating the token will not help.${rid}`,
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
59
|
if (!isJson) {
|
|
72
60
|
return {
|
|
73
61
|
kind: 'infra', retriable: true,
|
|
@@ -91,11 +79,10 @@ export async function fetchCortex(url, opts = {}, { retries = 2, baseDelayMs = 4
|
|
|
91
79
|
try {
|
|
92
80
|
const res = await fetch(url, opts)
|
|
93
81
|
const ct = res.headers.get('content-type') ?? ''
|
|
94
|
-
const denyReason = res.headers.get('x-deny-reason') ?? ''
|
|
95
82
|
const transient =
|
|
96
83
|
res.status === 429 ||
|
|
97
84
|
res.status >= 500 ||
|
|
98
|
-
(res.status === 403 && !ct.includes('application/json')
|
|
85
|
+
(res.status === 403 && !ct.includes('application/json')) // infra block, not app authz
|
|
99
86
|
if (transient && attempt < retries) {
|
|
100
87
|
await sleep(baseDelayMs * 2 ** attempt)
|
|
101
88
|
continue
|
|
@@ -135,7 +122,7 @@ export async function checkToken(token, base) {
|
|
|
135
122
|
const contentType = res.headers.get('content-type')
|
|
136
123
|
const body = await res.text()
|
|
137
124
|
if (!res.ok) {
|
|
138
|
-
return { ok: false, status: res.status, requestId, diagnosis: classify(res.status, contentType, body, requestId
|
|
125
|
+
return { ok: false, status: res.status, requestId, diagnosis: classify(res.status, contentType, body, requestId) }
|
|
139
126
|
}
|
|
140
127
|
let projectCount
|
|
141
128
|
try {
|
package/lib/doctor.mjs
CHANGED
|
@@ -24,6 +24,32 @@ function resolveToken() {
|
|
|
24
24
|
return { token: null, source: null }
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// `status` — the one-line SessionStart variant of doctor: a visible "is Cortex capturing?"
|
|
28
|
+
// signal inside Claude Code itself (three-machine dry-run finding 2026-06-09: with no
|
|
29
|
+
// indicator, a user can't tell whether their sessions are flowing to the org).
|
|
30
|
+
// Always returns 0 — a status line must never break a session start.
|
|
31
|
+
export async function runStatus() {
|
|
32
|
+
const base = resolveBase(process.env.CORTEX_URL)
|
|
33
|
+
const out = (m) => process.stdout.write(m + '\n')
|
|
34
|
+
const { token } = resolveToken()
|
|
35
|
+
if (!token) {
|
|
36
|
+
out('Cortex: NOT connected — no token found. Run: npx -y @theronap/cortex-mcp setup <token>')
|
|
37
|
+
return 0
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const r = await checkToken(token, base)
|
|
41
|
+
if (r.ok) {
|
|
42
|
+
const n = typeof r.projectCount === 'number' ? ` · ${r.projectCount} project${r.projectCount === 1 ? '' : 's'} visible` : ''
|
|
43
|
+
out(`Cortex: connected — sessions on this machine are captured to your org${n}.`)
|
|
44
|
+
} else {
|
|
45
|
+
out(`Cortex: NOT connected — ${r.diagnosis?.message ?? 'check failed'}. Run: npx -y @theronap/cortex-mcp doctor`)
|
|
46
|
+
}
|
|
47
|
+
} catch (e) {
|
|
48
|
+
out(`Cortex: status check failed (${e?.message ?? String(e)}) — run doctor.`)
|
|
49
|
+
}
|
|
50
|
+
return 0
|
|
51
|
+
}
|
|
52
|
+
|
|
27
53
|
export async function runDoctor() {
|
|
28
54
|
const base = resolveBase(process.env.CORTEX_URL)
|
|
29
55
|
const out = (m) => process.stdout.write(m + '\n')
|
package/lib/server.mjs
CHANGED
|
@@ -25,7 +25,7 @@ export async function runServer(version) {
|
|
|
25
25
|
const res = await fetchCortex(`${BASE}/api/mcp-context`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
26
26
|
if (!res.ok) {
|
|
27
27
|
const body = await res.text()
|
|
28
|
-
throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')
|
|
28
|
+
throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
|
|
29
29
|
}
|
|
30
30
|
const { context } = await res.json()
|
|
31
31
|
cache = { text: context, ts: now }
|
|
@@ -92,7 +92,7 @@ export async function runServer(version) {
|
|
|
92
92
|
return { content: [{ type: 'text', text: `Could not assemble story: ${e.message}` }] }
|
|
93
93
|
}
|
|
94
94
|
if (!res.ok) {
|
|
95
|
-
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id')
|
|
95
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
96
96
|
return { content: [{ type: 'text', text: `Could not assemble story: ${d.message}` }] }
|
|
97
97
|
}
|
|
98
98
|
const { answer, sourceCount, sources } = await res.json()
|
|
@@ -102,6 +102,34 @@ export async function runServer(version) {
|
|
|
102
102
|
},
|
|
103
103
|
)
|
|
104
104
|
|
|
105
|
+
server.registerTool(
|
|
106
|
+
'who_knows',
|
|
107
|
+
{
|
|
108
|
+
title: 'Who knows about X',
|
|
109
|
+
description: 'Ranked teammates who have visibly worked on a topic, with evidence — derived only from records you are permitted to see.',
|
|
110
|
+
inputSchema: { topic: z.string().describe('the topic/system/skill, e.g. "the embedding pipeline" or "stripe webhooks"') },
|
|
111
|
+
},
|
|
112
|
+
async ({ topic }) => {
|
|
113
|
+
let res
|
|
114
|
+
try {
|
|
115
|
+
res = await fetchCortex(`${BASE}/api/who-knows?q=${encodeURIComponent(topic)}`, {
|
|
116
|
+
headers: { Authorization: `Bearer ${TOKEN}` },
|
|
117
|
+
})
|
|
118
|
+
} catch (e) {
|
|
119
|
+
return { content: [{ type: 'text', text: `Could not look up experts: ${e.message}` }] }
|
|
120
|
+
}
|
|
121
|
+
if (!res.ok) {
|
|
122
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
123
|
+
return { content: [{ type: 'text', text: `Could not look up experts: ${d.message}` }] }
|
|
124
|
+
}
|
|
125
|
+
const { experts } = await res.json()
|
|
126
|
+
if (!experts?.length) return { content: [{ type: 'text', text: `No one visible to you has recorded work about "${topic}".` }] }
|
|
127
|
+
const lines = experts.map((e, i) =>
|
|
128
|
+
`${i + 1}. ${e.name} — ${e.count} visible record${e.count === 1 ? '' : 's'}\n e.g. ${e.evidence.join(' · ')}`)
|
|
129
|
+
return { content: [{ type: 'text', text: `People with visible work on "${topic}":\n${lines.join('\n')}` }] }
|
|
130
|
+
},
|
|
131
|
+
)
|
|
132
|
+
|
|
105
133
|
server.registerTool(
|
|
106
134
|
'set_record_privacy',
|
|
107
135
|
{
|
|
@@ -124,7 +152,7 @@ export async function runServer(version) {
|
|
|
124
152
|
return { content: [{ type: 'text', text: `Could not set privacy: ${e.message}` }] }
|
|
125
153
|
}
|
|
126
154
|
if (!res.ok) {
|
|
127
|
-
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id')
|
|
155
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
128
156
|
return { content: [{ type: 'text', text: `Could not set privacy: ${d.message}` }] }
|
|
129
157
|
}
|
|
130
158
|
const out = await res.json()
|
package/lib/setup.mjs
CHANGED
|
@@ -100,9 +100,24 @@ export async function runSetup(argv, version) {
|
|
|
100
100
|
grp.hooks = grp.hooks ?? []
|
|
101
101
|
grp.hooks.push({ type: 'command', command: captureCmd })
|
|
102
102
|
|
|
103
|
+
// Connected-status SessionStart hook: one line inside Claude Code itself saying whether
|
|
104
|
+
// this machine's sessions are flowing to the org (dry-run finding: silence is unreadable).
|
|
105
|
+
// `status` reads the token from ~/.claude.json, so the command carries no secret.
|
|
106
|
+
s.hooks.SessionStart = Array.isArray(s.hooks.SessionStart) ? s.hooks.SessionStart : []
|
|
107
|
+
const statusCmd = `npx -y ${spec} status`
|
|
108
|
+
for (const sg of s.hooks.SessionStart) {
|
|
109
|
+
if (Array.isArray(sg.hooks)) {
|
|
110
|
+
sg.hooks = sg.hooks.filter((h) => !/cortex-mcp(@[^ ]*)? status/.test(h.command ?? ''))
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
let sgrp = s.hooks.SessionStart.find((g) => (g.matcher ?? '') === '')
|
|
114
|
+
if (!sgrp) { sgrp = { matcher: '', hooks: [] }; s.hooks.SessionStart.push(sgrp) }
|
|
115
|
+
sgrp.hooks = sgrp.hooks ?? []
|
|
116
|
+
sgrp.hooks.push({ type: 'command', command: statusCmd })
|
|
117
|
+
|
|
103
118
|
ensureDir(settingsJson)
|
|
104
119
|
writeFileSync(settingsJson, JSON.stringify(s, null, 2))
|
|
105
|
-
log(` ✓ Capture hook → ${settingsJson}${bak ? ' (backup saved)' : ''}`)
|
|
120
|
+
log(` ✓ Capture hook + status line → ${settingsJson}${bak ? ' (backup saved)' : ''}`)
|
|
106
121
|
} catch (e) {
|
|
107
122
|
process.stderr.write(` ✗ failed to update ${settingsJson}: ${e.message}\n`)
|
|
108
123
|
process.exit(1)
|