@theronap/cortex-mcp 0.9.36 → 0.9.38

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.
Files changed (2) hide show
  1. package/lib/server.mjs +149 -31
  2. package/package.json +1 -1
package/lib/server.mjs CHANGED
@@ -167,21 +167,27 @@ export async function runServer(version) {
167
167
  const body = await res.text()
168
168
  throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
169
169
  }
170
- const { people = [], records = [] } = await res.json()
170
+ const { people = [], records = [], brains = [] } = await res.json()
171
171
  if (!people.length && !records.length) return { content: [{ type: 'text', text: `No visible results for "${query}".` }] }
172
+ // Multi-brain: tag each hit with its brain only when results span more than one (single-brain
173
+ // callers see no noise). `brains` also carries any brain that errored (fail-soft).
174
+ const multi = brains.filter((b) => b.ok).length > 1
175
+ const tag = (brain) => (multi && brain ? ` · ${brain}` : '')
172
176
  const lines = []
173
177
  if (people.length) {
174
178
  lines.push('People:')
175
179
  for (const p of people) {
176
180
  const meta = [p.title, p.company].filter(Boolean).join(', ')
177
- lines.push(`- ${p.name}${meta ? ` (${meta})` : ''} — mentioned in ${p.mentions} record${p.mentions === 1 ? '' : 's'}`)
181
+ lines.push(`- ${p.name}${meta ? ` (${meta})` : ''} — mentioned in ${p.mentions} record${p.mentions === 1 ? '' : 's'}${tag(p.brain)}`)
178
182
  }
179
183
  }
180
184
  if (records.length) {
181
185
  if (lines.length) lines.push('')
182
186
  lines.push('Activity:')
183
- for (const r of records) lines.push(`- [${r.source}] ${r.title}${r.project ? ` (${r.project})` : ''}`)
187
+ for (const r of records) lines.push(`- [${r.source}] ${r.title}${r.project ? ` (${r.project})` : ''}${tag(r.brain)}`)
184
188
  }
189
+ const failed = brains.filter((b) => !b.ok)
190
+ if (failed.length) lines.push('', `(couldn't reach ${failed.length} brain${failed.length === 1 ? '' : 's'}: ${failed.map((b) => b.name).join(', ')})`)
185
191
  return { content: [{ type: 'text', text: `Results for "${query}":\n${lines.join('\n')}` }] }
186
192
  },
187
193
  )
@@ -224,14 +230,24 @@ export async function runServer(version) {
224
230
  { headers: { Authorization: `Bearer ${TOKEN}` } })
225
231
  if (res.ok) {
226
232
  const page = await res.json()
227
- if (page?.authored && Array.isArray(page.tiers) && page.tiers.length) {
233
+ // Multi-brain: /api/brain/page returns matches[] (one per brain that has the page).
234
+ let matches = Array.isArray(page?.matches) ? page.matches.filter((m) => m.authored && Array.isArray(m.tiers) && m.tiers.length) : []
235
+ // Rollout back-compat: adapt an older server's single-page shape to one match.
236
+ if (!matches.length && page?.authored && Array.isArray(page.tiers) && page.tiers.length) {
237
+ matches = [{ brain: null, authored: true, title: page.title, tiers: page.tiers }]
238
+ }
239
+ if (matches.length) {
228
240
  const day = (d) => (d ? String(d).slice(0, 10) : '')
229
- const blocks = page.tiers.map((t) => {
230
- const secs = (t.sections ?? []).map((s) => `### ${s.heading}\n${s.body}`).join('\n\n')
231
- const head = `[${t.tier}${day(t.updated_at) ? ` · authored ${day(t.updated_at)}` : ''}${t.validity && t.validity !== 'current' ? ` · ${t.validity}` : ''}]`
232
- return [head, t.summary, secs].filter(Boolean).join('\n')
233
- })
234
- return { content: [{ type: 'text', text: `# ${page.title ?? key} (authored page)\n\n${blocks.join('\n\n---\n\n')}` }] }
241
+ const renderMatch = (m) => {
242
+ const blocks = m.tiers.map((t) => {
243
+ const secs = (t.sections ?? []).map((s) => `### ${s.heading}\n${s.body}`).join('\n\n')
244
+ const head = `[${t.tier}${day(t.updated_at) ? ` · authored ${day(t.updated_at)}` : ''}${t.validity && t.validity !== 'current' ? ` · ${t.validity}` : ''}]`
245
+ return [head, t.summary, secs].filter(Boolean).join('\n')
246
+ })
247
+ const brainTag = matches.length > 1 ? ` · brain: ${m.brain}` : ''
248
+ return `# ${m.title ?? key} (authored page${brainTag})\n\n${blocks.join('\n\n---\n\n')}`
249
+ }
250
+ return { content: [{ type: 'text', text: matches.map(renderMatch).join('\n\n═══\n\n') }] }
235
251
  }
236
252
  }
237
253
  } catch { /* fall through to the records-derived line */ }
@@ -253,10 +269,28 @@ export async function runServer(version) {
253
269
  kind: z.enum(['project', 'person', 'org', 'user']).optional().describe('node kind (default project; pass person/org for people/teams)'),
254
270
  expand: z.boolean().optional().describe('identifier names only: also list recent visible timeline events for this identifier (default: home + count)'),
255
271
  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.'),
272
+ 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.'),
256
273
  },
257
274
  },
258
- async ({ name, kind, expand, history }) => {
275
+ async ({ name, kind, expand, history, version }) => {
259
276
  const k = kind ?? 'project'
277
+ // PAGE HISTORY (Part A): a specific version reads one historical body, resolved within this page's
278
+ // own history. Not for identifier-shaped names (those are join keys, handled below).
279
+ if (version && !/^[a-z][a-z0-9_-]*:.+/i.test(name)) {
280
+ try {
281
+ const qs = new URLSearchParams({ kind: k, key: name, version })
282
+ const vr = await fetchCortex(`${BASE}/api/brain/page?${qs}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
283
+ if (vr.status === 404) return { content: [{ type: 'text', text: `No version "${version}" for "${name}" (or you can't see it). Use page_history "${name}" to list its versions.` }] }
284
+ if (!vr.ok) {
285
+ const d = classify(vr.status, vr.headers.get('content-type'), await vr.text(), vr.headers.get('x-vercel-id'))
286
+ return { content: [{ type: 'text', text: `Could not read version "${version}" of "${name}": ${d.message}` }] }
287
+ }
288
+ const v = await vr.json()
289
+ return { content: [{ type: 'text', text: `# ${name} — historical version (rev ${v.revNo} · ${v.op} · ${String(v.createdAt).slice(0, 10)} · ${v.tier})\nversion: ${v.version}\n\n${v.body}\n\n— This is a HISTORICAL snapshot, not the current page. \`read_page "${name}"\` (no version) shows what's live; \`rollback_page\` restores this one as a new version.` }] }
290
+ } catch (e) {
291
+ return { content: [{ type: 'text', text: `Could not read version "${version}" of "${name}": ${e.message}` }] }
292
+ }
293
+ }
260
294
  // PER-NODE TIMELINE (slice 4): history = the projection over the node's identifier stamps.
261
295
  if (history && !/^[a-z][a-z0-9_-]*:.+/i.test(name)) {
262
296
  try {
@@ -327,29 +361,113 @@ export async function runServer(version) {
327
361
  return { content: [{ type: 'text', text: `Could not read "${name}": ${d.message}` }] }
328
362
  }
329
363
  const page = await res.json()
330
- if (!page?.authored || !Array.isArray(page.tiers) || !page.tiers.length) {
331
- return { content: [{ type: 'text', text: `"${name}" (${k}) exists but has no authored page yet — nothing to read. \`grep\` for mentions, or author it if you hold first-hand knowledge worth capturing.` }] }
364
+ // Multi-brain (decision 3A): /api/brain/page returns matches[] — one entry per brain that has a
365
+ // page under this name. One match (the common case) reads exactly as before, plus a brain tag only
366
+ // when >1 brain matches — never silently one brain's version.
367
+ let matches = Array.isArray(page?.matches) ? page.matches.filter((m) => m.authored && Array.isArray(m.tiers) && m.tiers.length) : []
368
+ // Rollout back-compat: an older server returns the single-page shape ({authored, tiers}) with no
369
+ // matches[]; adapt it to one match so this build works against both old and new deployments.
370
+ if (!matches.length && page?.authored && Array.isArray(page.tiers) && page.tiers.length) {
371
+ matches = [{ brain: null, authored: true, ref: page.ref, title: page.title, tiers: page.tiers }]
372
+ }
373
+ if (!matches.length) {
374
+ 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). Otherwise \`grep "${name}"\` to locate it — it may be a red-link (a wanted page that isn't authored yet).` }] }
332
375
  }
333
376
  const day = (d) => (d ? String(d).slice(0, 10) : '')
334
- const blocks = page.tiers.map((t) => {
335
- const secs = (t.sections ?? []).map((s) => `### ${s.heading}\n${s.body}`).join('\n\n')
336
- // ADR-0018: a null version isn't "nothing to show" it means this variant predates content-
337
- // hash tracking (a 2026-06-29 import scar) and CANNOT be re-authored via base_version until an
338
- // admin backfills it. Silently omitting the line here is exactly what sent callers into an
339
- // unrecoverable base_version guessing loop; say so explicitly instead.
340
- const versionLine = t.version
341
- ? `\nversion: ${t.version}`
342
- : `\nversion: none (this variant predates content-hash tracking — base_version writes will always fail here; ask an admin about the ADR-0018 backfill)`
343
- const head = `[${t.tier}${day(t.updated_at) ? ` · authored ${day(t.updated_at)}` : ''}${t.validity && t.validity !== 'current' ? ` · ${t.validity}` : ''}${versionLine}]`
344
- return [head, t.summary, secs].filter(Boolean).join('\n')
377
+ const renderMatch = (m, tagBrain) => {
378
+ const blocks = m.tiers.map((t) => {
379
+ const secs = (t.sections ?? []).map((s) => `### ${s.heading}\n${s.body}`).join('\n\n')
380
+ // ADR-0018: a null version isn't "nothing to show" it means this variant predates content-
381
+ // hash tracking (a 2026-06-29 import scar) and CANNOT be re-authored via base_version until an
382
+ // admin backfills it. Silently omitting the line here is exactly what sent callers into an
383
+ // unrecoverable base_version guessing loop; say so explicitly instead.
384
+ const versionLine = t.version
385
+ ? `\nversion: ${t.version}`
386
+ : `\nversion: none (this variant predates content-hash tracking base_version writes will always fail here; ask an admin about the ADR-0018 backfill)`
387
+ const head = `[${t.tier}${day(t.updated_at) ? ` · authored ${day(t.updated_at)}` : ''}${t.validity && t.validity !== 'current' ? ` · ${t.validity}` : ''}${versionLine}]`
388
+ return [head, t.summary, secs].filter(Boolean).join('\n')
389
+ })
390
+ let footer = `— Follow any [[links]] above with read_page to go deeper.\n— If you hold fresher FIRST-HAND truth than this page — something you established THIS session (ran the command, made the change, hold the artifact) that a section here gets concretely wrong — re-author just those sections now with \`author\` (any node you are permitted to edit; concise; a material contradiction, not merely extra detail). Pass this page's \`version\` as base_version when you re-author — if the save comes back stale, the page changed under you: re-read, then re-author. Reading a stale page you can fix IS the trigger to fix it.`
391
+ // slice 4: when the page carries identifier stamps, the history projection is one flag away.
392
+ const allBody = m.tiers.flatMap((t) => (t.sections ?? []).map((s) => s.body)).join('\n')
393
+ const stamps = [...new Set((allBody.match(/\[\[repo:[a-z0-9][a-z0-9-]*\/[a-z0-9_.-]+\]\]/gi) ?? []).map((s) => s.toLowerCase()))]
394
+ if (stamps.length) footer += `\n— This page carries ${stamps.join(', ')} — \`read_page "${name}"\` with history: true for its event timeline (page = present, timeline = history).`
395
+ const brainTag = tagBrain ? ` · brain: ${m.brain}` : ''
396
+ return `# ${m.title ?? name} (full authored page${brainTag})\n\n${blocks.join('\n\n---\n\n')}\n\n${footer}`
397
+ }
398
+ if (matches.length === 1) {
399
+ return { content: [{ type: 'text', text: renderMatch(matches[0], false) }] }
400
+ }
401
+ const header = `"${name}" is authored in ${matches.length} of your brains — all shown (each tagged with its brain, newest tier first):`
402
+ return { content: [{ type: 'text', text: [header, ...matches.map((m) => renderMatch(m, true))].join('\n\n═══════════════════\n\n') }] }
403
+ },
404
+ )
405
+
406
+ server.registerTool(
407
+ 'page_history',
408
+ {
409
+ title: 'See a wiki page\'s edit history',
410
+ description: 'Show the VERSION history of an authored wiki page — every prior version, who changed it and when, newest first. This is how you see "what changed on this page and by whom", and it includes privacy changes (re-tiers). Then use `read_page` with a version to view an old body, or `rollback_page` to restore one. (Distinct from read_page\'s `history: true`, which is the raw event timeline via [[repo:…]] stamps.) RLS-scoped: you see history only for pages you may read.',
411
+ inputSchema: {
412
+ name: z.string().describe('the canonical node name exactly as written (e.g. "Cortex", "Ben")'),
413
+ kind: z.enum(['project', 'person', 'org', 'user']).optional().describe('node kind (default project)'),
414
+ limit: z.number().int().optional().describe('how many recent versions to show (default 20, max 200)'),
415
+ },
416
+ },
417
+ async ({ name, kind, limit }) => {
418
+ const k = kind ?? 'project'
419
+ let res
420
+ try {
421
+ const qs = new URLSearchParams({ kind: k, key: name, ...(limit ? { limit: String(limit) } : {}) })
422
+ res = await fetchCortex(`${BASE}/api/brain/page-history?${qs}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
423
+ } catch (e) {
424
+ return { content: [{ type: 'text', text: `Could not read history for "${name}": ${e.message}` }] }
425
+ }
426
+ if (res.status === 404) return { content: [{ type: 'text', text: `No ${k} named "${name}". Pass kind (person/org) if it isn't a project.` }] }
427
+ if (!res.ok) {
428
+ const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
429
+ return { content: [{ type: 'text', text: `Could not read history for "${name}": ${d.message}` }] }
430
+ }
431
+ const out = await res.json()
432
+ const revs = out.revisions ?? []
433
+ if (!revs.length) return { content: [{ type: 'text', text: `"${name}" (${k}) has no recorded version history yet.` }] }
434
+ const lines = revs.map((r) => {
435
+ const who = r.actor_name ? ` · ${r.actor_name}` : ''
436
+ const why = r.reason ? ` — ${r.reason}` : ''
437
+ return `- rev ${r.rev_no} · ${String(r.created_at).slice(0, 10)} · ${r.op} · ${r.tier}${who}${why}\n version: ${r.content_hash}`
345
438
  })
346
- let footer = `— Follow any [[links]] above with read_page to go deeper.\nIf you hold fresher FIRST-HAND truth than this page something you established THIS session (ran the command, made the change, hold the artifact) that a section here gets concretely wrong re-author just those sections now with \`author\` (any node you are permitted to edit; concise; a material contradiction, not merely extra detail). Pass this page's \`version\` as base_version when you re-author — if the save comes back stale, the page changed under you: re-read, then re-author. Reading a stale page you can fix IS the trigger to fix it.`
347
- // slice 4: when the page carries identifier stamps, the history projection is one flag away.
348
- // (client-side shape check — the acknowledged registry copy of identifiers.ts)
349
- const allBody = page.tiers.flatMap((t) => (t.sections ?? []).map((s) => s.body)).join('\n')
350
- const stamps = [...new Set((allBody.match(/\[\[repo:[a-z0-9][a-z0-9-]*\/[a-z0-9_.-]+\]\]/gi) ?? []).map((s) => s.toLowerCase()))]
351
- if (stamps.length) footer += `\n— This page carries ${stamps.join(', ')} — \`read_page "${name}"\` with history: true for its event timeline (page = present, timeline = history).`
352
- return { content: [{ type: 'text', text: `# ${page.title ?? name} (full authored page)\n\n${blocks.join('\n\n---\n\n')}\n\n${footer}` }] }
439
+ return { content: [{ type: 'text', text: `# ${name} — page history (newest first)\n${lines.join('\n')}\n\n\`read_page "${name}"\` with version:<rev_no|version> to view an old body; \`rollback_page\` to restore one.` }] }
440
+ },
441
+ )
442
+
443
+ server.registerTool(
444
+ 'rollback_page',
445
+ {
446
+ title: 'Roll a wiki page back to a prior version',
447
+ description: 'Restore a wiki page to an earlier version from its page_history — a forward, non-destructive write (the old versions are kept; a new "rollback" version is recorded). Use this to undo a mistaken or bad edit. You may only roll back a page you are allowed to edit. Find the target version with page_history first.',
448
+ inputSchema: {
449
+ name: z.string().describe('the exact page name'),
450
+ kind: z.enum(['project', 'person', 'org', 'user']).optional().describe('node kind (default project)'),
451
+ to_version: z.string().describe('which version to restore: a rev_no (e.g. "3") or a content_hash, from page_history'),
452
+ tier: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('which tier variant to roll back, if the node has more than one'),
453
+ reason: z.string().optional().describe('optional note recorded on the new rollback version (why you rolled back)'),
454
+ },
455
+ },
456
+ async ({ name, kind, to_version, tier, reason }) => {
457
+ const k = kind ?? 'project'
458
+ let res
459
+ try {
460
+ res = await fetchCortex(`${BASE}/api/brain/rollback`, {
461
+ method: 'POST',
462
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
463
+ body: JSON.stringify({ kind: k, name, to_version, ...(tier ? { tier } : {}), ...(reason ? { reason } : {}) }),
464
+ })
465
+ } catch (e) {
466
+ return { content: [{ type: 'text', text: `Could not roll back "${name}": ${e.message}` }] }
467
+ }
468
+ const out = await res.json().catch(() => null)
469
+ if (!res.ok) return { content: [{ type: 'text', text: `Could not roll back "${name}": ${out?.error ?? res.status}` }] }
470
+ return { content: [{ type: 'text', text: `Done — "${name}" ${out.note}. \`read_page "${name}"\` to confirm the current content.` }] }
353
471
  },
354
472
  )
355
473
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.36",
3
+ "version": "0.9.38",
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": {