@theronap/agnoclast-mcp 0.9.152 → 0.9.154
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 +15 -0
- package/lib/editors/claude.mjs +4 -5
- package/lib/server.mjs +23 -147
- package/package.json +1 -1
package/lib/diagnose.mjs
CHANGED
|
@@ -166,6 +166,7 @@ export function classify(status, contentType, bodyText, requestId) {
|
|
|
166
166
|
let appMessage = null
|
|
167
167
|
let appBrains = null
|
|
168
168
|
let appCandidates = null
|
|
169
|
+
let appAcceptUrl = null
|
|
169
170
|
if (isJson) {
|
|
170
171
|
try {
|
|
171
172
|
const parsed = JSON.parse(bodyText)
|
|
@@ -186,6 +187,7 @@ export function classify(status, contentType, bodyText, requestId) {
|
|
|
186
187
|
// saw a bare code like "would_drop_sections" and had no idea what to do with it. The routes
|
|
187
188
|
// were writing careful self-heal guidance that never reached anyone.
|
|
188
189
|
appHint = typeof parsed?.hint === 'string' && parsed.hint.trim() ? parsed.hint.trim() : null
|
|
190
|
+
appAcceptUrl = typeof parsed?.acceptUrl === 'string' && /^https:\/\//.test(parsed.acceptUrl) ? parsed.acceptUrl : null
|
|
189
191
|
} catch { /* not json after all */ }
|
|
190
192
|
}
|
|
191
193
|
const rid = requestId ? ` [request id: ${requestId}]` : ''
|
|
@@ -206,6 +208,19 @@ export function classify(status, contentType, bodyText, requestId) {
|
|
|
206
208
|
`Vercel firewall / deployment protection on the API route. Re-running setup will not help.${rid}`,
|
|
207
209
|
}
|
|
208
210
|
}
|
|
211
|
+
// Updated Terms / Privacy Policy not yet accepted (server legal_gate.ts, review D6). The credential is
|
|
212
|
+
// FINE — this must never read like the 401 branch above, whose advice (rotate the token, re-run setup)
|
|
213
|
+
// cannot help. Only a person can clear it, in a browser, so the message says exactly that and names
|
|
214
|
+
// the page. Not retriable: nothing changes until they accept.
|
|
215
|
+
if (isJson && appError === 'legal_acceptance_required') {
|
|
216
|
+
const url = appAcceptUrl ?? 'https://agnoclast.com/legal/accept'
|
|
217
|
+
return {
|
|
218
|
+
kind: 'app', retriable: false,
|
|
219
|
+
message: `Agnoclast needs the account owner to accept its updated Terms of Service / Privacy Policy ` +
|
|
220
|
+
`before it can continue (HTTP ${status}). Ask them to open ${url} in a browser, sign in, and accept. ` +
|
|
221
|
+
`Your token is valid — rotating it or re-running setup will not help.${rid}`,
|
|
222
|
+
}
|
|
223
|
+
}
|
|
209
224
|
// A brain-choice refusal is ANSWERABLE, so it must arrive as the question it is rather than as a
|
|
210
225
|
// code. Deliberately narrow: only these three errors reshape the message, so every other classify()
|
|
211
226
|
// output keeps its existing wording (and its tests).
|
package/lib/editors/claude.mjs
CHANGED
|
@@ -23,16 +23,15 @@ export function mergeClaudeMcp(existing, spec, token) {
|
|
|
23
23
|
* CAS-protected + snapshotted (page_revisions → page_history/rollback_page).
|
|
24
24
|
* Deliberately EXCLUDED — these keep prompting: send_imessage (external side effect),
|
|
25
25
|
* set_page_privacy / set_record_privacy / grant_page_access (visibility widening — the
|
|
26
|
-
* unowned-project accessible-default sharp edge, 2026-07-02),
|
|
27
|
-
* and deletes a variant row — the one write here that is not merely CAS-protected but genuinely
|
|
28
|
-
* lossy at the row level, so the prompt IS the guard D1 argued for), rollback_page, decide_page_merge /
|
|
26
|
+
* unowned-project accessible-default sharp edge, 2026-07-02), rollback_page,
|
|
29
27
|
* decide_file_request / request_file / get_file, create_brain / set_active_brain, alias_page,
|
|
30
|
-
* set_writing_style.
|
|
28
|
+
* set_writing_style. (replace_variant and decide_page_merge were here until one page per node,
|
|
29
|
+
* ADR-0064 §4, removed them.) */
|
|
31
30
|
export const ALLOWED_TOOL_NAMES = [
|
|
32
31
|
// read surface
|
|
33
32
|
'grep', 'read_page', 'my_context', 'project_status', 'session_context', 'search_org',
|
|
34
33
|
'list_records', 'page_history', 'page_diff', 'timeline_pull', 'my_brains', 'my_sessions', 'writing_style',
|
|
35
|
-
'code_graph_query', 'my_retier_notices', 'list_page_grants', 'file_requests',
|
|
34
|
+
'code_graph_query', 'my_retier_notices', 'list_page_grants', 'file_requests',
|
|
36
35
|
// live authoring core
|
|
37
36
|
'authoring_context', 'author', 'log_session',
|
|
38
37
|
// routine, reversible maintenance
|
package/lib/server.mjs
CHANGED
|
@@ -1438,7 +1438,7 @@ function renderNudge(payload) {
|
|
|
1438
1438
|
{
|
|
1439
1439
|
title: 'Read a wiki page in full',
|
|
1440
1440
|
description:
|
|
1441
|
-
'READ the full authored wiki page for one node (project/person/org/you) by its canonical name — every section
|
|
1441
|
+
'READ the full authored wiki page for one node (project/person/org/you) by its canonical name — every section 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.',
|
|
1442
1442
|
inputSchema: {
|
|
1443
1443
|
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'),
|
|
1444
1444
|
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.'),
|
|
@@ -1598,7 +1598,7 @@ function renderNudge(payload) {
|
|
|
1598
1598
|
// unrecoverable base_version guessing loop; say so explicitly instead.
|
|
1599
1599
|
const versionLine = t.version
|
|
1600
1600
|
? `\nversion: ${t.version}`
|
|
1601
|
-
: `\nversion: none (this
|
|
1601
|
+
: `\nversion: none (this page predates content-hash tracking — base_version writes will always fail here; ask an admin about the ADR-0018 backfill)`
|
|
1602
1602
|
const head = `[${t.tier}${day(t.updated_at) ? ` · authored ${day(t.updated_at)}` : ''}${t.validity && t.validity !== 'current' ? ` · ${t.validity}` : ''}${versionLine}]`
|
|
1603
1603
|
// ADR-0030 step 3b (2026-08-24): the summary header is NO LONGER RENDERED here either.
|
|
1604
1604
|
// ⚠ There were TWO of these — project_status and read_page — and the first patch fixed only
|
|
@@ -1792,18 +1792,17 @@ function renderNudge(payload) {
|
|
|
1792
1792
|
name: z.string().describe('the exact page name'),
|
|
1793
1793
|
kind: z.enum(['project', 'person', 'org', 'user']).optional().describe('node kind (default project)'),
|
|
1794
1794
|
to_version: z.string().describe('which version to restore: a rev_no (e.g. "3") or a content_hash, from page_history'),
|
|
1795
|
-
tier: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('which tier variant to roll back, if the node has more than one'),
|
|
1796
1795
|
reason: z.string().optional().describe('optional note recorded on the new rollback version (why you rolled back)'),
|
|
1797
1796
|
},
|
|
1798
1797
|
},
|
|
1799
|
-
async ({ name, kind, to_version,
|
|
1798
|
+
async ({ name, kind, to_version, reason }) => {
|
|
1800
1799
|
const k = kind ?? 'project'
|
|
1801
1800
|
let res
|
|
1802
1801
|
try {
|
|
1803
1802
|
res = await fetchCortex(`${BASE}/api/brain/rollback`, {
|
|
1804
1803
|
method: 'POST',
|
|
1805
1804
|
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
1806
|
-
body: JSON.stringify({ kind: k, name, to_version, ...(
|
|
1805
|
+
body: JSON.stringify({ kind: k, name, to_version, ...(reason ? { reason } : {}) }),
|
|
1807
1806
|
})
|
|
1808
1807
|
} catch (e) {
|
|
1809
1808
|
return toolError(`Could not roll back "${name}": ${e.message}`)
|
|
@@ -1825,19 +1824,18 @@ function renderNudge(payload) {
|
|
|
1825
1824
|
'rename_section',
|
|
1826
1825
|
{
|
|
1827
1826
|
title: 'Rename one section heading on a wiki page',
|
|
1828
|
-
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
|
|
1827
|
+
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 heading repeats, or when the new heading is already taken.',
|
|
1829
1828
|
inputSchema: {
|
|
1830
1829
|
name: z.string().describe('the exact page name, as read_page shows it'),
|
|
1831
1830
|
from: z.string().describe('the CURRENT heading, exactly as stored (match is case- and whitespace-insensitive)'),
|
|
1832
1831
|
to: z.string().describe('the new heading. Max 80 chars. Must not already be used by another section on this page.'),
|
|
1833
1832
|
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.'),
|
|
1834
|
-
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.'),
|
|
1835
1833
|
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).'),
|
|
1836
1834
|
reason: z.string().optional().describe('why you are renaming it — recorded in page_history like any other edit'),
|
|
1837
1835
|
identity_claim_ack: z.boolean().optional().describe('ONLY after a 409 identity_claim refusal, and only if the answer is genuinely yes: this text is meant to publish an email address or phone number as page content at this tier — e.g. the subject is publishing their OWN contact detail on their own page. Leave unset otherwise; the alternatives the refusal names (a confidential section, or set_routing_identifier for a private claim) are the right answer in every other case.'),
|
|
1838
1836
|
},
|
|
1839
1837
|
},
|
|
1840
|
-
async ({ name, from, to, base_version,
|
|
1838
|
+
async ({ name, from, to, base_version, ordinal, reason, identity_claim_ack }) => {
|
|
1841
1839
|
let res
|
|
1842
1840
|
try {
|
|
1843
1841
|
res = await fetchCortex(`${BASE}/api/brain/rename-section`, {
|
|
@@ -1845,7 +1843,7 @@ function renderNudge(payload) {
|
|
|
1845
1843
|
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
1846
1844
|
body: JSON.stringify({
|
|
1847
1845
|
name, from, to, base_version,
|
|
1848
|
-
...(
|
|
1846
|
+
...(ordinal !== undefined ? { ordinal } : {}),
|
|
1849
1847
|
...(reason ? { reason } : {}), ...(identity_claim_ack ? { identity_claim_ack: true } : {}),
|
|
1850
1848
|
}),
|
|
1851
1849
|
})
|
|
@@ -1881,7 +1879,6 @@ function renderNudge(payload) {
|
|
|
1881
1879
|
into: z.string().optional().describe('the heading of the section that SURVIVES and absorbs the content. Omit to drop `from` outright without folding it anywhere.'),
|
|
1882
1880
|
new_body: z.string().optional().describe('the condensed body the surviving section should read AFTER the merge — you write it, this route does not summarize. Required with `into`; must be absent without it.'),
|
|
1883
1881
|
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.'),
|
|
1884
|
-
tier: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('only when the page exists at MORE THAN ONE tier — which one to condense in. A merge never moves content between tiers.'),
|
|
1885
1882
|
from_ordinal: z.number().optional().describe('only when `from`\'s heading appears more than once on the page — which occurrence (the error lists the ordinals).'),
|
|
1886
1883
|
into_ordinal: z.number().optional().describe('only when `into`\'s heading appears more than once on the page — which occurrence (the error lists the ordinals).'),
|
|
1887
1884
|
reason: z.string().optional().describe('WHY you are condensing, in one short phrase — recorded in page_history. Say what became redundant, not what you did.'),
|
|
@@ -1889,7 +1886,7 @@ function renderNudge(payload) {
|
|
|
1889
1886
|
identity_claim_ack: z.boolean().optional().describe('ONLY after a 409 identity_claim refusal, and only if the answer is genuinely yes: this text is meant to publish an email address or phone number as page content at this tier.'),
|
|
1890
1887
|
},
|
|
1891
1888
|
},
|
|
1892
|
-
async ({ name, from, into, new_body, base_version,
|
|
1889
|
+
async ({ name, from, into, new_body, base_version, from_ordinal, into_ordinal, reason, drop_links_ack, identity_claim_ack }) => {
|
|
1893
1890
|
let res
|
|
1894
1891
|
try {
|
|
1895
1892
|
res = await fetchCortex(`${BASE}/api/brain/merge-sections`, {
|
|
@@ -1898,7 +1895,6 @@ function renderNudge(payload) {
|
|
|
1898
1895
|
body: JSON.stringify({
|
|
1899
1896
|
name, from, base_version,
|
|
1900
1897
|
...(into ? { into } : {}), ...(new_body !== undefined ? { new_body } : {}),
|
|
1901
|
-
...(tier ? { tier } : {}),
|
|
1902
1898
|
...(from_ordinal !== undefined ? { from_ordinal } : {}),
|
|
1903
1899
|
...(into_ordinal !== undefined ? { into_ordinal } : {}),
|
|
1904
1900
|
...(reason ? { reason } : {}),
|
|
@@ -2755,11 +2751,10 @@ function renderNudge(payload) {
|
|
|
2755
2751
|
summary: z.string().describe('the new summary: one sentence saying what this is and where it stands. May contain [[links]]. Max 2,000 chars — detail belongs in a section.'),
|
|
2756
2752
|
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.'),
|
|
2757
2753
|
reason: z.string().optional().describe('WHY the summary was wrong, in one short phrase — recorded in page_history. Say what changed ("blocker resolved 08-04; was still claiming BLOCKED"), not what you did.'),
|
|
2758
|
-
tier: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('only when the page exists at MORE THAN ONE tier — which one to rewrite. This never moves content between tiers.'),
|
|
2759
2754
|
identity_claim_ack: z.boolean().optional().describe('ONLY after a 409 identity_claim refusal, and only if the answer is genuinely yes: this text is meant to publish an email address or phone number as page content at this tier — e.g. the subject is publishing their OWN contact detail on their own page. Leave unset otherwise; the alternatives the refusal names (a confidential section, or set_routing_identifier for a private claim) are the right answer in every other case.'),
|
|
2760
2755
|
},
|
|
2761
2756
|
},
|
|
2762
|
-
async ({ name, summary, base_version, reason,
|
|
2757
|
+
async ({ name, summary, base_version, reason, identity_claim_ack }) => {
|
|
2763
2758
|
let res
|
|
2764
2759
|
try {
|
|
2765
2760
|
res = await fetchCortex(`${BASE}/api/brain/set-summary`, {
|
|
@@ -2767,7 +2762,7 @@ function renderNudge(payload) {
|
|
|
2767
2762
|
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
2768
2763
|
body: JSON.stringify({
|
|
2769
2764
|
name, summary, base_version,
|
|
2770
|
-
...(
|
|
2765
|
+
...(reason ? { reason } : {}),
|
|
2771
2766
|
...(identity_claim_ack ? { identity_claim_ack: true } : {}),
|
|
2772
2767
|
}),
|
|
2773
2768
|
})
|
|
@@ -2804,12 +2799,11 @@ function renderNudge(payload) {
|
|
|
2804
2799
|
new_string: z.string().describe('what to replace it with. May be empty, which deletes the anchored text.'),
|
|
2805
2800
|
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.'),
|
|
2806
2801
|
reason: z.string().describe('WHY you are making this change, in one short phrase — recorded in page_history exactly like an author edit.'),
|
|
2807
|
-
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.'),
|
|
2808
2802
|
ordinal: z.number().optional().describe('only when the same heading appears more than once on the page — which occurrence (the error lists the ordinals).'),
|
|
2809
2803
|
identity_claim_ack: z.boolean().optional().describe('ONLY after a 409 identity_claim refusal, and only if the answer is genuinely yes: this text is meant to publish an email address or phone number as page content at this tier — e.g. the subject is publishing their OWN contact detail on their own page. Leave unset otherwise; the alternatives the refusal names (a confidential section, or set_routing_identifier for a private claim) are the right answer in every other case.'),
|
|
2810
2804
|
},
|
|
2811
2805
|
},
|
|
2812
|
-
async ({ name, heading, old_string, new_string, base_version, reason,
|
|
2806
|
+
async ({ name, heading, old_string, new_string, base_version, reason, ordinal, identity_claim_ack }) => {
|
|
2813
2807
|
let res
|
|
2814
2808
|
try {
|
|
2815
2809
|
res = await fetchCortex(`${BASE}/api/brain/edit-page`, {
|
|
@@ -2817,7 +2811,7 @@ function renderNudge(payload) {
|
|
|
2817
2811
|
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
2818
2812
|
body: JSON.stringify({
|
|
2819
2813
|
name, heading, old_string, new_string, base_version, reason,
|
|
2820
|
-
...(
|
|
2814
|
+
...(ordinal !== undefined ? { ordinal } : {}),
|
|
2821
2815
|
...(identity_claim_ack ? { identity_claim_ack: true } : {}),
|
|
2822
2816
|
}),
|
|
2823
2817
|
})
|
|
@@ -3149,7 +3143,7 @@ function renderNudge(payload) {
|
|
|
3149
3143
|
org_id: z.string().describe('the org id of the brain to enumerate (from my_brains)'),
|
|
3150
3144
|
owner: z.string().optional().describe('only pages owned by this person — a user id or an EXACT display name. A name matching no member of the brain is an error, never a silently empty list. Owner applies to scoped/confidential pages; accessible pages have no owner.'),
|
|
3151
3145
|
updated_within_days: z.number().optional().describe('only pages touched in the last N days'),
|
|
3152
|
-
tier: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('only
|
|
3146
|
+
tier: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('only pages at this tier'),
|
|
3153
3147
|
kind: z.enum(['project', 'person', 'org', 'user']).optional().describe('only nodes of this kind'),
|
|
3154
3148
|
validity: z.enum(['current', 'superseded', 'all']).optional().describe("default 'current'. Pass 'all' for the pre-2026-08 behavior, which mixed superseded pages in with nothing marking them."),
|
|
3155
3149
|
name_contains: z.string().optional().describe('case-insensitive substring match on the node name or page title'),
|
|
@@ -3917,38 +3911,29 @@ function renderNudge(payload) {
|
|
|
3917
3911
|
'set_page_privacy',
|
|
3918
3912
|
{
|
|
3919
3913
|
title: 'Change who can see a wiki page',
|
|
3920
|
-
description: 'Re-tier a wiki page you own or may edit: "accessible" (anyone in the org), "scoped" (owner + their management chain), or "confidential" (owner only, plus explicit grants). Demoting a PROJECT page also demotes its evidence records (demote-only; each record\'s owner is notified and can revert). Promotions never touch records.
|
|
3914
|
+
description: 'Re-tier a wiki page you own or may edit: "accessible" (anyone in the org), "scoped" (owner + their management chain), or "confidential" (owner only, plus explicit grants). Demoting a PROJECT page also demotes its evidence records (demote-only; each record\'s owner is notified and can revert). Promotions never touch records. Org admins may demote any page, never promote.',
|
|
3921
3915
|
inputSchema: {
|
|
3922
3916
|
kind: z.enum(['project', 'person', 'org', 'user']).describe('the page kind'),
|
|
3923
3917
|
name: z.string().optional().describe('the exact page name (or pass `ref` instead — one of the two is required)'),
|
|
3924
3918
|
ref: z.string().optional().describe('the page\'s stable id, printed as `ref:` by read_page. PREFER THIS over name when you have it: a ref is unique across brains, so it addresses exactly one page and never needs a brain to disambiguate it.'),
|
|
3925
3919
|
tier: z.enum(['accessible', 'scoped', 'confidential']).describe('the new visibility tier'),
|
|
3926
|
-
source_tier: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('when the node has multiple tier variants: which one to move'),
|
|
3927
|
-
absorb: z.boolean().optional().describe('after merging your content into an existing target-tier page via author: true removes your now-absorbed source variant. Requires target_version.'),
|
|
3928
|
-
target_version: z.string().optional().describe('REQUIRED with absorb=true — the target page\'s version, read via read_page AFTER your author() merge landed. Proves the merge actually happened before your source page is deleted; a stale or guessed value is rejected, not silently accepted.'),
|
|
3929
3920
|
},
|
|
3930
3921
|
},
|
|
3931
|
-
async ({ kind, name, ref, tier
|
|
3922
|
+
async ({ kind, name, ref, tier }) => {
|
|
3932
3923
|
let res
|
|
3933
3924
|
try {
|
|
3934
3925
|
res = await fetchCortex(`${BASE}/api/brain/page-privacy`, {
|
|
3935
3926
|
method: 'POST',
|
|
3936
3927
|
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
3937
|
-
body: JSON.stringify({ kind, ...(name ? { name } : {}), ...(ref ? { ref } : {}), tier
|
|
3928
|
+
body: JSON.stringify({ kind, ...(name ? { name } : {}), ...(ref ? { ref } : {}), tier }),
|
|
3938
3929
|
})
|
|
3939
3930
|
} catch (e) {
|
|
3940
3931
|
return toolError(`Could not set page privacy: ${e.message}`)
|
|
3941
3932
|
}
|
|
3942
3933
|
const out = await res.json().catch(() => null)
|
|
3943
3934
|
if (!res.ok) {
|
|
3944
|
-
//
|
|
3945
|
-
|
|
3946
|
-
if (out?.error) {
|
|
3947
|
-
const extra = out.collision === 'readable' && out.blocking
|
|
3948
|
-
? `\nYour ${out.source?.tier} page: ${out.source?.summary ?? out.source?.title}\nExisting ${out.blocking.tier} page (current version: ${out.blocking.version ?? 'none — this page predates content-hash tracking and cannot be re-authored via base_version; ask an admin about a backfill'}): ${out.blocking.summary ?? out.blocking.title}`
|
|
3949
|
-
: ''
|
|
3950
|
-
return toolError(`Could not set page privacy: ${out.error}${extra}`)
|
|
3951
|
-
}
|
|
3935
|
+
// The server's refusals carry their own remedy — surface the message verbatim.
|
|
3936
|
+
if (out?.error) return toolError(`Could not set page privacy: ${out.error}`)
|
|
3952
3937
|
const d = classify(res.status, res.headers.get('content-type'), '', res.headers.get('x-vercel-id'))
|
|
3953
3938
|
return toolError(`Could not set page privacy: ${d.message}`)
|
|
3954
3939
|
}
|
|
@@ -3959,60 +3944,6 @@ function renderNudge(payload) {
|
|
|
3959
3944
|
},
|
|
3960
3945
|
)
|
|
3961
3946
|
|
|
3962
|
-
server.registerTool(
|
|
3963
|
-
'replace_variant',
|
|
3964
|
-
{
|
|
3965
|
-
title: 'Move one tier variant\'s body into another, collapsing the node to one page',
|
|
3966
|
-
description: 'DESTRUCTIVE, and the only sanctioned way to fix a FORKED page. When one node exists at two tiers with different bodies, this moves the SOURCE variant\'s body into the TARGET variant\'s slot and DELETES the source, leaving the node with a single page. The source\'s body WINS — this is not `absorb`, where the target survives; in a fork repair the target is usually the damaged page, so mirroring absorb would keep the damage and delete the good copy. Sections are copied as ROWS, never re-derived from text: re-deriving a body from context is exactly what destroyed 258 sections on 2026-07-18 while sincerely reporting "copied verbatim". BEFORE CALLING: read_page the TARGET and pass its version as target_version — it proves you know which body is about to be overwritten, and a stale or guessed value is rejected rather than silently accepted. If the target tier has NO page, do not use this: the slot is free, so set_page_privacy moves the page there cheaply. Both prior bodies are retained in page history and the operation is reversible via page_history/rollback_page. Owner or editor on BOTH variants; an OWNERLESS target may be overwritten only by an org admin.',
|
|
3967
|
-
inputSchema: {
|
|
3968
|
-
kind: z.enum(['project', 'person', 'org', 'user']).describe('the page kind'),
|
|
3969
|
-
name: z.string().optional().describe('the exact page name (or pass `ref` instead — one of the two is required)'),
|
|
3970
|
-
ref: z.string().optional().describe('the page\'s stable id, printed as `ref:` by read_page. PREFER THIS over name when you have it: a ref is unique across brains, so it addresses exactly one page and never needs a brain to disambiguate it.'),
|
|
3971
|
-
source_tier: z.enum(['accessible', 'scoped', 'confidential']).describe('the variant whose BODY WINS and survives. This variant\'s row is then deleted.'),
|
|
3972
|
-
target_tier: z.enum(['accessible', 'scoped', 'confidential']).describe('the OCCUPIED slot the body lands in. This variant\'s current body is DESTROYED (snapshotted to page history first). The surviving page sits at this tier.'),
|
|
3973
|
-
target_version: z.string().describe('REQUIRED — the TARGET page\'s version, from read_page. Proves you know what is being overwritten. Do not retry a rejection blindly; re-read the target and confirm you are replacing what you think you are.'),
|
|
3974
|
-
brain: z.string().optional().describe('only when the same page name exists in more than one of your brains'),
|
|
3975
|
-
},
|
|
3976
|
-
},
|
|
3977
|
-
async ({ kind, name, ref, source_tier, target_tier, target_version, brain }) => {
|
|
3978
|
-
let res
|
|
3979
|
-
try {
|
|
3980
|
-
res = await fetchCortex(`${BASE}/api/brain/replace-variant`, {
|
|
3981
|
-
method: 'POST',
|
|
3982
|
-
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
3983
|
-
body: JSON.stringify({
|
|
3984
|
-
kind, ...(name ? { name } : {}), ...(ref ? { ref } : {}),
|
|
3985
|
-
source_tier, target_tier, target_version, ...(brain ? { brain } : {}),
|
|
3986
|
-
}),
|
|
3987
|
-
})
|
|
3988
|
-
} catch (e) {
|
|
3989
|
-
return toolError(`Could not replace variant: ${e.message}`)
|
|
3990
|
-
}
|
|
3991
|
-
const out = await res.json().catch(() => null)
|
|
3992
|
-
if (!res.ok) {
|
|
3993
|
-
// The engine's rejections carry the remedy in their text (free slot → use set_page_privacy;
|
|
3994
|
-
// hash mismatch → re-read, do not retry blindly). Surface it verbatim rather than paraphrasing.
|
|
3995
|
-
if (out?.error) return toolError(`Could not replace variant: ${out.error}`)
|
|
3996
|
-
const d = classify(res.status, res.headers.get('content-type'), '', res.headers.get('x-vercel-id'))
|
|
3997
|
-
return toolError(`Could not replace variant: ${d.message}`)
|
|
3998
|
-
}
|
|
3999
|
-
if (!out) return { content: [{ type: 'text', text: 'Replace reported success, but the server returned no body — re-read the page before assuming it landed.' }] }
|
|
4000
|
-
// The summary rides along with the body, EXCEPT when the source has none — then the target's is
|
|
4001
|
-
// kept rather than erased. Say so on its own line rather than at the tail of `note`: this is the
|
|
4002
|
-
// 2026-08-04 W3 shape, where an empty summary copied over a populated one destroyed 31 of them
|
|
4003
|
-
// under a report that read as success. A rescue the operator does not see is still a silent write.
|
|
4004
|
-
const rescued = out.summaryRescued
|
|
4005
|
-
? `\n\n⚠ The ${out.moved.from} variant had NO summary. The ${out.moved.to} page's own summary was KEPT rather than overwritten with an empty one — check it still describes the body that just landed, and set_summary if not.`
|
|
4006
|
-
: ''
|
|
4007
|
-
return {
|
|
4008
|
-
content: [{
|
|
4009
|
-
type: 'text',
|
|
4010
|
-
text: `Done — "${out.moved.title}": the ${out.moved.from} body now occupies the ${out.moved.to} page (${out.sections} section${out.sections === 1 ? '' : 's'}), and the ${out.moved.from} variant was removed. The node now has ONE variant. Both prior bodies are retained in page history.${rescued}`,
|
|
4011
|
-
}],
|
|
4012
|
-
}
|
|
4013
|
-
},
|
|
4014
|
-
)
|
|
4015
|
-
|
|
4016
3947
|
server.registerTool(
|
|
4017
3948
|
'grant_page_access',
|
|
4018
3949
|
{
|
|
@@ -4024,16 +3955,15 @@ function renderNudge(payload) {
|
|
|
4024
3955
|
ref: z.string().optional().describe('the page\'s stable id, printed as `ref:` by read_page. Prefer this over name: unique across brains, so it addresses exactly one page.'),
|
|
4025
3956
|
grantee: z.string().describe('display name or email of a member OF THE PAGE\'S BRAIN (must resolve uniquely — use email if ambiguous)'),
|
|
4026
3957
|
action: z.enum(['grant', 'revoke']).describe('grant or revoke'),
|
|
4027
|
-
tier: z.enum(['scoped', 'confidential']).optional().describe('which variant (default: the most restrictive one)'),
|
|
4028
3958
|
},
|
|
4029
3959
|
},
|
|
4030
|
-
async ({ kind, name, ref, grantee, action
|
|
3960
|
+
async ({ kind, name, ref, grantee, action }) => {
|
|
4031
3961
|
let res
|
|
4032
3962
|
try {
|
|
4033
3963
|
res = await fetchCortex(`${BASE}/api/brain/page-grants`, {
|
|
4034
3964
|
method: 'POST',
|
|
4035
3965
|
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
4036
|
-
body: JSON.stringify({ kind, ...(name ? { name } : {}), ...(ref ? { ref } : {}), grantee, action
|
|
3966
|
+
body: JSON.stringify({ kind, ...(name ? { name } : {}), ...(ref ? { ref } : {}), grantee, action }),
|
|
4037
3967
|
})
|
|
4038
3968
|
} catch (e) {
|
|
4039
3969
|
return toolError(`Could not ${action}: ${e.message}`)
|
|
@@ -4049,7 +3979,7 @@ function renderNudge(payload) {
|
|
|
4049
3979
|
'list_page_grants',
|
|
4050
3980
|
{
|
|
4051
3981
|
title: 'List who has granted access to your page',
|
|
4052
|
-
description: 'Show every explicit access grant on YOUR
|
|
3982
|
+
description: 'Show every explicit access grant on YOUR pages (owner-only). Use after re-tiering a page — grants survive tier changes and keep granting until revoked.',
|
|
4053
3983
|
inputSchema: {
|
|
4054
3984
|
kind: z.enum(['project', 'person', 'org', 'user']).describe('the page kind'),
|
|
4055
3985
|
name: z.string().optional().describe('the exact page name (or pass `ref` instead — one of the two is required)'),
|
|
@@ -4069,7 +3999,7 @@ function renderNudge(payload) {
|
|
|
4069
3999
|
const out = await res.json().catch(() => null)
|
|
4070
4000
|
if (!res.ok) return toolError(`Could not list grants: ${out?.error ?? res.status}`)
|
|
4071
4001
|
if (!out.grants?.length) return { content: [{ type: 'text', text: 'No grants on this page.' }] }
|
|
4072
|
-
const lines = out.grants.map((g) => `- ${g.grantee_name ?? g.grantee_user_id} → ${g.tier}
|
|
4002
|
+
const lines = out.grants.map((g) => `- ${g.grantee_name ?? g.grantee_user_id} → ${g.tier} page (since ${String(g.created_at).slice(0, 10)})`)
|
|
4073
4003
|
return { content: [{ type: 'text', text: `Grants (${out.grants.length}):\n${lines.join('\n')}` }] }
|
|
4074
4004
|
},
|
|
4075
4005
|
)
|
|
@@ -4100,32 +4030,6 @@ function renderNudge(payload) {
|
|
|
4100
4030
|
},
|
|
4101
4031
|
)
|
|
4102
4032
|
|
|
4103
|
-
server.registerTool(
|
|
4104
|
-
'page_merge_requests',
|
|
4105
|
-
{
|
|
4106
|
-
title: 'Merge requests on pages you own',
|
|
4107
|
-
description: 'Someone tried to move their page into a tier slot your page occupies (they only saw "slot occupied"). Review pending requests here; read their variant, merge anything worth keeping into your page via author, then decide with decide_page_merge.',
|
|
4108
|
-
inputSchema: {},
|
|
4109
|
-
},
|
|
4110
|
-
async () => {
|
|
4111
|
-
let res
|
|
4112
|
-
try {
|
|
4113
|
-
res = await fetchCortex(`${BASE}/api/brain/page-merge-requests`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
4114
|
-
} catch (e) {
|
|
4115
|
-
return toolError(`Could not list merge requests: ${e.message}`)
|
|
4116
|
-
}
|
|
4117
|
-
if (!res.ok) {
|
|
4118
|
-
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
4119
|
-
return toolError(`Could not list merge requests: ${d.message}`)
|
|
4120
|
-
}
|
|
4121
|
-
const { requests } = await res.json()
|
|
4122
|
-
if (!requests?.length) return { content: [{ type: 'text', text: 'No pending page merge requests.' }] }
|
|
4123
|
-
const lines = requests.map((q) =>
|
|
4124
|
-
`- [${q.id}] ${q.requester_name ?? 'someone'} wants their ${q.kind} page merged into your ${q.requested_tier} "${q.page_title}". Merge via author first, then decide_page_merge.`)
|
|
4125
|
-
return { content: [{ type: 'text', text: `Pending page merge requests (${requests.length}):\n${lines.join('\n')}` }] }
|
|
4126
|
-
},
|
|
4127
|
-
)
|
|
4128
|
-
|
|
4129
4033
|
server.registerTool(
|
|
4130
4034
|
'request_person_page_merge',
|
|
4131
4035
|
{
|
|
@@ -4198,33 +4102,6 @@ function renderNudge(payload) {
|
|
|
4198
4102
|
},
|
|
4199
4103
|
)
|
|
4200
4104
|
|
|
4201
|
-
server.registerTool(
|
|
4202
|
-
'decide_page_merge',
|
|
4203
|
-
{
|
|
4204
|
-
title: 'Approve or deny a page merge request',
|
|
4205
|
-
description: 'Decide a pending page merge request you own. IMPORTANT: approve only AFTER you have merged whatever of the requester\'s content you want into your page (via author) — approving DELETES their variant of the node. Deny closes the request and nothing moves.',
|
|
4206
|
-
inputSchema: {
|
|
4207
|
-
id: z.string().describe('the request id from page_merge_requests'),
|
|
4208
|
-
decision: z.enum(['approve', 'deny']).describe('approve = their variant is removed (merge first!); deny = nothing moves'),
|
|
4209
|
-
},
|
|
4210
|
-
},
|
|
4211
|
-
async ({ id, decision }) => {
|
|
4212
|
-
let res
|
|
4213
|
-
try {
|
|
4214
|
-
res = await fetchCortex(`${BASE}/api/brain/page-merge-requests`, {
|
|
4215
|
-
method: 'POST',
|
|
4216
|
-
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
4217
|
-
body: JSON.stringify({ id, decision }),
|
|
4218
|
-
})
|
|
4219
|
-
} catch (e) {
|
|
4220
|
-
return toolError(`Could not decide: ${e.message}`)
|
|
4221
|
-
}
|
|
4222
|
-
const out = await res.json().catch(() => null)
|
|
4223
|
-
if (!res.ok) return toolError(`Could not decide: ${out?.error ?? res.status}`)
|
|
4224
|
-
return { content: [{ type: 'text', text: out.decision === 'denied' ? 'Denied — nothing moved.' : `Approved — removed variant(s): ${out.removed_variants.join(', ') || 'none remained'}. ${out.note}` }] }
|
|
4225
|
-
},
|
|
4226
|
-
)
|
|
4227
|
-
|
|
4228
4105
|
// ── File requests: ask the owner for a record's FULL original (lives on their machine) ──
|
|
4229
4106
|
const fail = (verb, res) => async () =>
|
|
4230
4107
|
toolError(`Could not ${verb}: ${classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id')).message}`)
|
|
@@ -4484,8 +4361,7 @@ function renderNudge(payload) {
|
|
|
4484
4361
|
// last chance to notice a page was fixed somewhere nobody reads.
|
|
4485
4362
|
const corrections = Array.isArray(out?.tierCorrections) && out.tierCorrections.length
|
|
4486
4363
|
? `\n⚠ TIER: this wrote to ${out.tierCorrections.map((c) => `${c.actualTier} (you asked for ${c.requestedTier})`).join('; ')}.` +
|
|
4487
|
-
`
|
|
4488
|
-
` a page can exist at several tiers and they drift apart independently.`
|
|
4364
|
+
` A page has one tier and author writes at it; to change who can see the page, use set_page_privacy.`
|
|
4489
4365
|
: ''
|
|
4490
4366
|
// KWA-26 — the advisory undated flag. The server has computed `undatedSections` since #558 and
|
|
4491
4367
|
// the route has returned it ever since; NOTHING PRINTED IT, so the one consumer the item names
|
package/package.json
CHANGED