@theronap/cortex-mcp 0.9.82 → 0.9.84
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/README.md +3 -3
- package/bin/cortex-mcp.mjs +38 -22
- package/lib/capture.mjs +1 -1
- package/lib/code_graph_cli.mjs +1 -1
- package/lib/context_log.mjs +5 -5
- package/lib/diagnose.mjs +81 -11
- package/lib/docs_scan.mjs +4 -4
- package/lib/doctor.mjs +48 -11
- package/lib/editors/antigravity.mjs +1 -1
- package/lib/editors/claude.mjs +29 -14
- package/lib/editors/codex.mjs +1 -1
- package/lib/editors/cursor.mjs +1 -1
- package/lib/editors/index.mjs +1 -1
- package/lib/graphify_sync.mjs +49 -4
- package/lib/grep_cli.mjs +1 -1
- package/lib/hydrate.mjs +1 -1
- package/lib/imessage_send.mjs +1 -1
- package/lib/ingest_folder.mjs +2 -2
- package/lib/install.mjs +6 -6
- package/lib/redact.mjs +1 -1
- package/lib/resolve.mjs +62 -12
- package/lib/server.mjs +211 -51
- package/lib/setup.mjs +11 -10
- package/lib/skills.mjs +89 -17
- package/lib/uninstall.mjs +3 -3
- package/lib/use_brain.mjs +82 -0
- package/package.json +1 -1
- package/skills/author-docs/SKILL.md +5 -5
- package/skills/context/SKILL.md +4 -4
- package/skills/log/SKILL.md +6 -6
- package/skills/walkthrough/SKILL.md +1 -1
- package/lib/precompact.mjs +0 -16
package/lib/server.mjs
CHANGED
|
@@ -40,7 +40,7 @@ async function redLinkTriage(BASE, TOKEN, name) {
|
|
|
40
40
|
// file-request tools, and a module-level `fail` would be silently shadowed by it from that point on.
|
|
41
41
|
const toolError = (text) => ({ content: [{ type: 'text', text }], isError: true })
|
|
42
42
|
|
|
43
|
-
// The
|
|
43
|
+
// The Agnoclast MCP server (stdio). Serves the signed-in employee's scoped org
|
|
44
44
|
// context to their AI assistant. CORTEX_TOKEN identifies the user + org.
|
|
45
45
|
|
|
46
46
|
export async function runServer(version) {
|
|
@@ -48,7 +48,7 @@ export async function runServer(version) {
|
|
|
48
48
|
const BASE = resolveBase(process.env.CORTEX_URL)
|
|
49
49
|
|
|
50
50
|
if (!TOKEN) {
|
|
51
|
-
process.stderr.write('cortex-mcp: CORTEX_TOKEN is required. Get yours from the
|
|
51
|
+
process.stderr.write('cortex-mcp: CORTEX_TOKEN is required. Get yours from the Agnoclast console → Connect your AI.\n')
|
|
52
52
|
process.exit(1)
|
|
53
53
|
}
|
|
54
54
|
|
|
@@ -90,7 +90,7 @@ export async function runServer(version) {
|
|
|
90
90
|
const now = Date.now()
|
|
91
91
|
if (cache && now - cache.ts < 5 * 60 * 1000) return cache.text
|
|
92
92
|
// fetchCortex retries transient infra/5xx; classify turns a failure into an honest message
|
|
93
|
-
// (token vs infra-block vs network) instead of a bare "
|
|
93
|
+
// (token vs infra-block vs network) instead of a bare "Agnoclast API 403: unknown".
|
|
94
94
|
const res = await fetchCortex(`${BASE}/api/mcp-context`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
95
95
|
if (!res.ok) {
|
|
96
96
|
const body = await res.text()
|
|
@@ -117,7 +117,7 @@ export async function runServer(version) {
|
|
|
117
117
|
server.registerTool(
|
|
118
118
|
'my_context',
|
|
119
119
|
{
|
|
120
|
-
title: 'My
|
|
120
|
+
title: 'My Agnoclast context',
|
|
121
121
|
description: 'Your current work context from the org. Pass a question to get query-centered session context seeded from the most relevant node and its neighborhood; omit it for the baseline snapshot.',
|
|
122
122
|
inputSchema: { question: z.string().optional().describe('optional opening user question to center the context around') },
|
|
123
123
|
},
|
|
@@ -137,6 +137,53 @@ export async function runServer(version) {
|
|
|
137
137
|
},
|
|
138
138
|
)
|
|
139
139
|
|
|
140
|
+
// Where this machine's unattended session captures land. The server half shipped 2026-08-09 with no
|
|
141
|
+
// client surface at all, so the only way to set it was a hand-written authenticated HTTP call —
|
|
142
|
+
// which meant three real users whose sessions were silently being held could not fix it themselves.
|
|
143
|
+
// This is the surface that makes it answerable in conversation: "put my sessions in TTO".
|
|
144
|
+
server.registerTool(
|
|
145
|
+
'set_capture_brain',
|
|
146
|
+
{
|
|
147
|
+
title: 'Choose where your session captures are saved',
|
|
148
|
+
description:
|
|
149
|
+
'Set which brain THIS PERSON\'s unattended session captures (the automatic end-of-session record) land in, or read the current setting by omitting `brain`. WHEN TO USE: whenever the user says their sessions are not being saved, asks where their work is going, or a session-start notice says captures are being HELD. WHY IT IS NEEDED: with more than one brain, a capture that names no brain cannot be routed and is held outside every brain — correct, but invisible, so it accumulates silently. This is per-PERSON and applies to their own captures only; it cannot be set for someone else. ⚠ PASS THE ORG ID when the user has two brains with the SAME NAME (e.g. two called "Personal") — a name matching more than one is REFUSED rather than guessed, and the error lists the ids to choose from. Setting this does NOT file already-held captures; those keep their original dates and may belong in different brains, so sort them deliberately rather than dumping them into the new default.',
|
|
150
|
+
inputSchema: {
|
|
151
|
+
brain: z.string().optional().describe('the brain name or org id (from my_brains) where this person\'s session captures should land. Omit to read the current setting instead of changing it.'),
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
async ({ brain }) => {
|
|
155
|
+
const url = `${BASE}/api/brain/capture-default`
|
|
156
|
+
if (!brain?.trim()) {
|
|
157
|
+
const res = await fetchCortex(url, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
158
|
+
if (!res.ok) {
|
|
159
|
+
const body = await res.text()
|
|
160
|
+
throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
|
|
161
|
+
}
|
|
162
|
+
const { defaults } = await res.json()
|
|
163
|
+
if (!defaults?.length) {
|
|
164
|
+
return { content: [{ type: 'text', text: 'No capture brain is set. If you belong to more than one brain, your session captures are being HELD outside every brain until you set one. Call this tool again with `brain` to fix it.' }] }
|
|
165
|
+
}
|
|
166
|
+
const lines = defaults.map((d) => `${d.sourceType} captures land in "${d.orgName}" (${d.orgId}), set ${String(d.updatedAt).slice(0, 10)}`)
|
|
167
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] }
|
|
168
|
+
}
|
|
169
|
+
const res = await fetchCortex(url, {
|
|
170
|
+
method: 'POST',
|
|
171
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
172
|
+
body: JSON.stringify({ sourceType: 'claude-code', brain: brain.trim() }),
|
|
173
|
+
})
|
|
174
|
+
const body = await res.text()
|
|
175
|
+
if (!res.ok) {
|
|
176
|
+
// Prefer the server's own error: a 409 lists the org ids of an ambiguous name, which IS the
|
|
177
|
+
// remedy, and a generic classification would throw that away.
|
|
178
|
+
let msg
|
|
179
|
+
try { msg = JSON.parse(body).error } catch { /* fall through to the classified message */ }
|
|
180
|
+
throw new Error(msg ?? classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
|
|
181
|
+
}
|
|
182
|
+
const j = JSON.parse(body)
|
|
183
|
+
return { content: [{ type: 'text', text: `✓ Your Claude Code sessions now land in "${j.brain}" (${j.orgId}). Sessions captured before now are still held and keep their original dates — file those deliberately, they may not all belong in this brain.` }] }
|
|
184
|
+
},
|
|
185
|
+
)
|
|
186
|
+
|
|
140
187
|
// T8: cortex-log's authoritative writer. The skill composes a curated summary, then calls this to
|
|
141
188
|
// persist it AS the durable record (capture_source='skill'). The ingest conflict guard ensures the
|
|
142
189
|
// auto-capture hook never clobbers it. Pass the SAME sessionId the hook uses so the two dedupe onto
|
|
@@ -144,8 +191,8 @@ export async function runServer(version) {
|
|
|
144
191
|
server.registerTool(
|
|
145
192
|
'log_session',
|
|
146
193
|
{
|
|
147
|
-
title: 'Log this session to
|
|
148
|
-
description: 'Persist a CURATED summary of this work session as its durable
|
|
194
|
+
title: 'Log this session to Agnoclast',
|
|
195
|
+
description: 'Persist a CURATED summary of this work session as its durable Agnoclast record (authoritative — supersedes the auto-capture hook). Call at session close after composing the summary. Pass sessionId (the Claude Code session id) if you have it so this dedupes with the auto-capture of the same session.',
|
|
149
196
|
inputSchema: {
|
|
150
197
|
summary: z.string().describe('the curated session summary (what was done, decided, left open) — becomes the durable record'),
|
|
151
198
|
project: z.string().optional().describe('project key/name this session worked in'),
|
|
@@ -172,7 +219,7 @@ export async function runServer(version) {
|
|
|
172
219
|
throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
|
|
173
220
|
}
|
|
174
221
|
const j = await res.json().catch(() => ({}))
|
|
175
|
-
return { content: [{ type: 'text', text: `Logged to
|
|
222
|
+
return { content: [{ type: 'text', text: `Logged to Agnoclast (authoritative): "${j.title ?? title ?? 'session'}" — ${j.inserted ? 'new record' : 'updated existing'}.` }] }
|
|
176
223
|
},
|
|
177
224
|
)
|
|
178
225
|
|
|
@@ -286,31 +333,6 @@ export async function runServer(version) {
|
|
|
286
333
|
},
|
|
287
334
|
)
|
|
288
335
|
|
|
289
|
-
server.registerTool(
|
|
290
|
-
'maintenance_candidates',
|
|
291
|
-
{
|
|
292
|
-
title: 'Review recent project-linked maintenance evidence',
|
|
293
|
-
description: 'Start here when preparing a handoff, status, or next step for a NAMED project. Returns a bounded, RLS-scoped set of recent raw records already linked to that project, before you trust the authored page. These are candidate evidence, not a command to edit: read them, call project_status, then decide whether a material contradiction warrants a minimal accountable correction. When correcting, retain the decisive factual qualifier (for example completion date or state) in the current-status text. Never mutate merely to clear a candidate.',
|
|
294
|
-
inputSchema: {
|
|
295
|
-
project: z.string().describe('the project key or exact project name from the task, e.g. "checkout-v2" or "Checkout v2"'),
|
|
296
|
-
since_days: z.number().optional().describe('how far back to inspect (1-90 days, default 30)'),
|
|
297
|
-
limit: z.number().optional().describe('max candidate records (1-50, default 20)'),
|
|
298
|
-
},
|
|
299
|
-
},
|
|
300
|
-
async ({ project, since_days, limit }) => {
|
|
301
|
-
const qs = new URLSearchParams({ project })
|
|
302
|
-
if (since_days != null) qs.set('since_days', String(since_days))
|
|
303
|
-
if (limit != null) qs.set('limit', String(limit))
|
|
304
|
-
const res = await fetchCortex(`${BASE}/api/maintenance/candidates?${qs}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
305
|
-
if (!res.ok) {
|
|
306
|
-
const body = await res.text()
|
|
307
|
-
throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
|
|
308
|
-
}
|
|
309
|
-
const { text } = await res.json()
|
|
310
|
-
return { content: [{ type: 'text', text }] }
|
|
311
|
-
},
|
|
312
|
-
)
|
|
313
|
-
|
|
314
336
|
server.registerTool(
|
|
315
337
|
'search_org',
|
|
316
338
|
{
|
|
@@ -379,7 +401,7 @@ export async function runServer(version) {
|
|
|
379
401
|
{
|
|
380
402
|
title: 'Query the local code structure graph (graphify)',
|
|
381
403
|
description:
|
|
382
|
-
'Query a structural code graph for the repo at the CURRENT working directory, built locally by graphify (tree-sitter AST — deterministic, no LLM, no server round-trip; this is LOCAL MACHINE data, not org-shared
|
|
404
|
+
'Query a structural code graph for the repo at the CURRENT working directory, built locally by graphify (tree-sitter AST — deterministic, no LLM, no server round-trip; this is LOCAL MACHINE data, not org-shared Agnoclast content, and reflects a snapshot of one commit, not live files). Use for MULTI-HOP questions a single grep cannot answer: what calls/imports/depends on X, how A structurally reaches B, or a repo-wide overview (hub/community files). Do NOT use for single-hop lookups (does file X import Y) — grep is faster and always current. Structure only — it knows what imports/calls what, never WHY; read the actual files or authored Agnoclast pages for intent.',
|
|
383
405
|
inputSchema: {
|
|
384
406
|
action: z.enum(['query', 'path', 'explain']).describe('"query" = open-ended natural-language question (graph traversal); "path" = shortest structural path between two named nodes; "explain" = describe one node and list its direct connections'),
|
|
385
407
|
question: z.string().optional().describe('required for action:"query" — the natural-language question'),
|
|
@@ -444,7 +466,7 @@ export async function runServer(version) {
|
|
|
444
466
|
description:
|
|
445
467
|
'READ the full authored wiki page for one node (project/person/org/you) by its canonical name — every section, every tier you can see. This is how you READ a node; `grep` only LOCATES pages (snippets + their [[links]]), it does not read them. Navigate like a researcher: read the page you need, then FOLLOW its inline [[links]] by calling read_page on each linked name — keep following while the linked pages stay relevant, stop when they do not. You decide how deep to go. Returns only what you are permitted to see.',
|
|
446
468
|
inputSchema: {
|
|
447
|
-
name: z.string().describe('the canonical node name exactly as written (e.g. "
|
|
469
|
+
name: z.string().describe('the canonical node name exactly as written (e.g. "Agnoclast", "Ben", or a [[link]] target) — identifier links ([[repo:owner/name]]) resolve to their authored HOME + a visible-event count'),
|
|
448
470
|
kind: z.enum(['project', 'person', 'org', 'user']).optional().describe('node kind (default project; pass person/org for people/teams)'),
|
|
449
471
|
expand: z.boolean().optional().describe('identifier names only: also list recent visible timeline events for this identifier (default: home + count)'),
|
|
450
472
|
history: z.boolean().optional().describe('node names only: return the node\'s TIMELINE (events joined via its [[repo:…]] stamps, reverse-chron, viewer-visible) instead of the page body. The page is the present; this is the history.'),
|
|
@@ -618,7 +640,7 @@ export async function runServer(version) {
|
|
|
618
640
|
title: 'See a wiki page\'s edit history',
|
|
619
641
|
description: 'Show the VERSION history of an authored wiki page — every prior version, who changed it and when, newest first. This is how you see "what changed on this page and by whom", and it includes privacy changes (re-tiers). Then use `read_page` with a version to view an old body, or `rollback_page` to restore one. (Distinct from read_page\'s `history: true`, which is the raw event timeline via [[repo:…]] stamps.) RLS-scoped: you see history only for pages you may read.',
|
|
620
642
|
inputSchema: {
|
|
621
|
-
name: z.string().describe('the canonical node name exactly as written (e.g. "
|
|
643
|
+
name: z.string().describe('the canonical node name exactly as written (e.g. "Agnoclast", "Ben")'),
|
|
622
644
|
kind: z.enum(['project', 'person', 'org', 'user']).optional().describe('node kind (default project)'),
|
|
623
645
|
limit: z.number().int().optional().describe('how many recent versions to show (default 20, max 200)'),
|
|
624
646
|
},
|
|
@@ -744,7 +766,14 @@ export async function runServer(version) {
|
|
|
744
766
|
}
|
|
745
767
|
const out = await res.json().catch(() => null)
|
|
746
768
|
if (!res.ok) return toolError(`Could not roll back "${name}": ${out?.error ?? res.status}`)
|
|
747
|
-
|
|
769
|
+
// #477: when the target revision had no summary, the page's CURRENT summary was kept rather than
|
|
770
|
+
// erased. Say it on its own line instead of at the tail of `note` — this is the 2026-08-04 W3
|
|
771
|
+
// shape, where empty summaries copied over populated ones destroyed 31 of them under a report
|
|
772
|
+
// that read as success. A rescue the operator does not see is still a silent write.
|
|
773
|
+
const keptLine = out.summaryKept === 'current'
|
|
774
|
+
? `\n\n⚠ That revision had NO summary, so the page's current summary was KEPT rather than erased — check it still describes the restored body, and use set_summary if not.`
|
|
775
|
+
: ''
|
|
776
|
+
return { content: [{ type: 'text', text: `Done — "${name}" ${out.note}. \`read_page "${name}"\` to confirm the current content.${keptLine}` }] }
|
|
748
777
|
},
|
|
749
778
|
)
|
|
750
779
|
|
|
@@ -910,13 +939,15 @@ export async function runServer(version) {
|
|
|
910
939
|
'writing_style',
|
|
911
940
|
{
|
|
912
941
|
title: 'How the user writes (for drafting in their voice)',
|
|
913
|
-
description: 'Returns the user\'s saved writing-style profile so you can DRAFT in their voice (email, message, doc). Call this right before composing anything on their behalf. Self-only — it is always the calling user\'s own profile. If none is saved, it tells you to derive one and save it with set_writing_style.',
|
|
914
|
-
inputSchema: {
|
|
942
|
+
description: 'Returns the user\'s saved writing-style profile so you can DRAFT in their voice (email, message, doc). Call this right before composing anything on their behalf. Self-only — it is always the calling user\'s own profile. If none is saved, it tells you to derive one and save it with set_writing_style. A profile is stored per (user, BRAIN) — it is injected into authoring IN a brain — so if you hold several, name the one you are drafting in.',
|
|
943
|
+
inputSchema: {
|
|
944
|
+
brain: z.string().optional().describe('which brain\'s style profile, by name or org id. Unnecessary when you only have one brain; pass the org id when a name matches more than one of yours'),
|
|
945
|
+
},
|
|
915
946
|
},
|
|
916
|
-
async () => {
|
|
947
|
+
async ({ brain } = {}) => {
|
|
917
948
|
let res
|
|
918
949
|
try {
|
|
919
|
-
res = await fetchCortex(`${BASE}/api/style`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
950
|
+
res = await fetchCortex(`${BASE}/api/style${brain ? `?brain=${encodeURIComponent(brain)}` : ''}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
920
951
|
} catch (e) {
|
|
921
952
|
return toolError(`Could not load writing style: ${e.message}`)
|
|
922
953
|
}
|
|
@@ -934,12 +965,15 @@ export async function runServer(version) {
|
|
|
934
965
|
{
|
|
935
966
|
title: 'Save the user\'s writing-style profile',
|
|
936
967
|
description: 'Save (or update) a description of HOW the user writes — tone, sentence rhythm, structure, formatting habits, signature quirks — derived from prose you have seen them write this session. Store the STYLE, never their private content. Self-only: it always updates the calling user\'s own profile. Pass an empty string to clear it.',
|
|
937
|
-
inputSchema: {
|
|
968
|
+
inputSchema: {
|
|
969
|
+
style_md: z.string().describe('a concise markdown description of the user\'s writing voice (tone/structure/quirks), ~1-2 paragraphs'),
|
|
970
|
+
brain: z.string().optional().describe('which brain to save the profile in, by name or org id. A profile is stored per (user, brain), so this is a real choice when you hold several; pass the org id when a name matches more than one of yours'),
|
|
971
|
+
},
|
|
938
972
|
},
|
|
939
|
-
async ({ style_md }) => {
|
|
973
|
+
async ({ style_md, brain }) => {
|
|
940
974
|
let res
|
|
941
975
|
try {
|
|
942
|
-
res = await fetchCortex(`${BASE}/api/style`, {
|
|
976
|
+
res = await fetchCortex(`${BASE}/api/style${brain ? `?brain=${encodeURIComponent(brain)}` : ''}`, {
|
|
943
977
|
method: 'PUT',
|
|
944
978
|
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
945
979
|
body: JSON.stringify({ style_md }),
|
|
@@ -1177,7 +1211,7 @@ export async function runServer(version) {
|
|
|
1177
1211
|
'list_brain_pages',
|
|
1178
1212
|
{
|
|
1179
1213
|
title: 'List every authored page in one brain',
|
|
1180
|
-
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
|
|
1214
|
+
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, and within it you see only the pages you are cleared to read — a confidential page owned by someone else is not listed, not even by title.',
|
|
1181
1215
|
inputSchema: {
|
|
1182
1216
|
org_id: z.string().describe('the org id of the brain to enumerate (from my_brains)'),
|
|
1183
1217
|
},
|
|
@@ -1213,7 +1247,7 @@ export async function runServer(version) {
|
|
|
1213
1247
|
{
|
|
1214
1248
|
title: 'Create a new brain under your existing account',
|
|
1215
1249
|
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 pass `brain` when an operation needs one named',
|
|
1216
|
-
inputSchema: { name: z.string().describe('display name for the new brain, e.g. "
|
|
1250
|
+
inputSchema: { name: z.string().describe('display name for the new brain, e.g. "Design Team"') },
|
|
1217
1251
|
},
|
|
1218
1252
|
async ({ name }) => {
|
|
1219
1253
|
let res
|
|
@@ -1304,7 +1338,7 @@ export async function runServer(version) {
|
|
|
1304
1338
|
'my_sessions',
|
|
1305
1339
|
{
|
|
1306
1340
|
title: 'My active AI sessions',
|
|
1307
|
-
description: "See what all of YOUR OWN active
|
|
1341
|
+
description: "See what all of YOUR OWN active Agnoclast/AI sessions are doing right now (working directory + how recently each was active), so you can coordinate across windows/devices. Self-only — only your own sessions, never anyone else's.",
|
|
1308
1342
|
inputSchema: {},
|
|
1309
1343
|
},
|
|
1310
1344
|
async () => {
|
|
@@ -1494,6 +1528,60 @@ export async function runServer(version) {
|
|
|
1494
1528
|
},
|
|
1495
1529
|
)
|
|
1496
1530
|
|
|
1531
|
+
server.registerTool(
|
|
1532
|
+
'replace_variant',
|
|
1533
|
+
{
|
|
1534
|
+
title: 'Move one tier variant\'s body into another, collapsing the node to one page',
|
|
1535
|
+
description: 'DESTRUCTIVE, and the only sanctioned way to fix a FORKED page. When one node exists at two tiers with different bodies, this moves the SOURCE variant\'s body into the TARGET variant\'s slot and DELETES the source, leaving the node with a single page. The source\'s body WINS — this is not `absorb`, where the target survives; in a fork repair the target is usually the damaged page, so mirroring absorb would keep the damage and delete the good copy. Sections are copied as ROWS, never re-derived from text: re-deriving a body from context is exactly what destroyed 258 sections on 2026-07-18 while sincerely reporting "copied verbatim". BEFORE CALLING: read_page the TARGET and pass its version as target_version — it proves you know which body is about to be overwritten, and a stale or guessed value is rejected rather than silently accepted. If the target tier has NO page, do not use this: the slot is free, so set_page_privacy moves the page there cheaply. Both prior bodies are retained in page history and the operation is reversible via page_history/rollback_page. Owner or editor on BOTH variants; an OWNERLESS target may be overwritten only by an org admin.',
|
|
1536
|
+
inputSchema: {
|
|
1537
|
+
kind: z.enum(['project', 'person', 'org', 'user']).describe('the page kind'),
|
|
1538
|
+
name: z.string().optional().describe('the exact page name (or pass `ref` instead — one of the two is required)'),
|
|
1539
|
+
ref: z.string().optional().describe('the page\'s stable id, printed as `ref:` by read_page. PREFER THIS over name when you have it: a ref is unique across brains, so it addresses exactly one page and never needs a brain to disambiguate it.'),
|
|
1540
|
+
source_tier: z.enum(['accessible', 'scoped', 'confidential']).describe('the variant whose BODY WINS and survives. This variant\'s row is then deleted.'),
|
|
1541
|
+
target_tier: z.enum(['accessible', 'scoped', 'confidential']).describe('the OCCUPIED slot the body lands in. This variant\'s current body is DESTROYED (snapshotted to page history first). The surviving page sits at this tier.'),
|
|
1542
|
+
target_version: z.string().describe('REQUIRED — the TARGET page\'s version, from read_page. Proves you know what is being overwritten. Do not retry a rejection blindly; re-read the target and confirm you are replacing what you think you are.'),
|
|
1543
|
+
brain: z.string().optional().describe('only when the same page name exists in more than one of your brains'),
|
|
1544
|
+
},
|
|
1545
|
+
},
|
|
1546
|
+
async ({ kind, name, ref, source_tier, target_tier, target_version, brain }) => {
|
|
1547
|
+
let res
|
|
1548
|
+
try {
|
|
1549
|
+
res = await fetchCortex(`${BASE}/api/brain/replace-variant`, {
|
|
1550
|
+
method: 'POST',
|
|
1551
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
1552
|
+
body: JSON.stringify({
|
|
1553
|
+
kind, ...(name ? { name } : {}), ...(ref ? { ref } : {}),
|
|
1554
|
+
source_tier, target_tier, target_version, ...(brain ? { brain } : {}),
|
|
1555
|
+
}),
|
|
1556
|
+
})
|
|
1557
|
+
} catch (e) {
|
|
1558
|
+
return toolError(`Could not replace variant: ${e.message}`)
|
|
1559
|
+
}
|
|
1560
|
+
const out = await res.json().catch(() => null)
|
|
1561
|
+
if (!res.ok) {
|
|
1562
|
+
// The engine's rejections carry the remedy in their text (free slot → use set_page_privacy;
|
|
1563
|
+
// hash mismatch → re-read, do not retry blindly). Surface it verbatim rather than paraphrasing.
|
|
1564
|
+
if (out?.error) return toolError(`Could not replace variant: ${out.error}`)
|
|
1565
|
+
const d = classify(res.status, res.headers.get('content-type'), '', res.headers.get('x-vercel-id'))
|
|
1566
|
+
return toolError(`Could not replace variant: ${d.message}`)
|
|
1567
|
+
}
|
|
1568
|
+
if (!out) return { content: [{ type: 'text', text: 'Replace reported success, but the server returned no body — re-read the page before assuming it landed.' }] }
|
|
1569
|
+
// The summary rides along with the body, EXCEPT when the source has none — then the target's is
|
|
1570
|
+
// kept rather than erased. Say so on its own line rather than at the tail of `note`: this is the
|
|
1571
|
+
// 2026-08-04 W3 shape, where an empty summary copied over a populated one destroyed 31 of them
|
|
1572
|
+
// under a report that read as success. A rescue the operator does not see is still a silent write.
|
|
1573
|
+
const rescued = out.summaryRescued
|
|
1574
|
+
? `\n\n⚠ The ${out.moved.from} variant had NO summary. The ${out.moved.to} page's own summary was KEPT rather than overwritten with an empty one — check it still describes the body that just landed, and set_summary if not.`
|
|
1575
|
+
: ''
|
|
1576
|
+
return {
|
|
1577
|
+
content: [{
|
|
1578
|
+
type: 'text',
|
|
1579
|
+
text: `Done — "${out.moved.title}": the ${out.moved.from} body now occupies the ${out.moved.to} page (${out.sections} section${out.sections === 1 ? '' : 's'}), and the ${out.moved.from} variant was removed. The node now has ONE variant. Both prior bodies are retained in page history.${rescued}`,
|
|
1580
|
+
}],
|
|
1581
|
+
}
|
|
1582
|
+
},
|
|
1583
|
+
)
|
|
1584
|
+
|
|
1497
1585
|
server.registerTool(
|
|
1498
1586
|
'grant_page_access',
|
|
1499
1587
|
{
|
|
@@ -1607,6 +1695,78 @@ export async function runServer(version) {
|
|
|
1607
1695
|
},
|
|
1608
1696
|
)
|
|
1609
1697
|
|
|
1698
|
+
server.registerTool(
|
|
1699
|
+
'request_person_page_merge',
|
|
1700
|
+
{
|
|
1701
|
+
title: 'Offer one duplicate person page for merge',
|
|
1702
|
+
description: 'Offer YOUR accessible person page to be merged into another current accessible person page in the SAME named brain. Use only when the two pages are unquestionably the same real person. Read both pages first and pass their refs and versions. This never crosses brains and does not use fuzzy matching.',
|
|
1703
|
+
inputSchema: {
|
|
1704
|
+
brain: z.string().describe('the exact brain name or brain id; pass an id if names collide'),
|
|
1705
|
+
source_ref: z.string().describe('ref of YOUR duplicate source person page'),
|
|
1706
|
+
target_ref: z.string().describe('ref of the canonical person page to retain'),
|
|
1707
|
+
source_version: z.string().describe('current version from reading the source page'),
|
|
1708
|
+
target_version: z.string().describe('current version from reading the canonical page'),
|
|
1709
|
+
reason: z.string().describe('why these pages are certainly the same person'),
|
|
1710
|
+
},
|
|
1711
|
+
},
|
|
1712
|
+
async (input) => {
|
|
1713
|
+
let res
|
|
1714
|
+
try {
|
|
1715
|
+
res = await fetchCortex(`${BASE}/api/brain/page-node-merge`, {
|
|
1716
|
+
method: 'POST', headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
1717
|
+
body: JSON.stringify({ action: 'request', ...input }),
|
|
1718
|
+
})
|
|
1719
|
+
} catch (e) { return toolError(`Could not request page merge: ${e.message}`) }
|
|
1720
|
+
const out = await res.json().catch(() => null)
|
|
1721
|
+
if (!res.ok) return toolError(`Could not request page merge: ${out?.error ?? res.status}`)
|
|
1722
|
+
return { content: [{ type: 'text', text: `Merge request ${out.request_id} created. The canonical-page owner must review and apply it.` }] }
|
|
1723
|
+
},
|
|
1724
|
+
)
|
|
1725
|
+
|
|
1726
|
+
server.registerTool(
|
|
1727
|
+
'person_page_merge_requests',
|
|
1728
|
+
{
|
|
1729
|
+
title: 'Review duplicate-person page merge requests',
|
|
1730
|
+
description: 'List pending same-brain person-page merges where you own the canonical page. Read both pages before applying. Applying copies every source summary and section verbatim into the canonical page, preserves its links and as-of dates, and supersedes rather than deletes the source page.',
|
|
1731
|
+
inputSchema: {},
|
|
1732
|
+
},
|
|
1733
|
+
async () => {
|
|
1734
|
+
let res
|
|
1735
|
+
try { res = await fetchCortex(`${BASE}/api/brain/page-node-merge`, { headers: { Authorization: `Bearer ${TOKEN}` } }) }
|
|
1736
|
+
catch (e) { return toolError(`Could not list person-page merge requests: ${e.message}`) }
|
|
1737
|
+
const out = await res.json().catch(() => null)
|
|
1738
|
+
if (!res.ok) return toolError(`Could not list person-page merge requests: ${out?.error ?? res.status}`)
|
|
1739
|
+
if (!out?.requests?.length) return { content: [{ type: 'text', text: 'No pending duplicate-person page merge requests.' }] }
|
|
1740
|
+
const lines = out.requests.map((q) => `- [${q.id}] ${q.source_owner_name ?? 'someone'}: "${q.source_title}" → "${q.target_title}" in ${q.brain}. Reason: ${q.reason}`)
|
|
1741
|
+
return { content: [{ type: 'text', text: `Pending person-page merges (${out.requests.length}):\n${lines.join('\n')}` }] }
|
|
1742
|
+
},
|
|
1743
|
+
)
|
|
1744
|
+
|
|
1745
|
+
server.registerTool(
|
|
1746
|
+
'apply_person_page_merge',
|
|
1747
|
+
{
|
|
1748
|
+
title: 'Apply a reviewed duplicate-person page merge',
|
|
1749
|
+
description: 'Apply a pending same-brain person-page merge you own. Re-read the canonical page immediately before applying and pass its version. This is preservation-first: all source summary/sections are retained verbatim in the canonical page, the source page is superseded (not deleted), and its old link target redirects to the canonical page.',
|
|
1750
|
+
inputSchema: {
|
|
1751
|
+
request_id: z.string().describe('id from person_page_merge_requests'),
|
|
1752
|
+
target_version: z.string().describe('current canonical-page version from a fresh read'),
|
|
1753
|
+
reason: z.string().describe('why the merge is approved'),
|
|
1754
|
+
},
|
|
1755
|
+
},
|
|
1756
|
+
async (input) => {
|
|
1757
|
+
let res
|
|
1758
|
+
try {
|
|
1759
|
+
res = await fetchCortex(`${BASE}/api/brain/page-node-merge`, {
|
|
1760
|
+
method: 'POST', headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
1761
|
+
body: JSON.stringify({ action: 'apply', ...input }),
|
|
1762
|
+
})
|
|
1763
|
+
} catch (e) { return toolError(`Could not apply page merge: ${e.message}`) }
|
|
1764
|
+
const out = await res.json().catch(() => null)
|
|
1765
|
+
if (!res.ok) return toolError(`Could not apply page merge: ${out?.error ?? res.status}`)
|
|
1766
|
+
return { content: [{ type: 'text', text: `Merged "${out.sourceTitle}" into "${out.targetTitle}". Preserved ${out.preservedSections} source block(s); source page is superseded, not deleted.` }] }
|
|
1767
|
+
},
|
|
1768
|
+
)
|
|
1769
|
+
|
|
1610
1770
|
server.registerTool(
|
|
1611
1771
|
'decide_page_merge',
|
|
1612
1772
|
{
|
|
@@ -1688,7 +1848,7 @@ export async function runServer(version) {
|
|
|
1688
1848
|
'decide_file_request',
|
|
1689
1849
|
{
|
|
1690
1850
|
title: 'Approve or deny a file request',
|
|
1691
|
-
description: 'As the OWNER of a record, approve or deny someone\'s request for its full original. On approve, your
|
|
1851
|
+
description: 'As the OWNER of a record, approve or deny someone\'s request for its full original. On approve, your Agnoclast desktop app fetches + shares the file. Get request_id from file_requests.',
|
|
1692
1852
|
inputSchema: { request_id: z.string(), decision: z.enum(['approve', 'deny']) },
|
|
1693
1853
|
},
|
|
1694
1854
|
async ({ request_id, decision }) => {
|
|
@@ -1730,7 +1890,7 @@ export async function runServer(version) {
|
|
|
1730
1890
|
},
|
|
1731
1891
|
)
|
|
1732
1892
|
|
|
1733
|
-
// send_imessage — local outbound texting (NOT org intelligence; writes nothing to
|
|
1893
|
+
// send_imessage — local outbound texting (NOT org intelligence; writes nothing to Agnoclast). Runs on
|
|
1734
1894
|
// this machine via Messages.app. Draft-by-default + recipient allowlist + OOB confirm (D3/D6/D10).
|
|
1735
1895
|
server.registerTool(
|
|
1736
1896
|
'send_imessage',
|
|
@@ -1754,7 +1914,7 @@ export async function runServer(version) {
|
|
|
1754
1914
|
// Two tools the working session uses to AUTHOR its understanding into the org wiki while it's hot:
|
|
1755
1915
|
// authoring_context → the companion call (§3): fetch the visible NAMESPACE + the node-type connection
|
|
1756
1916
|
// rules BEFORE writing, so the page links canonically (the L2 lever).
|
|
1757
|
-
// author → the write (§9 step 3/4): hand
|
|
1917
|
+
// author → the write (§9 step 3/4): hand Agnoclast a finished page (summary + sections WITH
|
|
1758
1918
|
// inline [[links]]); the server runs the resolution pass + tier-safe 2B write.
|
|
1759
1919
|
|
|
1760
1920
|
server.registerTool(
|
|
@@ -1762,7 +1922,7 @@ export async function runServer(version) {
|
|
|
1762
1922
|
{
|
|
1763
1923
|
title: 'Authoring context (call before author)',
|
|
1764
1924
|
description:
|
|
1765
|
-
'Fetch the scaffolding to author a
|
|
1925
|
+
'Fetch the scaffolding to author a Agnoclast wiki node: the canonical NAMESPACE (current node names — link to these with the EXACT name inside [[ ]]), any deliberately RETIRED page names and their successors, and the node-type CONNECTION RULES. ALWAYS call this BEFORE `author` so the page links to current knowledge rather than minting synonyms or reviving a retired page. Reference a node in the namespace as [[Name]]; if you reference something real that is NOT in the namespace, still write [[Name]] — that is a red-link marking a node worth creating. If the page IS about a specific code repo or chat channel, also stamp it once — [[repo:owner/name]] or [[channel:name]] (lowercase, no #) — identifier join keys, not page links.',
|
|
1766
1926
|
inputSchema: {
|
|
1767
1927
|
kind: z.enum(['project', 'person', 'org', 'user']).optional().describe('the node type you are about to author (default project)'),
|
|
1768
1928
|
brain: z.string().optional().describe('which brain\'s namespace to describe — pass the SAME brain you will pass to `author`, so the namespace you plan against is the one your write lands in. Unnecessary when you only have one brain.'),
|
|
@@ -1805,7 +1965,7 @@ export async function runServer(version) {
|
|
|
1805
1965
|
'Write your CURRENT understanding of a project/person/org/you into the org wiki as a maintained page. Call `authoring_context` FIRST. Author from your own synthesis of the session — the compiled mental model, not a transcript dump: what it IS, where it stands, dated decisions, open threads, key people. Weave inline [[links]] to other nodes (canonical names from the namespace; red-links for wanted-but-absent nodes). The server re-authorizes the tier and resolves links. CREATES the node if it does not exist yet (project/person/org) — the conversation IS the evidence, so a brand-new entity that surfaced only in this session is authorable on the spot; you do NOT need prior records. Because such a node has nothing external to corroborate it, author it DELIBERATELY: only when you genuinely understand it is a real, distinct entity, and use its exact canonical name so it does not duplicate one already in the namespace (`user` nodes are never created). Use this continuously whenever your understanding of a node meaningfully advanced, and at session end (/log). Authoring is PRE-AUTHORIZED — never ask the user "should I update the page?" before calling this (every edit is versioned + reversible via page_history/rollback_page); update, then briefly report what you updated. ⚠ WHICH BRAIN A NEW PAGE GOES IN IS A CONTENT DECISION, SO MAKE IT FROM THE CONTENT. Only a page that exists in NO brain needs this — an update resolves its brain from the page itself. If you hold more than one brain, the server REFUSES a create it cannot attribute (409 `create_needs_brain`) rather than letting the write pointer decide — the pointer is stale out-of-band state that knows nothing about what you are writing. So call `my_brains` (it returns each brain\'s name, page count and sample titles, which is enough to tell what each one is FOR) and pass `brain` up front; that turns a refused round trip into a single call. State which brain you picked and why in one short line, then proceed — do NOT ask when the answer is obvious from the content. DO ask when it is genuinely ambiguous: brains are a confidentiality boundary, so a page born in the wrong one can expose private work to a teammate, and that is not a filing error you can quietly fix later.',
|
|
1806
1966
|
inputSchema: {
|
|
1807
1967
|
kind: z.enum(['project', 'person', 'org', 'user']).describe('the node type'),
|
|
1808
|
-
name: z.string().describe('the canonical node name — an EXACT existing name from the namespace to update it, or a new name to create the node (project/person/org). e.g. "
|
|
1968
|
+
name: z.string().describe('the canonical node name — an EXACT existing name from the namespace to update it, or a new name to create the node (project/person/org). e.g. "Agnoclast" or "Theron Peterson"'),
|
|
1809
1969
|
summary: z.string().describe('one-sentence summary of what this is and its current state (may contain [[links]])'),
|
|
1810
1970
|
sections: z.array(z.object({
|
|
1811
1971
|
heading: z.string().describe('e.g. Overview, Current state, Decisions, Open threads, People'),
|
package/lib/setup.mjs
CHANGED
|
@@ -44,7 +44,7 @@ export async function runSetup(argv, version) {
|
|
|
44
44
|
if (!token || token.startsWith('-')) {
|
|
45
45
|
process.stderr.write(
|
|
46
46
|
'Usage: npx @theronap/cortex-mcp setup <CORTEX_TOKEN>\n\n' +
|
|
47
|
-
'Get your token from the
|
|
47
|
+
'Get your token from the Agnoclast console → Connect your AI.\n'
|
|
48
48
|
)
|
|
49
49
|
process.exit(1)
|
|
50
50
|
}
|
|
@@ -63,7 +63,7 @@ export async function runSetup(argv, version) {
|
|
|
63
63
|
|
|
64
64
|
const log = (m) => process.stdout.write(m + '\n')
|
|
65
65
|
log('')
|
|
66
|
-
log('
|
|
66
|
+
log('Agnoclast setup — wiring your AI assistant…')
|
|
67
67
|
|
|
68
68
|
// ── 1. MCP server in ~/.claude.json ──────────────────────────────────────
|
|
69
69
|
try {
|
|
@@ -83,7 +83,7 @@ export async function runSetup(argv, version) {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
// ── 1b. MCP server in Codex (~/.codex/config.toml), only if Codex is installed ──
|
|
86
|
-
// Codex gets the same
|
|
86
|
+
// Codex gets the same Agnoclast context tools as Claude Code. Non-fatal: a Codex hiccup must
|
|
87
87
|
// never block the primary Claude wiring.
|
|
88
88
|
const codexDir = join(home, '.codex')
|
|
89
89
|
if (existsSync(codexDir)) {
|
|
@@ -128,8 +128,9 @@ export async function runSetup(argv, version) {
|
|
|
128
128
|
process.exit(1)
|
|
129
129
|
}
|
|
130
130
|
const bak = backup(settingsJson)
|
|
131
|
-
// Capture (Stop) + status/skills/snapshot (SessionStart) +
|
|
132
|
-
// merge — the same pure function the `cortex install` Claude adapter uses.
|
|
131
|
+
// Capture (Stop) + status/skills/snapshot (SessionStart) + hydrate (UserPromptSubmit), idempotent
|
|
132
|
+
// merge — the same pure function the `cortex install` Claude adapter uses. Also unwires the retired
|
|
133
|
+
// PreCompact reminder from seats that still carry it.
|
|
133
134
|
s = mergeClaudeSettings(s, spec)
|
|
134
135
|
|
|
135
136
|
ensureDir(settingsJson)
|
|
@@ -157,7 +158,7 @@ export async function runSetup(argv, version) {
|
|
|
157
158
|
// ── 4. Self-verify — writing config proves "files written", NOT "connection works".
|
|
158
159
|
// Actually call the API so a bad/expired token is caught HERE, not 40 minutes into debugging.
|
|
159
160
|
log('')
|
|
160
|
-
log('Verifying your token against
|
|
161
|
+
log('Verifying your token against Agnoclast…')
|
|
161
162
|
const health = await checkToken(token, base)
|
|
162
163
|
if (health.ok) {
|
|
163
164
|
const n = health.projectCount
|
|
@@ -169,8 +170,8 @@ export async function runSetup(argv, version) {
|
|
|
169
170
|
}
|
|
170
171
|
|
|
171
172
|
log('')
|
|
172
|
-
log('⟳ IMPORTANT: fully quit and reopen Claude Code to load the
|
|
173
|
-
log(' Then your AI sees your
|
|
173
|
+
log('⟳ IMPORTANT: fully quit and reopen Claude Code to load the Agnoclast server.')
|
|
174
|
+
log(' Then your AI sees your Agnoclast context and your sessions flow into the org.')
|
|
174
175
|
log(' Re-check anytime: npx -y @theronap/cortex-mcp doctor')
|
|
175
176
|
log(` Console: ${base}`)
|
|
176
177
|
log('')
|
|
@@ -191,11 +192,11 @@ export async function runRepair(version) {
|
|
|
191
192
|
const token = readWiredToken()
|
|
192
193
|
if (!token) {
|
|
193
194
|
process.stderr.write(
|
|
194
|
-
'No existing
|
|
195
|
+
'No existing Agnoclast token found in ~/.claude.json or ~/.codex/config.toml.\n' +
|
|
195
196
|
'Run setup once with your token: npx -y @theronap/cortex-mcp setup <YOUR_TOKEN>\n',
|
|
196
197
|
)
|
|
197
198
|
process.exit(1)
|
|
198
199
|
}
|
|
199
|
-
process.stdout.write('
|
|
200
|
+
process.stdout.write('Agnoclast repair — re-running setup with your existing token at this version…\n')
|
|
200
201
|
await runSetup([token], version)
|
|
201
202
|
}
|