@theronap/cortex-mcp 0.9.11 → 0.9.13
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 +6 -0
- package/lib/capture.mjs +27 -0
- package/lib/extract_typed.mjs +68 -0
- package/lib/resolve.mjs +103 -0
- package/package.json +1 -1
package/bin/cortex-mcp.mjs
CHANGED
|
@@ -90,6 +90,12 @@ 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)
|
package/lib/capture.mjs
CHANGED
|
@@ -4,6 +4,20 @@ import { resolve } from 'path'
|
|
|
4
4
|
import { createHash } from 'crypto'
|
|
5
5
|
import { fetchCortex, classify, resolveBase } from './diagnose.mjs'
|
|
6
6
|
import { extractSession } from './edge_extract.mjs'
|
|
7
|
+
import { extractTyped } from './extract_typed.mjs'
|
|
8
|
+
|
|
9
|
+
// Fetch the node-type registry (global + this org's custom types) — the catalog the typed extractor
|
|
10
|
+
// produces against. Best-effort: null on any failure (typed extraction is then skipped, never blocks capture).
|
|
11
|
+
async function fetchRegistry(base, token) {
|
|
12
|
+
try {
|
|
13
|
+
const res = await fetchCortex(`${base}/api/node-types`, { headers: { Authorization: `Bearer ${token}` } })
|
|
14
|
+
if (!res.ok) return null
|
|
15
|
+
const j = await res.json().catch(() => ({}))
|
|
16
|
+
return Array.isArray(j.types) ? j.types : null
|
|
17
|
+
} catch {
|
|
18
|
+
return null
|
|
19
|
+
}
|
|
20
|
+
}
|
|
7
21
|
|
|
8
22
|
// Project name from the hook cwd — cross-platform. Windows hooks send backslash paths,
|
|
9
23
|
// and splitting on '/' alone turned the WHOLE path into one garbage project slug
|
|
@@ -112,6 +126,19 @@ export async function runCapture() {
|
|
|
112
126
|
? { ...common, summary: extracted.summary, people: extracted.people, entities: extracted.namedEntities }
|
|
113
127
|
: { ...common, transcript }
|
|
114
128
|
|
|
129
|
+
// Parallel typed extraction (OPT-IN via CORTEX_TYPED): registry-driven typed notes, sent ALONGSIDE the
|
|
130
|
+
// people/entities above. The server's typedNotes receiver persists them additively. Off by default so it
|
|
131
|
+
// never adds a 2nd `claude` call / latency until verified; flip to default once typed ≥ the blob path.
|
|
132
|
+
if (process.env.CORTEX_TYPED && transcript) {
|
|
133
|
+
try {
|
|
134
|
+
const registry = await fetchRegistry(base, token)
|
|
135
|
+
if (registry?.length) {
|
|
136
|
+
const typed = extractTyped(transcript, registry)
|
|
137
|
+
if (typed?.notes?.length) ingestBody.typedNotes = typed.notes
|
|
138
|
+
}
|
|
139
|
+
} catch { /* best-effort — never block capture */ }
|
|
140
|
+
}
|
|
141
|
+
|
|
115
142
|
let res
|
|
116
143
|
try {
|
|
117
144
|
res = await fetchCortex(`${base}/api/ingest`, {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { spawnSync } from 'child_process'
|
|
2
|
+
import { edgeSafeEnv } from './edge_extract.mjs'
|
|
3
|
+
|
|
4
|
+
// Typed extraction (synthesis slice 2) — registry-driven. Given a transcript + the node_types registry,
|
|
5
|
+
// the LLM emits TYPED NOTES (one per real thing that happened / was mentioned) conforming to the type
|
|
6
|
+
// definitions, instead of the old loose {summary, people, namedEntities} blob. Runs locally on the
|
|
7
|
+
// subscription (edgeSafeEnv strips ANTHROPIC_*). PARALLEL to edge_extract.mjs — does NOT replace it yet;
|
|
8
|
+
// built to be tested in isolation, then wired into capture once proven (slice 2b).
|
|
9
|
+
//
|
|
10
|
+
// Per the ontology (cortex-node-ontology-spec): programmatic fields are filled by the caller from source
|
|
11
|
+
// metadata BEFORE this runs; here the LLM fills only what the text supports, self-flags llm_filled fields,
|
|
12
|
+
// and uses "unknown" for required fields it can't determine (→ red-link downstream). It may also COIN a
|
|
13
|
+
// new type (open registry) when nothing fits.
|
|
14
|
+
|
|
15
|
+
function typeCard(t) {
|
|
16
|
+
const fields = (arr) => (Array.isArray(arr) ? arr : []).map((f) => `${f.field}${f.note ? ` (${f.note})` : ''}`).join(', ')
|
|
17
|
+
return `• ${t.name} [${t.layer}]${t.applies_to?.length ? ` applies_to:${t.applies_to.join('/')}` : ''}\n` +
|
|
18
|
+
` required: ${fields(t.required_fields) || '—'}\n` +
|
|
19
|
+
` optional: ${fields(t.optional_fields) || '—'}\n` +
|
|
20
|
+
` how: ${t.extraction_instructions}`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function extractTyped(transcript, registry, opts = {}) {
|
|
24
|
+
const text = (transcript ?? '').trim()
|
|
25
|
+
if (!text || !Array.isArray(registry) || !registry.length) return null
|
|
26
|
+
const catalog = registry.map(typeCard).join('\n')
|
|
27
|
+
const prompt =
|
|
28
|
+
'You are building an organization knowledge graph from a work session. Using ONLY the node/note types ' +
|
|
29
|
+
'in the CATALOG below, emit the typed notes this session supports — one per real thing that happened ' +
|
|
30
|
+
'(actions, decisions, communications) or entity referenced (people, projects, etc.).\n\n' +
|
|
31
|
+
'RULES:\n' +
|
|
32
|
+
'- Fill a field ONLY if the text supports it. If a REQUIRED field is unknowable, set it to "unknown".\n' +
|
|
33
|
+
'- List the fields YOU inferred (vs. ones obviously given) in "llm_filled".\n' +
|
|
34
|
+
'- Reference other things by name (e.g. actors:["Jane Doe"]) — do not invent ids.\n' +
|
|
35
|
+
'- NEVER invent numbers (amounts, values, metrics) — "unknown" if not explicitly stated.\n' +
|
|
36
|
+
'- Be conservative: only emit a note for something genuinely present. Empty array is fine.\n' +
|
|
37
|
+
'- If something important fits NO type, you may coin one: add it to "new_types" with ' +
|
|
38
|
+
'{name, layer, required_fields:[{field}], why}.\n\n' +
|
|
39
|
+
'Return ONLY minified JSON: {"notes":[{"type","fields":{...},"llm_filled":[...]}],"new_types":[...]}.\n\n' +
|
|
40
|
+
'--- CATALOG ---\n' + catalog + '\n\n--- SESSION ---\n' + text.slice(0, 12000) + '\n--- END ---'
|
|
41
|
+
try {
|
|
42
|
+
const r = spawnSync(
|
|
43
|
+
'claude',
|
|
44
|
+
['--print', '--model', opts.model ?? process.env.CORTEX_SUMMARY_MODEL ?? 'claude-haiku-4-5', prompt],
|
|
45
|
+
{ env: edgeSafeEnv(process.env, { CORTEX_SUMMARIZING: '1' }), encoding: 'utf8', timeout: 120_000, maxBuffer: 8 * 1024 * 1024 },
|
|
46
|
+
)
|
|
47
|
+
if (r.status !== 0 || !r.stdout) return null
|
|
48
|
+
return parseTyped(r.stdout)
|
|
49
|
+
} catch {
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function parseTyped(out) {
|
|
55
|
+
if (!out) return null
|
|
56
|
+
const start = out.indexOf('{')
|
|
57
|
+
const end = out.lastIndexOf('}')
|
|
58
|
+
if (start < 0 || end <= start) return null
|
|
59
|
+
try {
|
|
60
|
+
const o = JSON.parse(out.slice(start, end + 1))
|
|
61
|
+
return {
|
|
62
|
+
notes: Array.isArray(o.notes) ? o.notes : [],
|
|
63
|
+
new_types: Array.isArray(o.new_types) ? o.new_types : [],
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
}
|
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
|
+
}
|