@theronap/cortex-mcp 0.9.17 → 0.9.19

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.
@@ -130,6 +137,12 @@ if (cmd === 'setup') {
130
137
  // Install / repair the managed Cortex skills repository. No network — exits naturally.
131
138
  const { runSkills } = await import('../lib/skills.mjs')
132
139
  process.exitCode = await runSkills(rest)
140
+ } else if (cmd === 'precompact') {
141
+ // PreCompact hook (③ live wiki authoring, best-effort): print an "author now" reminder so the session
142
+ // sweeps its understanding into the wiki BEFORE compaction drops it. A hook cannot force a model turn
143
+ // (D2) — this is the documented fallback; /log is the hard backstop. No network; prints + exits.
144
+ const { runPrecompactReminder } = await import('../lib/precompact.mjs')
145
+ runPrecompactReminder()
133
146
  } else {
134
147
  // Default: run the MCP server (stays alive; never exits).
135
148
  const { runServer } = await import('../lib/server.mjs')
@@ -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,16 @@
1
+ // PreCompact "author now" reminder (③ live wiki authoring, [[cortex-wiki-authoring-spec]] D2).
2
+ //
3
+ // Wired by setup.mjs as a PreCompact hook. A hook CANNOT force a model turn — it can only inject text
4
+ // the model sees on its next turn (best-effort). So this prints a reminder to sweep understanding into
5
+ // the wiki BEFORE compaction discards the session's hot mental model. The HARD backstop is the /log
6
+ // skill (cortex-log step 5); this catches the in-session compaction that would otherwise lose the magic.
7
+ //
8
+ // Output goes to stdout, which the Claude Code harness surfaces as additional context for the next turn.
9
+ export function runPrecompactReminder() {
10
+ process.stdout.write(
11
+ 'Cortex: context is about to compact. If your understanding of any node (the project(s) you worked ' +
12
+ 'on, people you coordinated with, or yourself) advanced this session, AUTHOR it into the wiki NOW ' +
13
+ 'before it is lost: call `authoring_context` then `author` for each. This is a synthesis of your ' +
14
+ 'compiled understanding with inline [[links]], not a transcript dump. Skip nodes you did not advance.\n',
15
+ )
16
+ }
package/lib/server.mjs CHANGED
@@ -580,5 +580,89 @@ export async function runServer(version) {
580
580
  },
581
581
  )
582
582
 
583
+ // ── ③ LIVE WIKI AUTHORING ([[cortex-wiki-authoring-spec]]) ────────────────────────────────────────
584
+ // Two tools the working session uses to AUTHOR its understanding into the org wiki while it's hot:
585
+ // authoring_context → the companion call (§3): fetch the visible NAMESPACE + the node-type connection
586
+ // rules BEFORE writing, so the page links canonically (the L2 lever).
587
+ // author → the write (§9 step 3/4): hand Cortex a finished page (summary + sections WITH
588
+ // inline [[links]]); the server runs the resolution pass + tier-safe 2B write.
589
+
590
+ server.registerTool(
591
+ 'authoring_context',
592
+ {
593
+ title: 'Authoring context (call before author)',
594
+ description:
595
+ 'Fetch the scaffolding to author a Cortex wiki node: the canonical NAMESPACE (existing node names — link to these with the EXACT name inside [[ ]]) and the node-type CONNECTION RULES (what kinds of links to look for). ALWAYS call this BEFORE `author` so the page links to real nodes by their established names instead of minting synonyms. Reference a node in the namespace as [[Name]]; if you reference something real that is NOT in the namespace, still write [[Name]] — that is a red-link marking a node worth creating.',
596
+ inputSchema: {
597
+ kind: z.enum(['project', 'person', 'org', 'user']).optional().describe('the node type you are about to author (default project)'),
598
+ },
599
+ },
600
+ async ({ kind }) => {
601
+ const k = kind ?? 'project'
602
+ let res
603
+ try {
604
+ res = await fetchCortex(`${BASE}/api/brain/authoring-context?kind=${k}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
605
+ } catch (e) {
606
+ return { content: [{ type: 'text', text: `Could not fetch authoring context: ${e.message}` }] }
607
+ }
608
+ if (!res.ok) {
609
+ const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
610
+ return { content: [{ type: 'text', text: `Could not fetch authoring context: ${d.message}` }] }
611
+ }
612
+ const { connectionRules, namespace } = await res.json()
613
+ const ns = Array.isArray(namespace) ? namespace : []
614
+ const nsList = ns.map((n) => `[[${n}]]`).join(', ')
615
+ const text =
616
+ `Authoring a "${k}" node. Connection rules (kinds of links to look for):\n${connectionRules}\n\n` +
617
+ `NAMESPACE — ${ns.length} existing nodes; link with the EXACT name inside [[ ]]:\n${nsList}\n\n` +
618
+ `Now author the page (summary + sections) with inline [[links]] woven into the prose. Link, do not restate. ` +
619
+ `For something real that is not in this namespace, still write [[Name]] (a red-link). Then call \`author\`.`
620
+ return { content: [{ type: 'text', text }] }
621
+ },
622
+ )
623
+
624
+ server.registerTool(
625
+ 'author',
626
+ {
627
+ title: 'Author a wiki node (live, while it is hot)',
628
+ description:
629
+ 'Write your CURRENT understanding of a project/person/org/you into the org wiki as a maintained page. Call `authoring_context` FIRST. Author from your own synthesis of the session — the compiled mental model, not a transcript dump: what it IS, where it stands, dated decisions, open threads, key people. Weave inline [[links]] to other nodes (canonical names from the namespace; red-links for wanted-but-absent nodes). The server re-authorizes the tier and resolves links. Use this continuously whenever your understanding of a node meaningfully advanced, and at session end (/log).',
630
+ inputSchema: {
631
+ kind: z.enum(['project', 'person', 'org', 'user']).describe('the node type'),
632
+ name: z.string().describe('the EXACT canonical node name (from the namespace), e.g. "Cortex" or "Theron Peterson"'),
633
+ summary: z.string().describe('one-sentence summary of what this is and its current state (may contain [[links]])'),
634
+ sections: z.array(z.object({
635
+ heading: z.string().describe('e.g. Overview, Current state, Decisions, Open threads, People'),
636
+ body: z.string().describe('dense markdown WITH inline [[links]] where the prose references another node'),
637
+ })).describe('3-5 sections; the page body'),
638
+ tier: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('visibility tier (default accessible — the shareable page)'),
639
+ },
640
+ },
641
+ async ({ kind, name, summary, sections, tier }) => {
642
+ const pages = [{ tier: tier ?? 'accessible', summary, sections: Array.isArray(sections) ? sections : [] }]
643
+ let res
644
+ try {
645
+ res = await fetchCortex(`${BASE}/api/brain/author`, {
646
+ method: 'POST',
647
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
648
+ body: JSON.stringify({ kind, name, pages }),
649
+ })
650
+ } catch (e) {
651
+ return { content: [{ type: 'text', text: `Could not author "${name}": ${e.message}` }] }
652
+ }
653
+ if (!res.ok) {
654
+ const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
655
+ return { content: [{ type: 'text', text: `Could not author "${name}": ${d.message}` }] }
656
+ }
657
+ const out = await res.json()
658
+ const blue = out?.links?.blue ?? 0
659
+ const red = out?.links?.red ?? 0
660
+ const redList = Array.isArray(out?.redLinks) && out.redLinks.length ? `\nRed-links (wanted nodes): ${out.redLinks.map((r) => `[[${r}]]`).join(', ')}` : ''
661
+ const note = out?.built ? `Authored "${name}" (${out.built} tier${out.built === 1 ? '' : 's'}). Links: ${blue} resolved, ${red} red.${redList}`
662
+ : `No change to "${name}"${out?.skipped?.length ? ` (${out.skipped.join(', ')})` : ''}.`
663
+ return { content: [{ type: 'text', text: note }] }
664
+ },
665
+ )
666
+
583
667
  await server.connect(new StdioServerTransport())
584
668
  }
package/lib/setup.mjs CHANGED
@@ -181,6 +181,22 @@ export async function runSetup(argv, version) {
181
181
  }
182
182
  sgrp.hooks.push({ type: 'command', command: snapshotCmd })
183
183
 
184
+ // PreCompact "author now" reminder (③ live wiki authoring, best-effort). Merges into the PreCompact
185
+ // array WITHOUT clobbering other hooks (filters only prior cortex entries, then appends to the ''
186
+ // matcher group). A hook can't force a turn — this just nudges the session to sweep understanding
187
+ // into the wiki before compaction; the /log skill is the hard backstop.
188
+ s.hooks.PreCompact = Array.isArray(s.hooks.PreCompact) ? s.hooks.PreCompact : []
189
+ const precompactCmd = `npx -y ${spec} precompact`
190
+ for (const pg of s.hooks.PreCompact) {
191
+ if (Array.isArray(pg.hooks)) {
192
+ pg.hooks = pg.hooks.filter((h) => !/cortex-mcp(@[^ ]*)? precompact/.test(h.command ?? ''))
193
+ }
194
+ }
195
+ let pgrp = s.hooks.PreCompact.find((g) => (g.matcher ?? '') === '')
196
+ if (!pgrp) { pgrp = { matcher: '', hooks: [] }; s.hooks.PreCompact.push(pgrp) }
197
+ pgrp.hooks = pgrp.hooks ?? []
198
+ pgrp.hooks.push({ type: 'command', command: precompactCmd })
199
+
184
200
  ensureDir(settingsJson)
185
201
  writeFileSync(settingsJson, JSON.stringify(s, null, 2))
186
202
  log(` ✓ Capture hook + status line → ${settingsJson}${bak ? ' (backup saved)' : ''}`)
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.19",
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": {
@@ -39,6 +39,15 @@ No arguments. Read the conversation context.
39
39
  4. **Confirm + flag privacy** — `log_session` returns a confirmation; if it errors, tell the user to
40
40
  run `npx -y @theronap/cortex-mcp doctor`. If any record from this session should be confidential,
41
41
  note it so the user can mark it (`set_record_privacy`). Default is org-visible under access rules.
42
+ 5. **Sweep the wiki (author what you now understand)** — the HARD backstop for live authoring
43
+ ([[cortex-wiki-authoring-spec]] D2). For each node whose understanding meaningfully advanced this
44
+ session (the project(s) worked on, people you coordinated with, and yourself when your own focus
45
+ shifted): call `authoring_context` for its kind, then `author` to write the page from your compiled
46
+ understanding — what it IS, where it stands, dated decisions, open threads, key people — with inline
47
+ `[[links]]` to other nodes (canonical names from the namespace; red-links for wanted-but-absent
48
+ nodes). This is a synthesis, not a transcript dump. Skip nodes you didn't actually advance. If you
49
+ already authored a node mid-session and nothing changed since, `author` will report "no change" —
50
+ that's fine.
42
51
 
43
52
  ## Output
44
53
 
@@ -52,6 +61,7 @@ After calling `log_session`, show a short structured summary:
52
61
  **Open / blocked:** anything unresolved or waiting on someone
53
62
  **Coordinated with:** people involved
54
63
  **Logged:** ✅ persisted as the session's record (authoritative) (or ⚠ log_session errored — run doctor)
64
+ **Wiki authored:** [[Node A]], [[Node B]] — pages updated (or "— nothing advanced this session")
55
65
  ```
56
66
 
57
67
  ## Safety rules