@theronap/cortex-mcp 0.9.17 → 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.
@@ -103,6 +103,13 @@ if (cmd === 'setup') {
103
103
  await runRelationships()
104
104
  const { closeFetch } = await import('../lib/diagnose.mjs')
105
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()
106
113
  } else if (cmd === 'materialize') {
107
114
  // Brain-page summarizer on the SUBSCRIPTION (claude -p): claim authorized digest jobs, summarize
108
115
  // locally, submit the text for the server to write. `--drain` clears the whole backlog.
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.17",
3
+ "version": "0.9.18",
4
4
  "description": "Connect your AI assistant to Cortex — your org's projects, activity, gaps, and directives, scoped to you.",
5
5
  "type": "module",
6
6
  "bin": {