@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.
- package/lib/server.mjs +121 -7
- 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.
|
|
931
|
-
//
|
|
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
|
|
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