@theronap/cortex-mcp 0.9.128 → 0.9.130

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 CHANGED
@@ -119,8 +119,18 @@ export function wiredDistTag() {
119
119
  return null
120
120
  }
121
121
 
122
- // The production alias — exempt from Vercel Deployment Protection.
123
- export const CANONICAL_BASE = 'https://cortex-console.vercel.app'
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 !== 'cortex-console.vercel.app') {
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 ?? 'https://cortex-console.vercel.app').replace(/\/$/, '')}/api/mcp-context`
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
@@ -1331,11 +1331,30 @@ function renderNudge(payload) {
1331
1331
  return toolError(`Could not read the timeline for "${name}": ${d.message}`)
1332
1332
  }
1333
1333
  const t = await r.json()
1334
- if (!t.identifiers?.length) {
1335
- 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.` }] }
1334
+ // 🔴 THE EARLY RETURN USED TO FIRE ON `!identifiers.length` ALONE, WHICH DISCARDED EVERY
1335
+ // ATTACHED EVENT THE SERVER HAD ALREADY RETURNED. nodeTimeline() ends with
1336
+ // `events: mergeEvents([...identifierEvents, attachedEvents])` — Gate 4 page attachments
1337
+ // are unioned in explicitly, and its own doc says so: "a node with no stamps returns the
1338
+ // honest empty shape for identifiers — BUT GATE 4 STILL UNIONS EXPLICIT PAGE ATTACHMENTS".
1339
+ //
1340
+ // Measured 2026-09-03: the GEOL 100 page had 43 class sessions deterministically attached
1341
+ // (records_attached_to_node returns 30, its limit) and this client reported "carries no
1342
+ // identifier stamps yet — no history joins." The data was correct, complete, and INVISIBLE,
1343
+ // and the message actively misled — it told the reader to go stamp a page that already had
1344
+ // everything attached to it.
1345
+ //
1346
+ // The honest condition is: nothing to show at all.
1347
+ if (!t.identifiers?.length && !t.events?.length) {
1348
+ return { content: [{ type: 'text', text: `"${name}" has no history yet — no identifier stamps and nothing attached. Stamp its page with [[repo:owner/name]] (what it identifies), or attach records to it, and events will accrue here.` }] }
1336
1349
  }
1337
1350
  const lines = [`# ${name} — node timeline (history; the page is the present)`]
1338
- lines.push(`Joins: ${t.identifiers.map((i) => `[[${i.id}]] · ${i.visibleCount} visible`).join(' | ')}${t.incomplete ? ' (partial — one join failed to read)' : ''}`)
1351
+ if (t.identifiers?.length) {
1352
+ lines.push(`Joins: ${t.identifiers.map((i) => `[[${i.id}]] · ${i.visibleCount} visible`).join(' | ')}${t.incomplete ? ' (partial — one join failed to read)' : ''}`)
1353
+ } else {
1354
+ // Say WHERE the events came from. A reader who sees a timeline on a page with no stamps
1355
+ // should not have to guess whether the stamps are missing or simply not how it got here.
1356
+ lines.push(`Joins: none — these events are attached to the page directly, not matched through an identifier stamp.${t.incomplete ? ' (partial — one read failed)' : ''}`)
1357
+ }
1339
1358
  for (const e of t.events) lines.push(`- ${String(e.occurredAt).slice(0, 10)} · ${e.source} · ${e.summary}`)
1340
1359
  if (!t.events.length) lines.push('(no events visible to you yet on these joins)')
1341
1360
  if (t.siblings?.length) lines.push(`Sibling homes (share a stamp — bridges, not history): ${t.siblings.map((s) => `"${s.title}"`).join(', ')}`)
@@ -2148,6 +2167,51 @@ function renderNudge(payload) {
2148
2167
  },
2149
2168
  )
2150
2169
 
2170
+ server.registerTool(
2171
+ 'capture_record',
2172
+ {
2173
+ title: 'Hand something over and get it into the brain',
2174
+ 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.",
2175
+ inputSchema: {
2176
+ title: z.string().describe('a short name for this record'),
2177
+ content: z.string().describe('the actual content — transcript, notes, document text. Summarized server-side.'),
2178
+ occurred_at: z.string().describe('when the thing HAPPENED, ISO 8601 — not when you are uploading it'),
2179
+ ends_at: z.string().optional().describe('when it ended, ISO 8601. Omit if unknown; NEVER guess one.'),
2180
+ identifiers: z.array(z.string()).optional().describe('routing identifiers — how it reaches a page. e.g. ["series:geol-100-001-f2026"]'),
2181
+ 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)'),
2182
+ summary: z.string().optional().describe('your own summary; if given, the content is stored but not sent for summarization'),
2183
+ source: z.string().optional().describe("where it came from, for provenance — e.g. 'granola', 'learning-suite'. Default 'handoff'."),
2184
+ 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.'),
2185
+ },
2186
+ },
2187
+ async ({ title, content, occurred_at, ends_at, identifiers, container_record_id, summary, source, brain }) => {
2188
+ let res
2189
+ try {
2190
+ res = await fetchCortex(`${BASE}/api/records/capture`, {
2191
+ method: 'POST',
2192
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
2193
+ body: JSON.stringify({
2194
+ title, content, occurredAt: occurred_at, endsAt: ends_at, identifiers,
2195
+ containerRecordId: container_record_id, summary, source, brain,
2196
+ }),
2197
+ })
2198
+ } catch (e) {
2199
+ return toolError(`Could not reach Agnoclast to capture "${title}": ${e.message}`)
2200
+ }
2201
+ const body = await res.json().catch(() => ({}))
2202
+ if (!res.ok || !body?.ok) {
2203
+ return toolError(`Could not capture "${title}": ${body?.error ?? res.status}${body?.detail ? ` — ${body.detail}` : ''}`)
2204
+ }
2205
+ const lines = [`Captured "${title}" as record ${body.id}.`]
2206
+ // ⚠ AN UNROUTED RECORD IS REPORTED AS SUCH. It was stored, but nothing will find it — reporting
2207
+ // that as a plain success is how a record becomes invisible while looking filed.
2208
+ if (body.routed_by?.length) lines.push(`Routes via: ${body.routed_by.join(', ')} — it reaches whatever page claims those.`)
2209
+ 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.')
2210
+ if (body.contained) lines.push('Placed inside the container you named.')
2211
+ return { content: [{ type: 'text', text: lines.join('\n') }] }
2212
+ },
2213
+ )
2214
+
2151
2215
  server.registerTool(
2152
2216
  'capture_meeting',
2153
2217
  {
@@ -2201,6 +2265,18 @@ function renderNudge(payload) {
2201
2265
  return { content: [{ type: 'text', text: lines.join('\n') }] }
2202
2266
  }
2203
2267
 
2268
+ // ⚠ TWO OUTCOMES, AND ONLY ONE HAS A RECORD ID. On an account with Gate 4 private intake on,
2269
+ // the transcript lands as a SEALED UNIT and the response carries `intakeItemId` instead of
2270
+ // `recordId` — rendering that as `Captured as record undefined` was what a real capture printed
2271
+ // on 2026-09-01. Held-for-review is an honest outcome and now says so, including the step the
2272
+ // reader has to take for the transcript to reach the meeting's obligation.
2273
+ if (out.via === 'private_intake') {
2274
+ lines.push(`Captured as a sealed intake unit ${out.intakeItemId} (scoped) — held for review.`)
2275
+ lines.push(` ${out.occurredAt}${out.endsAt ? ` \u2192 ${out.endsAt}` : ''}`)
2276
+ lines.push(' It already carries the meeting\'s attendee identifiers. Materialize it to file it')
2277
+ lines.push(' under its pages and offer it as evidence against that meeting\'s obligation.')
2278
+ return { content: [{ type: 'text', text: lines.join('\n') }] }
2279
+ }
2204
2280
  lines.push(`Captured as record ${out.recordId} (scoped).`)
2205
2281
  lines.push(` ${out.occurredAt}${out.endsAt ? ` \u2192 ${out.endsAt}` : ' \u2014 no end recorded'}`)
2206
2282
  if (out.extentDropped) lines.push(` \u26a0 the end was REFUSED (${out.extentDropped}) and stored as unknown`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.128",
3
+ "version": "0.9.130",
4
4
  "description": "Connect your AI assistant to Cortex — your org's projects, activity, gaps, and directives, scoped to you.",
5
5
  "type": "module",
6
6
  "bin": {