@theronap/cortex-mcp 0.9.136 → 0.9.138

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/capture.mjs CHANGED
@@ -386,8 +386,17 @@ async function captureWork(stdinRaw) {
386
386
  }
387
387
  const extracted = transcript ? extractSession(transcript) : null
388
388
  if (extracted && /^NOOP\b/i.test(extracted.summary)) { process.stderr.write(`cortex: ${stamp} ${sid} NOT RECORDED — summarizer classified it a no-op session\n`); return }
389
+ // `pages` is the edge's PROPOSAL of where this record belongs (ADR-0055). Free-text names, resolved
390
+ // against real pages server-side and dropped when they match nothing — the same untrusted-edge
391
+ // contract people/entities use. Omitted entirely when empty so an older server sees no new field.
389
392
  const ingestBody = extracted
390
- ? { ...common, summary: extracted.summary, people: extracted.people, entities: extracted.namedEntities }
393
+ ? {
394
+ ...common,
395
+ summary: extracted.summary,
396
+ people: extracted.people,
397
+ entities: extracted.namedEntities,
398
+ ...(extracted.pages?.length ? { pages: extracted.pages } : {}),
399
+ }
391
400
  : { ...common, transcript }
392
401
 
393
402
  // Parallel typed extraction (OPT-IN via CORTEX_TYPED): registry-driven typed notes, sent ALONGSIDE the
@@ -0,0 +1,68 @@
1
+ // How "where do my captures land?" is answered — ONE renderer, two callers.
2
+ //
3
+ // WHY IT IS EXTRACTED. `set_capture_brain` (MCP) and `use-brain` (CLI) each had their own copy of
4
+ // this answer, and the copies had already drifted: the CLI said captures land somewhere "only if you
5
+ // belong to exactly one" brain, the tool said they "are being HELD" if you belong to more than one.
6
+ // Both were the same guess wearing different words. diagnose.mjs' token resolver carries the same
7
+ // warning for the same reason — "a third copy is how a machine ends up 'connected' to one command
8
+ // and 'no token found' to another".
9
+ //
10
+ // WHY IT TAKES DATA INSTEAD OF ASKING. Every branch below is decided by a fact the server already
11
+ // knows: what is staging, and how many brains you are in. Until 2026-09-08 neither surface fetched
12
+ // either, so both could only offer the reader a CONDITION — and on that day a person with one brain
13
+ // and nothing held was told his setup was broken, while a person with 52 held `email` records was
14
+ // told a number that named no source and a remedy that could not reach it.
15
+ //
16
+ // Pure: no I/O, no token, no formatting of anything it was not given.
17
+
18
+ /** The remedy, phrased for where it will be read. Same decision tree, same facts — only the thing
19
+ * the reader would type differs, and getting that wrong is not cosmetic: a CLI user pasting
20
+ * `source="email"` gets an error, and an agent told to run a shell command does the wrong thing.
21
+ * @type {Record<string, (source: string) => string>} */
22
+ const FIX = {
23
+ tool: (source) => `call this tool again with brain=<brain> source="${source}"`,
24
+ cli: (source) => `npx -y @theronap/cortex-mcp use-brain "<brain>" --source ${source}`,
25
+ }
26
+
27
+ /** @param {{defaults?: Array, held?: Array, brainCount?: number}} state
28
+ * @param {'tool'|'cli'} mode */
29
+ export function renderCaptureStatus(state, mode = 'tool') {
30
+ const defaults = state?.defaults ?? []
31
+ const held = state?.held ?? []
32
+ const brainCount = typeof state?.brainCount === 'number' ? state.brainCount : null
33
+ const fix = FIX[mode] ?? FIX.tool
34
+ const lines = []
35
+
36
+ for (const d of defaults) {
37
+ lines.push(`${d.sourceType} captures land in "${d.orgName}" (${d.orgId})${d.updatedAt ? `, set ${String(d.updatedAt).slice(0, 10)}` : ''}`)
38
+ }
39
+
40
+ // The actionable case, and it is stated whether or not other defaults exist: a set `claude-code`
41
+ // default says nothing about `email`, and reading the first as coverage for the second is exactly
42
+ // how 52 records stayed held for ten days behind a green-looking setting.
43
+ if (held.length) {
44
+ if (lines.length) lines.push('')
45
+ lines.push(`⚠ NOT being saved — held outside every brain, one line per source:`)
46
+ for (const h of held) {
47
+ lines.push(` · ${h.held} ${h.source}${h.oldest ? ` (oldest ${h.oldest})` : ''} — ${fix(h.source)}`)
48
+ }
49
+ lines.push('Each source routes on its own; setting one does nothing for the others.')
50
+ return lines.join('\n')
51
+ }
52
+
53
+ if (lines.length) {
54
+ lines.push('')
55
+ lines.push('Nothing is currently held.')
56
+ return lines.join('\n')
57
+ }
58
+
59
+ // No defaults AND nothing held. Whether that is fine or a latent problem is decided by the brain
60
+ // count, so say which — never both, and never as a question for the reader.
61
+ if (brainCount === 1) {
62
+ return 'No capture brain is set, and none is needed: you belong to exactly one brain, so captures route there automatically. Nothing is held. Nothing to do.'
63
+ }
64
+ if (brainCount !== null && brainCount > 1) {
65
+ return `No capture brain is set. Nothing is held right now, but you belong to ${brainCount} brains — a capture from a source with no default cannot be routed, so it will be held rather than saved. Set one per source you use, e.g. ${fix('claude-code')}`
66
+ }
67
+ return 'No capture brain is set, and nothing is currently held.'
68
+ }
@@ -96,6 +96,44 @@ const PEOPLE_FRAGMENT =
96
96
  '"importance" ("high" for people central to the work, "low" for incidental). ' +
97
97
  'Only real named humans; omit anonymous or incidental mentions. Empty array is fine.)'
98
98
 
99
+ // WHERE THIS RECORD BELONGS — the one question the extraction never asked.
100
+ //
101
+ // Measured 2026-09-05..08: of 221 page attachments since the ADR-0051 gate shipped, 215 were
102
+ // mechanical (`deterministic_attach` 197, `session_attach` 18) and 6 were judgment (`route_record`).
103
+ // 2.7%. A session's record lands on the author's profile and any page the session EDITED, and
104
+ // nowhere else — so four hours of work on ADR-0051 is invisible from the ADR-0051 page. ADR-0053
105
+ // measures the consequence: 20% of sessions are findable by subject for a developer, ~0% for a
106
+ // non-developer.
107
+ //
108
+ // Asking a later agent to fix this was tried and rejected: by the time the record exists the session
109
+ // that did the work is gone, and the agent inherits a title. Naive agents given exactly that job
110
+ // parked records because "only the title survived". THIS call already holds the whole transcript,
111
+ // already runs detached with nobody waiting, and already makes judgement calls (people, entities).
112
+ // It is the only place in the system where the context and the question are in the same room.
113
+ //
114
+ // ⚠ "MENTIONED" IS NOT "BELONGS ON", and that distinction is the entire fragment. A session that
115
+ // deploys to Vercel mentions Vercel; it does not belong on the Vercel page. Filing on every mention
116
+ // is worse than filing on none — it makes every page's record list noise, and noise is unreadable in
117
+ // exactly the way an empty list is not. `namedEntities` above is the MENTION list and already exists;
118
+ // this is deliberately a different, smaller question, which is why it is a separate key rather than
119
+ // a flag on that one.
120
+ //
121
+ // Names are proposed as free text and RESOLVED SERVER-SIDE against real pages, the same trust model
122
+ // people/entities already use — the edge is not trusted and does not hold the page list. A name that
123
+ // matches nothing is dropped, so a hallucinated page cannot create one.
124
+ const PAGES_FRAGMENT =
125
+ '"pages" (array of wiki page names this work should be FILED UNDER — the pages whose reader would ' +
126
+ 'want to know this session happened. Each an object with ' +
127
+ '"name" (the page name exactly as written in the session — prefer a name that appears verbatim, ' +
128
+ 'especially inside [[double brackets]], an ADR number and title, a project name, or a person\'s ' +
129
+ 'full name), ' +
130
+ '"why" (one short phrase: what this session did TO or ABOUT that subject). ' +
131
+ 'Include a page ONLY if the work was genuinely about that subject — something was decided, built, ' +
132
+ 'measured, fixed or discovered concerning it. EXCLUDE anything merely used, referenced in passing, ' +
133
+ 'or named as background: a session that deploys to a platform does not belong on that platform\'s ' +
134
+ 'page. Most sessions belong on one to three pages. Empty array is correct for a session with no ' +
135
+ 'durable subject, and is much better than a loose guess.)'
136
+
99
137
  const ENTITY_FRAGMENT =
100
138
  '"namedEntities" (array of important NON-PERSON things this content is about — concrete, named ' +
101
139
  'projects, processes, systems, products, documents, teams, tools, events, places, or topics. ' +
@@ -165,34 +203,109 @@ export function extractSession(transcript) {
165
203
  const text = task ? stripScheduledTaskBlocks(raw) : raw
166
204
  // A run whose entire transcript WAS the instruction block leaves nothing to judge. That is a
167
205
  // heartbeat by definition — skip without spending a `claude -p` call on it.
168
- if (task && !text) return { summary: 'NOOP', people: [], namedEntities: [] }
206
+ if (task && !text) return { summary: 'NOOP', people: [], namedEntities: [], pages: [] }
169
207
  const prompt =
170
208
  'You are processing a Claude Code work session for a knowledge base. Return ONLY minified JSON ' +
171
- '(no prose, no markdown fences) with EXACTLY these three keys:\n' +
209
+ '(no prose, no markdown fences) with EXACTLY these four keys:\n' +
172
210
  '"summary" (ONE concrete sentence under 20 words: what was worked on or decided. If the session ' +
173
211
  'had no real work — greetings, no tasks — set summary to exactly "NOOP"),\n' +
174
212
  PEOPLE_FRAGMENT + ',\n' +
175
- ENTITY_FRAGMENT +
213
+ ENTITY_FRAGMENT + ',\n' +
214
+ PAGES_FRAGMENT +
176
215
  (task ? scheduledTaskFragment(task) : '') +
177
216
  '\n\n--- SESSION ---\n' + text.slice(0, 12000) + '\n--- END ---'
178
217
  // Single-flight: if another session already has a summarizer running, skip (caller ships the tail;
179
218
  // server summarizes). Prevents the concurrent-`claude -p` stampede that deadlocks the OAuth refresh.
180
- if (!acquireSummaryLock()) return null
219
+ if (!acquireSummaryLock()) return edgeSkip('busy', 'another session is summarizing; the tail ships instead')
181
220
  try {
182
221
  const r = spawnSync(
183
222
  'claude',
184
223
  ['--print', '--model', process.env.CORTEX_SUMMARY_MODEL ?? 'claude-haiku-4-5', prompt],
185
224
  { env: edgeSafeEnv(process.env, { CORTEX_SUMMARIZING: '1' }), encoding: 'utf8', timeout: summaryTimeoutMs(), maxBuffer: 4 * 1024 * 1024 },
186
225
  )
187
- if (r.status !== 0 || !r.stdout) return null
188
- return parseEdgeJson(r.stdout.trim())
189
- } catch {
190
- return null
226
+ const why = classifyEdgeFailure(r)
227
+ if (why) return edgeSkip(why.reason, why.detail)
228
+ const parsed = parseEdgeJson(r.stdout.trim())
229
+ if (!parsed) return edgeSkip('unparseable', 'the model returned output that is not the expected JSON')
230
+ return parsed
231
+ } catch (e) {
232
+ return edgeSkip('threw', e instanceof Error ? e.message : String(e))
191
233
  } finally {
192
234
  releaseSummaryLock()
193
235
  }
194
236
  }
195
237
 
238
+ // ── WHY THE EXTRACTION DID NOT RUN ───────────────────────────────────────────────────────────────
239
+ //
240
+ // Every failure path here returns null, and the caller correctly treats null as "ship the transcript
241
+ // tail and let the server summarize". That fallback is right. What was wrong is that FIVE different
242
+ // failures were indistinguishable, and one of them never fixes itself.
243
+ //
244
+ // 🔴 THE EVIDENCE. Found 2026-09-08: `claude --print` had been exiting 1 with
245
+ // "Failed to authenticate: OAuth session expired and could not be refreshed" — so edge extraction
246
+ // returned null for EVERY session, on every seat with an expired token. Nothing said so anywhere.
247
+ // It was invisible because the fallback works: 51 of 51 `claude-code` records over the preceding ten
248
+ // days carried a summary, because the SERVER wrote them. A green outcome over a dead mechanism.
249
+ //
250
+ // Two things were silently untrue for however long that lasted:
251
+ // 1. this module's own header — "the cloud then receives only the derived digest, never the raw
252
+ // transcript" — since the fallback ships the (redacted) tail instead;
253
+ // 2. anything downstream of the extraction, including ADR-0055's page proposals, which cannot
254
+ // exist if the call that would make them never returns.
255
+ //
256
+ // A busy lock and an expired credential are not the same event: the first resolves itself on the
257
+ // next session, the second needs a human to run `claude setup-token` and will otherwise never
258
+ // recover. Collapsing them into one silent `return null` is the same defect family as
259
+ // claimRecordForTriage's "probably in the future" — a message that names one cause for many.
260
+ function edgeSkip(reason, detail) {
261
+ // stderr, not stdout: stdout of a Stop hook is not read, and anything written there would land in
262
+ // the transcript of the NEXT capture. Prefixed so `cortex doctor` and a log grep can find it.
263
+ process.stderr.write(`cortex: edge extraction SKIPPED [${reason}] ${detail}\n`)
264
+ return null
265
+ }
266
+
267
+ /**
268
+ * PURE (unit-tested): classify a finished `claude --print` result, or null when it succeeded.
269
+ *
270
+ * Exported so the failure taxonomy is testable without spawning anything — the auth case in
271
+ * particular could not otherwise be covered, and it is the one that matters most.
272
+ */
273
+ export function classifyEdgeFailure(r) {
274
+ if (!r) return { reason: 'no-result', detail: 'spawn returned nothing' }
275
+ // spawn itself failed — almost always ENOENT, i.e. `claude` is not on the hook's PATH. A Stop hook
276
+ // runs with a login shell's PATH, which is not always the interactive one.
277
+ if (r.error) {
278
+ return r.error.code === 'ENOENT'
279
+ ? { reason: 'no-claude', detail: '`claude` is not on PATH for this hook; edge extraction cannot run here' }
280
+ : { reason: 'spawn-failed', detail: r.error.message }
281
+ }
282
+ // timeout: spawnSync kills the child, so status is null and signal is set.
283
+ if (r.signal) return { reason: 'timeout', detail: `killed by ${r.signal} after ${summaryTimeoutMs()}ms` }
284
+
285
+ const out = `${r.stdout ?? ''}\n${r.stderr ?? ''}`
286
+ // ⚠ THE AUTH MESSAGE ARRIVES ON **STDOUT**, WITH EXIT 1 — not on stderr, which is where a reader
287
+ // would look for it. Verified against the live failure 2026-09-08:
288
+ // status 1, stderr "", stdout "Failed to authenticate: OAuth session expired and could not be
289
+ // refreshed". Matching only stderr would classify this as a plain non-zero exit and lose the one
290
+ // fact that tells a human what to do.
291
+ if (/oauth|authenticat|not logged in|401|unauthorized/i.test(out)) {
292
+ return {
293
+ reason: 'auth-expired',
294
+ detail: 'the subscription login for headless `claude --print` is dead. Run `claude setup-token`, '
295
+ + 'then set CLAUDE_CODE_OAUTH_TOKEN in ~/.claude/settings.json (env). '
296
+ + 'Until then EVERY session ships its transcript tail to the server instead of a local digest, '
297
+ + 'and no page proposals are made.',
298
+ }
299
+ }
300
+ if (r.status !== 0) return { reason: 'exit', detail: `claude exited ${r.status}: ${firstLine(out)}` }
301
+ if (!r.stdout) return { reason: 'no-output', detail: 'claude exited 0 with empty stdout' }
302
+ return null
303
+ }
304
+
305
+ function firstLine(s) {
306
+ return String(s ?? '').split('\n').map((l) => l.trim()).filter(Boolean)[0] ?? '(no output)'
307
+ }
308
+
196
309
  // Tolerant JSON extraction: the model may wrap output in prose / ```json fences, so grab the first
197
310
  // balanced-looking {...} span. Arrays pass through untouched — the SERVER validates them.
198
311
  export function parseEdgeJson(out) {
@@ -208,6 +321,10 @@ export function parseEdgeJson(out) {
208
321
  summary: summary.slice(0, 200),
209
322
  people: Array.isArray(o.people) ? o.people : [],
210
323
  namedEntities: Array.isArray(o.namedEntities) ? o.namedEntities : [],
324
+ // ⚠ This function WHITELISTS keys — a key absent here is silently dropped no matter what the
325
+ // model returned and no matter what the prompt asked for. Adding a prompt fragment without
326
+ // adding it here yields a feature that ships, runs, and does nothing.
327
+ pages: Array.isArray(o.pages) ? o.pages : [],
211
328
  }
212
329
  } catch {
213
330
  return null
package/lib/grep_cli.mjs CHANGED
@@ -30,6 +30,9 @@ export function parseGrepArgs(argv = []) {
30
30
  return out
31
31
  }
32
32
 
33
+ // How many [[links]] to print per hit before summarising the rest as "(+N more)".
34
+ export const LINKS_PER_HIT = 8
35
+
33
36
  // Pure: render a /api/grep payload to readable ASCII text.
34
37
  export function formatGrepHits(payload, query) {
35
38
  const hits = (payload && payload.hits) || []
@@ -41,12 +44,27 @@ export function formatGrepHits(payload, query) {
41
44
  ? `No matches for "${query}". If you expected a page here, it may be an unauthored red-link — \`read_page "${query}"\` to triage it (author it, or alias it to an existing page).`
42
45
  : `No matches for "${query}".`
43
46
  }
44
- const lines = [`${hits.length} match${hits.length === 1 ? '' : 'es'} for "${query}":`, '']
47
+ // `total` (server, 2026-09-05) is the pre-cap match count. Say so when the list was truncated:
48
+ // a capped list presented as a complete one is how "nothing else mentions this" gets concluded
49
+ // from a partial answer -- the same failure the per-brain status list exists to prevent.
50
+ const total = typeof payload.total === 'number' ? payload.total : hits.length
51
+ const truncated = total > hits.length
52
+ const header = truncated
53
+ ? `${hits.length} of ${total} matches for "${query}" (best-ranked first; narrow the query or raise max to see the rest):`
54
+ : `${hits.length} match${hits.length === 1 ? '' : 'es'} for "${query}":`
55
+ const lines = [header, '']
45
56
  for (const h of hits) {
46
57
  const head = h.heading ? ` > ${h.heading}` : ''
47
58
  lines.push(`- ${h.title}${head} [${h.tier}]`)
48
59
  if (h.snippet) lines.push(` ${String(h.snippet).replace(/\s+/g, ' ').trim()}`)
49
- if (h.links && h.links.length) lines.push(` -> ${h.links.map((l) => `[[${l}]]`).join(' ')}`)
60
+ // Cap the link list per hit. Median is 2 but the tail reaches 32, and at ~126 chars/hit the
61
+ // link lists were the second-largest cost in the payload after snippets. The count is kept so
62
+ // a trimmed list never reads as the page's complete outbound set.
63
+ if (h.links && h.links.length) {
64
+ const shown = h.links.slice(0, LINKS_PER_HIT)
65
+ const more = h.links.length - shown.length
66
+ lines.push(` -> ${shown.map((l) => `[[${l}]]`).join(' ')}${more > 0 ? ` (+${more} more)` : ''}`)
67
+ }
50
68
  }
51
69
  return lines.join('\n')
52
70
  }
package/lib/server.mjs CHANGED
@@ -10,6 +10,7 @@ import { folderClaimLine } from './folder_claims.mjs'
10
10
  import { resolveSessionKey, resolveLogSessionId } from './session_key.mjs'
11
11
  import { runSendImessage } from './imessage_send.mjs'
12
12
  import { formatGrepHits } from './grep_cli.mjs'
13
+ import { renderCaptureStatus } from './capture_status.mjs'
13
14
  import { renderTriage } from './red_link_triage.mjs'
14
15
  import { runCodeGraphQuery } from './code_graph_cli.mjs'
15
16
  import { repoFullNameFrom } from './capture.mjs'
@@ -230,12 +231,13 @@ function renderNudge(payload) {
230
231
  {
231
232
  title: 'Choose where your session captures are saved',
232
233
  description:
233
- '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.',
234
+ '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. ⚠ A default is PER SOURCE: setting `claude-code` does NOTHING for `email` or `imessage`, which keep staging until each gets its own. If a notice reports held captures, set the source it names.',
234
235
  inputSchema: {
235
236
  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.'),
237
+ source: z.string().optional().describe("which connector's captures this routes, e.g. 'claude-code', 'email', 'imessage', 'cursor'. Defaults to 'claude-code'. Each source routes independently — set the one the held-captures notice names."),
236
238
  },
237
239
  },
238
- async ({ brain }) => {
240
+ async ({ brain, source }) => {
239
241
  const url = `${BASE}/api/brain/capture-default`
240
242
  if (!brain?.trim()) {
241
243
  const res = await fetchCortex(url, { headers: { Authorization: `Bearer ${TOKEN}` } })
@@ -243,17 +245,16 @@ function renderNudge(payload) {
243
245
  const body = await res.text()
244
246
  throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
245
247
  }
246
- const { defaults } = await res.json()
247
- if (!defaults?.length) {
248
- 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.' }] }
249
- }
250
- const lines = defaults.map((d) => `${d.sourceType} captures land in "${d.orgName}" (${d.orgId}), set ${String(d.updatedAt).slice(0, 10)}`)
251
- return { content: [{ type: 'text', text: lines.join('\n') }] }
248
+ return { content: [{ type: 'text', text: renderCaptureStatus(await res.json()) }] }
252
249
  }
253
250
  const res = await fetchCortex(url, {
254
251
  method: 'POST',
255
252
  headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
256
- body: JSON.stringify({ sourceType: 'claude-code', brain: brain.trim() }),
253
+ // The source was pinned to 'claude-code' here and in use-brain from the day both shipped,
254
+ // while the server has always keyed capture_defaults on (auth_id, source_type). The result:
255
+ // the remedy the held-captures notice names could not reach any source but one. Defaulted,
256
+ // not required — the bare call keeps its old meaning exactly.
257
+ body: JSON.stringify({ sourceType: source?.trim() || 'claude-code', brain: brain.trim() }),
257
258
  })
258
259
  const body = await res.text()
259
260
  if (!res.ok) {
@@ -264,7 +265,7 @@ function renderNudge(payload) {
264
265
  throw new Error(msg ?? classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
265
266
  }
266
267
  const j = JSON.parse(body)
267
- 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.` }] }
268
+ return { content: [{ type: 'text', text: `✓ Your ${j.sourceType} captures now land in "${j.brain}" (${j.orgId}). This covers ${j.sourceType} ONLY — every other source keeps its own default and keeps staging until set. Captures from before now are still held and keep their original dates — file those deliberately, they may not all belong in this brain.` }] }
268
269
  },
269
270
  )
270
271
 
@@ -937,15 +938,22 @@ function renderNudge(payload) {
937
938
  const lines = []
938
939
  const sa = body?.sessionAttachments
939
940
  if (sa === null) {
940
- lines.push('⚠ Session-page attachment FAILED for this record — it was written, but not filed under the pages this session wrote. Re-run the reconciler.')
941
+ lines.push('⚠ Session-page attachment FAILED for this record — it was written, but not filed under the pages this session worked on. Re-run the reconciler.')
941
942
  } else if (sa && Array.isArray(sa.attached)) {
942
943
  if (sa.attached.length > 0) {
943
- lines.push(`Filed under ${sa.attached.length} page(s) this session wrote.`)
944
+ // "worked on", not "wrote" — [[ADR-0053]] made this line wrong on 2026-09-08.
945
+ // Attachments now come from two places: pages the session AUTHORED (ADR-0032, via
946
+ // page_revisions.session_key) and pages it merely OPENED (ADR-0053, via read_page). The
947
+ // server returns one flat `attached` list and does not say which is which, so this cannot
948
+ // report the split without a server change — but it must not assert the narrower of the two.
949
+ // Observed live the day ADR-0053 shipped: a session that authored nothing and only read
950
+ // [[Travis Peterson]] was told "Filed under 1 page(s) this session wrote."
951
+ lines.push(`Filed under ${sa.attached.length} page(s) this session worked on.`)
944
952
  } else {
945
- lines.push('No pages filed: this session authored none in this brain (the normal case).')
953
+ lines.push('No pages filed: this session neither wrote nor opened a page in this brain (the normal case).')
946
954
  }
947
955
  if (sa.droppedCrossBrain > 0) {
948
- lines.push(`${sa.droppedCrossBrain} page(s) this session wrote live in another brain and were skipped — a record belongs to one brain.`)
956
+ lines.push(`${sa.droppedCrossBrain} page(s) this session worked on live in another brain and were skipped — a record belongs to one brain.`)
949
957
  }
950
958
  }
951
959
  // Containment carried from capture. Three states, and they are not interchangeable:
@@ -1217,14 +1225,16 @@ function renderNudge(payload) {
1217
1225
  {
1218
1226
  title: 'Grep the brain wiki',
1219
1227
  description:
1220
- 'Ranked keyword search across your visible brain wiki pages. Multi-word natural-language queries work (results are ranked by relevance). Pass mode:"substring" for an exact literal match of symbols, identifiers, or [[links]]. Returns matching sections with a context snippet and their outbound [[links]].',
1228
+ 'Ranked keyword search across your visible brain wiki pages. Multi-word natural-language queries work (results are ranked by relevance). Pass mode:"substring" for an exact literal match of symbols, identifiers, or [[links]] — and for an English STOPWORD ("own", "not"), which ranked search drops. Returns matching sections with a context snippet and their outbound [[links]]. A broad query fans out across every brain you can see, so pass `max` when you only need to locate a page; the header always says how many matched in total, so a capped list is never mistaken for the whole answer.',
1221
1229
  inputSchema: {
1222
1230
  query: z.string().describe('search terms (natural language is fine)'),
1223
1231
  mode: z.enum(['substring', 'fts']).optional().describe("'fts' (default, ranked keyword) or 'substring' (exact literal — for identifiers / [[links]] / code)"),
1232
+ max: z.number().int().optional().describe('how many matches to return, best-ranked first (default 50, max 200). Lower it when you only need to LOCATE a page; a broad query across many brains can otherwise return well over a hundred matches in one call.'),
1224
1233
  },
1225
1234
  },
1226
- async ({ query, mode }) => {
1235
+ async ({ query, mode, max }) => {
1227
1236
  const qs = new URLSearchParams({ q: query, mode: mode === 'substring' ? 'substring' : 'fts' })
1237
+ if (max) qs.set('max', String(max))
1228
1238
  const res = await fetchCortex(`${BASE}/api/grep?${qs.toString()}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
1229
1239
  if (!res.ok) {
1230
1240
  const body = await res.text()
@@ -1313,7 +1323,7 @@ function renderNudge(payload) {
1313
1323
  '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.',
1314
1324
  inputSchema: {
1315
1325
  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'),
1316
- kind: z.enum(['project', 'person', 'org', 'user']).optional().describe('node kind (default project; pass person/org for people/teams)'),
1326
+ kind: z.enum(['project', 'person', 'org', 'user']).optional().describe('node kind a PREFERENCE, not a filter. The lookup matches every kind by title and only prefers this one when several share a name, so omitting it never hides a person/org/user page. Pass it to disambiguate a name that exists as more than one kind.'),
1317
1327
  expand: z.boolean().optional().describe('identifier names only: also list recent visible timeline events for this identifier (default: home + count)'),
1318
1328
  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.'),
1319
1329
  version: z.string().optional().describe('read a HISTORICAL version of this page instead of the current one: a rev_no (e.g. "3") or a content_hash from page_history. Use page_history first to see the versions, then rollback_page to restore one.'),
@@ -1445,7 +1455,16 @@ function renderNudge(payload) {
1445
1455
  matches = [{ brain: null, authored: true, ref: page.ref, title: page.title, tiers: page.tiers }]
1446
1456
  }
1447
1457
  if (!matches.length) {
1448
- return { content: [{ type: 'text', text: `No authored ${k} page named "${name}" in any of your brains. If it's a person or team, pass kind (person/org).${await redLinkTriage(BASE, TOKEN, name)}` }] }
1458
+ // This used to say: `No authored ${k} page named "X" ... If it's a person or team, pass
1459
+ // kind (person/org).` Both halves misled. resolveNodeByTitle matches EVERY kind and uses the
1460
+ // requested one only as an ORDER BY preference (resolve_node.ts: `order by (bd.engram_kind =
1461
+ // ${kind}) desc, ...`), so (a) naming the kind implies a kind-scoped search that never
1462
+ // happened, and (b) retrying with kind:'person' re-runs the identical query and returns the
1463
+ // identical nothing. That is worse than unhelpful: it hands the caller an experiment that
1464
+ // cannot disconfirm "the page does not exist", so the empty retry reads as CORROBORATION.
1465
+ // Two sessions ran that loop and reported read_page false negatives that did not exist.
1466
+ // Say what was actually searched, and point at a check that can actually come back different.
1467
+ return { content: [{ type: 'text', text: `No authored page named "${name}" — of ANY kind (project/person/org/user), in any brain you are an active member of. Passing \`kind\` will not change this: kind only orders the candidates, it does not narrow the search. If you expected a page here, the name may differ (\`grep "${name}"\` finds pages that MENTION it, and \`mode:"substring"\` matches an exact literal), or it may be an alias.${await redLinkTriage(BASE, TOKEN, name)}` }] }
1449
1468
  }
1450
1469
  const day = (d) => (d ? String(d).slice(0, 10) : '')
1451
1470
  const renderMatch = (m, tagBrain) => {
@@ -3398,14 +3417,35 @@ function renderNudge(payload) {
3398
3417
  if (view === 'sweep') {
3399
3418
  const cands = out?.candidates ?? []
3400
3419
  if (cands.length === 0) {
3401
- return { content: [{ type: 'text', text: `No page-name candidates for "${out?.title ?? recordId}". Park it a stale record with no match is not worth reading pages over.` }] }
3420
+ // A NULL RESULT MEANS "NO TITLE OVERLAP", NOT "NO HOME". Measured 2026-09-05 across four
3421
+ // naive agents working the backlog: this line recommended parking 4/5, 5/6, 6/6 and 4/5
3422
+ // records, and was WRONG almost every time — the records had correct homes that simply did
3423
+ // not share words with their subject lines. One agent said it plainly: "I'd treat sweep as
3424
+ // a hint, never as the recommendation it phrases itself as." So it no longer phrases itself
3425
+ // as one. This matcher compares TITLES; it cannot see a page whose subject is the record's
3426
+ // subject under a different name.
3427
+ return { content: [{ type: 'text', text:
3428
+ `No page-title overlap for "${out?.title ?? recordId}".\n\n`
3429
+ + `⚠ That means this matcher found nothing — NOT that no home exists. It compares titles only, so it is blind\n`
3430
+ + `whenever the right page is named differently from the record. Measured 2026-09-05: it recommended parking\n`
3431
+ + `records that were already correctly filed, in four sessions out of four.\n\n`
3432
+ + `If you have context on this record, use it — search_org or read_page will beat this matcher.\n`
3433
+ + `Park only once YOU have looked and found nothing.` }] }
3402
3434
  }
3403
3435
  const lines = cands.map((c) => ` ${c.strength === 'strong' ? '●' : '○'} ${c.title} (${c.strength}) — ${c.documentId}`)
3404
3436
  const rec = out?.recommendation
3405
3437
  return {
3406
3438
  content: [{
3407
3439
  type: 'text',
3408
- text: `Candidates for "${out?.title}":\n${lines.join('\n')}\n\nRecommended: ${rec?.action} ${rec?.why}\n● strong = titles contain each other, safe to route. weak = one generic word matched; read those pages ONLY if this record is worth the tokens, otherwise park.`,
3440
+ // "Recommended" overstated what a title match knows. Renamed to what it actually is
3441
+ // the matcher's own reading — and the weak rung no longer ends in "otherwise park",
3442
+ // which is the phrasing that pushed agents to park correctly-homed records.
3443
+ text: `Candidates for "${out?.title}":\n${lines.join('\n')}\n\n`
3444
+ + `Title matcher says: ${rec?.action} — ${rec?.why}\n`
3445
+ + `● strong = titles contain each other, safe to route. ○ weak = one generic word matched.\n`
3446
+ + `⚠ This ranks TITLE OVERLAP ONLY. A page whose subject is this record's subject under a different name\n`
3447
+ + `scores nothing here. Absence of candidates is not evidence of absence of a home — if you have context,\n`
3448
+ + `trust it over this list.`,
3409
3449
  }],
3410
3450
  }
3411
3451
  }
package/lib/use_brain.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { fetchCortex, classify, resolveBase } from './diagnose.mjs'
2
2
  import { resolveToken } from './doctor.mjs'
3
+ import { renderCaptureStatus } from './capture_status.mjs'
3
4
 
4
5
  // `use-brain` — set (or show) which brain this machine's unattended session captures land in.
5
6
  //
@@ -14,6 +15,32 @@ import { resolveToken } from './doctor.mjs'
14
15
 
15
16
  function out(m) { process.stdout.write(m + '\n') }
16
17
 
18
+ /** Split `--source <type>` / `--source=<type>` out of the argv, leaving the brain words.
19
+ *
20
+ * WHY THIS EXISTS. The POST body pinned `sourceType: 'claude-code'` from the day this command
21
+ * shipped, while the server has always keyed capture_defaults on (auth_id, source_type). So a
22
+ * person told by the SessionStart notice that N captures were being held could run exactly the
23
+ * command the notice named, watch it succeed, and fix only their claude-code captures — every
24
+ * `email` row kept staging and the warning kept firing. Measured 2026-09-08: one account had 52
25
+ * email records held for 10 days behind a correctly-followed remedy that could not reach them.
26
+ *
27
+ * `--source` DEFAULTS rather than being required: the bare form is what the notice, the docs and
28
+ * two months of muscle memory all say, and breaking it to fix this would trade one silent failure
29
+ * for a loud one. The old invocation keeps its old meaning exactly. */
30
+ export function parseUseBrainArgs(argv) {
31
+ const words = []
32
+ let source = 'claude-code'
33
+ let sawFlag = false
34
+ for (let i = 0; i < (argv ?? []).length; i++) {
35
+ const a = argv[i]
36
+ if (a === '--source') { source = (argv[++i] ?? '').trim(); sawFlag = true; continue }
37
+ if (a.startsWith('--source=')) { source = a.slice('--source='.length).trim(); sawFlag = true; continue }
38
+ if (a.startsWith('--')) continue
39
+ words.push(a)
40
+ }
41
+ return { source, brain: words.join(' ').trim(), sawFlag }
42
+ }
43
+
17
44
  export async function runUseBrain(args) {
18
45
  const base = resolveBase(process.env.CORTEX_URL)
19
46
  const { token } = resolveToken()
@@ -22,10 +49,14 @@ export async function runUseBrain(args) {
22
49
  return 1
23
50
  }
24
51
 
25
- // Everything after the subcommand is the brain, joined — so an unquoted multi-word name still
52
+ // Everything that is not a flag is the brain, joined — so an unquoted multi-word name still
26
53
  // works. `use-brain Real estate` is what a person actually types; refusing it over a missing pair
27
54
  // of quotes would be the same species of unhelpfulness this command exists to remove.
28
- const wanted = (args ?? []).filter((a) => !a.startsWith('--')).join(' ').trim()
55
+ const { source, brain: wanted, sawFlag } = parseUseBrainArgs(args)
56
+ if (sawFlag && !source) {
57
+ out('Agnoclast: --source needs a value, e.g. --source email')
58
+ return 1
59
+ }
29
60
  const url = `${base}/api/brain/capture-default`
30
61
 
31
62
  if (!wanted) {
@@ -38,14 +69,11 @@ export async function runUseBrain(args) {
38
69
  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
70
  return 1
40
71
  }
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})`)
72
+ // One renderer, shared with the `set_capture_brain` MCP tool — see capture_status.mjs for why
73
+ // these two answers must not be written twice.
74
+ const rendered = renderCaptureStatus(await res.json(), 'cli').split('\n')
75
+ out(`Agnoclast: ${rendered[0]}`)
76
+ for (const line of rendered.slice(1)) out(line)
49
77
  return 0
50
78
  } catch (e) {
51
79
  out(`Agnoclast: could not reach the server (${e?.message ?? String(e)})`)
@@ -57,7 +85,7 @@ export async function runUseBrain(args) {
57
85
  const res = await fetchCortex(url, {
58
86
  method: 'POST',
59
87
  headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
60
- body: JSON.stringify({ sourceType: 'claude-code', brain: wanted }),
88
+ body: JSON.stringify({ sourceType: source, brain: wanted }),
61
89
  })
62
90
  const body = await res.text()
63
91
  if (!res.ok) {
@@ -69,10 +97,14 @@ export async function runUseBrain(args) {
69
97
  return 1
70
98
  }
71
99
  const j = JSON.parse(body)
72
- out(`Agnoclast: your Claude Code sessions now land in "${j.brain}".`)
100
+ // Name the SOURCE this actually set. "your Claude Code sessions now land in X" was printed
101
+ // verbatim after setting an `email` default too, because the source was pinned — so the one
102
+ // line that could have revealed the pin instead concealed it.
103
+ out(`Agnoclast: ✓ your ${j.sourceType} captures now land in "${j.brain}".`)
73
104
  // Say plainly what this does NOT do. The setter's own server-side note makes the same point,
74
105
  // 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.')
106
+ out(` Only ${j.sourceType} every other connector keeps its own default (use --source).`)
107
+ out(' Captures from BEFORE now are still held — they keep their original dates until sorted.')
76
108
  out(' Ask your assistant to file them (they may not all belong in the same brain).')
77
109
  return 0
78
110
  } catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.136",
3
+ "version": "0.9.138",
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": {