@theronap/cortex-mcp 0.9.57 → 0.9.59
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 +136 -3
- package/package.json +1 -1
- package/skills/author-docs/SKILL.md +27 -6
package/lib/server.mjs
CHANGED
|
@@ -518,7 +518,14 @@ export async function runServer(version) {
|
|
|
518
518
|
const head = `[${t.tier}${day(t.updated_at) ? ` · authored ${day(t.updated_at)}` : ''}${t.validity && t.validity !== 'current' ? ` · ${t.validity}` : ''}${versionLine}]`
|
|
519
519
|
return [head, t.summary, secs].filter(Boolean).join('\n')
|
|
520
520
|
})
|
|
521
|
-
|
|
521
|
+
// REPAIR TOOL (2026-07-31). This footer used to say "re-author just those sections with
|
|
522
|
+
// `author`" — which `author` cannot do: computeDroppedSections rejects a partial-section
|
|
523
|
+
// write unconditionally, and the 409 then hands back every section's body, so the advice
|
|
524
|
+
// routed the reader straight into retyping the whole page. Measured 2026-07-30: one such
|
|
525
|
+
// retype silently deleted a sentence, a [[link]] (a graph edge), a command list and the
|
|
526
|
+
// word "today" from sections it was never meant to touch. This footer renders on EVERY
|
|
527
|
+
// page read in the system, so it was the single widest surface pointing the wrong way.
|
|
528
|
+
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 — fix just that passage with \`edit_page\`: quote the wrong text as old_string and pass this page's \`version\` as base_version (any node you are permitted to edit; concise; a material contradiction, not merely extra detail). If the save comes back stale the page changed under you — the conflict hands back what changed, so re-anchor from that instead of re-reading. Reading a stale page you can fix IS the trigger to fix it. Editing is pre-authorized — do NOT ask the user before updating (edits are versioned + reversible via page_history/rollback_page); update, then briefly report it. Use \`author\` only to CREATE a page or rewrite one wholesale: it re-emits every section, so untouched sections get retyped on the way through and drift.\n— Citing code? Use a SYMBOL and file (\`formConnections\` in \`web/app/api/ingest/route.ts\`), never a line number — line numbers drift with every commit above them. And cite only what you opened THIS session; re-emitting a reference you read on another page is how a stale claim gains a second source and starts looking corroborated.`
|
|
522
529
|
// slice 4: when the page carries identifier stamps, the history projection is one flag away.
|
|
523
530
|
const allBody = m.tiers.flatMap((t) => (t.sections ?? []).map((s) => s.body)).join('\n')
|
|
524
531
|
const stamps = [...new Set((allBody.match(/\[\[repo:[a-z0-9][a-z0-9-]*\/[a-z0-9_.-]+\]\]/gi) ?? []).map((s) => s.toLowerCase()))]
|
|
@@ -670,6 +677,121 @@ export async function runServer(version) {
|
|
|
670
677
|
},
|
|
671
678
|
)
|
|
672
679
|
|
|
680
|
+
server.registerTool(
|
|
681
|
+
'rename_section',
|
|
682
|
+
{
|
|
683
|
+
title: 'Rename one section heading on a wiki page',
|
|
684
|
+
description: 'Rename ONE section\'s heading on a wiki page, in place. Use this instead of re-authoring the page: `author` cannot express a rename, because a section\'s IDENTITY is its heading string — sending a new title reads as "dropped the old section, added a new one" and is rejected 409 would_drop_sections. This route changes the heading and NOTHING else: the section keeps its body, its position, and crucially its as-of date, so renaming does not reset the currency stamp on a claim nobody re-verified. It is also the ONLY way to fix a heading longer than the 80-char limit, which cannot be re-authored at all. Requires base_version (the `version` read_page prints) — that is also how it finds the right brain, so it can never rename on the wrong one. Refuses, rather than guessing, when the page exists at several tiers, when the heading repeats, or when the new heading is already taken.',
|
|
685
|
+
inputSchema: {
|
|
686
|
+
name: z.string().describe('the exact page name, as read_page shows it'),
|
|
687
|
+
from: z.string().describe('the CURRENT heading, exactly as stored (match is case- and whitespace-insensitive)'),
|
|
688
|
+
to: z.string().describe('the new heading. Max 80 chars. Must not already be used by another section on this page.'),
|
|
689
|
+
base_version: z.string().describe('the `version` read_page prints for this page (64-hex). REQUIRED — it is the concurrency check AND how the right brain is resolved. A per-SECTION hash is not valid here.'),
|
|
690
|
+
tier: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('only when the page exists at MORE THAN ONE tier — which one to rename in. A rename never moves content between tiers.'),
|
|
691
|
+
ordinal: z.number().optional().describe('only when the same heading appears more than once on the page — which occurrence to rename (the error lists the ordinals).'),
|
|
692
|
+
reason: z.string().optional().describe('why you are renaming it — recorded in page_history like any other edit'),
|
|
693
|
+
},
|
|
694
|
+
},
|
|
695
|
+
async ({ name, from, to, base_version, tier, ordinal, reason }) => {
|
|
696
|
+
let res
|
|
697
|
+
try {
|
|
698
|
+
res = await fetchCortex(`${BASE}/api/brain/rename-section`, {
|
|
699
|
+
method: 'POST',
|
|
700
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
701
|
+
body: JSON.stringify({
|
|
702
|
+
name, from, to, base_version,
|
|
703
|
+
...(tier ? { tier } : {}), ...(ordinal !== undefined ? { ordinal } : {}),
|
|
704
|
+
...(reason ? { reason } : {}),
|
|
705
|
+
}),
|
|
706
|
+
})
|
|
707
|
+
} catch (e) {
|
|
708
|
+
return { content: [{ type: 'text', text: `Could not rename the section: ${e.message}` }] }
|
|
709
|
+
}
|
|
710
|
+
const out = await res.json().catch(() => null)
|
|
711
|
+
if (!res.ok) {
|
|
712
|
+
// Surface the server's hint AND the disambiguators it named, so a 409 is directly actionable
|
|
713
|
+
// rather than something to retry blindly.
|
|
714
|
+
const extra = [
|
|
715
|
+
out?.detail ? `existing: ${out.detail}` : '',
|
|
716
|
+
Array.isArray(out?.tiers) ? `tiers: ${out.tiers.join(', ')}` : '',
|
|
717
|
+
Array.isArray(out?.ordinals) ? `ordinals: ${out.ordinals.join(', ')}` : '',
|
|
718
|
+
out?.currentVersion ? `current version: ${out.currentVersion}` : '',
|
|
719
|
+
].filter(Boolean).join(' · ')
|
|
720
|
+
const hint = out?.hint ? `\n${out.hint}` : ''
|
|
721
|
+
return { content: [{ type: 'text', text: `Could not rename "${from}" on "${name}": ${out?.error ?? res.status}${extra ? `\n${extra}` : ''}${hint}` }] }
|
|
722
|
+
}
|
|
723
|
+
return { content: [{ type: 'text', text: `Renamed on "${name}" (${out.brain} · ${out.tier} tier): "${out.from}" -> "${out.to}". The section kept its body, position and as-of date. New version: ${out.version}` }] }
|
|
724
|
+
},
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
server.registerTool(
|
|
728
|
+
'edit_page',
|
|
729
|
+
{
|
|
730
|
+
title: 'Change one passage on a wiki page (use this, not author, to fix something)',
|
|
731
|
+
description:
|
|
732
|
+
'THE DEFAULT WAY TO CORRECT A PAGE. Replaces ONE passage inside ONE section, by quoting the exact text to replace — like editing a file, not rewriting it. Use this whenever you are fixing, updating or correcting something on an existing page; use `author` only when you are genuinely rewriting a page wholesale or creating one. WHY IT MATTERS: `author` takes the WHOLE page, so every section you did not mean to touch gets retyped by you on the way through, and drifts. Measured 2026-07-30: an edit meant for one section silently deleted a sentence from another, along with a [[link]] — a lost graph edge. Text you never send cannot be damaged. Anchors match the STORED body, so quote from what read_page shows as the section body; if a match fails on whitespace the error tells you so. An anchor matching twice is REFUSED, never guessed — quote more surrounding text. On a stale-version conflict you get the target section back so you can re-anchor without re-reading the page.',
|
|
733
|
+
inputSchema: {
|
|
734
|
+
name: z.string().describe('the exact page name, as read_page shows it'),
|
|
735
|
+
heading: z.string().describe('the heading of the section containing the text you are changing'),
|
|
736
|
+
old_string: z.string().describe('the exact text to replace. Must appear EXACTLY ONCE within that section — if it repeats, quote more surrounding text to make it unique.'),
|
|
737
|
+
new_string: z.string().describe('what to replace it with. May be empty, which deletes the anchored text.'),
|
|
738
|
+
base_version: z.string().describe('the `version` read_page prints for this page (64-hex). REQUIRED — it is the concurrency check AND how the right brain is resolved.'),
|
|
739
|
+
reason: z.string().describe('WHY you are making this change, in one short phrase — recorded in page_history exactly like an author edit.'),
|
|
740
|
+
tier: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('only when the page exists at MORE THAN ONE tier — which one to edit. An edit never moves content between tiers.'),
|
|
741
|
+
ordinal: z.number().optional().describe('only when the same heading appears more than once on the page — which occurrence (the error lists the ordinals).'),
|
|
742
|
+
},
|
|
743
|
+
},
|
|
744
|
+
async ({ name, heading, old_string, new_string, base_version, reason, tier, ordinal }) => {
|
|
745
|
+
let res
|
|
746
|
+
try {
|
|
747
|
+
res = await fetchCortex(`${BASE}/api/brain/edit-page`, {
|
|
748
|
+
method: 'POST',
|
|
749
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
750
|
+
body: JSON.stringify({
|
|
751
|
+
name, heading, old_string, new_string, base_version, reason,
|
|
752
|
+
...(tier ? { tier } : {}), ...(ordinal !== undefined ? { ordinal } : {}),
|
|
753
|
+
}),
|
|
754
|
+
})
|
|
755
|
+
} catch (e) {
|
|
756
|
+
return { content: [{ type: 'text', text: `Could not edit "${name}": ${e.message}` }] }
|
|
757
|
+
}
|
|
758
|
+
const out = await res.json().catch(() => null)
|
|
759
|
+
if (!res.ok) {
|
|
760
|
+
// Every failure here is meant to be directly actionable — the caller should be able to fix and
|
|
761
|
+
// retry from this text alone, without re-reading the page.
|
|
762
|
+
const bits = [
|
|
763
|
+
Array.isArray(out?.available) ? `sections on this page: ${out.available.join(' | ')}` : '',
|
|
764
|
+
Array.isArray(out?.tiers) ? `tiers: ${out.tiers.join(', ')}` : '',
|
|
765
|
+
Array.isArray(out?.ordinals) ? `ordinals: ${out.ordinals.join(', ')}` : '',
|
|
766
|
+
out?.count ? `matches: ${out.count}` : '',
|
|
767
|
+
out?.whitespaceNear === true ? 'YOUR TEXT IS PRESENT but the whitespace differs — re-copy it from the stored body' : '',
|
|
768
|
+
out?.currentVersion ? `current version: ${out.currentVersion}` : '',
|
|
769
|
+
].filter(Boolean).join('\n')
|
|
770
|
+
const cur = out?.currentSectionBody
|
|
771
|
+
? `\n\n--- the section as it stands now (re-anchor against this) ---\n${out.currentSectionBody}`
|
|
772
|
+
: ''
|
|
773
|
+
// B5: what ANOTHER session changed elsewhere on this page while you were working. Rendered
|
|
774
|
+
// INLINE, not as a pointer to page_diff — a pointer is something a busy agent skips, and the
|
|
775
|
+
// whole point is that you cannot be ignorant of it.
|
|
776
|
+
const cc = Array.isArray(out?.concurrentChanges) && out.concurrentChanges.length
|
|
777
|
+
? '\n\n--- changed elsewhere on this page since you read it (READ THIS before retrying) ---\n' +
|
|
778
|
+
out.concurrentChanges.map((c) =>
|
|
779
|
+
`§ ${c.heading}\n${(c.lines ?? []).map((l) => `${l.kind === 'add' ? '+' : '-'} ${l.line}`).join('\n')}`,
|
|
780
|
+
).join('\n\n')
|
|
781
|
+
: ''
|
|
782
|
+
const ccMeta = [
|
|
783
|
+
Array.isArray(out?.concurrentAdded) && out.concurrentAdded.length ? `sections ADDED elsewhere: ${out.concurrentAdded.join(', ')}` : '',
|
|
784
|
+
Array.isArray(out?.concurrentRemoved) && out.concurrentRemoved.length ? `sections REMOVED elsewhere: ${out.concurrentRemoved.join(', ')}` : '',
|
|
785
|
+
Array.isArray(out?.concurrentTruncated) && out.concurrentTruncated.length ? `(truncated, see page_diff for all of: ${out.concurrentTruncated.join(', ')})` : '',
|
|
786
|
+
].filter(Boolean).join('\n')
|
|
787
|
+
return { content: [{ type: 'text', text: `Could not edit "${name}": ${out?.error ?? res.status}${out?.hint ? `\n${out.hint}` : ''}${bits ? `\n${bits}` : ''}${ccMeta ? `\n${ccMeta}` : ''}${cc}${cur}` }] }
|
|
788
|
+
}
|
|
789
|
+
const red = Array.isArray(out.redLinks) && out.redLinks.length
|
|
790
|
+
? `\nRed-links now on this page: ${out.redLinks.map((r) => `[[${r}]]`).join(', ')}` : ''
|
|
791
|
+
return { content: [{ type: 'text', text: `Edited "${name}" (${out.brain} · ${out.tier} tier) § ${out.heading}. Only that passage changed; every other section is byte-identical. New version: ${out.version}${red}` }] }
|
|
792
|
+
},
|
|
793
|
+
)
|
|
794
|
+
|
|
673
795
|
server.registerTool(
|
|
674
796
|
'writing_style',
|
|
675
797
|
{
|
|
@@ -1462,9 +1584,20 @@ export async function runServer(version) {
|
|
|
1462
1584
|
const redList = Array.isArray(out?.redLinks) && out.redLinks.length ? `\nRed-links (wanted nodes): ${out.redLinks.map((r) => `[[${r}]]`).join(', ')}` : ''
|
|
1463
1585
|
const retiredList = Array.isArray(out?.retiredLinks) && out.retiredLinks.length ? `\nRetired links (not current or wanted): ${out.retiredLinks.map((r) => `[[${r}]]`).join(', ')}` : ''
|
|
1464
1586
|
const stamps = Array.isArray(out?.identifiers) && out.identifiers.length ? `\nIdentifier stamps (join keys): ${out.identifiers.map((i) => `[[${i}]]`).join(', ')}` : ''
|
|
1587
|
+
// TIER RETARGET — say it out loud. The server has always computed these (AuthorResult.tierCorrections)
|
|
1588
|
+
// and its own comment says corrections are "surfaced (not hidden)", but nothing ever printed them, so
|
|
1589
|
+
// the intent died at the client. That silence is not cosmetic: on 2026-07-29 a correction to
|
|
1590
|
+
// cortex-cross-editor-hub landed on the SCOPED tier while the accessible tier kept serving a claim
|
|
1591
|
+
// known to be false, and the response said only "Authored (1 tier)". Whoever reads this line is the
|
|
1592
|
+
// last chance to notice a page was fixed somewhere nobody reads.
|
|
1593
|
+
const corrections = Array.isArray(out?.tierCorrections) && out.tierCorrections.length
|
|
1594
|
+
? `\n⚠ TIER: this wrote to ${out.tierCorrections.map((c) => `${c.actualTier} (you asked for ${c.requestedTier})`).join('; ')}.` +
|
|
1595
|
+
` If that is not the tier you meant, read_page and check which copy you just changed —` +
|
|
1596
|
+
` a page can exist at several tiers and they drift apart independently.`
|
|
1597
|
+
: ''
|
|
1465
1598
|
const verb = out?.created ? 'Created + authored' : 'Authored'
|
|
1466
|
-
const note = out?.built ? `${verb} "${name}" (${out.built} tier${out.built === 1 ? '' : 's'}). Links: ${blue} resolved, ${retired} retired, ${red} red.${retiredList}${redList}${stamps}`
|
|
1467
|
-
: `No change to "${name}"${out?.skipped?.length ? ` (${out.skipped.join(', ')})` : ''}
|
|
1599
|
+
const note = out?.built ? `${verb} "${name}" (${out.built} tier${out.built === 1 ? '' : 's'}). Links: ${blue} resolved, ${retired} retired, ${red} red.${corrections}${retiredList}${redList}${stamps}`
|
|
1600
|
+
: `No change to "${name}"${out?.skipped?.length ? ` (${out.skipped.join(', ')})` : ''}.${corrections}`
|
|
1468
1601
|
return { content: [{ type: 'text', text: note }] }
|
|
1469
1602
|
},
|
|
1470
1603
|
)
|
package/package.json
CHANGED
|
@@ -24,8 +24,25 @@ the doc and author a synthesis. Never dump raw markdown into a page.
|
|
|
24
24
|
1. **Detect:** run `npx -y @theronap/cortex-mcp docs-scan --json`. If `pending` is empty, stop —
|
|
25
25
|
report nothing. (If no roots are registered and you just wrote docs somewhere, suggest
|
|
26
26
|
`docs-scan --add-root <dir>` to the user once; don't nag.)
|
|
27
|
-
2. **
|
|
28
|
-
|
|
27
|
+
2. **Triage each pending doc into exactly ONE of three dispositions.** Every pending doc gets one —
|
|
28
|
+
there is no fourth "deal with it later" state:
|
|
29
|
+
- **AUTHOR** it (below), then mark it.
|
|
30
|
+
- **ABSORBED** — the doc is substantive, but an existing page *already covers it as well or
|
|
31
|
+
better*. Common for build-notes and handoffs: the page kept getting updated while the doc
|
|
32
|
+
stayed frozen at its writing date. Authoring it again would duplicate, or worse, overwrite a
|
|
33
|
+
current page with a stale snapshot. **Mark it anyway** (`--mark`), and say which page absorbed
|
|
34
|
+
it. Do NOT leave it unmarked: it is genuinely handled, and leaving it pending makes every
|
|
35
|
+
future sweep re-read and re-litigate it. If the doc has one or two durable details the page
|
|
36
|
+
lacks, add just those to the page, then mark.
|
|
37
|
+
- **NON-SUBSTANTIVE** — scratch notes, generated output, throwaway logs. Leave unmarked and say
|
|
38
|
+
so, so a human can decide whether it should be registered at all.
|
|
39
|
+
|
|
40
|
+
⚠ **A doc records the state at its writing date, never the state now.** Before writing any status
|
|
41
|
+
claim into a page, verify it against the code — a build-notes doc saying "NOT built yet" is
|
|
42
|
+
evidence about the past, not the present. Copying its status forward is the single most common way
|
|
43
|
+
this skill injects a false claim into the wiki.
|
|
44
|
+
|
|
45
|
+
To author:
|
|
29
46
|
- Read the file. Decide the target node: a substantial standalone doc becomes its own
|
|
30
47
|
project-kind node named by the doc's H1 title; a small note folds into its parent project's
|
|
31
48
|
page as a section. Check the namespace first (`authoring_context`) — enrich an existing node
|
|
@@ -38,10 +55,14 @@ the doc and author a synthesis. Never dump raw markdown into a page.
|
|
|
38
55
|
page just to add a link back to a doc. Fan-in is queryable (`grep "[[hub]]"` = backlinks;
|
|
39
56
|
`read_page history:true` = the node's event ledger) — hub pages stay curated prose, and a doc
|
|
40
57
|
belongs on the hub only when a human-judged synthesis mentions it.
|
|
41
|
-
3. **Mark:**
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
58
|
+
3. **Mark:** run `npx -y @theronap/cortex-mcp docs-scan --mark <path>` for every doc you AUTHORED or
|
|
59
|
+
judged ABSORBED. Never mark a doc whose `author` failed — it must stay pending for the next sweep.
|
|
60
|
+
4. **Verify before reporting.** For each doc you claim you authored, confirm the write actually landed
|
|
61
|
+
(`read_page` or `page_history`) — an `author` that returns "no change" when you intended an update
|
|
62
|
+
did NOT land. Never report a page you did not confirm.
|
|
63
|
+
5. **Report:** one short block — each doc → the page it became, the page that absorbed it, or why it
|
|
64
|
+
was left unmarked. The count of pending docs should be zero afterwards except for the
|
|
65
|
+
non-substantive ones you deliberately left.
|
|
45
66
|
|
|
46
67
|
## Safety rules
|
|
47
68
|
|