@theronap/cortex-mcp 0.9.76 → 0.9.78

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.
@@ -49,10 +49,11 @@ if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
49
49
  ` uninstall remove ALL Agnoclast wiring (MCP, hooks, skills, launchd, cron). --dry-run to preview, --purge to also wipe ~/.cortex + npx cache\n` +
50
50
  ` doctor live health check — confirm your token works (no restart needed)\n` +
51
51
  ` status one-line connected/not-connected check (used by the SessionStart hook)\n` +
52
+ ` use-brain [<brain>] where your session captures are saved — no arg shows the current setting\n` +
52
53
  ` skills install/repair the managed Agnoclast skills — bundled + org-published (also wired by setup)\n` +
53
54
  ` skills push <file> publish a SKILL.md to your org (owner/manager/admin)\n` +
54
55
  ` docs-scan detect new/changed local docs pending Agnoclast authoring (used by /cortex-author-docs)\n` +
55
- ` graphify-sync [path] rebuild the local code graph (graphify) + log an evidence-tier timeline event\n` +
56
+ ` graphify-sync [path] [--brain <name-or-id>] rebuild the local code graph + log a timeline event\n` +
56
57
  ` snapshot-context save the exact startup context Agnoclast served to a local snapshot\n` +
57
58
  ` hydrate UserPromptSubmit hook — inject query-centered context on the first substantive turn\n` +
58
59
  ` statusline ambient presence line for the Claude Code statusline (local read only)\n` +
@@ -122,7 +123,7 @@ if (cmd === 'login') {
122
123
  } else if (cmd === 'resolve') {
123
124
  // Entity identity dedup: judge the server-flagged fuzzy duplicate pairs locally via `claude -p`.
124
125
  const { runResolve } = await import('../lib/resolve.mjs')
125
- await runResolve()
126
+ await runResolve(rest)
126
127
  const { closeFetch } = await import('../lib/diagnose.mjs')
127
128
  await closeFetch()
128
129
  } else if (cmd === 'materialize') {
@@ -177,12 +178,27 @@ if (cmd === 'login') {
177
178
  // The cortex-author-docs skill authors the pending docs into wiki pages. No network.
178
179
  const { runDocsScan } = await import('../lib/docs_scan.mjs')
179
180
  process.exitCode = await runDocsScan(rest)
181
+ } else if (cmd === 'use-brain') {
182
+ // Set (or show) which brain this machine's unattended session captures land in. Registered here,
183
+ // BEFORE the default branch — an unrecognised subcommand falls through to "start the MCP server",
184
+ // which is how `connect-calendar` once silently became the file-watching daemon on a pilot user's
185
+ // machine and reported that daemon's errors instead of its own.
186
+ const { runUseBrain } = await import('../lib/use_brain.mjs')
187
+ process.exitCode = await runUseBrain(rest)
188
+ const { closeFetch } = await import('../lib/diagnose.mjs')
189
+ await closeFetch()
180
190
  } else if (cmd === 'precompact') {
181
- // PreCompact hook (③ live wiki authoring, best-effort): print an "author now" reminder so the session
182
- // sweeps its understanding into the wiki BEFORE compaction drops it. A hook cannot force a model turn
183
- // (D2) this is the documented fallback; /log is the hard backstop. No network; prints + exits.
184
- const { runPrecompactReminder } = await import('../lib/precompact.mjs')
185
- runPrecompactReminder()
191
+ // RETIRED deliberately kept as a silent no-op, do not delete yet.
192
+ //
193
+ // The reminder it used to print never reached a model: PreCompact takes a blocking `decision` and has
194
+ // no additionalContext channel, so its stdout went nowhere (verified 2026-08-10 against 7,232 local
195
+ // transcripts — see the PreCompact block in lib/editors/claude.mjs for the measurement and control).
196
+ //
197
+ // Why this branch survives the removal: seats installed before this release still have
198
+ // `PreCompact: npx -y @theronap/cortex-mcp@latest precompact` in ~/.claude/settings.json, and @latest
199
+ // resolves to THIS build. Deleting the branch would turn a harmless no-op into an unknown-command
200
+ // error on every compaction for anyone who has not re-run install. mergeClaudeSettings unwires them
201
+ // on their next install/repair; drop this branch a release after that has had time to propagate.
186
202
  } else {
187
203
  // Default: run the MCP server (stays alive; never exits).
188
204
  const { runServer } = await import('../lib/server.mjs')
package/lib/diagnose.mjs CHANGED
@@ -90,10 +90,19 @@ export function classify(status, contentType, bodyText, requestId) {
90
90
  const isJson = (contentType ?? '').includes('application/json')
91
91
  let appError = null
92
92
  let appHint = null
93
+ let appMessage = null
94
+ let appBrains = null
93
95
  if (isJson) {
94
96
  try {
95
97
  const parsed = JSON.parse(bodyText)
96
98
  appError = parsed?.error ?? null
99
+ // Same lesson as `hint`, one layer up. brain_choice_response.ts writes a full explanation to
100
+ // `message` and every brain's NAME / PAGE COUNT / SAMPLE TITLES to `brains` — its comment says
101
+ // "THE BODY IS THE ANSWER TO ITS OWN QUESTION", precisely so a model can pick a brain from what
102
+ // each one HOLDS. Only `error` survived here, so the agent got the bare code `brain_required`
103
+ // and could not answer it. Measured on the CLI twin of this bug: `✗ brain_required`, full stop.
104
+ appMessage = typeof parsed?.message === 'string' && parsed.message.trim() ? parsed.message.trim() : null
105
+ appBrains = Array.isArray(parsed?.brains) ? parsed.brains : null
97
106
  // `hint` carries the RECOVERY instruction for the errors an agent is meant to act on, not
98
107
  // just report: 409 would_drop ("re-author including the dropped sections…") and 413 too_large
99
108
  // ("split the section…"). It used to be dropped here — only `error` survived — so the agent
@@ -120,6 +129,25 @@ export function classify(status, contentType, bodyText, requestId) {
120
129
  `Vercel firewall / deployment protection on the API route. Re-running setup will not help.${rid}`,
121
130
  }
122
131
  }
132
+ // A brain-choice refusal is ANSWERABLE, so it must arrive as the question it is rather than as a
133
+ // code. Deliberately narrow: only these two errors reshape the message, so every other classify()
134
+ // output keeps its existing wording (and its tests).
135
+ if (isJson && (appError === 'brain_required' || appError === 'unknown_brain') && (appMessage || appBrains)) {
136
+ const list = (appBrains ?? []).map((b) => {
137
+ const pages = typeof b?.pageCount === 'number' ? ` — ${b.pageCount} page${b.pageCount === 1 ? '' : 's'}` : ''
138
+ const titles = Array.isArray(b?.sampleTitles) && b.sampleTitles.length
139
+ ? `: ${b.sampleTitles.slice(0, 2).join('; ')}` : ''
140
+ // Name AND id: brain names are NOT unique (one account holds two called "Personal"), so a name
141
+ // alone can come back as unknown_brain. The id always resolves.
142
+ return ` - ${b?.name ?? '(unnamed)'}${pages}${titles}${b?.orgId ? ` [${b.orgId}]` : ''}`
143
+ })
144
+ return {
145
+ kind: 'app', retriable: false,
146
+ message: `Agnoclast API ${status}: ${appMessage ?? appError}${list.length ? `\n${list.join('\n')}` : ''}` +
147
+ `\nRe-run this tool with \`brain\` set to one of the names or ids above.${rid}`,
148
+ }
149
+ }
150
+
123
151
  return {
124
152
  kind: 'app', retriable: status >= 500,
125
153
  message: `Agnoclast API ${status}: ${appError ?? 'unknown error'}.${appHint ? ` ${appHint}` : ''}${rid}`,
@@ -207,12 +235,19 @@ export async function checkToken(token, base) {
207
235
  return { ok: false, status: res.status, requestId, diagnosis: classify(res.status, contentType, body, requestId) }
208
236
  }
209
237
  let projectCount
238
+ let captureNotice = null
210
239
  try {
211
- const ctx = JSON.parse(body).context ?? ''
240
+ const parsed = JSON.parse(body)
241
+ const ctx = parsed.context ?? ''
212
242
  const m = ctx.match(/## Projects \((\d+)\)/)
213
243
  if (m) projectCount = Number(m[1])
244
+ // The server's "your work is not landing" advisory. Optional by design: an older server does not
245
+ // send it and this must stay a health check, so a missing field is simply no notice.
246
+ if (parsed.captureNotice && typeof parsed.captureNotice.message === 'string') {
247
+ captureNotice = parsed.captureNotice
248
+ }
214
249
  } catch { /* context shape changed — non-fatal for a health check */ }
215
- return { ok: true, status: 200, projectCount, requestId }
250
+ return { ok: true, status: 200, projectCount, requestId, captureNotice }
216
251
  }
217
252
 
218
253
  // Probe the org-skills surface. SEPARATE FROM checkToken ON PURPOSE.
package/lib/doctor.mjs CHANGED
@@ -11,7 +11,10 @@ import { checkToken, checkSkills, resolveBase } from './diagnose.mjs'
11
11
 
12
12
  // Token resolution: env first, then the Claude config the setup command wrote (so `doctor`
13
13
  // works the moment after `setup`, before any restart). Returns { token, source }.
14
- function resolveToken() {
14
+ // Exported so `use-brain` resolves the token EXACTLY as doctor/status do. There is already a second,
15
+ // subtly different copy of this in context_log.mjs (returns a bare token, not {token, source}); a
16
+ // third copy is how a machine ends up "connected" to one command and "no token found" to another.
17
+ export function resolveToken() {
15
18
  if (process.env.CORTEX_TOKEN) return { token: process.env.CORTEX_TOKEN, source: 'CORTEX_TOKEN env' }
16
19
  const claudeJson = join(homedir(), '.claude.json')
17
20
  if (existsSync(claudeJson)) {
@@ -39,6 +42,16 @@ export async function runStatus() {
39
42
  try {
40
43
  const r = await checkToken(token, base)
41
44
  if (r.ok) {
45
+ // ⚠ THE NOTICE REPLACES THE HAPPY LINE RATHER THAN FOLLOWING IT.
46
+ // "connected — sessions on this machine are captured to your org" was printed truthfully to
47
+ // three people whose sessions were, at that moment, landing in NO brain: connected is a fact
48
+ // about the TOKEN, and every reader takes it as a fact about their WORK. Printing both would
49
+ // leave the reassurance that caused four days of silent loss sitting directly above the
50
+ // warning that contradicts it.
51
+ if (r.captureNotice?.message) {
52
+ out(`Agnoclast: ⚠ ${r.captureNotice.message}`)
53
+ return 0
54
+ }
42
55
  const n = typeof r.projectCount === 'number' ? ` · ${r.projectCount} project${r.projectCount === 1 ? '' : 's'} visible` : ''
43
56
  out(`Agnoclast: connected — sessions on this machine are captured to your org${n}.`)
44
57
  } else {
@@ -41,7 +41,8 @@ export const CORTEX_ALLOWED_TOOLS = [
41
41
  /** Merge Agnoclast's Claude Code hooks into a ~/.claude/settings.json object. Pure + idempotent: drops
42
42
  * any prior cortex entry (old token/path/version) from each hook array before appending the current
43
43
  * one — capture (Stop), status + skills-repair + snapshot-context (SessionStart), hydrate
44
- * (UserPromptSubmit), precompact (PreCompact). Commands carry NO inline token (each subcommand
44
+ * (UserPromptSubmit). Also UNWIRES the retired PreCompact reminder from seats that still carry it.
45
+ * Commands carry NO inline token (each subcommand
45
46
  * self-resolves it). Mirrors setup.mjs
46
47
  * step 2 exactly; foreign hooks are never touched. Also merges the CORTEX_ALLOWED_TOOLS permission
47
48
  * allowlist — additive-only: a user's own allow entries (even extra mcp__cortex__* ones) are never
@@ -105,16 +106,28 @@ export function mergeClaudeSettings(existing, spec) {
105
106
  hgrp.hooks = hgrp.hooks ?? []
106
107
  hgrp.hooks.push({ type: 'command', command: hydrateCmd })
107
108
 
108
- // PreCompact — "author now" reminder.
109
- s.hooks.PreCompact = Array.isArray(s.hooks.PreCompact) ? s.hooks.PreCompact : []
110
- const precompactCmd = `npx -y ${spec} precompact`
111
- for (const pg of s.hooks.PreCompact) {
112
- if (Array.isArray(pg.hooks)) pg.hooks = pg.hooks.filter((h) => !/cortex-mcp(@[^ ]*)? precompact/.test(h.command ?? ''))
109
+ // PreCompact — REMOVED, and this block is the migration that unwires existing seats.
110
+ //
111
+ // The "author now" reminder wrote to stdout on the assumption the harness surfaced it as context for
112
+ // the next turn. It does not: PreCompact accepts a blocking `decision` and nothing else — it has no
113
+ // additionalContext channel so the reminder never reached a model. Measured 2026-08-10 across 7,232
114
+ // local transcripts: the reminder text appears 5 times, every one of them a tool_result from someone
115
+ // READING the file, assistant prose about it, or a compaction summary that absorbed such prose. Zero
116
+ // injections, against >=13 transcripts that demonstrably compacted. The control is what makes that
117
+ // conclusive rather than merely absent: SessionStart's hook output, on a channel that IS injected,
118
+ // appears in 912 transcripts of the same corpus.
119
+ //
120
+ // So: filter, never append. Every install/repair strips the stale entry from seats that already have
121
+ // it, which is why this runs unconditionally instead of shipping as a separate migration.
122
+ if (Array.isArray(s.hooks.PreCompact)) {
123
+ for (const pg of s.hooks.PreCompact) {
124
+ if (Array.isArray(pg.hooks)) pg.hooks = pg.hooks.filter((h) => !/cortex-mcp(@[^ ]*)? precompact/.test(h.command ?? ''))
125
+ }
126
+ // Drop groups we just emptied, then the key itself if no foreign hook remains — a bare
127
+ // `PreCompact: []` reads as "cortex wires this event" to the next person to open settings.json.
128
+ s.hooks.PreCompact = s.hooks.PreCompact.filter((pg) => (pg.hooks ?? []).length > 0)
129
+ if (s.hooks.PreCompact.length === 0) delete s.hooks.PreCompact
113
130
  }
114
- let pgrp = s.hooks.PreCompact.find((g) => (g.matcher ?? '') === '')
115
- if (!pgrp) { pgrp = { matcher: '', hooks: [] }; s.hooks.PreCompact.push(pgrp) }
116
- pgrp.hooks = pgrp.hooks ?? []
117
- pgrp.hooks.push({ type: 'command', command: precompactCmd })
118
131
 
119
132
  // Permissions — pre-authorize the read + authoring core so a page update never stalls on a
120
133
  // permission prompt. Append-missing only (no filter-and-rebuild like the hooks above): removals
@@ -26,8 +26,42 @@ function repoFullNameFromRemote(cwd) {
26
26
  return m ? `${m[1]}/${m[2]}` : null
27
27
  }
28
28
 
29
+ // `--brain <name-or-org-id>` — which brain an UNROUTED repo's graph event lands in.
30
+ //
31
+ // Deliberately a flag and nothing cleverer. /api/timeline/graphify routes deterministically by repo
32
+ // (resolveSourceRoute) and only asks when the repo has no route; its comment is blunt about why it
33
+ // must not be guessed: this writes a `records` row unique on (org_id, dedupe_key), so "a repo whose
34
+ // graph updates land in two brains becomes two record sets that never reconcile, and re-pointing
35
+ // later converges on nothing (ADR-0020). It is the one write class where a wrong answer is genuinely
36
+ // unrecoverable." So: no sweep (unlike `resolve`, which is a maintenance pass over everything) and
37
+ // no auto-pick.
38
+ //
39
+ // It does NOT create a source route as a side effect, though that would stop the question recurring:
40
+ // routes are append-only precisely because "re-pointing a live source splits its history
41
+ // irreparably", and a near-irreversible write should not fall out of a CLI flag. This command runs
42
+ // from a per-repo cron/launchd job, so the flag lives in that job's definition — answered once,
43
+ // where it is visible. (Route creation currently has NO client on any surface; that is a separate
44
+ // gap, not this command's to paper over.)
45
+ // Split argv into { cwd, brain }. Pure + exported so the ordering trap below is unit-testable
46
+ // without a git repo, a graphify binary or a network.
47
+ //
48
+ // THE TRAP: argv[0] doubles as the optional repo path, and `--brain`'s VALUE has no leading '-'.
49
+ // Parsed naively, `graphify-sync --brain Personal` reads "Personal" as the path and syncs whatever
50
+ // happens to be there. So the flag and its value are stripped BEFORE the positional check.
51
+ export function parseGraphifyArgs(argv = [], fallbackCwd = process.cwd()) {
52
+ const bIdx = argv.indexOf('--brain')
53
+ const brain = bIdx === -1 ? null : argv[bIdx + 1]
54
+ if (bIdx !== -1 && (!brain || brain.startsWith('-'))) {
55
+ return { error: 'Usage: graphify-sync [path] [--brain <name-or-org-id>]' }
56
+ }
57
+ const rest = bIdx === -1 ? argv : argv.filter((_, i) => i !== bIdx && i !== bIdx + 1)
58
+ return { cwd: rest[0] && !rest[0].startsWith('-') ? rest[0] : fallbackCwd, brain }
59
+ }
60
+
29
61
  export async function runGraphifySync(argv = []) {
30
- const cwd = argv[0] && !argv[0].startsWith('-') ? argv[0] : process.cwd()
62
+ const parsed = parseGraphifyArgs(argv)
63
+ if (parsed.error) { process.stderr.write(parsed.error + '\n'); return 1 }
64
+ const { cwd, brain } = parsed
31
65
  const TOKEN = process.env.CORTEX_TOKEN
32
66
  const BASE = resolveBase(process.env.CORTEX_URL)
33
67
  if (!TOKEN) {
@@ -73,11 +107,22 @@ export async function runGraphifySync(argv = []) {
73
107
  const res = await fetchCortex(`${BASE}/api/timeline/graphify`, {
74
108
  method: 'POST',
75
109
  headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
76
- body: JSON.stringify({ repo, commitSha, nodeCount, edgeCount, communityCount }),
110
+ body: JSON.stringify({ repo, commitSha, nodeCount, edgeCount, communityCount, ...(brain ? { brain } : {}) }),
77
111
  })
78
112
  if (!res.ok) {
79
113
  const body = await res.text()
80
- process.stderr.write(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message + '\n')
114
+ const d = classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id'))
115
+ process.stderr.write(d.message + '\n')
116
+ // classify names the brains but speaks in MCP terms ("re-run this tool with `brain`"). Say the
117
+ // actual flag, and where to put it — this runs unattended from cron, so the person reading this
118
+ // is looking at a log after the fact, not a prompt.
119
+ if (res.status === 409 && !brain) {
120
+ process.stderr.write(
121
+ `\n${repo} has no routing decision yet, so it cannot be filed without one.\n` +
122
+ `Re-run with: cortex-mcp graphify-sync ${cwd === process.cwd() ? '' : `${cwd} `}--brain "<name-or-org-id>"\n` +
123
+ `and add that flag to this repo's cron/launchd job so it stops asking.\n`,
124
+ )
125
+ }
81
126
  return 1
82
127
  }
83
128
  const payload = await res.json()
package/lib/resolve.mjs CHANGED
@@ -8,47 +8,97 @@ import { edgeSafeEnv } from './edge_extract.mjs'
8
8
  // and pushes decisions back (POST /api/resolve-apply): confident-same → entity_merges, else → rejected
9
9
  // so the pair never re-flags. Conservative by construction. Always exits cleanly.
10
10
 
11
- export async function runResolve() {
11
+ // Which brains to sweep. BOTH endpoints require the brain to be NAMED (ADR-0022 requireBrain —
12
+ // answering a scoped read out of an ARBITRARY brain is the defect that whole class exists to
13
+ // prevent). Until 2026-08-09 this command named none, so a multi-brain caller got 409 on step 1 and
14
+ // the dedup sweep did nothing at all, silently, forever.
15
+ //
16
+ // Naming ONE brain would have been the smaller fix and the wrong one: `resolve` is a MAINTENANCE
17
+ // SWEEP over the user's entities, so doing one brain and reporting success is ADR-0022's other
18
+ // failure — "silently truncating a result set and presenting it as complete". With no --brain we
19
+ // enumerate the caller's brains and sweep EACH, naming it explicitly. Not a guess: every request
20
+ // still names exactly one brain, and all of them actually get done.
21
+ //
22
+ // A sole-brain caller sees today's behaviour: one pass, no flag, no prompt.
23
+ export async function brainsToSweep(base, token, wanted, deps = {}) {
24
+ const fetchFn = deps.fetchCortex ?? fetchCortex
25
+ if (wanted) return [{ orgId: wanted, name: wanted }] // explicit: pass through (name or org id)
26
+ const res = await fetchFn(`${base}/api/brains`, { headers: { Authorization: `Bearer ${token}` } })
27
+ if (!res.ok) {
28
+ const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
29
+ throw new Error(`could not list your brains — ${d.message}`)
30
+ }
31
+ const j = await res.json().catch(() => ({}))
32
+ const brains = Array.isArray(j.brains) ? j.brains : []
33
+ // Carry the ORG ID, never the name: brain names are NOT unique (this account holds two called
34
+ // "Personal"), and a duplicate name comes back as unknown_brain.
35
+ return brains.filter((b) => b?.orgId).map((b) => ({ orgId: b.orgId, name: b.name ?? b.orgId }))
36
+ }
37
+
38
+ export async function runResolve(argv = []) {
12
39
  // recursion guard (we spawn `claude --print`; if its Stop hook fires capture, that no-ops on this flag)
13
40
  if (process.env.CORTEX_SUMMARIZING) { process.stderr.write('cortex: summarizer subprocess, skipping\n'); return }
14
41
  const token = process.env.CORTEX_TOKEN
15
42
  if (!token) { process.stderr.write('cortex: CORTEX_TOKEN not set, skipping\n'); return }
16
43
  const base = resolveBase(process.env.CORTEX_URL)
17
44
 
45
+ const bIdx = argv.indexOf('--brain')
46
+ const wanted = bIdx === -1 ? null : argv[bIdx + 1]
47
+ if (bIdx !== -1 && (!wanted || wanted.startsWith('-'))) {
48
+ process.stderr.write('Usage: resolve [--brain <name-or-org-id>]\n'); return
49
+ }
50
+
51
+ let targets
52
+ try {
53
+ targets = await brainsToSweep(base, token, wanted)
54
+ } catch (e) { process.stderr.write(`cortex: resolve — ${e.message}\n`); return }
55
+ if (!targets.length) { process.stderr.write('cortex: no brains to sweep\n'); return }
56
+
57
+ for (const t of targets) {
58
+ // Label each line with the brain when sweeping several: an unlabelled "merged 3" cannot be acted
59
+ // on, because you cannot tell WHERE three entities just merged.
60
+ await resolveOneBrain(base, token, t, targets.length > 1)
61
+ }
62
+ }
63
+
64
+ async function resolveOneBrain(base, token, brain, labelled) {
65
+ const tag = labelled ? `[${brain.name}] ` : ''
66
+ const qs = `?brain=${encodeURIComponent(brain.orgId)}`
67
+
18
68
  // 1. pull the flagged candidate pairs
19
69
  let candidates = []
20
70
  try {
21
- const res = await fetchCortex(`${base}/api/resolve-candidates`, { headers: { Authorization: `Bearer ${token}` } })
71
+ const res = await fetchCortex(`${base}/api/resolve-candidates${qs}`, { headers: { Authorization: `Bearer ${token}` } })
22
72
  if (!res.ok) {
23
73
  const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
24
- process.stderr.write(`cortex: resolve-candidates failed — ${d.message}\n`); return
74
+ process.stderr.write(`cortex: ${tag}resolve-candidates failed — ${d.message}\n`); return
25
75
  }
26
76
  const j = await res.json().catch(() => ({}))
27
77
  candidates = Array.isArray(j.candidates) ? j.candidates : []
28
- } catch (e) { process.stderr.write(`cortex: resolve fetch failed — ${e.message}\n`); return }
78
+ } catch (e) { process.stderr.write(`cortex: ${tag}resolve fetch failed — ${e.message}\n`); return }
29
79
 
30
- if (!candidates.length) { process.stderr.write('cortex: no duplicate candidates to judge\n'); return }
31
- process.stderr.write(`cortex: judging ${candidates.length} candidate pair(s) locally…\n`)
80
+ if (!candidates.length) { process.stderr.write(`cortex: ${tag}no duplicate candidates to judge\n`); return }
81
+ process.stderr.write(`cortex: ${tag}judging ${candidates.length} candidate pair(s) locally…\n`)
32
82
 
33
83
  // 2. judge locally on the subscription
34
84
  const decisions = judgeCandidates(candidates)
35
- if (decisions === null) { process.stderr.write('cortex: judge unavailable (is `claude` on PATH?) — skipping\n'); return }
85
+ if (decisions === null) { process.stderr.write(`cortex: ${tag}judge unavailable (is \`claude\` on PATH?) — skipping\n`); return }
36
86
 
37
- // 3. apply judged decisions
87
+ // 3. apply SAME brain the candidates came from, or the merges land in the wrong graph
38
88
  try {
39
- const res = await fetchCortex(`${base}/api/resolve-apply`, {
89
+ const res = await fetchCortex(`${base}/api/resolve-apply${qs}`, {
40
90
  method: 'POST',
41
91
  headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
42
92
  body: JSON.stringify({ decisions }),
43
93
  })
44
94
  if (res.ok) {
45
95
  const j = await res.json().catch(() => ({}))
46
- process.stderr.write(`cortex: dedup — merged ${j.merged ?? 0}, rejected ${j.rejected ?? 0}, skipped ${j.skipped ?? 0}\n`)
96
+ process.stderr.write(`cortex: ${tag}dedup — merged ${j.merged ?? 0}, rejected ${j.rejected ?? 0}, skipped ${j.skipped ?? 0}\n`)
47
97
  } else {
48
98
  const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
49
- process.stderr.write(`cortex: resolve-apply failed — ${d.message}\n`)
99
+ process.stderr.write(`cortex: ${tag}resolve-apply failed — ${d.message}\n`)
50
100
  }
51
- } catch (e) { process.stderr.write(`cortex: resolve-apply failed — ${e.message}\n`) }
101
+ } catch (e) { process.stderr.write(`cortex: ${tag}resolve-apply failed — ${e.message}\n`) }
52
102
  }
53
103
 
54
104
  // ONE `claude --print` call judges every pair. Returns DedupDecision[] for the apply endpoint, or null
package/lib/server.mjs CHANGED
@@ -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
@@ -892,13 +939,15 @@ export async function runServer(version) {
892
939
  'writing_style',
893
940
  {
894
941
  title: 'How the user writes (for drafting in their voice)',
895
- 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.',
896
- 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
+ },
897
946
  },
898
- async () => {
947
+ async ({ brain } = {}) => {
899
948
  let res
900
949
  try {
901
- 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}` } })
902
951
  } catch (e) {
903
952
  return toolError(`Could not load writing style: ${e.message}`)
904
953
  }
@@ -916,12 +965,15 @@ export async function runServer(version) {
916
965
  {
917
966
  title: 'Save the user\'s writing-style profile',
918
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.',
919
- inputSchema: { style_md: z.string().describe('a concise markdown description of the user\'s writing voice (tone/structure/quirks), ~1-2 paragraphs') },
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
+ },
920
972
  },
921
- async ({ style_md }) => {
973
+ async ({ style_md, brain }) => {
922
974
  let res
923
975
  try {
924
- res = await fetchCortex(`${BASE}/api/style`, {
976
+ res = await fetchCortex(`${BASE}/api/style${brain ? `?brain=${encodeURIComponent(brain)}` : ''}`, {
925
977
  method: 'PUT',
926
978
  headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
927
979
  body: JSON.stringify({ style_md }),
@@ -1159,7 +1211,7 @@ export async function runServer(version) {
1159
1211
  'list_brain_pages',
1160
1212
  {
1161
1213
  title: 'List every authored page in one brain',
1162
- 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.',
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.',
1163
1215
  inputSchema: {
1164
1216
  org_id: z.string().describe('the org id of the brain to enumerate (from my_brains)'),
1165
1217
  },
package/lib/setup.mjs CHANGED
@@ -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) + precompact (PreCompact), idempotent
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)
@@ -0,0 +1,82 @@
1
+ import { fetchCortex, classify, resolveBase } from './diagnose.mjs'
2
+ import { resolveToken } from './doctor.mjs'
3
+
4
+ // `use-brain` — set (or show) which brain this machine's unattended session captures land in.
5
+ //
6
+ // WHY A SUBCOMMAND EXISTS AT ALL. The server side of this shipped 2026-08-09 with NO client surface:
7
+ // no CLI, no MCP tool, no console setting. The only way to set a capture default was a raw
8
+ // authenticated HTTP call, which meant the only people who could fix a broken capture were the ones
9
+ // who could hand-write a curl with a bearer token. Three real users needed it; one of them is not
10
+ // technical. A fix only its author can operate is not a fix.
11
+ //
12
+ // Pairs with the SessionStart notice: the notice tells you captures are being held and names this
13
+ // command, so the loop from "something is wrong" to "it is fixed" is one paste with no docs.
14
+
15
+ function out(m) { process.stdout.write(m + '\n') }
16
+
17
+ export async function runUseBrain(args) {
18
+ const base = resolveBase(process.env.CORTEX_URL)
19
+ const { token } = resolveToken()
20
+ if (!token) {
21
+ out('Agnoclast: no token found. Run: npx -y @theronap/cortex-mcp setup <token>')
22
+ return 1
23
+ }
24
+
25
+ // Everything after the subcommand is the brain, joined — so an unquoted multi-word name still
26
+ // works. `use-brain Real estate` is what a person actually types; refusing it over a missing pair
27
+ // of quotes would be the same species of unhelpfulness this command exists to remove.
28
+ const wanted = (args ?? []).filter((a) => !a.startsWith('--')).join(' ').trim()
29
+ const url = `${base}/api/brain/capture-default`
30
+
31
+ if (!wanted) {
32
+ // No argument: report the current state rather than erroring. "What is it set to?" is a fair
33
+ // question and the answer is one GET away.
34
+ try {
35
+ const res = await fetchCortex(url, { headers: { Authorization: `Bearer ${token}` } })
36
+ if (!res.ok) {
37
+ const body = await res.text()
38
+ out(`Agnoclast: could not read your capture settings — ${classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message}`)
39
+ return 1
40
+ }
41
+ const { defaults } = await res.json()
42
+ if (!defaults?.length) {
43
+ out('Agnoclast: no capture brain set. Your unattended session captures land in a brain only if')
44
+ out(' you belong to exactly one; otherwise they are HELD outside every brain until you set this.')
45
+ out(' Set one: npx -y @theronap/cortex-mcp use-brain "<brain name or org id>"')
46
+ return 0
47
+ }
48
+ for (const d of defaults) out(`Agnoclast: ${d.sourceType} captures land in "${d.orgName}" (${d.orgId})`)
49
+ return 0
50
+ } catch (e) {
51
+ out(`Agnoclast: could not reach the server (${e?.message ?? String(e)})`)
52
+ return 1
53
+ }
54
+ }
55
+
56
+ try {
57
+ const res = await fetchCortex(url, {
58
+ method: 'POST',
59
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
60
+ body: JSON.stringify({ sourceType: 'claude-code', brain: wanted }),
61
+ })
62
+ const body = await res.text()
63
+ if (!res.ok) {
64
+ let msg = classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message
65
+ // The server's own error text is better than a generic one here: a 404 names the brain that was
66
+ // not found, and a 409 lists the org ids of an ambiguous name — which is the whole remedy.
67
+ try { const j = JSON.parse(body); if (j.error) msg = j.error } catch { /* keep the classified message */ }
68
+ out(`Agnoclast: ${msg}`)
69
+ return 1
70
+ }
71
+ const j = JSON.parse(body)
72
+ out(`Agnoclast: ✓ your Claude Code sessions now land in "${j.brain}".`)
73
+ // Say plainly what this does NOT do. The setter's own server-side note makes the same point,
74
+ // because "I fixed it" reading as "and the backlog is handled" is how held records stay held.
75
+ out(' Sessions captured BEFORE now are still held — they keep their original dates until sorted.')
76
+ out(' Ask your assistant to file them (they may not all belong in the same brain).')
77
+ return 0
78
+ } catch (e) {
79
+ out(`Agnoclast: could not reach the server (${e?.message ?? String(e)})`)
80
+ return 1
81
+ }
82
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.76",
3
+ "version": "0.9.78",
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": {
@@ -1,16 +0,0 @@
1
- // PreCompact "author now" reminder (③ live wiki authoring, [[cortex-wiki-authoring-spec]] D2).
2
- //
3
- // Wired by setup.mjs as a PreCompact hook. A hook CANNOT force a model turn — it can only inject text
4
- // the model sees on its next turn (best-effort). So this prints a reminder to sweep understanding into
5
- // the wiki BEFORE compaction discards the session's hot mental model. The HARD backstop is the /log
6
- // skill (cortex-log step 5); this catches the in-session compaction that would otherwise lose the magic.
7
- //
8
- // Output goes to stdout, which the Claude Code harness surfaces as additional context for the next turn.
9
- export function runPrecompactReminder() {
10
- process.stdout.write(
11
- 'Agnoclast: context is about to compact. If your understanding of any node (the project(s) you worked ' +
12
- 'on, people you coordinated with, or yourself) advanced this session, AUTHOR it into the wiki NOW ' +
13
- 'before it is lost: call `authoring_context` then `author` for each. This is a synthesis of your ' +
14
- 'compiled understanding with inline [[links]], not a transcript dump. Skip nodes you did not advance.\n',
15
- )
16
- }