@theronap/cortex-mcp 0.9.53 → 0.9.55
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/lib/editors/claude.mjs +1 -1
- package/lib/server.mjs +100 -15
- package/package.json +1 -1
package/lib/editors/claude.mjs
CHANGED
|
@@ -28,7 +28,7 @@ export function mergeClaudeMcp(existing, spec, token) {
|
|
|
28
28
|
export const CORTEX_ALLOWED_TOOLS = [
|
|
29
29
|
// read surface
|
|
30
30
|
'grep', 'read_page', 'my_context', 'project_status', 'session_context', 'search_org',
|
|
31
|
-
'list_records', 'page_history', 'timeline_pull', 'my_brains', 'my_sessions', 'writing_style',
|
|
31
|
+
'list_records', 'page_history', 'page_diff', 'timeline_pull', 'my_brains', 'my_sessions', 'writing_style',
|
|
32
32
|
'code_graph_query', 'my_retier_notices', 'list_page_grants', 'file_requests', 'page_merge_requests',
|
|
33
33
|
// live authoring core
|
|
34
34
|
'authoring_context', 'author', 'log_session',
|
package/lib/server.mjs
CHANGED
|
@@ -499,7 +499,15 @@ export async function runServer(version) {
|
|
|
499
499
|
const day = (d) => (d ? String(d).slice(0, 10) : '')
|
|
500
500
|
const renderMatch = (m, tagBrain) => {
|
|
501
501
|
const blocks = m.tiers.map((t) => {
|
|
502
|
-
const secs = (t.sections ?? []).map((s) =>
|
|
502
|
+
const secs = (t.sections ?? []).map((s) => {
|
|
503
|
+
// Undefined is an older-server response during a rolling deploy: do
|
|
504
|
+
// not invent a currency verdict until this server has computed one.
|
|
505
|
+
const currency = s.hasExplicitDate === false
|
|
506
|
+
? '\n⚠ **Undated section — verify before relying on its claims.**\n'
|
|
507
|
+
: ''
|
|
508
|
+
const asOf = s.asOf ? ` · as of ${day(s.asOf)}` : ''
|
|
509
|
+
return `### ${s.heading}${asOf}${currency}${s.body}`
|
|
510
|
+
}).join('\n\n')
|
|
503
511
|
// ADR-0018: a null version isn't "nothing to show" — it means this variant predates content-
|
|
504
512
|
// hash tracking (a 2026-06-29 import scar) and CANNOT be re-authored via base_version until an
|
|
505
513
|
// admin backfills it. Silently omitting the line here is exactly what sent callers into an
|
|
@@ -556,10 +564,78 @@ export async function runServer(version) {
|
|
|
556
564
|
if (!revs.length) return { content: [{ type: 'text', text: `"${name}" (${k}) has no recorded version history yet.` }] }
|
|
557
565
|
const lines = revs.map((r) => {
|
|
558
566
|
const who = r.actor_name ? ` · ${r.actor_name}` : ''
|
|
567
|
+
// change_kind first and bracketed so a column of [correct] is scannable — the whole point is
|
|
568
|
+
// that a page with repeated corrections looks different at a glance from one that only grew.
|
|
569
|
+
const what = r.change_kind ? ` · [${r.change_kind}]` : ''
|
|
559
570
|
const why = r.reason ? ` — ${r.reason}` : ''
|
|
560
|
-
|
|
571
|
+
// Session keys run up to 200 chars; a short prefix is enough to group a session's edits and to
|
|
572
|
+
// hand to a human. Null on every pre-2026-07-27 revision — render nothing rather than "none",
|
|
573
|
+
// so "not recorded" never reads as "recorded as empty".
|
|
574
|
+
const sess = r.session_key ? `\n session: ${String(r.session_key).slice(0, 24)}` : ''
|
|
575
|
+
return `- rev ${r.rev_no} · ${String(r.created_at).slice(0, 10)} · ${r.op}${what} · ${r.tier}${who}${why}\n version: ${r.content_hash}${sess}`
|
|
561
576
|
})
|
|
562
|
-
|
|
577
|
+
const anyKind = revs.some((r) => r.change_kind)
|
|
578
|
+
const hint = anyKind
|
|
579
|
+
? `\n— \`page_diff "${name}"\` to see exactly what a revision changed.`
|
|
580
|
+
: `\n— Revisions written before 2026-07-27 carry no reason/change_kind — that is "not recorded", not "no reason".`
|
|
581
|
+
return { content: [{ type: 'text', text: `# ${name} — page history (newest first)\n${lines.join('\n')}\n\n— \`read_page "${name}"\` with version:<rev_no|version> to view an old body; \`rollback_page\` to restore one.${hint}` }] }
|
|
582
|
+
},
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
server.registerTool(
|
|
586
|
+
'page_diff',
|
|
587
|
+
{
|
|
588
|
+
title: 'See exactly what an edit changed',
|
|
589
|
+
description: 'Show WHAT CHANGED between two versions of an authored wiki page — which sections were added, removed or rewritten, plus the reason and change_kind recorded for the edit. Use it when page_history tells you an edit happened and you need to know what it actually did: before trusting a claim that was recently rewritten, when auditing whether a "correct" edit really fixed something, or before rollback_page so you know what you would be undoing. `from` defaults to the version immediately before `to`, so passing just `to` answers "what did this one edit change?". RLS-scoped: you can diff only pages you may read.',
|
|
590
|
+
inputSchema: {
|
|
591
|
+
name: z.string().describe('the canonical node name exactly as written (e.g. "Agnoclast")'),
|
|
592
|
+
kind: z.enum(['project', 'person', 'org', 'user']).optional().describe('node kind (default project)'),
|
|
593
|
+
to: z.string().describe('the NEWER version: a rev_no (e.g. "6") or a content_hash, from page_history'),
|
|
594
|
+
from: z.string().optional().describe('the OLDER version to compare against. Omit to use the revision immediately before `to` — which is what you want for "what did this edit change?"'),
|
|
595
|
+
lines: z.boolean().optional().describe('also show line-level +/- within each changed section. Off by default: the section-level answer is usually what you want and is far shorter.'),
|
|
596
|
+
},
|
|
597
|
+
},
|
|
598
|
+
async ({ name, kind, to, from, lines }) => {
|
|
599
|
+
const k = kind ?? 'project'
|
|
600
|
+
let res
|
|
601
|
+
try {
|
|
602
|
+
const qs = new URLSearchParams({
|
|
603
|
+
kind: k, key: name, to,
|
|
604
|
+
...(from ? { from } : {}), ...(lines ? { lines: '1' } : {}),
|
|
605
|
+
})
|
|
606
|
+
res = await fetchCortex(`${BASE}/api/brain/page-diff?${qs}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
607
|
+
} catch (e) {
|
|
608
|
+
return { content: [{ type: 'text', text: `Could not diff "${name}": ${e.message}` }] }
|
|
609
|
+
}
|
|
610
|
+
if (res.status === 404) return { content: [{ type: 'text', text: `No ${k} named "${name}", or it has no version "${to}". Run \`page_history "${name}"\` to list its versions.` }] }
|
|
611
|
+
if (!res.ok) {
|
|
612
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
613
|
+
return { content: [{ type: 'text', text: `Could not diff "${name}": ${d.message}` }] }
|
|
614
|
+
}
|
|
615
|
+
const out = await res.json()
|
|
616
|
+
const d = out.diff
|
|
617
|
+
const head = d.from.revNo === 0
|
|
618
|
+
? `# ${name} — rev ${d.to.revNo} (first version)`
|
|
619
|
+
: `# ${name} — rev ${d.from.revNo} → rev ${d.to.revNo}`
|
|
620
|
+
const meta = []
|
|
621
|
+
meta.push(`${String(d.to.createdAt).slice(0, 10)} · ${d.to.op}${d.to.changeKind ? ` · [${d.to.changeKind}]` : ''}${d.to.actorName ? ` · ${d.to.actorName}` : ''}`)
|
|
622
|
+
if (d.to.reason) meta.push(`reason: ${d.to.reason}`)
|
|
623
|
+
if (d.to.sessionKey) meta.push(`session: ${String(d.to.sessionKey).slice(0, 24)}`)
|
|
624
|
+
const body = []
|
|
625
|
+
if (d.summaryChanged) body.push('- summary: CHANGED')
|
|
626
|
+
for (const h of d.sections.added) body.push(`- + added section: ${h}`)
|
|
627
|
+
for (const h of d.sections.removed) body.push(`- − removed section: ${h}`)
|
|
628
|
+
for (const c of d.sections.changed) {
|
|
629
|
+
body.push(`- ~ changed section: ${c.heading}`)
|
|
630
|
+
// Cap the rendered hunk. A section body can be 65k chars; dumping a full rewrite into a tool
|
|
631
|
+
// result buries the signal and burns the reader's context for no gain.
|
|
632
|
+
for (const l of (c.lines ?? []).slice(0, 40)) body.push(` ${l.kind === 'add' ? '+' : '−'} ${l.line}`)
|
|
633
|
+
if ((c.lines?.length ?? 0) > 40) body.push(` … ${c.lines.length - 40} more changed lines`)
|
|
634
|
+
}
|
|
635
|
+
if (!body.length) body.push('- no section or summary changes (metadata-only revision, e.g. a re-tier)')
|
|
636
|
+
const tail = d.sections.unchangedCount ? `\n\n${d.sections.unchangedCount} section(s) unchanged.` : ''
|
|
637
|
+
const hint = lines ? '' : '\n— Pass `lines: true` to see the actual changed lines within each section.'
|
|
638
|
+
return { content: [{ type: 'text', text: `${head}\n${meta.join(' · ')}\n\n${body.join('\n')}${tail}${hint}` }] }
|
|
563
639
|
},
|
|
564
640
|
)
|
|
565
641
|
|
|
@@ -648,7 +724,7 @@ export async function runServer(version) {
|
|
|
648
724
|
'my_brains',
|
|
649
725
|
{
|
|
650
726
|
title: 'List your brains + which one writes land in',
|
|
651
|
-
description: 'List the brains (orgs/workspaces) you belong to and
|
|
727
|
+
description: 'List the brains (orgs/workspaces) you belong to. Reads span ALL of them, and writes to an EXISTING page now resolve to the brain that holds it — you do NOT need to check or switch anything before authoring, and you should not. The active brain is only a default for creating a page that exists nowhere yet. Use this when you genuinely need to see what brains exist or how they are populated.',
|
|
652
728
|
inputSchema: {},
|
|
653
729
|
},
|
|
654
730
|
async () => {
|
|
@@ -676,13 +752,13 @@ export async function runServer(version) {
|
|
|
676
752
|
// Which layer is deciding, and what clearing it would fall back to — otherwise the pointer
|
|
677
753
|
// confusion simply reappears one level down.
|
|
678
754
|
const note = !activeIsExplicit
|
|
679
|
-
? '\n(
|
|
755
|
+
? '\n(you have one brain; nothing to choose.)'
|
|
680
756
|
: activeSource === 'session'
|
|
681
757
|
? `\n(this SESSION's override — your other sessions are unaffected${accountOrgId && accountOrgId !== sessionOrgId ? `; clearing it falls back to ${accountOrgId}` : ''}.)`
|
|
682
758
|
: activeSource === 'account'
|
|
683
759
|
? '\n(account-wide pointer — shared by every session that has not set its own.)'
|
|
684
760
|
: ''
|
|
685
|
-
return { content: [{ type: 'text', text: `Your brains (▶ =
|
|
761
|
+
return { content: [{ type: 'text', text: `Your brains (▶ = default for NEW pages only — edits to existing pages route themselves):\n${lines.join('\n')}${note}` }] }
|
|
686
762
|
},
|
|
687
763
|
)
|
|
688
764
|
|
|
@@ -724,8 +800,8 @@ export async function runServer(version) {
|
|
|
724
800
|
server.registerTool(
|
|
725
801
|
'set_active_brain',
|
|
726
802
|
{
|
|
727
|
-
title: '
|
|
728
|
-
description: "
|
|
803
|
+
title: 'Set the default brain for NEW pages',
|
|
804
|
+
description: "RARELY NEEDED — do not reach for this reflexively. Editing an EXISTING page routes itself: author/set_page_validity/rollback resolve the brain from the page (via base_version, ref, or name), so switching first is unnecessary and switching WRONG is now impossible to cause. This only sets the default for creating a page that exists in NO brain yet, and for log_session/capture. If you find yourself about to call this so an edit lands correctly, don't — just author; it will find the page. Pass org_id = null to clear. SCOPE: 'session' (default) changes ONLY this session; 'account' changes the person-wide pointer and redirects every other open session that has not set its own.",
|
|
729
805
|
inputSchema: {
|
|
730
806
|
org_id: z.string().nullable().describe('the org id of the brain to write to (from my_brains), or null to clear the pointer'),
|
|
731
807
|
scope: z.enum(['session', 'account']).optional()
|
|
@@ -764,7 +840,7 @@ export async function runServer(version) {
|
|
|
764
840
|
'create_brain',
|
|
765
841
|
{
|
|
766
842
|
title: 'Create a new brain under your existing account',
|
|
767
|
-
description: 'Create a brand-new brain (org/workspace) — a fully independent knowledge graph — under your EXISTING account. No new login, no new email/password: this adds a second membership to the account you are already using. Reads never cross brains; use set_active_brain
|
|
843
|
+
description: 'Create a brand-new brain (org/workspace) — a fully independent knowledge graph — under your EXISTING account. No new login, no new email/password: this adds a second membership to the account you are already using. Reads never cross brains; new pages default to your active brain, so use set_active_brain if you want NEW pages to land in this one; edits to existing pages always route to whichever brain holds them.',
|
|
768
844
|
inputSchema: { name: z.string().describe('display name for the new brain, e.g. "Cortex Codebase"') },
|
|
769
845
|
},
|
|
770
846
|
async ({ name }) => {
|
|
@@ -783,7 +859,7 @@ export async function runServer(version) {
|
|
|
783
859
|
return { content: [{ type: 'text', text: `Could not create brain: ${d.message}` }] }
|
|
784
860
|
}
|
|
785
861
|
const r = await res.json()
|
|
786
|
-
return { content: [{ type: 'text', text: `Created brain "${name}" [${r.orgId}].
|
|
862
|
+
return { content: [{ type: 'text', text: `Created brain "${name}" [${r.orgId}]. Reads already span it. Edits to pages in it route themselves; set_active_brain only if you want NEW pages to default here.` }] }
|
|
787
863
|
},
|
|
788
864
|
)
|
|
789
865
|
|
|
@@ -1307,7 +1383,7 @@ export async function runServer(version) {
|
|
|
1307
1383
|
{
|
|
1308
1384
|
title: 'Authoring context (call before author)',
|
|
1309
1385
|
description:
|
|
1310
|
-
'Fetch the scaffolding to author a Cortex wiki node: the canonical NAMESPACE (
|
|
1386
|
+
'Fetch the scaffolding to author a Cortex wiki node: the canonical NAMESPACE (current node names — link to these with the EXACT name inside [[ ]]), any deliberately RETIRED page names and their successors, and the node-type CONNECTION RULES. ALWAYS call this BEFORE `author` so the page links to current knowledge rather than minting synonyms or reviving a retired page. 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. If the page IS about a specific code repo or chat channel, also stamp it once — [[repo:owner/name]] or [[channel:name]] (lowercase, no #) — identifier join keys, not page links.',
|
|
1311
1387
|
inputSchema: {
|
|
1312
1388
|
kind: z.enum(['project', 'person', 'org', 'user']).optional().describe('the node type you are about to author (default project)'),
|
|
1313
1389
|
},
|
|
@@ -1324,12 +1400,17 @@ export async function runServer(version) {
|
|
|
1324
1400
|
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
1325
1401
|
return { content: [{ type: 'text', text: `Could not fetch authoring context: ${d.message}` }] }
|
|
1326
1402
|
}
|
|
1327
|
-
const { connectionRules, namespace } = await res.json()
|
|
1403
|
+
const { connectionRules, namespace, retiredLinks } = await res.json()
|
|
1328
1404
|
const ns = Array.isArray(namespace) ? namespace : []
|
|
1329
1405
|
const nsList = ns.map((n) => `[[${n}]]`).join(', ')
|
|
1406
|
+
const retired = Array.isArray(retiredLinks) ? retiredLinks : []
|
|
1407
|
+
const retiredList = retired.length
|
|
1408
|
+
? `\n\nRETIRED PAGES — do not treat these as current or author over them: ${retired.map((r) => `[[${r.name}]] (${r.validity}${r.supersededBy ? ` → [[${r.supersededBy}]]` : ''})`).join(', ')}`
|
|
1409
|
+
: ''
|
|
1330
1410
|
const text =
|
|
1331
1411
|
`Authoring a "${k}" node. Connection rules (kinds of links to look for):\n${connectionRules}\n\n` +
|
|
1332
1412
|
`NAMESPACE — ${ns.length} existing nodes; link with the EXACT name inside [[ ]]:\n${nsList}\n\n` +
|
|
1413
|
+
retiredList +
|
|
1333
1414
|
`Now author the page (summary + sections) with inline [[links]] woven into the prose. Link, do not restate. ` +
|
|
1334
1415
|
`For something real that is not in this namespace, still write [[Name]] (a red-link). Then call \`author\`.`
|
|
1335
1416
|
return { content: [{ type: 'text', text }] }
|
|
@@ -1352,9 +1433,11 @@ export async function runServer(version) {
|
|
|
1352
1433
|
})).describe('3-5 sections; the page body'),
|
|
1353
1434
|
tier: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('visibility tier. Omit for the safe default: scoped (you + your management chain) on nodes that support it — your user page, projects you own-scope — and accessible elsewhere (person/org pages are the shared wiki). Pass accessible explicitly when the page is meant for the whole org.'),
|
|
1354
1435
|
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.'),
|
|
1436
|
+
reason: z.string().describe('WHY you are making this edit, in one short phrase — recorded permanently in page_history so a later reader can tell a routine addition from a correction. Say what CHANGED and what prompted it ("Ben pilot abandoned per Theron 07-17", "corrected: 0069 already widened the CHECK"), not what you did ("updated page"). This is the field that makes staleness auditable.'),
|
|
1437
|
+
change_kind: z.enum(['add', 'correct', 'supersede', 'expand', 'retire']).optional().describe('what KIND of edit: "add" (new information), "correct" (the page said something FALSE — the currency-critical one), "supersede" (was true, now outdated by events), "expand" (elaborates, no claim changed), "retire" (putting the page or a section to rest). Be honest with "correct" — a page whose history shows repeated corrections is a page whose claims need checking, and that signal is the point.'),
|
|
1355
1438
|
},
|
|
1356
1439
|
},
|
|
1357
|
-
async ({ kind, name, summary, sections, tier, base_version }) => {
|
|
1440
|
+
async ({ kind, name, summary, sections, tier, base_version, reason, change_kind }) => {
|
|
1358
1441
|
// No client-side tier default — the server computes the per-kind safe default (page-privacy
|
|
1359
1442
|
// T4/D10) so version-pinned installs can't bake a stale policy.
|
|
1360
1443
|
const pages = [{ ...(tier ? { tier } : {}), summary, base_version, sections: Array.isArray(sections) ? sections : [] }]
|
|
@@ -1363,7 +1446,7 @@ export async function runServer(version) {
|
|
|
1363
1446
|
res = await fetchCortex(`${BASE}/api/brain/author`, {
|
|
1364
1447
|
method: 'POST',
|
|
1365
1448
|
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
1366
|
-
body: JSON.stringify({ kind, name, pages }),
|
|
1449
|
+
body: JSON.stringify({ kind, name, pages, reason, change_kind }),
|
|
1367
1450
|
})
|
|
1368
1451
|
} catch (e) {
|
|
1369
1452
|
return { content: [{ type: 'text', text: `Could not author "${name}": ${e.message}` }] }
|
|
@@ -1374,11 +1457,13 @@ export async function runServer(version) {
|
|
|
1374
1457
|
}
|
|
1375
1458
|
const out = await res.json()
|
|
1376
1459
|
const blue = out?.links?.blue ?? 0
|
|
1460
|
+
const retired = out?.links?.retired ?? 0
|
|
1377
1461
|
const red = out?.links?.red ?? 0
|
|
1378
1462
|
const redList = Array.isArray(out?.redLinks) && out.redLinks.length ? `\nRed-links (wanted nodes): ${out.redLinks.map((r) => `[[${r}]]`).join(', ')}` : ''
|
|
1463
|
+
const retiredList = Array.isArray(out?.retiredLinks) && out.retiredLinks.length ? `\nRetired links (not current or wanted): ${out.retiredLinks.map((r) => `[[${r}]]`).join(', ')}` : ''
|
|
1379
1464
|
const stamps = Array.isArray(out?.identifiers) && out.identifiers.length ? `\nIdentifier stamps (join keys): ${out.identifiers.map((i) => `[[${i}]]`).join(', ')}` : ''
|
|
1380
1465
|
const verb = out?.created ? 'Created + authored' : 'Authored'
|
|
1381
|
-
const note = out?.built ? `${verb} "${name}" (${out.built} tier${out.built === 1 ? '' : 's'}). Links: ${blue} resolved, ${red} red.${redList}${stamps}`
|
|
1466
|
+
const note = out?.built ? `${verb} "${name}" (${out.built} tier${out.built === 1 ? '' : 's'}). Links: ${blue} resolved, ${retired} retired, ${red} red.${retiredList}${redList}${stamps}`
|
|
1382
1467
|
: `No change to "${name}"${out?.skipped?.length ? ` (${out.skipped.join(', ')})` : ''}.`
|
|
1383
1468
|
return { content: [{ type: 'text', text: note }] }
|
|
1384
1469
|
},
|