@theronap/cortex-mcp 0.9.132 → 0.9.134

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.
Files changed (2) hide show
  1. package/lib/server.mjs +92 -7
  2. package/package.json +1 -1
package/lib/server.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
3
3
  import { z } from 'zod'
4
- import { writeFileSync, mkdirSync } from 'fs'
4
+ import { writeFileSync, mkdirSync, readFileSync, statSync } from 'fs'
5
5
  import { homedir } from 'os'
6
6
  import { join } from 'path'
7
7
  import { createHash, randomUUID } from 'crypto'
@@ -927,8 +927,13 @@ function renderNudge(payload) {
927
927
  // attached: [...] filed under N pages
928
928
  //
929
929
  // Reporting only a count would make the first indistinguishable from the second, which is how
930
- // a broken write hides inside a normal-looking result. `undefined` means the server has not
931
- // shipped this field yet, so nothing is said rather than something wrong.
930
+ // a broken write hides inside a normal-looking result.
931
+ //
932
+ // ⚠ AND `undefined` IS A FOURTH STATE: NOT ATTEMPTED. Session-page attachment only runs for
933
+ // claude-code sources — a handoff, an email, a calendar event has no session to join on. The
934
+ // server used to send null in that case, having never tried, and this reported a FAILURE for
935
+ // the ordinary case (observed 2026-09-03 on the first handoff record materialised). Silence is
936
+ // correct here, and it also covers a server too old to send the field at all.
932
937
  const lines = []
933
938
  const sa = body?.sessionAttachments
934
939
  if (sa === null) {
@@ -943,6 +948,19 @@ function renderNudge(payload) {
943
948
  lines.push(`${sa.droppedCrossBrain} page(s) this session wrote live in another brain and were skipped — a record belongs to one brain.`)
944
949
  }
945
950
  }
951
+ // Containment carried from capture. Three states, and they are not interchangeable:
952
+ // true placed in the container the capture named
953
+ // false the edge already existed — a re-materialise, not a failure
954
+ // null/undefined no container was ever requested (say nothing)
955
+ // containerSkipped is the only one that needs a warning: it means a container WAS asked for and
956
+ // did not happen, which is exactly the silence this whole path exists to remove.
957
+ if (body?.containerSkipped) {
958
+ lines.push(`⚠ CONTAINER NOT APPLIED — ${body.containerSkipped}. The record exists but is not inside the event you named; place it with contain_record or leave it out.`)
959
+ } else if (body?.contained === true) {
960
+ lines.push('Placed inside the container named at capture.')
961
+ } else if (body?.contained === false) {
962
+ lines.push('Already inside the container named at capture (no change).')
963
+ }
946
964
  const text = lines.length
947
965
  ? `${lines.join('\n')}\n\n${JSON.stringify(body, null, 2)}`
948
966
  : JSON.stringify(body, null, 2)
@@ -2235,6 +2253,45 @@ function renderNudge(payload) {
2235
2253
  },
2236
2254
  )
2237
2255
 
2256
+ server.registerTool(
2257
+ 'supersede_record',
2258
+ {
2259
+ title: 'Mark a record as no longer the answer',
2260
+ description: "Say that a record is wrong, outdated or replaced — WITHOUT deleting it. Use it when you re-capture something properly and the old copy should stop showing up: a transcript filed with a bad occurred_at, a duplicate capture, a low-fidelity copy replaced by a content_path one. \u26a0 NOTHING IS DESTROYED AND THIS IS REVERSIBLE (`restore: true`). The row, its page attachments and its containment all stay exactly as they were; only the READS change — it disappears from its pages, from its container's contents, and from the \"holds N\" count on find_sessions. It stays SEARCHABLE on purpose: hiding a record from search would be most of the way to deleting it without the audit trail. \u26a0 THERE IS NO DELETE, DELIBERATELY (ADR-0049) — 13 of the 15 foreign keys to a record CASCADE, so a real delete would unfile everything inside a container, drop clearance anchors and rewrite the claim ledger, while still leaving revision rows behind. If you actually need content destroyed rather than hidden, say so to the user rather than reaching for this. \u26a0 REFUSED ON A CONTAINER (a class session, a meeting): hiding an event that still holds other records leaves their placements pointing at nothing. Supersede the records INSIDE it instead.",
2261
+ inputSchema: {
2262
+ record_id: z.string().describe('the record that is no longer the answer'),
2263
+ reason: z.string().optional().describe('WHY it is no longer right — required unless restoring, and the one thing that cannot be inferred later'),
2264
+ superseded_by: z.string().optional().describe('the record that REPLACES it, if there is one. Must be in the same brain. Optional: "this is wrong" is a complete statement on its own.'),
2265
+ restore: z.boolean().optional().describe('true = undo a previous supersede and make it current again'),
2266
+ },
2267
+ },
2268
+ async ({ record_id, reason, superseded_by, restore }) => {
2269
+ let res
2270
+ try {
2271
+ res = await fetchCortex(`${BASE}/api/records/supersede`, {
2272
+ method: 'POST',
2273
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
2274
+ body: JSON.stringify({ recordId: record_id, reason, supersededBy: superseded_by, restore }),
2275
+ })
2276
+ } catch (e) {
2277
+ return toolError(`Could not reach Agnoclast: ${e.message}`)
2278
+ }
2279
+ const body = await res.json().catch(() => ({}))
2280
+ if (!res.ok || !body?.ok) {
2281
+ return toolError(`Could not supersede ${record_id}: ${body?.error ?? res.status}${body?.detail ? ` \u2014 ${body.detail}` : ''}`)
2282
+ }
2283
+ if (restore) {
2284
+ return { content: [{ type: 'text', text: `Restored ${record_id} — it is a current record again and reappears on its pages and in its container.` }] }
2285
+ }
2286
+ const l = [`Superseded ${record_id}.`]
2287
+ if (body.supersededBy) l.push(`Replaced by ${body.supersededBy}.`)
2288
+ // \u26a0 SAY WHAT DID NOT HAPPEN. The caller's next question is always whether this deleted
2289
+ // something. It did not, and a reader who assumes it did will not think to restore.
2290
+ l.push('Nothing was deleted — the row, its page attachments and its containment are intact. It is hidden from its pages, its container contents and session holds-counts, and stays searchable. `restore: true` reverses this.')
2291
+ return { content: [{ type: 'text', text: l.join('\n') }] }
2292
+ },
2293
+ )
2294
+
2238
2295
  server.registerTool(
2239
2296
  'find_sessions',
2240
2297
  {
@@ -2276,10 +2333,11 @@ function renderNudge(payload) {
2276
2333
  'capture_record',
2277
2334
  {
2278
2335
  title: 'Hand something over and get it into the brain',
2279
- 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.",
2336
+ 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. \u26a0 PASS `content_path` FOR ANYTHING LONG: a file is read and sent verbatim, whereas retyping a transcript into `content` routes every character through a model that is not guaranteed to reproduce it exactly. Use `content` only for something short you are composing yourself.",
2280
2337
  inputSchema: {
2281
2338
  title: z.string().describe('a short name for this record'),
2282
- content: z.string().describe('the actual content transcript, notes, document text. Summarized server-side.'),
2339
+ content: z.string().optional().describe('the actual content, inline. For anything long prefer content_path — see below.'),
2340
+ content_path: z.string().optional().describe('ABSOLUTE PATH to a file whose contents become the record, read and sent verbatim. USE THIS FOR TRANSCRIPTS AND DOCUMENTS: it is the only way the stored text is guaranteed byte-identical to the source. Exactly one of content / content_path.'),
2283
2341
  occurred_at: z.string().describe('when the thing HAPPENED, ISO 8601 — not when you are uploading it'),
2284
2342
  ends_at: z.string().optional().describe('when it ended, ISO 8601. Omit if unknown; NEVER guess one.'),
2285
2343
  identifiers: z.array(z.string()).optional().describe('routing identifiers — how it reaches a page. e.g. ["series:geol-100-001-f2026"]'),
@@ -2289,14 +2347,34 @@ function renderNudge(payload) {
2289
2347
  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.'),
2290
2348
  },
2291
2349
  },
2292
- async ({ title, content, occurred_at, ends_at, identifiers, container_record_id, summary, source, brain }) => {
2350
+ async ({ title, content, content_path, occurred_at, ends_at, identifiers, container_record_id, summary, source, brain }) => {
2351
+ // 🔴 A FILE IS READ HERE RATHER THAN RETYPED UPSTREAM, AND THAT IS A FIDELITY GUARANTEE,
2352
+ // NOT A CONVENIENCE. Passing a 39,000-character lecture transcript through `content` means the
2353
+ // caller has to emit every character of it, and a model reproducing a long document verbatim is
2354
+ // likely-correct, never certain-correct. Measured 2026-09-03: the first real transcript captured
2355
+ // this way had to carry a note in its own body saying it was re-entered and unverified. Reading
2356
+ // the file makes byte-identity structural instead of a matter of care.
2357
+ if ((content == null || content === '') === (content_path == null || content_path === '')) {
2358
+ return toolError('Pass exactly one of content or content_path.')
2359
+ }
2360
+ let text = content
2361
+ if (content_path) {
2362
+ try {
2363
+ const st = statSync(content_path)
2364
+ if (!st.isFile()) return toolError(`Not a file: ${content_path}`)
2365
+ text = readFileSync(content_path, 'utf8')
2366
+ } catch (e) {
2367
+ return toolError(`Could not read ${content_path}: ${e.message}`)
2368
+ }
2369
+ if (!text.trim()) return toolError(`${content_path} is empty — nothing to capture.`)
2370
+ }
2293
2371
  let res
2294
2372
  try {
2295
2373
  res = await fetchCortex(`${BASE}/api/records/capture`, {
2296
2374
  method: 'POST',
2297
2375
  headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
2298
2376
  body: JSON.stringify({
2299
- title, content, occurredAt: occurred_at, endsAt: ends_at, identifiers,
2377
+ title, content: text, occurredAt: occurred_at, endsAt: ends_at, identifiers,
2300
2378
  containerRecordId: container_record_id, summary, source, brain,
2301
2379
  }),
2302
2380
  })
@@ -2317,6 +2395,13 @@ function renderNudge(payload) {
2317
2395
  if (body.duplicate) l.push('This matched an existing unit rather than creating a new one.')
2318
2396
  if (body.routed_by?.length) l.push(`Carries: ${body.routed_by.join(', ')} — it will route on those once materialised.`)
2319
2397
  else l.push('⚠ NO IDENTIFIERS — once materialised it will reach no page.')
2398
+ // ⚠ A CONTAINER THE CALLER ASKED FOR MUST NOT VANISH FROM THE REPORT. It cannot be applied
2399
+ // yet — there is no record — but it IS carried in the sealed payload and applied at
2400
+ // materialisation. Saying nothing here is what made a caller re-derive a placement they had
2401
+ // already specified (observed 2026-09-03).
2402
+ if (body.containerDeferred) {
2403
+ l.push(`Container ${body.containerRecordId} is carried with it and applied when you materialise — you do not need to place it again.`)
2404
+ }
2320
2405
  return { content: [{ type: 'text', text: l.join('\n') }] }
2321
2406
  }
2322
2407
  const lines = [`Captured "${title}" as record ${body.id}.`]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.132",
3
+ "version": "0.9.134",
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": {