@theronap/cortex-mcp 0.9.123 → 0.9.124

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/diagnose.mjs CHANGED
@@ -155,6 +155,7 @@ export function classify(status, contentType, bodyText, requestId) {
155
155
  let appHint = null
156
156
  let appMessage = null
157
157
  let appBrains = null
158
+ let appCandidates = null
158
159
  if (isJson) {
159
160
  try {
160
161
  const parsed = JSON.parse(bodyText)
@@ -166,6 +167,9 @@ export function classify(status, contentType, bodyText, requestId) {
166
167
  // and could not answer it. Measured on the CLI twin of this bug: `✗ brain_required`, full stop.
167
168
  appMessage = typeof parsed?.message === 'string' && parsed.message.trim() ? parsed.message.trim() : null
168
169
  appBrains = Array.isArray(parsed?.brains) ? parsed.brains : null
170
+ // ambiguous_brain carries the COLLIDING subset separately. Prefer it: listing all of
171
+ // someone's brains when only two share the name buries the answer in the noise.
172
+ appCandidates = Array.isArray(parsed?.candidates) ? parsed.candidates : null
169
173
  // `hint` carries the RECOVERY instruction for the errors an agent is meant to act on, not
170
174
  // just report: 409 would_drop ("re-author including the dropped sections…") and 413 too_large
171
175
  // ("split the section…"). It used to be dropped here — only `error` survived — so the agent
@@ -193,21 +197,32 @@ export function classify(status, contentType, bodyText, requestId) {
193
197
  }
194
198
  }
195
199
  // A brain-choice refusal is ANSWERABLE, so it must arrive as the question it is rather than as a
196
- // code. Deliberately narrow: only these two errors reshape the message, so every other classify()
200
+ // code. Deliberately narrow: only these three errors reshape the message, so every other classify()
197
201
  // output keeps its existing wording (and its tests).
198
- if (isJson && (appError === 'brain_required' || appError === 'unknown_brain') && (appMessage || appBrains)) {
199
- const list = (appBrains ?? []).map((b) => {
202
+ //
203
+ // `ambiguous_brain` was added 2026-08-28 WITH the server change that first emits it. Shipping that
204
+ // server change alone would have been a net REGRESSION here: the fallthrough below renders
205
+ // `appError`, not `appMessage`, so a duplicate brain name would have reached the agent as the bare
206
+ // string "Agnoclast API 409: ambiguous_brain." — strictly less actionable than the unknown_brain it
207
+ // replaced, which this branch already caught. Same defect the CHANGELOG records for `brain_required`.
208
+ const isBrainChoice = appError === 'brain_required' || appError === 'unknown_brain' || appError === 'ambiguous_brain'
209
+ if (isJson && isBrainChoice && (appMessage || appBrains || appCandidates)) {
210
+ // `?.length`, not `??`: an empty candidates array is not nullish, so `??` would have kept it
211
+ // and rendered a refusal that says "the candidates below" above nothing at all.
212
+ const list = (appCandidates?.length ? appCandidates : (appBrains ?? [])).map((b) => {
200
213
  const pages = typeof b?.pageCount === 'number' ? ` — ${b.pageCount} page${b.pageCount === 1 ? '' : 's'}` : ''
201
214
  const titles = Array.isArray(b?.sampleTitles) && b.sampleTitles.length
202
215
  ? `: ${b.sampleTitles.slice(0, 2).join('; ')}` : ''
203
216
  // Name AND id: brain names are NOT unique (one account holds two called "Personal"), so a name
204
217
  // alone can come back as unknown_brain. The id always resolves.
205
- return ` - ${b?.name ?? '(unnamed)'}${pages}${titles}${b?.orgId ? ` [${b.orgId}]` : ''}`
218
+ return ` - ${b?.name ?? b?.brain ?? '(unnamed)'}${pages}${titles}${b?.orgId ? ` [${b.orgId}]` : ''}`
206
219
  })
207
220
  return {
208
221
  kind: 'app', retriable: false,
209
222
  message: `Agnoclast API ${status}: ${appMessage ?? appError}${list.length ? `\n${list.join('\n')}` : ''}` +
210
- `\nRe-run this tool with \`brain\` set to one of the names or ids above.${rid}`,
223
+ `\n${appError === 'ambiguous_brain'
224
+ ? 'Re-run this tool with `brain` set to one of the IDS above. A name is what was ambiguous, so passing a name again cannot resolve it.'
225
+ : 'Re-run this tool with `brain` set to one of the names or ids above.'}${rid}`,
211
226
  }
212
227
  }
213
228
 
package/lib/resolve.mjs CHANGED
@@ -31,7 +31,7 @@ export async function brainsToSweep(base, token, wanted, deps = {}) {
31
31
  const j = await res.json().catch(() => ({}))
32
32
  const brains = Array.isArray(j.brains) ? j.brains : []
33
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.
34
+ // "Personal"), and a duplicate name comes back as ambiguous_brain (409).
35
35
  return brains.filter((b) => b?.orgId).map((b) => ({ orgId: b.orgId, name: b.name ?? b.orgId }))
36
36
  }
37
37
 
package/lib/server.mjs CHANGED
@@ -324,9 +324,14 @@ function renderNudge(payload) {
324
324
  }
325
325
 
326
326
  // Records are unique on the ORG-SCOPED (org_id, dedupe_key) and every segment carries this
327
- // session's dedupe key. Cross-brain segments therefore never collide that constraint is what
328
- // makes the whole design work — but two aimed at the SAME brain would silently merge and lose
329
- // one. Refuse instead of letting that happen quietly.
327
+ // session's dedupe key, so cross-brain segments never collide AS RECORDS.
328
+ //
329
+ // That is only half the path, and reading it as the whole path is what broke segmenting for
330
+ // twelve days. Upstream of records sits a private-intake unit keyed per ACCOUNT, which the
331
+ // org-scoping does not help at all — see `segmentKey` below. Records were never the problem.
332
+ //
333
+ // Two segments aimed at the SAME brain would still silently merge and lose one, at both
334
+ // layers. Refuse instead of letting that happen quietly.
330
335
  const seenBrain = new Set()
331
336
  for (const s of list) {
332
337
  const k = String(s.brain ?? '').trim().toLowerCase()
@@ -371,6 +376,13 @@ function renderNudge(payload) {
371
376
  ...(seg.project ? { project: seg.project } : {}),
372
377
  ...(seg.title ? { title: seg.title } : {}),
373
378
  ...(sessionId ? { sessionId } : {}),
379
+ // ONE session split across brains: every segment carries the SAME `sessionId` on
380
+ // purpose — it is the only thing pairing them. But a private-intake unit is keyed per
381
+ // ACCOUNT, not per brain, so without a discriminator all segments collapse onto ONE
382
+ // unit: the first materializes and the rest fail `materialize_failed`. Sent ONLY when
383
+ // actually segmenting, so a single-brain log keeps its exact previous key and no
384
+ // pending unit is orphaned.
385
+ ...(list.length > 1 && seg.brain ? { segmentKey: String(seg.brain) } : {}),
374
386
  // ADR-0022 deleted the write pointer, so a session-class source routes ONLY by an
375
387
  // explicit brain or a sole membership — anything else STAGES. This tool never sent one,
376
388
  // so every close-out from a multi-brain member landed in staged_records instead of the
@@ -2605,6 +2617,57 @@ function renderNudge(payload) {
2605
2617
  },
2606
2618
  )
2607
2619
 
2620
+ server.registerTool(
2621
+ 'rename_page',
2622
+ {
2623
+ title: 'Give a page a correct name',
2624
+ description: "Change the TITLE of an existing page, keeping the page itself — every record, attachment, routing claim, grant and revision on it survives untouched, because they all point at the node id and nothing anywhere points at a name. Use it on the stub names automatic minting produces from an email localpart or a display-name header: `Bna2005` -> `Brandon Andersen`, `Theriv` -> `The Riviera`, `Kentgee` -> `Kent Gee`. NOT COSMETIC — a stub carrying a bad name will not dedupe against the real person when they are authored properly, so renaming it is how you PREVENT the duplicate that would otherwise have to be merged later. The old name is kept as an alias automatically, so existing [[links]] and readers who know the old name still resolve. Renaming ONTO a name another page already holds is refused, naming the holder: combining two pages is a merge decision, not a rename. `user` pages cannot be renamed (that is an account identity).",
2625
+ inputSchema: {
2626
+ kind: z.enum(['project', 'person', 'org']).describe('the page kind'),
2627
+ name: z.string().optional().describe('current page title (or pass ref)'),
2628
+ ref: z.string().optional().describe('node ref from read_page — prefer over name when available, and REQUIRED when the current name is ambiguous across brains'),
2629
+ brain: z.string().optional().describe('brain label when the title is ambiguous across brains'),
2630
+ new_name: z.string().describe('the correct title, written the way it should read on the page'),
2631
+ },
2632
+ },
2633
+ async ({ kind, name, ref, brain, new_name }) => {
2634
+ if (!name && !ref) return toolError('Pass the page by `name` or `ref`.')
2635
+ let res
2636
+ try {
2637
+ res = await fetchCortex(`${BASE}/api/brain/rename-page`, {
2638
+ method: 'POST',
2639
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
2640
+ body: JSON.stringify({ kind, newName: new_name, ...(name ? { name } : {}), ...(ref ? { ref } : {}), ...(brain ? { brain } : {}) }),
2641
+ })
2642
+ } catch (e) {
2643
+ return toolError(`Could not rename: ${e.message}`)
2644
+ }
2645
+ const out = await res.json().catch(() => null)
2646
+ if (!res.ok) {
2647
+ // An ambiguous name is the case most likely to hit here, because a stub name that needs
2648
+ // fixing is exactly the kind of name that got minted more than once. Hand back the refs.
2649
+ if (out?.error === 'ambiguous_page' && Array.isArray(out.candidates)) {
2650
+ const rows = out.candidates.map((c) => ` • ${c.brain} — ref ${c.ref}`).join('\n')
2651
+ return toolError(`"${name}" names a page in more than one brain. Re-run with the ref of the one you mean:\n${rows}`)
2652
+ }
2653
+ if (out?.error === 'name_taken') {
2654
+ return toolError(`Not renamed — ${out.detail ?? `"${new_name}" is already held by another page`}. Renaming onto it would silently combine two pages; request a merge instead.`)
2655
+ }
2656
+ if (out?.error === 'unchanged') return toolError(`Not renamed — ${out.detail ?? 'that is the same name'}.`)
2657
+ if (out?.error === 'kind_not_renamable') return toolError(`Not renamed — ${out.detail ?? 'that page kind cannot be renamed'}.`)
2658
+ return toolError(`Could not rename: ${out?.detail ?? out?.error ?? res.status}`)
2659
+ }
2660
+ if (!out) return { content: [{ type: 'text', text: `Renamed to "${new_name}", but the server returned no body — re-read the page to confirm.` }] }
2661
+ // Report what the server OBSERVED. `recordsCarried` is the number that answers the question a
2662
+ // caller actually has about a rename ("did the page keep its stuff?"), so it is never omitted —
2663
+ // including when it is 0, which is the truth for a freshly minted stub.
2664
+ const lines = [`Renamed "${out.from}" -> "${out.to}" in ${out.brain} (${out.kind}, ref ${out.ref}).`]
2665
+ lines.push(`${out.recordsCarried} record(s) came along; ${out.documentsRetitled} tier-doc(s) retitled.`)
2666
+ lines.push(`"${out.aliasWritten}" still resolves to this page, so existing [[links]] are not broken.`)
2667
+ return { content: [{ type: 'text', text: lines.join(' ') }] }
2668
+ },
2669
+ )
2670
+
2608
2671
  server.registerTool(
2609
2672
  'unalias_page',
2610
2673
  {
@@ -2795,20 +2858,62 @@ function renderNudge(payload) {
2795
2858
  reason: z.string().describe('why these pages — recorded with the attachment'),
2796
2859
  park: z.boolean().optional().describe('no good home exists; leave it alone rather than guessing'),
2797
2860
  tier: z.number().int().optional().describe('2 when this came from the cheap sweep rather than your own context'),
2861
+ identifierDispositions: z.array(z.any()).optional().describe(
2862
+ 'ADR-0044. REQUIRED when this record carries identifiers no page claims — the refusal names exactly which. '
2863
+ + 'One entry per unresolved identifier, and this is a DIFFERENT question from documentIds. '
2864
+ + '⚠ AN IDENTIFIER BELONGS ON THE PAGE WHOSE SUBJECT *HAS* IT — a person page for their address, a project '
2865
+ + "page for that project's repo. Association is not attribution: a person who WORKS ON a repo does not get "
2866
+ + 'the repo identifier, and a project does not get a contributor\'s email. So the right page is FREQUENTLY '
2867
+ + 'NOT one of the pages you are routing to. Routing asks what this RECORD is about (several pages); this '
2868
+ + 'asks what each IDENTIFIER is an attribute of (one page). '
2869
+ + 'Forms: {identifier, page} to claim on an existing page (id or ref) · '
2870
+ + '{identifier, createPage:{name, kind, brain}} when the thing has no page yet — the usual case for a new '
2871
+ + 'correspondent, and brain is required because pages cannot move between brains · '
2872
+ + '{none:true, identifiers:[...], reason} when they are attributes of nothing worth a page. '
2873
+ + '`none` takes MANY at once by design — a mailing list can carry 190 recipients and disposing of them one '
2874
+ + 'by one would make the record impossible to finish. It SUPPRESSES NOTHING: the same address arriving '
2875
+ + 'tomorrow is asked about again, and the record itself is still filed either way.',
2876
+ ),
2798
2877
  },
2799
2878
  },
2800
- async ({ recordId, documentIds, reason, park, tier }) => {
2879
+ async ({ recordId, documentIds, reason, park, tier, identifierDispositions }) => {
2801
2880
  let res
2802
2881
  try {
2803
2882
  res = await fetchCortex(`${BASE}/api/brain/triage`, {
2804
2883
  method: 'POST',
2805
2884
  headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
2806
- body: JSON.stringify({ action: park ? 'park' : 'route', recordId, documentIds, reason, tier }),
2885
+ body: JSON.stringify({ action: park ? 'park' : 'route', recordId, documentIds, reason, tier, identifierDispositions }),
2807
2886
  })
2808
2887
  } catch (e) {
2809
2888
  return toolError(`Could not route record: ${e.message}`)
2810
2889
  }
2811
2890
  const out = await res.json().catch(() => null)
2891
+ // ADR-0044 refusal. Render it as INSTRUCTIONS, not as an error string: the agent is one call
2892
+ // away from succeeding and needs to know which identifiers and what the rule is. A bare
2893
+ // "identifiers_undisposed" would send it hunting through docs for a contract it was never told.
2894
+ if (!res.ok && out?.error === 'identifiers_undisposed') {
2895
+ const lines = [
2896
+ 'Cannot finish — this record carries identifiers that no page claims:',
2897
+ ...(out.undisposed ?? []).map((i) => ` ${i}`),
2898
+ '',
2899
+ 'Each needs a disposition. An identifier belongs on the page whose SUBJECT HAS it — a person',
2900
+ 'page for their address, a project page for that project\'s repo. Association is not',
2901
+ 'attribution, so this is often NOT a page you are routing the record to.',
2902
+ '',
2903
+ ' {identifier, page} claim on an existing page',
2904
+ ' {identifier, createPage:{name, kind, brain}} the thing has no page yet',
2905
+ ' {none:true, identifiers:[...], reason} attributes of nothing worth a page',
2906
+ '',
2907
+ '`none` takes many at once and suppresses nothing — tomorrow\'s copy is asked again.',
2908
+ ]
2909
+ if (out.claimedSoFar?.length) {
2910
+ lines.push('', `Already claimed this call: ${out.claimedSoFar.map((c) => `${c.identifier} -> ${c.page}`).join(', ')}`)
2911
+ }
2912
+ if (out.errors?.length) {
2913
+ lines.push('', `Refused: ${out.errors.map((e) => `${e.identifier} (${e.error})`).join(', ')}`)
2914
+ }
2915
+ return toolError(lines.join('\n'))
2916
+ }
2812
2917
  if (!res.ok) return toolError(`Could not route record: ${out?.error ?? res.status}${out?.detail ? ` — ${out.detail}` : ''}`)
2813
2918
  if (park) return { content: [{ type: 'text', text: 'Parked. It stays visible and unrouted rather than badly attached.' }] }
2814
2919
  return { content: [{ type: 'text', text: `Routed — attached to ${out?.attached?.length ?? 0} page(s). Recorded as a session judgment, not a rule match.` }] }
package/lib/skills.mjs CHANGED
@@ -320,16 +320,25 @@ export async function syncOrgSkills(opts = {}) {
320
320
  // have, or what to type next. Pure + exported so the shape is unit-testable without a network.
321
321
  export function renderPushError(body, status) {
322
322
  const lines = [body?.message ?? body?.error ?? `HTTP ${status}`]
323
- const brains = Array.isArray(body?.brains) ? body.brains : []
323
+ const ambiguous = body?.error === 'ambiguous_brain'
324
+ // Prefer the COLLIDING subset. On an ambiguity the full list buries the answer: two rows reading
325
+ // "Personal" among five brains is not a choice anyone can make.
326
+ const candidates = Array.isArray(body?.candidates) ? body.candidates : []
327
+ const brains = candidates.length ? candidates : (Array.isArray(body?.brains) ? body.brains : [])
324
328
  if (brains.length) {
325
- lines.push('', ' Your brains:')
329
+ lines.push('', ambiguous ? ' Brains matching that name:' : ' Your brains:')
326
330
  for (const b of brains) {
327
331
  const pages = typeof b?.pageCount === 'number' ? ` — ${b.pageCount} page${b.pageCount === 1 ? '' : 's'}` : ''
328
332
  const titles = Array.isArray(b?.sampleTitles) && b.sampleTitles.length
329
333
  ? `: ${b.sampleTitles.slice(0, 2).join('; ')}` : ''
330
- lines.push(` ${b?.name ?? b?.orgId ?? '(unnamed)'}${pages}${titles}`)
334
+ // ALWAYS print the id. Without it an ambiguity refusal is a dead end: every row reads the same,
335
+ // and the only address that separates them is the one this line was omitting.
336
+ const id = b?.orgId ? ` [${b.orgId}]` : ''
337
+ lines.push(` ${b?.name ?? b?.brain ?? '(unnamed)'}${pages}${titles}${id}`)
331
338
  }
332
- lines.push('', ' Re-run with --brain "<name>".')
339
+ lines.push('', ambiguous
340
+ ? ' Re-run with --brain "<id>" — a name is what was ambiguous, so a name cannot resolve it.'
341
+ : ' Re-run with --brain "<name>".')
333
342
  }
334
343
  return lines.join('\n')
335
344
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.123",
3
+ "version": "0.9.124",
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": {