@theronap/cortex-mcp 0.9.89 → 0.9.90

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 +246 -49
  2. package/package.json +1 -1
package/lib/server.mjs CHANGED
@@ -192,62 +192,179 @@ export async function runServer(version) {
192
192
  'log_session',
193
193
  {
194
194
  title: 'Log this session to Agnoclast',
195
- description: 'Persist a CURATED summary of this work session as its durable Agnoclast record (authoritative — supersedes the auto-capture hook). Call at session close after composing the summary. Pass sessionId (the Claude Code session id) if you have it so this dedupes with the auto-capture of the same session. If you belong to more than one brain, pass `brain` — without it a session log has no route and is STAGED rather than recorded.',
195
+ description: 'Persist a CURATED summary of this work session as its durable Agnoclast record (authoritative — supersedes the auto-capture hook). Call at session close after composing the summary. Pass sessionId (the Claude Code session id) so this dedupes with the auto-capture of the same session. If you belong to more than one brain you MUST name one — without a brain a session log has no route and is STAGED rather than recorded. **If the session touched work belonging to different brains, pass `segments` instead of `summary` and split it** — one segment per brain, each summary standing on its own and never alluding to the others. Reports every segment individually; a partial result is reported as PARTIAL, never as success.',
196
196
  inputSchema: {
197
- summary: z.string().describe('the curated session summary (what was done, decided, left open) becomes the durable record'),
197
+ summary: z.string().optional().describe('the curated session summary the single-brain form. Omit when passing `segments`'),
198
+ segments: z.array(z.object({
199
+ brain: z.string().describe('destination brain for this half of the session (name or org id)'),
200
+ summary: z.string().describe('a summary that stands ON ITS OWN. It must not mention, allude to, or imply that other segments exist — "the rest of this session covered personal projects" leaks in prose exactly what splitting was meant to contain'),
201
+ title: z.string().optional(),
202
+ project: z.string().optional(),
203
+ })).optional().describe('SPLIT the log, one entry per brain. Use whenever a session touched work belonging to different brains: a session is a container of time, not a topic, and every single-brain answer is wrong — filing it all in the org brain exposes personal work to colleagues, filing it all in the personal one denies the org its record, and summarizing half silently drops the other half. Max ONE segment per brain (they share this session\'s dedupe key, so two aimed at the same brain would overwrite each other). When a chunk is ambiguous, put it in the MORE PRIVATE brain — a misfile there is private, a misfile the other way is visible to everyone in the org.'),
198
204
  project: z.string().optional().describe('project key/name this session worked in'),
199
205
  title: z.string().optional().describe('short title for the session'),
200
- sessionId: z.string().optional().describe('the Claude Code session id (dedupes with the auto-capture hook of the same session)'),
206
+ sessionId: z.string().optional().describe('the Claude Code session id shared by every segment, and the only thing pairing them. Deliberately NOT a link: segments never reference each other, so a reader cleared for one brain cannot tell the others exist, while you can join on it across brains'),
201
207
  brain: z.string().optional().describe('which brain to record this session in (name or org id, one of your own). REQUIRED IN EFFECT for a multi-brain member: session-class sources route only by an explicit brain or a sole membership, so omitting it stages the log instead of recording it.'),
202
208
  privacy: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('tier this record AT WRITE TIME. Use when the summary names confidential work (a candidate evaluation, a security finding) — safer than letting it land org-visible and re-tiering after, which leaves it readable in between.'),
203
209
  },
204
210
  },
205
- async ({ summary, project, title, sessionId, brain, privacy }) => {
206
- const res = await fetchCortex(`${BASE}/api/ingest`, {
207
- method: 'POST',
208
- headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
209
- body: JSON.stringify({
210
- source: 'claude-code',
211
- captureSource: 'skill',
212
- summary,
213
- ...(project ? { project } : {}),
214
- ...(title ? { title } : {}),
215
- ...(sessionId ? { sessionId } : {}),
216
- // ADR-0022 deleted the write pointer, so a session-class source routes ONLY by an explicit
217
- // brain or a sole membershipanything else STAGES. This tool never sent one, so every
218
- // close-out from a multi-brain member landed in staged_records instead of the org. Measured
219
- // 2026-08-09: 86 staged rows, and the pile is not drainable by /api/staged/promote because
220
- // promote resolves through the same branch session-class sources skip.
221
- ...(brain ? { brain } : {}),
222
- ...(privacy ? { privacy } : {}),
223
- payload: { via: 'log_session' },
224
- }),
225
- })
226
- if (!res.ok) {
227
- const body = await res.text()
228
- throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
211
+ async ({ summary, segments, project, title, sessionId, brain, privacy }) => {
212
+ // A session is a container of TIME, not a topic (ADR-0029 step 4). Normalize to a list of
213
+ // segments; the single-brain call is just a one-segment list.
214
+ const list = Array.isArray(segments) && segments.length
215
+ ? segments.map((s) => ({ brain: s.brain, summary: s.summary, title: s.title ?? title, project: s.project ?? project }))
216
+ : (summary ? [{ brain, summary, title, project }] : null)
217
+ if (!list) {
218
+ return toolError('Pass `summary` (single brain) or a non-empty `segments` array (one entry per brain).')
219
+ }
220
+
221
+ // Records are unique on the ORG-SCOPED (org_id, dedupe_key) and every segment carries this
222
+ // session's dedupe key. Cross-brain segments therefore never collide that constraint is what
223
+ // makes the whole design workbut two aimed at the SAME brain would silently merge and lose
224
+ // one. Refuse instead of letting that happen quietly.
225
+ const seenBrain = new Set()
226
+ for (const s of list) {
227
+ const k = String(s.brain ?? '').trim().toLowerCase()
228
+ if (seenBrain.has(k)) {
229
+ return toolError(`Two segments target the same brain (${s.brain}). They share this session's dedupe key, so the second would overwrite the first — merge them into one segment.`)
230
+ }
231
+ seenBrain.add(k)
232
+ }
233
+
234
+ let brainIndex = null
235
+ const orgIdFor = async (nameOrId) => {
236
+ if (!nameOrId) return null
237
+ if (!brainIndex) {
238
+ try {
239
+ const r = await fetchCortex(`${BASE}/api/brains`, { headers: { Authorization: `Bearer ${TOKEN}` } })
240
+ brainIndex = r.ok ? ((await r.json().catch(() => ({}))).brains ?? []) : []
241
+ } catch {
242
+ brainIndex = []
243
+ }
244
+ }
245
+ const q = String(nameOrId).trim().toLowerCase()
246
+ const hit = brainIndex.find(
247
+ (b) => String(b.orgId ?? '').toLowerCase() === q || String(b.name ?? '').toLowerCase() === q,
248
+ )
249
+ return hit?.orgId ?? null
229
250
  }
230
- const j = await res.json().catch(() => ({}))
231
251
 
232
- // NEVER report a non-record as "logged". `staged` and `skipped` are 200 OK responses that wrote
233
- // no record, and this line used to print "Logged to Agnoclast (authoritative) … updated existing"
234
- // for both — `j.inserted` is merely falsy on a staged write, which reads as an UPDATE. That is
235
- // how a session close-out was reported as saved when it had not been (2026-08-09), and it is the
236
- // same failure shape the cortex-log skill warns about for `author`: a success string over a write
237
- // that did not land. The caller cannot tell the difference, so the message has to.
238
- if (j.staged) {
239
- const why = j.reason === 'no_route_for_source' && !brain
240
- ? 'no brain was named and you belong to more than one, so it had nowhere to route'
241
- : `reason: ${j.reason ?? 'unknown'}`
242
- return { content: [{ type: 'text', text: `NOT LOGGED — STAGED, not recorded (${why}). Re-run log_session with brain:"<name>" to record it. Staged session logs cannot currently be drained by /api/staged/promote.` }] }
243
- }
244
- if (j.skipped) {
245
- return { content: [{ type: 'text', text: `NOT LOGGED — the server skipped this write: ${j.skipped}` }] }
246
- }
247
- if (!j.id) {
248
- return { content: [{ type: 'text', text: `NOT LOGGED — the server returned no record id. Raw response: ${JSON.stringify(j).slice(0, 300)}` }] }
252
+ const lines = []
253
+ let failures = 0
254
+
255
+ for (const seg of list) {
256
+ const where = seg.brain ?? '(no brain named)'
257
+ let res
258
+ try {
259
+ res = await fetchCortex(`${BASE}/api/ingest`, {
260
+ method: 'POST',
261
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
262
+ body: JSON.stringify({
263
+ source: 'claude-code',
264
+ captureSource: 'skill',
265
+ summary: seg.summary,
266
+ ...(seg.project ? { project: seg.project } : {}),
267
+ ...(seg.title ? { title: seg.title } : {}),
268
+ ...(sessionId ? { sessionId } : {}),
269
+ // ADR-0022 deleted the write pointer, so a session-class source routes ONLY by an
270
+ // explicit brain or a sole membership — anything else STAGES. This tool never sent one,
271
+ // so every close-out from a multi-brain member landed in staged_records instead of the
272
+ // org. Measured 2026-08-09: 86 staged rows, not drainable by /api/staged/promote.
273
+ ...(seg.brain ? { brain: seg.brain } : {}),
274
+ ...(privacy ? { privacy } : {}),
275
+ payload: { via: 'log_session' },
276
+ }),
277
+ })
278
+ } catch (e) {
279
+ failures++
280
+ lines.push(`✗ ${where}: ${e.message}`)
281
+ continue
282
+ }
283
+ if (!res.ok) {
284
+ const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
285
+ failures++
286
+ lines.push(`✗ ${where}: ${d.message}`)
287
+ continue
288
+ }
289
+ const j = await res.json().catch(() => ({}))
290
+
291
+ // NEVER report a non-record as "logged". `staged` and `skipped` are 200 OK responses that
292
+ // wrote no record, and this used to print "Logged … updated existing" for both, because
293
+ // `j.inserted` is merely falsy on a staged write.
294
+ if (j.staged) {
295
+ const why = j.reason === 'no_route_for_source' && !seg.brain
296
+ ? 'no brain named and you belong to more than one, so it had nowhere to route'
297
+ : `reason: ${j.reason ?? 'unknown'}`
298
+ failures++
299
+ lines.push(`✗ ${where}: NOT LOGGED — staged, not recorded (${why}). Re-run this segment with brain:"<name>" — staged session logs cannot be drained by /api/staged/promote.`)
300
+ continue
301
+ }
302
+ if (j.skipped) {
303
+ failures++
304
+ lines.push(`✗ ${where}: NOT LOGGED — server skipped the write: ${j.skipped}`)
305
+ continue
306
+ }
307
+
308
+ // Gate 4 routes a session log into PRIVATE INTAKE rather than straight to records, answering
309
+ // `via:"private_intake"` with an intakeItemId and no id. The previous check fell through to
310
+ // "no record id" and reported NOT LOGGED over a write that had landed — a false negative that
311
+ // drives retries, and retries against ingest make duplicates.
312
+ //
313
+ // Materializing here is not an optimization. Close-out is the ONLY moment the destination
314
+ // brain is known; a unit left in intake goes cleanup-due in a day and becomes an item no later
315
+ // session has the authority to route, because deciding which brain half of someone's session
316
+ // belongs in is exactly the judgment a stranger cannot make.
317
+ if (!j.id && j.via === 'private_intake' && j.intakeItemId) {
318
+ const orgId = await orgIdFor(seg.brain)
319
+ if (!orgId) {
320
+ failures++
321
+ lines.push(`✗ ${where}: captured to intake as ${j.intakeItemId}, but that brain did not resolve to an org id — materialize it by hand`)
322
+ continue
323
+ }
324
+ let m
325
+ try {
326
+ m = await fetchCortex(`${BASE}/api/intake/materialize`, {
327
+ method: 'POST',
328
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
329
+ body: JSON.stringify({
330
+ intakeItemId: j.intakeItemId,
331
+ orgId,
332
+ title: seg.title,
333
+ summary: seg.summary,
334
+ source: 'claude-code',
335
+ recordType: 'ai_session',
336
+ origin: 'session',
337
+ }),
338
+ })
339
+ } catch (e) {
340
+ failures++
341
+ lines.push(`✗ ${where}: intake ${j.intakeItemId} NOT materialized — ${e.message}`)
342
+ continue
343
+ }
344
+ const mj = await m.json().catch(() => ({}))
345
+ if (!m.ok || !mj.recordId) {
346
+ failures++
347
+ lines.push(`✗ ${where}: intake ${j.intakeItemId} NOT materialized — ${mj.error ?? m.status}`)
348
+ continue
349
+ }
350
+ lines.push(`✓ ${where}: record ${mj.recordId}`)
351
+ continue
352
+ }
353
+
354
+ if (!j.id) {
355
+ failures++
356
+ lines.push(`✗ ${where}: NOT LOGGED — no record id. Raw: ${JSON.stringify(j).slice(0, 200)}`)
357
+ continue
358
+ }
359
+ lines.push(`✓ ${where}: record ${j.id}${j.inserted ? '' : ' (updated existing)'}`)
249
360
  }
250
- return { content: [{ type: 'text', text: `Logged to Agnoclast (authoritative): "${j.title ?? title ?? 'session'}" — ${j.inserted ? 'new record' : 'updated existing'} (id ${j.id}).` }] }
361
+
362
+ // Partial success is a real outcome once there is more than one segment, and "3 of 4 landed"
363
+ // must never read as done — that is the same silent-loss shape this whole path was repaired for.
364
+ const head = failures === 0
365
+ ? `Logged ${lines.length} segment${lines.length === 1 ? '' : 's'}.`
366
+ : `PARTIAL — ${lines.length - failures} of ${lines.length} segments logged, ${failures} FAILED. A partly-logged session is not a logged session; re-run the failed segments.`
367
+ return { content: [{ type: 'text', text: `${head}\n${lines.join('\n')}` }] }
251
368
  },
252
369
  )
253
370
 
@@ -485,7 +602,7 @@ export async function runServer(version) {
485
602
  source: z.string().optional(),
486
603
  recordType: z.string().optional(),
487
604
  dedupeKey: z.string().optional(),
488
- origin: z.enum(['deterministic', 'llm', 'user']).optional(),
605
+ origin: z.enum(['deterministic', 'llm', 'user', 'session']).optional(),
489
606
  },
490
607
  },
491
608
  async (args) => {
@@ -553,11 +670,89 @@ export async function runServer(version) {
553
670
  },
554
671
  )
555
672
 
673
+ server.registerTool(
674
+ 'intake_defer',
675
+ {
676
+ title: 'Defer a private intake item to the owner',
677
+ description:
678
+ 'The THIRD terminal outcome, and the right one whenever the honest answer is "this is not mine to decide." Use it when a claimed unit is someone else\'s private content, when the destination brain is a real judgment call rather than a lookup, or when publishing and destroying are both wrong — a stranger\'s message thread, a photo you cannot place, an email whose brain depends on context only the owner has. Destroys NOTHING: the unit stays encrypted and intact, its state becomes `awaiting_user`, and your `question` is what the owner actually sees. Prefer this over letting a lease lapse — a lapsed lease says nothing, increments `attempts`, and hands the identical dead end to the next session. Requires the claim, one item per call.',
679
+ inputSchema: {
680
+ intakeItemId: z.string().describe('intake item uuid from intake_claim'),
681
+ question: z.string().describe('what you need the owner to decide, in their words not yours — this is the entire message they get, so "which brain should this iMessage thread go to, if any?" beats "needs triage"'),
682
+ },
683
+ },
684
+ async ({ intakeItemId, question }) => {
685
+ let res
686
+ try {
687
+ res = await fetchCortex(`${BASE}/api/intake/defer`, {
688
+ method: 'POST',
689
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
690
+ body: JSON.stringify({ intakeItemId, question }),
691
+ })
692
+ } catch (e) {
693
+ return toolError(`Could not defer: ${e.message}`)
694
+ }
695
+ const out = await res.json().catch(() => null)
696
+ if (!res.ok) {
697
+ if (out?.error === 'not_claimed') {
698
+ return toolError('You do not hold a claim on that item — intake_claim it first, so the question follows from having read it.')
699
+ }
700
+ if (out?.error === 'already_terminal') {
701
+ return toolError(`Nothing left to ask about: ${out?.detail ?? 'the unit is already materialized or discarded'}.`)
702
+ }
703
+ return toolError(`Could not defer: ${out?.error ?? res.status}${out?.detail ? ` — ${out.detail}` : ''}`)
704
+ }
705
+ if (out?.alreadyDeferred) {
706
+ return { content: [{ type: 'text', text: 'Already awaiting the owner — nothing to do.' }] }
707
+ }
708
+ return { content: [{ type: 'text', text: 'Deferred to the owner. Content preserved, question queued in intake_cleanup_status.openQuestions, and the unit no longer gates cleanup.' }] }
709
+ },
710
+ )
711
+
712
+ server.registerTool(
713
+ 'intake_release',
714
+ {
715
+ title: 'Hand an intake claim back',
716
+ description:
717
+ 'Give a claimed unit back to the queue WITHOUT deciding anything. This is not a fourth outcome — materialize, discard and defer resolve a unit; release just ends your hold on it. Use it when you claimed more than you can act on, or when the unit turns out to belong to work you are not doing. Prefer it over letting the lease expire: a lapse and a crash are indistinguishable in the ledger, so silently timing out costs the pile the one signal that says a session looked and chose to pass. Use intake_defer instead when the unit needs the OWNER to decide — release puts it back in front of the next session, which will hit whatever wall you did.',
718
+ inputSchema: {
719
+ intakeItemId: z.string().describe('intake item uuid from intake_claim'),
720
+ reason: z.string().describe('why you are handing it back — "claimed too broadly", "not related to this session\'s work". Recorded on the claim, and the only thing distinguishing this from a lapsed lease'),
721
+ },
722
+ },
723
+ async ({ intakeItemId, reason }) => {
724
+ let res
725
+ try {
726
+ res = await fetchCortex(`${BASE}/api/intake/release`, {
727
+ method: 'POST',
728
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
729
+ body: JSON.stringify({ intakeItemId, reason }),
730
+ })
731
+ } catch (e) {
732
+ return toolError(`Could not release: ${e.message}`)
733
+ }
734
+ const out = await res.json().catch(() => null)
735
+ if (!res.ok) {
736
+ if (out?.error === 'not_claimed') {
737
+ return toolError('You do not hold a claim on that item — there is nothing to hand back.')
738
+ }
739
+ if (out?.error === 'awaiting_user') {
740
+ return toolError('That unit is already deferred to the owner. Releasing it would orphan the open question — leave it, or have the owner answer it.')
741
+ }
742
+ if (out?.error === 'already_terminal') {
743
+ return toolError(`Nothing to release: ${out?.detail ?? 'the unit is already materialized or discarded'}.`)
744
+ }
745
+ return toolError(`Could not release: ${out?.error ?? res.status}${out?.detail ? ` — ${out.detail}` : ''}`)
746
+ }
747
+ return { content: [{ type: 'text', text: 'Released. The unit is back in the queue as pending, and your lease is gone.' }] }
748
+ },
749
+ )
750
+
556
751
  server.registerTool(
557
752
  'intake_cleanup_status',
558
753
  {
559
754
  title: 'Private intake cleanup status',
560
- description: 'Counts of pending/claimed/awaiting/cleanup-due intake items plus open clarifying questions for the owner.',
755
+ description: 'Counts of pending/claimed/awaiting/cleanup-due intake items, open clarifying questions for the owner, and `needsAttention` — units that keep coming back, with attempts / releases / deferrals / lapses each broken out. A high `lapses` means sessions claimed that unit and neither resolved it nor handed it back; a `deferrals` above 1 means the owner has already been asked more than once. Both are the OWNER\'s signal to act on, not yours — surface them rather than trying to clear them yourself.',
561
756
  inputSchema: {},
562
757
  },
563
758
  async () => {
@@ -1517,14 +1712,16 @@ export async function runServer(version) {
1517
1712
  project: z.string().optional().describe('project key to filter to (e.g. "cortex")'),
1518
1713
  since_days: z.number().optional().describe('only records from the last N days'),
1519
1714
  limit: z.number().optional().describe('max rows (1-50, default 20)'),
1715
+ session: z.string().optional().describe('a session id — returns every record that session produced, INCLUDING the separate halves of a log that was split across brains. Segmented logs share a sessionId and never link to each other, so this is the only way to reassemble one. RLS-scoped: you get the halves you are cleared for and cannot tell whether others exist'),
1520
1716
  },
1521
1717
  },
1522
- async ({ type, project, since_days, limit }) => {
1718
+ async ({ type, project, since_days, limit, session }) => {
1523
1719
  const qs = new URLSearchParams()
1524
1720
  if (type) qs.set('type', type)
1525
1721
  if (project) qs.set('project', project)
1526
1722
  if (since_days != null) qs.set('since_days', String(since_days))
1527
1723
  if (limit != null) qs.set('limit', String(limit))
1724
+ if (session) qs.set('session', session)
1528
1725
  let res
1529
1726
  try {
1530
1727
  res = await fetchCortex(`${BASE}/api/records?${qs}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.89",
3
+ "version": "0.9.90",
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": {