@theronap/cortex-mcp 0.9.52 → 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.
- package/lib/diagnose.mjs +20 -0
- package/lib/editors/claude.mjs +1 -1
- package/lib/install.mjs +4 -2
- package/lib/server.mjs +95 -13
- package/lib/session_key.mjs +13 -0
- package/lib/setup.mjs +9 -7
- package/package.json +1 -1
package/lib/diagnose.mjs
CHANGED
|
@@ -36,6 +36,26 @@ export function readWiredToken() {
|
|
|
36
36
|
return null
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// Pure: pull the cortex-mcp dist-tag / version out of a wired command line. Exported for tests.
|
|
40
|
+
// Returns 'latest' | 'stable' | a version string (e.g. '0.9.4') | null (no cortex-mcp spec present).
|
|
41
|
+
export function parseDistTag(line) {
|
|
42
|
+
const m = String(line || '').match(/@theronap\/cortex-mcp@(latest|stable|[0-9][^\s"']*)/)
|
|
43
|
+
return m ? m[1] : null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Read the dist-tag / version this machine is currently wired to (from the ~/.claude.json cortex MCP
|
|
47
|
+
// command), so a re-run of setup/repair can PRESERVE an intentional channel instead of forcing @stable
|
|
48
|
+
// every time — that silently knocks a dogfooder on @latest back to the pilot channel (bit Theron
|
|
49
|
+
// 2026-07-24). Returns 'latest' | 'stable' | a version string | null (nothing wired yet).
|
|
50
|
+
export function wiredDistTag() {
|
|
51
|
+
try {
|
|
52
|
+
const cfg = JSON.parse(readFileSync(join(homedir(), '.claude.json'), 'utf8'))
|
|
53
|
+
const c = cfg?.mcpServers?.cortex
|
|
54
|
+
return parseDistTag([c?.command, ...(c?.args ?? [])].filter(Boolean).join(' '))
|
|
55
|
+
} catch { /* fall through */ }
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
|
|
39
59
|
// The production alias — exempt from Vercel Deployment Protection.
|
|
40
60
|
export const CANONICAL_BASE = 'https://cortex-console.vercel.app'
|
|
41
61
|
|
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/install.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { homedir } from 'node:os'
|
|
|
7
7
|
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs'
|
|
8
8
|
import { join, dirname } from 'node:path'
|
|
9
9
|
import { ADAPTERS, resolveEditors } from './editors/index.mjs'
|
|
10
|
-
import { checkToken, resolveBase, readWiredToken } from './diagnose.mjs'
|
|
10
|
+
import { checkToken, resolveBase, readWiredToken, wiredDistTag } from './diagnose.mjs'
|
|
11
11
|
|
|
12
12
|
const PKG = '@theronap/cortex-mcp'
|
|
13
13
|
export const MANIFEST_PATH = join(homedir(), '.cortex', 'editors.json')
|
|
@@ -68,7 +68,9 @@ export function writeManifest(manifest, { path = MANIFEST_PATH } = {}) {
|
|
|
68
68
|
export async function runInstall(argv, version) {
|
|
69
69
|
const { token: argToken, editor } = parseInstallArgs(argv)
|
|
70
70
|
const token = argToken || readWiredToken()
|
|
71
|
-
|
|
71
|
+
// Preserve an intentional @latest (dogfood) pin across re-runs; fresh installs + existing @stable
|
|
72
|
+
// get @stable. See wiredDistTag — forcing @stable here silently demoted a dogfooder (2026-07-24).
|
|
73
|
+
const spec = wiredDistTag() === 'latest' ? `${PKG}@latest` : `${PKG}@stable`
|
|
72
74
|
const home = homedir()
|
|
73
75
|
const log = (m) => process.stdout.write(m + '\n')
|
|
74
76
|
|
package/lib/server.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { homedir } from 'os'
|
|
|
6
6
|
import { join } from 'path'
|
|
7
7
|
import { createHash, randomUUID } from 'crypto'
|
|
8
8
|
import { fetchCortex, classify, resolveBase, setSessionKey } from './diagnose.mjs'
|
|
9
|
+
import { resolveSessionKey } from './session_key.mjs'
|
|
9
10
|
import { runSendImessage } from './imessage_send.mjs'
|
|
10
11
|
import { formatGrepHits } from './grep_cli.mjs'
|
|
11
12
|
import { renderTriage } from './red_link_triage.mjs'
|
|
@@ -42,7 +43,18 @@ export async function runServer(version) {
|
|
|
42
43
|
// Self active-sessions (ADR-0017): one MCP process = one AI session. A stable per-process key + the
|
|
43
44
|
// working dir identify this session; we heartbeat /api/session-ping while alive so the user can see
|
|
44
45
|
// all THEIR sessions via my_sessions. Self-only on the server; best-effort (never breaks serving).
|
|
45
|
-
|
|
46
|
+
//
|
|
47
|
+
// PGL-21 fix: "one MCP process = one AI session" is an assumption, not a guarantee — the HOST
|
|
48
|
+
// (Claude Code) can restart this stdio process mid-conversation (reconnects, tool-loading events),
|
|
49
|
+
// and a fresh randomUUID() on every restart silently orphaned the PRIOR process's session-scoped
|
|
50
|
+
// write-pointer row (session_write_pointers): an explicit set_active_brain(scope:'session') would
|
|
51
|
+
// stop taking effect one restart later, falling back to the account pointer with no visible cause
|
|
52
|
+
// — reproduced live 2026-07-27. Claude Code sets CLAUDE_CODE_SESSION_ID for the lifetime of one
|
|
53
|
+
// logical conversation across any number of subprocess restarts, so prefer it as the session key;
|
|
54
|
+
// fall back to a fresh randomUUID() for any other MCP host that doesn't set it (unchanged behavior
|
|
55
|
+
// there — no host that never had continuity loses anything). Bonus: my_sessions / session_presence
|
|
56
|
+
// (also keyed on SESSION_KEY, ADR-0017) stop fragmenting one conversation into several "sessions" too.
|
|
57
|
+
const SESSION_KEY = resolveSessionKey(process.env, randomUUID)
|
|
46
58
|
// ADR-0020 Stage 2: stamp this key on every outbound request (fetchCortex injects it) so writes
|
|
47
59
|
// resolve THIS session's brain pointer. Must be set BEFORE the first fetchCortex call below —
|
|
48
60
|
// otherwise the opening requests of a session would silently resolve by the account pointer.
|
|
@@ -498,7 +510,7 @@ export async function runServer(version) {
|
|
|
498
510
|
const head = `[${t.tier}${day(t.updated_at) ? ` · authored ${day(t.updated_at)}` : ''}${t.validity && t.validity !== 'current' ? ` · ${t.validity}` : ''}${versionLine}]`
|
|
499
511
|
return [head, t.summary, secs].filter(Boolean).join('\n')
|
|
500
512
|
})
|
|
501
|
-
let footer = `— Follow any [[links]] above with read_page to go deeper.\n— If you hold fresher FIRST-HAND truth than this page — something you established THIS session (ran the command, made the change, hold the artifact) that a section here gets concretely wrong — re-author just those sections now with \`author\` (any node you are permitted to edit; concise; a material contradiction, not merely extra detail). Pass this page's \`version\` as base_version when you re-author — if the save comes back stale, the page changed under you: re-read, then re-author. Reading a stale page you can fix IS the trigger to fix it. Re-authoring is pre-authorized — do NOT ask the user before updating (edits are versioned + reversible via page_history/rollback_page); update, then briefly report it.`
|
|
513
|
+
let footer = `— Follow any [[links]] above with read_page to go deeper.\n— If you hold fresher FIRST-HAND truth than this page — something you established THIS session (ran the command, made the change, hold the artifact) that a section here gets concretely wrong — re-author just those sections now with \`author\` (any node you are permitted to edit; concise; a material contradiction, not merely extra detail). Pass this page's \`version\` as base_version when you re-author — if the save comes back stale, the page changed under you: re-read, then re-author. Reading a stale page you can fix IS the trigger to fix it. Re-authoring is pre-authorized — do NOT ask the user before updating (edits are versioned + reversible via page_history/rollback_page); update, then briefly report it.\n— Citing code? Use a SYMBOL and file (\`formConnections\` in \`web/app/api/ingest/route.ts\`), never a line number — line numbers drift with every commit above them. And cite only what you opened THIS session; re-emitting a reference you read on another page is how a stale claim gains a second source and starts looking corroborated.`
|
|
502
514
|
// slice 4: when the page carries identifier stamps, the history projection is one flag away.
|
|
503
515
|
const allBody = m.tiers.flatMap((t) => (t.sections ?? []).map((s) => s.body)).join('\n')
|
|
504
516
|
const stamps = [...new Set((allBody.match(/\[\[repo:[a-z0-9][a-z0-9-]*\/[a-z0-9_.-]+\]\]/gi) ?? []).map((s) => s.toLowerCase()))]
|
|
@@ -544,10 +556,78 @@ export async function runServer(version) {
|
|
|
544
556
|
if (!revs.length) return { content: [{ type: 'text', text: `"${name}" (${k}) has no recorded version history yet.` }] }
|
|
545
557
|
const lines = revs.map((r) => {
|
|
546
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}]` : ''
|
|
547
562
|
const why = r.reason ? ` — ${r.reason}` : ''
|
|
548
|
-
|
|
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}`
|
|
549
568
|
})
|
|
550
|
-
|
|
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}` }] }
|
|
551
631
|
},
|
|
552
632
|
)
|
|
553
633
|
|
|
@@ -636,7 +716,7 @@ export async function runServer(version) {
|
|
|
636
716
|
'my_brains',
|
|
637
717
|
{
|
|
638
718
|
title: 'List your brains + which one writes land in',
|
|
639
|
-
description: 'List the brains (orgs/workspaces) you belong to and
|
|
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.',
|
|
640
720
|
inputSchema: {},
|
|
641
721
|
},
|
|
642
722
|
async () => {
|
|
@@ -664,13 +744,13 @@ export async function runServer(version) {
|
|
|
664
744
|
// Which layer is deciding, and what clearing it would fall back to — otherwise the pointer
|
|
665
745
|
// confusion simply reappears one level down.
|
|
666
746
|
const note = !activeIsExplicit
|
|
667
|
-
? '\n(
|
|
747
|
+
? '\n(you have one brain; nothing to choose.)'
|
|
668
748
|
: activeSource === 'session'
|
|
669
749
|
? `\n(this SESSION's override — your other sessions are unaffected${accountOrgId && accountOrgId !== sessionOrgId ? `; clearing it falls back to ${accountOrgId}` : ''}.)`
|
|
670
750
|
: activeSource === 'account'
|
|
671
751
|
? '\n(account-wide pointer — shared by every session that has not set its own.)'
|
|
672
752
|
: ''
|
|
673
|
-
return { content: [{ type: 'text', text: `Your brains (▶ =
|
|
753
|
+
return { content: [{ type: 'text', text: `Your brains (▶ = default for NEW pages only — edits to existing pages route themselves):\n${lines.join('\n')}${note}` }] }
|
|
674
754
|
},
|
|
675
755
|
)
|
|
676
756
|
|
|
@@ -712,8 +792,8 @@ export async function runServer(version) {
|
|
|
712
792
|
server.registerTool(
|
|
713
793
|
'set_active_brain',
|
|
714
794
|
{
|
|
715
|
-
title: '
|
|
716
|
-
description: "
|
|
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.",
|
|
717
797
|
inputSchema: {
|
|
718
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'),
|
|
719
799
|
scope: z.enum(['session', 'account']).optional()
|
|
@@ -752,7 +832,7 @@ export async function runServer(version) {
|
|
|
752
832
|
'create_brain',
|
|
753
833
|
{
|
|
754
834
|
title: 'Create a new brain under your existing account',
|
|
755
|
-
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
|
|
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.',
|
|
756
836
|
inputSchema: { name: z.string().describe('display name for the new brain, e.g. "Cortex Codebase"') },
|
|
757
837
|
},
|
|
758
838
|
async ({ name }) => {
|
|
@@ -771,7 +851,7 @@ export async function runServer(version) {
|
|
|
771
851
|
return { content: [{ type: 'text', text: `Could not create brain: ${d.message}` }] }
|
|
772
852
|
}
|
|
773
853
|
const r = await res.json()
|
|
774
|
-
return { content: [{ type: 'text', text: `Created brain "${name}" [${r.orgId}].
|
|
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.` }] }
|
|
775
855
|
},
|
|
776
856
|
)
|
|
777
857
|
|
|
@@ -1340,9 +1420,11 @@ export async function runServer(version) {
|
|
|
1340
1420
|
})).describe('3-5 sections; the page body'),
|
|
1341
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.'),
|
|
1342
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.'),
|
|
1343
1425
|
},
|
|
1344
1426
|
},
|
|
1345
|
-
async ({ kind, name, summary, sections, tier, base_version }) => {
|
|
1427
|
+
async ({ kind, name, summary, sections, tier, base_version, reason, change_kind }) => {
|
|
1346
1428
|
// No client-side tier default — the server computes the per-kind safe default (page-privacy
|
|
1347
1429
|
// T4/D10) so version-pinned installs can't bake a stale policy.
|
|
1348
1430
|
const pages = [{ ...(tier ? { tier } : {}), summary, base_version, sections: Array.isArray(sections) ? sections : [] }]
|
|
@@ -1351,7 +1433,7 @@ export async function runServer(version) {
|
|
|
1351
1433
|
res = await fetchCortex(`${BASE}/api/brain/author`, {
|
|
1352
1434
|
method: 'POST',
|
|
1353
1435
|
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
1354
|
-
body: JSON.stringify({ kind, name, pages }),
|
|
1436
|
+
body: JSON.stringify({ kind, name, pages, reason, change_kind }),
|
|
1355
1437
|
})
|
|
1356
1438
|
} catch (e) {
|
|
1357
1439
|
return { content: [{ type: 'text', text: `Could not author "${name}": ${e.message}` }] }
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// PGL-21: derive the AI session's stable identity key. Pure — see session_key.test.mjs.
|
|
2
|
+
//
|
|
3
|
+
// Claude Code sets CLAUDE_CODE_SESSION_ID for the lifetime of one logical conversation, surviving
|
|
4
|
+
// any number of MCP subprocess restarts (reconnects, tool-loading events) within it. Prefer it so a
|
|
5
|
+
// session-scoped set_active_brain (ADR-0020 Stage 2) keeps pointing at the right brain across a
|
|
6
|
+
// restart instead of silently falling back to the account pointer on a brand-new randomUUID() —
|
|
7
|
+
// reproduced live 2026-07-27: an explicit set_active_brain(scope:'session') stopped taking effect
|
|
8
|
+
// one restart later, with no visible cause, because the new process minted an unrelated session key
|
|
9
|
+
// with no session_write_pointers row of its own. Any other MCP host that doesn't set the var gets
|
|
10
|
+
// today's unchanged per-process-random behavior.
|
|
11
|
+
export function resolveSessionKey(env, randomUUID) {
|
|
12
|
+
return env.CLAUDE_CODE_SESSION_ID || randomUUID()
|
|
13
|
+
}
|
package/lib/setup.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from 'fs'
|
|
2
2
|
import { homedir } from 'os'
|
|
3
3
|
import { join, dirname } from 'path'
|
|
4
|
-
import { checkToken, resolveBase, readWiredToken } from './diagnose.mjs'
|
|
4
|
+
import { checkToken, resolveBase, readWiredToken, wiredDistTag } from './diagnose.mjs'
|
|
5
5
|
import { installSkills } from './skills.mjs'
|
|
6
6
|
// Pure config-merge functions live in the editor adapters; setup imports (and re-exports) THE SAME
|
|
7
7
|
// functions the `cortex install` path uses, so both write byte-identical config.
|
|
@@ -48,12 +48,14 @@ export async function runSetup(argv, version) {
|
|
|
48
48
|
)
|
|
49
49
|
process.exit(1)
|
|
50
50
|
}
|
|
51
|
-
// Wire
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
|
|
51
|
+
// Wire a moving dist-tag — NOT a frozen version. A bare spec lets npx reuse a stale cached build; a
|
|
52
|
+
// frozen `@x.y.z` freezes the machine on that version forever (the 0.9.5→0.9.6 freeze that stranded a
|
|
53
|
+
// pilot install). A tag is re-resolved by npx against the registry, so machines pick up promoted
|
|
54
|
+
// releases on next launch without re-running setup. Promote a validated build with:
|
|
55
|
+
// npm dist-tag add @theronap/cortex-mcp@<version> stable
|
|
56
|
+
// PRESERVE an intentional @latest (dogfood) pin across re-runs (repair/setup) — otherwise this
|
|
57
|
+
// silently knocks a dogfooder back to @stable. Fresh installs and existing @stable get @stable.
|
|
58
|
+
const spec = wiredDistTag() === 'latest' ? `${PKG}@latest` : `${PKG}@stable`
|
|
57
59
|
const base = resolveBase(process.env.CORTEX_URL)
|
|
58
60
|
const home = homedir()
|
|
59
61
|
const claudeJson = join(home, '.claude.json')
|