@theronap/cortex-mcp 0.9.45 → 0.9.47
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/bin/cortex-mcp.mjs +8 -0
- package/lib/code_graph_cli.mjs +59 -0
- package/lib/graphify_sync.mjs +89 -0
- package/lib/grep_cli.mjs +8 -1
- package/lib/server.mjs +103 -2
- package/package.json +1 -1
package/bin/cortex-mcp.mjs
CHANGED
|
@@ -50,6 +50,7 @@ if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
|
50
50
|
` skills install/repair the managed Cortex skills — bundled + org-published (also wired by setup)\n` +
|
|
51
51
|
` skills push <file> publish a SKILL.md to your org (owner/manager/admin)\n` +
|
|
52
52
|
` docs-scan detect new/changed local docs pending Cortex authoring (used by /cortex-author-docs)\n` +
|
|
53
|
+
` graphify-sync [path] rebuild the local code graph (graphify) + log an evidence-tier timeline event\n` +
|
|
53
54
|
` snapshot-context save the exact startup context Cortex served to a local snapshot\n` +
|
|
54
55
|
` capture Stop-hook capturer (invoked by Claude Code)\n` +
|
|
55
56
|
` ingest-folder <path> ingest a local markdown folder as your authored records\n` +
|
|
@@ -128,6 +129,13 @@ if (cmd === 'setup') {
|
|
|
128
129
|
process.exitCode = await runGrep(rest)
|
|
129
130
|
const { closeFetch } = await import('../lib/diagnose.mjs')
|
|
130
131
|
await closeFetch()
|
|
132
|
+
} else if (cmd === 'graphify-sync') {
|
|
133
|
+
// Local producer: `graphify update` + log an evidence-tier timeline event. Run from a cron/
|
|
134
|
+
// launchd job per repo, not a git hook (shared-checkout hazard — see reference memory).
|
|
135
|
+
const { runGraphifySync } = await import('../lib/graphify_sync.mjs')
|
|
136
|
+
process.exitCode = await runGraphifySync(rest)
|
|
137
|
+
const { closeFetch } = await import('../lib/diagnose.mjs')
|
|
138
|
+
await closeFetch()
|
|
131
139
|
} else if (cmd === 'snapshot-context') {
|
|
132
140
|
const { runSnapshotContext } = await import('../lib/context_log.mjs')
|
|
133
141
|
process.exitCode = await runSnapshotContext()
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { spawnSync } from 'child_process'
|
|
2
|
+
import { existsSync } from 'fs'
|
|
3
|
+
import { join } from 'path'
|
|
4
|
+
|
|
5
|
+
// Thin local wrapper around the `graphify` CLI's read-only query subcommands. Deliberately NOT a
|
|
6
|
+
// fetchCortex client like grep/read_page: this is LOCAL-MACHINE data (a tree-sitter AST graph of
|
|
7
|
+
// whatever repo the session's cwd happens to be in), not org-shared Cortex content, and it never
|
|
8
|
+
// becomes the wiki graph — see cortex-wiki-primary-spec (structural/extracted data is evidence,
|
|
9
|
+
// never auto-promoted into authored pages). No LLM, no network call; graphify already built the
|
|
10
|
+
// graph on disk, this just queries it.
|
|
11
|
+
|
|
12
|
+
function graphPath(cwd) {
|
|
13
|
+
return join(cwd, 'graphify-out', 'graph.json')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function hasGraphifyBinary() {
|
|
17
|
+
const r = spawnSync('graphify', ['--version'], { encoding: 'utf8', timeout: 10_000 })
|
|
18
|
+
return !r.error && r.status === 0
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Pure-ish: build the argv for a given action, or return an error string if params are missing.
|
|
22
|
+
export function buildArgs({ action, question, from, to, node }) {
|
|
23
|
+
if (action === 'path') {
|
|
24
|
+
if (!from || !to) return { error: 'action:"path" requires both "from" and "to".' }
|
|
25
|
+
return { args: ['path', from, to] }
|
|
26
|
+
}
|
|
27
|
+
if (action === 'explain') {
|
|
28
|
+
if (!node) return { error: 'action:"explain" requires "node".' }
|
|
29
|
+
return { args: ['explain', node] }
|
|
30
|
+
}
|
|
31
|
+
if (!question) return { error: 'action:"query" requires "question".' }
|
|
32
|
+
return { args: ['query', question] }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Effectful: run one of graphify's query/path/explain subcommands against the graph already built
|
|
36
|
+
// for `cwd`. Returns { ok, text } — never throws, always something readable to hand back to the model.
|
|
37
|
+
export function runCodeGraphQuery({ action, question, from, to, node }, cwd = process.cwd()) {
|
|
38
|
+
if (!existsSync(graphPath(cwd))) {
|
|
39
|
+
return {
|
|
40
|
+
ok: false,
|
|
41
|
+
text: `No code graph found at ${graphPath(cwd)}. Run the graphify skill (\`/graphify .\`) in this repo first to build one.`,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (!hasGraphifyBinary()) {
|
|
45
|
+
return {
|
|
46
|
+
ok: false,
|
|
47
|
+
text: 'graphify CLI not found on PATH. Install it with `uv tool install graphifyy` (or `pipx install graphifyy`), then run the graphify skill to build a graph.',
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const built = buildArgs({ action, question, from, to, node })
|
|
51
|
+
if (built.error) return { ok: false, text: built.error }
|
|
52
|
+
|
|
53
|
+
const r = spawnSync('graphify', built.args, { cwd, encoding: 'utf8', timeout: 60_000, maxBuffer: 4 * 1024 * 1024 })
|
|
54
|
+
if (r.error) return { ok: false, text: `graphify failed to run: ${r.error.message}` }
|
|
55
|
+
const out = (r.stdout || '').trim()
|
|
56
|
+
const err = (r.stderr || '').trim()
|
|
57
|
+
if (r.status !== 0) return { ok: false, text: err || out || `graphify exited with status ${r.status}` }
|
|
58
|
+
return { ok: true, text: out || '(no results)' }
|
|
59
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { spawnSync } from 'child_process'
|
|
2
|
+
import { existsSync, readFileSync } from 'fs'
|
|
3
|
+
import { join } from 'path'
|
|
4
|
+
import { fetchCortex, classify, resolveBase } from './diagnose.mjs'
|
|
5
|
+
|
|
6
|
+
// `cortex-mcp graphify-sync [path]` — local producer: incrementally rebuild a repo's structural
|
|
7
|
+
// code graph (graphify — tree-sitter AST, no LLM) and log an evidence-tier timeline event via
|
|
8
|
+
// POST /api/timeline/graphify. LLM-free, idempotent (server dedupes on repo+commit), additive —
|
|
9
|
+
// never touches the wiki graph (cortex-wiki-primary-spec: extracted/structural data is evidence,
|
|
10
|
+
// never auto-promoted into an authored page).
|
|
11
|
+
//
|
|
12
|
+
// Meant to run from a periodic cron/launchd job per repo you want graphed — NOT a git post-commit
|
|
13
|
+
// hook. A hook fires synchronously inside `git commit`/`git push` and can race another session's
|
|
14
|
+
// git operations in a shared checkout (see reference-cortex-shared-checkout-hazards); a timer-based
|
|
15
|
+
// job just reads whatever the tree looks like at that moment, no hook into git operations at all.
|
|
16
|
+
|
|
17
|
+
function run(cmd, args, cwd) {
|
|
18
|
+
const r = spawnSync(cmd, args, { cwd, encoding: 'utf8', timeout: 120_000 })
|
|
19
|
+
return r.status === 0 ? (r.stdout || '').trim() : null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function repoFullNameFromRemote(cwd) {
|
|
23
|
+
const url = run('git', ['remote', 'get-url', 'origin'], cwd)
|
|
24
|
+
if (!url) return null
|
|
25
|
+
const m = /github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/i.exec(url)
|
|
26
|
+
return m ? `${m[1]}/${m[2]}` : null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function runGraphifySync(argv = []) {
|
|
30
|
+
const cwd = argv[0] && !argv[0].startsWith('-') ? argv[0] : process.cwd()
|
|
31
|
+
const TOKEN = process.env.CORTEX_TOKEN
|
|
32
|
+
const BASE = resolveBase(process.env.CORTEX_URL)
|
|
33
|
+
if (!TOKEN) {
|
|
34
|
+
process.stderr.write('cortex-mcp graphify-sync: CORTEX_TOKEN is required.\n')
|
|
35
|
+
return 1
|
|
36
|
+
}
|
|
37
|
+
const graphFile = join(cwd, 'graphify-out', 'graph.json')
|
|
38
|
+
if (!existsSync(graphFile)) {
|
|
39
|
+
process.stderr.write(`cortex-mcp graphify-sync: no graph at ${graphFile} — run the graphify skill (\`/graphify .\`) in this repo first.\n`)
|
|
40
|
+
return 1
|
|
41
|
+
}
|
|
42
|
+
if (!run('graphify', ['--version'], cwd)) {
|
|
43
|
+
process.stderr.write('cortex-mcp graphify-sync: graphify CLI not on PATH (`uv tool install graphifyy`).\n')
|
|
44
|
+
return 1
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const update = spawnSync('graphify', ['update', '.'], { cwd, encoding: 'utf8', timeout: 300_000, maxBuffer: 8 * 1024 * 1024 })
|
|
48
|
+
if (update.status !== 0) {
|
|
49
|
+
process.stderr.write(`cortex-mcp graphify-sync: \`graphify update\` failed:\n${(update.stderr || update.stdout || '').trim()}\n`)
|
|
50
|
+
return 1
|
|
51
|
+
}
|
|
52
|
+
if (update.stdout) process.stdout.write(update.stdout.trim() + '\n')
|
|
53
|
+
|
|
54
|
+
let graph
|
|
55
|
+
try {
|
|
56
|
+
graph = JSON.parse(readFileSync(graphFile, 'utf8'))
|
|
57
|
+
} catch (e) {
|
|
58
|
+
process.stderr.write(`cortex-mcp graphify-sync: could not read ${graphFile}: ${e.message}\n`)
|
|
59
|
+
return 1
|
|
60
|
+
}
|
|
61
|
+
const nodes = Array.isArray(graph.nodes) ? graph.nodes : []
|
|
62
|
+
const nodeCount = nodes.length
|
|
63
|
+
const edgeCount = Array.isArray(graph.links) ? graph.links.length : 0
|
|
64
|
+
const communityCount = new Set(nodes.map((n) => n.community).filter((c) => c !== undefined)).size
|
|
65
|
+
const commitSha = typeof graph.built_at_commit === 'string' ? graph.built_at_commit : null
|
|
66
|
+
|
|
67
|
+
const repo = repoFullNameFromRemote(cwd)
|
|
68
|
+
if (!commitSha || !repo) {
|
|
69
|
+
process.stdout.write('cortex-mcp graphify-sync: graph updated locally; skipping timeline log (no git commit / no github.com origin found).\n')
|
|
70
|
+
return 0
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const res = await fetchCortex(`${BASE}/api/timeline/graphify`, {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
76
|
+
body: JSON.stringify({ repo, commitSha, nodeCount, edgeCount, communityCount }),
|
|
77
|
+
})
|
|
78
|
+
if (!res.ok) {
|
|
79
|
+
const body = await res.text()
|
|
80
|
+
process.stderr.write(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message + '\n')
|
|
81
|
+
return 1
|
|
82
|
+
}
|
|
83
|
+
const payload = await res.json()
|
|
84
|
+
process.stdout.write(
|
|
85
|
+
`Logged to Cortex timeline: ${repo}@${commitSha.slice(0, 7)} (${nodeCount} nodes, ${edgeCount} edges, ${communityCount} communities)` +
|
|
86
|
+
`${payload.inserted ? '' : ' (already logged)'}\n`,
|
|
87
|
+
)
|
|
88
|
+
return 0
|
|
89
|
+
}
|
package/lib/grep_cli.mjs
CHANGED
|
@@ -33,7 +33,14 @@ export function parseGrepArgs(argv = []) {
|
|
|
33
33
|
// Pure: render a /api/grep payload to readable ASCII text.
|
|
34
34
|
export function formatGrepHits(payload, query) {
|
|
35
35
|
const hits = (payload && payload.hits) || []
|
|
36
|
-
if (!hits.length)
|
|
36
|
+
if (!hits.length) {
|
|
37
|
+
// Reactive red-link hint — ONLY when the query looks like a page NAME (short, no code/operators), so
|
|
38
|
+
// code and typo searches don't get nagged. read_page carries the full triage (node / new / alias).
|
|
39
|
+
const nameish = /^[\w .'-]{2,40}$/.test(query) && query.split(/\s+/).length <= 5
|
|
40
|
+
return nameish
|
|
41
|
+
? `No matches for "${query}". If you expected a page here, it may be an unauthored red-link — \`read_page "${query}"\` to triage it (author it, or alias it to an existing page).`
|
|
42
|
+
: `No matches for "${query}".`
|
|
43
|
+
}
|
|
37
44
|
const lines = [`${hits.length} match${hits.length === 1 ? '' : 'es'} for "${query}":`, '']
|
|
38
45
|
for (const h of hits) {
|
|
39
46
|
const head = h.heading ? ` > ${h.heading}` : ''
|
package/lib/server.mjs
CHANGED
|
@@ -8,6 +8,30 @@ import { createHash, randomUUID } from 'crypto'
|
|
|
8
8
|
import { fetchCortex, classify, resolveBase } from './diagnose.mjs'
|
|
9
9
|
import { runSendImessage } from './imessage_send.mjs'
|
|
10
10
|
import { formatGrepHits } from './grep_cli.mjs'
|
|
11
|
+
import { runCodeGraphQuery } from './code_graph_cli.mjs'
|
|
12
|
+
|
|
13
|
+
// Reactive red-link triage (Mechanism 2). On a read_page miss, ask the server whether the name is a
|
|
14
|
+
// tracked wanted page and whether a bare node exists for it, and turn that into an actionable 3-way
|
|
15
|
+
// prompt (author-for-node / author-new / alias). Returns '' on any error so a miss never gets worse.
|
|
16
|
+
async function redLinkTriage(BASE, TOKEN, name) {
|
|
17
|
+
try {
|
|
18
|
+
const r = await fetchCortex(`${BASE}/api/brain/red-link?name=${encodeURIComponent(name)}`, {
|
|
19
|
+
headers: { Authorization: `Bearer ${TOKEN}` },
|
|
20
|
+
})
|
|
21
|
+
if (!r.ok) return ''
|
|
22
|
+
const t = await r.json()
|
|
23
|
+
const refs = t.tracked
|
|
24
|
+
? ` It's referenced by ${t.refCount} page${t.refCount === 1 ? '' : 's'}${t.isSteward ? ' and is routed to YOU as its most-likely steward' : ''}.`
|
|
25
|
+
: ''
|
|
26
|
+
const aliasHint = `if it's really an existing page under another title, \`grep "${name}"\` to find it, then \`alias_page name="${name}" target_name="<that page>"\``
|
|
27
|
+
if (t.category === 'node') {
|
|
28
|
+
return `\n\n[[${name}]] is a wanted page — a ${t.isPerson ? 'person' : 'node'} exists but has no page yet.${refs} Either author it now with \`author\`, or ${aliasHint}.`
|
|
29
|
+
}
|
|
30
|
+
return `\n\n"${name}" isn't authored anywhere yet.${refs} Either author a new page with \`author\`, or ${aliasHint}.`
|
|
31
|
+
} catch {
|
|
32
|
+
return ''
|
|
33
|
+
}
|
|
34
|
+
}
|
|
11
35
|
|
|
12
36
|
// The Cortex MCP server (stdio). Serves the signed-in employee's scoped org
|
|
13
37
|
// context to their AI assistant. CORTEX_TOKEN identifies the user + org.
|
|
@@ -284,6 +308,26 @@ export async function runServer(version) {
|
|
|
284
308
|
},
|
|
285
309
|
)
|
|
286
310
|
|
|
311
|
+
server.registerTool(
|
|
312
|
+
'code_graph_query',
|
|
313
|
+
{
|
|
314
|
+
title: 'Query the local code structure graph (graphify)',
|
|
315
|
+
description:
|
|
316
|
+
'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 Cortex 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 Cortex pages for intent.',
|
|
317
|
+
inputSchema: {
|
|
318
|
+
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'),
|
|
319
|
+
question: z.string().optional().describe('required for action:"query" — the natural-language question'),
|
|
320
|
+
from: z.string().optional().describe('required for action:"path" — the starting node name'),
|
|
321
|
+
to: z.string().optional().describe('required for action:"path" — the target node name'),
|
|
322
|
+
node: z.string().optional().describe('required for action:"explain" — the node name to describe'),
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
async ({ action, question, from, to, node }) => {
|
|
326
|
+
const result = runCodeGraphQuery({ action, question, from, to, node })
|
|
327
|
+
return { content: [{ type: 'text', text: result.text }] }
|
|
328
|
+
},
|
|
329
|
+
)
|
|
330
|
+
|
|
287
331
|
server.registerTool(
|
|
288
332
|
'project_status',
|
|
289
333
|
{
|
|
@@ -423,7 +467,7 @@ export async function runServer(version) {
|
|
|
423
467
|
return { content: [{ type: 'text', text: `Could not read "${name}": ${e.message}` }] }
|
|
424
468
|
}
|
|
425
469
|
if (res.status === 404) {
|
|
426
|
-
return { content: [{ type: 'text', text: `No ${k} page named "${name}". If it's a person or team, pass kind (person/org)
|
|
470
|
+
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)}` }] }
|
|
427
471
|
}
|
|
428
472
|
if (!res.ok) {
|
|
429
473
|
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
@@ -440,7 +484,7 @@ export async function runServer(version) {
|
|
|
440
484
|
matches = [{ brain: null, authored: true, ref: page.ref, title: page.title, tiers: page.tiers }]
|
|
441
485
|
}
|
|
442
486
|
if (!matches.length) {
|
|
443
|
-
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)
|
|
487
|
+
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)}` }] }
|
|
444
488
|
}
|
|
445
489
|
const day = (d) => (d ? String(d).slice(0, 10) : '')
|
|
446
490
|
const renderMatch = (m, tagBrain) => {
|
|
@@ -791,6 +835,63 @@ export async function runServer(version) {
|
|
|
791
835
|
},
|
|
792
836
|
)
|
|
793
837
|
|
|
838
|
+
server.registerTool(
|
|
839
|
+
'alias_page',
|
|
840
|
+
{
|
|
841
|
+
title: 'Point a wanted name at an existing page',
|
|
842
|
+
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`.',
|
|
843
|
+
inputSchema: {
|
|
844
|
+
name: z.string().describe('the wanted [[Name]] to redirect (the red-link)'),
|
|
845
|
+
target_name: z.string().describe('the exact title of the existing authored page it should resolve to'),
|
|
846
|
+
target_kind: z.enum(['project', 'person', 'org', 'user']).optional().describe('disambiguate the target if two pages share a title'),
|
|
847
|
+
},
|
|
848
|
+
},
|
|
849
|
+
async ({ name, target_name, target_kind }) => {
|
|
850
|
+
let res
|
|
851
|
+
try {
|
|
852
|
+
res = await fetchCortex(`${BASE}/api/brain/alias`, {
|
|
853
|
+
method: 'POST',
|
|
854
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
855
|
+
body: JSON.stringify({ name, target_name, ...(target_kind ? { target_kind } : {}) }),
|
|
856
|
+
})
|
|
857
|
+
} catch (e) {
|
|
858
|
+
return { content: [{ type: 'text', text: `Could not alias: ${e.message}` }] }
|
|
859
|
+
}
|
|
860
|
+
const out = await res.json().catch(() => null)
|
|
861
|
+
if (!res.ok) return { content: [{ type: 'text', text: `Could not alias "${name}": ${out?.error ?? res.status}` }] }
|
|
862
|
+
if (!out) return { content: [{ type: 'text', text: `Aliased "${name}", but the server returned no body — re-read the page to confirm.` }] }
|
|
863
|
+
return { content: [{ type: 'text', text: `Done — [[${out.alias}]] now resolves to "${out.target}" (${out.target_kind}). It's out of the wanted-page backlog.` }] }
|
|
864
|
+
},
|
|
865
|
+
)
|
|
866
|
+
|
|
867
|
+
server.registerTool(
|
|
868
|
+
'snooze_red_link',
|
|
869
|
+
{
|
|
870
|
+
title: 'Defer a wanted page routed to you',
|
|
871
|
+
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`).',
|
|
872
|
+
inputSchema: {
|
|
873
|
+
name: z.string().describe('the wanted page name to snooze (as shown in "Pages the org needs you to author")'),
|
|
874
|
+
days: z.number().int().positive().optional().describe('how many days to defer (default 7)'),
|
|
875
|
+
},
|
|
876
|
+
},
|
|
877
|
+
async ({ name, days }) => {
|
|
878
|
+
let res
|
|
879
|
+
try {
|
|
880
|
+
res = await fetchCortex(`${BASE}/api/brain/red-link/snooze`, {
|
|
881
|
+
method: 'POST',
|
|
882
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
883
|
+
body: JSON.stringify({ name, ...(days ? { days } : {}) }),
|
|
884
|
+
})
|
|
885
|
+
} catch (e) {
|
|
886
|
+
return { content: [{ type: 'text', text: `Could not snooze: ${e.message}` }] }
|
|
887
|
+
}
|
|
888
|
+
const out = await res.json().catch(() => null)
|
|
889
|
+
if (!res.ok) return { content: [{ type: 'text', text: `Could not snooze "${name}": ${out?.error ?? res.status}` }] }
|
|
890
|
+
if (!out) return { content: [{ type: 'text', text: `Snoozed "${name}", but the server returned no body.` }] }
|
|
891
|
+
return { content: [{ type: 'text', text: `Snoozed "${out.name}" for ${out.days} day${out.days === 1 ? '' : 's'} — it won't surface until then.` }] }
|
|
892
|
+
},
|
|
893
|
+
)
|
|
894
|
+
|
|
794
895
|
server.registerTool(
|
|
795
896
|
'set_page_privacy',
|
|
796
897
|
{
|