@theronap/cortex-mcp 0.9.30 → 0.9.32

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/server.mjs CHANGED
@@ -517,6 +517,187 @@ export async function runServer(version) {
517
517
  },
518
518
  )
519
519
 
520
+ server.registerTool(
521
+ 'set_page_privacy',
522
+ {
523
+ title: 'Change who can see a wiki page',
524
+ description: 'Re-tier a wiki page you own or may edit: "accessible" (anyone in the org), "scoped" (owner + their management chain), or "confidential" (owner only, plus explicit grants). Demoting a PROJECT page also demotes its evidence records (demote-only; each record\'s owner is notified and can revert). Promotions never touch records. If the target tier already has a page, you\'ll be asked to merge via author first, then re-run with absorb=true. Org admins may demote any page, never promote.',
525
+ inputSchema: {
526
+ kind: z.enum(['project', 'person', 'org', 'user']).describe('the page kind'),
527
+ name: z.string().describe('the exact page name'),
528
+ tier: z.enum(['accessible', 'scoped', 'confidential']).describe('the new visibility tier'),
529
+ source_tier: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('when the node has multiple tier variants: which one to move'),
530
+ absorb: z.boolean().optional().describe('after merging your content into an existing target-tier page via author: true removes your now-absorbed source variant'),
531
+ },
532
+ },
533
+ async ({ kind, name, tier, source_tier, absorb }) => {
534
+ let res
535
+ try {
536
+ res = await fetchCortex(`${BASE}/api/brain/page-privacy`, {
537
+ method: 'POST',
538
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
539
+ body: JSON.stringify({ kind, name, tier, ...(source_tier ? { source_tier } : {}), ...(absorb ? { absorb: true } : {}) }),
540
+ })
541
+ } catch (e) {
542
+ return { content: [{ type: 'text', text: `Could not set page privacy: ${e.message}` }] }
543
+ }
544
+ const out = await res.json().catch(() => null)
545
+ if (!res.ok) {
546
+ // 409s carry the collision protocol (merge instruction, both variants when readable) —
547
+ // surface the server's structured message verbatim so the agent can follow it.
548
+ if (out?.error) {
549
+ const extra = out.collision === 'readable' && out.blocking
550
+ ? `\nYour ${out.source?.tier} page: ${out.source?.summary ?? out.source?.title}\nExisting ${out.blocking.tier} page: ${out.blocking.summary ?? out.blocking.title}`
551
+ : ''
552
+ return { content: [{ type: 'text', text: `Could not set page privacy: ${out.error}${extra}` }] }
553
+ }
554
+ const d = classify(res.status, res.headers.get('content-type'), '', res.headers.get('x-vercel-id'))
555
+ return { content: [{ type: 'text', text: `Could not set page privacy: ${d.message}` }] }
556
+ }
557
+ const g = out.live_grants?.length
558
+ ? ` Grants still active for: ${out.live_grants.map((x) => x.grantee_name ?? x.grantee_user_id).join(', ')}.`
559
+ : ''
560
+ return { content: [{ type: 'text', text: `Done — "${out.moved.title}" moved ${out.moved.from} → ${out.moved.to}.${out.ownership_taken ? ' (You took ownership of this previously owner-less page.)' : ''} ${out.note}${g}` }] }
561
+ },
562
+ )
563
+
564
+ server.registerTool(
565
+ 'grant_page_access',
566
+ {
567
+ title: 'Grant or revoke a specific person\'s access to your page',
568
+ description: 'Share one of YOUR non-accessible wiki pages with a specific org member (or take that access back). A grant lets exactly that person read the page even though its tier would hide it — the escape hatch for "confidential, but Dana needs it". Owner-only. Grants survive re-tiering: revoke them when they should end.',
569
+ inputSchema: {
570
+ kind: z.enum(['project', 'person', 'org', 'user']).describe('the page kind'),
571
+ name: z.string().describe('the exact page name'),
572
+ grantee: z.string().describe('the org member\'s display name or email (must resolve uniquely — use email if ambiguous)'),
573
+ action: z.enum(['grant', 'revoke']).describe('grant or revoke'),
574
+ tier: z.enum(['scoped', 'confidential']).optional().describe('which variant (default: the most restrictive one)'),
575
+ },
576
+ },
577
+ async ({ kind, name, grantee, action, tier }) => {
578
+ let res
579
+ try {
580
+ res = await fetchCortex(`${BASE}/api/brain/page-grants`, {
581
+ method: 'POST',
582
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
583
+ body: JSON.stringify({ kind, name, grantee, action, ...(tier ? { tier } : {}) }),
584
+ })
585
+ } catch (e) {
586
+ return { content: [{ type: 'text', text: `Could not ${action}: ${e.message}` }] }
587
+ }
588
+ const out = await res.json().catch(() => null)
589
+ if (!res.ok) return { content: [{ type: 'text', text: `Could not ${action}: ${out?.error ?? res.status}` }] }
590
+ const verb = { granted: 'now has access to', already_granted: 'already had access to', revoked: 'no longer has access to', not_granted: 'had no grant on' }[out.action]
591
+ return { content: [{ type: 'text', text: `Done — ${out.grantee} ${verb} the ${out.tier} page.` }] }
592
+ },
593
+ )
594
+
595
+ server.registerTool(
596
+ 'list_page_grants',
597
+ {
598
+ title: 'List who has granted access to your page',
599
+ description: 'Show every explicit access grant on YOUR page\'s tier variants (owner-only). Use after re-tiering a page — grants survive tier changes and keep granting until revoked.',
600
+ inputSchema: {
601
+ kind: z.enum(['project', 'person', 'org', 'user']).describe('the page kind'),
602
+ name: z.string().describe('the exact page name'),
603
+ },
604
+ },
605
+ async ({ kind, name }) => {
606
+ let res
607
+ try {
608
+ res = await fetchCortex(`${BASE}/api/brain/page-grants?kind=${encodeURIComponent(kind)}&name=${encodeURIComponent(name)}`, {
609
+ headers: { Authorization: `Bearer ${TOKEN}` },
610
+ })
611
+ } catch (e) {
612
+ return { content: [{ type: 'text', text: `Could not list grants: ${e.message}` }] }
613
+ }
614
+ const out = await res.json().catch(() => null)
615
+ if (!res.ok) return { content: [{ type: 'text', text: `Could not list grants: ${out?.error ?? res.status}` }] }
616
+ if (!out.grants?.length) return { content: [{ type: 'text', text: 'No grants on this page.' }] }
617
+ const lines = out.grants.map((g) => `- ${g.grantee_name ?? g.grantee_user_id} → ${g.tier} variant (since ${String(g.created_at).slice(0, 10)})`)
618
+ return { content: [{ type: 'text', text: `Grants (${out.grants.length}):\n${lines.join('\n')}` }] }
619
+ },
620
+ )
621
+
622
+ server.registerTool(
623
+ 'my_retier_notices',
624
+ {
625
+ title: 'Records of yours that a page demotion re-tiered',
626
+ description: 'When someone demotes a project page, its evidence records follow (demote-only) — including yours. This lists those notices (newest first) and marks them seen. To undo one, call set_record_privacy with the record_id and its previous tier (shown as from_privacy).',
627
+ inputSchema: {},
628
+ },
629
+ async () => {
630
+ let res
631
+ try {
632
+ res = await fetchCortex(`${BASE}/api/brain/retier-notices`, { headers: { Authorization: `Bearer ${TOKEN}` } })
633
+ } catch (e) {
634
+ return { content: [{ type: 'text', text: `Could not list notices: ${e.message}` }] }
635
+ }
636
+ if (!res.ok) {
637
+ const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
638
+ return { content: [{ type: 'text', text: `Could not list notices: ${d.message}` }] }
639
+ }
640
+ const { notices } = await res.json()
641
+ if (!notices?.length) return { content: [{ type: 'text', text: 'No re-tier notices.' }] }
642
+ const lines = notices.map((n) =>
643
+ `- record ${n.record_id} ("${n.record_title ?? 'untitled'}") ${n.from_privacy} → ${n.to_privacy} — ${n.demoted_by_name ?? 'someone'} demoted the ${n.node_name ?? n.node_kind} page. Revert: set_record_privacy(record_id, "${n.from_privacy}").`)
644
+ return { content: [{ type: 'text', text: `Your re-tiered records (${notices.length}):\n${lines.join('\n')}` }] }
645
+ },
646
+ )
647
+
648
+ server.registerTool(
649
+ 'page_merge_requests',
650
+ {
651
+ title: 'Merge requests on pages you own',
652
+ description: 'Someone tried to move their page into a tier slot your page occupies (they only saw "slot occupied"). Review pending requests here; read their variant, merge anything worth keeping into your page via author, then decide with decide_page_merge.',
653
+ inputSchema: {},
654
+ },
655
+ async () => {
656
+ let res
657
+ try {
658
+ res = await fetchCortex(`${BASE}/api/brain/page-merge-requests`, { headers: { Authorization: `Bearer ${TOKEN}` } })
659
+ } catch (e) {
660
+ return { content: [{ type: 'text', text: `Could not list merge requests: ${e.message}` }] }
661
+ }
662
+ if (!res.ok) {
663
+ const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
664
+ return { content: [{ type: 'text', text: `Could not list merge requests: ${d.message}` }] }
665
+ }
666
+ const { requests } = await res.json()
667
+ if (!requests?.length) return { content: [{ type: 'text', text: 'No pending page merge requests.' }] }
668
+ const lines = requests.map((q) =>
669
+ `- [${q.id}] ${q.requester_name ?? 'someone'} wants their ${q.kind} page merged into your ${q.requested_tier} "${q.page_title}". Merge via author first, then decide_page_merge.`)
670
+ return { content: [{ type: 'text', text: `Pending page merge requests (${requests.length}):\n${lines.join('\n')}` }] }
671
+ },
672
+ )
673
+
674
+ server.registerTool(
675
+ 'decide_page_merge',
676
+ {
677
+ title: 'Approve or deny a page merge request',
678
+ description: 'Decide a pending page merge request you own. IMPORTANT: approve only AFTER you have merged whatever of the requester\'s content you want into your page (via author) — approving DELETES their variant of the node. Deny closes the request and nothing moves.',
679
+ inputSchema: {
680
+ id: z.string().describe('the request id from page_merge_requests'),
681
+ decision: z.enum(['approve', 'deny']).describe('approve = their variant is removed (merge first!); deny = nothing moves'),
682
+ },
683
+ },
684
+ async ({ id, decision }) => {
685
+ let res
686
+ try {
687
+ res = await fetchCortex(`${BASE}/api/brain/page-merge-requests`, {
688
+ method: 'POST',
689
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
690
+ body: JSON.stringify({ id, decision }),
691
+ })
692
+ } catch (e) {
693
+ return { content: [{ type: 'text', text: `Could not decide: ${e.message}` }] }
694
+ }
695
+ const out = await res.json().catch(() => null)
696
+ if (!res.ok) return { content: [{ type: 'text', text: `Could not decide: ${out?.error ?? res.status}` }] }
697
+ return { content: [{ type: 'text', text: out.decision === 'denied' ? 'Denied — nothing moved.' : `Approved — removed variant(s): ${out.removed_variants.join(', ') || 'none remained'}. ${out.note}` }] }
698
+ },
699
+ )
700
+
520
701
  // ── File requests: ask the owner for a record's FULL original (lives on their machine) ──
521
702
  const fail = (verb, res) => async () =>
522
703
  ({ content: [{ type: 'text', text: `Could not ${verb}: ${classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id')).message}` }] })
@@ -688,12 +869,14 @@ export async function runServer(version) {
688
869
  heading: z.string().describe('e.g. Overview, Current state, Decisions, Open threads, People'),
689
870
  body: z.string().describe('dense markdown WITH inline [[links]] where the prose references another node'),
690
871
  })).describe('3-5 sections; the page body'),
691
- tier: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('visibility tier (default accessible — the shareable page)'),
872
+ 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.'),
692
873
  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.'),
693
874
  },
694
875
  },
695
876
  async ({ kind, name, summary, sections, tier, base_version }) => {
696
- const pages = [{ tier: tier ?? 'accessible', summary, base_version, sections: Array.isArray(sections) ? sections : [] }]
877
+ // No client-side tier default the server computes the per-kind safe default (page-privacy
878
+ // T4/D10) so version-pinned installs can't bake a stale policy.
879
+ const pages = [{ ...(tier ? { tier } : {}), summary, base_version, sections: Array.isArray(sections) ? sections : [] }]
697
880
  let res
698
881
  try {
699
882
  res = await fetchCortex(`${BASE}/api/brain/author`, {
package/lib/setup.mjs CHANGED
@@ -61,6 +61,28 @@ function ensureDir(path) {
61
61
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
62
62
  }
63
63
 
64
+ // Merge the capture Stop hook into a Codex hooks.json object. Pure + idempotent: drops any prior
65
+ // cortex capture entry (old token / old path / old pinned version) before appending the current one,
66
+ // so re-running never duplicates and never leaves a stale version pinned behind a live one.
67
+ // Mirrors the Claude Stop-hook merge in step 2 below, minus the SessionStart/PreCompact hooks Codex
68
+ // doesn't support (confirmed: Codex's hook runtime only recognizes PreToolUse/PostToolUse/PreCompact/
69
+ // UserPromptSubmit/Stop — no SessionStart, so status/skills-repair/snapshot-context stay Claude-only).
70
+ export function mergeCodexHooks(existing, captureCmd) {
71
+ const h = existing && typeof existing === 'object' ? existing : {}
72
+ h.hooks = h.hooks && typeof h.hooks === 'object' ? h.hooks : {}
73
+ h.hooks.Stop = Array.isArray(h.hooks.Stop) ? h.hooks.Stop : []
74
+ for (const grp of h.hooks.Stop) {
75
+ if (Array.isArray(grp.hooks)) {
76
+ grp.hooks = grp.hooks.filter((c) => !/cortex-mcp.*capture|capture-session-cloud/.test(c.command ?? ''))
77
+ }
78
+ }
79
+ let grp = h.hooks.Stop.find((g) => (g.matcher ?? '') === '')
80
+ if (!grp) { grp = { matcher: '', hooks: [] }; h.hooks.Stop.push(grp) }
81
+ grp.hooks = grp.hooks ?? []
82
+ grp.hooks.push({ type: 'command', command: captureCmd })
83
+ return h
84
+ }
85
+
64
86
  export async function runSetup(argv, version) {
65
87
  const token = argv[0]
66
88
  if (!token || token.startsWith('-')) {
@@ -110,7 +132,7 @@ export async function runSetup(argv, version) {
110
132
 
111
133
  // ── 1b. MCP server in Codex (~/.codex/config.toml), only if Codex is installed ──
112
134
  // Codex gets the same Cortex context tools as Claude Code. Non-fatal: a Codex hiccup must
113
- // never block the primary Claude wiring. Capture/skills self-heal stay Claude-driven for now.
135
+ // never block the primary Claude wiring.
114
136
  const codexDir = join(home, '.codex')
115
137
  if (existsSync(codexDir)) {
116
138
  try {
@@ -122,6 +144,25 @@ export async function runSetup(argv, version) {
122
144
  } catch (e) {
123
145
  log(` ⚠ Codex MCP wiring skipped: ${e.message} (Claude wiring unaffected)`)
124
146
  }
147
+
148
+ // ── 1c. Capture Stop hook in Codex (~/.codex/hooks.json) ──
149
+ // Without this, Codex sessions get context tools + skills but never capture — a silent gap
150
+ // (found 2026-07-02: a hand-set hook on Theron's machine was pinned to a stale 0.4.5, years
151
+ // behind `stable`, because nothing in setup/repair ever refreshed it). Same idempotent
152
+ // merge pattern as the Claude Stop hook below; non-fatal on failure.
153
+ try {
154
+ const codexHooks = join(codexDir, 'hooks.json')
155
+ let existingHooks
156
+ try { existingHooks = readJson(codexHooks) } catch { existingHooks = {} } // malformed → start fresh, don't block
157
+ const bak = backup(codexHooks)
158
+ const captureCmd = `CORTEX_TOKEN=${token} npx -y ${spec} capture`
159
+ ensureDir(codexHooks)
160
+ writeFileSync(codexHooks, JSON.stringify(mergeCodexHooks(existingHooks, captureCmd), null, 2))
161
+ log(` ✓ Capture hook → ${codexHooks}${bak ? ' (backup saved)' : ''}`)
162
+ log(' Codex will ask you to re-approve this hook once (it hashes hooks.json for tamper-detection).')
163
+ } catch (e) {
164
+ log(` ⚠ Codex capture hook skipped: ${e.message} (Claude wiring unaffected)`)
165
+ }
125
166
  }
126
167
 
127
168
  // ── 2. Capture Stop hook in ~/.claude/settings.json ──────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.30",
3
+ "version": "0.9.32",
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": {