@theronap/agnoclast-mcp 0.9.151 → 0.9.153
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 +66 -128
- 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
|
@@ -78,6 +78,33 @@ export const sectionCurrencyStamp = (s, day) => {
|
|
|
78
78
|
// invisible on exactly the sections that were healthy.
|
|
79
79
|
export const renderSection = (s, day) => `### ${s.heading}${sectionCurrencyStamp(s, day)}\n${s.body}`
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* ADR-0062 §5.1. A materialize PREVIEW goes to its own route: a server too old to know it answers 404,
|
|
83
|
+
* where /api/intake/materialize would ignore a `mode` it does not know and publish.
|
|
84
|
+
*/
|
|
85
|
+
export function intakeMaterializePath(mode) {
|
|
86
|
+
return mode === 'preview' ? '/api/intake/preview' : '/api/intake/materialize'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** ADR-0062 §5.2. Which page governs a materialized record, and who chose it — or '' for an older server. */
|
|
90
|
+
export function renderGoverningLine(governing) {
|
|
91
|
+
if (!governing || typeof governing !== 'object') return ''
|
|
92
|
+
const name = governing.title || 'a page'
|
|
93
|
+
const who = governing.designatedBy === 'session' ? 'chosen by you'
|
|
94
|
+
: governing.designatedBy === 'human' ? 'chosen by a person'
|
|
95
|
+
: 'chosen by the ranking — pass governingPage to decide it yourself'
|
|
96
|
+
return `Governed by ${name} (${governing.tier}) — ${who}.`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The one line a preview leads with, so it cannot be mistaken for a publish. */
|
|
100
|
+
export function renderIntakePreviewLine(body) {
|
|
101
|
+
const p = (body && typeof body === 'object' && body.placement) || {}
|
|
102
|
+
const pages = Array.isArray(p.pages) ? p.pages.length : 0
|
|
103
|
+
const g = p.governing
|
|
104
|
+
const gov = g ? `${g.title || g.kind} (${g.tier})${p.governingJudged ? ', your choice' : ''}` : 'none'
|
|
105
|
+
return `PREVIEW — nothing was written. Would attach ${pages} page(s); governing: ${gov}; born ${p.tier ?? 'unknown'}.`
|
|
106
|
+
}
|
|
107
|
+
|
|
81
108
|
// TOP-OF-PAGE NOTES — ADR-0065's size note, and ADR-0064 §3's "Also visible to you" callout.
|
|
82
109
|
//
|
|
83
110
|
// ⚠ PRINTED VERBATIM, NEVER COMPOSED HERE. The server decides whether a note fires and what it says
|
|
@@ -88,6 +115,11 @@ export const renderSection = (s, day) => `### ${s.heading}${sectionCurrencyStamp
|
|
|
88
115
|
// it from offset 0, so the footer of a huge page is the part nobody reaches — and these notes exist for
|
|
89
116
|
// exactly those pages. Shared by read_page and project_status so the two cannot drift, which is how every
|
|
90
117
|
// earlier fix to this file's page rendering landed on one surface and not the other.
|
|
118
|
+
// v1 SCOPE (Theron, 2026-09-16): page splitting moves to v2 and the server refuses it with
|
|
119
|
+
// `splitting_unavailable`. split_page is not registered, so agents do not see a verb that can only refuse.
|
|
120
|
+
// The definition stays for v2: flip this together with the server's SPLITTING_AVAILABLE.
|
|
121
|
+
export const SPLIT_PAGE_TOOL_AVAILABLE = false
|
|
122
|
+
|
|
91
123
|
export const renderHeaderNotes = (notes) => {
|
|
92
124
|
const lines = (Array.isArray(notes) ? notes : [])
|
|
93
125
|
.filter((n) => typeof n === 'string' && n.trim() !== '')
|
|
@@ -877,7 +909,8 @@ function renderNudge(payload) {
|
|
|
877
909
|
{
|
|
878
910
|
title: 'Claim private intake items',
|
|
879
911
|
description:
|
|
880
|
-
'Claim a lease on private intake items (relevance or cleanup). Returns decrypted payloads for the lease holder only. Requires x-cortex-session-key (set automatically by this MCP server). Aged work never blocks a claim: the response reports cleanupDueCount and how far behind the oldest unit is, and servicing it is expected but always your call. Claiming is a commitment to process — hand back anything you will not finish with intake_release, or intake_defer if it is the owner\'s decision to make.'
|
|
912
|
+
'Claim a lease on private intake items (relevance or cleanup). Returns decrypted payloads for the lease holder only. Requires x-cortex-session-key (set automatically by this MCP server). Aged work never blocks a claim: the response reports cleanupDueCount and how far behind the oldest unit is, and servicing it is expected but always your call. Claiming is a commitment to process — hand back anything you will not finish with intake_release, or intake_defer if it is the owner\'s decision to make. ' +
|
|
913
|
+
'YOU ARE THE JUDGE of every unit you claim (ADR-0062): nothing is published until you materialize, and materializing is when it becomes visible. Each unit carries a `proposal` — the brain its signals point to and, for that brain, the pages it would attach to, the page that would govern it, and the `tier` it would be born with — computed without writing anything. Decide the brain, the pages (confirm, drop or add), which page should govern (the page whose reasons for restricting its audience fit the content, not a person page), and obligations. Pages only tighten a tier; if a record should be LOOSER than `tierReason.baseline`, that is the owner\'s call — intake_defer with the question.',
|
|
881
914
|
inputSchema: {
|
|
882
915
|
claimKind: z.enum(['relevance', 'cleanup']).optional().describe('default relevance'),
|
|
883
916
|
limit: z.number().optional().describe('max items (default 10). Ignored when intakeItemIds is given.'),
|
|
@@ -947,7 +980,9 @@ function renderNudge(payload) {
|
|
|
947
980
|
{
|
|
948
981
|
title: 'Materialize a private intake item',
|
|
949
982
|
description:
|
|
950
|
-
'Atomically publish a claimed intake item into one brain. Deterministic identifier homes in that brain are always attached; documentIds may add further pages.
|
|
983
|
+
'Atomically publish a claimed intake item into one brain — the disclosure moment (ADR-0062). Deterministic identifier homes in that brain are always attached; documentIds may add further pages. The record is born at the strictest of its source baseline, what the producer asked for, and every attached page; a source floor (iMessage, claude-code) pins it. ' +
|
|
984
|
+
'Name the governing page with governingPage (one of documentIds, never a person page) — otherwise the ranking picks it. ' +
|
|
985
|
+
'Pass mode "preview" first when your choice differs from the claim\'s proposal: it returns the pages, the governing page and the tier this exact call would produce, and writes nothing. Never writes private intake into search/history before this call.',
|
|
951
986
|
inputSchema: {
|
|
952
987
|
intakeItemId: z.string().describe('intake item uuid'),
|
|
953
988
|
orgId: z.string().describe('destination brain org uuid'),
|
|
@@ -958,10 +993,21 @@ function renderNudge(payload) {
|
|
|
958
993
|
recordType: z.string().optional(),
|
|
959
994
|
dedupeKey: z.string().optional(),
|
|
960
995
|
origin: z.enum(['deterministic', 'llm', 'user', 'session']).optional(),
|
|
996
|
+
governingPage: z.string().optional().describe(
|
|
997
|
+
'the page that should GOVERN this record (ADR-0062) — a page `ref:` or document id, and one of documentIds. ' +
|
|
998
|
+
'Choose the page whose reasons for restricting its audience fit the content; never a person page. It can only ' +
|
|
999
|
+
'tighten the record, and the ranking will not move it. Omit to leave the choice to the ranking.',
|
|
1000
|
+
),
|
|
1001
|
+
mode: z.enum(['preview', 'apply']).optional().describe(
|
|
1002
|
+
'"preview": report the pages, governing page and tier this call would produce, and write nothing. Default "apply".',
|
|
1003
|
+
),
|
|
961
1004
|
},
|
|
962
1005
|
},
|
|
963
1006
|
async (args) => {
|
|
964
|
-
|
|
1007
|
+
// A preview goes to its OWN route: a server too old to know it answers 404, where the materialize
|
|
1008
|
+
// route would ignore a `mode` it does not know and publish.
|
|
1009
|
+
const preview = args.mode === 'preview'
|
|
1010
|
+
const res = await fetchCortex(`${BASE}${intakeMaterializePath(args.mode)}`, {
|
|
965
1011
|
method: 'POST',
|
|
966
1012
|
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
967
1013
|
body: JSON.stringify({
|
|
@@ -976,13 +1022,20 @@ function renderNudge(payload) {
|
|
|
976
1022
|
dedupe_key: args.dedupeKey,
|
|
977
1023
|
},
|
|
978
1024
|
attachmentMeta: (args.documentIds ?? []).map(() => ({ origin: args.origin ?? 'llm' })),
|
|
1025
|
+
...(args.governingPage ? { governingDocumentId: args.governingPage } : {}),
|
|
979
1026
|
}),
|
|
980
1027
|
})
|
|
981
1028
|
if (!res.ok) {
|
|
982
1029
|
const body = await res.text()
|
|
1030
|
+
if (preview && res.status === 404) {
|
|
1031
|
+
return toolError('This server cannot preview a materialization yet — nothing was written. Materialize without mode, or wait for the server to update.')
|
|
1032
|
+
}
|
|
983
1033
|
return toolError(body)
|
|
984
1034
|
}
|
|
985
1035
|
const body = await res.json()
|
|
1036
|
+
if (preview) {
|
|
1037
|
+
return { content: [{ type: 'text', text: `${renderIntakePreviewLine(body)}\n\n${JSON.stringify(body, null, 2)}` }] }
|
|
1038
|
+
}
|
|
986
1039
|
// WHERE THE SESSION'S OWN WORK LANDED, in words rather than buried in the JSON dump.
|
|
987
1040
|
//
|
|
988
1041
|
// Three outcomes that look identical in raw JSON and mean completely different things:
|
|
@@ -999,6 +1052,8 @@ function renderNudge(payload) {
|
|
|
999
1052
|
// the ordinary case (observed 2026-09-03 on the first handoff record materialised). Silence is
|
|
1000
1053
|
// correct here, and it also covers a server too old to send the field at all.
|
|
1001
1054
|
const lines = []
|
|
1055
|
+
const governingLine = renderGoverningLine(body?.governing)
|
|
1056
|
+
if (governingLine) lines.push(governingLine)
|
|
1002
1057
|
const sa = body?.sessionAttachments
|
|
1003
1058
|
if (sa === null) {
|
|
1004
1059
|
lines.push('⚠ Session-page attachment FAILED for this record — it was written, but not filed under the pages this session worked on. Re-run the reconciler.')
|
|
@@ -1737,18 +1792,17 @@ function renderNudge(payload) {
|
|
|
1737
1792
|
name: z.string().describe('the exact page name'),
|
|
1738
1793
|
kind: z.enum(['project', 'person', 'org', 'user']).optional().describe('node kind (default project)'),
|
|
1739
1794
|
to_version: z.string().describe('which version to restore: a rev_no (e.g. "3") or a content_hash, from page_history'),
|
|
1740
|
-
tier: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('which tier variant to roll back, if the node has more than one'),
|
|
1741
1795
|
reason: z.string().optional().describe('optional note recorded on the new rollback version (why you rolled back)'),
|
|
1742
1796
|
},
|
|
1743
1797
|
},
|
|
1744
|
-
async ({ name, kind, to_version,
|
|
1798
|
+
async ({ name, kind, to_version, reason }) => {
|
|
1745
1799
|
const k = kind ?? 'project'
|
|
1746
1800
|
let res
|
|
1747
1801
|
try {
|
|
1748
1802
|
res = await fetchCortex(`${BASE}/api/brain/rollback`, {
|
|
1749
1803
|
method: 'POST',
|
|
1750
1804
|
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
1751
|
-
body: JSON.stringify({ kind: k, name, to_version, ...(
|
|
1805
|
+
body: JSON.stringify({ kind: k, name, to_version, ...(reason ? { reason } : {}) }),
|
|
1752
1806
|
})
|
|
1753
1807
|
} catch (e) {
|
|
1754
1808
|
return toolError(`Could not roll back "${name}": ${e.message}`)
|
|
@@ -1880,7 +1934,7 @@ function renderNudge(payload) {
|
|
|
1880
1934
|
},
|
|
1881
1935
|
)
|
|
1882
1936
|
|
|
1883
|
-
server.registerTool(
|
|
1937
|
+
if (SPLIT_PAGE_TOOL_AVAILABLE) server.registerTool(
|
|
1884
1938
|
'split_page',
|
|
1885
1939
|
{
|
|
1886
1940
|
title: 'Move sections onto a new child page',
|
|
@@ -3862,38 +3916,29 @@ function renderNudge(payload) {
|
|
|
3862
3916
|
'set_page_privacy',
|
|
3863
3917
|
{
|
|
3864
3918
|
title: 'Change who can see a wiki page',
|
|
3865
|
-
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.
|
|
3919
|
+
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.',
|
|
3866
3920
|
inputSchema: {
|
|
3867
3921
|
kind: z.enum(['project', 'person', 'org', 'user']).describe('the page kind'),
|
|
3868
3922
|
name: z.string().optional().describe('the exact page name (or pass `ref` instead — one of the two is required)'),
|
|
3869
3923
|
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.'),
|
|
3870
3924
|
tier: z.enum(['accessible', 'scoped', 'confidential']).describe('the new visibility tier'),
|
|
3871
|
-
source_tier: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('when the node has multiple tier variants: which one to move'),
|
|
3872
|
-
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.'),
|
|
3873
|
-
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.'),
|
|
3874
3925
|
},
|
|
3875
3926
|
},
|
|
3876
|
-
async ({ kind, name, ref, tier
|
|
3927
|
+
async ({ kind, name, ref, tier }) => {
|
|
3877
3928
|
let res
|
|
3878
3929
|
try {
|
|
3879
3930
|
res = await fetchCortex(`${BASE}/api/brain/page-privacy`, {
|
|
3880
3931
|
method: 'POST',
|
|
3881
3932
|
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
3882
|
-
body: JSON.stringify({ kind, ...(name ? { name } : {}), ...(ref ? { ref } : {}), tier
|
|
3933
|
+
body: JSON.stringify({ kind, ...(name ? { name } : {}), ...(ref ? { ref } : {}), tier }),
|
|
3883
3934
|
})
|
|
3884
3935
|
} catch (e) {
|
|
3885
3936
|
return toolError(`Could not set page privacy: ${e.message}`)
|
|
3886
3937
|
}
|
|
3887
3938
|
const out = await res.json().catch(() => null)
|
|
3888
3939
|
if (!res.ok) {
|
|
3889
|
-
//
|
|
3890
|
-
|
|
3891
|
-
if (out?.error) {
|
|
3892
|
-
const extra = out.collision === 'readable' && out.blocking
|
|
3893
|
-
? `\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}`
|
|
3894
|
-
: ''
|
|
3895
|
-
return toolError(`Could not set page privacy: ${out.error}${extra}`)
|
|
3896
|
-
}
|
|
3940
|
+
// The server's refusals carry their own remedy — surface the message verbatim.
|
|
3941
|
+
if (out?.error) return toolError(`Could not set page privacy: ${out.error}`)
|
|
3897
3942
|
const d = classify(res.status, res.headers.get('content-type'), '', res.headers.get('x-vercel-id'))
|
|
3898
3943
|
return toolError(`Could not set page privacy: ${d.message}`)
|
|
3899
3944
|
}
|
|
@@ -3904,60 +3949,6 @@ function renderNudge(payload) {
|
|
|
3904
3949
|
},
|
|
3905
3950
|
)
|
|
3906
3951
|
|
|
3907
|
-
server.registerTool(
|
|
3908
|
-
'replace_variant',
|
|
3909
|
-
{
|
|
3910
|
-
title: 'Move one tier variant\'s body into another, collapsing the node to one page',
|
|
3911
|
-
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.',
|
|
3912
|
-
inputSchema: {
|
|
3913
|
-
kind: z.enum(['project', 'person', 'org', 'user']).describe('the page kind'),
|
|
3914
|
-
name: z.string().optional().describe('the exact page name (or pass `ref` instead — one of the two is required)'),
|
|
3915
|
-
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.'),
|
|
3916
|
-
source_tier: z.enum(['accessible', 'scoped', 'confidential']).describe('the variant whose BODY WINS and survives. This variant\'s row is then deleted.'),
|
|
3917
|
-
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.'),
|
|
3918
|
-
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.'),
|
|
3919
|
-
brain: z.string().optional().describe('only when the same page name exists in more than one of your brains'),
|
|
3920
|
-
},
|
|
3921
|
-
},
|
|
3922
|
-
async ({ kind, name, ref, source_tier, target_tier, target_version, brain }) => {
|
|
3923
|
-
let res
|
|
3924
|
-
try {
|
|
3925
|
-
res = await fetchCortex(`${BASE}/api/brain/replace-variant`, {
|
|
3926
|
-
method: 'POST',
|
|
3927
|
-
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
3928
|
-
body: JSON.stringify({
|
|
3929
|
-
kind, ...(name ? { name } : {}), ...(ref ? { ref } : {}),
|
|
3930
|
-
source_tier, target_tier, target_version, ...(brain ? { brain } : {}),
|
|
3931
|
-
}),
|
|
3932
|
-
})
|
|
3933
|
-
} catch (e) {
|
|
3934
|
-
return toolError(`Could not replace variant: ${e.message}`)
|
|
3935
|
-
}
|
|
3936
|
-
const out = await res.json().catch(() => null)
|
|
3937
|
-
if (!res.ok) {
|
|
3938
|
-
// The engine's rejections carry the remedy in their text (free slot → use set_page_privacy;
|
|
3939
|
-
// hash mismatch → re-read, do not retry blindly). Surface it verbatim rather than paraphrasing.
|
|
3940
|
-
if (out?.error) return toolError(`Could not replace variant: ${out.error}`)
|
|
3941
|
-
const d = classify(res.status, res.headers.get('content-type'), '', res.headers.get('x-vercel-id'))
|
|
3942
|
-
return toolError(`Could not replace variant: ${d.message}`)
|
|
3943
|
-
}
|
|
3944
|
-
if (!out) return { content: [{ type: 'text', text: 'Replace reported success, but the server returned no body — re-read the page before assuming it landed.' }] }
|
|
3945
|
-
// The summary rides along with the body, EXCEPT when the source has none — then the target's is
|
|
3946
|
-
// kept rather than erased. Say so on its own line rather than at the tail of `note`: this is the
|
|
3947
|
-
// 2026-08-04 W3 shape, where an empty summary copied over a populated one destroyed 31 of them
|
|
3948
|
-
// under a report that read as success. A rescue the operator does not see is still a silent write.
|
|
3949
|
-
const rescued = out.summaryRescued
|
|
3950
|
-
? `\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.`
|
|
3951
|
-
: ''
|
|
3952
|
-
return {
|
|
3953
|
-
content: [{
|
|
3954
|
-
type: 'text',
|
|
3955
|
-
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}`,
|
|
3956
|
-
}],
|
|
3957
|
-
}
|
|
3958
|
-
},
|
|
3959
|
-
)
|
|
3960
|
-
|
|
3961
3952
|
server.registerTool(
|
|
3962
3953
|
'grant_page_access',
|
|
3963
3954
|
{
|
|
@@ -4045,32 +4036,6 @@ function renderNudge(payload) {
|
|
|
4045
4036
|
},
|
|
4046
4037
|
)
|
|
4047
4038
|
|
|
4048
|
-
server.registerTool(
|
|
4049
|
-
'page_merge_requests',
|
|
4050
|
-
{
|
|
4051
|
-
title: 'Merge requests on pages you own',
|
|
4052
|
-
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.',
|
|
4053
|
-
inputSchema: {},
|
|
4054
|
-
},
|
|
4055
|
-
async () => {
|
|
4056
|
-
let res
|
|
4057
|
-
try {
|
|
4058
|
-
res = await fetchCortex(`${BASE}/api/brain/page-merge-requests`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
4059
|
-
} catch (e) {
|
|
4060
|
-
return toolError(`Could not list merge requests: ${e.message}`)
|
|
4061
|
-
}
|
|
4062
|
-
if (!res.ok) {
|
|
4063
|
-
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
4064
|
-
return toolError(`Could not list merge requests: ${d.message}`)
|
|
4065
|
-
}
|
|
4066
|
-
const { requests } = await res.json()
|
|
4067
|
-
if (!requests?.length) return { content: [{ type: 'text', text: 'No pending page merge requests.' }] }
|
|
4068
|
-
const lines = requests.map((q) =>
|
|
4069
|
-
`- [${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.`)
|
|
4070
|
-
return { content: [{ type: 'text', text: `Pending page merge requests (${requests.length}):\n${lines.join('\n')}` }] }
|
|
4071
|
-
},
|
|
4072
|
-
)
|
|
4073
|
-
|
|
4074
4039
|
server.registerTool(
|
|
4075
4040
|
'request_person_page_merge',
|
|
4076
4041
|
{
|
|
@@ -4143,33 +4108,6 @@ function renderNudge(payload) {
|
|
|
4143
4108
|
},
|
|
4144
4109
|
)
|
|
4145
4110
|
|
|
4146
|
-
server.registerTool(
|
|
4147
|
-
'decide_page_merge',
|
|
4148
|
-
{
|
|
4149
|
-
title: 'Approve or deny a page merge request',
|
|
4150
|
-
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.',
|
|
4151
|
-
inputSchema: {
|
|
4152
|
-
id: z.string().describe('the request id from page_merge_requests'),
|
|
4153
|
-
decision: z.enum(['approve', 'deny']).describe('approve = their variant is removed (merge first!); deny = nothing moves'),
|
|
4154
|
-
},
|
|
4155
|
-
},
|
|
4156
|
-
async ({ id, decision }) => {
|
|
4157
|
-
let res
|
|
4158
|
-
try {
|
|
4159
|
-
res = await fetchCortex(`${BASE}/api/brain/page-merge-requests`, {
|
|
4160
|
-
method: 'POST',
|
|
4161
|
-
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
4162
|
-
body: JSON.stringify({ id, decision }),
|
|
4163
|
-
})
|
|
4164
|
-
} catch (e) {
|
|
4165
|
-
return toolError(`Could not decide: ${e.message}`)
|
|
4166
|
-
}
|
|
4167
|
-
const out = await res.json().catch(() => null)
|
|
4168
|
-
if (!res.ok) return toolError(`Could not decide: ${out?.error ?? res.status}`)
|
|
4169
|
-
return { content: [{ type: 'text', text: out.decision === 'denied' ? 'Denied — nothing moved.' : `Approved — removed variant(s): ${out.removed_variants.join(', ') || 'none remained'}. ${out.note}` }] }
|
|
4170
|
-
},
|
|
4171
|
-
)
|
|
4172
|
-
|
|
4173
4111
|
// ── File requests: ask the owner for a record's FULL original (lives on their machine) ──
|
|
4174
4112
|
const fail = (verb, res) => async () =>
|
|
4175
4113
|
toolError(`Could not ${verb}: ${classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id')).message}`)
|
package/package.json
CHANGED