@theronap/cortex-mcp 0.9.48 → 0.9.50

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++) {
@@ -0,0 +1,30 @@
1
+ // Red-link triage rendering (Mechanism 2) — the text an agent sees on a read_page miss.
2
+ //
3
+ // Standalone + dependency-free (like grep_cli.mjs) so it unit-tests without pulling the MCP SDK in.
4
+ // server.mjs owns the fetch; this owns the wording.
5
+ //
6
+ // The demoted arm is the one that matters. A superseded/historical page stays greppable
7
+ // (grep_brain_sections applies no validity filter) but read_page won't serve it (authored_page_tiers
8
+ // filters validity='current'), so an agent that greps a hit and then read_page's it lands here — and
9
+ // used to be told "no page yet, author it now". Authoring is pre-authorized, so the compliant next step
10
+ // was to overwrite a page a human deliberately retired, with nothing to catch it. Absence invites
11
+ // authoring; a demotion forbids it.
12
+ //
13
+ // Keyed off the ADDITIVE `t.demoted` flag, checked BEFORE category — never off a new category value. A
14
+ // server predating the flag omits it and every arm behaves exactly as before, so client and server can
15
+ // ship in either order.
16
+
17
+ // PURE: triage payload → miss text.
18
+ export function renderTriage(t, name) {
19
+ const refs = t.tracked
20
+ ? ` It's referenced by ${t.refCount} page${t.refCount === 1 ? '' : 's'}${t.isSteward ? ' and is routed to YOU as its most-likely steward' : ''}.`
21
+ : ''
22
+ const aliasHint = `if it's really an existing page under another title, \`grep "${name}"\` to find it, then \`alias_page name="${name}" target_name="<that page>"\``
23
+ if (t.demoted) {
24
+ return `\n\n[[${name}]] EXISTS but is marked ${t.validity ?? 'superseded'} — it was authored and then deliberately retired, so read_page (which serves only current pages) will not show it. It is NOT missing.${refs} Do NOT author over it: that would silently overwrite a decision someone made on purpose. Read it with \`page_history "${name}"\` then \`read_page "${name}"\` with a version. If it genuinely should be live again, revive it deliberately with \`set_page_validity\`.`
25
+ }
26
+ if (t.category === 'node') {
27
+ return `\n\n[[${name}]] is a wanted page — a ${t.isPerson ? 'person' : 'node'} exists but has no page yet.${refs} Either author it now with \`author\`, or ${aliasHint}.`
28
+ }
29
+ return `\n\n"${name}" isn't authored anywhere yet.${refs} Either author a new page with \`author\`, or ${aliasHint}.`
30
+ }
package/lib/server.mjs CHANGED
@@ -5,29 +5,23 @@ 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
+ import { renderTriage } from './red_link_triage.mjs'
11
12
  import { runCodeGraphQuery } from './code_graph_cli.mjs'
12
13
 
13
14
  // Reactive red-link triage (Mechanism 2). On a read_page miss, ask the server whether the name is a
14
- // tracked wanted page and whether a bare node exists for it, and turn that into an actionable 3-way
15
- // prompt (author-for-node / author-new / alias). Returns '' on any error so a miss never gets worse.
15
+ // tracked wanted page, whether a bare node exists for it, and whether it's a deliberately demoted page,
16
+ // and turn that into an actionable prompt. Wording lives in ./red_link_triage.mjs (pure + tested).
17
+ // Returns '' on any error so a miss never gets worse.
16
18
  async function redLinkTriage(BASE, TOKEN, name) {
17
19
  try {
18
20
  const r = await fetchCortex(`${BASE}/api/brain/red-link?name=${encodeURIComponent(name)}`, {
19
21
  headers: { Authorization: `Bearer ${TOKEN}` },
20
22
  })
21
23
  if (!r.ok) return ''
22
- const t = await r.json()
23
- const refs = t.tracked
24
- ? ` It's referenced by ${t.refCount} page${t.refCount === 1 ? '' : 's'}${t.isSteward ? ' and is routed to YOU as its most-likely steward' : ''}.`
25
- : ''
26
- const aliasHint = `if it's really an existing page under another title, \`grep "${name}"\` to find it, then \`alias_page name="${name}" target_name="<that page>"\``
27
- if (t.category === 'node') {
28
- return `\n\n[[${name}]] is a wanted page — a ${t.isPerson ? 'person' : 'node'} exists but has no page yet.${refs} Either author it now with \`author\`, or ${aliasHint}.`
29
- }
30
- return `\n\n"${name}" isn't authored anywhere yet.${refs} Either author a new page with \`author\`, or ${aliasHint}.`
24
+ return renderTriage(await r.json(), name)
31
25
  } catch {
32
26
  return ''
33
27
  }
@@ -49,6 +43,10 @@ export async function runServer(version) {
49
43
  // working dir identify this session; we heartbeat /api/session-ping while alive so the user can see
50
44
  // all THEIR sessions via my_sessions. Self-only on the server; best-effort (never breaks serving).
51
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)
52
50
  async function pingSession() {
53
51
  try {
54
52
  await fetchCortex(`${BASE}/api/session-ping`, {
@@ -652,10 +650,26 @@ export async function runServer(version) {
652
650
  const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
653
651
  return { content: [{ type: 'text', text: `Could not list brains: ${d.message}` }] }
654
652
  }
655
- const { brains, activeIsExplicit } = await res.json()
653
+ const { brains, activeIsExplicit, activeSource, sessionOrgId, accountOrgId } = await res.json()
656
654
  if (!brains?.length) return { content: [{ type: 'text', text: 'You have no brains.' }] }
657
- const lines = brains.map((b) => `${b.isActive ? '▶' : ' '} ${b.name} (${b.role}${b.status !== 'active' ? `, ${b.status}` : ''}) [${b.orgId}]`)
658
- 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
+ : ''
659
673
  return { content: [{ type: 'text', text: `Your brains (▶ = writes land here):\n${lines.join('\n')}${note}` }] }
660
674
  },
661
675
  )
@@ -664,16 +678,20 @@ export async function runServer(version) {
664
678
  'set_active_brain',
665
679
  {
666
680
  title: 'Choose the brain your writes go to',
667
- 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.',
668
- 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') },
681
+ 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.",
682
+ inputSchema: {
683
+ org_id: z.string().nullable().describe('the org id of the brain to write to (from my_brains), or null to clear the pointer'),
684
+ scope: z.enum(['session', 'account']).optional()
685
+ .describe("'session' (default) = this window only; 'account' = person-wide, affects your other open sessions too"),
686
+ },
669
687
  },
670
- async ({ org_id }) => {
688
+ async ({ org_id, scope }) => {
671
689
  let res
672
690
  try {
673
691
  res = await fetchCortex(`${BASE}/api/brains`, {
674
692
  method: 'PUT',
675
693
  headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
676
- body: JSON.stringify({ orgId: org_id ?? null }),
694
+ body: JSON.stringify({ orgId: org_id ?? null, scope: scope ?? 'session' }),
677
695
  })
678
696
  } catch (e) {
679
697
  return { content: [{ type: 'text', text: `Could not set active brain: ${e.message}` }] }
@@ -683,7 +701,15 @@ export async function runServer(version) {
683
701
  return { content: [{ type: 'text', text: `Could not set active brain: ${d.message}` }] }
684
702
  }
685
703
  const r = await res.json()
686
- 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).' }] }
704
+ // Always say WHICH scope changed. The default is session-only, so a caller expecting the old
705
+ // person-wide stickiness must be able to see that it did NOT change their other windows.
706
+ const where = r.scope === 'session' ? 'this session only' : 'ALL your sessions (person-wide)'
707
+ if (!r.activeOrgId) {
708
+ return { content: [{ type: 'text', text: r.scope === 'session'
709
+ ? 'Cleared this session\'s brain override — writes fall back to your account pointer.'
710
+ : 'Cleared your account-wide active-brain pointer (writes fall back to your default brain).' }] }
711
+ }
712
+ return { content: [{ type: 'text', text: `Writes now land in brain ${r.activeOrgId} — ${where}.` }] }
687
713
  },
688
714
  )
689
715
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.48",
3
+ "version": "0.9.50",
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": {