@theronap/cortex-mcp 0.9.10 → 0.9.12
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 +12 -0
- package/lib/grep_cli.mjs +71 -0
- package/lib/grep_cli.test.mjs +42 -0
- package/lib/resolve.mjs +103 -0
- package/lib/server.mjs +24 -0
- package/package.json +1 -1
package/bin/cortex-mcp.mjs
CHANGED
|
@@ -90,11 +90,23 @@ if (cmd === 'setup') {
|
|
|
90
90
|
await runCapture()
|
|
91
91
|
const { closeFetch } = await import('../lib/diagnose.mjs')
|
|
92
92
|
await closeFetch()
|
|
93
|
+
} else if (cmd === 'resolve') {
|
|
94
|
+
// Entity identity dedup: judge the server-flagged fuzzy duplicate pairs locally via `claude -p`.
|
|
95
|
+
const { runResolve } = await import('../lib/resolve.mjs')
|
|
96
|
+
await runResolve()
|
|
97
|
+
const { closeFetch } = await import('../lib/diagnose.mjs')
|
|
98
|
+
await closeFetch()
|
|
93
99
|
} else if (cmd === 'ingest-folder') {
|
|
94
100
|
const { runIngestFolder } = await import('../lib/ingest_folder.mjs')
|
|
95
101
|
await runIngestFolder(rest)
|
|
96
102
|
const { closeFetch } = await import('../lib/diagnose.mjs')
|
|
97
103
|
await closeFetch()
|
|
104
|
+
} else if (cmd === 'grep') {
|
|
105
|
+
// Literal substring search over the viewer's visible brain wiki (thin client of /api/grep).
|
|
106
|
+
const { runGrep } = await import('../lib/grep_cli.mjs')
|
|
107
|
+
process.exitCode = await runGrep(rest)
|
|
108
|
+
const { closeFetch } = await import('../lib/diagnose.mjs')
|
|
109
|
+
await closeFetch()
|
|
98
110
|
} else if (cmd === 'snapshot-context') {
|
|
99
111
|
const { runSnapshotContext } = await import('../lib/context_log.mjs')
|
|
100
112
|
process.exitCode = await runSnapshotContext()
|
package/lib/grep_cli.mjs
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { fetchCortex, classify, resolveBase } from './diagnose.mjs'
|
|
2
|
+
|
|
3
|
+
// `cortex grep` CLI + shared formatting for the MCP grep tool (Lane B / T6). Thin client of
|
|
4
|
+
// GET /api/grep — the server runs the RLS-INVOKER RPC AS the viewer, so no DB credentials live here.
|
|
5
|
+
// Output is ASCII-only (outbound-message convention).
|
|
6
|
+
|
|
7
|
+
// Pure: parse argv after `grep` → { query, mode, max }.
|
|
8
|
+
// cortex grep <terms...> [--mode fts | --fts] [--max N]
|
|
9
|
+
export function parseGrepArgs(argv = []) {
|
|
10
|
+
const out = { query: '', mode: 'substring', max: undefined }
|
|
11
|
+
const terms = []
|
|
12
|
+
for (let i = 0; i < argv.length; i++) {
|
|
13
|
+
const a = argv[i]
|
|
14
|
+
if (a === '--mode') {
|
|
15
|
+
out.mode = argv[++i] === 'fts' ? 'fts' : 'substring'
|
|
16
|
+
} else if (a === '--fts') {
|
|
17
|
+
out.mode = 'fts'
|
|
18
|
+
} else if (a === '--max') {
|
|
19
|
+
const n = Number(argv[++i])
|
|
20
|
+
if (Number.isFinite(n)) out.max = Math.trunc(n)
|
|
21
|
+
} else {
|
|
22
|
+
terms.push(a)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
out.query = terms.join(' ').trim()
|
|
26
|
+
return out
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Pure: render a /api/grep payload to readable ASCII text.
|
|
30
|
+
export function formatGrepHits(payload, query) {
|
|
31
|
+
const hits = (payload && payload.hits) || []
|
|
32
|
+
if (!hits.length) return `No matches for "${query}".`
|
|
33
|
+
const lines = [`${hits.length} match${hits.length === 1 ? '' : 'es'} for "${query}":`, '']
|
|
34
|
+
for (const h of hits) {
|
|
35
|
+
const head = h.heading ? ` > ${h.heading}` : ''
|
|
36
|
+
lines.push(`- ${h.title}${head} [${h.tier}]`)
|
|
37
|
+
if (h.snippet) lines.push(` ${String(h.snippet).replace(/\s+/g, ' ').trim()}`)
|
|
38
|
+
if (h.links && h.links.length) lines.push(` -> ${h.links.map((l) => `[[${l}]]`).join(' ')}`)
|
|
39
|
+
}
|
|
40
|
+
return lines.join('\n')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Effectful: run the CLI subcommand. Returns an exit code.
|
|
44
|
+
export async function runGrep(rest = []) {
|
|
45
|
+
const TOKEN = process.env.CORTEX_TOKEN
|
|
46
|
+
const BASE = resolveBase(process.env.CORTEX_URL)
|
|
47
|
+
if (!TOKEN) {
|
|
48
|
+
process.stderr.write('cortex grep: CORTEX_TOKEN is required (get yours from the Cortex console).\n')
|
|
49
|
+
return 1
|
|
50
|
+
}
|
|
51
|
+
const { query, mode, max } = parseGrepArgs(rest)
|
|
52
|
+
if (!query) {
|
|
53
|
+
process.stderr.write('usage: cortex grep <query> [--mode fts] [--max N]\n')
|
|
54
|
+
return 1
|
|
55
|
+
}
|
|
56
|
+
const qs = new URLSearchParams({ q: query, mode })
|
|
57
|
+
if (max) qs.set('max', String(max))
|
|
58
|
+
const res = await fetchCortex(`${BASE}/api/grep?${qs.toString()}`, {
|
|
59
|
+
headers: { Authorization: `Bearer ${TOKEN}` },
|
|
60
|
+
})
|
|
61
|
+
if (!res.ok) {
|
|
62
|
+
const body = await res.text()
|
|
63
|
+
process.stderr.write(
|
|
64
|
+
classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message + '\n',
|
|
65
|
+
)
|
|
66
|
+
return 1
|
|
67
|
+
}
|
|
68
|
+
const payload = await res.json()
|
|
69
|
+
process.stdout.write(formatGrepHits(payload, query) + '\n')
|
|
70
|
+
return 0
|
|
71
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, it, expect } from 'bun:test'
|
|
2
|
+
import { parseGrepArgs, formatGrepHits } from './grep_cli.mjs'
|
|
3
|
+
|
|
4
|
+
describe('parseGrepArgs', () => {
|
|
5
|
+
it('joins free terms into the query, defaults substring', () => {
|
|
6
|
+
expect(parseGrepArgs(['hello', 'world'])).toEqual({ query: 'hello world', mode: 'substring', max: undefined })
|
|
7
|
+
})
|
|
8
|
+
it('honors --mode fts and --fts', () => {
|
|
9
|
+
expect(parseGrepArgs(['q', '--mode', 'fts']).mode).toBe('fts')
|
|
10
|
+
expect(parseGrepArgs(['--fts', 'q']).mode).toBe('fts')
|
|
11
|
+
expect(parseGrepArgs(['q', '--mode', 'bogus']).mode).toBe('substring')
|
|
12
|
+
})
|
|
13
|
+
it('parses --max as an integer, ignores non-numeric', () => {
|
|
14
|
+
expect(parseGrepArgs(['q', '--max', '25']).max).toBe(25)
|
|
15
|
+
expect(parseGrepArgs(['q', '--max', 'abc']).max).toBeUndefined()
|
|
16
|
+
})
|
|
17
|
+
it('keeps the query when flags are interleaved', () => {
|
|
18
|
+
expect(parseGrepArgs(['foo', '--max', '5', 'bar']).query).toBe('foo bar')
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('formatGrepHits', () => {
|
|
23
|
+
it('reports no matches', () => {
|
|
24
|
+
expect(formatGrepHits({ hits: [] }, 'xyz')).toBe('No matches for "xyz".')
|
|
25
|
+
expect(formatGrepHits({}, 'xyz')).toBe('No matches for "xyz".')
|
|
26
|
+
})
|
|
27
|
+
it('renders ASCII hit lines with heading, tier, snippet, links', () => {
|
|
28
|
+
const out = formatGrepHits(
|
|
29
|
+
{ hits: [{ title: 'Acme', heading: 'Current state', tier: 'accessible', snippet: 'big deal', links: ['Bob', 'Q3'] }] },
|
|
30
|
+
'deal',
|
|
31
|
+
)
|
|
32
|
+
expect(out).toContain('1 match for "deal":')
|
|
33
|
+
expect(out).toContain('- Acme > Current state [accessible]')
|
|
34
|
+
expect(out).toContain(' big deal')
|
|
35
|
+
expect(out).toContain(' -> [[Bob]] [[Q3]]')
|
|
36
|
+
})
|
|
37
|
+
it('is ASCII-only', () => {
|
|
38
|
+
const out = formatGrepHits({ hits: [{ title: 'X', heading: 'H', tier: 'scoped', snippet: 's', links: ['L'] }] }, 'q')
|
|
39
|
+
// eslint-disable-next-line no-control-regex
|
|
40
|
+
expect(/^[\x00-\x7F]*$/.test(out)).toBe(true)
|
|
41
|
+
})
|
|
42
|
+
})
|
package/lib/resolve.mjs
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
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 resolve` — the JUDGE half of entity identity dedup (Grey harvest). The server FLAGS the
|
|
6
|
+
// fuzzy band of near-duplicate non-person entities (GET /api/resolve-candidates); this command judges
|
|
7
|
+
// each pair LOCALLY via `claude -p` (the engine preference — the server never calls the metered API)
|
|
8
|
+
// and pushes decisions back (POST /api/resolve-apply): confident-same → entity_merges, else → rejected
|
|
9
|
+
// so the pair never re-flags. Conservative by construction. Always exits cleanly.
|
|
10
|
+
|
|
11
|
+
export async function runResolve() {
|
|
12
|
+
// recursion guard (we spawn `claude --print`; if its Stop hook fires capture, that no-ops on this flag)
|
|
13
|
+
if (process.env.CORTEX_SUMMARIZING) { process.stderr.write('cortex: summarizer subprocess, skipping\n'); return }
|
|
14
|
+
const token = process.env.CORTEX_TOKEN
|
|
15
|
+
if (!token) { process.stderr.write('cortex: CORTEX_TOKEN not set, skipping\n'); return }
|
|
16
|
+
const base = resolveBase(process.env.CORTEX_URL)
|
|
17
|
+
|
|
18
|
+
// 1. pull the flagged candidate pairs
|
|
19
|
+
let candidates = []
|
|
20
|
+
try {
|
|
21
|
+
const res = await fetchCortex(`${base}/api/resolve-candidates`, { headers: { Authorization: `Bearer ${token}` } })
|
|
22
|
+
if (!res.ok) {
|
|
23
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
24
|
+
process.stderr.write(`cortex: resolve-candidates failed — ${d.message}\n`); return
|
|
25
|
+
}
|
|
26
|
+
const j = await res.json().catch(() => ({}))
|
|
27
|
+
candidates = Array.isArray(j.candidates) ? j.candidates : []
|
|
28
|
+
} catch (e) { process.stderr.write(`cortex: resolve fetch failed — ${e.message}\n`); return }
|
|
29
|
+
|
|
30
|
+
if (!candidates.length) { process.stderr.write('cortex: no duplicate candidates to judge\n'); return }
|
|
31
|
+
process.stderr.write(`cortex: judging ${candidates.length} candidate pair(s) locally…\n`)
|
|
32
|
+
|
|
33
|
+
// 2. judge locally on the subscription
|
|
34
|
+
const decisions = judgeCandidates(candidates)
|
|
35
|
+
if (decisions === null) { process.stderr.write('cortex: judge unavailable (is `claude` on PATH?) — skipping\n'); return }
|
|
36
|
+
|
|
37
|
+
// 3. apply judged decisions
|
|
38
|
+
try {
|
|
39
|
+
const res = await fetchCortex(`${base}/api/resolve-apply`, {
|
|
40
|
+
method: 'POST',
|
|
41
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
42
|
+
body: JSON.stringify({ decisions }),
|
|
43
|
+
})
|
|
44
|
+
if (res.ok) {
|
|
45
|
+
const j = await res.json().catch(() => ({}))
|
|
46
|
+
process.stderr.write(`cortex: dedup — merged ${j.merged ?? 0}, rejected ${j.rejected ?? 0}, skipped ${j.skipped ?? 0}\n`)
|
|
47
|
+
} else {
|
|
48
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
49
|
+
process.stderr.write(`cortex: resolve-apply failed — ${d.message}\n`)
|
|
50
|
+
}
|
|
51
|
+
} catch (e) { process.stderr.write(`cortex: resolve-apply failed — ${e.message}\n`) }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ONE `claude --print` call judges every pair. Returns DedupDecision[] for the apply endpoint, or null
|
|
55
|
+
// if the CLI is unavailable. Every judged pair (merge AND non-merge) is returned so non-merges get
|
|
56
|
+
// recorded as rejected and never re-flag.
|
|
57
|
+
function judgeCandidates(candidates) {
|
|
58
|
+
const list = candidates.map((c, i) =>
|
|
59
|
+
`[${i}] kind=${c.kind}\n` +
|
|
60
|
+
` A: "${c.aName}" (e.g. record: ${c.aSample ?? 'n/a'}; ${c.aMentions} mentions)\n` +
|
|
61
|
+
` B: "${c.bName}" (e.g. record: ${c.bSample ?? 'n/a'}; ${c.bMentions} mentions)\n` +
|
|
62
|
+
` shared records: ${c.sharedRecords}; name similarity: ${Number(c.sim).toFixed(2)}`,
|
|
63
|
+
).join('\n')
|
|
64
|
+
const prompt =
|
|
65
|
+
'You are deduplicating an organization knowledge graph. For each candidate pair below, decide ' +
|
|
66
|
+
'whether A and B are the SAME real-world entity (a spelling/abbreviation/variant of ONE thing) or ' +
|
|
67
|
+
'DIFFERENT things that merely have similar names. Be CONSERVATIVE: only merge when confident; when ' +
|
|
68
|
+
'in doubt, do NOT merge. Same kind only.\n' +
|
|
69
|
+
'Return ONLY a minified JSON array — one object per pair: ' +
|
|
70
|
+
'{"i":<index>,"merge":<true|false>,"confidence":<0..1>,"reason":"<short>"}.\n\n' + list
|
|
71
|
+
try {
|
|
72
|
+
const r = spawnSync(
|
|
73
|
+
'claude',
|
|
74
|
+
['--print', '--model', process.env.CORTEX_SUMMARY_MODEL ?? 'claude-haiku-4-5', prompt],
|
|
75
|
+
{ env: edgeSafeEnv(process.env, { CORTEX_SUMMARIZING: '1' }), encoding: 'utf8', timeout: 120_000, maxBuffer: 4 * 1024 * 1024 },
|
|
76
|
+
)
|
|
77
|
+
if (r.status !== 0 || !r.stdout) return null
|
|
78
|
+
return parseDecisions(r.stdout, candidates)
|
|
79
|
+
} catch {
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function parseDecisions(out, candidates) {
|
|
85
|
+
const start = out.indexOf('[')
|
|
86
|
+
const end = out.lastIndexOf(']')
|
|
87
|
+
if (start < 0 || end <= start) return []
|
|
88
|
+
let arr
|
|
89
|
+
try { arr = JSON.parse(out.slice(start, end + 1)) } catch { return [] }
|
|
90
|
+
if (!Array.isArray(arr)) return []
|
|
91
|
+
const decisions = []
|
|
92
|
+
for (const d of arr) {
|
|
93
|
+
const c = candidates[Number(d?.i)]
|
|
94
|
+
if (!c) continue
|
|
95
|
+
decisions.push({
|
|
96
|
+
aId: c.aId, bId: c.bId, kind: c.kind,
|
|
97
|
+
merge: !!d.merge,
|
|
98
|
+
confidence: Number(d.confidence) || 0,
|
|
99
|
+
reason: typeof d.reason === 'string' ? d.reason.slice(0, 200) : undefined,
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
return decisions
|
|
103
|
+
}
|
package/lib/server.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { join } from 'path'
|
|
|
7
7
|
import { createHash, randomUUID } from 'crypto'
|
|
8
8
|
import { fetchCortex, classify, resolveBase } from './diagnose.mjs'
|
|
9
9
|
import { runSendImessage } from './imessage_send.mjs'
|
|
10
|
+
import { formatGrepHits } from './grep_cli.mjs'
|
|
10
11
|
|
|
11
12
|
// The Cortex MCP server (stdio). Serves the signed-in employee's scoped org
|
|
12
13
|
// context to their AI assistant. CORTEX_TOKEN identifies the user + org.
|
|
@@ -185,6 +186,29 @@ export async function runServer(version) {
|
|
|
185
186
|
},
|
|
186
187
|
)
|
|
187
188
|
|
|
189
|
+
server.registerTool(
|
|
190
|
+
'grep',
|
|
191
|
+
{
|
|
192
|
+
title: 'Grep the brain wiki',
|
|
193
|
+
description:
|
|
194
|
+
'Literal substring search across your visible brain wiki pages (matches symbols, identifiers, [[links]]). Returns matching sections with a context snippet and their outbound [[links]].',
|
|
195
|
+
inputSchema: {
|
|
196
|
+
query: z.string().describe('literal substring to find'),
|
|
197
|
+
mode: z.enum(['substring', 'fts']).optional().describe("'substring' (default, grep-like) or 'fts' (ranked keyword)"),
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
async ({ query, mode }) => {
|
|
201
|
+
const qs = new URLSearchParams({ q: query, mode: mode === 'fts' ? 'fts' : 'substring' })
|
|
202
|
+
const res = await fetchCortex(`${BASE}/api/grep?${qs.toString()}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
203
|
+
if (!res.ok) {
|
|
204
|
+
const body = await res.text()
|
|
205
|
+
throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
|
|
206
|
+
}
|
|
207
|
+
const payload = await res.json()
|
|
208
|
+
return { content: [{ type: 'text', text: formatGrepHits(payload, query) }] }
|
|
209
|
+
},
|
|
210
|
+
)
|
|
211
|
+
|
|
188
212
|
server.registerTool(
|
|
189
213
|
'project_status',
|
|
190
214
|
{
|