@theronap/agnoclast-mcp 0.9.96
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/README.md +47 -0
- package/bin/cortex-mcp.mjs +223 -0
- package/lib/capture.mjs +470 -0
- package/lib/code_graph_cli.mjs +59 -0
- package/lib/context_log.mjs +92 -0
- package/lib/diagnose.mjs +360 -0
- package/lib/docs_scan.mjs +171 -0
- package/lib/doctor.mjs +117 -0
- package/lib/edge_extract.mjs +156 -0
- package/lib/editors/_fsutil.mjs +31 -0
- package/lib/editors/antigravity.mjs +130 -0
- package/lib/editors/claude.mjs +202 -0
- package/lib/editors/codex.mjs +111 -0
- package/lib/editors/cursor.mjs +77 -0
- package/lib/editors/index.mjs +42 -0
- package/lib/extract_typed.mjs +68 -0
- package/lib/graphify_sync.mjs +134 -0
- package/lib/grep_cli.mjs +82 -0
- package/lib/hydrate.mjs +181 -0
- package/lib/imessage_send.mjs +88 -0
- package/lib/ingest_folder.mjs +170 -0
- package/lib/install.mjs +163 -0
- package/lib/login.mjs +148 -0
- package/lib/managed.mjs +49 -0
- package/lib/migrate_key.mjs +139 -0
- package/lib/presence.mjs +226 -0
- package/lib/publish_targets.mjs +51 -0
- package/lib/red_link_triage.mjs +37 -0
- package/lib/redact.mjs +40 -0
- package/lib/rename_notice.mjs +31 -0
- package/lib/resolve.mjs +153 -0
- package/lib/server.mjs +2986 -0
- package/lib/session_key.mjs +37 -0
- package/lib/setup.mjs +215 -0
- package/lib/skills.mjs +374 -0
- package/lib/statusline.mjs +67 -0
- package/lib/uninstall.mjs +237 -0
- package/lib/use_brain.mjs +82 -0
- package/lib/with_token.mjs +66 -0
- package/package.json +36 -0
- package/skills/author-docs/SKILL.md +74 -0
- package/skills/context/SKILL.md +25 -0
- package/skills/log/SKILL.md +114 -0
- package/skills/walkthrough/SKILL.md +189 -0
package/lib/server.mjs
ADDED
|
@@ -0,0 +1,2986 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
import { writeFileSync, mkdirSync } from 'fs'
|
|
5
|
+
import { homedir } from 'os'
|
|
6
|
+
import { join } from 'path'
|
|
7
|
+
import { createHash, randomUUID } from 'crypto'
|
|
8
|
+
import { fetchCortex, classify, resolveBase, resolveEnvToken, setSessionKey } from './diagnose.mjs'
|
|
9
|
+
import { resolveSessionKey, resolveLogSessionId } from './session_key.mjs'
|
|
10
|
+
import { runSendImessage } from './imessage_send.mjs'
|
|
11
|
+
import { formatGrepHits } from './grep_cli.mjs'
|
|
12
|
+
import { renderTriage } from './red_link_triage.mjs'
|
|
13
|
+
import { runCodeGraphQuery } from './code_graph_cli.mjs'
|
|
14
|
+
|
|
15
|
+
// Reactive red-link triage (Mechanism 2). On a read_page miss, ask the server whether the name is a
|
|
16
|
+
// tracked wanted page, whether a bare node exists for it, and whether it's a deliberately demoted page,
|
|
17
|
+
// and turn that into an actionable prompt. Wording lives in ./red_link_triage.mjs (pure + tested).
|
|
18
|
+
// Returns '' on any error so a miss never gets worse.
|
|
19
|
+
async function redLinkTriage(BASE, TOKEN, name) {
|
|
20
|
+
try {
|
|
21
|
+
const r = await fetchCortex(`${BASE}/api/brain/red-link?name=${encodeURIComponent(name)}`, {
|
|
22
|
+
headers: { Authorization: `Bearer ${TOKEN}` },
|
|
23
|
+
})
|
|
24
|
+
if (!r.ok) return ''
|
|
25
|
+
return renderTriage(await r.json(), name)
|
|
26
|
+
} catch {
|
|
27
|
+
return ''
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// MCP tool results carry an `isError` flag, and a failure that omits it is indistinguishable from a
|
|
32
|
+
// success at the protocol level — the caller just sees a normal result whose text happens to begin
|
|
33
|
+
// "Could not". This file had 41 tools, 64 failure returns and ZERO uses of isError, so nothing
|
|
34
|
+
// downstream could tell a rejected write from a completed one. Measured 2026-07-31: a walker over
|
|
35
|
+
// 397 transcripts scored 18 REJECTED `author` calls as successful writes for exactly this reason,
|
|
36
|
+
// and an agent skimming its own tool result is exposed the same way. Route every failure through
|
|
37
|
+
// here so the flag cannot be forgotten at a new call site.
|
|
38
|
+
//
|
|
39
|
+
// NAME: deliberately not `fail` — runServer already has a local `fail(verb, res)` for the
|
|
40
|
+
// file-request tools, and a module-level `fail` would be silently shadowed by it from that point on.
|
|
41
|
+
const toolError = (text) => ({ content: [{ type: 'text', text }], isError: true })
|
|
42
|
+
|
|
43
|
+
// SECTION CURRENCY (gate 3) — ONE renderer, used by BOTH read_page and project_status.
|
|
44
|
+
//
|
|
45
|
+
// Extracted as a PURE function on the pickBrainMatch precedent: that fix pulled a shared rule out of two
|
|
46
|
+
// resolvers specifically so they could not drift, after they drifted. This is the same situation found
|
|
47
|
+
// 2026-08-15. THREE surfaces render authored sections from the same server-computed fields:
|
|
48
|
+
// • renderAuthoredNodeBody (web/lib/engine/authored_page_tiers.ts) — console/web
|
|
49
|
+
// • read_page here — printed a date and "⚠ Undated section" as two adjacent lines
|
|
50
|
+
// • project_status here — printed NO currency at all: no as-of, no warning, nothing
|
|
51
|
+
// PR #558 fixed only the first. project_status is the tool the routing docs reach for FIRST, and gate 3
|
|
52
|
+
// reads "a reader can date any claim without a second query" — a reader there could date nothing.
|
|
53
|
+
//
|
|
54
|
+
// The two dates are DIFFERENT facts and both true: `asOf` is when the section's text last CHANGED; an
|
|
55
|
+
// explicit date in the prose is when the CLAIM was true. Stated as one sentence they inform; stacked as
|
|
56
|
+
// two lines they read as the page contradicting itself.
|
|
57
|
+
//
|
|
58
|
+
// ⚠ Do NOT "simplify" this by keying on `asOf` — it is set on every section always, so the detector
|
|
59
|
+
// would go silent everywhere. The branch must key on `hasExplicitDate`, and on `=== false` rather than
|
|
60
|
+
// falsy: `undefined` is an older server mid-rolling-deploy that has computed no verdict, and inventing
|
|
61
|
+
// one there is the 0093 don't-impute violation.
|
|
62
|
+
export const sectionCurrencyStamp = (s, day) => {
|
|
63
|
+
const on = s.asOf ? day(s.asOf) : ''
|
|
64
|
+
return s.hasExplicitDate === false
|
|
65
|
+
? `${on ? ` · text last written ${on} —` : ' —'} ⚠ the claim itself carries no date; verify before relying on it`
|
|
66
|
+
: (on ? ` · as of ${on}` : '')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ⚠ The newline is a FIX, not cosmetics. read_page's old form was `${asOf}${currency}${s.body}`, where
|
|
70
|
+
// only the UNDATED branch contributed a trailing \n — so every DATED section ran its body straight onto
|
|
71
|
+
// the header line ("· as of 2026-08-14Identifiers are candidates, never publication."). The defect was
|
|
72
|
+
// invisible on exactly the sections that were healthy.
|
|
73
|
+
export const renderSection = (s, day) => `### ${s.heading}${sectionCurrencyStamp(s, day)}\n${s.body}`
|
|
74
|
+
|
|
75
|
+
// The Agnoclast MCP server (stdio). Serves the signed-in employee's scoped org
|
|
76
|
+
// context to their AI assistant. CORTEX_TOKEN identifies the user + org.
|
|
77
|
+
|
|
78
|
+
export async function runServer(version) {
|
|
79
|
+
const TOKEN = resolveEnvToken().token
|
|
80
|
+
const BASE = resolveBase(process.env.CORTEX_URL)
|
|
81
|
+
|
|
82
|
+
if (!TOKEN) {
|
|
83
|
+
process.stderr.write('cortex-mcp: CORTEX_TOKEN is required. Get yours from the Agnoclast console → Connect your AI.\n')
|
|
84
|
+
process.exit(1)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Self active-sessions (ADR-0017): one MCP process = one AI session. A stable per-process key + the
|
|
88
|
+
// working dir identify this session; we heartbeat /api/session-ping while alive so the user can see
|
|
89
|
+
// all THEIR sessions via my_sessions. Self-only on the server; best-effort (never breaks serving).
|
|
90
|
+
//
|
|
91
|
+
// PGL-21 fix: "one MCP process = one AI session" is an assumption, not a guarantee — the HOST
|
|
92
|
+
// (Claude Code) can restart this stdio process mid-conversation (reconnects, tool-loading events),
|
|
93
|
+
// and a fresh randomUUID() on every restart silently orphaned the PRIOR process's session-scoped
|
|
94
|
+
// ADR-0022 deleted the write pointer, so a session no longer carries a brain of its own.
|
|
95
|
+
// stop taking effect one restart later, falling back to the account pointer with no visible cause
|
|
96
|
+
// — reproduced live 2026-07-27. Claude Code sets CLAUDE_CODE_SESSION_ID for the lifetime of one
|
|
97
|
+
// logical conversation across any number of subprocess restarts, so prefer it as the session key;
|
|
98
|
+
// fall back to a fresh randomUUID() for any other MCP host that doesn't set it (unchanged behavior
|
|
99
|
+
// there — no host that never had continuity loses anything). Bonus: my_sessions / session_presence
|
|
100
|
+
// (also keyed on SESSION_KEY, ADR-0017) stop fragmenting one conversation into several "sessions" too.
|
|
101
|
+
const SESSION_KEY = resolveSessionKey(process.env, randomUUID)
|
|
102
|
+
// ADR-0020 Stage 2: stamp this key on every outbound request (fetchCortex injects it) so writes
|
|
103
|
+
// resolve THIS session's brain pointer. Must be set BEFORE the first fetchCortex call below —
|
|
104
|
+
// otherwise the opening requests of a session would silently resolve by the account pointer.
|
|
105
|
+
setSessionKey(SESSION_KEY)
|
|
106
|
+
async function pingSession() {
|
|
107
|
+
try {
|
|
108
|
+
await fetchCortex(`${BASE}/api/session-ping`, {
|
|
109
|
+
method: 'POST',
|
|
110
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
111
|
+
// mcpVersion (ADR-0033 T9): the fleet currently has NO way to report which client build a
|
|
112
|
+
// seat runs — session-ping carried liveness only. That is why every version gate in this
|
|
113
|
+
// ADR was unobservable. Additive and forward-compatible: the route destructures sessionKey
|
|
114
|
+
// and cwd and ignores the rest, so this is inert until a column exists to store it.
|
|
115
|
+
body: JSON.stringify({ sessionKey: SESSION_KEY, cwd: process.cwd(), mcpVersion: version }),
|
|
116
|
+
})
|
|
117
|
+
} catch { /* best-effort heartbeat — never disrupt the session */ }
|
|
118
|
+
}
|
|
119
|
+
void pingSession()
|
|
120
|
+
const _hb = setInterval(() => void pingSession(), 60_000)
|
|
121
|
+
if (typeof _hb.unref === 'function') _hb.unref() // don't keep the process alive just for the heartbeat
|
|
122
|
+
|
|
123
|
+
// Cache context for 5 minutes so repeated tool calls don't re-fetch.
|
|
124
|
+
let cache = null
|
|
125
|
+
async function fetchContext() {
|
|
126
|
+
const now = Date.now()
|
|
127
|
+
if (cache && now - cache.ts < 5 * 60 * 1000) return cache.text
|
|
128
|
+
// fetchCortex retries transient infra/5xx; classify turns a failure into an honest message
|
|
129
|
+
// (token vs infra-block vs network) instead of a bare "Agnoclast API 403: unknown".
|
|
130
|
+
const res = await fetchCortex(`${BASE}/api/mcp-context`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
131
|
+
if (!res.ok) {
|
|
132
|
+
const body = await res.text()
|
|
133
|
+
throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
|
|
134
|
+
}
|
|
135
|
+
const { context, brainRefs } = await res.json()
|
|
136
|
+
cache = { text: context, ts: now }
|
|
137
|
+
// T11: stash the digest node refs surfaced this fetch so capture.mjs can forward them as
|
|
138
|
+
// hydrated_from at the session's ingest (feedback-loop guard). Keyed by cwd so the matching
|
|
139
|
+
// session picks them up. Best-effort + inert when brainRefs is empty (digest flag off).
|
|
140
|
+
try {
|
|
141
|
+
if (Array.isArray(brainRefs) && brainRefs.length) {
|
|
142
|
+
const dir = join(homedir(), '.cortex', 'brain-refs')
|
|
143
|
+
mkdirSync(dir, { recursive: true })
|
|
144
|
+
const key = createHash('sha1').update(process.cwd()).digest('hex').slice(0, 16)
|
|
145
|
+
writeFileSync(join(dir, `${key}.json`), JSON.stringify({ refs: brainRefs, ts: now }))
|
|
146
|
+
}
|
|
147
|
+
} catch { /* best-effort — never break context serving */ }
|
|
148
|
+
return context
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const server = new McpServer({ name: 'cortex', version })
|
|
152
|
+
|
|
153
|
+
server.registerTool(
|
|
154
|
+
'my_context',
|
|
155
|
+
{
|
|
156
|
+
title: 'My Agnoclast context',
|
|
157
|
+
description: 'Your current work context from the org. Pass a question to get query-centered session context seeded from the most relevant node and its neighborhood; omit it for the baseline snapshot.',
|
|
158
|
+
inputSchema: { question: z.string().optional().describe('optional opening user question to center the context around') },
|
|
159
|
+
},
|
|
160
|
+
async ({ question }) => {
|
|
161
|
+
if (!question?.trim()) return { content: [{ type: 'text', text: await fetchContext() }] }
|
|
162
|
+
const res = await fetchCortex(`${BASE}/api/session-context`, {
|
|
163
|
+
method: 'POST',
|
|
164
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
165
|
+
body: JSON.stringify({ question }),
|
|
166
|
+
})
|
|
167
|
+
if (!res.ok) {
|
|
168
|
+
const body = await res.text()
|
|
169
|
+
throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
|
|
170
|
+
}
|
|
171
|
+
const { context } = await res.json()
|
|
172
|
+
return { content: [{ type: 'text', text: context }] }
|
|
173
|
+
},
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
// Where this machine's unattended session captures land. The server half shipped 2026-08-09 with no
|
|
177
|
+
// client surface at all, so the only way to set it was a hand-written authenticated HTTP call —
|
|
178
|
+
// which meant three real users whose sessions were silently being held could not fix it themselves.
|
|
179
|
+
// This is the surface that makes it answerable in conversation: "put my sessions in TTO".
|
|
180
|
+
server.registerTool(
|
|
181
|
+
'set_capture_brain',
|
|
182
|
+
{
|
|
183
|
+
title: 'Choose where your session captures are saved',
|
|
184
|
+
description:
|
|
185
|
+
'Set which brain THIS PERSON\'s unattended session captures (the automatic end-of-session record) land in, or read the current setting by omitting `brain`. WHEN TO USE: whenever the user says their sessions are not being saved, asks where their work is going, or a session-start notice says captures are being HELD. WHY IT IS NEEDED: with more than one brain, a capture that names no brain cannot be routed and is held outside every brain — correct, but invisible, so it accumulates silently. This is per-PERSON and applies to their own captures only; it cannot be set for someone else. ⚠ PASS THE ORG ID when the user has two brains with the SAME NAME (e.g. two called "Personal") — a name matching more than one is REFUSED rather than guessed, and the error lists the ids to choose from. Setting this does NOT file already-held captures; those keep their original dates and may belong in different brains, so sort them deliberately rather than dumping them into the new default.',
|
|
186
|
+
inputSchema: {
|
|
187
|
+
brain: z.string().optional().describe('the brain name or org id (from my_brains) where this person\'s session captures should land. Omit to read the current setting instead of changing it.'),
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
async ({ brain }) => {
|
|
191
|
+
const url = `${BASE}/api/brain/capture-default`
|
|
192
|
+
if (!brain?.trim()) {
|
|
193
|
+
const res = await fetchCortex(url, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
194
|
+
if (!res.ok) {
|
|
195
|
+
const body = await res.text()
|
|
196
|
+
throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
|
|
197
|
+
}
|
|
198
|
+
const { defaults } = await res.json()
|
|
199
|
+
if (!defaults?.length) {
|
|
200
|
+
return { content: [{ type: 'text', text: 'No capture brain is set. If you belong to more than one brain, your session captures are being HELD outside every brain until you set one. Call this tool again with `brain` to fix it.' }] }
|
|
201
|
+
}
|
|
202
|
+
const lines = defaults.map((d) => `${d.sourceType} captures land in "${d.orgName}" (${d.orgId}), set ${String(d.updatedAt).slice(0, 10)}`)
|
|
203
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] }
|
|
204
|
+
}
|
|
205
|
+
const res = await fetchCortex(url, {
|
|
206
|
+
method: 'POST',
|
|
207
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
208
|
+
body: JSON.stringify({ sourceType: 'claude-code', brain: brain.trim() }),
|
|
209
|
+
})
|
|
210
|
+
const body = await res.text()
|
|
211
|
+
if (!res.ok) {
|
|
212
|
+
// Prefer the server's own error: a 409 lists the org ids of an ambiguous name, which IS the
|
|
213
|
+
// remedy, and a generic classification would throw that away.
|
|
214
|
+
let msg
|
|
215
|
+
try { msg = JSON.parse(body).error } catch { /* fall through to the classified message */ }
|
|
216
|
+
throw new Error(msg ?? classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
|
|
217
|
+
}
|
|
218
|
+
const j = JSON.parse(body)
|
|
219
|
+
return { content: [{ type: 'text', text: `✓ Your Claude Code sessions now land in "${j.brain}" (${j.orgId}). Sessions captured before now are still held and keep their original dates — file those deliberately, they may not all belong in this brain.` }] }
|
|
220
|
+
},
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
// T8: cortex-log's authoritative writer. The skill composes a curated summary, then calls this to
|
|
224
|
+
// persist it AS the durable record (capture_source='skill'). The ingest conflict guard ensures the
|
|
225
|
+
// auto-capture hook never clobbers it. Pass the SAME sessionId the hook uses so the two dedupe onto
|
|
226
|
+
// one record; without it the curated log still lands as its own authoritative record.
|
|
227
|
+
server.registerTool(
|
|
228
|
+
'log_session',
|
|
229
|
+
{
|
|
230
|
+
title: 'Log this session to Agnoclast',
|
|
231
|
+
description: 'Persist a CURATED summary of this work session as its durable Agnoclast record (authoritative — supersedes the auto-capture hook). Call at session close after composing the summary. `sessionId` now defaults to this session automatically (from CLAUDE_CODE_SESSION_ID) so the log dedupes with the auto-capture of the same session — pass it explicitly only to log on behalf of a DIFFERENT session, e.g. recovering one whose own close-out failed. If you belong to more than one brain you MUST name one — without a brain a session log has no route and is STAGED rather than recorded. **If the session touched work belonging to different brains, pass `segments` instead of `summary` and split it** — one segment per brain, each summary standing on its own and never alluding to the others. Reports every segment individually; a partial result is reported as PARTIAL, never as success.',
|
|
232
|
+
inputSchema: {
|
|
233
|
+
summary: z.string().optional().describe('the curated session summary — the single-brain form. Omit when passing `segments`'),
|
|
234
|
+
segments: z.array(z.object({
|
|
235
|
+
brain: z.string().describe('destination brain for this half of the session (name or org id)'),
|
|
236
|
+
summary: z.string().describe('a summary that stands ON ITS OWN. It must not mention, allude to, or imply that other segments exist — "the rest of this session covered personal projects" leaks in prose exactly what splitting was meant to contain'),
|
|
237
|
+
title: z.string().optional(),
|
|
238
|
+
project: z.string().optional(),
|
|
239
|
+
})).optional().describe('SPLIT the log, one entry per brain. Use whenever a session touched work belonging to different brains: a session is a container of time, not a topic, and every single-brain answer is wrong — filing it all in the org brain exposes personal work to colleagues, filing it all in the personal one denies the org its record, and summarizing half silently drops the other half. Max ONE segment per brain (they share this session\'s dedupe key, so two aimed at the same brain would overwrite each other). When a chunk is ambiguous, put it in the MORE PRIVATE brain — a misfile there is private, a misfile the other way is visible to everyone in the org.'),
|
|
240
|
+
project: z.string().optional().describe('project key/name this session worked in'),
|
|
241
|
+
title: z.string().optional().describe('short title for the session'),
|
|
242
|
+
sessionId: z.string().optional().describe('the Claude Code session id — DEFAULTS to the current session, so omit it unless you are logging on behalf of another one. It is shared by every segment and the only thing pairing them, and it is also the join to the pages this session wrote (page_revisions.session_key); without it the record is unattributable forever, since the fallback dedupe key is a timestamp. Deliberately NOT a link: segments never reference each other, so a reader cleared for one brain cannot tell the others exist, while you can join on it across brains'),
|
|
243
|
+
brain: z.string().optional().describe('which brain to record this session in (name or org id, one of your own). REQUIRED IN EFFECT for a multi-brain member: session-class sources route only by an explicit brain or a sole membership, so omitting it stages the log instead of recording it.'),
|
|
244
|
+
privacy: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('tier this record AT WRITE TIME. Use when the summary names confidential work (a candidate evaluation, a security finding) — safer than letting it land org-visible and re-tiering after, which leaves it readable in between.'),
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
async ({ summary, segments, project, title, sessionId, brain, privacy }) => {
|
|
248
|
+
// Default the session id from the environment when the caller omits it.
|
|
249
|
+
//
|
|
250
|
+
// `sessionId` is optional and the model routinely does not pass it, so the record lands with a
|
|
251
|
+
// TIMESTAMP dedupe key (`claude-code:<ISO>`) and a null payload.session_id. Measured against
|
|
252
|
+
// prod 2026-08-19: of 136 skill records, only 33 carried a session id — the other 103 are
|
|
253
|
+
// unattributable by any means, because the timestamp key is not a fallback identity. That is
|
|
254
|
+
// 76% of every /cortex-log close-out unable to be joined to the pages that session wrote
|
|
255
|
+
// (page_revisions.session_key), which is the join the whole currency audit runs on.
|
|
256
|
+
//
|
|
257
|
+
// The process has always known this value. It is the same CLAUDE_CODE_SESSION_ID the capture
|
|
258
|
+
// hook sends as hook.session_id, which is precisely what makes the two dedupe onto one record
|
|
259
|
+
// instead of two — the stated purpose of the parameter in this tool's own description.
|
|
260
|
+
//
|
|
261
|
+
// ⚠ resolveLogSessionId, NOT resolveSessionKey / SESSION_KEY. The latter falls back to
|
|
262
|
+
// randomUUID(), and a random per-process uuid here would be WORSE than the timestamp it
|
|
263
|
+
// replaces: it looks like a real session id, mints `claude-code:<random>` as a dedupe key that
|
|
264
|
+
// pairs with no capture record and matches no page_revisions.session_key, and so manufactures a
|
|
265
|
+
// confident-looking join that is silently wrong. A missing id is honest; an invented one is not.
|
|
266
|
+
// Hosts that do not set the var keep today's timestamp behaviour. Negative-controlled in
|
|
267
|
+
// session_key.test.mjs.
|
|
268
|
+
sessionId = resolveLogSessionId(sessionId, process.env)
|
|
269
|
+
// A session is a container of TIME, not a topic (ADR-0029 step 4). Normalize to a list of
|
|
270
|
+
// segments; the single-brain call is just a one-segment list.
|
|
271
|
+
const list = Array.isArray(segments) && segments.length
|
|
272
|
+
? segments.map((s) => ({ brain: s.brain, summary: s.summary, title: s.title ?? title, project: s.project ?? project }))
|
|
273
|
+
: (summary ? [{ brain, summary, title, project }] : null)
|
|
274
|
+
if (!list) {
|
|
275
|
+
return toolError('Pass `summary` (single brain) or a non-empty `segments` array (one entry per brain).')
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Records are unique on the ORG-SCOPED (org_id, dedupe_key) and every segment carries this
|
|
279
|
+
// session's dedupe key. Cross-brain segments therefore never collide — that constraint is what
|
|
280
|
+
// makes the whole design work — but two aimed at the SAME brain would silently merge and lose
|
|
281
|
+
// one. Refuse instead of letting that happen quietly.
|
|
282
|
+
const seenBrain = new Set()
|
|
283
|
+
for (const s of list) {
|
|
284
|
+
const k = String(s.brain ?? '').trim().toLowerCase()
|
|
285
|
+
if (seenBrain.has(k)) {
|
|
286
|
+
return toolError(`Two segments target the same brain (${s.brain}). They share this session's dedupe key, so the second would overwrite the first — merge them into one segment.`)
|
|
287
|
+
}
|
|
288
|
+
seenBrain.add(k)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
let brainIndex = null
|
|
292
|
+
const orgIdFor = async (nameOrId) => {
|
|
293
|
+
if (!nameOrId) return null
|
|
294
|
+
if (!brainIndex) {
|
|
295
|
+
try {
|
|
296
|
+
const r = await fetchCortex(`${BASE}/api/brains`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
297
|
+
brainIndex = r.ok ? ((await r.json().catch(() => ({}))).brains ?? []) : []
|
|
298
|
+
} catch {
|
|
299
|
+
brainIndex = []
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const q = String(nameOrId).trim().toLowerCase()
|
|
303
|
+
const hit = brainIndex.find(
|
|
304
|
+
(b) => String(b.orgId ?? '').toLowerCase() === q || String(b.name ?? '').toLowerCase() === q,
|
|
305
|
+
)
|
|
306
|
+
return hit?.orgId ?? null
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const lines = []
|
|
310
|
+
let failures = 0
|
|
311
|
+
|
|
312
|
+
for (const seg of list) {
|
|
313
|
+
const where = seg.brain ?? '(no brain named)'
|
|
314
|
+
let res
|
|
315
|
+
try {
|
|
316
|
+
res = await fetchCortex(`${BASE}/api/ingest`, {
|
|
317
|
+
method: 'POST',
|
|
318
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
319
|
+
body: JSON.stringify({
|
|
320
|
+
source: 'claude-code',
|
|
321
|
+
captureSource: 'skill',
|
|
322
|
+
summary: seg.summary,
|
|
323
|
+
...(seg.project ? { project: seg.project } : {}),
|
|
324
|
+
...(seg.title ? { title: seg.title } : {}),
|
|
325
|
+
...(sessionId ? { sessionId } : {}),
|
|
326
|
+
// ADR-0022 deleted the write pointer, so a session-class source routes ONLY by an
|
|
327
|
+
// explicit brain or a sole membership — anything else STAGES. This tool never sent one,
|
|
328
|
+
// so every close-out from a multi-brain member landed in staged_records instead of the
|
|
329
|
+
// org. Measured 2026-08-09: 86 staged rows, not drainable by /api/staged/promote.
|
|
330
|
+
...(seg.brain ? { brain: seg.brain } : {}),
|
|
331
|
+
...(privacy ? { privacy } : {}),
|
|
332
|
+
payload: { via: 'log_session' },
|
|
333
|
+
}),
|
|
334
|
+
})
|
|
335
|
+
} catch (e) {
|
|
336
|
+
failures++
|
|
337
|
+
lines.push(`✗ ${where}: ${e.message}`)
|
|
338
|
+
continue
|
|
339
|
+
}
|
|
340
|
+
if (!res.ok) {
|
|
341
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
342
|
+
failures++
|
|
343
|
+
lines.push(`✗ ${where}: ${d.message}`)
|
|
344
|
+
continue
|
|
345
|
+
}
|
|
346
|
+
const j = await res.json().catch(() => ({}))
|
|
347
|
+
|
|
348
|
+
// NEVER report a non-record as "logged". `staged` and `skipped` are 200 OK responses that
|
|
349
|
+
// wrote no record, and this used to print "Logged … updated existing" for both, because
|
|
350
|
+
// `j.inserted` is merely falsy on a staged write.
|
|
351
|
+
if (j.staged) {
|
|
352
|
+
const why = j.reason === 'no_route_for_source' && !seg.brain
|
|
353
|
+
? 'no brain named and you belong to more than one, so it had nowhere to route'
|
|
354
|
+
: `reason: ${j.reason ?? 'unknown'}`
|
|
355
|
+
failures++
|
|
356
|
+
lines.push(`✗ ${where}: NOT LOGGED — staged, not recorded (${why}). Re-run this segment with brain:"<name>" — staged session logs cannot be drained by /api/staged/promote.`)
|
|
357
|
+
continue
|
|
358
|
+
}
|
|
359
|
+
if (j.skipped) {
|
|
360
|
+
failures++
|
|
361
|
+
lines.push(`✗ ${where}: NOT LOGGED — server skipped the write: ${j.skipped}`)
|
|
362
|
+
continue
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Gate 4 routes a session log into PRIVATE INTAKE rather than straight to records, answering
|
|
366
|
+
// `via:"private_intake"` with an intakeItemId and no id. The previous check fell through to
|
|
367
|
+
// "no record id" and reported NOT LOGGED over a write that had landed — a false negative that
|
|
368
|
+
// drives retries, and retries against ingest make duplicates.
|
|
369
|
+
//
|
|
370
|
+
// Materializing here is not an optimization. Close-out is the ONLY moment the destination
|
|
371
|
+
// brain is known; a unit left in intake goes cleanup-due in a day and becomes an item no later
|
|
372
|
+
// session has the authority to route, because deciding which brain half of someone's session
|
|
373
|
+
// belongs in is exactly the judgment a stranger cannot make.
|
|
374
|
+
if (!j.id && j.via === 'private_intake' && j.intakeItemId) {
|
|
375
|
+
const orgId = await orgIdFor(seg.brain)
|
|
376
|
+
if (!orgId) {
|
|
377
|
+
failures++
|
|
378
|
+
lines.push(`✗ ${where}: captured to intake as ${j.intakeItemId}, but that brain did not resolve to an org id — materialize it by hand`)
|
|
379
|
+
continue
|
|
380
|
+
}
|
|
381
|
+
let m
|
|
382
|
+
try {
|
|
383
|
+
m = await fetchCortex(`${BASE}/api/intake/materialize`, {
|
|
384
|
+
method: 'POST',
|
|
385
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
386
|
+
// NESTED under `record`, and snake_case inside it. The route reads the record body
|
|
387
|
+
// from `body.record` and ignores top-level title/summary entirely — a flat body still
|
|
388
|
+
// answers `ok` with a recordId, having written a record with no content. Four such
|
|
389
|
+
// records exist in the corpus from exactly this mistake, and this call was the fifth
|
|
390
|
+
// until 2026-08-15: the close-out reported success while changing nothing.
|
|
391
|
+
body: JSON.stringify({
|
|
392
|
+
intakeItemId: j.intakeItemId,
|
|
393
|
+
orgId,
|
|
394
|
+
record: {
|
|
395
|
+
title: seg.title,
|
|
396
|
+
summary: seg.summary,
|
|
397
|
+
source: 'claude-code',
|
|
398
|
+
record_type: 'ai_session',
|
|
399
|
+
},
|
|
400
|
+
}),
|
|
401
|
+
})
|
|
402
|
+
} catch (e) {
|
|
403
|
+
failures++
|
|
404
|
+
lines.push(`✗ ${where}: intake ${j.intakeItemId} NOT materialized — ${e.message}`)
|
|
405
|
+
continue
|
|
406
|
+
}
|
|
407
|
+
const mj = await m.json().catch(() => ({}))
|
|
408
|
+
if (!m.ok || !mj.recordId) {
|
|
409
|
+
failures++
|
|
410
|
+
lines.push(`✗ ${where}: intake ${j.intakeItemId} NOT materialized — ${mj.error ?? m.status}`)
|
|
411
|
+
continue
|
|
412
|
+
}
|
|
413
|
+
lines.push(`✓ ${where}: record ${mj.recordId}`)
|
|
414
|
+
continue
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (!j.id) {
|
|
418
|
+
failures++
|
|
419
|
+
lines.push(`✗ ${where}: NOT LOGGED — no record id. Raw: ${JSON.stringify(j).slice(0, 200)}`)
|
|
420
|
+
continue
|
|
421
|
+
}
|
|
422
|
+
lines.push(`✓ ${where}: record ${j.id}${j.inserted ? '' : ' (updated existing)'}`)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Partial success is a real outcome once there is more than one segment, and "3 of 4 landed"
|
|
426
|
+
// must never read as done — that is the same silent-loss shape this whole path was repaired for.
|
|
427
|
+
const head = failures === 0
|
|
428
|
+
? `Logged ${lines.length} segment${lines.length === 1 ? '' : 's'}.`
|
|
429
|
+
: `PARTIAL — ${lines.length - failures} of ${lines.length} segments logged, ${failures} FAILED. A partly-logged session is not a logged session; re-run the failed segments.`
|
|
430
|
+
return { content: [{ type: 'text', text: `${head}\n${lines.join('\n')}` }] }
|
|
431
|
+
},
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
server.registerTool(
|
|
435
|
+
'maintenance_candidates',
|
|
436
|
+
{
|
|
437
|
+
title: 'Review recent project-linked maintenance evidence',
|
|
438
|
+
description: 'Start here when preparing a handoff, status, or next step for a NAMED project. Returns a bounded, RLS-scoped set of recent raw records already linked to that project, before you trust the authored page. These are candidate evidence, not a command to edit: read them, call project_status, then decide whether a material contradiction warrants a minimal accountable correction. When correcting, retain the decisive factual qualifier (for example completion date or state) in the current-status text. Never mutate merely to clear a candidate.',
|
|
439
|
+
inputSchema: {
|
|
440
|
+
project: z.string().describe('the project key or exact project name from the task, e.g. "checkout-v2" or "Checkout v2"'),
|
|
441
|
+
brain: z.string().optional().describe('brain name or org id when you belong to more than one brain; omit for a sole brain'),
|
|
442
|
+
since_days: z.number().optional().describe('how far back to inspect (1-90 days, default 30)'),
|
|
443
|
+
limit: z.number().optional().describe('max candidate records (1-50, default 20)'),
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
async ({ project, brain, since_days, limit }) => {
|
|
447
|
+
const qs = new URLSearchParams({ project })
|
|
448
|
+
if (brain) qs.set('brain', brain)
|
|
449
|
+
if (since_days != null) qs.set('since_days', String(since_days))
|
|
450
|
+
if (limit != null) qs.set('limit', String(limit))
|
|
451
|
+
const res = await fetchCortex(`${BASE}/api/maintenance/candidates?${qs}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
452
|
+
if (!res.ok) {
|
|
453
|
+
const body = await res.text()
|
|
454
|
+
throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
|
|
455
|
+
}
|
|
456
|
+
const { text } = await res.json()
|
|
457
|
+
return { content: [{ type: 'text', text }] }
|
|
458
|
+
},
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
server.registerTool(
|
|
462
|
+
'gate3_status',
|
|
463
|
+
{
|
|
464
|
+
title: 'Gate 3 currency monitor',
|
|
465
|
+
description: 'Read the aggregate-only Gate 3 currency-monitor status for this brain. It counts explicit maintenance-candidate reviews and their same-session durable corrections in the rolling window; it never exposes project names, evidence content, or session keys. "machine_evidence_ready" means enough ordinary-work evidence has accumulated to request a human closure decision, not that the monitor closes the gate itself.',
|
|
466
|
+
inputSchema: {
|
|
467
|
+
days: z.number().optional().describe('rolling window in days (1-90, default 14)'),
|
|
468
|
+
brain: z.string().optional().describe('brain name or org id when you belong to more than one brain; omit for a sole brain'),
|
|
469
|
+
},
|
|
470
|
+
},
|
|
471
|
+
async ({ days, brain }) => {
|
|
472
|
+
const qs = new URLSearchParams()
|
|
473
|
+
if (days != null) qs.set('days', String(days))
|
|
474
|
+
if (brain) qs.set('brain', brain)
|
|
475
|
+
const suffix = qs.size ? `?${qs}` : ''
|
|
476
|
+
const res = await fetchCortex(`${BASE}/api/gates/3/status${suffix}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
477
|
+
if (!res.ok) {
|
|
478
|
+
const body = await res.text()
|
|
479
|
+
throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
|
|
480
|
+
}
|
|
481
|
+
const status = await res.json()
|
|
482
|
+
const text = status.machineEvidenceReady
|
|
483
|
+
? `Gate 3 machine evidence is ready: ${status.maintenanceReviewSessions} independent maintenance-review sessions and ${status.correctionSessions} correction sessions in ${status.days} days. A human should confirm these were ordinary work before closing the gate.`
|
|
484
|
+
: `Gate 3 is still collecting evidence: ${status.maintenanceReviewSessions}/${status.requiredReviewSessions} independent maintenance-review sessions and ${status.correctionSessions}/${status.requiredCorrectionSessions} correction sessions in ${status.days} days. No gate decision has been made.`
|
|
485
|
+
return { content: [{ type: 'text', text }] }
|
|
486
|
+
},
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
server.registerTool(
|
|
490
|
+
'gate2_status',
|
|
491
|
+
{
|
|
492
|
+
title: 'Gate 2 edit-accountability monitor',
|
|
493
|
+
description: 'Read the aggregate-only Gate 2 status for this brain — whether every edit records WHICH SESSION made it. It counts only the REPAIRED write paths (absorb, retier, replace) since the 2026-08-17 fix, because the author path was never broken and would certify a repair it never exercised. Operator and migration writes are excluded: they legitimately have no session, and imputing one would violate the 0093 don\'t-impute rule. It never exposes page titles, refs, reasons or session keys. "regressed" means a repaired path lost its session attribution again and the gate must NOT be closed; "machine_evidence_ready" means enough ordinary-work evidence has accumulated to request a human closure decision.',
|
|
494
|
+
inputSchema: {
|
|
495
|
+
days: z.number().optional().describe('rolling window in days (1-90, default 14)'),
|
|
496
|
+
brain: z.string().optional().describe('brain name or org id when you belong to more than one brain; omit for a sole brain'),
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
async ({ days, brain }) => {
|
|
500
|
+
const qs = new URLSearchParams()
|
|
501
|
+
if (days != null) qs.set('days', String(days))
|
|
502
|
+
if (brain) qs.set('brain', brain)
|
|
503
|
+
const suffix = qs.size ? `?${qs}` : ''
|
|
504
|
+
const res = await fetchCortex(`${BASE}/api/gates/2/status${suffix}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
505
|
+
if (!res.ok) {
|
|
506
|
+
const body = await res.text()
|
|
507
|
+
throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
|
|
508
|
+
}
|
|
509
|
+
const status = await res.json()
|
|
510
|
+
const text = status.status === 'regressed'
|
|
511
|
+
? `\u26a0 Gate 2 has REGRESSED: ${status.unattributedMcpRevisions} MCP-authored revisions since ${status.since} carry NO session (${status.unattributedRepairedRevisions} of them on the repaired absorb/retier/replace paths). Attribution is being dropped again — do not close this gate; find the writer.`
|
|
512
|
+
: status.machineEvidenceReady
|
|
513
|
+
? `Gate 2 machine evidence is ready: ${status.repairedPathSessions} distinct sessions exercised the repaired write paths across ${status.repairedPathRevisions} revisions since ${status.since}, and NONE lost its session. A human should confirm these were ordinary work before closing the gate.`
|
|
514
|
+
: `Gate 2 is still collecting evidence: ${status.repairedPathSessions}/${status.requiredRepairedPathSessions} distinct sessions have exercised the repaired write paths (absorb/retier/replace) since ${status.since}, across ${status.repairedPathRevisions} revisions, 0 unattributed. No gate decision has been made.`
|
|
515
|
+
return { content: [{ type: 'text', text }] }
|
|
516
|
+
},
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
server.registerTool(
|
|
520
|
+
'session_context',
|
|
521
|
+
{
|
|
522
|
+
title: 'Query-centered session context',
|
|
523
|
+
description: 'Build a session-start context block around the user\'s opening question: pick the most relevant seed node, pull its neighborhood, and compress farther hops.',
|
|
524
|
+
inputSchema: { question: z.string().describe('the opening user question or request for this session') },
|
|
525
|
+
},
|
|
526
|
+
async ({ question }) => {
|
|
527
|
+
const res = await fetchCortex(`${BASE}/api/session-context`, {
|
|
528
|
+
method: 'POST',
|
|
529
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
530
|
+
body: JSON.stringify({ question }),
|
|
531
|
+
})
|
|
532
|
+
if (!res.ok) {
|
|
533
|
+
const body = await res.text()
|
|
534
|
+
throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
|
|
535
|
+
}
|
|
536
|
+
const { context } = await res.json()
|
|
537
|
+
return { content: [{ type: 'text', text: context }] }
|
|
538
|
+
},
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
// Pull/claim attribution ([[cortex-subscription-inference]]): the agent decides which project a
|
|
542
|
+
// messaging thread is about — on ITS subscription, zero server-metered inference — and records it
|
|
543
|
+
// here. The server stamps `project:<slug>` onto the thread's timeline events via the locked-down
|
|
544
|
+
// cortex_labeler role, so the thread threads onto that project's node timeline (retroactive @> join).
|
|
545
|
+
server.registerTool(
|
|
546
|
+
'attribute_thread',
|
|
547
|
+
{
|
|
548
|
+
title: 'Attribute a messaging thread to a project',
|
|
549
|
+
description: "Record that a messaging thread (email, etc.) belongs to a project — stamps project:<slug> onto the thread's timeline events so its whole history threads onto that project's node timeline. Attribute AS YOU WORK: when you recognize which known project a thread you're looking at is about, attribute it. KNOWN projects only — if you're not confident, don't (a wrong tag pollutes a timeline; leaving it unattributed self-heals). Idempotent (safe to re-call) and reversible (remove:true undoes an attribution you made).",
|
|
550
|
+
inputSchema: {
|
|
551
|
+
threadKey: z.string().describe('the thread identifier — a Gmail threadId, or a full `thread:<id>` key'),
|
|
552
|
+
project: z.string().describe('the slug/key of the EXISTING project the thread belongs to'),
|
|
553
|
+
remove: z.boolean().optional().describe('true to REMOVE a project attribution you previously made (reversibility)'),
|
|
554
|
+
},
|
|
555
|
+
},
|
|
556
|
+
async ({ threadKey, project, remove }) => {
|
|
557
|
+
const res = await fetchCortex(`${BASE}/api/timeline/attribute`, {
|
|
558
|
+
method: 'POST',
|
|
559
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
560
|
+
body: JSON.stringify({ threadKey, project, ...(remove ? { remove: true } : {}) }),
|
|
561
|
+
})
|
|
562
|
+
if (!res.ok) {
|
|
563
|
+
const body = await res.text()
|
|
564
|
+
throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
|
|
565
|
+
}
|
|
566
|
+
const j = await res.json().catch(() => ({}))
|
|
567
|
+
const verb = remove ? 'Removed' : 'Attributed'
|
|
568
|
+
const prep = remove ? 'from' : 'to'
|
|
569
|
+
return { content: [{ type: 'text', text: `${verb} ${j.threadKey ?? threadKey} ${prep} ${j.project ?? `project:${project}`} — ${j.stamped ?? 0} event(s) ${remove ? 'un' : ''}stamped.` }] }
|
|
570
|
+
},
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
// The PULL half: surface the org's unattributed messaging backlog so the agent can triage it and
|
|
574
|
+
// attribute the threads it recognizes (pairs with attribute_thread above).
|
|
575
|
+
server.registerTool(
|
|
576
|
+
'timeline_pull',
|
|
577
|
+
{
|
|
578
|
+
title: 'Pull the unclaimed backlog to triage',
|
|
579
|
+
description: "Surface everything captured in the org that NO ONE has judged yet — the backlog awaiting your judgment. Two sections: unattributed EMAIL threads (grouped, since a thread is what you actually triage), and unclaimed RECORDS from every other source (commits, sessions, docs, calendar) one by one. Call it as you work; when you recognize which KNOWN project a surfaced thread is about, attribute it with attribute_thread. Things you don't recognize: leave them — they stay in the backlog and self-heal as the graph fills. Pass a `project` you're working on to RANK the thread half by relevance (threads sharing participants with that project come first, marked ★); records are always newest-first. Returns enough to RECOGNIZE an item — participants, subject, title, recency — never the bodies.",
|
|
580
|
+
inputSchema: {
|
|
581
|
+
limit: z.number().optional().describe('max threads to return (default 20)'),
|
|
582
|
+
project: z.string().optional().describe('optional KNOWN project slug to rank the backlog by relevance to what you are working on; omit for the whole backlog newest-first'),
|
|
583
|
+
},
|
|
584
|
+
},
|
|
585
|
+
async ({ limit, project }) => {
|
|
586
|
+
const params = new URLSearchParams()
|
|
587
|
+
if (typeof limit === 'number') params.set('limit', String(limit))
|
|
588
|
+
if (typeof project === 'string' && project) params.set('project', project)
|
|
589
|
+
const qs = params.toString() ? `?${params}` : ''
|
|
590
|
+
const res = await fetchCortex(`${BASE}/api/timeline/pull${qs}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
591
|
+
if (!res.ok) {
|
|
592
|
+
const body = await res.text()
|
|
593
|
+
throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
|
|
594
|
+
}
|
|
595
|
+
const { threads, records } = await res.json()
|
|
596
|
+
if (!threads?.length && !records?.length) return { content: [{ type: 'text', text: 'Nothing in the backlog — everything captured so far has been claimed.' }] }
|
|
597
|
+
|
|
598
|
+
const sections = []
|
|
599
|
+
|
|
600
|
+
if (threads?.length) {
|
|
601
|
+
const lines = threads.map((t) => {
|
|
602
|
+
const who = (t.participants ?? []).map((p) => String(p).replace(/^email:/, '')).join(', ')
|
|
603
|
+
const when = t.lastAt ? String(t.lastAt).slice(0, 10) : '—'
|
|
604
|
+
const rel = t.relevance && t.relevance.score > 0 ? ` · ★${t.relevance.score} ${t.relevance.reason}` : ''
|
|
605
|
+
return `- ${t.threadKey} — ${t.subject ?? '(no subject)'} · ${who || 'unknown participants'} · ${t.eventCount} msg · ${when}${rel}`
|
|
606
|
+
})
|
|
607
|
+
const header = project
|
|
608
|
+
? `Unattributed threads (${threads.length}), ranked for project:${project} (★ = shares participants)`
|
|
609
|
+
: `Unattributed threads (${threads.length})`
|
|
610
|
+
sections.push(`${header} — attribute recognized ones with attribute_thread(threadKey, project):\n${lines.join('\n')}`)
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// The other 12 sources. No thread concept exists for a commit or a session log, so each is one
|
|
614
|
+
// row. `node` is shown when the record already carries a project — often the whole answer, and it
|
|
615
|
+
// is why these are NOT ranked by relevance: a record that already has a node needs judgment least.
|
|
616
|
+
if (records?.length) {
|
|
617
|
+
const lines = records.map((r) => {
|
|
618
|
+
const when = r.occurredAt ? String(r.occurredAt).slice(0, 10) : '—'
|
|
619
|
+
const node = r.node ? ` · → ${r.node}` : ''
|
|
620
|
+
return `- ${r.source}/${r.recordType ?? 'record'} — ${r.title ?? '(untitled)'} · ${when}${node} [${r.recordId}]`
|
|
621
|
+
})
|
|
622
|
+
sections.push(`Unclaimed records (${records.length}) from other sources — newest first:\n${lines.join('\n')}`)
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return { content: [{ type: 'text', text: sections.join('\n\n') }] }
|
|
626
|
+
},
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
// ── Gate 4 private intake (Slice 3) ──────────────────────────────────────────────────────────
|
|
630
|
+
server.registerTool(
|
|
631
|
+
'intake_changes',
|
|
632
|
+
{
|
|
633
|
+
title: 'Check private intake changes',
|
|
634
|
+
description:
|
|
635
|
+
'Session heartbeat for Gate 4 private intake. Returns an operational delta since your cursor (no ciphertext) plus cleanupDueCount. Call at session start, before each user turn, after long actions, and at least every two minutes while actively working. cleanupDueCount is advisory: prefer to service it with claimKind=cleanup when you reach a natural break, but never park the work you were actually asked to do in order to drain the queue first. NOTE afterSeq is this feed\'s own sequence — the changeSeq returned by ingest is a different counter, and passing it here seeks past the end and looks like a dead feed.',
|
|
636
|
+
inputSchema: {
|
|
637
|
+
afterSeq: z.number().optional().describe('cursor from the previous call (default 0)'),
|
|
638
|
+
limit: z.number().optional().describe('max change rows (default 100)'),
|
|
639
|
+
},
|
|
640
|
+
},
|
|
641
|
+
async ({ afterSeq, limit }) => {
|
|
642
|
+
const params = new URLSearchParams()
|
|
643
|
+
if (typeof afterSeq === 'number') params.set('afterSeq', String(afterSeq))
|
|
644
|
+
if (typeof limit === 'number') params.set('limit', String(limit))
|
|
645
|
+
const qs = params.toString() ? `?${params}` : ''
|
|
646
|
+
const res = await fetchCortex(`${BASE}/api/intake/changes${qs}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
647
|
+
if (!res.ok) {
|
|
648
|
+
const body = await res.text()
|
|
649
|
+
if (res.status === 403) return toolError('Private intake is not enabled for this account.')
|
|
650
|
+
throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
|
|
651
|
+
}
|
|
652
|
+
return { content: [{ type: 'text', text: JSON.stringify(await res.json(), null, 2) }] }
|
|
653
|
+
},
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
server.registerTool(
|
|
657
|
+
'intake_claim',
|
|
658
|
+
{
|
|
659
|
+
title: 'Claim private intake items',
|
|
660
|
+
description:
|
|
661
|
+
'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.',
|
|
662
|
+
inputSchema: {
|
|
663
|
+
claimKind: z.enum(['relevance', 'cleanup']).optional().describe('default relevance'),
|
|
664
|
+
limit: z.number().optional().describe('max items (default 10). Ignored when intakeItemIds is given.'),
|
|
665
|
+
intakeItemIds: z.array(z.string()).optional().describe(
|
|
666
|
+
'claim these specific units instead of the head of the queue (max 50). Without it you get FIFO, ' +
|
|
667
|
+
'so reaching one known unit means claiming everything ahead of it. Ids come from intake_changes ' +
|
|
668
|
+
'or from an ingest response. The reply adds an `outcomes` entry per requested id — claimed, ' +
|
|
669
|
+
'held_by_you (you already hold a live lease on it — proceed, do not re-claim), ' +
|
|
670
|
+
'held (someone else has a live lease; the holder is a stable hash, never their session key), ' +
|
|
671
|
+
'ineligible (already resolved or deferred to the owner), not_found, or unavailable (a momentary ' +
|
|
672
|
+
'lock — retrying is reasonable). Outcomes are best-effort, not a snapshot you can rely on.',
|
|
673
|
+
),
|
|
674
|
+
},
|
|
675
|
+
},
|
|
676
|
+
async ({ claimKind, limit, intakeItemIds }) => {
|
|
677
|
+
const res = await fetchCortex(`${BASE}/api/intake/claim`, {
|
|
678
|
+
method: 'POST',
|
|
679
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
680
|
+
body: JSON.stringify({
|
|
681
|
+
claimKind: claimKind ?? 'relevance',
|
|
682
|
+
limit,
|
|
683
|
+
includePayload: true,
|
|
684
|
+
// Forwarded only when present. An empty array is a real request to claim nothing and must
|
|
685
|
+
// survive as one; sending `[]` where the caller sent nothing would silently switch a FIFO
|
|
686
|
+
// claim into a no-op.
|
|
687
|
+
...(intakeItemIds ? { intakeItemIds } : {}),
|
|
688
|
+
}),
|
|
689
|
+
})
|
|
690
|
+
if (!res.ok) {
|
|
691
|
+
const body = await res.text()
|
|
692
|
+
if (res.status === 403) return toolError('Private intake is not enabled for this account.')
|
|
693
|
+
// Kept deliberately after the server stopped sending it. A published MCP build outlives any
|
|
694
|
+
// one deployment, so this client will meet servers that still refuse relevance claims while
|
|
695
|
+
// cleanup is due. Surfacing that as its own message beats a generic HTTP failure.
|
|
696
|
+
if (res.status === 409) return toolError(body)
|
|
697
|
+
throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const j = await res.json()
|
|
701
|
+
|
|
702
|
+
// The nudge that replaced the 409. It has to be legible without doing arithmetic on a
|
|
703
|
+
// timestamp, so say how far behind rather than printing an ISO string and hoping — "6 days"
|
|
704
|
+
// is a reason to act and "2026-08-09T…" is a field to skim past.
|
|
705
|
+
let nudge = ''
|
|
706
|
+
if ((j?.cleanupDueCount ?? 0) > 0) {
|
|
707
|
+
const n = j.cleanupDueCount
|
|
708
|
+
let behind = ''
|
|
709
|
+
const oldest = j?.oldestCleanupDueAt ? Date.parse(j.oldestCleanupDueAt) : NaN
|
|
710
|
+
if (Number.isFinite(oldest)) {
|
|
711
|
+
const mins = Math.max(0, Math.floor((Date.now() - oldest) / 60000))
|
|
712
|
+
behind = mins >= 1440 ? `, oldest ${Math.floor(mins / 1440)}d overdue`
|
|
713
|
+
: mins >= 60 ? `, oldest ${Math.floor(mins / 60)}h overdue`
|
|
714
|
+
: `, oldest ${mins}m overdue`
|
|
715
|
+
}
|
|
716
|
+
nudge = `\n\n⏳ ${n} intake unit${n === 1 ? '' : 's'} past the cleanup deadline${behind}. `
|
|
717
|
+
+ `Nothing is blocked — run intake_claim with claimKind:"cleanup" when you reach a natural break.`
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
return { content: [{ type: 'text', text: JSON.stringify(j, null, 2) + nudge }] }
|
|
721
|
+
},
|
|
722
|
+
)
|
|
723
|
+
|
|
724
|
+
server.registerTool(
|
|
725
|
+
'intake_materialize',
|
|
726
|
+
{
|
|
727
|
+
title: 'Materialize a private intake item',
|
|
728
|
+
description:
|
|
729
|
+
'Atomically publish a claimed intake item into one brain. Deterministic identifier homes in that brain are always attached; documentIds may add further pages. Sets record confidentiality to the strictest attached page tier. Never writes private intake into search/history before this call.',
|
|
730
|
+
inputSchema: {
|
|
731
|
+
intakeItemId: z.string().describe('intake item uuid'),
|
|
732
|
+
orgId: z.string().describe('destination brain org uuid'),
|
|
733
|
+
documentIds: z.array(z.string()).optional().describe('additional brain_documents ids in orgId (deterministic homes are merged automatically)'),
|
|
734
|
+
title: z.string().optional(),
|
|
735
|
+
summary: z.string().optional(),
|
|
736
|
+
source: z.string().optional(),
|
|
737
|
+
recordType: z.string().optional(),
|
|
738
|
+
dedupeKey: z.string().optional(),
|
|
739
|
+
origin: z.enum(['deterministic', 'llm', 'user', 'session']).optional(),
|
|
740
|
+
},
|
|
741
|
+
},
|
|
742
|
+
async (args) => {
|
|
743
|
+
const res = await fetchCortex(`${BASE}/api/intake/materialize`, {
|
|
744
|
+
method: 'POST',
|
|
745
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
746
|
+
body: JSON.stringify({
|
|
747
|
+
intakeItemId: args.intakeItemId,
|
|
748
|
+
orgId: args.orgId,
|
|
749
|
+
documentIds: args.documentIds ?? [],
|
|
750
|
+
record: {
|
|
751
|
+
title: args.title,
|
|
752
|
+
summary: args.summary,
|
|
753
|
+
source: args.source,
|
|
754
|
+
record_type: args.recordType,
|
|
755
|
+
dedupe_key: args.dedupeKey,
|
|
756
|
+
},
|
|
757
|
+
attachmentMeta: (args.documentIds ?? []).map(() => ({ origin: args.origin ?? 'llm' })),
|
|
758
|
+
}),
|
|
759
|
+
})
|
|
760
|
+
if (!res.ok) {
|
|
761
|
+
const body = await res.text()
|
|
762
|
+
return toolError(body)
|
|
763
|
+
}
|
|
764
|
+
return { content: [{ type: 'text', text: JSON.stringify(await res.json(), null, 2) }] }
|
|
765
|
+
},
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
server.registerTool(
|
|
769
|
+
'intake_discard',
|
|
770
|
+
{
|
|
771
|
+
title: 'Discard a private intake item',
|
|
772
|
+
description:
|
|
773
|
+
'The OTHER terminal outcome for a claimed intake unit: this is nothing, drop it. Use it for content that should never become a record — unsubscribe receipts, empty greetings, marketing blasts, a stranger\'s photo — instead of materializing junk into a brain because materialize was the only verb available. IRREVERSIBLE: the ciphertext and nonces are destroyed in the same transaction, and a content-free tombstone stops the connector re-delivering the unit. You must already hold the claim (discarding something you never read is refused), `reason` is required and is stored, and it is one item per call — a loop discarding a whole pile on one decision is a bulk job, not judgment.',
|
|
774
|
+
inputSchema: {
|
|
775
|
+
intakeItemId: z.string().describe('intake item uuid from intake_claim'),
|
|
776
|
+
reason: z.string().describe('why this is nothing — recorded on the claim, and the only surviving trace of the decision'),
|
|
777
|
+
},
|
|
778
|
+
},
|
|
779
|
+
async ({ intakeItemId, reason }) => {
|
|
780
|
+
let res
|
|
781
|
+
try {
|
|
782
|
+
res = await fetchCortex(`${BASE}/api/intake/discard`, {
|
|
783
|
+
method: 'POST',
|
|
784
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
785
|
+
body: JSON.stringify({ intakeItemId, reason }),
|
|
786
|
+
})
|
|
787
|
+
} catch (e) {
|
|
788
|
+
return toolError(`Could not discard: ${e.message}`)
|
|
789
|
+
}
|
|
790
|
+
const out = await res.json().catch(() => null)
|
|
791
|
+
if (!res.ok) {
|
|
792
|
+
if (out?.error === 'not_claimed') {
|
|
793
|
+
return toolError('You do not hold a claim on that item — intake_claim it first, so the discard follows from having read it.')
|
|
794
|
+
}
|
|
795
|
+
if (out?.error === 'already_materialized') {
|
|
796
|
+
return toolError('That unit already became a record. Discarding it now would orphan the record from its source — detach or retier the record instead.')
|
|
797
|
+
}
|
|
798
|
+
return toolError(`Could not discard: ${out?.error ?? res.status}${out?.detail ? ` — ${out.detail}` : ''}`)
|
|
799
|
+
}
|
|
800
|
+
if (out?.alreadyDiscarded) {
|
|
801
|
+
return { content: [{ type: 'text', text: 'Already discarded — nothing to do.' }] }
|
|
802
|
+
}
|
|
803
|
+
return { content: [{ type: 'text', text: 'Discarded. Content destroyed, tombstone written so the source cannot re-deliver it.' }] }
|
|
804
|
+
},
|
|
805
|
+
)
|
|
806
|
+
|
|
807
|
+
server.registerTool(
|
|
808
|
+
'intake_defer',
|
|
809
|
+
{
|
|
810
|
+
title: 'Defer a private intake item to the owner',
|
|
811
|
+
description:
|
|
812
|
+
'The THIRD terminal outcome, and the right one whenever the honest answer is "this is not mine to decide." Use it when a claimed unit is someone else\'s private content, when the destination brain is a real judgment call rather than a lookup, or when publishing and destroying are both wrong — a stranger\'s message thread, a photo you cannot place, an email whose brain depends on context only the owner has. Destroys NOTHING: the unit stays encrypted and intact, its state becomes `awaiting_user`, and your `question` is what the owner actually sees. Prefer this over letting a lease lapse — a lapsed lease says nothing, increments `attempts`, and hands the identical dead end to the next session. Requires the claim, one item per call.',
|
|
813
|
+
inputSchema: {
|
|
814
|
+
intakeItemId: z.string().describe('intake item uuid from intake_claim'),
|
|
815
|
+
question: z.string().describe('what you need the owner to decide, in their words not yours — this is the entire message they get, so "which brain should this iMessage thread go to, if any?" beats "needs triage"'),
|
|
816
|
+
options: z.array(z.string()).optional().describe('the concrete choices, when the decision is a pick rather than an open question — e.g. ["Personal","TTO","Discard — nothing to record"]. Stored on the question and rendered by intake_cleanup_status, so the owner can answer with a choice instead of prose.'),
|
|
817
|
+
},
|
|
818
|
+
},
|
|
819
|
+
async ({ intakeItemId, question, options }) => {
|
|
820
|
+
let res
|
|
821
|
+
try {
|
|
822
|
+
res = await fetchCortex(`${BASE}/api/intake/defer`, {
|
|
823
|
+
method: 'POST',
|
|
824
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
825
|
+
body: JSON.stringify({ intakeItemId, question, ...(options?.length ? { options } : {}) }),
|
|
826
|
+
})
|
|
827
|
+
} catch (e) {
|
|
828
|
+
return toolError(`Could not defer: ${e.message}`)
|
|
829
|
+
}
|
|
830
|
+
const out = await res.json().catch(() => null)
|
|
831
|
+
if (!res.ok) {
|
|
832
|
+
if (out?.error === 'not_claimed') {
|
|
833
|
+
return toolError('You do not hold a claim on that item — intake_claim it first, so the question follows from having read it.')
|
|
834
|
+
}
|
|
835
|
+
if (out?.error === 'already_terminal') {
|
|
836
|
+
return toolError(`Nothing left to ask about: ${out?.detail ?? 'the unit is already materialized or discarded'}.`)
|
|
837
|
+
}
|
|
838
|
+
return toolError(`Could not defer: ${out?.error ?? res.status}${out?.detail ? ` — ${out.detail}` : ''}`)
|
|
839
|
+
}
|
|
840
|
+
if (out?.alreadyDeferred) {
|
|
841
|
+
return { content: [{ type: 'text', text: 'Already awaiting the owner — nothing to do.' }] }
|
|
842
|
+
}
|
|
843
|
+
return { content: [{ type: 'text', text: 'Deferred to the owner. Content preserved, question queued in intake_cleanup_status.openQuestions, and the unit no longer gates cleanup.' }] }
|
|
844
|
+
},
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
server.registerTool(
|
|
848
|
+
'intake_release',
|
|
849
|
+
{
|
|
850
|
+
title: 'Hand an intake claim back',
|
|
851
|
+
description:
|
|
852
|
+
'Give a claimed unit back to the queue WITHOUT deciding anything. This is not a fourth outcome — materialize, discard and defer resolve a unit; release just ends your hold on it. Use it when you claimed more than you can act on, or when the unit turns out to belong to work you are not doing. Prefer it over letting the lease expire: a lapse and a crash are indistinguishable in the ledger, so silently timing out costs the pile the one signal that says a session looked and chose to pass. Use intake_defer instead when the unit needs the OWNER to decide — release puts it back in front of the next session, which will hit whatever wall you did.',
|
|
853
|
+
inputSchema: {
|
|
854
|
+
intakeItemId: z.string().describe('intake item uuid from intake_claim'),
|
|
855
|
+
reason: z.string().describe('why you are handing it back — "claimed too broadly", "not related to this session\'s work". Recorded on the claim, and the only thing distinguishing this from a lapsed lease'),
|
|
856
|
+
},
|
|
857
|
+
},
|
|
858
|
+
async ({ intakeItemId, reason }) => {
|
|
859
|
+
let res
|
|
860
|
+
try {
|
|
861
|
+
res = await fetchCortex(`${BASE}/api/intake/release`, {
|
|
862
|
+
method: 'POST',
|
|
863
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
864
|
+
body: JSON.stringify({ intakeItemId, reason }),
|
|
865
|
+
})
|
|
866
|
+
} catch (e) {
|
|
867
|
+
return toolError(`Could not release: ${e.message}`)
|
|
868
|
+
}
|
|
869
|
+
const out = await res.json().catch(() => null)
|
|
870
|
+
if (!res.ok) {
|
|
871
|
+
if (out?.error === 'not_claimed') {
|
|
872
|
+
return toolError('You do not hold a claim on that item — there is nothing to hand back.')
|
|
873
|
+
}
|
|
874
|
+
if (out?.error === 'awaiting_user') {
|
|
875
|
+
return toolError('That unit is already deferred to the owner. Releasing it would orphan the open question — leave it, or have the owner answer it.')
|
|
876
|
+
}
|
|
877
|
+
if (out?.error === 'already_terminal') {
|
|
878
|
+
return toolError(`Nothing to release: ${out?.detail ?? 'the unit is already materialized or discarded'}.`)
|
|
879
|
+
}
|
|
880
|
+
return toolError(`Could not release: ${out?.error ?? res.status}${out?.detail ? ` — ${out.detail}` : ''}`)
|
|
881
|
+
}
|
|
882
|
+
return { content: [{ type: 'text', text: 'Released. The unit is back in the queue as pending, and your lease is gone.' }] }
|
|
883
|
+
},
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
server.registerTool(
|
|
887
|
+
'intake_cleanup_status',
|
|
888
|
+
{
|
|
889
|
+
title: 'Private intake cleanup status',
|
|
890
|
+
description: 'Counts of pending/claimed/awaiting/cleanup-due intake items, open clarifying questions for the owner, and `needsAttention` — units that keep coming back, with attempts / releases / deferrals / lapses each broken out. A high `lapses` means sessions claimed that unit and neither resolved it nor handed it back; a `deferrals` above 1 means the owner has already been asked more than once. Both are the OWNER\'s signal to act on, not yours — surface them rather than trying to clear them yourself.',
|
|
891
|
+
inputSchema: {},
|
|
892
|
+
},
|
|
893
|
+
async () => {
|
|
894
|
+
const res = await fetchCortex(`${BASE}/api/intake/cleanup-status`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
895
|
+
if (!res.ok) {
|
|
896
|
+
const body = await res.text()
|
|
897
|
+
if (res.status === 403) return toolError('Private intake is not enabled for this account.')
|
|
898
|
+
throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
|
|
899
|
+
}
|
|
900
|
+
return { content: [{ type: 'text', text: JSON.stringify(await res.json(), null, 2) }] }
|
|
901
|
+
},
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
server.registerTool(
|
|
905
|
+
'search_org',
|
|
906
|
+
{
|
|
907
|
+
title: 'Search the org',
|
|
908
|
+
description: 'Search your visible work activity and projects by keyword.',
|
|
909
|
+
inputSchema: { query: z.string().describe('keyword to search for — people or work activity') },
|
|
910
|
+
},
|
|
911
|
+
async ({ query }) => {
|
|
912
|
+
// Real search via /api/search: records (RLS-scoped retrieve) + graph entities (people),
|
|
913
|
+
// instead of substring-grepping the cached context. People were previously invisible to search.
|
|
914
|
+
const res = await fetchCortex(`${BASE}/api/search?q=${encodeURIComponent(query)}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
915
|
+
if (!res.ok) {
|
|
916
|
+
const body = await res.text()
|
|
917
|
+
throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
|
|
918
|
+
}
|
|
919
|
+
const { people = [], records = [], brains = [] } = await res.json()
|
|
920
|
+
if (!people.length && !records.length) return { content: [{ type: 'text', text: `No visible results for "${query}".` }] }
|
|
921
|
+
// Multi-brain: tag each hit with its brain only when results span more than one (single-brain
|
|
922
|
+
// callers see no noise). `brains` also carries any brain that errored (fail-soft).
|
|
923
|
+
const multi = brains.filter((b) => b.ok).length > 1
|
|
924
|
+
const tag = (brain) => (multi && brain ? ` · ${brain}` : '')
|
|
925
|
+
const lines = []
|
|
926
|
+
if (people.length) {
|
|
927
|
+
lines.push('People:')
|
|
928
|
+
for (const p of people) {
|
|
929
|
+
const meta = [p.title, p.company].filter(Boolean).join(', ')
|
|
930
|
+
lines.push(`- ${p.name}${meta ? ` (${meta})` : ''} — mentioned in ${p.mentions} record${p.mentions === 1 ? '' : 's'}${tag(p.brain)}`)
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
if (records.length) {
|
|
934
|
+
if (lines.length) lines.push('')
|
|
935
|
+
lines.push('Activity:')
|
|
936
|
+
for (const r of records) lines.push(`- [${r.source}] ${r.title}${r.project ? ` (${r.project})` : ''}${tag(r.brain)}`)
|
|
937
|
+
}
|
|
938
|
+
const failed = brains.filter((b) => !b.ok)
|
|
939
|
+
if (failed.length) lines.push('', `(couldn't reach ${failed.length} brain${failed.length === 1 ? '' : 's'}: ${failed.map((b) => b.name).join(', ')})`)
|
|
940
|
+
return { content: [{ type: 'text', text: `Results for "${query}":\n${lines.join('\n')}` }] }
|
|
941
|
+
},
|
|
942
|
+
)
|
|
943
|
+
|
|
944
|
+
server.registerTool(
|
|
945
|
+
'search_spam_email',
|
|
946
|
+
{
|
|
947
|
+
title: 'Search Gmail Spam intentionally',
|
|
948
|
+
description: 'Search a connected Gmail Spam folder only when the user explicitly asks you to find a message that may have been marked as Spam. This is read-only: results are returned for this request only and are NOT added to Agnoclast records, timeline, or future context. Give a specific Gmail search such as a sender, subject words, or date. Normal Gmail sync never reads Spam.',
|
|
949
|
+
inputSchema: {
|
|
950
|
+
query: z.string().min(2).describe('specific Gmail search within Spam, e.g. `from:billing@example.com`, `subject:(appointment reminder)`, or `after:2026/08/01`'),
|
|
951
|
+
account: z.string().email().optional().describe('which connected Gmail account to search when more than one is available'),
|
|
952
|
+
limit: z.number().int().min(1).max(10).optional().describe('maximum matches to return (default 5; max 10)'),
|
|
953
|
+
},
|
|
954
|
+
},
|
|
955
|
+
async ({ query, account, limit }) => {
|
|
956
|
+
const res = await fetchCortex(`${BASE}/api/gmail/search-spam`, {
|
|
957
|
+
method: 'POST',
|
|
958
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
959
|
+
body: JSON.stringify({ query, ...(account ? { account } : {}), ...(limit ? { limit } : {}) }),
|
|
960
|
+
})
|
|
961
|
+
const body = await res.json().catch(() => null)
|
|
962
|
+
if (!res.ok) {
|
|
963
|
+
if (res.status === 409 && Array.isArray(body?.accounts)) {
|
|
964
|
+
return toolError(`Choose a connected Gmail account and call again with account: ${body.accounts.join(', ')}`)
|
|
965
|
+
}
|
|
966
|
+
return toolError(body?.error ?? `Spam search failed (${res.status}).`)
|
|
967
|
+
}
|
|
968
|
+
const rows = Array.isArray(body?.messages) ? body.messages : []
|
|
969
|
+
if (!rows.length) return { content: [{ type: 'text', text: `No Spam matches in ${body?.account ?? 'the connected Gmail account'}. Nothing was saved to Agnoclast.` }] }
|
|
970
|
+
const lines = [`Spam matches in ${body.account} (read-only; not saved to Agnoclast):`]
|
|
971
|
+
for (const m of rows) {
|
|
972
|
+
lines.push(`- ${m.date || 'unknown date'} · ${m.from || 'unknown sender'} · ${m.subject}\n ${m.snippet || '(no preview)'}\n ${m.sourceUri}`)
|
|
973
|
+
}
|
|
974
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] }
|
|
975
|
+
},
|
|
976
|
+
)
|
|
977
|
+
|
|
978
|
+
server.registerTool(
|
|
979
|
+
'grep',
|
|
980
|
+
{
|
|
981
|
+
title: 'Grep the brain wiki',
|
|
982
|
+
description:
|
|
983
|
+
'Ranked keyword search across your visible brain wiki pages. Multi-word natural-language queries work (results are ranked by relevance). Pass mode:"substring" for an exact literal match of symbols, identifiers, or [[links]]. Returns matching sections with a context snippet and their outbound [[links]].',
|
|
984
|
+
inputSchema: {
|
|
985
|
+
query: z.string().describe('search terms (natural language is fine)'),
|
|
986
|
+
mode: z.enum(['substring', 'fts']).optional().describe("'fts' (default, ranked keyword) or 'substring' (exact literal — for identifiers / [[links]] / code)"),
|
|
987
|
+
},
|
|
988
|
+
},
|
|
989
|
+
async ({ query, mode }) => {
|
|
990
|
+
const qs = new URLSearchParams({ q: query, mode: mode === 'substring' ? 'substring' : 'fts' })
|
|
991
|
+
const res = await fetchCortex(`${BASE}/api/grep?${qs.toString()}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
992
|
+
if (!res.ok) {
|
|
993
|
+
const body = await res.text()
|
|
994
|
+
throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
|
|
995
|
+
}
|
|
996
|
+
const payload = await res.json()
|
|
997
|
+
return { content: [{ type: 'text', text: formatGrepHits(payload, query) }] }
|
|
998
|
+
},
|
|
999
|
+
)
|
|
1000
|
+
|
|
1001
|
+
server.registerTool(
|
|
1002
|
+
'code_graph_query',
|
|
1003
|
+
{
|
|
1004
|
+
title: 'Query the local code structure graph (graphify)',
|
|
1005
|
+
description:
|
|
1006
|
+
'Query a structural code graph for the repo at the CURRENT working directory, built locally by graphify (tree-sitter AST — deterministic, no LLM, no server round-trip; this is LOCAL MACHINE data, not org-shared Agnoclast content, and reflects a snapshot of one commit, not live files). Use for MULTI-HOP questions a single grep cannot answer: what calls/imports/depends on X, how A structurally reaches B, or a repo-wide overview (hub/community files). Do NOT use for single-hop lookups (does file X import Y) — grep is faster and always current. Structure only — it knows what imports/calls what, never WHY; read the actual files or authored Agnoclast pages for intent.',
|
|
1007
|
+
inputSchema: {
|
|
1008
|
+
action: z.enum(['query', 'path', 'explain']).describe('"query" = open-ended natural-language question (graph traversal); "path" = shortest structural path between two named nodes; "explain" = describe one node and list its direct connections'),
|
|
1009
|
+
question: z.string().optional().describe('required for action:"query" — the natural-language question'),
|
|
1010
|
+
from: z.string().optional().describe('required for action:"path" — the starting node name'),
|
|
1011
|
+
to: z.string().optional().describe('required for action:"path" — the target node name'),
|
|
1012
|
+
node: z.string().optional().describe('required for action:"explain" — the node name to describe'),
|
|
1013
|
+
},
|
|
1014
|
+
},
|
|
1015
|
+
async ({ action, question, from, to, node }) => {
|
|
1016
|
+
const result = runCodeGraphQuery({ action, question, from, to, node })
|
|
1017
|
+
return { content: [{ type: 'text', text: result.text }] }
|
|
1018
|
+
},
|
|
1019
|
+
)
|
|
1020
|
+
|
|
1021
|
+
server.registerTool(
|
|
1022
|
+
'project_status',
|
|
1023
|
+
{
|
|
1024
|
+
title: 'Project status',
|
|
1025
|
+
description: 'Status of a specific project by key (e.g. checkout-v2). Returns the AUTHORED wiki page (the synthesized understanding) when one exists, falling back to the records-derived line. Returns only what you can see.',
|
|
1026
|
+
inputSchema: { key: z.string().describe('project key, e.g. checkout-v2') },
|
|
1027
|
+
},
|
|
1028
|
+
async ({ key }) => {
|
|
1029
|
+
// Prefer the AUTHORED page (synthesized understanding, sovereign over records). Freshest tier leads,
|
|
1030
|
+
// each dated, so diverged tiers read as "newest first" instead of an undated stale/fresh blend.
|
|
1031
|
+
try {
|
|
1032
|
+
const res = await fetchCortex(`${BASE}/api/brain/page?kind=project&key=${encodeURIComponent(key)}`,
|
|
1033
|
+
{ headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
1034
|
+
if (res.ok) {
|
|
1035
|
+
const page = await res.json()
|
|
1036
|
+
// Multi-brain: /api/brain/page returns matches[] (one per brain that has the page).
|
|
1037
|
+
let matches = Array.isArray(page?.matches) ? page.matches.filter((m) => m.authored && Array.isArray(m.tiers) && m.tiers.length) : []
|
|
1038
|
+
// Rollout back-compat: adapt an older server's single-page shape to one match.
|
|
1039
|
+
if (!matches.length && page?.authored && Array.isArray(page.tiers) && page.tiers.length) {
|
|
1040
|
+
matches = [{ brain: null, authored: true, title: page.title, tiers: page.tiers }]
|
|
1041
|
+
}
|
|
1042
|
+
if (matches.length) {
|
|
1043
|
+
const day = (d) => (d ? String(d).slice(0, 10) : '')
|
|
1044
|
+
const renderMatch = (m) => {
|
|
1045
|
+
const blocks = m.tiers.map((t) => {
|
|
1046
|
+
// Was heading-then-body with NO currency at all — no as-of, no warning — while
|
|
1047
|
+
// read_page showed it. project_status is what the routing docs reach for first, so a
|
|
1048
|
+
// reader here could date nothing. Same renderer as read_page now, so the two cannot
|
|
1049
|
+
// drift again.
|
|
1050
|
+
const secs = (t.sections ?? []).map((s) => renderSection(s, day)).join('\n\n')
|
|
1051
|
+
const head = `[${t.tier}${day(t.updated_at) ? ` · authored ${day(t.updated_at)}` : ''}${t.validity && t.validity !== 'current' ? ` · ${t.validity}` : ''}]`
|
|
1052
|
+
return [head, t.summary, secs].filter(Boolean).join('\n')
|
|
1053
|
+
})
|
|
1054
|
+
const brainTag = matches.length > 1 ? ` · brain: ${m.brain}` : ''
|
|
1055
|
+
return `# ${m.title ?? key} (authored page${brainTag})\n\n${blocks.join('\n\n---\n\n')}`
|
|
1056
|
+
}
|
|
1057
|
+
return { content: [{ type: 'text', text: matches.map(renderMatch).join('\n\n═══\n\n') }] }
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
} catch { /* fall through to the records-derived line */ }
|
|
1061
|
+
// Fallback: no authored page yet — surface the records-derived line from the context snapshot.
|
|
1062
|
+
const text = await fetchContext()
|
|
1063
|
+
const line = text.split('\n').find((l) => l.includes(`**${key}**`) || l.includes(key))
|
|
1064
|
+
return { content: [{ type: 'text', text: line ? `Project ${key} (records-derived; not yet authored):\n${line.trim()}` : `No visible project "${key}".` }] }
|
|
1065
|
+
},
|
|
1066
|
+
)
|
|
1067
|
+
|
|
1068
|
+
server.registerTool(
|
|
1069
|
+
'read_page',
|
|
1070
|
+
{
|
|
1071
|
+
title: 'Read a wiki page in full',
|
|
1072
|
+
description:
|
|
1073
|
+
'READ the full authored wiki page for one node (project/person/org/you) by its canonical name — every section, every tier 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.',
|
|
1074
|
+
inputSchema: {
|
|
1075
|
+
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'),
|
|
1076
|
+
kind: z.enum(['project', 'person', 'org', 'user']).optional().describe('node kind (default project; pass person/org for people/teams)'),
|
|
1077
|
+
expand: z.boolean().optional().describe('identifier names only: also list recent visible timeline events for this identifier (default: home + count)'),
|
|
1078
|
+
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.'),
|
|
1079
|
+
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.'),
|
|
1080
|
+
},
|
|
1081
|
+
},
|
|
1082
|
+
async ({ name, kind, expand, history, version }) => {
|
|
1083
|
+
const k = kind ?? 'project'
|
|
1084
|
+
// PAGE HISTORY (Part A): a specific version reads one historical body, resolved within this page's
|
|
1085
|
+
// own history. Not for identifier-shaped names (those are join keys, handled below).
|
|
1086
|
+
if (version && !/^[a-z][a-z0-9_-]*:.+/i.test(name)) {
|
|
1087
|
+
try {
|
|
1088
|
+
const qs = new URLSearchParams({ kind: k, key: name, version })
|
|
1089
|
+
const vr = await fetchCortex(`${BASE}/api/brain/page?${qs}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
1090
|
+
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.` }] }
|
|
1091
|
+
if (!vr.ok) {
|
|
1092
|
+
const d = classify(vr.status, vr.headers.get('content-type'), await vr.text(), vr.headers.get('x-vercel-id'))
|
|
1093
|
+
return toolError(`Could not read version "${version}" of "${name}": ${d.message}`)
|
|
1094
|
+
}
|
|
1095
|
+
const v = await vr.json()
|
|
1096
|
+
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.` }] }
|
|
1097
|
+
} catch (e) {
|
|
1098
|
+
return toolError(`Could not read version "${version}" of "${name}": ${e.message}`)
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
// PER-NODE TIMELINE (slice 4): history = the projection over the node's identifier stamps.
|
|
1102
|
+
if (history && !/^[a-z][a-z0-9_-]*:.+/i.test(name)) {
|
|
1103
|
+
try {
|
|
1104
|
+
const qs = new URLSearchParams({ kind: k, key: name })
|
|
1105
|
+
const r = await fetchCortex(`${BASE}/api/node/timeline?${qs}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
1106
|
+
if (r.status === 404) return { content: [{ type: 'text', text: `No ${k} named "${name}" — cannot project a timeline.` }] }
|
|
1107
|
+
if (!r.ok) {
|
|
1108
|
+
const d = classify(r.status, r.headers.get('content-type'), await r.text(), r.headers.get('x-vercel-id'))
|
|
1109
|
+
return toolError(`Could not read the timeline for "${name}": ${d.message}`)
|
|
1110
|
+
}
|
|
1111
|
+
const t = await r.json()
|
|
1112
|
+
if (!t.identifiers?.length) {
|
|
1113
|
+
return { content: [{ type: 'text', text: `"${name}" carries no identifier stamps yet — no history joins. Stamp its page with [[repo:owner/name]] (what it identifies) and events will accrue here.` }] }
|
|
1114
|
+
}
|
|
1115
|
+
const lines = [`# ${name} — node timeline (history; the page is the present)`]
|
|
1116
|
+
lines.push(`Joins: ${t.identifiers.map((i) => `[[${i.id}]] · ${i.visibleCount} visible`).join(' | ')}${t.incomplete ? ' (partial — one join failed to read)' : ''}`)
|
|
1117
|
+
for (const e of t.events) lines.push(`- ${String(e.occurredAt).slice(0, 10)} · ${e.source} · ${e.summary}`)
|
|
1118
|
+
if (!t.events.length) lines.push('(no events visible to you yet on these joins)')
|
|
1119
|
+
if (t.siblings?.length) lines.push(`Sibling homes (share a stamp — bridges, not history): ${t.siblings.map((s) => `"${s.title}"`).join(', ')}`)
|
|
1120
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] }
|
|
1121
|
+
} catch (e) {
|
|
1122
|
+
return toolError(`Could not read the timeline for "${name}": ${e.message}`)
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
// IDENTIFIER RESOLUTION (slice 3): an identifier-shaped name is a JOIN KEY, not a page — resolve
|
|
1126
|
+
// it to its authored home + a viewer-honest event count instead of 404ing. Loose shape-detect here;
|
|
1127
|
+
// the server enforces the strict canonical law (identifiers.ts) and 400s malformed forms with an
|
|
1128
|
+
// actionable message we surface verbatim.
|
|
1129
|
+
if (/^[a-z][a-z0-9_-]*:.+/i.test(name)) {
|
|
1130
|
+
try {
|
|
1131
|
+
const qs = new URLSearchParams({ id: name, ...(expand ? { expand: '1' } : {}) })
|
|
1132
|
+
const r = await fetchCortex(`${BASE}/api/identifier/resolve?${qs}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
1133
|
+
if (r.ok) {
|
|
1134
|
+
const out = await r.json()
|
|
1135
|
+
const lines = [`# ${out.identifier} (identifier — a join key, not a page)`]
|
|
1136
|
+
if (out.status === 'unhomed') {
|
|
1137
|
+
lines.push(`No authored home carries this stamp yet — data but no home. If you know what this identifies, author its page and stamp [[${out.identifier}]] on it (that page becomes the home).`)
|
|
1138
|
+
} else if (out.status === 'healthy') {
|
|
1139
|
+
const h = out.homes[0]
|
|
1140
|
+
lines.push(`Home: "${h.title}" (${h.kind}, ${h.tier}) — \`read_page "${h.title}"\` for the full page.`)
|
|
1141
|
+
} else {
|
|
1142
|
+
lines.push(`⚠ ${out.homes.length} pages carry this stamp — possible duplicate homes, consider reconciling:`)
|
|
1143
|
+
for (const h of out.homes) lines.push(` - "${h.title}" (${h.kind}, ${h.tier})`)
|
|
1144
|
+
}
|
|
1145
|
+
lines.push(`· ${out.events.visibleCount} tagged timeline event${out.events.visibleCount === 1 ? '' : 's'} visible to you${out.events.recent.length ? ':' : expand ? '.' : ' — pass expand: true to list recent ones.'}`)
|
|
1146
|
+
for (const e of out.events.recent) lines.push(` - ${String(e.occurredAt).slice(0, 10)} · ${e.source} · ${e.summary}`)
|
|
1147
|
+
// KWA-28 — an identifier node is VIRTUAL: derived per read, zero stored rows, so there is no
|
|
1148
|
+
// as_of to fetch. The item says exactly what to do in that case: "where the object is derived
|
|
1149
|
+
// per-read, stamp the read itself." Note this as-of means something DIFFERENT from every
|
|
1150
|
+
// other one in gate 3 — "this answer was computed now", not "this claim was true then" — and
|
|
1151
|
+
// the wording says so, because collapsing the two under one word is how a resolution that is
|
|
1152
|
+
// merely FRESH gets read as a claim that is VERIFIED. The home and the count are both live
|
|
1153
|
+
// computations over data that can change between two reads a minute apart.
|
|
1154
|
+
lines.push(`· Resolved ${new Date().toISOString().slice(0, 16).replace('T', ' ')}Z — this node is derived per read (no stored row), so this is when the answer was COMPUTED, not when anything was verified.`)
|
|
1155
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] }
|
|
1156
|
+
}
|
|
1157
|
+
if (r.status === 400) {
|
|
1158
|
+
const body = await r.json().catch(() => null)
|
|
1159
|
+
return { content: [{ type: 'text', text: `"${name}" looks like an identifier but is not canonical: ${body?.error ?? 'expected repo:owner/name'}. Fix the stamp, or \`grep "${name}"\` to find where it appears.` }] }
|
|
1160
|
+
}
|
|
1161
|
+
// resolver endpoint unavailable (older server) → fall through to the normal page path below
|
|
1162
|
+
} catch { /* network hiccup → fall through to the page path */ }
|
|
1163
|
+
}
|
|
1164
|
+
let res
|
|
1165
|
+
try {
|
|
1166
|
+
res = await fetchCortex(`${BASE}/api/brain/page?kind=${k}&key=${encodeURIComponent(name)}`,
|
|
1167
|
+
{ headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
1168
|
+
} catch (e) {
|
|
1169
|
+
return toolError(`Could not read "${name}": ${e.message}`)
|
|
1170
|
+
}
|
|
1171
|
+
if (res.status === 404) {
|
|
1172
|
+
return { content: [{ type: 'text', text: `No ${k} page named "${name}". If it's a person or team, pass kind (person/org).${await redLinkTriage(BASE, TOKEN, name)}` }] }
|
|
1173
|
+
}
|
|
1174
|
+
if (!res.ok) {
|
|
1175
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
1176
|
+
return toolError(`Could not read "${name}": ${d.message}`)
|
|
1177
|
+
}
|
|
1178
|
+
const page = await res.json()
|
|
1179
|
+
// Multi-brain (decision 3A): /api/brain/page returns matches[] — one entry per brain that has a
|
|
1180
|
+
// page under this name. One match (the common case) reads exactly as before, plus a brain tag only
|
|
1181
|
+
// when >1 brain matches — never silently one brain's version.
|
|
1182
|
+
let matches = Array.isArray(page?.matches) ? page.matches.filter((m) => m.authored && Array.isArray(m.tiers) && m.tiers.length) : []
|
|
1183
|
+
// Rollout back-compat: an older server returns the single-page shape ({authored, tiers}) with no
|
|
1184
|
+
// matches[]; adapt it to one match so this build works against both old and new deployments.
|
|
1185
|
+
if (!matches.length && page?.authored && Array.isArray(page.tiers) && page.tiers.length) {
|
|
1186
|
+
matches = [{ brain: null, authored: true, ref: page.ref, title: page.title, tiers: page.tiers }]
|
|
1187
|
+
}
|
|
1188
|
+
if (!matches.length) {
|
|
1189
|
+
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).${await redLinkTriage(BASE, TOKEN, name)}` }] }
|
|
1190
|
+
}
|
|
1191
|
+
const day = (d) => (d ? String(d).slice(0, 10) : '')
|
|
1192
|
+
const renderMatch = (m, tagBrain) => {
|
|
1193
|
+
const blocks = m.tiers.map((t) => {
|
|
1194
|
+
const secs = (t.sections ?? []).map((s) => {
|
|
1195
|
+
// Shared with project_status via renderSection — see its definition for why this is one
|
|
1196
|
+
// function and not two (they drifted; #558 fixed one of three surfaces).
|
|
1197
|
+
return renderSection(s, day)
|
|
1198
|
+
}).join('\n\n')
|
|
1199
|
+
// ADR-0018: a null version isn't "nothing to show" — it means this variant predates content-
|
|
1200
|
+
// hash tracking (a 2026-06-29 import scar) and CANNOT be re-authored via base_version until an
|
|
1201
|
+
// admin backfills it. Silently omitting the line here is exactly what sent callers into an
|
|
1202
|
+
// unrecoverable base_version guessing loop; say so explicitly instead.
|
|
1203
|
+
const versionLine = t.version
|
|
1204
|
+
? `\nversion: ${t.version}`
|
|
1205
|
+
: `\nversion: none (this variant predates content-hash tracking — base_version writes will always fail here; ask an admin about the ADR-0018 backfill)`
|
|
1206
|
+
const head = `[${t.tier}${day(t.updated_at) ? ` · authored ${day(t.updated_at)}` : ''}${t.validity && t.validity !== 'current' ? ` · ${t.validity}` : ''}${versionLine}]`
|
|
1207
|
+
return [head, t.summary, secs].filter(Boolean).join('\n')
|
|
1208
|
+
})
|
|
1209
|
+
// REPAIR TOOL (2026-07-31). This footer used to say "re-author just those sections with
|
|
1210
|
+
// `author`" — which `author` cannot do: computeDroppedSections rejects a partial-section
|
|
1211
|
+
// write unconditionally, and the 409 then hands back every section's body, so the advice
|
|
1212
|
+
// routed the reader straight into retyping the whole page. Measured 2026-07-30: one such
|
|
1213
|
+
// retype silently deleted a sentence, a [[link]] (a graph edge), a command list and the
|
|
1214
|
+
// word "today" from sections it was never meant to touch. This footer renders on EVERY
|
|
1215
|
+
// page read in the system, so it was the single widest surface pointing the wrong way.
|
|
1216
|
+
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. If what went false is the SUMMARY at the top rather than a section, use \`set_summary\` — \`edit_page\` cannot reach it, and worse, it carries the old summary FORWARD into every revision it writes, so fixing a section silently re-certifies a summary that already contradicts 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.`
|
|
1217
|
+
// ADDRESSING (2026-08-04). The server accepts `ref` on every page/node route and its ambiguity
|
|
1218
|
+
// 409s hand refs back — but read_page never PRINTED one, so the only way to obtain a ref was to
|
|
1219
|
+
// trigger the error first. That made ID addressing reachable in principle and unusable in
|
|
1220
|
+
// practice. The ref is per-NODE (engram_ref) while `version` is per-TIER, which is why it rides
|
|
1221
|
+
// on the page header rather than inside a tier block.
|
|
1222
|
+
if (m.ref) footer += `\n— \`ref:\` above is this page's stable id. Pass it as \`ref\` to any page tool (set_page_privacy, page grants, node policy, author, rollback, node timeline) to address THIS page: refs are unique across brains, so two pages sharing a name in different brains cannot collide and no active-brain guess is involved. Prefer it over \`name\` whenever you already hold one.`
|
|
1223
|
+
// slice 4: when the page carries identifier stamps, the history projection is one flag away.
|
|
1224
|
+
const allBody = m.tiers.flatMap((t) => (t.sections ?? []).map((s) => s.body)).join('\n')
|
|
1225
|
+
const stamps = [...new Set((allBody.match(/\[\[repo:[a-z0-9][a-z0-9-]*\/[a-z0-9_.-]+\]\]/gi) ?? []).map((s) => s.toLowerCase()))]
|
|
1226
|
+
if (stamps.length) footer += `\n— This page carries ${stamps.join(', ')} — \`read_page "${name}"\` with history: true for its event timeline (page = present, timeline = history).`
|
|
1227
|
+
// Gate 4's per-node backlog nudge (also gate 3's KWA-36). The SERVER decides whether this
|
|
1228
|
+
// fires — it sends `nudge` only when 25+ records are unclaimed AND the page has gone 7+ days
|
|
1229
|
+
// unedited — so there is no threshold logic here to drift out of sync. It rides in the footer
|
|
1230
|
+
// because that is the surface with evidence behind it: a session read a page through a [[link]],
|
|
1231
|
+
// saw the footer, and repaired the page. A dashboard nobody opens would not have.
|
|
1232
|
+
if (m.backlog?.nudge) footer += `\n— ${m.backlog.nudge}`
|
|
1233
|
+
const brainTag = tagBrain ? ` · brain: ${m.brain}` : ''
|
|
1234
|
+
// Always emitted, not only in the multi-brain case: the ref is what makes the brain question
|
|
1235
|
+
// moot, so withholding it until brains collide is exactly backwards.
|
|
1236
|
+
const refLine = m.ref ? `\nref: ${m.ref}` : ''
|
|
1237
|
+
return `# ${m.title ?? name} (full authored page${brainTag})${refLine}\n\n${blocks.join('\n\n---\n\n')}\n\n${footer}`
|
|
1238
|
+
}
|
|
1239
|
+
if (matches.length === 1) {
|
|
1240
|
+
return { content: [{ type: 'text', text: renderMatch(matches[0], false) }] }
|
|
1241
|
+
}
|
|
1242
|
+
const header = `"${name}" is authored in ${matches.length} of your brains — all shown (each tagged with its brain, newest tier first):`
|
|
1243
|
+
return { content: [{ type: 'text', text: [header, ...matches.map((m) => renderMatch(m, true))].join('\n\n═══════════════════\n\n') }] }
|
|
1244
|
+
},
|
|
1245
|
+
)
|
|
1246
|
+
|
|
1247
|
+
server.registerTool(
|
|
1248
|
+
'page_history',
|
|
1249
|
+
{
|
|
1250
|
+
title: 'See a wiki page\'s edit history',
|
|
1251
|
+
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.',
|
|
1252
|
+
inputSchema: {
|
|
1253
|
+
name: z.string().describe('the canonical node name exactly as written (e.g. "Agnoclast", "Ben")'),
|
|
1254
|
+
kind: z.enum(['project', 'person', 'org', 'user']).optional().describe('node kind (default project)'),
|
|
1255
|
+
limit: z.number().int().optional().describe('how many recent versions to show (default 20, max 200)'),
|
|
1256
|
+
},
|
|
1257
|
+
},
|
|
1258
|
+
async ({ name, kind, limit }) => {
|
|
1259
|
+
const k = kind ?? 'project'
|
|
1260
|
+
let res
|
|
1261
|
+
try {
|
|
1262
|
+
const qs = new URLSearchParams({ kind: k, key: name, ...(limit ? { limit: String(limit) } : {}) })
|
|
1263
|
+
res = await fetchCortex(`${BASE}/api/brain/page-history?${qs}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
1264
|
+
} catch (e) {
|
|
1265
|
+
return toolError(`Could not read history for "${name}": ${e.message}`)
|
|
1266
|
+
}
|
|
1267
|
+
if (res.status === 404) return { content: [{ type: 'text', text: `No ${k} named "${name}". Pass kind (person/org) if it isn't a project.` }] }
|
|
1268
|
+
if (!res.ok) {
|
|
1269
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
1270
|
+
return toolError(`Could not read history for "${name}": ${d.message}`)
|
|
1271
|
+
}
|
|
1272
|
+
const out = await res.json()
|
|
1273
|
+
const revs = out.revisions ?? []
|
|
1274
|
+
if (!revs.length) return { content: [{ type: 'text', text: `"${name}" (${k}) has no recorded version history yet.` }] }
|
|
1275
|
+
const lines = revs.map((r) => {
|
|
1276
|
+
const who = r.actor_name ? ` · ${r.actor_name}` : ''
|
|
1277
|
+
// change_kind first and bracketed so a column of [correct] is scannable — the whole point is
|
|
1278
|
+
// that a page with repeated corrections looks different at a glance from one that only grew.
|
|
1279
|
+
const what = r.change_kind ? ` · [${r.change_kind}]` : ''
|
|
1280
|
+
const why = r.reason ? ` — ${r.reason}` : ''
|
|
1281
|
+
// Session keys run up to 200 chars; a short prefix is enough to group a session's edits and to
|
|
1282
|
+
// hand to a human. Null on every pre-2026-07-27 revision — render nothing rather than "none",
|
|
1283
|
+
// so "not recorded" never reads as "recorded as empty".
|
|
1284
|
+
const sess = r.session_key ? `\n session: ${String(r.session_key).slice(0, 24)}` : ''
|
|
1285
|
+
return `- rev ${r.rev_no} · ${String(r.created_at).slice(0, 10)} · ${r.op}${what} · ${r.tier}${who}${why}\n version: ${r.content_hash}${sess}`
|
|
1286
|
+
})
|
|
1287
|
+
const anyKind = revs.some((r) => r.change_kind)
|
|
1288
|
+
const hint = anyKind
|
|
1289
|
+
? `\n— \`page_diff "${name}"\` to see exactly what a revision changed.`
|
|
1290
|
+
: `\n— Revisions written before 2026-07-27 carry no reason/change_kind — that is "not recorded", not "no reason".`
|
|
1291
|
+
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.${hint}` }] }
|
|
1292
|
+
},
|
|
1293
|
+
)
|
|
1294
|
+
|
|
1295
|
+
server.registerTool(
|
|
1296
|
+
'page_diff',
|
|
1297
|
+
{
|
|
1298
|
+
title: 'See exactly what an edit changed',
|
|
1299
|
+
description: 'Show WHAT CHANGED between two versions of an authored wiki page — which sections were added, removed or rewritten, plus the reason and change_kind recorded for the edit. Use it when page_history tells you an edit happened and you need to know what it actually did: before trusting a claim that was recently rewritten, when auditing whether a "correct" edit really fixed something, or before rollback_page so you know what you would be undoing. `from` defaults to the version immediately before `to`, so passing just `to` answers "what did this one edit change?". RLS-scoped: you can diff only pages you may read.',
|
|
1300
|
+
inputSchema: {
|
|
1301
|
+
name: z.string().describe('the canonical node name exactly as written (e.g. "Agnoclast")'),
|
|
1302
|
+
kind: z.enum(['project', 'person', 'org', 'user']).optional().describe('node kind (default project)'),
|
|
1303
|
+
to: z.string().describe('the NEWER version: a rev_no (e.g. "6") or a content_hash, from page_history'),
|
|
1304
|
+
from: z.string().optional().describe('the OLDER version to compare against. Omit to use the revision immediately before `to` — which is what you want for "what did this edit change?"'),
|
|
1305
|
+
lines: z.boolean().optional().describe('also show line-level +/- within each changed section. Off by default: the section-level answer is usually what you want and is far shorter.'),
|
|
1306
|
+
},
|
|
1307
|
+
},
|
|
1308
|
+
async ({ name, kind, to, from, lines }) => {
|
|
1309
|
+
const k = kind ?? 'project'
|
|
1310
|
+
let res
|
|
1311
|
+
try {
|
|
1312
|
+
const qs = new URLSearchParams({
|
|
1313
|
+
kind: k, key: name, to,
|
|
1314
|
+
...(from ? { from } : {}), ...(lines ? { lines: '1' } : {}),
|
|
1315
|
+
})
|
|
1316
|
+
res = await fetchCortex(`${BASE}/api/brain/page-diff?${qs}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
1317
|
+
} catch (e) {
|
|
1318
|
+
return toolError(`Could not diff "${name}": ${e.message}`)
|
|
1319
|
+
}
|
|
1320
|
+
if (res.status === 404) return { content: [{ type: 'text', text: `No ${k} named "${name}", or it has no version "${to}". Run \`page_history "${name}"\` to list its versions.` }] }
|
|
1321
|
+
if (!res.ok) {
|
|
1322
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
1323
|
+
return toolError(`Could not diff "${name}": ${d.message}`)
|
|
1324
|
+
}
|
|
1325
|
+
const out = await res.json()
|
|
1326
|
+
const d = out.diff
|
|
1327
|
+
const head = d.from.revNo === 0
|
|
1328
|
+
? `# ${name} — rev ${d.to.revNo} (first version)`
|
|
1329
|
+
: `# ${name} — rev ${d.from.revNo} → rev ${d.to.revNo}`
|
|
1330
|
+
const meta = []
|
|
1331
|
+
meta.push(`${String(d.to.createdAt).slice(0, 10)} · ${d.to.op}${d.to.changeKind ? ` · [${d.to.changeKind}]` : ''}${d.to.actorName ? ` · ${d.to.actorName}` : ''}`)
|
|
1332
|
+
if (d.to.reason) meta.push(`reason: ${d.to.reason}`)
|
|
1333
|
+
if (d.to.sessionKey) meta.push(`session: ${String(d.to.sessionKey).slice(0, 24)}`)
|
|
1334
|
+
const body = []
|
|
1335
|
+
if (d.summaryChanged) body.push('- summary: CHANGED')
|
|
1336
|
+
for (const h of d.sections.added) body.push(`- + added section: ${h}`)
|
|
1337
|
+
for (const h of d.sections.removed) body.push(`- − removed section: ${h}`)
|
|
1338
|
+
for (const c of d.sections.changed) {
|
|
1339
|
+
body.push(`- ~ changed section: ${c.heading}`)
|
|
1340
|
+
// Cap the rendered hunk. A section body can be 65k chars; dumping a full rewrite into a tool
|
|
1341
|
+
// result buries the signal and burns the reader's context for no gain.
|
|
1342
|
+
for (const l of (c.lines ?? []).slice(0, 40)) body.push(` ${l.kind === 'add' ? '+' : '−'} ${l.line}`)
|
|
1343
|
+
if ((c.lines?.length ?? 0) > 40) body.push(` … ${c.lines.length - 40} more changed lines`)
|
|
1344
|
+
}
|
|
1345
|
+
if (!body.length) body.push('- no section or summary changes (metadata-only revision, e.g. a re-tier)')
|
|
1346
|
+
const tail = d.sections.unchangedCount ? `\n\n${d.sections.unchangedCount} section(s) unchanged.` : ''
|
|
1347
|
+
const hint = lines ? '' : '\n— Pass `lines: true` to see the actual changed lines within each section.'
|
|
1348
|
+
return { content: [{ type: 'text', text: `${head}\n${meta.join(' · ')}\n\n${body.join('\n')}${tail}${hint}` }] }
|
|
1349
|
+
},
|
|
1350
|
+
)
|
|
1351
|
+
|
|
1352
|
+
server.registerTool(
|
|
1353
|
+
'rollback_page',
|
|
1354
|
+
{
|
|
1355
|
+
title: 'Roll a wiki page back to a prior version',
|
|
1356
|
+
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.',
|
|
1357
|
+
inputSchema: {
|
|
1358
|
+
name: z.string().describe('the exact page name'),
|
|
1359
|
+
kind: z.enum(['project', 'person', 'org', 'user']).optional().describe('node kind (default project)'),
|
|
1360
|
+
to_version: z.string().describe('which version to restore: a rev_no (e.g. "3") or a content_hash, from page_history'),
|
|
1361
|
+
tier: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('which tier variant to roll back, if the node has more than one'),
|
|
1362
|
+
reason: z.string().optional().describe('optional note recorded on the new rollback version (why you rolled back)'),
|
|
1363
|
+
},
|
|
1364
|
+
},
|
|
1365
|
+
async ({ name, kind, to_version, tier, reason }) => {
|
|
1366
|
+
const k = kind ?? 'project'
|
|
1367
|
+
let res
|
|
1368
|
+
try {
|
|
1369
|
+
res = await fetchCortex(`${BASE}/api/brain/rollback`, {
|
|
1370
|
+
method: 'POST',
|
|
1371
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
1372
|
+
body: JSON.stringify({ kind: k, name, to_version, ...(tier ? { tier } : {}), ...(reason ? { reason } : {}) }),
|
|
1373
|
+
})
|
|
1374
|
+
} catch (e) {
|
|
1375
|
+
return toolError(`Could not roll back "${name}": ${e.message}`)
|
|
1376
|
+
}
|
|
1377
|
+
const out = await res.json().catch(() => null)
|
|
1378
|
+
if (!res.ok) return toolError(`Could not roll back "${name}": ${out?.error ?? res.status}`)
|
|
1379
|
+
// #477: when the target revision had no summary, the page's CURRENT summary was kept rather than
|
|
1380
|
+
// erased. Say it on its own line instead of at the tail of `note` — this is the 2026-08-04 W3
|
|
1381
|
+
// shape, where empty summaries copied over populated ones destroyed 31 of them under a report
|
|
1382
|
+
// that read as success. A rescue the operator does not see is still a silent write.
|
|
1383
|
+
const keptLine = out.summaryKept === 'current'
|
|
1384
|
+
? `\n\n⚠ That revision had NO summary, so the page's current summary was KEPT rather than erased — check it still describes the restored body, and use set_summary if not.`
|
|
1385
|
+
: ''
|
|
1386
|
+
return { content: [{ type: 'text', text: `Done — "${name}" ${out.note}. \`read_page "${name}"\` to confirm the current content.${keptLine}` }] }
|
|
1387
|
+
},
|
|
1388
|
+
)
|
|
1389
|
+
|
|
1390
|
+
server.registerTool(
|
|
1391
|
+
'rename_section',
|
|
1392
|
+
{
|
|
1393
|
+
title: 'Rename one section heading on a wiki page',
|
|
1394
|
+
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.',
|
|
1395
|
+
inputSchema: {
|
|
1396
|
+
name: z.string().describe('the exact page name, as read_page shows it'),
|
|
1397
|
+
from: z.string().describe('the CURRENT heading, exactly as stored (match is case- and whitespace-insensitive)'),
|
|
1398
|
+
to: z.string().describe('the new heading. Max 80 chars. Must not already be used by another section on this page.'),
|
|
1399
|
+
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.'),
|
|
1400
|
+
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.'),
|
|
1401
|
+
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).'),
|
|
1402
|
+
reason: z.string().optional().describe('why you are renaming it — recorded in page_history like any other edit'),
|
|
1403
|
+
},
|
|
1404
|
+
},
|
|
1405
|
+
async ({ name, from, to, base_version, tier, ordinal, reason }) => {
|
|
1406
|
+
let res
|
|
1407
|
+
try {
|
|
1408
|
+
res = await fetchCortex(`${BASE}/api/brain/rename-section`, {
|
|
1409
|
+
method: 'POST',
|
|
1410
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
1411
|
+
body: JSON.stringify({
|
|
1412
|
+
name, from, to, base_version,
|
|
1413
|
+
...(tier ? { tier } : {}), ...(ordinal !== undefined ? { ordinal } : {}),
|
|
1414
|
+
...(reason ? { reason } : {}),
|
|
1415
|
+
}),
|
|
1416
|
+
})
|
|
1417
|
+
} catch (e) {
|
|
1418
|
+
return toolError(`Could not rename the section: ${e.message}`)
|
|
1419
|
+
}
|
|
1420
|
+
const out = await res.json().catch(() => null)
|
|
1421
|
+
if (!res.ok) {
|
|
1422
|
+
// Surface the server's hint AND the disambiguators it named, so a 409 is directly actionable
|
|
1423
|
+
// rather than something to retry blindly.
|
|
1424
|
+
const extra = [
|
|
1425
|
+
out?.detail ? `existing: ${out.detail}` : '',
|
|
1426
|
+
Array.isArray(out?.tiers) ? `tiers: ${out.tiers.join(', ')}` : '',
|
|
1427
|
+
Array.isArray(out?.ordinals) ? `ordinals: ${out.ordinals.join(', ')}` : '',
|
|
1428
|
+
out?.currentVersion ? `current version: ${out.currentVersion}` : '',
|
|
1429
|
+
].filter(Boolean).join(' · ')
|
|
1430
|
+
const hint = out?.hint ? `\n${out.hint}` : ''
|
|
1431
|
+
return toolError(`Could not rename "${from}" on "${name}": ${out?.error ?? res.status}${extra ? `\n${extra}` : ''}${hint}`)
|
|
1432
|
+
}
|
|
1433
|
+
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}` }] }
|
|
1434
|
+
},
|
|
1435
|
+
)
|
|
1436
|
+
|
|
1437
|
+
server.registerTool(
|
|
1438
|
+
'set_summary',
|
|
1439
|
+
{
|
|
1440
|
+
title: 'Rewrite a wiki page summary without touching its sections',
|
|
1441
|
+
description: 'Replace a page\'s SUMMARY — the one-sentence line at the top — in place, leaving every section untouched. Use this the moment you notice a summary that no longer matches the page: `edit_page` structurally CANNOT reach it (it edits section bodies; the summary is not one), and `author` reaches it only by retyping every section on the way through, which is how a 2026-07-30 write silently deleted a sentence and a [[link]] from a section nobody meant to touch. Correcting a body with `edit_page` actively carries the OLD summary forward into the new revision, so a stale summary does not decay quietly — each unrelated fix re-certifies it. Fixing it matters more than it looks: page retrieval FTS-matches on title + summary ALONE — the tsvector is built over title and summary, and section bodies are not in that index — so a stale summary decides whether the page is found by that path at all, and it is the first line every reader sees before a word of the body. Requires base_version (the `version` read_page prints) — that is also how it finds the right brain, so it can never write to the wrong one. Recorded in page_history as a correction, and reversible with rollback_page.',
|
|
1442
|
+
inputSchema: {
|
|
1443
|
+
name: z.string().describe('the exact page name, as read_page shows it'),
|
|
1444
|
+
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.'),
|
|
1445
|
+
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.'),
|
|
1446
|
+
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.'),
|
|
1447
|
+
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.'),
|
|
1448
|
+
},
|
|
1449
|
+
},
|
|
1450
|
+
async ({ name, summary, base_version, reason, tier }) => {
|
|
1451
|
+
let res
|
|
1452
|
+
try {
|
|
1453
|
+
res = await fetchCortex(`${BASE}/api/brain/set-summary`, {
|
|
1454
|
+
method: 'POST',
|
|
1455
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
1456
|
+
body: JSON.stringify({
|
|
1457
|
+
name, summary, base_version,
|
|
1458
|
+
...(tier ? { tier } : {}), ...(reason ? { reason } : {}),
|
|
1459
|
+
}),
|
|
1460
|
+
})
|
|
1461
|
+
} catch (e) {
|
|
1462
|
+
return toolError(`Could not set the summary: ${e.message}`)
|
|
1463
|
+
}
|
|
1464
|
+
const out = await res.json().catch(() => null)
|
|
1465
|
+
if (!res.ok) {
|
|
1466
|
+
// Surface the server's hint AND the disambiguators it named, so a 409 is directly actionable
|
|
1467
|
+
// rather than something to retry blindly.
|
|
1468
|
+
const extra = [
|
|
1469
|
+
out?.detail ? `detail: ${out.detail}` : '',
|
|
1470
|
+
Array.isArray(out?.tiers) ? `tiers: ${out.tiers.join(', ')}` : '',
|
|
1471
|
+
out?.currentVersion ? `current version: ${out.currentVersion}` : '',
|
|
1472
|
+
].filter(Boolean).join(' · ')
|
|
1473
|
+
const hint = out?.hint ? `\n${out.hint}` : ''
|
|
1474
|
+
return toolError(`Could not set the summary on "${name}": ${out?.error ?? res.status}${extra ? `\n${extra}` : ''}${hint}`)
|
|
1475
|
+
}
|
|
1476
|
+
return { content: [{ type: 'text', text: `Summary rewritten on "${name}" (${out.brain} · ${out.tier} tier). Every section kept its body, position and as-of date.\nwas: ${out.previousSummary}\nnow: ${out.summary}\nNew version: ${out.version}` }] }
|
|
1477
|
+
},
|
|
1478
|
+
)
|
|
1479
|
+
|
|
1480
|
+
server.registerTool(
|
|
1481
|
+
'edit_page',
|
|
1482
|
+
{
|
|
1483
|
+
title: 'Change one passage on a wiki page (use this, not author, to fix something)',
|
|
1484
|
+
description:
|
|
1485
|
+
'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.',
|
|
1486
|
+
inputSchema: {
|
|
1487
|
+
name: z.string().describe('the exact page name, as read_page shows it'),
|
|
1488
|
+
heading: z.string().describe('the heading of the section containing the text you are changing'),
|
|
1489
|
+
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.'),
|
|
1490
|
+
new_string: z.string().describe('what to replace it with. May be empty, which deletes the anchored text.'),
|
|
1491
|
+
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.'),
|
|
1492
|
+
reason: z.string().describe('WHY you are making this change, in one short phrase — recorded in page_history exactly like an author edit.'),
|
|
1493
|
+
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.'),
|
|
1494
|
+
ordinal: z.number().optional().describe('only when the same heading appears more than once on the page — which occurrence (the error lists the ordinals).'),
|
|
1495
|
+
},
|
|
1496
|
+
},
|
|
1497
|
+
async ({ name, heading, old_string, new_string, base_version, reason, tier, ordinal }) => {
|
|
1498
|
+
let res
|
|
1499
|
+
try {
|
|
1500
|
+
res = await fetchCortex(`${BASE}/api/brain/edit-page`, {
|
|
1501
|
+
method: 'POST',
|
|
1502
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
1503
|
+
body: JSON.stringify({
|
|
1504
|
+
name, heading, old_string, new_string, base_version, reason,
|
|
1505
|
+
...(tier ? { tier } : {}), ...(ordinal !== undefined ? { ordinal } : {}),
|
|
1506
|
+
}),
|
|
1507
|
+
})
|
|
1508
|
+
} catch (e) {
|
|
1509
|
+
return toolError(`Could not edit "${name}": ${e.message}`)
|
|
1510
|
+
}
|
|
1511
|
+
const out = await res.json().catch(() => null)
|
|
1512
|
+
if (!res.ok) {
|
|
1513
|
+
// Every failure here is meant to be directly actionable — the caller should be able to fix and
|
|
1514
|
+
// retry from this text alone, without re-reading the page.
|
|
1515
|
+
const bits = [
|
|
1516
|
+
Array.isArray(out?.available) ? `sections on this page: ${out.available.join(' | ')}` : '',
|
|
1517
|
+
Array.isArray(out?.tiers) ? `tiers: ${out.tiers.join(', ')}` : '',
|
|
1518
|
+
Array.isArray(out?.ordinals) ? `ordinals: ${out.ordinals.join(', ')}` : '',
|
|
1519
|
+
out?.count ? `matches: ${out.count}` : '',
|
|
1520
|
+
out?.whitespaceNear === true ? 'YOUR TEXT IS PRESENT but the whitespace differs — re-copy it from the stored body' : '',
|
|
1521
|
+
out?.currentVersion ? `current version: ${out.currentVersion}` : '',
|
|
1522
|
+
].filter(Boolean).join('\n')
|
|
1523
|
+
const cur = out?.currentSectionBody
|
|
1524
|
+
? `\n\n--- the section as it stands now (re-anchor against this) ---\n${out.currentSectionBody}`
|
|
1525
|
+
: ''
|
|
1526
|
+
// B5: what ANOTHER session changed elsewhere on this page while you were working. Rendered
|
|
1527
|
+
// INLINE, not as a pointer to page_diff — a pointer is something a busy agent skips, and the
|
|
1528
|
+
// whole point is that you cannot be ignorant of it.
|
|
1529
|
+
const cc = Array.isArray(out?.concurrentChanges) && out.concurrentChanges.length
|
|
1530
|
+
? '\n\n--- changed elsewhere on this page since you read it (READ THIS before retrying) ---\n' +
|
|
1531
|
+
out.concurrentChanges.map((c) =>
|
|
1532
|
+
`§ ${c.heading}\n${(c.lines ?? []).map((l) => `${l.kind === 'add' ? '+' : '-'} ${l.line}`).join('\n')}`,
|
|
1533
|
+
).join('\n\n')
|
|
1534
|
+
: ''
|
|
1535
|
+
const ccMeta = [
|
|
1536
|
+
Array.isArray(out?.concurrentAdded) && out.concurrentAdded.length ? `sections ADDED elsewhere: ${out.concurrentAdded.join(', ')}` : '',
|
|
1537
|
+
Array.isArray(out?.concurrentRemoved) && out.concurrentRemoved.length ? `sections REMOVED elsewhere: ${out.concurrentRemoved.join(', ')}` : '',
|
|
1538
|
+
Array.isArray(out?.concurrentTruncated) && out.concurrentTruncated.length ? `(truncated, see page_diff for all of: ${out.concurrentTruncated.join(', ')})` : '',
|
|
1539
|
+
].filter(Boolean).join('\n')
|
|
1540
|
+
return toolError(`Could not edit "${name}": ${out?.error ?? res.status}${out?.hint ? `\n${out.hint}` : ''}${bits ? `\n${bits}` : ''}${ccMeta ? `\n${ccMeta}` : ''}${cc}${cur}`)
|
|
1541
|
+
}
|
|
1542
|
+
const red = Array.isArray(out.redLinks) && out.redLinks.length
|
|
1543
|
+
? `\nRed-links now on this page: ${out.redLinks.map((r) => `[[${r}]]`).join(', ')}` : ''
|
|
1544
|
+
// KWA-26 — same advisory flag as `author`, on the path that actually gets used. Absent (not
|
|
1545
|
+
// false) from an older server means "no verdict computed", so say nothing rather than imply the
|
|
1546
|
+
// section is dated — the 0093 don't-impute rule.
|
|
1547
|
+
const undatedNote = out?.undated === true
|
|
1548
|
+
? `\n⚠ This section now carries no explicit calendar date. A reader can see WHEN the text was written but not when the claim was TRUE. If it asserts a status, add the date inline — you still hold the context. The edit already landed; this is advisory.`
|
|
1549
|
+
: ''
|
|
1550
|
+
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}${undatedNote}` }] }
|
|
1551
|
+
},
|
|
1552
|
+
)
|
|
1553
|
+
|
|
1554
|
+
server.registerTool(
|
|
1555
|
+
'writing_style',
|
|
1556
|
+
{
|
|
1557
|
+
title: 'How the user writes (for drafting in their voice)',
|
|
1558
|
+
description: 'Returns the user\'s saved writing-style profile so you can DRAFT in their voice (email, message, doc). Call this right before composing anything on their behalf. Self-only — it is always the calling user\'s own profile. If none is saved, it tells you to derive one and save it with set_writing_style. A profile is stored per (user, BRAIN) — it is injected into authoring IN a brain — so if you hold several, name the one you are drafting in.',
|
|
1559
|
+
inputSchema: {
|
|
1560
|
+
brain: z.string().optional().describe('which brain\'s style profile, by name or org id. Unnecessary when you only have one brain; pass the org id when a name matches more than one of yours'),
|
|
1561
|
+
},
|
|
1562
|
+
},
|
|
1563
|
+
async ({ brain } = {}) => {
|
|
1564
|
+
let res
|
|
1565
|
+
try {
|
|
1566
|
+
res = await fetchCortex(`${BASE}/api/style${brain ? `?brain=${encodeURIComponent(brain)}` : ''}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
1567
|
+
} catch (e) {
|
|
1568
|
+
return toolError(`Could not load writing style: ${e.message}`)
|
|
1569
|
+
}
|
|
1570
|
+
if (!res.ok) {
|
|
1571
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
1572
|
+
return toolError(`Could not load writing style: ${d.message}`)
|
|
1573
|
+
}
|
|
1574
|
+
const { style } = await res.json()
|
|
1575
|
+
return { content: [{ type: 'text', text: style }] }
|
|
1576
|
+
},
|
|
1577
|
+
)
|
|
1578
|
+
|
|
1579
|
+
server.registerTool(
|
|
1580
|
+
'set_writing_style',
|
|
1581
|
+
{
|
|
1582
|
+
title: 'Save the user\'s writing-style profile',
|
|
1583
|
+
description: 'Save (or update) a description of HOW the user writes — tone, sentence rhythm, structure, formatting habits, signature quirks — derived from prose you have seen them write this session. Store the STYLE, never their private content. Self-only: it always updates the calling user\'s own profile. Pass an empty string to clear it.',
|
|
1584
|
+
inputSchema: {
|
|
1585
|
+
style_md: z.string().describe('a concise markdown description of the user\'s writing voice (tone/structure/quirks), ~1-2 paragraphs'),
|
|
1586
|
+
brain: z.string().optional().describe('which brain to save the profile in, by name or org id. A profile is stored per (user, brain), so this is a real choice when you hold several; pass the org id when a name matches more than one of yours'),
|
|
1587
|
+
},
|
|
1588
|
+
},
|
|
1589
|
+
async ({ style_md, brain }) => {
|
|
1590
|
+
let res
|
|
1591
|
+
try {
|
|
1592
|
+
res = await fetchCortex(`${BASE}/api/style${brain ? `?brain=${encodeURIComponent(brain)}` : ''}`, {
|
|
1593
|
+
method: 'PUT',
|
|
1594
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
1595
|
+
body: JSON.stringify({ style_md }),
|
|
1596
|
+
})
|
|
1597
|
+
} catch (e) {
|
|
1598
|
+
return toolError(`Could not save writing style: ${e.message}`)
|
|
1599
|
+
}
|
|
1600
|
+
if (!res.ok) {
|
|
1601
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
1602
|
+
return toolError(`Could not save writing style: ${d.message}`)
|
|
1603
|
+
}
|
|
1604
|
+
const r = await res.json()
|
|
1605
|
+
return { content: [{ type: 'text', text: r.saved ? `Saved your writing-style profile (${r.chars} chars).` : 'Cleared your writing-style profile.' }] }
|
|
1606
|
+
},
|
|
1607
|
+
)
|
|
1608
|
+
|
|
1609
|
+
server.registerTool(
|
|
1610
|
+
'my_brains',
|
|
1611
|
+
{
|
|
1612
|
+
title: 'List your brains and what each one holds',
|
|
1613
|
+
description: 'List the brains (orgs/workspaces) you belong to, with what each one CONTAINS — page count and sample titles. Reads span ALL of them, and an edit to an EXISTING page routes to the brain holding that page, so you do NOT need to check anything before editing. There is no active brain to set (ADR-0022 deleted the write pointer). Use this when creating a page that exists in NO brain yet: pick by relevance from the contents shown here and pass it as `brain`, because a caller with more than one brain must name one.',
|
|
1614
|
+
inputSchema: {},
|
|
1615
|
+
},
|
|
1616
|
+
async () => {
|
|
1617
|
+
let res
|
|
1618
|
+
try {
|
|
1619
|
+
res = await fetchCortex(`${BASE}/api/brains`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
1620
|
+
} catch (e) {
|
|
1621
|
+
return toolError(`Could not list brains: ${e.message}`)
|
|
1622
|
+
}
|
|
1623
|
+
if (!res.ok) {
|
|
1624
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
1625
|
+
return toolError(`Could not list brains: ${d.message}`)
|
|
1626
|
+
}
|
|
1627
|
+
const { brains } = await res.json()
|
|
1628
|
+
if (!brains?.length) return { content: [{ type: 'text', text: 'You have no brains.' }] }
|
|
1629
|
+
// Show CONTENTS, not just the name (ADR-0020 §7 corollary): a brain named TTO that holds 4
|
|
1630
|
+
// pages while the real TTO inventory sits in Personal reads as correct and is exactly
|
|
1631
|
+
// backwards. The page count and a couple of titles make that visible at the moment of choosing.
|
|
1632
|
+
//
|
|
1633
|
+
// ⚠ ADR-0022 CLEANUP. This handler used to destructure activeIsExplicit / activeSource /
|
|
1634
|
+
// sessionOrgId / accountOrgId and render a ▶ marker from b.isActive. /api/brains stopped
|
|
1635
|
+
// returning ALL of those when the write pointer was deleted, and the client kept reading them.
|
|
1636
|
+
// The failure was SILENT and confidently wrong rather than an error: `activeIsExplicit` arrived
|
|
1637
|
+
// `undefined`, `!undefined` is `true`, so the branch meaning "you have one brain" printed
|
|
1638
|
+
// unconditionally — telling a six-brain account it had one. The ▶ legend likewise advertised a
|
|
1639
|
+
// marker that could no longer appear, because b.isActive was undefined for every row.
|
|
1640
|
+
//
|
|
1641
|
+
// The lesson worth keeping: a client reading a field the server no longer sends does not fail,
|
|
1642
|
+
// it narrates. Every branch here asserted a FACT ("you have one brain") that was only ever an
|
|
1643
|
+
// inference from a DIFFERENT condition (no explicit pointer set). Deleting the pointer decoupled
|
|
1644
|
+
// the two and left the assertion running. Prefer deleting a stale branch to correcting it — a
|
|
1645
|
+
// corrected branch still reads state that no longer exists.
|
|
1646
|
+
const lines = brains.map((b) => {
|
|
1647
|
+
const inv = b.pageCount === 0
|
|
1648
|
+
? 'EMPTY'
|
|
1649
|
+
: `${b.pageCount} page${b.pageCount === 1 ? '' : 's'}${b.sampleTitles?.length ? `: ${b.sampleTitles.slice(0, 2).join(', ')}` : ''}`
|
|
1650
|
+
return ` ${b.name} (${b.role}${b.status !== 'active' ? `, ${b.status}` : ''}) — ${inv} [${b.orgId}]`
|
|
1651
|
+
})
|
|
1652
|
+
// Say how writes actually route now, since that is the question this list gets opened to answer.
|
|
1653
|
+
const header = brains.length === 1
|
|
1654
|
+
? 'Your brain:'
|
|
1655
|
+
: 'Your brains (reads span all of them; an edit routes to the brain holding the page; creating a NEW page takes an explicit `brain`):'
|
|
1656
|
+
return { content: [{ type: 'text', text: `${header}\n${lines.join('\n')}` }] }
|
|
1657
|
+
},
|
|
1658
|
+
)
|
|
1659
|
+
|
|
1660
|
+
// Stage 1 of agent-assisted member-add. It PREPARES the invite and stops — it does not perform it.
|
|
1661
|
+
//
|
|
1662
|
+
// WHY IT STOPS. Member-add lives on /api/invite and /api/members, which authenticate with
|
|
1663
|
+
// `verifyAuthToken` (the Supabase login JWT) and reject the personal token this client holds. That
|
|
1664
|
+
// split is not an oversight: `create_brain` accepts a personal token and writes a `users` row, so
|
|
1665
|
+
// the line is not "membership writes need a browser" — it is "enrolling YOURSELF is self-service,
|
|
1666
|
+
// granting a THIRD PARTY access to your brain is not." Stage 2 revisits that deliberately.
|
|
1667
|
+
//
|
|
1668
|
+
// What is worth automating is everything up to the grant. `managerId` is a `users.id` scoped to
|
|
1669
|
+
// ONE brain — a multi-brain caller has a different one per membership and no way to see any of
|
|
1670
|
+
// them from a chat window. That lookup is the part that actually blocks people, so this resolves
|
|
1671
|
+
// it and hands back a payload the console can accept verbatim.
|
|
1672
|
+
server.registerTool(
|
|
1673
|
+
'add_to_brain',
|
|
1674
|
+
{
|
|
1675
|
+
title: 'Add someone to one of your brains',
|
|
1676
|
+
description: "Add a person to one of your brains, or work out what it would take. Resolves which brain, whether you may add to it, and the manager id they are placed under. WITHOUT execute:true it only reports the plan and hands back a console link — call it that way first and show the user what you are about to do. WITH execute:true it performs the add, and then `brain` is REQUIRED: an access grant must never be aimed by a shared write pointer. Adding cannot be undone through the API. Use when asked to invite/add someone to a brain.",
|
|
1677
|
+
inputSchema: {
|
|
1678
|
+
email: z.string().describe("the person's email address — the login their Agnoclast account is (or will be) on"),
|
|
1679
|
+
name: z.string().optional().describe('their full name; falls back to the email local-part'),
|
|
1680
|
+
title: z.string().optional().describe('job title (optional)'),
|
|
1681
|
+
role: z.enum(['member', 'manager', 'owner']).optional().describe("their role in this brain (default 'member'). NOTE: only owner/manager can see records scoped to people below them"),
|
|
1682
|
+
brain: z.string().optional().describe('which brain, by name or org id. Optional when planning; REQUIRED with execute:true. Pass the org id when a name matches more than one of your brains'),
|
|
1683
|
+
execute: z.boolean().optional().describe('default false. false = report the plan only. true = actually add them — not undoable through the API, and requires an explicit brain'),
|
|
1684
|
+
},
|
|
1685
|
+
},
|
|
1686
|
+
async ({ email, name, title, role, brain, execute }) => {
|
|
1687
|
+
const addr = (email ?? '').trim().toLowerCase()
|
|
1688
|
+
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(addr)) return toolError(`"${email}" does not look like an email address.`)
|
|
1689
|
+
|
|
1690
|
+
// Executing without naming a brain would let the ACCOUNT-WIDE write pointer decide who gets
|
|
1691
|
+
// access to what. That pointer is shared by every session that has not set its own, so it
|
|
1692
|
+
// reflects whatever unrelated work last touched it — it is not information about this grant.
|
|
1693
|
+
// Planning may fall back to it (nothing happens); performing may not.
|
|
1694
|
+
if (execute && !brain?.trim()) {
|
|
1695
|
+
return toolError('To actually add someone you must name the brain — an access grant must not be aimed by the shared write pointer. Re-run with brain set (org id if the name is not unique).')
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
let res
|
|
1699
|
+
try {
|
|
1700
|
+
res = await fetchCortex(`${BASE}/api/brains`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
1701
|
+
} catch (e) {
|
|
1702
|
+
return toolError(`Could not read your brains: ${e.message}`)
|
|
1703
|
+
}
|
|
1704
|
+
if (!res.ok) {
|
|
1705
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
1706
|
+
return toolError(`Could not read your brains: ${d.message}`)
|
|
1707
|
+
}
|
|
1708
|
+
const { brains } = await res.json()
|
|
1709
|
+
if (!brains?.length) return toolError('You have no brains, so there is nothing to add anyone to.')
|
|
1710
|
+
|
|
1711
|
+
// Name or org id, either way — a person says "my mom's brain", not a uuid.
|
|
1712
|
+
//
|
|
1713
|
+
// ⚠ BRAIN NAMES ARE NOT UNIQUE ACROSS ACCOUNTS, and the collision is the LIKELY case, not the
|
|
1714
|
+
// exotic one: the default personal brain is called "Personal", so the moment you are added to
|
|
1715
|
+
// someone else's you hold two. Observed 2026-08-04 within a minute of exactly that happening.
|
|
1716
|
+
// A `.find()` here would silently return whichever sorted first and add the person to an
|
|
1717
|
+
// arbitrary one — a wrong-brain member-add with no symptom, which is the same silent-misroute
|
|
1718
|
+
// family as the write pointer. An org id always wins; an ambiguous NAME is a 409-shaped error,
|
|
1719
|
+
// never a guess.
|
|
1720
|
+
const wanted = brain?.trim().toLowerCase()
|
|
1721
|
+
let target
|
|
1722
|
+
if (wanted) {
|
|
1723
|
+
const byId = brains.find((b) => b.orgId.toLowerCase() === wanted)
|
|
1724
|
+
const byName = brains.filter((b) => b.name.toLowerCase() === wanted)
|
|
1725
|
+
if (!byId && byName.length > 1) {
|
|
1726
|
+
const rows = byName.map((b) => ` ${b.name} (${b.role}, ${b.pageCount} pages) [${b.orgId}]`)
|
|
1727
|
+
return toolError(
|
|
1728
|
+
`You belong to ${byName.length} brains called "${brain}". I will not guess which one to add someone to — pass the org id:\n${rows.join('\n')}`,
|
|
1729
|
+
)
|
|
1730
|
+
}
|
|
1731
|
+
target = byId ?? byName[0]
|
|
1732
|
+
} else {
|
|
1733
|
+
// ADR-0022: there is no active brain to fall back to — /api/brains stopped sending isActive
|
|
1734
|
+
// when the write pointer was deleted, so the old `brains.find((b) => b.isActive)` here could
|
|
1735
|
+
// only ever return undefined. Behaviour was already correct (undefined falls through to the
|
|
1736
|
+
// error below, which is the right contract), but it was correct by accident, reading a field
|
|
1737
|
+
// that no longer exists. Made explicit: sole membership resolves itself, anything else asks.
|
|
1738
|
+
target = brains.length === 1 ? brains[0] : undefined
|
|
1739
|
+
}
|
|
1740
|
+
if (!target) {
|
|
1741
|
+
const names = brains.map((b) => `${b.name} [${b.orgId}]`).join(', ')
|
|
1742
|
+
return toolError(
|
|
1743
|
+
wanted
|
|
1744
|
+
? `No brain called "${brain}". You belong to: ${names}.`
|
|
1745
|
+
: `Could not tell which brain you mean — you belong to ${brains.length} and there is no default. Pass one of: ${names}.`,
|
|
1746
|
+
)
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
// Role is PER MEMBERSHIP. Being an owner elsewhere grants nothing here, and the server will
|
|
1750
|
+
// enforce this again — checking now turns a later 403 into an answer.
|
|
1751
|
+
if (!['owner', 'manager', 'admin'].includes(target.role)) {
|
|
1752
|
+
return toolError(`You are a "${target.role}" in ${target.name}, and only owners and managers can add people. Ask an owner of ${target.name} to do it.`)
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
// Absent on any console deployed before userId was added to /api/brains. Say so precisely —
|
|
1756
|
+
// a published MCP version is not a deployed API, and the two drift.
|
|
1757
|
+
if (!target.userId) {
|
|
1758
|
+
return toolError(`This Agnoclast deployment does not report your member id for ${target.name} yet, so the manager cannot be resolved. The API needs the /api/brains update that adds "userId".`)
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
const payload = {
|
|
1762
|
+
name: name?.trim() || addr.split('@')[0],
|
|
1763
|
+
email: addr,
|
|
1764
|
+
...(title?.trim() ? { title: title.trim() } : {}),
|
|
1765
|
+
role: role ?? 'member',
|
|
1766
|
+
managerId: target.userId,
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
if (execute) {
|
|
1770
|
+
// `brain` is sent as the ORG ID, never the label the caller typed: the server resolves
|
|
1771
|
+
// labels too, and a name that was unambiguous here could match differently there. The id is
|
|
1772
|
+
// the same value on both sides.
|
|
1773
|
+
let done
|
|
1774
|
+
try {
|
|
1775
|
+
done = await fetchCortex(`${BASE}/api/invite`, {
|
|
1776
|
+
method: 'POST',
|
|
1777
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
1778
|
+
body: JSON.stringify({ ...payload, brain: target.orgId }),
|
|
1779
|
+
})
|
|
1780
|
+
} catch (e) {
|
|
1781
|
+
return toolError(`Could not add ${payload.email} to ${target.name}: ${e.message}`)
|
|
1782
|
+
}
|
|
1783
|
+
if (!done.ok) {
|
|
1784
|
+
const d = classify(done.status, done.headers.get('content-type'), await done.text(), done.headers.get('x-vercel-id'))
|
|
1785
|
+
return toolError(`Could not add ${payload.email} to ${target.name}: ${d.message}`)
|
|
1786
|
+
}
|
|
1787
|
+
const r = await done.json().catch(() => ({}))
|
|
1788
|
+
if (r.alreadyMember) {
|
|
1789
|
+
return { content: [{ type: 'text', text: `${payload.email} was already a member of ${target.name} — nothing changed.` }] }
|
|
1790
|
+
}
|
|
1791
|
+
// Two different outcomes for the human: a brand-new account has a credential that somebody
|
|
1792
|
+
// must physically pass on, an existing one has none.
|
|
1793
|
+
const how = r.existingAccount
|
|
1794
|
+
? 'They already had an Agnoclast account, so they keep their current login and simply gain this brain.'
|
|
1795
|
+
: `A new account was created. Temporary password: ${r.password} — they must change it on first sign-in.`
|
|
1796
|
+
return { content: [{ type: 'text', text: `Added ${payload.name} <${payload.email}> to ${target.name} as ${payload.role}, placed under you.\n${how}\n\nThis cannot be undone through the API — removing a membership currently needs direct database access.` }] }
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
const text = [
|
|
1800
|
+
`Ready to add ${payload.name} <${payload.email}> to ${target.name} as ${payload.role}.`,
|
|
1801
|
+
'',
|
|
1802
|
+
` brain ${target.name} [${target.orgId}]`,
|
|
1803
|
+
` your role ${target.role} — you may add people here`,
|
|
1804
|
+
` manager you [${target.userId}] (they are placed under you)`,
|
|
1805
|
+
'',
|
|
1806
|
+
'Nothing has happened yet. To go ahead, re-run with execute:true and the same brain —',
|
|
1807
|
+
`or do it yourself at ${BASE}/?invite=1 with these values:`,
|
|
1808
|
+
'',
|
|
1809
|
+
` Full name ${payload.name}`,
|
|
1810
|
+
` Email ${payload.email}`,
|
|
1811
|
+
...(payload.title ? [` Job title ${payload.title}`] : []),
|
|
1812
|
+
` Role ${payload.role}`,
|
|
1813
|
+
` Manager you`,
|
|
1814
|
+
'',
|
|
1815
|
+
'If they already have an Agnoclast account they keep their existing password and simply gain',
|
|
1816
|
+
'this brain; if not, the console shows a temporary password to pass on. Either way the button',
|
|
1817
|
+
'handles it — you do not need to know which in advance.',
|
|
1818
|
+
'',
|
|
1819
|
+
`payload: ${JSON.stringify(payload)}`,
|
|
1820
|
+
].join('\n')
|
|
1821
|
+
|
|
1822
|
+
return { content: [{ type: 'text', text }] }
|
|
1823
|
+
},
|
|
1824
|
+
)
|
|
1825
|
+
|
|
1826
|
+
server.registerTool(
|
|
1827
|
+
'list_brain_pages',
|
|
1828
|
+
{
|
|
1829
|
+
title: 'List every authored page in one brain',
|
|
1830
|
+
description: 'QUERY the authored pages in ONE brain, by its org id (from my_brains) — filter by owner, recency, tier, kind or name, and sort. Returns one row per node with its kind, validity, tier(s), owner(s), last-updated date and content hash. This is the structural counterpart to `grep`: use it when the question is a FILTER-AND-SORT ("what has X written this week", "which pages are confidential", "what is stale") and grep when you need to match WORDS inside page text. ⚠ `validity` defaults to `current`, so superseded pages are EXCLUDED unless you ask for them — a stale page presented as live is a failure this system keeps hitting. Also use it to VERIFY a brain-to-brain migration: list BOTH brains, diff the page sets, compare freshness before retiring any original. You can only list a brain you are a member of, and within it you see only the pages you are cleared to read — a confidential page owned by someone else is not listed, not even by title, and that holds for every filter combination including `owner`.',
|
|
1831
|
+
inputSchema: {
|
|
1832
|
+
org_id: z.string().describe('the org id of the brain to enumerate (from my_brains)'),
|
|
1833
|
+
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.'),
|
|
1834
|
+
updated_within_days: z.number().optional().describe('only pages touched in the last N days'),
|
|
1835
|
+
tier: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('only nodes that have a row at this tier. The row still reports every tier you can see, so a multi-tier page does not come back describing itself as single-tier.'),
|
|
1836
|
+
kind: z.enum(['project', 'person', 'org', 'user']).optional().describe('only nodes of this kind'),
|
|
1837
|
+
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."),
|
|
1838
|
+
name_contains: z.string().optional().describe('case-insensitive substring match on the node name or page title'),
|
|
1839
|
+
sort: z.enum(['recent', 'name']).optional().describe("default 'recent' (newest first). Ties break deterministically, so repeat calls are stable."),
|
|
1840
|
+
limit: z.number().optional().describe('default 50, capped at 500'),
|
|
1841
|
+
},
|
|
1842
|
+
},
|
|
1843
|
+
async ({ org_id, owner, updated_within_days, tier, kind, validity, name_contains, sort, limit }) => {
|
|
1844
|
+
// Only send params the caller actually set: an omitted filter and an empty one are different
|
|
1845
|
+
// requests, and the route validates enums strictly rather than ignoring unknown values.
|
|
1846
|
+
const qs = new URLSearchParams({ orgId: org_id })
|
|
1847
|
+
if (owner) qs.set('owner', owner)
|
|
1848
|
+
if (updated_within_days != null) qs.set('updated_within_days', String(updated_within_days))
|
|
1849
|
+
if (tier) qs.set('tier', tier)
|
|
1850
|
+
if (kind) qs.set('kind', kind)
|
|
1851
|
+
if (validity) qs.set('validity', validity)
|
|
1852
|
+
if (name_contains) qs.set('name_contains', name_contains)
|
|
1853
|
+
if (sort) qs.set('sort', sort)
|
|
1854
|
+
if (limit != null) qs.set('limit', String(limit))
|
|
1855
|
+
let res
|
|
1856
|
+
try {
|
|
1857
|
+
res = await fetchCortex(`${BASE}/api/brains/pages?${qs}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
1858
|
+
} catch (e) {
|
|
1859
|
+
return toolError(`Could not list pages: ${e.message}`)
|
|
1860
|
+
}
|
|
1861
|
+
if (!res.ok) {
|
|
1862
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
1863
|
+
return toolError(`Could not list pages: ${d.message}`)
|
|
1864
|
+
}
|
|
1865
|
+
const { nodeCount, rowCount, pages } = await res.json()
|
|
1866
|
+
if (!pages?.length) return { content: [{ type: 'text', text: `Brain ${org_id} has no authored pages.` }] }
|
|
1867
|
+
// One terse line per node: name ·kind· date [NON-CURRENT validity] {non-default tiers}. A migration
|
|
1868
|
+
// diff needs the name, the freshness date, and whether a page is already retired or multi-tier.
|
|
1869
|
+
const lines = pages.map((p) => {
|
|
1870
|
+
const flag = p.validity && p.validity !== 'current' ? ` [${String(p.validity).toUpperCase()}]` : ''
|
|
1871
|
+
const tiers = p.tiers?.length && !(p.tiers.length === 1 && p.tiers[0] === 'accessible') ? ` {${p.tiers.join('+')}}` : ''
|
|
1872
|
+
const day = p.updatedAt ? String(p.updatedAt).slice(0, 10) : '????-??-??'
|
|
1873
|
+
return `${p.name ?? '(unnamed)'} ·${p.kind}· ${day}${flag}${tiers}`
|
|
1874
|
+
})
|
|
1875
|
+
const header = `${nodeCount} page${nodeCount === 1 ? '' : 's'} in brain ${org_id} (${rowCount} digest rows across tiers):`
|
|
1876
|
+
return { content: [{ type: 'text', text: `${header}\n${lines.join('\n')}` }] }
|
|
1877
|
+
},
|
|
1878
|
+
)
|
|
1879
|
+
|
|
1880
|
+
server.registerTool(
|
|
1881
|
+
'create_brain',
|
|
1882
|
+
{
|
|
1883
|
+
title: 'Create a new brain under your existing account',
|
|
1884
|
+
description: 'Create a brand-new brain (org/workspace) — a fully independent knowledge graph — under your EXISTING account. No new login, no new email/password: this adds a second membership to the account you are already using. Reads never cross brains; new pages default to your active brain, so use pass `brain` when an operation needs one named',
|
|
1885
|
+
inputSchema: { name: z.string().describe('display name for the new brain, e.g. "Design Team"') },
|
|
1886
|
+
},
|
|
1887
|
+
async ({ name }) => {
|
|
1888
|
+
let res
|
|
1889
|
+
try {
|
|
1890
|
+
res = await fetchCortex(`${BASE}/api/brains`, {
|
|
1891
|
+
method: 'POST',
|
|
1892
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
1893
|
+
body: JSON.stringify({ name }),
|
|
1894
|
+
})
|
|
1895
|
+
} catch (e) {
|
|
1896
|
+
return toolError(`Could not create brain: ${e.message}`)
|
|
1897
|
+
}
|
|
1898
|
+
if (!res.ok) {
|
|
1899
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
1900
|
+
return toolError(`Could not create brain: ${d.message}`)
|
|
1901
|
+
}
|
|
1902
|
+
const r = await res.json()
|
|
1903
|
+
return { content: [{ type: 'text', text: `Created brain "${name}" [${r.orgId}]. Reads already span it. Edits to pages in it route themselves from the page, so nothing needs pointing at it; pass brain="${name}" on an operation that creates something new here.` }] }
|
|
1904
|
+
},
|
|
1905
|
+
)
|
|
1906
|
+
|
|
1907
|
+
server.registerTool(
|
|
1908
|
+
'list_records',
|
|
1909
|
+
{
|
|
1910
|
+
title: 'List activity records (filtered)',
|
|
1911
|
+
description: 'List recent activity records you can see, filtered by type, project, and recency. RLS-scoped. Use to enumerate "what happened on project X this week" or "recent meetings". Returns each record\'s FULL summary + id — never a truncated preview, so what you get back is the whole record you are permitted to read (use an id with request_file to ask its owner for the original file).',
|
|
1912
|
+
inputSchema: {
|
|
1913
|
+
type: z.string().optional().describe("record type, e.g. 'meeting', 'comm', 'activity', 'ai_session', 'doc', 'note'"),
|
|
1914
|
+
project: z.string().optional().describe('project key to filter to (e.g. "cortex")'),
|
|
1915
|
+
since_days: z.number().optional().describe('only records from the last N days'),
|
|
1916
|
+
limit: z.number().optional().describe('max rows (1-50, default 20)'),
|
|
1917
|
+
session: z.string().optional().describe('a session id — returns every record that session produced, INCLUDING the separate halves of a log that was split across brains. Segmented logs share a sessionId and never link to each other, so this is the only way to reassemble one. RLS-scoped: you get the halves you are cleared for and cannot tell whether others exist'),
|
|
1918
|
+
},
|
|
1919
|
+
},
|
|
1920
|
+
async ({ type, project, since_days, limit, session }) => {
|
|
1921
|
+
const qs = new URLSearchParams()
|
|
1922
|
+
if (type) qs.set('type', type)
|
|
1923
|
+
if (project) qs.set('project', project)
|
|
1924
|
+
if (since_days != null) qs.set('since_days', String(since_days))
|
|
1925
|
+
if (limit != null) qs.set('limit', String(limit))
|
|
1926
|
+
if (session) qs.set('session', session)
|
|
1927
|
+
let res
|
|
1928
|
+
try {
|
|
1929
|
+
res = await fetchCortex(`${BASE}/api/records?${qs}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
1930
|
+
} catch (e) {
|
|
1931
|
+
return toolError(`Could not list records: ${e.message}`)
|
|
1932
|
+
}
|
|
1933
|
+
if (!res.ok) {
|
|
1934
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
1935
|
+
return toolError(`Could not list records: ${d.message}`)
|
|
1936
|
+
}
|
|
1937
|
+
const { text } = await res.json()
|
|
1938
|
+
return { content: [{ type: 'text', text }] }
|
|
1939
|
+
},
|
|
1940
|
+
)
|
|
1941
|
+
|
|
1942
|
+
server.registerTool(
|
|
1943
|
+
'my_day',
|
|
1944
|
+
{
|
|
1945
|
+
title: 'My daily log (chronological)',
|
|
1946
|
+
description: "Your OWN activity for a day, composed chronologically from that day's records of every kind — sessions, meetings, comms, docs, notes. The \"what did I do today\" rollup: a derived view over records (nothing new is stored), self-scoped to you, each item tagged with its kind. Defaults to today in your local timezone. Pass `date` (YYYY-MM-DD) for another day, or `days` for a trailing window (e.g. days:7 for the week). Confidential records are segregated into their own block.",
|
|
1947
|
+
inputSchema: {
|
|
1948
|
+
date: z.string().optional().describe('the day to roll up, YYYY-MM-DD (default: today in your local timezone)'),
|
|
1949
|
+
days: z.number().optional().describe('trailing window ending on `date` — e.g. 7 for the past week (default 1, max 31)'),
|
|
1950
|
+
},
|
|
1951
|
+
},
|
|
1952
|
+
async ({ date, days }) => {
|
|
1953
|
+
// Resolve the caller's local timezone + today client-side (the MCP server runs on the user's
|
|
1954
|
+
// machine) so the day boundary matches their wall clock, not the server's UTC.
|
|
1955
|
+
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
|
|
1956
|
+
const today = new Date().toLocaleDateString('en-CA', { timeZone: tz }) // en-CA → YYYY-MM-DD
|
|
1957
|
+
const qs = new URLSearchParams({ date: date || today, tz })
|
|
1958
|
+
if (days != null) qs.set('days', String(days))
|
|
1959
|
+
let res
|
|
1960
|
+
try {
|
|
1961
|
+
res = await fetchCortex(`${BASE}/api/records/daily?${qs}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
1962
|
+
} catch (e) {
|
|
1963
|
+
return toolError(`Could not build daily log: ${e.message}`)
|
|
1964
|
+
}
|
|
1965
|
+
if (!res.ok) {
|
|
1966
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
1967
|
+
return toolError(`Could not build daily log: ${d.message}`)
|
|
1968
|
+
}
|
|
1969
|
+
const { text } = await res.json()
|
|
1970
|
+
return { content: [{ type: 'text', text }] }
|
|
1971
|
+
},
|
|
1972
|
+
)
|
|
1973
|
+
|
|
1974
|
+
server.registerTool(
|
|
1975
|
+
'my_sessions',
|
|
1976
|
+
{
|
|
1977
|
+
title: 'My active AI sessions',
|
|
1978
|
+
description: "See what all of YOUR OWN active Agnoclast/AI sessions are doing right now (working directory + how recently each was active), so you can coordinate across windows/devices. Self-only — only your own sessions, never anyone else's.",
|
|
1979
|
+
inputSchema: {},
|
|
1980
|
+
},
|
|
1981
|
+
async () => {
|
|
1982
|
+
let res
|
|
1983
|
+
try {
|
|
1984
|
+
res = await fetchCortex(`${BASE}/api/sessions?current=${encodeURIComponent(SESSION_KEY)}`, {
|
|
1985
|
+
headers: { Authorization: `Bearer ${TOKEN}` },
|
|
1986
|
+
})
|
|
1987
|
+
} catch (e) {
|
|
1988
|
+
return toolError(`Could not list your sessions: ${e.message}`)
|
|
1989
|
+
}
|
|
1990
|
+
if (!res.ok) {
|
|
1991
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
1992
|
+
return toolError(`Could not list your sessions: ${d.message}`)
|
|
1993
|
+
}
|
|
1994
|
+
const { text } = await res.json()
|
|
1995
|
+
return { content: [{ type: 'text', text }] }
|
|
1996
|
+
},
|
|
1997
|
+
)
|
|
1998
|
+
|
|
1999
|
+
server.registerTool(
|
|
2000
|
+
'set_record_privacy',
|
|
2001
|
+
{
|
|
2002
|
+
title: 'Set a record\'s privacy tier',
|
|
2003
|
+
description: 'Reclassify the access tier of one of YOUR OWN records: "accessible" (anyone in the org), "scoped" (you + your management chain), or "confidential" (you + people you explicitly grant access). Get record_id from a `list_records` result or the "id:" on a `my_context` activity line. Only affects records you own — others return an error.',
|
|
2004
|
+
inputSchema: {
|
|
2005
|
+
record_id: z.string().describe('the record id (uuid), e.g. the "id:" on a list_records line'),
|
|
2006
|
+
privacy: z.enum(['accessible', 'scoped', 'confidential']).describe('the new access tier'),
|
|
2007
|
+
},
|
|
2008
|
+
},
|
|
2009
|
+
async ({ record_id, privacy }) => {
|
|
2010
|
+
let res
|
|
2011
|
+
try {
|
|
2012
|
+
res = await fetchCortex(`${BASE}/api/records/${record_id}`, {
|
|
2013
|
+
method: 'PATCH',
|
|
2014
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
2015
|
+
body: JSON.stringify({ privacy }),
|
|
2016
|
+
})
|
|
2017
|
+
} catch (e) {
|
|
2018
|
+
return toolError(`Could not set privacy: ${e.message}`)
|
|
2019
|
+
}
|
|
2020
|
+
if (!res.ok) {
|
|
2021
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
2022
|
+
return toolError(`Could not set privacy: ${d.message}`)
|
|
2023
|
+
}
|
|
2024
|
+
const out = await res.json()
|
|
2025
|
+
return { content: [{ type: 'text', text: `Done — record ${out.id} is now "${out.privacy}".` }] }
|
|
2026
|
+
},
|
|
2027
|
+
)
|
|
2028
|
+
|
|
2029
|
+
server.registerTool(
|
|
2030
|
+
'set_page_validity',
|
|
2031
|
+
{
|
|
2032
|
+
title: 'Mark a wiki page current / superseded / historical',
|
|
2033
|
+
description: 'Set the CURRENCY of one of YOUR OWN authored pages so the brain can filter out stale info: "current" (reflects reality now — the default), "superseded" (replaced by a newer page — pass superseded_by with that page\'s name), or "historical" (kept for the record, no longer current). Optionally set modality: "reality" (as-built), "plan" (intended, not yet real), or "construction" (being built now). Call this the moment your understanding changes that a page is no longer the live truth — e.g. right after you author the new reality, mark the old page superseded. Owner-only.',
|
|
2034
|
+
inputSchema: {
|
|
2035
|
+
kind: z.enum(['project', 'person', 'org', 'user']).describe('the page kind'),
|
|
2036
|
+
name: z.string().describe('the exact page name'),
|
|
2037
|
+
validity: z.enum(['current', 'superseded', 'historical']).describe('the currency state'),
|
|
2038
|
+
modality: z.enum(['reality', 'plan', 'construction']).optional().describe('what the page describes (optional)'),
|
|
2039
|
+
superseded_by: z.string().optional().describe('for superseded: the name of the page that replaced it'),
|
|
2040
|
+
},
|
|
2041
|
+
},
|
|
2042
|
+
async ({ kind, name, validity, modality, superseded_by }) => {
|
|
2043
|
+
let res
|
|
2044
|
+
try {
|
|
2045
|
+
res = await fetchCortex(`${BASE}/api/brain/validity`, {
|
|
2046
|
+
method: 'POST',
|
|
2047
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
2048
|
+
body: JSON.stringify({ kind, name, validity, modality, superseded_by }),
|
|
2049
|
+
})
|
|
2050
|
+
} catch (e) {
|
|
2051
|
+
return toolError(`Could not set validity: ${e.message}`)
|
|
2052
|
+
}
|
|
2053
|
+
if (!res.ok) {
|
|
2054
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
2055
|
+
return toolError(`Could not set validity: ${d.message}`)
|
|
2056
|
+
}
|
|
2057
|
+
const out = await res.json()
|
|
2058
|
+
return { content: [{ type: 'text', text: `Done — "${out.name}" is now ${out.validity}${out.modality ? ` / ${out.modality}` : ''} (${out.docs_updated} tier-doc(s) updated).` }] }
|
|
2059
|
+
},
|
|
2060
|
+
)
|
|
2061
|
+
|
|
2062
|
+
server.registerTool(
|
|
2063
|
+
'alias_page',
|
|
2064
|
+
{
|
|
2065
|
+
title: 'Point a wanted name at an existing page',
|
|
2066
|
+
description: 'Record that a red-link — a [[Name]] referenced in the wiki but never authored — actually MEANS an existing authored page under a different title. After this, read_page and [[links]] for that name resolve to the target page, and the name leaves the org\'s wanted-page backlog. Use this when read_page says a name is a wanted page but you recognize it as an existing page (e.g. [[tto]] -> "BYU TTO — Technology Transfer Office"). To CREATE a genuinely new page instead, use `author`.',
|
|
2067
|
+
inputSchema: {
|
|
2068
|
+
name: z.string().describe('the wanted [[Name]] to redirect (the red-link)'),
|
|
2069
|
+
target_name: z.string().describe('the exact title of the existing authored page it should resolve to'),
|
|
2070
|
+
target_kind: z.enum(['project', 'person', 'org', 'user']).optional().describe('disambiguate the target if two pages share a title'),
|
|
2071
|
+
},
|
|
2072
|
+
},
|
|
2073
|
+
async ({ name, target_name, target_kind }) => {
|
|
2074
|
+
let res
|
|
2075
|
+
try {
|
|
2076
|
+
res = await fetchCortex(`${BASE}/api/brain/alias`, {
|
|
2077
|
+
method: 'POST',
|
|
2078
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
2079
|
+
body: JSON.stringify({ name, target_name, ...(target_kind ? { target_kind } : {}) }),
|
|
2080
|
+
})
|
|
2081
|
+
} catch (e) {
|
|
2082
|
+
return toolError(`Could not alias: ${e.message}`)
|
|
2083
|
+
}
|
|
2084
|
+
const out = await res.json().catch(() => null)
|
|
2085
|
+
if (!res.ok) return toolError(`Could not alias "${name}": ${out?.error ?? res.status}`)
|
|
2086
|
+
if (!out) return { content: [{ type: 'text', text: `Aliased "${name}", but the server returned no body — re-read the page to confirm.` }] }
|
|
2087
|
+
return { content: [{ type: 'text', text: `Done — [[${out.alias}]] now resolves to "${out.target}" (${out.target_kind}). It's out of the wanted-page backlog.` }] }
|
|
2088
|
+
},
|
|
2089
|
+
)
|
|
2090
|
+
|
|
2091
|
+
server.registerTool(
|
|
2092
|
+
'set_routing_identifier',
|
|
2093
|
+
{
|
|
2094
|
+
title: 'Claim a routing identifier on a page',
|
|
2095
|
+
description: 'Declare that a page is a Gate 4 attach HOME for an identifier (repo:owner/name or file:owner/name:path). Body [[repo:…]] stamps are navigation only and do NOT drive attach — use this instead. Prefer the narrowest id: mother pages own repo:; feature pages own file: paths. Do not copy every body mention into a routing claim.',
|
|
2096
|
+
inputSchema: {
|
|
2097
|
+
kind: z.enum(['project', 'person', 'org', 'user']).describe('the page kind'),
|
|
2098
|
+
name: z.string().optional().describe('page title (or pass ref)'),
|
|
2099
|
+
ref: z.string().optional().describe('node ref from read_page — prefer over name when available'),
|
|
2100
|
+
brain: z.string().optional().describe('brain label when the title is ambiguous across brains'),
|
|
2101
|
+
identifier: z.string().describe('canonical identifier, e.g. repo:theronap/cortex or file:theronap/cortex:web/lib/engine/github_intake.ts'),
|
|
2102
|
+
},
|
|
2103
|
+
},
|
|
2104
|
+
async ({ kind, name, ref, brain, identifier }) => {
|
|
2105
|
+
if (!name && !ref) return toolError('Pass name or ref')
|
|
2106
|
+
let res
|
|
2107
|
+
try {
|
|
2108
|
+
res = await fetchCortex(`${BASE}/api/brain/routing-identifiers`, {
|
|
2109
|
+
method: 'POST',
|
|
2110
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
2111
|
+
body: JSON.stringify({ kind, name, ref, brain, identifier }),
|
|
2112
|
+
})
|
|
2113
|
+
} catch (e) {
|
|
2114
|
+
return toolError(`Could not set routing identifier: ${e.message}`)
|
|
2115
|
+
}
|
|
2116
|
+
const out = await res.json().catch(() => null)
|
|
2117
|
+
if (!res.ok) return toolError(`Could not set routing identifier: ${out?.error ?? res.status}`)
|
|
2118
|
+
const set = out?.set?.join(', ') ?? identifier
|
|
2119
|
+
return { content: [{ type: 'text', text: `Routing identifier set on document ${out?.documentId ?? '?'}: ${set}. Future matching events will attach here (body mentions alone will not).` }] }
|
|
2120
|
+
},
|
|
2121
|
+
)
|
|
2122
|
+
|
|
2123
|
+
// ── Gate 4 record triage, over timeline_claims ────────────────────────────────────────────
|
|
2124
|
+
// A connector event materializes in seconds and has no idea what the work WAS. The session that
|
|
2125
|
+
// did the work knows exactly, and arrives later. These three tools are that handoff: look at what
|
|
2126
|
+
// landed, claim what is yours, route it when you know where it goes.
|
|
2127
|
+
//
|
|
2128
|
+
// The ledger is `timeline_claims` (0105) — one claim discipline over one stream. Unclaimed means
|
|
2129
|
+
// NO claim row: absence IS the backlog, and nothing is written at ingest.
|
|
2130
|
+
|
|
2131
|
+
server.registerTool(
|
|
2132
|
+
'pending_records',
|
|
2133
|
+
{
|
|
2134
|
+
title: 'Records waiting for a home',
|
|
2135
|
+
description: 'List recent connector records (GitHub pushes, PRs, email) that NOBODY HAS ATTENDED TO yet — no claim row in the Gate 4 ledger. Check this when your session starts if the headline count sounds related to what you are about to work on; records from your own recent commits are usually in here, and you are the only one who can recognize them as yours. Returns titles and current homes only, never payloads. Use view "sweep" on one record to get graded page-name candidates without reading any pages.',
|
|
2136
|
+
inputSchema: {
|
|
2137
|
+
view: z.enum(['digest', 'sweep']).optional().describe('digest = what is waiting (default); sweep = cheap graded candidates for one record'),
|
|
2138
|
+
recordId: z.string().optional().describe('required for view "sweep"'),
|
|
2139
|
+
hours: z.number().int().positive().optional().describe('lookback window, default 3'),
|
|
2140
|
+
stale: z.boolean().optional().describe('include old unclaimed records — the cleanup pile, not the live one'),
|
|
2141
|
+
limit: z.number().int().positive().optional(),
|
|
2142
|
+
},
|
|
2143
|
+
},
|
|
2144
|
+
async ({ view, recordId, hours, stale, limit }) => {
|
|
2145
|
+
const params = new URLSearchParams()
|
|
2146
|
+
params.set('view', view === 'sweep' ? 'sweep' : 'digest')
|
|
2147
|
+
if (recordId) params.set('recordId', recordId)
|
|
2148
|
+
if (hours) params.set('hours', String(hours))
|
|
2149
|
+
if (stale) params.set('stale', '1')
|
|
2150
|
+
if (limit) params.set('limit', String(limit))
|
|
2151
|
+
|
|
2152
|
+
let res
|
|
2153
|
+
try {
|
|
2154
|
+
res = await fetchCortex(`${BASE}/api/brain/triage?${params}`, {
|
|
2155
|
+
headers: { Authorization: `Bearer ${TOKEN}` },
|
|
2156
|
+
})
|
|
2157
|
+
} catch (e) {
|
|
2158
|
+
return toolError(`Could not read pending records: ${e.message}`)
|
|
2159
|
+
}
|
|
2160
|
+
const out = await res.json().catch(() => null)
|
|
2161
|
+
if (!res.ok) return toolError(`Could not read pending records: ${out?.error ?? res.status}`)
|
|
2162
|
+
|
|
2163
|
+
if (view === 'sweep') {
|
|
2164
|
+
const cands = out?.candidates ?? []
|
|
2165
|
+
if (cands.length === 0) {
|
|
2166
|
+
return { content: [{ type: 'text', text: `No page-name candidates for "${out?.title ?? recordId}". Park it — a stale record with no match is not worth reading pages over.` }] }
|
|
2167
|
+
}
|
|
2168
|
+
const lines = cands.map((c) => ` ${c.strength === 'strong' ? '●' : '○'} ${c.title} (${c.strength}) — ${c.documentId}`)
|
|
2169
|
+
const rec = out?.recommendation
|
|
2170
|
+
return {
|
|
2171
|
+
content: [{
|
|
2172
|
+
type: 'text',
|
|
2173
|
+
text: `Candidates for "${out?.title}":\n${lines.join('\n')}\n\nRecommended: ${rec?.action} — ${rec?.why}\n● strong = titles contain each other, safe to route. ○ weak = one generic word matched; read those pages ONLY if this record is worth the tokens, otherwise park.`,
|
|
2174
|
+
}],
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
const records = out?.records ?? []
|
|
2179
|
+
if (records.length === 0) {
|
|
2180
|
+
return { content: [{ type: 'text', text: 'Nothing waiting for a home.' }] }
|
|
2181
|
+
}
|
|
2182
|
+
const lines = records.map((r) => {
|
|
2183
|
+
const homes = r.currentHomes?.length ? r.currentHomes.join(', ') : 'nothing'
|
|
2184
|
+
const held = r.claimedBySession ? ` [claimed: ${String(r.claimedBySession).slice(0, 12)}…]` : ''
|
|
2185
|
+
return ` • ${r.title}\n ${r.source} · ${r.ageHours}h ago · on: ${homes}${held}\n ${r.recordId}`
|
|
2186
|
+
})
|
|
2187
|
+
return {
|
|
2188
|
+
content: [{
|
|
2189
|
+
type: 'text',
|
|
2190
|
+
text: `${records.length} record(s) waiting for a home:\n\n${lines.join('\n\n')}\n\nRecognize any as your own work? claim_record it now, then route_record once you know where it belongs.`,
|
|
2191
|
+
}],
|
|
2192
|
+
}
|
|
2193
|
+
},
|
|
2194
|
+
)
|
|
2195
|
+
|
|
2196
|
+
server.registerTool(
|
|
2197
|
+
'claim_record',
|
|
2198
|
+
{
|
|
2199
|
+
title: 'Claim a pending record as your work',
|
|
2200
|
+
description: 'Say "this record is mine, I will route it once I know where it goes." Use it as soon as you recognize your own work in pending_records, even before you know the destination page — the claim takes it out of the backlog so nothing else guesses at something you have real context on. Claims are leased and expire, so a dead session never holds a record hostage, and a record held by another LIVE session cannot be taken. Pass release=true to give one back when it turns out not to be yours — that deletes the claim, so the record looks untouched again rather than attended-to.',
|
|
2201
|
+
inputSchema: {
|
|
2202
|
+
recordId: z.string().describe('record id from pending_records'),
|
|
2203
|
+
note: z.string().optional().describe('what you think this is — kept for audit'),
|
|
2204
|
+
leaseMinutes: z.number().int().positive().optional().describe('how long you need it, default 90'),
|
|
2205
|
+
release: z.boolean().optional().describe('give the claim back instead of taking it'),
|
|
2206
|
+
},
|
|
2207
|
+
},
|
|
2208
|
+
async ({ recordId, note, leaseMinutes, release }) => {
|
|
2209
|
+
let res
|
|
2210
|
+
try {
|
|
2211
|
+
res = await fetchCortex(`${BASE}/api/brain/triage`, {
|
|
2212
|
+
method: 'POST',
|
|
2213
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
2214
|
+
body: JSON.stringify({ action: release ? 'release' : 'claim', recordId, note, leaseMinutes }),
|
|
2215
|
+
})
|
|
2216
|
+
} catch (e) {
|
|
2217
|
+
return toolError(`Could not claim record: ${e.message}`)
|
|
2218
|
+
}
|
|
2219
|
+
const out = await res.json().catch(() => null)
|
|
2220
|
+
if (!res.ok) {
|
|
2221
|
+
if (out?.error === 'already_claimed') {
|
|
2222
|
+
// Name the holder when the server knows it. The fallback matters: `heldBy` is absent when
|
|
2223
|
+
// nothing holds a live lease, which means the record was not claimable rather than taken —
|
|
2224
|
+
// printing "another session has it" there sends the reader chasing a session that does not
|
|
2225
|
+
// exist. (This branch printed a bare `undefined` until 2026-08-14; the field was renamed
|
|
2226
|
+
// server-side and the tool was never updated, which is the whole reason it says both now.)
|
|
2227
|
+
if (out.heldBy) {
|
|
2228
|
+
const until = out.heldUntil ? `, lease to ${out.heldUntil}` : ''
|
|
2229
|
+
return toolError(`Session ${String(out.heldBy).slice(0, 16)}… is holding that record${until}. Leave it to them.`)
|
|
2230
|
+
}
|
|
2231
|
+
return toolError(out.detail || 'Could not claim that record and no session holds it — re-run pending_records; it may have been resolved already.')
|
|
2232
|
+
}
|
|
2233
|
+
return toolError(`Could not claim record: ${out?.error ?? res.status}`)
|
|
2234
|
+
}
|
|
2235
|
+
if (release) return { content: [{ type: 'text', text: 'Released — it is back in the pending pool.' }] }
|
|
2236
|
+
return { content: [{ type: 'text', text: `Claimed until ${out?.expiresAt ?? 'the lease expires'}. Call route_record when you know where it belongs.` }] }
|
|
2237
|
+
},
|
|
2238
|
+
)
|
|
2239
|
+
|
|
2240
|
+
server.registerTool(
|
|
2241
|
+
'route_record',
|
|
2242
|
+
{
|
|
2243
|
+
title: 'Route a record to the pages it belongs on',
|
|
2244
|
+
description: 'Attach a pending record to the pages you judge correct — the point of the whole triage path. Use this when you have real context on what the work was; that judgment is better than any rule the webhook could run. Attachments are additive: existing deterministic homes (routing identifiers, your profile) stay. Pass park=true instead when you have looked and there is genuinely no good home — parking beats attaching to a page that merely shares a word.',
|
|
2245
|
+
inputSchema: {
|
|
2246
|
+
recordId: z.string().describe('record id from pending_records'),
|
|
2247
|
+
documentIds: z.array(z.string()).optional().describe('page document ids to attach (from pending_records sweep, or read_page)'),
|
|
2248
|
+
reason: z.string().describe('why these pages — recorded with the attachment'),
|
|
2249
|
+
park: z.boolean().optional().describe('no good home exists; leave it alone rather than guessing'),
|
|
2250
|
+
tier: z.number().int().optional().describe('2 when this came from the cheap sweep rather than your own context'),
|
|
2251
|
+
},
|
|
2252
|
+
},
|
|
2253
|
+
async ({ recordId, documentIds, reason, park, tier }) => {
|
|
2254
|
+
let res
|
|
2255
|
+
try {
|
|
2256
|
+
res = await fetchCortex(`${BASE}/api/brain/triage`, {
|
|
2257
|
+
method: 'POST',
|
|
2258
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
2259
|
+
body: JSON.stringify({ action: park ? 'park' : 'route', recordId, documentIds, reason, tier }),
|
|
2260
|
+
})
|
|
2261
|
+
} catch (e) {
|
|
2262
|
+
return toolError(`Could not route record: ${e.message}`)
|
|
2263
|
+
}
|
|
2264
|
+
const out = await res.json().catch(() => null)
|
|
2265
|
+
if (!res.ok) return toolError(`Could not route record: ${out?.error ?? res.status}${out?.detail ? ` — ${out.detail}` : ''}`)
|
|
2266
|
+
if (park) return { content: [{ type: 'text', text: 'Parked. It stays visible and unrouted rather than badly attached.' }] }
|
|
2267
|
+
return { content: [{ type: 'text', text: `Routed — attached to ${out?.attached?.length ?? 0} page(s). Recorded as a session judgment, not a rule match.` }] }
|
|
2268
|
+
},
|
|
2269
|
+
)
|
|
2270
|
+
|
|
2271
|
+
server.registerTool(
|
|
2272
|
+
'unroute_record',
|
|
2273
|
+
{
|
|
2274
|
+
title: 'Remove pages a record should not be on',
|
|
2275
|
+
description:
|
|
2276
|
+
'Detach pages a record does not belong on — the inverse of route_record, and the only way a wrong placement can be undone. route_record is purely ADDITIVE, so attaching more pages can never fix a bad one. Use this when you can see a record sitting on a page it has no real relationship to — the classic case is a fuzzy title match, e.g. a commit attached to a page merely because both contain a common word. Two things it will refuse rather than surprise you: it will not remove every attachment (a record with no home is invisible, which is worse than a wrong home — route or park it instead), and detaching the page that GOVERNS the tier can tighten the record but never republish it, since a widening is pinned and proposed for a human to confirm.',
|
|
2277
|
+
inputSchema: {
|
|
2278
|
+
recordId: z.string().describe('record id from pending_records'),
|
|
2279
|
+
documentIds: z.array(z.string()).describe('page document ids to REMOVE from this record'),
|
|
2280
|
+
reason: z.string().describe('why these placements are wrong — recorded with the removal'),
|
|
2281
|
+
},
|
|
2282
|
+
},
|
|
2283
|
+
async ({ recordId, documentIds, reason }) => {
|
|
2284
|
+
let res
|
|
2285
|
+
try {
|
|
2286
|
+
res = await fetchCortex(`${BASE}/api/brain/triage`, {
|
|
2287
|
+
method: 'POST',
|
|
2288
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
2289
|
+
body: JSON.stringify({ action: 'detach', recordId, documentIds, reason }),
|
|
2290
|
+
})
|
|
2291
|
+
} catch (e) {
|
|
2292
|
+
return toolError(`Could not detach: ${e.message}`)
|
|
2293
|
+
}
|
|
2294
|
+
const out = await res.json().catch(() => null)
|
|
2295
|
+
if (!res.ok) {
|
|
2296
|
+
// These two are guard rails, not faults — say what to do instead of just naming the code.
|
|
2297
|
+
if (out?.error === 'would_strand') {
|
|
2298
|
+
return toolError(`Refused: ${out.detail ?? 'that would leave the record with no pages at all.'}`)
|
|
2299
|
+
}
|
|
2300
|
+
if (out?.error === 'not_attached') {
|
|
2301
|
+
return toolError(`Nothing removed: ${out.detail ?? 'those pages are not attached to this record.'}`)
|
|
2302
|
+
}
|
|
2303
|
+
return toolError(`Could not detach: ${out?.error ?? res.status}${out?.detail ? ` — ${out.detail}` : ''}`)
|
|
2304
|
+
}
|
|
2305
|
+
const n = out?.detached?.length ?? 0
|
|
2306
|
+
const left = out?.remaining ?? 0
|
|
2307
|
+
return {
|
|
2308
|
+
content: [{
|
|
2309
|
+
type: 'text',
|
|
2310
|
+
text: `Detached ${n} page(s); ${left} attachment(s) remain. Record tier: ${out?.privacy ?? 'unchanged'}. Recorded as a session judgment.`,
|
|
2311
|
+
}],
|
|
2312
|
+
}
|
|
2313
|
+
},
|
|
2314
|
+
)
|
|
2315
|
+
|
|
2316
|
+
server.registerTool(
|
|
2317
|
+
'set_governing_page',
|
|
2318
|
+
{
|
|
2319
|
+
title: 'Choose which attached page sets a record\'s tier',
|
|
2320
|
+
description:
|
|
2321
|
+
'Move a record\'s GOVERNING page — the one attached page whose tier the record takes. A record can sit on several pages, but exactly one of them decides how visible it is; the others confer access without authority (ADR-0027). Use this when a record is on the right pages but the WRONG one is deciding its tier — most often a record governed by your own user node when it plainly belongs to a project. The page must already be attached: run route_record first if it is not, because attaching is a relevance judgement and this is not. It applies immediately in BOTH directions, tightening or widening, because you asking for it IS the human confirmation a widening requires — so read the tier you are moving to before you move. Every move is recorded as a session judgment and is the signal the placement heuristics are calibrated against, which is why the reason matters.',
|
|
2322
|
+
inputSchema: {
|
|
2323
|
+
recordId: z.string().describe('record id (from pending_records or my_records)'),
|
|
2324
|
+
documentId: z.string().describe('document id of the ATTACHED page that should govern the tier'),
|
|
2325
|
+
reason: z.string().describe('why this page should set the tier — recorded, and read as calibration signal'),
|
|
2326
|
+
},
|
|
2327
|
+
},
|
|
2328
|
+
async ({ recordId, documentId, reason }) => {
|
|
2329
|
+
let res
|
|
2330
|
+
try {
|
|
2331
|
+
res = await fetchCortex(`${BASE}/api/brain/triage`, {
|
|
2332
|
+
method: 'POST',
|
|
2333
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
2334
|
+
body: JSON.stringify({ action: 'regovern', recordId, documentId, reason }),
|
|
2335
|
+
})
|
|
2336
|
+
} catch (e) {
|
|
2337
|
+
return toolError(`Could not set the governing page: ${e.message}`)
|
|
2338
|
+
}
|
|
2339
|
+
const out = await res.json().catch(() => null)
|
|
2340
|
+
if (!res.ok) {
|
|
2341
|
+
// A guard rail, not a fault — say what to do instead of naming the code.
|
|
2342
|
+
if (out?.error === 'not_attached') {
|
|
2343
|
+
return toolError(
|
|
2344
|
+
'That page is not attached to this record, so it cannot govern it. Attach it first with route_record.',
|
|
2345
|
+
)
|
|
2346
|
+
}
|
|
2347
|
+
return toolError(
|
|
2348
|
+
`Could not set the governing page: ${out?.error ?? res.status}${out?.detail ? ` — ${out.detail}` : ''}`,
|
|
2349
|
+
)
|
|
2350
|
+
}
|
|
2351
|
+
if (out?.reaffirmed) {
|
|
2352
|
+
return {
|
|
2353
|
+
content: [{
|
|
2354
|
+
type: 'text',
|
|
2355
|
+
text: `That page already governed this record; recorded your confirmation. Tier: ${out?.toPrivacy ?? 'unchanged'}.`,
|
|
2356
|
+
}],
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
const moved =
|
|
2360
|
+
out?.fromPrivacy && out?.toPrivacy && out.fromPrivacy !== out.toPrivacy
|
|
2361
|
+
? `Tier ${out.fromPrivacy} -> ${out.toPrivacy} (${out?.direction}).`
|
|
2362
|
+
: `Tier unchanged (${out?.toPrivacy ?? 'unknown'}).`
|
|
2363
|
+
return {
|
|
2364
|
+
content: [{
|
|
2365
|
+
type: 'text',
|
|
2366
|
+
text: `Governing page moved. ${moved} Recorded as a session judgment${out?.correctedAuto ? ' and counted as a correction to the placement heuristics' : ''}.`,
|
|
2367
|
+
}],
|
|
2368
|
+
}
|
|
2369
|
+
},
|
|
2370
|
+
)
|
|
2371
|
+
|
|
2372
|
+
server.registerTool(
|
|
2373
|
+
'snooze_red_link',
|
|
2374
|
+
{
|
|
2375
|
+
title: 'Defer a wanted page routed to you',
|
|
2376
|
+
description: 'Stop a wanted page (a red-link the org routed to you as its most-likely steward) from surfacing in your context for a while. Use when you can\'t author it right now but it is genuinely yours to write. It comes back after the snooze passes. To dismiss it permanently, author it (`author`) or alias it to an existing page (`alias_page`).',
|
|
2377
|
+
inputSchema: {
|
|
2378
|
+
name: z.string().describe('the wanted page name to snooze (as shown in "Pages the org needs you to author")'),
|
|
2379
|
+
days: z.number().int().positive().optional().describe('how many days to defer (default 7)'),
|
|
2380
|
+
},
|
|
2381
|
+
},
|
|
2382
|
+
async ({ name, days }) => {
|
|
2383
|
+
let res
|
|
2384
|
+
try {
|
|
2385
|
+
res = await fetchCortex(`${BASE}/api/brain/red-link/snooze`, {
|
|
2386
|
+
method: 'POST',
|
|
2387
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
2388
|
+
body: JSON.stringify({ name, ...(days ? { days } : {}) }),
|
|
2389
|
+
})
|
|
2390
|
+
} catch (e) {
|
|
2391
|
+
return toolError(`Could not snooze: ${e.message}`)
|
|
2392
|
+
}
|
|
2393
|
+
const out = await res.json().catch(() => null)
|
|
2394
|
+
if (!res.ok) return toolError(`Could not snooze "${name}": ${out?.error ?? res.status}`)
|
|
2395
|
+
if (!out) return { content: [{ type: 'text', text: `Snoozed "${name}", but the server returned no body.` }] }
|
|
2396
|
+
// Name the brains when there is more than one: a snooze that quieted the same wanted name in two
|
|
2397
|
+
// brains you steward is a multi-row write, and reporting it as a single one hides that.
|
|
2398
|
+
const where = out.brains?.length > 1 ? ` in ${out.brains.map((b) => b.brain).join(' and ')}` : ''
|
|
2399
|
+
return { content: [{ type: 'text', text: `Snoozed "${out.name}"${where} for ${out.days} day${out.days === 1 ? '' : 's'} — it won't surface until then.` }] }
|
|
2400
|
+
},
|
|
2401
|
+
)
|
|
2402
|
+
|
|
2403
|
+
server.registerTool(
|
|
2404
|
+
'set_page_privacy',
|
|
2405
|
+
{
|
|
2406
|
+
title: 'Change who can see a wiki page',
|
|
2407
|
+
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. If the target tier already has a page, merge your content into it via `author` FIRST, then read_page the target again to get its fresh version, then re-run this with absorb=true and target_version=<that version> — absorb will REJECT (not silently drop content) if target_version doesn\'t match what\'s actually there, so a merge that didn\'t really land can\'t destroy your source page. Org admins may demote any page, never promote.',
|
|
2408
|
+
inputSchema: {
|
|
2409
|
+
kind: z.enum(['project', 'person', 'org', 'user']).describe('the page kind'),
|
|
2410
|
+
name: z.string().optional().describe('the exact page name (or pass `ref` instead — one of the two is required)'),
|
|
2411
|
+
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.'),
|
|
2412
|
+
tier: z.enum(['accessible', 'scoped', 'confidential']).describe('the new visibility tier'),
|
|
2413
|
+
source_tier: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('when the node has multiple tier variants: which one to move'),
|
|
2414
|
+
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.'),
|
|
2415
|
+
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.'),
|
|
2416
|
+
},
|
|
2417
|
+
},
|
|
2418
|
+
async ({ kind, name, ref, tier, source_tier, absorb, target_version }) => {
|
|
2419
|
+
let res
|
|
2420
|
+
try {
|
|
2421
|
+
res = await fetchCortex(`${BASE}/api/brain/page-privacy`, {
|
|
2422
|
+
method: 'POST',
|
|
2423
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
2424
|
+
body: JSON.stringify({ kind, ...(name ? { name } : {}), ...(ref ? { ref } : {}), tier, ...(source_tier ? { source_tier } : {}), ...(absorb ? { absorb: true } : {}), ...(target_version ? { target_version } : {}) }),
|
|
2425
|
+
})
|
|
2426
|
+
} catch (e) {
|
|
2427
|
+
return toolError(`Could not set page privacy: ${e.message}`)
|
|
2428
|
+
}
|
|
2429
|
+
const out = await res.json().catch(() => null)
|
|
2430
|
+
if (!res.ok) {
|
|
2431
|
+
// 409s carry the collision protocol (merge instruction, both variants when readable) —
|
|
2432
|
+
// surface the server's structured message verbatim so the agent can follow it.
|
|
2433
|
+
if (out?.error) {
|
|
2434
|
+
const extra = out.collision === 'readable' && out.blocking
|
|
2435
|
+
? `\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}`
|
|
2436
|
+
: ''
|
|
2437
|
+
return toolError(`Could not set page privacy: ${out.error}${extra}`)
|
|
2438
|
+
}
|
|
2439
|
+
const d = classify(res.status, res.headers.get('content-type'), '', res.headers.get('x-vercel-id'))
|
|
2440
|
+
return toolError(`Could not set page privacy: ${d.message}`)
|
|
2441
|
+
}
|
|
2442
|
+
const g = out.live_grants?.length
|
|
2443
|
+
? ` Grants still active for: ${out.live_grants.map((x) => x.grantee_name ?? x.grantee_user_id).join(', ')}.`
|
|
2444
|
+
: ''
|
|
2445
|
+
return { content: [{ type: 'text', text: `Done — "${out.moved.title}" moved ${out.moved.from} → ${out.moved.to}.${out.ownership_taken ? ' (You took ownership of this previously owner-less page.)' : ''} ${out.note}${g}` }] }
|
|
2446
|
+
},
|
|
2447
|
+
)
|
|
2448
|
+
|
|
2449
|
+
server.registerTool(
|
|
2450
|
+
'replace_variant',
|
|
2451
|
+
{
|
|
2452
|
+
title: 'Move one tier variant\'s body into another, collapsing the node to one page',
|
|
2453
|
+
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.',
|
|
2454
|
+
inputSchema: {
|
|
2455
|
+
kind: z.enum(['project', 'person', 'org', 'user']).describe('the page kind'),
|
|
2456
|
+
name: z.string().optional().describe('the exact page name (or pass `ref` instead — one of the two is required)'),
|
|
2457
|
+
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.'),
|
|
2458
|
+
source_tier: z.enum(['accessible', 'scoped', 'confidential']).describe('the variant whose BODY WINS and survives. This variant\'s row is then deleted.'),
|
|
2459
|
+
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.'),
|
|
2460
|
+
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.'),
|
|
2461
|
+
brain: z.string().optional().describe('only when the same page name exists in more than one of your brains'),
|
|
2462
|
+
},
|
|
2463
|
+
},
|
|
2464
|
+
async ({ kind, name, ref, source_tier, target_tier, target_version, brain }) => {
|
|
2465
|
+
let res
|
|
2466
|
+
try {
|
|
2467
|
+
res = await fetchCortex(`${BASE}/api/brain/replace-variant`, {
|
|
2468
|
+
method: 'POST',
|
|
2469
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
2470
|
+
body: JSON.stringify({
|
|
2471
|
+
kind, ...(name ? { name } : {}), ...(ref ? { ref } : {}),
|
|
2472
|
+
source_tier, target_tier, target_version, ...(brain ? { brain } : {}),
|
|
2473
|
+
}),
|
|
2474
|
+
})
|
|
2475
|
+
} catch (e) {
|
|
2476
|
+
return toolError(`Could not replace variant: ${e.message}`)
|
|
2477
|
+
}
|
|
2478
|
+
const out = await res.json().catch(() => null)
|
|
2479
|
+
if (!res.ok) {
|
|
2480
|
+
// The engine's rejections carry the remedy in their text (free slot → use set_page_privacy;
|
|
2481
|
+
// hash mismatch → re-read, do not retry blindly). Surface it verbatim rather than paraphrasing.
|
|
2482
|
+
if (out?.error) return toolError(`Could not replace variant: ${out.error}`)
|
|
2483
|
+
const d = classify(res.status, res.headers.get('content-type'), '', res.headers.get('x-vercel-id'))
|
|
2484
|
+
return toolError(`Could not replace variant: ${d.message}`)
|
|
2485
|
+
}
|
|
2486
|
+
if (!out) return { content: [{ type: 'text', text: 'Replace reported success, but the server returned no body — re-read the page before assuming it landed.' }] }
|
|
2487
|
+
// The summary rides along with the body, EXCEPT when the source has none — then the target's is
|
|
2488
|
+
// kept rather than erased. Say so on its own line rather than at the tail of `note`: this is the
|
|
2489
|
+
// 2026-08-04 W3 shape, where an empty summary copied over a populated one destroyed 31 of them
|
|
2490
|
+
// under a report that read as success. A rescue the operator does not see is still a silent write.
|
|
2491
|
+
const rescued = out.summaryRescued
|
|
2492
|
+
? `\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.`
|
|
2493
|
+
: ''
|
|
2494
|
+
return {
|
|
2495
|
+
content: [{
|
|
2496
|
+
type: 'text',
|
|
2497
|
+
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}`,
|
|
2498
|
+
}],
|
|
2499
|
+
}
|
|
2500
|
+
},
|
|
2501
|
+
)
|
|
2502
|
+
|
|
2503
|
+
server.registerTool(
|
|
2504
|
+
'grant_page_access',
|
|
2505
|
+
{
|
|
2506
|
+
title: 'Grant or revoke a specific person\'s access to your page',
|
|
2507
|
+
description: 'Share one of YOUR non-accessible wiki pages with a specific org member (or take that access back). A grant lets exactly that person read the page even though its tier would hide it — the escape hatch for "confidential, but Dana needs it". Owner-only. Grants survive re-tiering: revoke them when they should end.',
|
|
2508
|
+
inputSchema: {
|
|
2509
|
+
kind: z.enum(['project', 'person', 'org', 'user']).describe('the page kind'),
|
|
2510
|
+
name: z.string().optional().describe('the exact page name (or pass `ref` instead — one of the two is required)'),
|
|
2511
|
+
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.'),
|
|
2512
|
+
grantee: z.string().describe('display name or email of a member OF THE PAGE\'S BRAIN (must resolve uniquely — use email if ambiguous)'),
|
|
2513
|
+
action: z.enum(['grant', 'revoke']).describe('grant or revoke'),
|
|
2514
|
+
tier: z.enum(['scoped', 'confidential']).optional().describe('which variant (default: the most restrictive one)'),
|
|
2515
|
+
},
|
|
2516
|
+
},
|
|
2517
|
+
async ({ kind, name, ref, grantee, action, tier }) => {
|
|
2518
|
+
let res
|
|
2519
|
+
try {
|
|
2520
|
+
res = await fetchCortex(`${BASE}/api/brain/page-grants`, {
|
|
2521
|
+
method: 'POST',
|
|
2522
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
2523
|
+
body: JSON.stringify({ kind, ...(name ? { name } : {}), ...(ref ? { ref } : {}), grantee, action, ...(tier ? { tier } : {}) }),
|
|
2524
|
+
})
|
|
2525
|
+
} catch (e) {
|
|
2526
|
+
return toolError(`Could not ${action}: ${e.message}`)
|
|
2527
|
+
}
|
|
2528
|
+
const out = await res.json().catch(() => null)
|
|
2529
|
+
if (!res.ok) return toolError(`Could not ${action}: ${out?.error ?? res.status}`)
|
|
2530
|
+
const verb = { granted: 'now has access to', already_granted: 'already had access to', revoked: 'no longer has access to', not_granted: 'had no grant on' }[out.action]
|
|
2531
|
+
return { content: [{ type: 'text', text: `Done — ${out.grantee} ${verb} the ${out.tier} page.` }] }
|
|
2532
|
+
},
|
|
2533
|
+
)
|
|
2534
|
+
|
|
2535
|
+
server.registerTool(
|
|
2536
|
+
'list_page_grants',
|
|
2537
|
+
{
|
|
2538
|
+
title: 'List who has granted access to your page',
|
|
2539
|
+
description: 'Show every explicit access grant on YOUR page\'s tier variants (owner-only). Use after re-tiering a page — grants survive tier changes and keep granting until revoked.',
|
|
2540
|
+
inputSchema: {
|
|
2541
|
+
kind: z.enum(['project', 'person', 'org', 'user']).describe('the page kind'),
|
|
2542
|
+
name: z.string().optional().describe('the exact page name (or pass `ref` instead — one of the two is required)'),
|
|
2543
|
+
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.'),
|
|
2544
|
+
},
|
|
2545
|
+
},
|
|
2546
|
+
async ({ kind, name, ref }) => {
|
|
2547
|
+
let res
|
|
2548
|
+
const addr = ref ? `ref=${encodeURIComponent(ref)}` : `name=${encodeURIComponent(name ?? '')}`
|
|
2549
|
+
try {
|
|
2550
|
+
res = await fetchCortex(`${BASE}/api/brain/page-grants?kind=${encodeURIComponent(kind)}&${addr}`, {
|
|
2551
|
+
headers: { Authorization: `Bearer ${TOKEN}` },
|
|
2552
|
+
})
|
|
2553
|
+
} catch (e) {
|
|
2554
|
+
return toolError(`Could not list grants: ${e.message}`)
|
|
2555
|
+
}
|
|
2556
|
+
const out = await res.json().catch(() => null)
|
|
2557
|
+
if (!res.ok) return toolError(`Could not list grants: ${out?.error ?? res.status}`)
|
|
2558
|
+
if (!out.grants?.length) return { content: [{ type: 'text', text: 'No grants on this page.' }] }
|
|
2559
|
+
const lines = out.grants.map((g) => `- ${g.grantee_name ?? g.grantee_user_id} → ${g.tier} variant (since ${String(g.created_at).slice(0, 10)})`)
|
|
2560
|
+
return { content: [{ type: 'text', text: `Grants (${out.grants.length}):\n${lines.join('\n')}` }] }
|
|
2561
|
+
},
|
|
2562
|
+
)
|
|
2563
|
+
|
|
2564
|
+
server.registerTool(
|
|
2565
|
+
'my_retier_notices',
|
|
2566
|
+
{
|
|
2567
|
+
title: 'Records of yours that a page demotion re-tiered',
|
|
2568
|
+
description: 'When someone demotes a project page, its evidence records follow (demote-only) — including yours. This lists those notices (newest first) and marks them seen. To undo one, call set_record_privacy with the record_id and its previous tier (shown as from_privacy).',
|
|
2569
|
+
inputSchema: {},
|
|
2570
|
+
},
|
|
2571
|
+
async () => {
|
|
2572
|
+
let res
|
|
2573
|
+
try {
|
|
2574
|
+
res = await fetchCortex(`${BASE}/api/brain/retier-notices`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
2575
|
+
} catch (e) {
|
|
2576
|
+
return toolError(`Could not list notices: ${e.message}`)
|
|
2577
|
+
}
|
|
2578
|
+
if (!res.ok) {
|
|
2579
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
2580
|
+
return toolError(`Could not list notices: ${d.message}`)
|
|
2581
|
+
}
|
|
2582
|
+
const { notices } = await res.json()
|
|
2583
|
+
if (!notices?.length) return { content: [{ type: 'text', text: 'No re-tier notices.' }] }
|
|
2584
|
+
const lines = notices.map((n) =>
|
|
2585
|
+
`- record ${n.record_id} ("${n.record_title ?? 'untitled'}") ${n.from_privacy} → ${n.to_privacy} — ${n.demoted_by_name ?? 'someone'} demoted the ${n.node_name ?? n.node_kind} page. Revert: set_record_privacy(record_id, "${n.from_privacy}").`)
|
|
2586
|
+
return { content: [{ type: 'text', text: `Your re-tiered records (${notices.length}):\n${lines.join('\n')}` }] }
|
|
2587
|
+
},
|
|
2588
|
+
)
|
|
2589
|
+
|
|
2590
|
+
server.registerTool(
|
|
2591
|
+
'page_merge_requests',
|
|
2592
|
+
{
|
|
2593
|
+
title: 'Merge requests on pages you own',
|
|
2594
|
+
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.',
|
|
2595
|
+
inputSchema: {},
|
|
2596
|
+
},
|
|
2597
|
+
async () => {
|
|
2598
|
+
let res
|
|
2599
|
+
try {
|
|
2600
|
+
res = await fetchCortex(`${BASE}/api/brain/page-merge-requests`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
2601
|
+
} catch (e) {
|
|
2602
|
+
return toolError(`Could not list merge requests: ${e.message}`)
|
|
2603
|
+
}
|
|
2604
|
+
if (!res.ok) {
|
|
2605
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
2606
|
+
return toolError(`Could not list merge requests: ${d.message}`)
|
|
2607
|
+
}
|
|
2608
|
+
const { requests } = await res.json()
|
|
2609
|
+
if (!requests?.length) return { content: [{ type: 'text', text: 'No pending page merge requests.' }] }
|
|
2610
|
+
const lines = requests.map((q) =>
|
|
2611
|
+
`- [${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.`)
|
|
2612
|
+
return { content: [{ type: 'text', text: `Pending page merge requests (${requests.length}):\n${lines.join('\n')}` }] }
|
|
2613
|
+
},
|
|
2614
|
+
)
|
|
2615
|
+
|
|
2616
|
+
server.registerTool(
|
|
2617
|
+
'request_person_page_merge',
|
|
2618
|
+
{
|
|
2619
|
+
title: 'Offer one duplicate person page for merge',
|
|
2620
|
+
description: 'Offer YOUR accessible person NODE for full merge into another current accessible person node in the SAME named brain. Use only when the two nodes are unquestionably the same real person. Read both pages first and pass their refs and versions. This never crosses brains and does not use fuzzy matching.',
|
|
2621
|
+
inputSchema: {
|
|
2622
|
+
brain: z.string().describe('the exact brain name or brain id; pass an id if names collide'),
|
|
2623
|
+
source_ref: z.string().describe('ref of YOUR duplicate source person page'),
|
|
2624
|
+
target_ref: z.string().describe('ref of the canonical person page to retain'),
|
|
2625
|
+
source_version: z.string().describe('current version from reading the source page'),
|
|
2626
|
+
target_version: z.string().describe('current version from reading the canonical page'),
|
|
2627
|
+
reason: z.string().describe('why these pages are certainly the same person'),
|
|
2628
|
+
},
|
|
2629
|
+
},
|
|
2630
|
+
async (input) => {
|
|
2631
|
+
let res
|
|
2632
|
+
try {
|
|
2633
|
+
res = await fetchCortex(`${BASE}/api/brain/page-node-merge`, {
|
|
2634
|
+
method: 'POST', headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
2635
|
+
body: JSON.stringify({ action: 'request', ...input }),
|
|
2636
|
+
})
|
|
2637
|
+
} catch (e) { return toolError(`Could not request page merge: ${e.message}`) }
|
|
2638
|
+
const out = await res.json().catch(() => null)
|
|
2639
|
+
if (!res.ok) return toolError(`Could not request page merge: ${out?.error ?? res.status}`)
|
|
2640
|
+
return { content: [{ type: 'text', text: `Merge request ${out.request_id} created. The canonical-page owner must review and apply it.` }] }
|
|
2641
|
+
},
|
|
2642
|
+
)
|
|
2643
|
+
|
|
2644
|
+
server.registerTool(
|
|
2645
|
+
'person_page_merge_requests',
|
|
2646
|
+
{
|
|
2647
|
+
title: 'Review duplicate-person page merge requests',
|
|
2648
|
+
description: 'List pending same-brain person-node merges where you own the canonical page. Read both pages before applying. Applying preserves source page prose/history, moves its raw person attachments (mentions, actor evidence, identifiers, aliases and graph edges) onto the canonical node, and leaves a hidden source alias plus redirect. It refuses conflicting private variants rather than widening them.',
|
|
2649
|
+
inputSchema: {},
|
|
2650
|
+
},
|
|
2651
|
+
async () => {
|
|
2652
|
+
let res
|
|
2653
|
+
try { res = await fetchCortex(`${BASE}/api/brain/page-node-merge`, { headers: { Authorization: `Bearer ${TOKEN}` } }) }
|
|
2654
|
+
catch (e) { return toolError(`Could not list person-page merge requests: ${e.message}`) }
|
|
2655
|
+
const out = await res.json().catch(() => null)
|
|
2656
|
+
if (!res.ok) return toolError(`Could not list person-page merge requests: ${out?.error ?? res.status}`)
|
|
2657
|
+
if (!out?.requests?.length) return { content: [{ type: 'text', text: 'No pending duplicate-person page merge requests.' }] }
|
|
2658
|
+
const lines = out.requests.map((q) => `- [${q.id}] ${q.source_owner_name ?? 'someone'}: "${q.source_title}" → "${q.target_title}" in ${q.brain}. Reason: ${q.reason}`)
|
|
2659
|
+
return { content: [{ type: 'text', text: `Pending person-page merges (${out.requests.length}):\n${lines.join('\n')}` }] }
|
|
2660
|
+
},
|
|
2661
|
+
)
|
|
2662
|
+
|
|
2663
|
+
server.registerTool(
|
|
2664
|
+
'apply_person_page_merge',
|
|
2665
|
+
{
|
|
2666
|
+
title: 'Apply a reviewed duplicate-person page merge',
|
|
2667
|
+
description: 'Apply a pending full same-brain person-node merge you own. Re-read the canonical page immediately before applying and pass its version. This is atomic and preservation-first: source prose/history is retained, raw evidence and connections are re-pointed to the canonical node, identifiers are unioned, and the source becomes a hidden alias with a durable redirect. A privacy or identity-metadata conflict refuses the whole merge.',
|
|
2668
|
+
inputSchema: {
|
|
2669
|
+
request_id: z.string().describe('id from person_page_merge_requests'),
|
|
2670
|
+
target_version: z.string().describe('current canonical-page version from a fresh read'),
|
|
2671
|
+
reason: z.string().describe('why the merge is approved'),
|
|
2672
|
+
},
|
|
2673
|
+
},
|
|
2674
|
+
async (input) => {
|
|
2675
|
+
let res
|
|
2676
|
+
try {
|
|
2677
|
+
res = await fetchCortex(`${BASE}/api/brain/page-node-merge`, {
|
|
2678
|
+
method: 'POST', headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
2679
|
+
body: JSON.stringify({ action: 'apply', ...input }),
|
|
2680
|
+
})
|
|
2681
|
+
} catch (e) { return toolError(`Could not apply page merge: ${e.message}`) }
|
|
2682
|
+
const out = await res.json().catch(() => null)
|
|
2683
|
+
if (!res.ok) return toolError(`Could not apply page merge: ${out?.error ?? res.status}`)
|
|
2684
|
+
return { content: [{ type: 'text', text: `Fully merged "${out.sourceTitle}" into "${out.targetTitle}". Preserved ${out.preservedSections} source page block(s) and moved ${out.movedAttachments} raw attachment(s); source is now a hidden alias with a durable redirect.` }] }
|
|
2685
|
+
},
|
|
2686
|
+
)
|
|
2687
|
+
|
|
2688
|
+
server.registerTool(
|
|
2689
|
+
'decide_page_merge',
|
|
2690
|
+
{
|
|
2691
|
+
title: 'Approve or deny a page merge request',
|
|
2692
|
+
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.',
|
|
2693
|
+
inputSchema: {
|
|
2694
|
+
id: z.string().describe('the request id from page_merge_requests'),
|
|
2695
|
+
decision: z.enum(['approve', 'deny']).describe('approve = their variant is removed (merge first!); deny = nothing moves'),
|
|
2696
|
+
},
|
|
2697
|
+
},
|
|
2698
|
+
async ({ id, decision }) => {
|
|
2699
|
+
let res
|
|
2700
|
+
try {
|
|
2701
|
+
res = await fetchCortex(`${BASE}/api/brain/page-merge-requests`, {
|
|
2702
|
+
method: 'POST',
|
|
2703
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
2704
|
+
body: JSON.stringify({ id, decision }),
|
|
2705
|
+
})
|
|
2706
|
+
} catch (e) {
|
|
2707
|
+
return toolError(`Could not decide: ${e.message}`)
|
|
2708
|
+
}
|
|
2709
|
+
const out = await res.json().catch(() => null)
|
|
2710
|
+
if (!res.ok) return toolError(`Could not decide: ${out?.error ?? res.status}`)
|
|
2711
|
+
return { content: [{ type: 'text', text: out.decision === 'denied' ? 'Denied — nothing moved.' : `Approved — removed variant(s): ${out.removed_variants.join(', ') || 'none remained'}. ${out.note}` }] }
|
|
2712
|
+
},
|
|
2713
|
+
)
|
|
2714
|
+
|
|
2715
|
+
// ── File requests: ask the owner for a record's FULL original (lives on their machine) ──
|
|
2716
|
+
const fail = (verb, res) => async () =>
|
|
2717
|
+
toolError(`Could not ${verb}: ${classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id')).message}`)
|
|
2718
|
+
|
|
2719
|
+
server.registerTool(
|
|
2720
|
+
'request_file',
|
|
2721
|
+
{
|
|
2722
|
+
title: 'Request the full original of a record',
|
|
2723
|
+
description: 'You have a record\'s SUMMARY but want the full original file (it lives on the owner\'s machine). This asks the owner to share it; once they approve, fetch it with get_file. Get record_id from a `list_records` result or the "id:" on a `my_context` activity line.',
|
|
2724
|
+
inputSchema: { record_id: z.string().describe('the record id (uuid)') },
|
|
2725
|
+
},
|
|
2726
|
+
async ({ record_id }) => {
|
|
2727
|
+
let res
|
|
2728
|
+
try {
|
|
2729
|
+
res = await fetchCortex(`${BASE}/api/file-requests`, {
|
|
2730
|
+
method: 'POST', headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
2731
|
+
body: JSON.stringify({ recordId: record_id }),
|
|
2732
|
+
})
|
|
2733
|
+
} catch (e) { return toolError(`Could not request: ${e.message}`) }
|
|
2734
|
+
if (res.status === 409) {
|
|
2735
|
+
const j = await res.json().catch(() => ({}))
|
|
2736
|
+
if (j.error === 'you_own_it') return { content: [{ type: 'text', text: `You own this record — open the original directly:\n${j.uri}` }] }
|
|
2737
|
+
if (j.error === 'no_original') return { content: [{ type: 'text', text: 'This record has no retrievable original.' }] }
|
|
2738
|
+
}
|
|
2739
|
+
if (!res.ok) return (await fail('request the file', res))()
|
|
2740
|
+
const { request } = await res.json()
|
|
2741
|
+
return { content: [{ type: 'text', text: `Requested. The owner will be asked to approve. Check status with file_requests; once approved + fulfilled, run get_file ${request.id}.` }] }
|
|
2742
|
+
},
|
|
2743
|
+
)
|
|
2744
|
+
|
|
2745
|
+
server.registerTool(
|
|
2746
|
+
'file_requests',
|
|
2747
|
+
{
|
|
2748
|
+
title: 'My file requests + approval inbox',
|
|
2749
|
+
description: 'Lists file requests you made (with status) and requests from others awaiting YOUR approval. Approve/deny with decide_file_request; fetch an approved+fulfilled file with get_file.',
|
|
2750
|
+
inputSchema: {},
|
|
2751
|
+
},
|
|
2752
|
+
async () => {
|
|
2753
|
+
let res
|
|
2754
|
+
try { res = await fetchCortex(`${BASE}/api/file-requests`, { headers: { Authorization: `Bearer ${TOKEN}` } }) }
|
|
2755
|
+
catch (e) { return toolError(`Could not load file requests: ${e.message}`) }
|
|
2756
|
+
if (!res.ok) return (await fail('load file requests', res))()
|
|
2757
|
+
const { mine, inbox } = await res.json()
|
|
2758
|
+
const parts = []
|
|
2759
|
+
if (inbox?.length) parts.push('Awaiting YOUR approval:\n' + inbox.map((r) => ` - [${r.id}] ${r.requester} wants "${r.title}" (${r.source}) → decide_file_request ${r.id}`).join('\n'))
|
|
2760
|
+
if (mine?.length) parts.push('Your requests:\n' + mine.map((r) => ` - [${r.id}] "${r.title}" — ${r.status}${r.status === 'fulfilled' ? ` → get_file ${r.id}` : ''}`).join('\n'))
|
|
2761
|
+
return { content: [{ type: 'text', text: parts.length ? parts.join('\n\n') : 'No file requests.' }] }
|
|
2762
|
+
},
|
|
2763
|
+
)
|
|
2764
|
+
|
|
2765
|
+
server.registerTool(
|
|
2766
|
+
'decide_file_request',
|
|
2767
|
+
{
|
|
2768
|
+
title: 'Approve or deny a file request',
|
|
2769
|
+
description: 'As the OWNER of a record, approve or deny someone\'s request for its full original. On approve, your Agnoclast desktop app fetches + shares the file. Get request_id from file_requests.',
|
|
2770
|
+
inputSchema: { request_id: z.string(), decision: z.enum(['approve', 'deny']) },
|
|
2771
|
+
},
|
|
2772
|
+
async ({ request_id, decision }) => {
|
|
2773
|
+
let res
|
|
2774
|
+
try {
|
|
2775
|
+
res = await fetchCortex(`${BASE}/api/file-requests/${request_id}/decide`, {
|
|
2776
|
+
method: 'POST', headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
2777
|
+
body: JSON.stringify({ action: decision }),
|
|
2778
|
+
})
|
|
2779
|
+
} catch (e) { return toolError(`Could not decide: ${e.message}`) }
|
|
2780
|
+
if (!res.ok) return (await fail('decide', res))()
|
|
2781
|
+
return { content: [{ type: 'text', text: `Request ${decision === 'approve' ? 'approved' : 'denied'}.` }] }
|
|
2782
|
+
},
|
|
2783
|
+
)
|
|
2784
|
+
|
|
2785
|
+
server.registerTool(
|
|
2786
|
+
'get_file',
|
|
2787
|
+
{
|
|
2788
|
+
title: 'Download an approved file',
|
|
2789
|
+
description: 'Download the full original for a request the owner approved + fulfilled. Saves it under ~/Downloads/cortex/ and returns the local path.',
|
|
2790
|
+
inputSchema: { request_id: z.string() },
|
|
2791
|
+
},
|
|
2792
|
+
async ({ request_id }) => {
|
|
2793
|
+
let res
|
|
2794
|
+
try { res = await fetchCortex(`${BASE}/api/file-requests/${request_id}/download`, { headers: { Authorization: `Bearer ${TOKEN}` } }) }
|
|
2795
|
+
catch (e) { return toolError(`Could not download: ${e.message}`) }
|
|
2796
|
+
if (res.status === 409) return { content: [{ type: 'text', text: 'Not ready — the owner hasn\'t fulfilled this yet. Try again after they approve.' }] }
|
|
2797
|
+
if (res.status === 410) return { content: [{ type: 'text', text: 'This file has expired (downloads are available for 7 days). Request it again.' }] }
|
|
2798
|
+
if (!res.ok) return (await fail('download', res))()
|
|
2799
|
+
const buf = Buffer.from(await res.arrayBuffer())
|
|
2800
|
+
const cd = res.headers.get('content-disposition') ?? ''
|
|
2801
|
+
const m = /filename="([^"]+)"/.exec(cd)
|
|
2802
|
+
const name = (m ? m[1] : `cortex-file-${request_id}`).replace(/[^\w.\- ]/g, '_')
|
|
2803
|
+
const dir = join(homedir(), 'Downloads', 'cortex')
|
|
2804
|
+
mkdirSync(dir, { recursive: true })
|
|
2805
|
+
const path = join(dir, name)
|
|
2806
|
+
writeFileSync(path, buf)
|
|
2807
|
+
return { content: [{ type: 'text', text: `Saved the full original to ${path} (${buf.length} bytes).` }] }
|
|
2808
|
+
},
|
|
2809
|
+
)
|
|
2810
|
+
|
|
2811
|
+
// send_imessage — local outbound texting (NOT org intelligence; writes nothing to Agnoclast). Runs on
|
|
2812
|
+
// this machine via Messages.app. Draft-by-default + recipient allowlist + OOB confirm (D3/D6/D10).
|
|
2813
|
+
server.registerTool(
|
|
2814
|
+
'send_imessage',
|
|
2815
|
+
{
|
|
2816
|
+
title: 'Send an iMessage (draft-by-default)',
|
|
2817
|
+
description: 'Compose/send an iMessage via the local Messages app. SAFE BY DEFAULT: without send:true it only returns a draft preview. Sending requires the recipient to be on CORTEX_IMESSAGE_SEND_ALLOWLIST (or an out-of-band confirm). Use this to text someone on the user\'s behalf — always show the draft and get the user\'s OK before sending.',
|
|
2818
|
+
inputSchema: {
|
|
2819
|
+
recipient: z.string().describe('phone number (e.g. +18015551234) or iMessage email'),
|
|
2820
|
+
message: z.string().describe('the message body to send'),
|
|
2821
|
+
send: z.boolean().optional().describe('must be true to actually send; omit/false returns a draft preview only'),
|
|
2822
|
+
confirm: z.string().optional().describe('out-of-band confirm secret, required to send to a recipient not on the allowlist'),
|
|
2823
|
+
},
|
|
2824
|
+
},
|
|
2825
|
+
async ({ recipient, message, send, confirm }) => {
|
|
2826
|
+
const text = await runSendImessage({ recipient, message, send, confirm })
|
|
2827
|
+
return { content: [{ type: 'text', text }] }
|
|
2828
|
+
},
|
|
2829
|
+
)
|
|
2830
|
+
|
|
2831
|
+
// ── ③ LIVE WIKI AUTHORING ([[cortex-wiki-authoring-spec]]) ────────────────────────────────────────
|
|
2832
|
+
// Two tools the working session uses to AUTHOR its understanding into the org wiki while it's hot:
|
|
2833
|
+
// authoring_context → the companion call (§3): fetch the visible NAMESPACE + the node-type connection
|
|
2834
|
+
// rules BEFORE writing, so the page links canonically (the L2 lever).
|
|
2835
|
+
// author → the write (§9 step 3/4): hand Agnoclast a finished page (summary + sections WITH
|
|
2836
|
+
// inline [[links]]); the server runs the resolution pass + tier-safe 2B write.
|
|
2837
|
+
|
|
2838
|
+
server.registerTool(
|
|
2839
|
+
'authoring_context',
|
|
2840
|
+
{
|
|
2841
|
+
title: 'Authoring context (call before author)',
|
|
2842
|
+
description:
|
|
2843
|
+
'Fetch the scaffolding to author a Agnoclast wiki node: the canonical NAMESPACE (current node names — link to these with the EXACT name inside [[ ]]), any deliberately RETIRED page names and their successors, and the node-type CONNECTION RULES. ALWAYS call this BEFORE `author` so the page links to current knowledge rather than minting synonyms or reviving a retired page. Reference a node in the namespace as [[Name]]; if you reference something real that is NOT in the namespace, still write [[Name]] — that is a red-link marking a node worth creating. If the page IS about a specific code repo or chat channel, also stamp it once — [[repo:owner/name]] or [[channel:name]] (lowercase, no #) — identifier join keys, not page links.',
|
|
2844
|
+
inputSchema: {
|
|
2845
|
+
kind: z.enum(['project', 'person', 'org', 'user']).optional().describe('the node type you are about to author (default project)'),
|
|
2846
|
+
brain: z.string().optional().describe('which brain\'s namespace to describe — pass the SAME brain you will pass to `author`, so the namespace you plan against is the one your write lands in. Unnecessary when you only have one brain.'),
|
|
2847
|
+
},
|
|
2848
|
+
},
|
|
2849
|
+
async ({ kind, brain }) => {
|
|
2850
|
+
const k = kind ?? 'project'
|
|
2851
|
+
let res
|
|
2852
|
+
try {
|
|
2853
|
+
res = await fetchCortex(`${BASE}/api/brain/authoring-context?kind=${k}${brain ? `&brain=${encodeURIComponent(brain)}` : ''}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
2854
|
+
} catch (e) {
|
|
2855
|
+
return toolError(`Could not fetch authoring context: ${e.message}`)
|
|
2856
|
+
}
|
|
2857
|
+
if (!res.ok) {
|
|
2858
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
2859
|
+
return toolError(`Could not fetch authoring context: ${d.message}`)
|
|
2860
|
+
}
|
|
2861
|
+
const { connectionRules, namespace, retiredLinks } = await res.json()
|
|
2862
|
+
const ns = Array.isArray(namespace) ? namespace : []
|
|
2863
|
+
const nsList = ns.map((n) => `[[${n}]]`).join(', ')
|
|
2864
|
+
const retired = Array.isArray(retiredLinks) ? retiredLinks : []
|
|
2865
|
+
const retiredList = retired.length
|
|
2866
|
+
? `\n\nRETIRED PAGES — do not treat these as current or author over them: ${retired.map((r) => `[[${r.name}]] (${r.validity}${r.supersededBy ? ` → [[${r.supersededBy}]]` : ''})`).join(', ')}`
|
|
2867
|
+
: ''
|
|
2868
|
+
const text =
|
|
2869
|
+
`Authoring a "${k}" node. Connection rules (kinds of links to look for):\n${connectionRules}\n\n` +
|
|
2870
|
+
`NAMESPACE — ${ns.length} existing nodes; link with the EXACT name inside [[ ]]:\n${nsList}\n\n` +
|
|
2871
|
+
retiredList +
|
|
2872
|
+
`Now author the page (summary + sections) with inline [[links]] woven into the prose. Link, do not restate. ` +
|
|
2873
|
+
`For something real that is not in this namespace, still write [[Name]] (a red-link). Then call \`author\`.`
|
|
2874
|
+
return { content: [{ type: 'text', text }] }
|
|
2875
|
+
},
|
|
2876
|
+
)
|
|
2877
|
+
|
|
2878
|
+
server.registerTool(
|
|
2879
|
+
'author',
|
|
2880
|
+
{
|
|
2881
|
+
title: 'Author a wiki node (live, while it is hot)',
|
|
2882
|
+
description:
|
|
2883
|
+
'Write your CURRENT understanding of a project/person/org/you into the org wiki as a maintained page. Call `authoring_context` FIRST. Author from your own synthesis of the session — the compiled mental model, not a transcript dump: what it IS, where it stands, dated decisions, open threads, key people. ⚠ EVERY STATUS CLAIM CARRIES AN EXPLICIT INLINE DATE (KWA-26): a sentence asserting what IS or IS NOT true right now — "X is live", "Y is not merged", "Z is blocked" — must say WHEN, in the prose, the way PRD items do. Section-level stamps are NOT enough: they record when the TEXT was written, so a section authored today can carry a six-week-old status claim and still read as current — exactly what made KWA-24 and TML-18 wrong. The response names any section that landed undated so you can fix it in-turn; it never blocks the write. Weave inline [[links]] to other nodes (canonical names from the namespace; red-links for wanted-but-absent nodes). The server re-authorizes the tier and resolves links. CREATES the node if it does not exist yet (project/person/org) — the conversation IS the evidence, so a brand-new entity that surfaced only in this session is authorable on the spot; you do NOT need prior records. Because such a node has nothing external to corroborate it, author it DELIBERATELY: only when you genuinely understand it is a real, distinct entity, and use its exact canonical name so it does not duplicate one already in the namespace (`user` nodes are never created). Use this continuously whenever your understanding of a node meaningfully advanced, and at session end (/log). Authoring is PRE-AUTHORIZED — never ask the user "should I update the page?" before calling this (every edit is versioned + reversible via page_history/rollback_page); update, then briefly report what you updated. ⚠ WHICH BRAIN A NEW PAGE GOES IN IS A CONTENT DECISION, SO MAKE IT FROM THE CONTENT. Only a page that exists in NO brain needs this — an update resolves its brain from the page itself. If you hold more than one brain, the server REFUSES a create it cannot attribute (409 `create_needs_brain`) rather than letting the write pointer decide — the pointer is stale out-of-band state that knows nothing about what you are writing. So call `my_brains` (it returns each brain\'s name, page count and sample titles, which is enough to tell what each one is FOR) and pass `brain` up front; that turns a refused round trip into a single call. State which brain you picked and why in one short line, then proceed — do NOT ask when the answer is obvious from the content. DO ask when it is genuinely ambiguous: brains are a confidentiality boundary, so a page born in the wrong one can expose private work to a teammate, and that is not a filing error you can quietly fix later.',
|
|
2884
|
+
inputSchema: {
|
|
2885
|
+
kind: z.enum(['project', 'person', 'org', 'user']).describe('the node type'),
|
|
2886
|
+
name: z.string().describe('the canonical node name — an EXACT existing name from the namespace to update it, or a new name to create the node (project/person/org). e.g. "Agnoclast" or "Theron Peterson"'),
|
|
2887
|
+
summary: z.string().describe('one-sentence summary of what this is and its current state (may contain [[links]])'),
|
|
2888
|
+
sections: z.array(z.object({
|
|
2889
|
+
heading: z.string().describe('e.g. Overview, Current state, Decisions, Open threads, People'),
|
|
2890
|
+
body: z.string().describe('dense markdown WITH inline [[links]] where the prose references another node'),
|
|
2891
|
+
})).describe('3-5 sections; the page body'),
|
|
2892
|
+
tier: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('visibility tier. Omit for the safe default: scoped (you + your management chain) on nodes that support it — your user page, projects you own-scope — and accessible elsewhere (person/org pages are the shared wiki). Pass accessible explicitly when the page is meant for the whole org.'),
|
|
2893
|
+
base_version: z.string().optional().describe('the `version` hash shown when you read this page (read_page) — REQUIRED when updating an existing page, so a concurrent edit is caught instead of clobbered. Omit only for a brand-new node. If the save returns "stale" or "read first", read_page again and retry with the fresh version.'),
|
|
2894
|
+
reason: z.string().describe('WHY you are making this edit, in one short phrase — recorded permanently in page_history so a later reader can tell a routine addition from a correction. Say what CHANGED and what prompted it ("Ben pilot abandoned per Theron 07-17", "corrected: 0069 already widened the CHECK"), not what you did ("updated page"). This is the field that makes staleness auditable.'),
|
|
2895
|
+
change_kind: z.enum(['add', 'correct', 'supersede', 'expand', 'retire']).optional().describe('what KIND of edit: "add" (new information), "correct" (the page said something FALSE — the currency-critical one), "supersede" (was true, now outdated by events), "expand" (elaborates, no claim changed), "retire" (putting the page or a section to rest). Be honest with "correct" — a page whose history shows repeated corrections is a page whose claims need checking, and that signal is the point.'),
|
|
2896
|
+
brain: z.string().optional().describe('which brain a genuinely NEW page is created in — a brain name or its org id. Choose by RELEVANCE to what you are writing (`my_brains` shows what each brain holds), not by the active pointer. Has top precedence, so it also disambiguates a page name you hold in several brains. Unnecessary when the brain is resolvable from the write itself (base_version, or an existing page of this name) and unnecessary when you only have one brain.'),
|
|
2897
|
+
},
|
|
2898
|
+
},
|
|
2899
|
+
async ({ kind, name, summary, sections, tier, base_version, reason, change_kind, brain }) => {
|
|
2900
|
+
// No client-side tier default — the server computes the per-kind safe default (page-privacy
|
|
2901
|
+
// T4/D10) so version-pinned installs can't bake a stale policy.
|
|
2902
|
+
const pages = [{ ...(tier ? { tier } : {}), summary, base_version, sections: Array.isArray(sections) ? sections : [] }]
|
|
2903
|
+
let res
|
|
2904
|
+
try {
|
|
2905
|
+
res = await fetchCortex(`${BASE}/api/brain/author`, {
|
|
2906
|
+
method: 'POST',
|
|
2907
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
2908
|
+
// `brain` is forwarded only when the caller named one. The server's resolveAuthorBrain gives
|
|
2909
|
+
// an explicit brain top precedence and 409s on an unknown one rather than falling back to
|
|
2910
|
+
// the pointer, so sending an empty value would turn "I did not choose" into "I chose wrong".
|
|
2911
|
+
body: JSON.stringify({ kind, name, pages, reason, change_kind, ...(brain ? { brain } : {}) }),
|
|
2912
|
+
})
|
|
2913
|
+
} catch (e) {
|
|
2914
|
+
return toolError(`Could not author "${name}": ${e.message}`)
|
|
2915
|
+
}
|
|
2916
|
+
if (!res.ok) {
|
|
2917
|
+
// Read the body ONCE — classify() consumes it as text, so parsing after it is a spent stream.
|
|
2918
|
+
const raw = await res.text()
|
|
2919
|
+
let err = null
|
|
2920
|
+
try { err = JSON.parse(raw) } catch { /* not JSON; classify handles it below */ }
|
|
2921
|
+
// BRAIN-RESOLUTION REFUSALS carry the only thing that makes them actionable: which brains, and
|
|
2922
|
+
// what is in each. classify() flattens a response to a generic status message, so routing these
|
|
2923
|
+
// through it would drop the payload and leave the agent to guess a brain — the exact guess the
|
|
2924
|
+
// 409 exists to prevent. Page counts and sample titles matter more than the names: per
|
|
2925
|
+
// brain_identity.ts a brain's NAME is actively misleading about its contents.
|
|
2926
|
+
if (err?.error === 'create_needs_brain' || err?.error === 'ambiguous_brain' || err?.error === 'unknown_brain') {
|
|
2927
|
+
const list = (err.brains ?? []).map((b) => {
|
|
2928
|
+
const count = b.pageCount != null ? ` — ${b.pageCount} page${b.pageCount === 1 ? '' : 's'}` : ''
|
|
2929
|
+
const sample = b.sampleTitles?.length ? `: ${b.sampleTitles.slice(0, 4).join(', ')}` : ''
|
|
2930
|
+
return ` • ${b.name ?? b.brain} (${b.orgId})${count}${sample}`
|
|
2931
|
+
}).join('\n')
|
|
2932
|
+
return toolError(
|
|
2933
|
+
`Could not author "${name}": ${err.message ?? err.error}` +
|
|
2934
|
+
(list ? `\n\nYour brains:\n${list}` : '') +
|
|
2935
|
+
`\n\nRe-run author with brain:"<name>" — choose by what each brain HOLDS, not by its name.`,
|
|
2936
|
+
)
|
|
2937
|
+
}
|
|
2938
|
+
const d = classify(res.status, res.headers.get('content-type'), raw, res.headers.get('x-vercel-id'))
|
|
2939
|
+
return toolError(`Could not author "${name}": ${d.message}`)
|
|
2940
|
+
}
|
|
2941
|
+
const out = await res.json()
|
|
2942
|
+
const blue = out?.links?.blue ?? 0
|
|
2943
|
+
const retired = out?.links?.retired ?? 0
|
|
2944
|
+
const red = out?.links?.red ?? 0
|
|
2945
|
+
const redList = Array.isArray(out?.redLinks) && out.redLinks.length ? `\nRed-links (wanted nodes): ${out.redLinks.map((r) => `[[${r}]]`).join(', ')}` : ''
|
|
2946
|
+
const retiredList = Array.isArray(out?.retiredLinks) && out.retiredLinks.length ? `\nRetired links (not current or wanted): ${out.retiredLinks.map((r) => `[[${r}]]`).join(', ')}` : ''
|
|
2947
|
+
const stamps = Array.isArray(out?.identifiers) && out.identifiers.length ? `\nIdentifier stamps (join keys): ${out.identifiers.map((i) => `[[${i}]]`).join(', ')}` : ''
|
|
2948
|
+
// TIER RETARGET — say it out loud. The server has always computed these (AuthorResult.tierCorrections)
|
|
2949
|
+
// and its own comment says corrections are "surfaced (not hidden)", but nothing ever printed them, so
|
|
2950
|
+
// the intent died at the client. That silence is not cosmetic: on 2026-07-29 a correction to
|
|
2951
|
+
// cortex-cross-editor-hub landed on the SCOPED tier while the accessible tier kept serving a claim
|
|
2952
|
+
// known to be false, and the response said only "Authored (1 tier)". Whoever reads this line is the
|
|
2953
|
+
// last chance to notice a page was fixed somewhere nobody reads.
|
|
2954
|
+
const corrections = Array.isArray(out?.tierCorrections) && out.tierCorrections.length
|
|
2955
|
+
? `\n⚠ TIER: this wrote to ${out.tierCorrections.map((c) => `${c.actualTier} (you asked for ${c.requestedTier})`).join('; ')}.` +
|
|
2956
|
+
` If that is not the tier you meant, read_page and check which copy you just changed —` +
|
|
2957
|
+
` a page can exist at several tiers and they drift apart independently.`
|
|
2958
|
+
: ''
|
|
2959
|
+
// KWA-26 — the advisory undated flag. The server has computed `undatedSections` since #558 and
|
|
2960
|
+
// the route has returned it ever since; NOTHING PRINTED IT, so the one consumer the item names
|
|
2961
|
+
// never saw it: "the author path returns a flag naming undated status claims SO THE AGENT FIXES
|
|
2962
|
+
// THEM IN-TURN." A flag the agent cannot see does not exist. Same shape as tierCorrections
|
|
2963
|
+
// directly above — computed, returned, and silently dropped at the client — and the fourth
|
|
2964
|
+
// instance of it in this subsystem.
|
|
2965
|
+
//
|
|
2966
|
+
// Advisory by DESIGN, not by omission: the write has already landed by the time this prints
|
|
2967
|
+
// (2026-07-28, option (b) "make this blocking" was weighed and rejected — rejecting the write
|
|
2968
|
+
// would lose the session's understanding, the more expensive failure). So this is phrased as
|
|
2969
|
+
// work the agent can do NOW, while the context is still hot, which is the only moment the fix
|
|
2970
|
+
// is cheap.
|
|
2971
|
+
const undated = Array.isArray(out?.undatedSections) && out.undatedSections.length
|
|
2972
|
+
? `\n⚠ Undated (${out.undatedSections.length}): ${out.undatedSections.map((h) => `"${h}"`).join(', ')}.` +
|
|
2973
|
+
` These landed with no explicit calendar date in the heading or body, so a reader cannot tell` +
|
|
2974
|
+
` WHEN the claim was true — only when the text was last written. If any of them assert a` +
|
|
2975
|
+
` STATUS ("X is live", "Y is not merged"), add the date inline with edit_page while you still` +
|
|
2976
|
+
` hold the context. The write already landed; this is advisory.`
|
|
2977
|
+
: ''
|
|
2978
|
+
const verb = out?.created ? 'Created + authored' : 'Authored'
|
|
2979
|
+
const note = out?.built ? `${verb} "${name}" (${out.built} tier${out.built === 1 ? '' : 's'}). Links: ${blue} resolved, ${retired} retired, ${red} red.${corrections}${retiredList}${redList}${stamps}${undated}`
|
|
2980
|
+
: `No change to "${name}"${out?.skipped?.length ? ` (${out.skipped.join(', ')})` : ''}.${corrections}`
|
|
2981
|
+
return { content: [{ type: 'text', text: note }] }
|
|
2982
|
+
},
|
|
2983
|
+
)
|
|
2984
|
+
|
|
2985
|
+
await server.connect(new StdioServerTransport())
|
|
2986
|
+
}
|