@theronap/cortex-mcp 0.9.53 → 0.9.54

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.
@@ -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
@@ -556,10 +556,78 @@ export async function runServer(version) {
556
556
  if (!revs.length) return { content: [{ type: 'text', text: `"${name}" (${k}) has no recorded version history yet.` }] }
557
557
  const lines = revs.map((r) => {
558
558
  const who = r.actor_name ? ` · ${r.actor_name}` : ''
559
+ // change_kind first and bracketed so a column of [correct] is scannable — the whole point is
560
+ // that a page with repeated corrections looks different at a glance from one that only grew.
561
+ const what = r.change_kind ? ` · [${r.change_kind}]` : ''
559
562
  const why = r.reason ? ` — ${r.reason}` : ''
560
- return `- rev ${r.rev_no} · ${String(r.created_at).slice(0, 10)} · ${r.op} · ${r.tier}${who}${why}\n version: ${r.content_hash}`
563
+ // Session keys run up to 200 chars; a short prefix is enough to group a session's edits and to
564
+ // hand to a human. Null on every pre-2026-07-27 revision — render nothing rather than "none",
565
+ // so "not recorded" never reads as "recorded as empty".
566
+ const sess = r.session_key ? `\n session: ${String(r.session_key).slice(0, 24)}` : ''
567
+ 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
568
  })
562
- 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.` }] }
569
+ const anyKind = revs.some((r) => r.change_kind)
570
+ const hint = anyKind
571
+ ? `\n— \`page_diff "${name}"\` to see exactly what a revision changed.`
572
+ : `\n— Revisions written before 2026-07-27 carry no reason/change_kind — that is "not recorded", not "no reason".`
573
+ 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}` }] }
574
+ },
575
+ )
576
+
577
+ server.registerTool(
578
+ 'page_diff',
579
+ {
580
+ title: 'See exactly what an edit changed',
581
+ 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.',
582
+ inputSchema: {
583
+ name: z.string().describe('the canonical node name exactly as written (e.g. "Agnoclast")'),
584
+ kind: z.enum(['project', 'person', 'org', 'user']).optional().describe('node kind (default project)'),
585
+ to: z.string().describe('the NEWER version: a rev_no (e.g. "6") or a content_hash, from page_history'),
586
+ 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?"'),
587
+ 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.'),
588
+ },
589
+ },
590
+ async ({ name, kind, to, from, lines }) => {
591
+ const k = kind ?? 'project'
592
+ let res
593
+ try {
594
+ const qs = new URLSearchParams({
595
+ kind: k, key: name, to,
596
+ ...(from ? { from } : {}), ...(lines ? { lines: '1' } : {}),
597
+ })
598
+ res = await fetchCortex(`${BASE}/api/brain/page-diff?${qs}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
599
+ } catch (e) {
600
+ return { content: [{ type: 'text', text: `Could not diff "${name}": ${e.message}` }] }
601
+ }
602
+ 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.` }] }
603
+ if (!res.ok) {
604
+ const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
605
+ return { content: [{ type: 'text', text: `Could not diff "${name}": ${d.message}` }] }
606
+ }
607
+ const out = await res.json()
608
+ const d = out.diff
609
+ const head = d.from.revNo === 0
610
+ ? `# ${name} — rev ${d.to.revNo} (first version)`
611
+ : `# ${name} — rev ${d.from.revNo} → rev ${d.to.revNo}`
612
+ const meta = []
613
+ meta.push(`${String(d.to.createdAt).slice(0, 10)} · ${d.to.op}${d.to.changeKind ? ` · [${d.to.changeKind}]` : ''}${d.to.actorName ? ` · ${d.to.actorName}` : ''}`)
614
+ if (d.to.reason) meta.push(`reason: ${d.to.reason}`)
615
+ if (d.to.sessionKey) meta.push(`session: ${String(d.to.sessionKey).slice(0, 24)}`)
616
+ const body = []
617
+ if (d.summaryChanged) body.push('- summary: CHANGED')
618
+ for (const h of d.sections.added) body.push(`- + added section: ${h}`)
619
+ for (const h of d.sections.removed) body.push(`- − removed section: ${h}`)
620
+ for (const c of d.sections.changed) {
621
+ body.push(`- ~ changed section: ${c.heading}`)
622
+ // Cap the rendered hunk. A section body can be 65k chars; dumping a full rewrite into a tool
623
+ // result buries the signal and burns the reader's context for no gain.
624
+ for (const l of (c.lines ?? []).slice(0, 40)) body.push(` ${l.kind === 'add' ? '+' : '−'} ${l.line}`)
625
+ if ((c.lines?.length ?? 0) > 40) body.push(` … ${c.lines.length - 40} more changed lines`)
626
+ }
627
+ if (!body.length) body.push('- no section or summary changes (metadata-only revision, e.g. a re-tier)')
628
+ const tail = d.sections.unchangedCount ? `\n\n${d.sections.unchangedCount} section(s) unchanged.` : ''
629
+ const hint = lines ? '' : '\n— Pass `lines: true` to see the actual changed lines within each section.'
630
+ return { content: [{ type: 'text', text: `${head}\n${meta.join(' · ')}\n\n${body.join('\n')}${tail}${hint}` }] }
563
631
  },
564
632
  )
565
633
 
@@ -648,7 +716,7 @@ export async function runServer(version) {
648
716
  'my_brains',
649
717
  {
650
718
  title: 'List your brains + which one writes land in',
651
- description: 'List the brains (orgs/workspaces) you belong to and show which one is your ACTIVE WRITE brain — where author/log_session/capture currently land. Reads span all your brains; writes go to the active one. Use this to see your options before set_active_brain, or when a session seems to belong to a different brain than the one you are writing to.',
719
+ 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
720
  inputSchema: {},
653
721
  },
654
722
  async () => {
@@ -676,13 +744,13 @@ export async function runServer(version) {
676
744
  // Which layer is deciding, and what clearing it would fall back to — otherwise the pointer
677
745
  // confusion simply reappears one level down.
678
746
  const note = !activeIsExplicit
679
- ? '\n(active brain is the default — you have one brain; set_active_brain once you hold more.)'
747
+ ? '\n(you have one brain; nothing to choose.)'
680
748
  : activeSource === 'session'
681
749
  ? `\n(this SESSION's override — your other sessions are unaffected${accountOrgId && accountOrgId !== sessionOrgId ? `; clearing it falls back to ${accountOrgId}` : ''}.)`
682
750
  : activeSource === 'account'
683
751
  ? '\n(account-wide pointer — shared by every session that has not set its own.)'
684
752
  : ''
685
- return { content: [{ type: 'text', text: `Your brains (▶ = writes land here):\n${lines.join('\n')}${note}` }] }
753
+ return { content: [{ type: 'text', text: `Your brains (▶ = default for NEW pages only — edits to existing pages route themselves):\n${lines.join('\n')}${note}` }] }
686
754
  },
687
755
  )
688
756
 
@@ -724,8 +792,8 @@ export async function runServer(version) {
724
792
  server.registerTool(
725
793
  'set_active_brain',
726
794
  {
727
- title: 'Choose the brain your writes go to',
728
- description: "Point your writes (author / log_session / capture) at one of your brains, by its org id (from my_brains). Pass org_id = null to clear it. You can only select a brain you are a member of. SCOPE: 'session' (default) changes ONLY this session other windows you have open keep writing where they were, which is almost always what you want. 'account' changes the sticky person-wide pointer, which redirects every other session that has not set its own.",
795
+ title: 'Set the default brain for NEW pages',
796
+ 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
797
  inputSchema: {
730
798
  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
799
  scope: z.enum(['session', 'account']).optional()
@@ -764,7 +832,7 @@ export async function runServer(version) {
764
832
  'create_brain',
765
833
  {
766
834
  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 afterward to point writes at it (creating it does not switch your active write brain automatically).',
835
+ 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
836
  inputSchema: { name: z.string().describe('display name for the new brain, e.g. "Cortex Codebase"') },
769
837
  },
770
838
  async ({ name }) => {
@@ -783,7 +851,7 @@ export async function runServer(version) {
783
851
  return { content: [{ type: 'text', text: `Could not create brain: ${d.message}` }] }
784
852
  }
785
853
  const r = await res.json()
786
- return { content: [{ type: 'text', text: `Created brain "${name}" [${r.orgId}]. Run set_active_brain with this org_id to start writing to it reads already span it automatically.` }] }
854
+ 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
855
  },
788
856
  )
789
857
 
@@ -1352,9 +1420,11 @@ export async function runServer(version) {
1352
1420
  })).describe('3-5 sections; the page body'),
1353
1421
  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
1422
  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.'),
1423
+ 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.'),
1424
+ 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
1425
  },
1356
1426
  },
1357
- async ({ kind, name, summary, sections, tier, base_version }) => {
1427
+ async ({ kind, name, summary, sections, tier, base_version, reason, change_kind }) => {
1358
1428
  // No client-side tier default — the server computes the per-kind safe default (page-privacy
1359
1429
  // T4/D10) so version-pinned installs can't bake a stale policy.
1360
1430
  const pages = [{ ...(tier ? { tier } : {}), summary, base_version, sections: Array.isArray(sections) ? sections : [] }]
@@ -1363,7 +1433,7 @@ export async function runServer(version) {
1363
1433
  res = await fetchCortex(`${BASE}/api/brain/author`, {
1364
1434
  method: 'POST',
1365
1435
  headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
1366
- body: JSON.stringify({ kind, name, pages }),
1436
+ body: JSON.stringify({ kind, name, pages, reason, change_kind }),
1367
1437
  })
1368
1438
  } catch (e) {
1369
1439
  return { content: [{ type: 'text', text: `Could not author "${name}": ${e.message}` }] }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.53",
3
+ "version": "0.9.54",
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": {