@theronap/cortex-mcp 0.9.16 → 0.9.18
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 +15 -0
- package/lib/capture.mjs +9 -0
- package/lib/context_log.mjs +20 -1
- package/lib/links.mjs +105 -0
- package/lib/materialize.mjs +117 -0
- package/package.json +1 -1
package/bin/cortex-mcp.mjs
CHANGED
|
@@ -46,6 +46,7 @@ if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
|
46
46
|
` doctor live health check — confirm your token works (no restart needed)\n` +
|
|
47
47
|
` status one-line connected/not-connected check (used by the SessionStart hook)\n` +
|
|
48
48
|
` skills install/repair the managed Cortex skills (also wired by setup)\n` +
|
|
49
|
+
` materialize [--drain] build brain pages on your Claude subscription (claude -p); --drain clears the backlog\n` +
|
|
49
50
|
` snapshot-context save the exact startup context Cortex served to a local snapshot\n` +
|
|
50
51
|
` capture Stop-hook capturer (invoked by Claude Code)\n` +
|
|
51
52
|
` ingest-folder <path> ingest a local markdown folder as your authored records\n` +
|
|
@@ -102,6 +103,20 @@ if (cmd === 'setup') {
|
|
|
102
103
|
await runRelationships()
|
|
103
104
|
const { closeFetch } = await import('../lib/diagnose.mjs')
|
|
104
105
|
await closeFetch()
|
|
106
|
+
} else if (cmd === 'links') {
|
|
107
|
+
// CU-2 cross-user link judge: judge server-flagged candidate pairs ("do these interconnect?") via
|
|
108
|
+
// `claude -p` and push verdicts back (queue/reject — never an auto-write). Sibling of `relationships`.
|
|
109
|
+
const { runLinks } = await import('../lib/links.mjs')
|
|
110
|
+
await runLinks()
|
|
111
|
+
const { closeFetch } = await import('../lib/diagnose.mjs')
|
|
112
|
+
await closeFetch()
|
|
113
|
+
} else if (cmd === 'materialize') {
|
|
114
|
+
// Brain-page summarizer on the SUBSCRIPTION (claude -p): claim authorized digest jobs, summarize
|
|
115
|
+
// locally, submit the text for the server to write. `--drain` clears the whole backlog.
|
|
116
|
+
const { runMaterializeCli } = await import('../lib/materialize.mjs')
|
|
117
|
+
process.exitCode = await runMaterializeCli(rest)
|
|
118
|
+
const { closeFetch } = await import('../lib/diagnose.mjs')
|
|
119
|
+
await closeFetch()
|
|
105
120
|
} else if (cmd === 'ingest-folder') {
|
|
106
121
|
const { runIngestFolder } = await import('../lib/ingest_folder.mjs')
|
|
107
122
|
await runIngestFolder(rest)
|
package/lib/capture.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { createHash } from 'crypto'
|
|
|
5
5
|
import { fetchCortex, classify, resolveBase } from './diagnose.mjs'
|
|
6
6
|
import { extractSession } from './edge_extract.mjs'
|
|
7
7
|
import { extractTyped } from './extract_typed.mjs'
|
|
8
|
+
import { runMaterialize } from './materialize.mjs'
|
|
8
9
|
import { redactSecrets } from './redact.mjs'
|
|
9
10
|
|
|
10
11
|
// Fetch the node-type registry (global + this org's custom types) — the catalog the typed extractor
|
|
@@ -164,4 +165,12 @@ export async function runCapture() {
|
|
|
164
165
|
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
165
166
|
process.stderr.write(`cortex: ingest failed — ${d.message}\n`)
|
|
166
167
|
}
|
|
168
|
+
|
|
169
|
+
// Keystone fix (b): ingest just marked this session's nodes dirty server-side. Summarize the freshly
|
|
170
|
+
// dirtied pages on the SUBSCRIPTION (claude -p) so the brain stays current without the dead server
|
|
171
|
+
// API. OPT-IN (CORTEX_MATERIALIZE) + bounded so it never adds unbounded latency to session stop;
|
|
172
|
+
// `cortex-mcp materialize --drain` is the bulk/backlog path. Best-effort: never blocks capture.
|
|
173
|
+
if (process.env.CORTEX_MATERIALIZE) {
|
|
174
|
+
try { await runMaterialize({ max: 3 }) } catch { /* best-effort */ }
|
|
175
|
+
}
|
|
167
176
|
}
|
package/lib/context_log.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, unlinkSync } from 'fs'
|
|
2
2
|
import { homedir } from 'os'
|
|
3
3
|
import { join } from 'path'
|
|
4
4
|
import { fetchCortex, classify, resolveBase } from './diagnose.mjs'
|
|
@@ -28,6 +28,23 @@ function snapshotDir() {
|
|
|
28
28
|
return join(homedir(), '.cortex', 'context-snapshots')
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// Keep only the most recent N timestamped snapshots; latest.md + index.jsonl are
|
|
32
|
+
// always preserved. Without this the per-session-start archives grow unbounded
|
|
33
|
+
// (2k+ files / ~40MB observed in the field). Best-effort: a prune failure must
|
|
34
|
+
// never break session start.
|
|
35
|
+
const SNAPSHOT_RETENTION = 50
|
|
36
|
+
|
|
37
|
+
function pruneSnapshots(dir, keep = SNAPSHOT_RETENTION) {
|
|
38
|
+
try {
|
|
39
|
+
const stamped = readdirSync(dir)
|
|
40
|
+
.filter((f) => /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.md$/.test(f))
|
|
41
|
+
.sort() // stamp() is zero-padded, so lexicographic order === chronological
|
|
42
|
+
for (const f of stamped.slice(0, Math.max(0, stamped.length - keep))) {
|
|
43
|
+
try { unlinkSync(join(dir, f)) } catch { /* ignore individual failures */ }
|
|
44
|
+
}
|
|
45
|
+
} catch { /* ignore — never break session start */ }
|
|
46
|
+
}
|
|
47
|
+
|
|
31
48
|
export async function runSnapshotContext() {
|
|
32
49
|
const out = (m) => process.stdout.write(m + '\n')
|
|
33
50
|
const token = resolveToken()
|
|
@@ -75,6 +92,8 @@ export async function runSnapshotContext() {
|
|
|
75
92
|
chars: context.length,
|
|
76
93
|
}) + '\n', { flag: 'a' })
|
|
77
94
|
|
|
95
|
+
pruneSnapshots(dir)
|
|
96
|
+
|
|
78
97
|
out(`Cortex: logged startup context → ${file}`)
|
|
79
98
|
return 0
|
|
80
99
|
}
|
package/lib/links.mjs
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { spawnSync } from 'child_process'
|
|
2
|
+
import { fetchCortex, resolveBase, classify } from './diagnose.mjs'
|
|
3
|
+
import { edgeSafeEnv } from './edge_extract.mjs'
|
|
4
|
+
|
|
5
|
+
// `cortex-mcp links` — the JUDGE half of CU-2 (cross-user link curation), sibling of `relationships`.
|
|
6
|
+
// The server FLAGS proposed candidate pairs (GET /api/link-judge-candidates) with resolved endpoint names
|
|
7
|
+
// + co-usage evidence; this command judges each LOCALLY via `claude -p` (the server never calls the metered
|
|
8
|
+
// API) — "do these two genuinely interconnect?" — and pushes verdicts back (POST /api/link-judge-apply).
|
|
9
|
+
// SAFETY: the apply only ever moves a candidate to `queued` (human approval) or `rejected`; it never writes
|
|
10
|
+
// a cross_user_links row. Conservative by construction: the prompt asks the model to default to "incidental".
|
|
11
|
+
|
|
12
|
+
export async function runLinks() {
|
|
13
|
+
// recursion guard (we spawn `claude --print`; its Stop hook capture no-ops on this flag)
|
|
14
|
+
if (process.env.CORTEX_SUMMARIZING) { process.stderr.write('cortex: summarizer subprocess, skipping\n'); return }
|
|
15
|
+
const token = process.env.CORTEX_TOKEN
|
|
16
|
+
if (!token) { process.stderr.write('cortex: CORTEX_TOKEN not set, skipping\n'); return }
|
|
17
|
+
const base = resolveBase(process.env.CORTEX_URL)
|
|
18
|
+
|
|
19
|
+
// 1. pull proposed candidates (each with resolved endpoint names + co-usage count)
|
|
20
|
+
let candidates = []
|
|
21
|
+
try {
|
|
22
|
+
const res = await fetchCortex(`${base}/api/link-judge-candidates`, { headers: { Authorization: `Bearer ${token}` } })
|
|
23
|
+
if (!res.ok) {
|
|
24
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
25
|
+
process.stderr.write(`cortex: link-judge-candidates failed — ${d.message}\n`); return
|
|
26
|
+
}
|
|
27
|
+
const j = await res.json().catch(() => ({}))
|
|
28
|
+
candidates = Array.isArray(j.candidates) ? j.candidates : []
|
|
29
|
+
} catch (e) { process.stderr.write(`cortex: links fetch failed — ${e.message}\n`); return }
|
|
30
|
+
|
|
31
|
+
if (!candidates.length) { process.stderr.write('cortex: no link candidates to judge\n'); return }
|
|
32
|
+
process.stderr.write(`cortex: judging ${candidates.length} link candidate(s) locally…\n`)
|
|
33
|
+
|
|
34
|
+
// 2. judge locally on the subscription — ONE call per candidate (each is a single pair)
|
|
35
|
+
const judgments = []
|
|
36
|
+
let judged = 0
|
|
37
|
+
for (const c of candidates) {
|
|
38
|
+
const verdict = judgeLink(c)
|
|
39
|
+
if (verdict === null) { process.stderr.write('cortex: judge unavailable (is `claude` on PATH?) — skipping\n'); break }
|
|
40
|
+
judged++
|
|
41
|
+
if (verdict) judgments.push(verdict)
|
|
42
|
+
}
|
|
43
|
+
if (!judged) return
|
|
44
|
+
if (!judgments.length) { process.stderr.write(`cortex: judged ${judged} candidate(s), no verdicts produced\n`); return }
|
|
45
|
+
|
|
46
|
+
// 3. apply the verdicts (queue / reject)
|
|
47
|
+
try {
|
|
48
|
+
const res = await fetchCortex(`${base}/api/link-judge-apply`, {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
51
|
+
body: JSON.stringify({ judgments }),
|
|
52
|
+
})
|
|
53
|
+
if (res.ok) {
|
|
54
|
+
const j = await res.json().catch(() => ({}))
|
|
55
|
+
process.stderr.write(`cortex: links — queued ${j.queued ?? 0}, rejected ${j.rejected ?? 0}, skipped ${j.skipped ?? 0}\n`)
|
|
56
|
+
} else {
|
|
57
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
58
|
+
process.stderr.write(`cortex: link-judge-apply failed — ${d.message}\n`)
|
|
59
|
+
}
|
|
60
|
+
} catch (e) { process.stderr.write(`cortex: link-judge-apply failed — ${e.message}\n`) }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ONE `claude --print` call decides whether a candidate pair genuinely interconnects. Returns a judgment
|
|
64
|
+
// { candidateId, interconnected, confidence, relationship, reason }, or null if the CLI is unavailable.
|
|
65
|
+
// Mirrors web/lib/engine/link_judge.ts buildJudgePrompt/parseJudgeResponse (kept in sync).
|
|
66
|
+
function judgeLink(c) {
|
|
67
|
+
if (!c || typeof c.candidateId !== 'string' || !c.src || !c.dst) return false
|
|
68
|
+
const co = c.coUsageCount != null ? `They have been surfaced together ${c.coUsageCount} time(s) when answering questions.` : ''
|
|
69
|
+
const prompt = [
|
|
70
|
+
`Two entities in an organization's knowledge graph keep showing up together but are not yet linked.`,
|
|
71
|
+
`A: ${c.src.name} (${c.src.kind})`,
|
|
72
|
+
`B: ${c.dst.name} (${c.dst.kind})`,
|
|
73
|
+
co,
|
|
74
|
+
`Question: do A and B genuinely interconnect (a real working/organizational relationship), or is the co-occurrence incidental?`,
|
|
75
|
+
`Reply with ONLY a JSON object: {"interconnected": boolean, "confidence": 0..1, "relationship": "<short label>", "reason": "<one line>"}.`,
|
|
76
|
+
`Be conservative — if it looks incidental, say interconnected:false. Never invent specifics you weren't given.`,
|
|
77
|
+
].filter(Boolean).join('\n')
|
|
78
|
+
try {
|
|
79
|
+
const r = spawnSync(
|
|
80
|
+
'claude',
|
|
81
|
+
['--print', '--model', process.env.CORTEX_SUMMARY_MODEL ?? 'claude-haiku-4-5', prompt],
|
|
82
|
+
{ env: edgeSafeEnv(process.env, { CORTEX_SUMMARIZING: '1' }), encoding: 'utf8', timeout: 120_000, maxBuffer: 4 * 1024 * 1024 },
|
|
83
|
+
)
|
|
84
|
+
if (r.status !== 0 || !r.stdout) return null
|
|
85
|
+
return parseVerdict(r.stdout, c.candidateId)
|
|
86
|
+
} catch {
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function parseVerdict(out, candidateId) {
|
|
92
|
+
const start = out.indexOf('{')
|
|
93
|
+
const end = out.lastIndexOf('}')
|
|
94
|
+
if (start < 0 || end <= start) return false
|
|
95
|
+
let o
|
|
96
|
+
try { o = JSON.parse(out.slice(start, end + 1)) } catch { return false }
|
|
97
|
+
const confidence = Math.max(0, Math.min(1, Number(o.confidence) || 0))
|
|
98
|
+
return {
|
|
99
|
+
candidateId,
|
|
100
|
+
interconnected: !!o.interconnected,
|
|
101
|
+
confidence,
|
|
102
|
+
relationship: typeof o.relationship === 'string' ? o.relationship.trim().slice(0, 80) : undefined,
|
|
103
|
+
reason: typeof o.reason === 'string' ? o.reason.trim().slice(0, 280) : undefined,
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { spawnSync } from 'child_process'
|
|
2
|
+
import { fetchCortex, resolveBase } from './diagnose.mjs'
|
|
3
|
+
import { edgeSafeEnv } from './edge_extract.mjs'
|
|
4
|
+
|
|
5
|
+
// Edge materialize worker (keystone fix "b"): the brain-page summarizer moved OFF the dead server-side
|
|
6
|
+
// Anthropic API onto the user's Claude SUBSCRIPTION (`claude -p`), mirroring edge extraction + edge
|
|
7
|
+
// relationship inference. The SERVER still owns gather + tier-authorization + the write
|
|
8
|
+
// (/api/brain/materialize/{claim,submit}); this worker ONLY runs the LLM step. It claims a batch of
|
|
9
|
+
// jobs the user is authorized to summarize, runs `claude --print` per (node, tier), and POSTs the RAW
|
|
10
|
+
// model text back for the server to parse + validate + write. The edge is never trusted: it cannot
|
|
11
|
+
// pick a node, choose a tier, or shape a section — it only turns a server-handed corpus into text.
|
|
12
|
+
//
|
|
13
|
+
// Best-effort throughout: a tier the edge can't summarize is simply not submitted and stays queued for
|
|
14
|
+
// a later run. Guarded by CORTEX_SUMMARIZING so it never recurses through the Stop-hook capturer.
|
|
15
|
+
|
|
16
|
+
const SUMMARY_MODEL = process.env.CORTEX_SUMMARY_MODEL ?? 'claude-haiku-4-5'
|
|
17
|
+
|
|
18
|
+
// ONE `claude --print` call → the raw model text for a single tier's digest prompt. Subscription auth
|
|
19
|
+
// via edgeSafeEnv (keeps CLAUDE_CODE_OAUTH_TOKEN, strips billed/proxy ANTHROPIC_*). null on any failure.
|
|
20
|
+
// The server returns a separate system + user prompt; claude --print takes one prompt, so we prepend
|
|
21
|
+
// the system instructions (same as edge_extract puts its instructions in the prompt body).
|
|
22
|
+
function summarizeTier(tier) {
|
|
23
|
+
const prompt = `${tier.system}\n\n${tier.user}`
|
|
24
|
+
try {
|
|
25
|
+
const r = spawnSync(
|
|
26
|
+
'claude',
|
|
27
|
+
['--print', '--model', SUMMARY_MODEL, prompt],
|
|
28
|
+
{
|
|
29
|
+
env: edgeSafeEnv(process.env, { CORTEX_SUMMARIZING: '1' }),
|
|
30
|
+
encoding: 'utf8',
|
|
31
|
+
timeout: 120_000,
|
|
32
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
33
|
+
},
|
|
34
|
+
)
|
|
35
|
+
if (r.status !== 0 || !r.stdout) return null
|
|
36
|
+
return r.stdout.trim() || null
|
|
37
|
+
} catch {
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function claimBatch(base, token, max) {
|
|
43
|
+
const res = await fetchCortex(`${base}/api/brain/materialize/claim`, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
46
|
+
body: JSON.stringify({ max }),
|
|
47
|
+
})
|
|
48
|
+
if (!res.ok) throw new Error(`claim HTTP ${res.status}`)
|
|
49
|
+
const j = await res.json().catch(() => ({}))
|
|
50
|
+
return Array.isArray(j.jobs) ? j.jobs : []
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function submitNode(base, token, kind, ref, results) {
|
|
54
|
+
const res = await fetchCortex(`${base}/api/brain/materialize/submit`, {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
57
|
+
body: JSON.stringify({ kind, ref, results }),
|
|
58
|
+
})
|
|
59
|
+
if (!res.ok) throw new Error(`submit HTTP ${res.status}`)
|
|
60
|
+
return res.json().catch(() => ({}))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Claim → summarize → submit. opts.drain loops until the queue is empty (clears a backlog); otherwise
|
|
64
|
+
// it does ONE bounded batch (the freshness path called after a capture). Returns a tally.
|
|
65
|
+
export async function runMaterialize(opts = {}) {
|
|
66
|
+
const log = opts.log ?? ((m) => process.stderr.write(`${m}\n`))
|
|
67
|
+
const token = process.env.CORTEX_TOKEN
|
|
68
|
+
if (!token) { log('cortex: CORTEX_TOKEN not set, skipping materialize'); return { built: 0, batches: 0 } }
|
|
69
|
+
// Never run inside the summarizer subprocess (it sets CORTEX_SUMMARIZING) — would recurse.
|
|
70
|
+
if (process.env.CORTEX_SUMMARIZING) return { built: 0, batches: 0 }
|
|
71
|
+
|
|
72
|
+
const base = resolveBase(process.env.CORTEX_URL)
|
|
73
|
+
const max = Math.min(Math.max(Number(opts.max) || 6, 1), 12)
|
|
74
|
+
const maxBatches = opts.drain ? (Number(opts.maxBatches) || 50) : 1
|
|
75
|
+
|
|
76
|
+
let built = 0
|
|
77
|
+
let batches = 0
|
|
78
|
+
for (let i = 0; i < maxBatches; i++) {
|
|
79
|
+
let jobs
|
|
80
|
+
try {
|
|
81
|
+
jobs = await claimBatch(base, token, max)
|
|
82
|
+
} catch (e) {
|
|
83
|
+
log(`cortex: materialize claim failed — ${e.message}`)
|
|
84
|
+
break
|
|
85
|
+
}
|
|
86
|
+
if (!jobs.length) break
|
|
87
|
+
batches++
|
|
88
|
+
for (const job of jobs) {
|
|
89
|
+
const results = []
|
|
90
|
+
for (const tier of job.tiers ?? []) {
|
|
91
|
+
const text = summarizeTier(tier)
|
|
92
|
+
if (text) results.push({ tier: tier.tier, text })
|
|
93
|
+
}
|
|
94
|
+
if (!results.length) { log(`cortex: ${job.kind} "${job.name}" — summarizer produced nothing, leaving queued`); continue }
|
|
95
|
+
try {
|
|
96
|
+
const out = await submitNode(base, token, job.kind, job.ref, results)
|
|
97
|
+
const n = out.built ?? 0
|
|
98
|
+
built += n
|
|
99
|
+
log(`cortex: materialized ${job.kind} "${job.name}" — ${n} tier${n === 1 ? '' : 's'}${out.settled ? '' : ' (partial)'}`)
|
|
100
|
+
} catch (e) {
|
|
101
|
+
log(`cortex: materialize submit failed for ${job.kind} "${job.name}" — ${e.message}`)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (opts.drain) log(`cortex: materialize drain complete — ${built} tier-page(s) built across ${batches} batch(es)`)
|
|
106
|
+
return { built, batches }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// CLI entry: `cortex-mcp materialize [--drain] [--max N]`. --drain clears the whole backlog now;
|
|
110
|
+
// without it, one bounded batch. Always exits 0 (best-effort maintenance, never a hard failure).
|
|
111
|
+
export async function runMaterializeCli(args = []) {
|
|
112
|
+
const drain = args.includes('--drain')
|
|
113
|
+
const mi = args.indexOf('--max')
|
|
114
|
+
const max = mi >= 0 ? Number(args[mi + 1]) : undefined
|
|
115
|
+
await runMaterialize({ drain, max })
|
|
116
|
+
return 0
|
|
117
|
+
}
|