@theronap/cortex-mcp 0.9.129 → 0.9.131
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/diagnose.mjs +14 -4
- package/lib/server.mjs +94 -0
- package/package.json +1 -1
package/lib/diagnose.mjs
CHANGED
|
@@ -119,8 +119,18 @@ export function wiredDistTag() {
|
|
|
119
119
|
return null
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
// The production
|
|
123
|
-
|
|
122
|
+
// The canonical production host — exempt from Vercel Deployment Protection.
|
|
123
|
+
//
|
|
124
|
+
// ⚠ cortex-console.vercel.app IS STILL A LIVE ALIAS AND MUST STAY IN THE EXEMPT SET BELOW. It was
|
|
125
|
+
// the canonical host until agnoclast.com; installed clients, frozen generated scripts and hook
|
|
126
|
+
// commands on machines nobody can reach still send it. Removing it from the set would make
|
|
127
|
+
// resolveBase() treat a WORKING production alias as a protected deployment URL and silently redirect
|
|
128
|
+
// those clients — the exact failure this function exists to prevent, caused by the fix for it.
|
|
129
|
+
export const CANONICAL_BASE = 'https://agnoclast.com'
|
|
130
|
+
|
|
131
|
+
// Production aliases: real hosts that serve the app and are exempt from Deployment Protection, as
|
|
132
|
+
// opposed to per-deployment URLs (cortex-console-<hash>-<team>.vercel.app) which are NOT.
|
|
133
|
+
const PRODUCTION_ALIASES = new Set(['agnoclast.com', 'cortex-console.vercel.app'])
|
|
124
134
|
|
|
125
135
|
// Resolve the API base from CORTEX_URL. A Vercel *deployment* URL
|
|
126
136
|
// (cortex-console-<hash>-<team>.vercel.app, or any non-canonical *.vercel.app) is guarded by
|
|
@@ -134,7 +144,7 @@ export function resolveBase(rawUrl) {
|
|
|
134
144
|
if (!raw) return CANONICAL_BASE
|
|
135
145
|
let host
|
|
136
146
|
try { host = new URL(raw).host } catch { return CANONICAL_BASE }
|
|
137
|
-
if (host.endsWith('.vercel.app') && host
|
|
147
|
+
if (host.endsWith('.vercel.app') && !PRODUCTION_ALIASES.has(host)) {
|
|
138
148
|
process.stderr.write(
|
|
139
149
|
`cortex: CORTEX_URL (${raw}) is a protected Vercel deployment URL; using ${CANONICAL_BASE} instead.\n`,
|
|
140
150
|
)
|
|
@@ -317,7 +327,7 @@ export async function checkToken(token, base) {
|
|
|
317
327
|
message: `Token "${String(token).slice(0, 8)}…" is not a valid Agnoclast token (expected a UUID). ` +
|
|
318
328
|
`Re-run setup with the token from the console.` } }
|
|
319
329
|
}
|
|
320
|
-
const url = `${(base ??
|
|
330
|
+
const url = `${(base ?? CANONICAL_BASE).replace(/\/$/, '')}/api/mcp-context`
|
|
321
331
|
let res
|
|
322
332
|
try {
|
|
323
333
|
res = await fetchCortex(url, { headers: { Authorization: `Bearer ${token}` } })
|
package/lib/server.mjs
CHANGED
|
@@ -2167,6 +2167,100 @@ function renderNudge(payload) {
|
|
|
2167
2167
|
},
|
|
2168
2168
|
)
|
|
2169
2169
|
|
|
2170
|
+
server.registerTool(
|
|
2171
|
+
'find_sessions',
|
|
2172
|
+
{
|
|
2173
|
+
title: 'Find the calendar sessions on a given day',
|
|
2174
|
+
description: "Find the event records on a local calendar day — how you get the id of a SPECIFIC session (\"the Wednesday MSB 380 class\") so you can place something inside it with capture_record's container_record_id. Pass `tz` as the person's own timezone: a day means the day THEY lived through, and resolving it in UTC puts an evening class on the wrong date. Each result says whether it has a CONTAINER — a session with `container_type: null` cannot hold anything, and naming it as a target fails later for a reason you could not otherwise have seen.",
|
|
2175
|
+
inputSchema: {
|
|
2176
|
+
date: z.string().describe('the local calendar day, YYYY-MM-DD'),
|
|
2177
|
+
tz: z.string().optional().describe("the person's IANA timezone, e.g. America/Denver. Defaults to UTC, which is usually NOT what you want."),
|
|
2178
|
+
q: z.string().optional().describe('case-insensitive title filter, e.g. "MSB 380"'),
|
|
2179
|
+
},
|
|
2180
|
+
},
|
|
2181
|
+
async ({ date, tz, q }) => {
|
|
2182
|
+
const qs = new URLSearchParams({ date, ...(tz ? { tz } : {}), ...(q ? { q } : {}) })
|
|
2183
|
+
let res
|
|
2184
|
+
try {
|
|
2185
|
+
res = await fetchCortex(`${BASE}/api/sessions?${qs}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
2186
|
+
} catch (e) {
|
|
2187
|
+
return toolError(`Could not reach Agnoclast to find sessions on ${date}: ${e.message}`)
|
|
2188
|
+
}
|
|
2189
|
+
const body = await res.json().catch(() => ({}))
|
|
2190
|
+
if (!res.ok || !body?.ok) return toolError(`Could not find sessions on ${date}: ${body?.error ?? res.status}`)
|
|
2191
|
+
if (!body.sessions?.length) {
|
|
2192
|
+
return { content: [{ type: 'text', text: `No sessions on ${date}${q ? ` matching "${q}"` : ''}${tz ? ` (${tz})` : ' (UTC — pass tz if that is wrong)'}.` }] }
|
|
2193
|
+
}
|
|
2194
|
+
const lines = [`Sessions on ${date}${tz ? ` (${tz})` : ' (UTC)'}:`]
|
|
2195
|
+
for (const s of body.sessions) {
|
|
2196
|
+
const when = new Date(s.startsAt).toISOString().slice(11, 16)
|
|
2197
|
+
const box = s.containerType
|
|
2198
|
+
? `container: ${s.containerType}${s.sealed ? ', sealed' : ''}${s.alreadyContains ? `, holds ${s.alreadyContains}` : ''}`
|
|
2199
|
+
: '⚠ NO CONTAINER — cannot hold anything'
|
|
2200
|
+
lines.push(`- ${s.title ?? '(untitled)'} · ${when}Z${s.location ? ` · ${s.location}` : ''}\n id: ${s.recordId}\n ${box}${s.seriesKey ? `\n series: ${s.seriesKey}` : ''}`)
|
|
2201
|
+
}
|
|
2202
|
+
lines.push('', 'Use an id as capture_record\'s container_record_id to place something inside that session.')
|
|
2203
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] }
|
|
2204
|
+
},
|
|
2205
|
+
)
|
|
2206
|
+
|
|
2207
|
+
server.registerTool(
|
|
2208
|
+
'capture_record',
|
|
2209
|
+
{
|
|
2210
|
+
title: 'Hand something over and get it into the brain',
|
|
2211
|
+
description: "Put a document, transcript, notes or any pasted content into the brain as a real record, routed to where it belongs. USE THIS WHEN THERE IS NO CONNECTOR — a lecture recording, an export from a tool nobody has wired up, something a person just handed you. ⚠ `identifiers` IS HOW IT REACHES A PAGE: a record routes to whatever page CLAIMS an identifier it carries (series:… for a class or recurring meeting, repo:owner/name, project:slug, email:someone@example.com). WITHOUT ONE IT REACHES NO PAGE — that is not an error, but it means nobody will find it, so say so rather than reporting success. `occurred_at` is WHEN THE THING HAPPENED, not now: a transcript handed over today may belong to yesterday's class, and filing it under today puts it in the wrong interval invisibly. `container_record_id` additionally places it INSIDE a specific event (a class session, a meeting) — that is an explicit human decision and is recorded as one; overlap alone never places anything.",
|
|
2212
|
+
inputSchema: {
|
|
2213
|
+
title: z.string().describe('a short name for this record'),
|
|
2214
|
+
content: z.string().describe('the actual content — transcript, notes, document text. Summarized server-side.'),
|
|
2215
|
+
occurred_at: z.string().describe('when the thing HAPPENED, ISO 8601 — not when you are uploading it'),
|
|
2216
|
+
ends_at: z.string().optional().describe('when it ended, ISO 8601. Omit if unknown; NEVER guess one.'),
|
|
2217
|
+
identifiers: z.array(z.string()).optional().describe('routing identifiers — how it reaches a page. e.g. ["series:geol-100-001-f2026"]'),
|
|
2218
|
+
container_record_id: z.string().optional().describe('put it INSIDE this event (the record id of a class session or meeting that has a container)'),
|
|
2219
|
+
summary: z.string().optional().describe('your own summary; if given, the content is stored but not sent for summarization'),
|
|
2220
|
+
source: z.string().optional().describe("where it came from, for provenance — e.g. 'granola', 'learning-suite'. Default 'handoff'."),
|
|
2221
|
+
brain: z.string().optional().describe('which brain, by name or org id. REQUIRED if you belong to more than one — placing a record IS a disclosure decision.'),
|
|
2222
|
+
},
|
|
2223
|
+
},
|
|
2224
|
+
async ({ title, content, occurred_at, ends_at, identifiers, container_record_id, summary, source, brain }) => {
|
|
2225
|
+
let res
|
|
2226
|
+
try {
|
|
2227
|
+
res = await fetchCortex(`${BASE}/api/records/capture`, {
|
|
2228
|
+
method: 'POST',
|
|
2229
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
2230
|
+
body: JSON.stringify({
|
|
2231
|
+
title, content, occurredAt: occurred_at, endsAt: ends_at, identifiers,
|
|
2232
|
+
containerRecordId: container_record_id, summary, source, brain,
|
|
2233
|
+
}),
|
|
2234
|
+
})
|
|
2235
|
+
} catch (e) {
|
|
2236
|
+
return toolError(`Could not reach Agnoclast to capture "${title}": ${e.message}`)
|
|
2237
|
+
}
|
|
2238
|
+
const body = await res.json().catch(() => ({}))
|
|
2239
|
+
if (!res.ok || !body?.ok) {
|
|
2240
|
+
return toolError(`Could not capture "${title}": ${body?.error ?? res.status}${body?.detail ? ` — ${body.detail}` : ''}`)
|
|
2241
|
+
}
|
|
2242
|
+
// ⚠ A SEALED UNIT IS NOT A RECORD YET, AND SAYING "captured" WOULD BE A LIE OF OMISSION.
|
|
2243
|
+
// On a private-intake account the content is stored encrypted and becomes a record only when
|
|
2244
|
+
// materialised — so it cannot be found, routed or contained until then. Reporting it as a
|
|
2245
|
+
// plain success is how someone believes a transcript is filed when it is sitting sealed.
|
|
2246
|
+
if (body.sealed) {
|
|
2247
|
+
const l = [`Stored "${title}" as a SEALED private-intake unit (${body.intakeItemId}).`]
|
|
2248
|
+
l.push('It is NOT a record yet — it becomes one when you materialise it, and only then can it be routed or placed in a container.')
|
|
2249
|
+
if (body.duplicate) l.push('This matched an existing unit rather than creating a new one.')
|
|
2250
|
+
if (body.routed_by?.length) l.push(`Carries: ${body.routed_by.join(', ')} — it will route on those once materialised.`)
|
|
2251
|
+
else l.push('⚠ NO IDENTIFIERS — once materialised it will reach no page.')
|
|
2252
|
+
return { content: [{ type: 'text', text: l.join('\n') }] }
|
|
2253
|
+
}
|
|
2254
|
+
const lines = [`Captured "${title}" as record ${body.id}.`]
|
|
2255
|
+
// ⚠ AN UNROUTED RECORD IS REPORTED AS SUCH. It was stored, but nothing will find it — reporting
|
|
2256
|
+
// that as a plain success is how a record becomes invisible while looking filed.
|
|
2257
|
+
if (body.routed_by?.length) lines.push(`Routes via: ${body.routed_by.join(', ')} — it reaches whatever page claims those.`)
|
|
2258
|
+
else lines.push('⚠ NO IDENTIFIERS — this record reaches no page. Nobody will find it unless you give it one (identifiers) or attach it by hand.')
|
|
2259
|
+
if (body.contained) lines.push('Placed inside the container you named.')
|
|
2260
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] }
|
|
2261
|
+
},
|
|
2262
|
+
)
|
|
2263
|
+
|
|
2170
2264
|
server.registerTool(
|
|
2171
2265
|
'capture_meeting',
|
|
2172
2266
|
{
|
package/package.json
CHANGED