@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.
- package/lib/server.mjs +149 -31
- 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
|
-
|
|
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
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
331
|
-
|
|
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
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
|