@theronap/cortex-mcp 0.9.23 → 0.9.25
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 +0 -13
- package/lib/server.mjs +7 -108
- package/package.json +1 -1
- package/lib/links.mjs +0 -105
- package/lib/relationships.mjs +0 -138
package/bin/cortex-mcp.mjs
CHANGED
|
@@ -97,19 +97,6 @@ if (cmd === 'setup') {
|
|
|
97
97
|
await runResolve()
|
|
98
98
|
const { closeFetch } = await import('../lib/diagnose.mjs')
|
|
99
99
|
await closeFetch()
|
|
100
|
-
} else if (cmd === 'relationships') {
|
|
101
|
-
// Tier-3 relationship inference: judge server-flagged nodes' prose-implied relationships via `claude -p`.
|
|
102
|
-
const { runRelationships } = await import('../lib/relationships.mjs')
|
|
103
|
-
await runRelationships()
|
|
104
|
-
const { closeFetch } = await import('../lib/diagnose.mjs')
|
|
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
100
|
} else if (cmd === 'materialize') {
|
|
114
101
|
// Brain-page summarizer on the SUBSCRIPTION (claude -p): claim authorized digest jobs, summarize
|
|
115
102
|
// locally, submit the text for the server to write. `--drain` clears the whole backlog.
|
package/lib/server.mjs
CHANGED
|
@@ -271,95 +271,16 @@ export async function runServer(version) {
|
|
|
271
271
|
}
|
|
272
272
|
const page = await res.json()
|
|
273
273
|
if (!page?.authored || !Array.isArray(page.tiers) || !page.tiers.length) {
|
|
274
|
-
return { content: [{ type: 'text', text: `"${name}" (${k}) exists but has no authored page yet — nothing to read.
|
|
274
|
+
return { content: [{ type: 'text', text: `"${name}" (${k}) exists but has no authored page yet — nothing to read. \`grep\` for mentions, or author it if you hold first-hand knowledge worth capturing.` }] }
|
|
275
275
|
}
|
|
276
276
|
const day = (d) => (d ? String(d).slice(0, 10) : '')
|
|
277
277
|
const blocks = page.tiers.map((t) => {
|
|
278
278
|
const secs = (t.sections ?? []).map((s) => `### ${s.heading}\n${s.body}`).join('\n\n')
|
|
279
|
-
const head = `[${t.tier}${day(t.updated_at) ? ` · authored ${day(t.updated_at)}` : ''}${t.validity && t.validity !== 'current' ? ` · ${t.validity}` : ''}]`
|
|
279
|
+
const head = `[${t.tier}${day(t.updated_at) ? ` · authored ${day(t.updated_at)}` : ''}${t.validity && t.validity !== 'current' ? ` · ${t.validity}` : ''}${t.version ? `\nversion: ${t.version}` : ''}]`
|
|
280
280
|
return [head, t.summary, secs].filter(Boolean).join('\n')
|
|
281
281
|
})
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
)
|
|
285
|
-
|
|
286
|
-
server.registerTool(
|
|
287
|
-
'story',
|
|
288
|
-
{
|
|
289
|
-
title: 'Story of a topic',
|
|
290
|
-
description: 'Ask "what\'s the story of X?" — assembles a chronological narrative of what happened with a project, topic, or piece of work, from across all your activity (commits, sessions, discussions). Scoped to what you can see.',
|
|
291
|
-
inputSchema: { question: z.string().describe('the topic/project/thing to get the story of, e.g. "the auth work" or "checkout-v2"') },
|
|
292
|
-
},
|
|
293
|
-
async ({ question }) => {
|
|
294
|
-
let res
|
|
295
|
-
try {
|
|
296
|
-
res = await fetchCortex(`${BASE}/api/story`, {
|
|
297
|
-
method: 'POST',
|
|
298
|
-
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
299
|
-
body: JSON.stringify({ question }),
|
|
300
|
-
})
|
|
301
|
-
} catch (e) {
|
|
302
|
-
return { content: [{ type: 'text', text: `Could not assemble story: ${e.message}` }] }
|
|
303
|
-
}
|
|
304
|
-
if (!res.ok) {
|
|
305
|
-
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
306
|
-
return { content: [{ type: 'text', text: `Could not assemble story: ${d.message}` }] }
|
|
307
|
-
}
|
|
308
|
-
const { answer, sourceCount, sources } = await res.json()
|
|
309
|
-
const srcList = (sources ?? []).map((s) => ` - [${s.id}] ${s.title} (${s.source})`).join('\n')
|
|
310
|
-
const tail = srcList ? `\n\nSources (pass an id to set_record_privacy to reclassify a record you own):\n${srcList}` : ''
|
|
311
|
-
return { content: [{ type: 'text', text: `${answer}\n\n(assembled from ${sourceCount} record${sourceCount === 1 ? '' : 's'})${tail}` }] }
|
|
312
|
-
},
|
|
313
|
-
)
|
|
314
|
-
|
|
315
|
-
server.registerTool(
|
|
316
|
-
'who_knows',
|
|
317
|
-
{
|
|
318
|
-
title: 'Who knows about X',
|
|
319
|
-
description: 'Ranked teammates who have visibly worked on a topic, with evidence — derived only from records you are permitted to see.',
|
|
320
|
-
inputSchema: { topic: z.string().describe('the topic/system/skill, e.g. "the embedding pipeline" or "stripe webhooks"') },
|
|
321
|
-
},
|
|
322
|
-
async ({ topic }) => {
|
|
323
|
-
let res
|
|
324
|
-
try {
|
|
325
|
-
res = await fetchCortex(`${BASE}/api/who-knows?q=${encodeURIComponent(topic)}`, {
|
|
326
|
-
headers: { Authorization: `Bearer ${TOKEN}` },
|
|
327
|
-
})
|
|
328
|
-
} catch (e) {
|
|
329
|
-
return { content: [{ type: 'text', text: `Could not look up experts: ${e.message}` }] }
|
|
330
|
-
}
|
|
331
|
-
if (!res.ok) {
|
|
332
|
-
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
333
|
-
return { content: [{ type: 'text', text: `Could not look up experts: ${d.message}` }] }
|
|
334
|
-
}
|
|
335
|
-
const { experts } = await res.json()
|
|
336
|
-
if (!experts?.length) return { content: [{ type: 'text', text: `No one visible to you has recorded work about "${topic}".` }] }
|
|
337
|
-
const lines = experts.map((e, i) =>
|
|
338
|
-
`${i + 1}. ${e.name} — ${e.count} visible record${e.count === 1 ? '' : 's'}\n e.g. ${e.evidence.join(' · ')}`)
|
|
339
|
-
return { content: [{ type: 'text', text: `People with visible work on "${topic}":\n${lines.join('\n')}` }] }
|
|
340
|
-
},
|
|
341
|
-
)
|
|
342
|
-
|
|
343
|
-
server.registerTool(
|
|
344
|
-
'daily_brief',
|
|
345
|
-
{
|
|
346
|
-
title: 'Your daily brief',
|
|
347
|
-
description: 'A short, action-forward brief of what needs YOU today: pending decisions/directives, your stalled or unowned projects, today\'s meetings, who else moved on your work, and where you left off. The inverse of my_context — call it at the start of a session or whenever you want "what should I focus on?". RLS-scoped to you.',
|
|
348
|
-
inputSchema: {},
|
|
349
|
-
},
|
|
350
|
-
async () => {
|
|
351
|
-
let res
|
|
352
|
-
try {
|
|
353
|
-
res = await fetchCortex(`${BASE}/api/brief`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
354
|
-
} catch (e) {
|
|
355
|
-
return { content: [{ type: 'text', text: `Could not build your brief: ${e.message}` }] }
|
|
356
|
-
}
|
|
357
|
-
if (!res.ok) {
|
|
358
|
-
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
359
|
-
return { content: [{ type: 'text', text: `Could not build your brief: ${d.message}` }] }
|
|
360
|
-
}
|
|
361
|
-
const { brief } = await res.json()
|
|
362
|
-
return { content: [{ type: 'text', text: brief || 'Nothing needs you right now.' }] }
|
|
282
|
+
const footer = `— Follow any [[links]] above with read_page to go deeper.\n— If you hold fresher FIRST-HAND truth than this page — something you established THIS session (ran the command, made the change, hold the artifact) that a section here gets concretely wrong — re-author just those sections now with \`author\` (any node you are permitted to edit; concise; a material contradiction, not merely extra detail). Pass this page's \`version\` as base_version when you re-author — if the save comes back stale, the page changed under you: re-read, then re-author. Reading a stale page you can fix IS the trigger to fix it.`
|
|
283
|
+
return { content: [{ type: 'text', text: `# ${page.title ?? name} (full authored page)\n\n${blocks.join('\n\n---\n\n')}\n\n${footer}` }] }
|
|
363
284
|
},
|
|
364
285
|
)
|
|
365
286
|
|
|
@@ -413,29 +334,6 @@ export async function runServer(version) {
|
|
|
413
334
|
},
|
|
414
335
|
)
|
|
415
336
|
|
|
416
|
-
server.registerTool(
|
|
417
|
-
'org_report',
|
|
418
|
-
{
|
|
419
|
-
title: 'Cross-project status report',
|
|
420
|
-
description: 'A status digest across all projects you can see: how many are healthy vs slowing/stalled/unowned, what needs attention, and (for managers) cross-source gaps. RLS-scoped to you. Use for "where do things stand?" / a standup or weekly review.',
|
|
421
|
-
inputSchema: {},
|
|
422
|
-
},
|
|
423
|
-
async () => {
|
|
424
|
-
let res
|
|
425
|
-
try {
|
|
426
|
-
res = await fetchCortex(`${BASE}/api/report`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
427
|
-
} catch (e) {
|
|
428
|
-
return { content: [{ type: 'text', text: `Could not build the report: ${e.message}` }] }
|
|
429
|
-
}
|
|
430
|
-
if (!res.ok) {
|
|
431
|
-
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
432
|
-
return { content: [{ type: 'text', text: `Could not build the report: ${d.message}` }] }
|
|
433
|
-
}
|
|
434
|
-
const { report } = await res.json()
|
|
435
|
-
return { content: [{ type: 'text', text: report }] }
|
|
436
|
-
},
|
|
437
|
-
)
|
|
438
|
-
|
|
439
337
|
server.registerTool(
|
|
440
338
|
'list_records',
|
|
441
339
|
{
|
|
@@ -729,10 +627,11 @@ export async function runServer(version) {
|
|
|
729
627
|
body: z.string().describe('dense markdown WITH inline [[links]] where the prose references another node'),
|
|
730
628
|
})).describe('3-5 sections; the page body'),
|
|
731
629
|
tier: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('visibility tier (default accessible — the shareable page)'),
|
|
630
|
+
base_version: z.string().optional().describe('the `version` hash shown when you read this page (read_page) — REQUIRED when updating an existing page, so a concurrent edit is caught instead of clobbered. Omit only for a brand-new node. If the save returns "stale" or "read first", read_page again and retry with the fresh version.'),
|
|
732
631
|
},
|
|
733
632
|
},
|
|
734
|
-
async ({ kind, name, summary, sections, tier }) => {
|
|
735
|
-
const pages = [{ tier: tier ?? 'accessible', summary, sections: Array.isArray(sections) ? sections : [] }]
|
|
633
|
+
async ({ kind, name, summary, sections, tier, base_version }) => {
|
|
634
|
+
const pages = [{ tier: tier ?? 'accessible', summary, base_version, sections: Array.isArray(sections) ? sections : [] }]
|
|
736
635
|
let res
|
|
737
636
|
try {
|
|
738
637
|
res = await fetchCortex(`${BASE}/api/brain/author`, {
|
package/package.json
CHANGED
package/lib/links.mjs
DELETED
|
@@ -1,105 +0,0 @@
|
|
|
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
|
-
}
|
package/lib/relationships.mjs
DELETED
|
@@ -1,138 +0,0 @@
|
|
|
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 relationships` — the JUDGE half of Tier-3 relationship inference (relationship layer slice 3,
|
|
6
|
-
// sibling of `resolve`). The server FLAGS nodes worth judging (GET /api/relationship-candidates) with their
|
|
7
|
-
// accessible evidence + already-known connections; this command reads that evidence LOCALLY via `claude -p`
|
|
8
|
-
// (the engine preference — the server never calls the metered API) and infers prose-implied relationships,
|
|
9
|
-
// then pushes them back (POST /api/relationship-apply) where high/medium-confidence ones become [[links]] on
|
|
10
|
-
// the node's page. Conservative by construction: structural/known links are excluded from the prompt, only
|
|
11
|
-
// relationships actually stated in the evidence are returned, and low confidence is dropped server-side.
|
|
12
|
-
|
|
13
|
-
// Relationship taxonomy (spec §2) — the judge must pick from this enum (business only; no romantic/family).
|
|
14
|
-
const REL_TYPES = [
|
|
15
|
-
'reports_to', 'manages', 'member_of', 'employed_by', 'works_on', 'works_with', 'owns', 'depends_on',
|
|
16
|
-
'produces', 'customer_of', 'vendor_to', 'partner_of', 'invested_in', 'governs', 'about', 'cites', 'supersedes',
|
|
17
|
-
]
|
|
18
|
-
|
|
19
|
-
export async function runRelationships() {
|
|
20
|
-
// recursion guard (we spawn `claude --print`; if its Stop hook fires capture, that no-ops on this flag)
|
|
21
|
-
if (process.env.CORTEX_SUMMARIZING) { process.stderr.write('cortex: summarizer subprocess, skipping\n'); return }
|
|
22
|
-
const token = process.env.CORTEX_TOKEN
|
|
23
|
-
if (!token) { process.stderr.write('cortex: CORTEX_TOKEN not set, skipping\n'); return }
|
|
24
|
-
const base = resolveBase(process.env.CORTEX_URL)
|
|
25
|
-
|
|
26
|
-
// 1. pull the flagged candidate nodes (each with evidence + known connections)
|
|
27
|
-
let candidates = []
|
|
28
|
-
try {
|
|
29
|
-
const res = await fetchCortex(`${base}/api/relationship-candidates`, { headers: { Authorization: `Bearer ${token}` } })
|
|
30
|
-
if (!res.ok) {
|
|
31
|
-
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
32
|
-
process.stderr.write(`cortex: relationship-candidates failed — ${d.message}\n`); return
|
|
33
|
-
}
|
|
34
|
-
const j = await res.json().catch(() => ({}))
|
|
35
|
-
candidates = Array.isArray(j.candidates) ? j.candidates : []
|
|
36
|
-
} catch (e) { process.stderr.write(`cortex: relationships fetch failed — ${e.message}\n`); return }
|
|
37
|
-
|
|
38
|
-
if (!candidates.length) { process.stderr.write('cortex: no nodes to judge for relationships\n'); return }
|
|
39
|
-
process.stderr.write(`cortex: inferring relationships for ${candidates.length} node(s) locally…\n`)
|
|
40
|
-
|
|
41
|
-
// 2. judge locally on the subscription — ONE call per node (evidence is per-node, bounded)
|
|
42
|
-
const relationships = []
|
|
43
|
-
let judged = 0
|
|
44
|
-
for (const c of candidates) {
|
|
45
|
-
const inferred = judgeNode(c)
|
|
46
|
-
if (inferred === null) { process.stderr.write('cortex: judge unavailable (is `claude` on PATH?) — skipping\n'); break }
|
|
47
|
-
judged++
|
|
48
|
-
for (const r of inferred) relationships.push(r)
|
|
49
|
-
}
|
|
50
|
-
if (!judged) return
|
|
51
|
-
if (!relationships.length) { process.stderr.write(`cortex: judged ${judged} node(s), no new relationships inferred\n`); return }
|
|
52
|
-
|
|
53
|
-
// 3. apply the judged relationships
|
|
54
|
-
try {
|
|
55
|
-
const res = await fetchCortex(`${base}/api/relationship-apply`, {
|
|
56
|
-
method: 'POST',
|
|
57
|
-
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
58
|
-
body: JSON.stringify({ relationships }),
|
|
59
|
-
})
|
|
60
|
-
if (res.ok) {
|
|
61
|
-
const j = await res.json().catch(() => ({}))
|
|
62
|
-
process.stderr.write(`cortex: relationships — wrote ${j.relationships ?? 0} across ${j.subjects ?? 0} node(s), skipped ${j.skipped ?? 0}\n`)
|
|
63
|
-
} else {
|
|
64
|
-
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
65
|
-
process.stderr.write(`cortex: relationship-apply failed — ${d.message}\n`)
|
|
66
|
-
}
|
|
67
|
-
} catch (e) { process.stderr.write(`cortex: relationship-apply failed — ${e.message}\n`) }
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// ONE `claude --print` call infers a node's prose-implied relationships from its evidence. Returns
|
|
71
|
-
// InferredRelationship[] (subjectId/subjectName carried from the candidate), or null if the CLI is
|
|
72
|
-
// unavailable. Conservative: already-known connections are listed as "do NOT re-assert"; only relationships
|
|
73
|
-
// the evidence actually states are returned; the model self-scores confidence and the server drops < 0.5.
|
|
74
|
-
function judgeNode(c) {
|
|
75
|
-
if (!c || typeof c.nodeId !== 'string' || typeof c.nodeName !== 'string') return []
|
|
76
|
-
const evidence = (Array.isArray(c.evidence) ? c.evidence : [])
|
|
77
|
-
.slice(0, 30)
|
|
78
|
-
.map((e, i) => `[${i}] ${e?.title ?? ''}${e?.summary ? ` — ${String(e.summary).slice(0, 280)}` : ''}`)
|
|
79
|
-
.join('\n')
|
|
80
|
-
if (!evidence.trim()) return []
|
|
81
|
-
const known = (Array.isArray(c.knownConnections) ? c.knownConnections : []).filter(Boolean).join(', ') || '(none)'
|
|
82
|
-
const prompt =
|
|
83
|
-
`You are mapping business relationships for ONE person in an organization knowledge graph: "${c.nodeName}".\n` +
|
|
84
|
-
`Below is evidence (record titles + summaries) that mentions them. Identify relationships of "${c.nodeName}" ` +
|
|
85
|
-
`that are EXPLICITLY stated or strongly implied by THIS evidence — to other people, teams, projects, ` +
|
|
86
|
-
`systems, or organizations.\n\n` +
|
|
87
|
-
`Rules:\n` +
|
|
88
|
-
`- Use ONLY these relationship types: ${REL_TYPES.join(', ')}.\n` +
|
|
89
|
-
`- Be CONSERVATIVE: only assert a relationship the evidence actually supports. When unsure, omit it.\n` +
|
|
90
|
-
`- Do NOT re-assert these already-known connections: ${known}.\n` +
|
|
91
|
-
`- "target" is the OTHER endpoint's name exactly as written. Never invent names, numbers, or titles.\n` +
|
|
92
|
-
`- Business relationships only (no romantic/family).\n` +
|
|
93
|
-
`Return ONLY a minified JSON array — one object per relationship: ` +
|
|
94
|
-
`{"rel_type":"<one of the enum>","target":"<name>","confidence":<0..1>,"evidence":"<short quote>"}.\n` +
|
|
95
|
-
`If the evidence supports no clear relationship, return [].\n\n` +
|
|
96
|
-
`EVIDENCE:\n${evidence}`
|
|
97
|
-
try {
|
|
98
|
-
const r = spawnSync(
|
|
99
|
-
'claude',
|
|
100
|
-
['--print', '--model', process.env.CORTEX_SUMMARY_MODEL ?? 'claude-haiku-4-5', prompt],
|
|
101
|
-
{ env: edgeSafeEnv(process.env, { CORTEX_SUMMARIZING: '1' }), encoding: 'utf8', timeout: 120_000, maxBuffer: 4 * 1024 * 1024 },
|
|
102
|
-
)
|
|
103
|
-
if (r.status !== 0 || !r.stdout) return null
|
|
104
|
-
return parseInferred(r.stdout, c)
|
|
105
|
-
} catch {
|
|
106
|
-
return null
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function parseInferred(out, c) {
|
|
111
|
-
const start = out.indexOf('[')
|
|
112
|
-
const end = out.lastIndexOf(']')
|
|
113
|
-
if (start < 0 || end <= start) return []
|
|
114
|
-
let arr
|
|
115
|
-
try { arr = JSON.parse(out.slice(start, end + 1)) } catch { return [] }
|
|
116
|
-
if (!Array.isArray(arr)) return []
|
|
117
|
-
const valid = new Set(REL_TYPES)
|
|
118
|
-
const out2 = []
|
|
119
|
-
for (const d of arr) {
|
|
120
|
-
const relType = typeof d?.rel_type === 'string' ? d.rel_type.trim() : ''
|
|
121
|
-
const target = typeof d?.target === 'string' ? d.target.trim() : ''
|
|
122
|
-
if (!valid.has(relType) || !target) continue
|
|
123
|
-
out2.push({
|
|
124
|
-
subjectId: c.nodeId,
|
|
125
|
-
subjectName: c.nodeName,
|
|
126
|
-
// echo the candidate's node-kind/tier/owner so the server writes the inference at the right
|
|
127
|
-
// tier + owner (accessible/person vs the user's own scoped|confidential page). Server re-validates.
|
|
128
|
-
nodeKind: c.nodeKind,
|
|
129
|
-
tier: c.tier,
|
|
130
|
-
ownerUserId: c.ownerUserId ?? null,
|
|
131
|
-
relType,
|
|
132
|
-
target,
|
|
133
|
-
confidence: Number(d.confidence) || 0,
|
|
134
|
-
evidence: typeof d.evidence === 'string' ? d.evidence.slice(0, 240) : undefined,
|
|
135
|
-
})
|
|
136
|
-
}
|
|
137
|
-
return out2
|
|
138
|
-
}
|