@theronap/cortex-mcp 0.9.131 → 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 +121 -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)
@@ -2167,6 +2185,74 @@ function renderNudge(payload) {
2167
2185
  },
2168
2186
  )
2169
2187
 
2188
+ server.registerTool(
2189
+ 'propose_containers',
2190
+ {
2191
+ title: 'Which event could this record belong to?',
2192
+ description: "Given a record, list the events (class sessions, meetings) it could have COME FROM — candidates only, nothing is written. Candidates are generated by time overlap and filtered by whether the container's type admits that kind of record; a CLOSED container still qualifies, because closing seals a candidate set but does not stop admitting (a transcript synced days later still belongs to the meeting it came from). ⚠ OVERLAP IS NOT AN ANSWER: a coding session that overlapped a meeting may be evidence you SKIPPED it. Ranked by how TIGHT the container is, ascending — a point in a 50-minute class is specific evidence, the same point in an all-day event is nearly none. Choose, then call contain_record.",
2193
+ inputSchema: {
2194
+ record_id: z.string().describe('the record to place'),
2195
+ limit: z.number().optional().describe('max candidates (default 10)'),
2196
+ },
2197
+ },
2198
+ async ({ record_id, limit }) => {
2199
+ const qs = new URLSearchParams(limit ? { limit: String(limit) } : {})
2200
+ let res
2201
+ try {
2202
+ res = await fetchCortex(`${BASE}/api/records/${encodeURIComponent(record_id)}/containers?${qs}`,
2203
+ { headers: { Authorization: `Bearer ${TOKEN}` } })
2204
+ } catch (e) {
2205
+ return toolError(`Could not reach Agnoclast to find containers for ${record_id}: ${e.message}`)
2206
+ }
2207
+ const body = await res.json().catch(() => ({}))
2208
+ if (!res.ok || !body?.ok) return toolError(`Could not propose containers: ${body?.error ?? res.status}`)
2209
+ if (!body.proposals?.length) {
2210
+ return { content: [{ type: 'text', text: `No container candidates for ${record_id}. Either nothing overlaps its time, or no overlapping event's type admits this kind of record.` }] }
2211
+ }
2212
+ const lines = [`Container candidates for ${record_id} (tightest first):`]
2213
+ for (const p of body.proposals) {
2214
+ lines.push(`- ${p.title ?? '(untitled)'} · ${p.containerType} · ${p.containerMinutes}min · starts ${p.startsAt}`
2215
+ + `\n id: ${p.containerRecordId}${p.late ? '\n LATE — this arrived after the container closed; still a valid home' : ''}`)
2216
+ }
2217
+ lines.push('', 'Nothing has been written. Use contain_record with the id you choose.')
2218
+ return { content: [{ type: 'text', text: lines.join('\n') }] }
2219
+ },
2220
+ )
2221
+
2222
+ server.registerTool(
2223
+ 'contain_record',
2224
+ {
2225
+ title: 'Place a record inside an event',
2226
+ description: "Put a record INSIDE an event — the transcript that came out of a meeting, the session that filled a work block. This is a DECISION, not an inference, and it is recorded as one (designated_by: human). ⚠ It only accepts a container that propose_containers actually offered for this record: naming an arbitrary id would skip the checks that keep containment inside one brain and inside the container type's admission set. `kind` defaults to 'produced' (it came OUT of that event); use 'context' for something that was merely going on at the time. Placing a record in an event is different from attaching it to a PAGE: pages are what a record is ABOUT, containers are where it CAME FROM.",
2227
+ inputSchema: {
2228
+ record_id: z.string().describe('the record to place'),
2229
+ container_record_id: z.string().describe('the event to place it in — an id from propose_containers or find_sessions'),
2230
+ kind: z.enum(['produced', 'context']).optional().describe("'produced' (default) = it came out of that event; 'context' = it was merely happening at the same time"),
2231
+ },
2232
+ },
2233
+ async ({ record_id, container_record_id, kind }) => {
2234
+ let res
2235
+ try {
2236
+ res = await fetchCortex(`${BASE}/api/records/${encodeURIComponent(record_id)}/containers`, {
2237
+ method: 'POST',
2238
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
2239
+ body: JSON.stringify({ containerRecordId: container_record_id, kind }),
2240
+ })
2241
+ } catch (e) {
2242
+ return toolError(`Could not reach Agnoclast to place ${record_id}: ${e.message}`)
2243
+ }
2244
+ const body = await res.json().catch(() => ({}))
2245
+ if (!res.ok || !body?.ok) {
2246
+ return toolError(`Could not place ${record_id}: ${body?.error ?? res.status}${body?.detail ? ` — ${body.detail}` : ''}`)
2247
+ }
2248
+ // `contained: false` means it was ALREADY in that container — a no-op, not a failure. Saying
2249
+ // "placed" either way would hide that a second call did nothing.
2250
+ return { content: [{ type: 'text', text: body.contained
2251
+ ? `Placed ${record_id} inside ${container_record_id} as '${kind ?? 'produced'}'.`
2252
+ : `${record_id} was already inside ${container_record_id} — nothing changed.` }] }
2253
+ },
2254
+ )
2255
+
2170
2256
  server.registerTool(
2171
2257
  'find_sessions',
2172
2258
  {
@@ -2208,10 +2294,11 @@ function renderNudge(payload) {
2208
2294
  'capture_record',
2209
2295
  {
2210
2296
  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.",
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.",
2212
2298
  inputSchema: {
2213
2299
  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.'),
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.'),
2215
2302
  occurred_at: z.string().describe('when the thing HAPPENED, ISO 8601 — not when you are uploading it'),
2216
2303
  ends_at: z.string().optional().describe('when it ended, ISO 8601. Omit if unknown; NEVER guess one.'),
2217
2304
  identifiers: z.array(z.string()).optional().describe('routing identifiers — how it reaches a page. e.g. ["series:geol-100-001-f2026"]'),
@@ -2221,14 +2308,34 @@ function renderNudge(payload) {
2221
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.'),
2222
2309
  },
2223
2310
  },
2224
- 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
+ }
2225
2332
  let res
2226
2333
  try {
2227
2334
  res = await fetchCortex(`${BASE}/api/records/capture`, {
2228
2335
  method: 'POST',
2229
2336
  headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
2230
2337
  body: JSON.stringify({
2231
- title, content, occurredAt: occurred_at, endsAt: ends_at, identifiers,
2338
+ title, content: text, occurredAt: occurred_at, endsAt: ends_at, identifiers,
2232
2339
  containerRecordId: container_record_id, summary, source, brain,
2233
2340
  }),
2234
2341
  })
@@ -2249,6 +2356,13 @@ function renderNudge(payload) {
2249
2356
  if (body.duplicate) l.push('This matched an existing unit rather than creating a new one.')
2250
2357
  if (body.routed_by?.length) l.push(`Carries: ${body.routed_by.join(', ')} — it will route on those once materialised.`)
2251
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
+ }
2252
2366
  return { content: [{ type: 'text', text: l.join('\n') }] }
2253
2367
  }
2254
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.131",
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": {