@theronap/cortex-mcp 0.9.49 → 0.9.51

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/diagnose.mjs CHANGED
@@ -69,7 +69,19 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
69
69
  export function classify(status, contentType, bodyText, requestId) {
70
70
  const isJson = (contentType ?? '').includes('application/json')
71
71
  let appError = null
72
- if (isJson) { try { appError = JSON.parse(bodyText)?.error ?? null } catch { /* not json after all */ } }
72
+ let appHint = null
73
+ if (isJson) {
74
+ try {
75
+ const parsed = JSON.parse(bodyText)
76
+ appError = parsed?.error ?? null
77
+ // `hint` carries the RECOVERY instruction for the errors an agent is meant to act on, not
78
+ // just report: 409 would_drop ("re-author including the dropped sections…") and 413 too_large
79
+ // ("split the section…"). It used to be dropped here — only `error` survived — so the agent
80
+ // saw a bare code like "would_drop_sections" and had no idea what to do with it. The routes
81
+ // were writing careful self-heal guidance that never reached anyone.
82
+ appHint = typeof parsed?.hint === 'string' && parsed.hint.trim() ? parsed.hint.trim() : null
83
+ } catch { /* not json after all */ }
84
+ }
73
85
  const rid = requestId ? ` [request id: ${requestId}]` : ''
74
86
 
75
87
  if (status === 401 || (isJson && appError === 'invalid token')) {
@@ -90,7 +102,7 @@ export function classify(status, contentType, bodyText, requestId) {
90
102
  }
91
103
  return {
92
104
  kind: 'app', retriable: status >= 500,
93
- message: `Cortex API ${status}: ${appError ?? 'unknown error'}.${rid}`,
105
+ message: `Cortex API ${status}: ${appError ?? 'unknown error'}.${appHint ? ` ${appHint}` : ''}${rid}`,
94
106
  }
95
107
  }
96
108
 
@@ -104,8 +116,22 @@ export function classify(status, contentType, bodyText, requestId) {
104
116
  // WORSE — 3 unbounded attempts stacked. Now worst case = (retries+1) * timeoutMs + backoff, and a
105
117
  // timed-out attempt is treated as transient (retried, then surfaced as the reach error the callers
106
118
  // already swallow). Tunable via CORTEX_HTTP_TIMEOUT_MS; per-call override via opts.timeoutMs.
119
+ // ADR-0020 Stage 2 — this process's session identity, stamped on every request so the server can
120
+ // resolve THIS session's write pointer instead of the person-wide one. Set once by the MCP server at
121
+ // startup (setSessionKey); left null in the one-shot hook processes (capture, context_log), which
122
+ // have no session of their own and correctly fall back to the account pointer.
123
+ //
124
+ // Injected HERE rather than at each call site because fetchCortex is the single chokepoint every
125
+ // caller already funnels through — ~30 call sites pass their own `headers` object, and a header this
126
+ // load-bearing must not depend on remembering it at each one.
127
+ let SESSION_KEY = null
128
+ export function setSessionKey(key) { SESSION_KEY = key || null }
129
+
107
130
  export async function fetchCortex(url, opts = {}, { retries = 2, baseDelayMs = 400 } = {}) {
108
- const { timeoutMs: optTimeout, signal: callerSignal, ...fetchOpts } = opts
131
+ const { timeoutMs: optTimeout, signal: callerSignal, ...rest } = opts
132
+ const fetchOpts = SESSION_KEY
133
+ ? { ...rest, headers: { ...(rest.headers ?? {}), 'x-cortex-session-key': SESSION_KEY } }
134
+ : rest
109
135
  const timeoutMs = optTimeout ?? (Number(process.env.CORTEX_HTTP_TIMEOUT_MS) || 15_000)
110
136
  let lastErr
111
137
  for (let attempt = 0; attempt <= retries; attempt++) {
package/lib/server.mjs CHANGED
@@ -5,7 +5,7 @@ import { writeFileSync, mkdirSync } from 'fs'
5
5
  import { homedir } from 'os'
6
6
  import { join } from 'path'
7
7
  import { createHash, randomUUID } from 'crypto'
8
- import { fetchCortex, classify, resolveBase } from './diagnose.mjs'
8
+ import { fetchCortex, classify, resolveBase, setSessionKey } from './diagnose.mjs'
9
9
  import { runSendImessage } from './imessage_send.mjs'
10
10
  import { formatGrepHits } from './grep_cli.mjs'
11
11
  import { renderTriage } from './red_link_triage.mjs'
@@ -43,6 +43,10 @@ export async function runServer(version) {
43
43
  // working dir identify this session; we heartbeat /api/session-ping while alive so the user can see
44
44
  // all THEIR sessions via my_sessions. Self-only on the server; best-effort (never breaks serving).
45
45
  const SESSION_KEY = randomUUID()
46
+ // ADR-0020 Stage 2: stamp this key on every outbound request (fetchCortex injects it) so writes
47
+ // resolve THIS session's brain pointer. Must be set BEFORE the first fetchCortex call below —
48
+ // otherwise the opening requests of a session would silently resolve by the account pointer.
49
+ setSessionKey(SESSION_KEY)
46
50
  async function pingSession() {
47
51
  try {
48
52
  await fetchCortex(`${BASE}/api/session-ping`, {
@@ -646,28 +650,83 @@ export async function runServer(version) {
646
650
  const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
647
651
  return { content: [{ type: 'text', text: `Could not list brains: ${d.message}` }] }
648
652
  }
649
- const { brains, activeIsExplicit } = await res.json()
653
+ const { brains, activeIsExplicit, activeSource, sessionOrgId, accountOrgId } = await res.json()
650
654
  if (!brains?.length) return { content: [{ type: 'text', text: 'You have no brains.' }] }
651
- const lines = brains.map((b) => `${b.isActive ? '▶' : ' '} ${b.name} (${b.role}${b.status !== 'active' ? `, ${b.status}` : ''}) [${b.orgId}]`)
652
- const note = activeIsExplicit ? '' : '\n(active brain is the default you have one brain; set_active_brain once you hold more.)'
655
+ // Show CONTENTS, not just the name (ADR-0020 §7 corollary): a brain named TTO that holds 4
656
+ // pages while the real TTO inventory sits in Personal reads as correct and is exactly
657
+ // backwards. The page count and a couple of titles make that visible at the moment of choosing.
658
+ const lines = brains.map((b) => {
659
+ const inv = b.pageCount === 0
660
+ ? 'EMPTY'
661
+ : `${b.pageCount} page${b.pageCount === 1 ? '' : 's'}${b.sampleTitles?.length ? `: ${b.sampleTitles.slice(0, 2).join(', ')}` : ''}`
662
+ return `${b.isActive ? '▶' : ' '} ${b.name} (${b.role}${b.status !== 'active' ? `, ${b.status}` : ''}) — ${inv} [${b.orgId}]`
663
+ })
664
+ // Which layer is deciding, and what clearing it would fall back to — otherwise the pointer
665
+ // confusion simply reappears one level down.
666
+ const note = !activeIsExplicit
667
+ ? '\n(active brain is the default — you have one brain; set_active_brain once you hold more.)'
668
+ : activeSource === 'session'
669
+ ? `\n(this SESSION's override — your other sessions are unaffected${accountOrgId && accountOrgId !== sessionOrgId ? `; clearing it falls back to ${accountOrgId}` : ''}.)`
670
+ : activeSource === 'account'
671
+ ? '\n(account-wide pointer — shared by every session that has not set its own.)'
672
+ : ''
653
673
  return { content: [{ type: 'text', text: `Your brains (▶ = writes land here):\n${lines.join('\n')}${note}` }] }
654
674
  },
655
675
  )
656
676
 
677
+ server.registerTool(
678
+ 'list_brain_pages',
679
+ {
680
+ title: 'List every authored page in one brain',
681
+ description: 'Enumerate ALL authored pages in ONE brain, by its org id (from my_brains). Unlike my_brains — which ships only a count plus a few sample titles — this returns the FULL page list: one row per node with its kind, validity, tier(s), last-updated date and content hash. Use it to audit a brain, or to VERIFY a brain-to-brain migration — list BOTH brains, diff the page sets, and compare freshness before retiring any original. You can only list a brain you are a member of; reads never widen past your own brains.',
682
+ inputSchema: {
683
+ org_id: z.string().describe('the org id of the brain to enumerate (from my_brains)'),
684
+ },
685
+ },
686
+ async ({ org_id }) => {
687
+ let res
688
+ try {
689
+ res = await fetchCortex(`${BASE}/api/brains/pages?orgId=${encodeURIComponent(org_id)}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
690
+ } catch (e) {
691
+ return { content: [{ type: 'text', text: `Could not list pages: ${e.message}` }] }
692
+ }
693
+ if (!res.ok) {
694
+ const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
695
+ return { content: [{ type: 'text', text: `Could not list pages: ${d.message}` }] }
696
+ }
697
+ const { nodeCount, rowCount, pages } = await res.json()
698
+ if (!pages?.length) return { content: [{ type: 'text', text: `Brain ${org_id} has no authored pages.` }] }
699
+ // One terse line per node: name ·kind· date [NON-CURRENT validity] {non-default tiers}. A migration
700
+ // diff needs the name, the freshness date, and whether a page is already retired or multi-tier.
701
+ const lines = pages.map((p) => {
702
+ const flag = p.validity && p.validity !== 'current' ? ` [${String(p.validity).toUpperCase()}]` : ''
703
+ const tiers = p.tiers?.length && !(p.tiers.length === 1 && p.tiers[0] === 'accessible') ? ` {${p.tiers.join('+')}}` : ''
704
+ const day = p.updatedAt ? String(p.updatedAt).slice(0, 10) : '????-??-??'
705
+ return `${p.name ?? '(unnamed)'} ·${p.kind}· ${day}${flag}${tiers}`
706
+ })
707
+ const header = `${nodeCount} page${nodeCount === 1 ? '' : 's'} in brain ${org_id} (${rowCount} digest rows across tiers):`
708
+ return { content: [{ type: 'text', text: `${header}\n${lines.join('\n')}` }] }
709
+ },
710
+ )
711
+
657
712
  server.registerTool(
658
713
  'set_active_brain',
659
714
  {
660
715
  title: 'Choose the brain your writes go to',
661
- description: 'Point your writes (author / log_session / capture) at one of your brains, by its org id (from my_brains). Sticky — it stays until you change it. Pass org_id = null to clear it and fall back to the default. You can only select a brain you are a member of.',
662
- inputSchema: { org_id: z.string().nullable().describe('the org id of the brain to write to (from my_brains), or null to clear the pointer') },
716
+ 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.",
717
+ inputSchema: {
718
+ org_id: z.string().nullable().describe('the org id of the brain to write to (from my_brains), or null to clear the pointer'),
719
+ scope: z.enum(['session', 'account']).optional()
720
+ .describe("'session' (default) = this window only; 'account' = person-wide, affects your other open sessions too"),
721
+ },
663
722
  },
664
- async ({ org_id }) => {
723
+ async ({ org_id, scope }) => {
665
724
  let res
666
725
  try {
667
726
  res = await fetchCortex(`${BASE}/api/brains`, {
668
727
  method: 'PUT',
669
728
  headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
670
- body: JSON.stringify({ orgId: org_id ?? null }),
729
+ body: JSON.stringify({ orgId: org_id ?? null, scope: scope ?? 'session' }),
671
730
  })
672
731
  } catch (e) {
673
732
  return { content: [{ type: 'text', text: `Could not set active brain: ${e.message}` }] }
@@ -677,7 +736,15 @@ export async function runServer(version) {
677
736
  return { content: [{ type: 'text', text: `Could not set active brain: ${d.message}` }] }
678
737
  }
679
738
  const r = await res.json()
680
- return { content: [{ type: 'text', text: r.activeOrgId ? `Writes now land in brain ${r.activeOrgId}.` : 'Cleared your active-brain pointer (writes fall back to your default brain).' }] }
739
+ // Always say WHICH scope changed. The default is session-only, so a caller expecting the old
740
+ // person-wide stickiness must be able to see that it did NOT change their other windows.
741
+ const where = r.scope === 'session' ? 'this session only' : 'ALL your sessions (person-wide)'
742
+ if (!r.activeOrgId) {
743
+ return { content: [{ type: 'text', text: r.scope === 'session'
744
+ ? 'Cleared this session\'s brain override — writes fall back to your account pointer.'
745
+ : 'Cleared your account-wide active-brain pointer (writes fall back to your default brain).' }] }
746
+ }
747
+ return { content: [{ type: 'text', text: `Writes now land in brain ${r.activeOrgId} — ${where}.` }] }
681
748
  },
682
749
  )
683
750
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.49",
3
+ "version": "0.9.51",
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": {
@@ -10,7 +10,8 @@
10
10
  "bin",
11
11
  "lib",
12
12
  "skills",
13
- "!lib/**/*.test.mjs"
13
+ "!lib/**/*.test.mjs",
14
+ "!scripts"
14
15
  ],
15
16
  "engines": {
16
17
  "node": ">=18"
@@ -26,5 +27,10 @@
26
27
  "ai",
27
28
  "org-intelligence"
28
29
  ],
29
- "license": "MIT"
30
+ "license": "MIT",
31
+ "scripts": {
32
+ "release": "node scripts/release.mjs release",
33
+ "promote": "node scripts/release.mjs promote",
34
+ "rollback": "node scripts/release.mjs rollback"
35
+ }
30
36
  }