@theronap/cortex-mcp 0.9.132 → 0.9.133

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 +53 -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)
@@ -2276,10 +2294,11 @@ function renderNudge(payload) {
2276
2294
  'capture_record',
2277
2295
  {
2278
2296
  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.",
2297
+ 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
2298
  inputSchema: {
2281
2299
  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.'),
2300
+ content: z.string().optional().describe('the actual content, inline. For anything long prefer content_path — see below.'),
2301
+ 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
2302
  occurred_at: z.string().describe('when the thing HAPPENED, ISO 8601 — not when you are uploading it'),
2284
2303
  ends_at: z.string().optional().describe('when it ended, ISO 8601. Omit if unknown; NEVER guess one.'),
2285
2304
  identifiers: z.array(z.string()).optional().describe('routing identifiers — how it reaches a page. e.g. ["series:geol-100-001-f2026"]'),
@@ -2289,14 +2308,34 @@ function renderNudge(payload) {
2289
2308
  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
2309
  },
2291
2310
  },
2292
- async ({ title, content, occurred_at, ends_at, identifiers, container_record_id, summary, source, brain }) => {
2311
+ async ({ title, content, content_path, occurred_at, ends_at, identifiers, container_record_id, summary, source, brain }) => {
2312
+ // 🔴 A FILE IS READ HERE RATHER THAN RETYPED UPSTREAM, AND THAT IS A FIDELITY GUARANTEE,
2313
+ // NOT A CONVENIENCE. Passing a 39,000-character lecture transcript through `content` means the
2314
+ // caller has to emit every character of it, and a model reproducing a long document verbatim is
2315
+ // likely-correct, never certain-correct. Measured 2026-09-03: the first real transcript captured
2316
+ // this way had to carry a note in its own body saying it was re-entered and unverified. Reading
2317
+ // the file makes byte-identity structural instead of a matter of care.
2318
+ if ((content == null || content === '') === (content_path == null || content_path === '')) {
2319
+ return toolError('Pass exactly one of content or content_path.')
2320
+ }
2321
+ let text = content
2322
+ if (content_path) {
2323
+ try {
2324
+ const st = statSync(content_path)
2325
+ if (!st.isFile()) return toolError(`Not a file: ${content_path}`)
2326
+ text = readFileSync(content_path, 'utf8')
2327
+ } catch (e) {
2328
+ return toolError(`Could not read ${content_path}: ${e.message}`)
2329
+ }
2330
+ if (!text.trim()) return toolError(`${content_path} is empty — nothing to capture.`)
2331
+ }
2293
2332
  let res
2294
2333
  try {
2295
2334
  res = await fetchCortex(`${BASE}/api/records/capture`, {
2296
2335
  method: 'POST',
2297
2336
  headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
2298
2337
  body: JSON.stringify({
2299
- title, content, occurredAt: occurred_at, endsAt: ends_at, identifiers,
2338
+ title, content: text, occurredAt: occurred_at, endsAt: ends_at, identifiers,
2300
2339
  containerRecordId: container_record_id, summary, source, brain,
2301
2340
  }),
2302
2341
  })
@@ -2317,6 +2356,13 @@ function renderNudge(payload) {
2317
2356
  if (body.duplicate) l.push('This matched an existing unit rather than creating a new one.')
2318
2357
  if (body.routed_by?.length) l.push(`Carries: ${body.routed_by.join(', ')} — it will route on those once materialised.`)
2319
2358
  else l.push('⚠ NO IDENTIFIERS — once materialised it will reach no page.')
2359
+ // ⚠ A CONTAINER THE CALLER ASKED FOR MUST NOT VANISH FROM THE REPORT. It cannot be applied
2360
+ // yet — there is no record — but it IS carried in the sealed payload and applied at
2361
+ // materialisation. Saying nothing here is what made a caller re-derive a placement they had
2362
+ // already specified (observed 2026-09-03).
2363
+ if (body.containerDeferred) {
2364
+ l.push(`Container ${body.containerRecordId} is carried with it and applied when you materialise — you do not need to place it again.`)
2365
+ }
2320
2366
  return { content: [{ type: 'text', text: l.join('\n') }] }
2321
2367
  }
2322
2368
  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.133",
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": {