@theronap/cortex-mcp 0.9.88 → 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.
package/lib/server.mjs CHANGED
@@ -192,34 +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.',
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'),
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.'),
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.'),
201
209
  },
202
210
  },
203
- async ({ summary, project, title, sessionId }) => {
204
- const res = await fetchCortex(`${BASE}/api/ingest`, {
205
- method: 'POST',
206
- headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
207
- body: JSON.stringify({
208
- source: 'claude-code',
209
- captureSource: 'skill',
210
- summary,
211
- ...(project ? { project } : {}),
212
- ...(title ? { title } : {}),
213
- ...(sessionId ? { sessionId } : {}),
214
- payload: { via: 'log_session' },
215
- }),
216
- })
217
- if (!res.ok) {
218
- const body = await res.text()
219
- 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 work — but 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
220
250
  }
221
- const j = await res.json().catch(() => ({}))
222
- return { content: [{ type: 'text', text: `Logged to Agnoclast (authoritative): "${j.title ?? title ?? 'session'}" — ${j.inserted ? 'new record' : 'updated existing'}.` }] }
251
+
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)'}`)
360
+ }
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')}` }] }
223
368
  },
224
369
  )
225
370
 
@@ -457,7 +602,7 @@ export async function runServer(version) {
457
602
  source: z.string().optional(),
458
603
  recordType: z.string().optional(),
459
604
  dedupeKey: z.string().optional(),
460
- origin: z.enum(['deterministic', 'llm', 'user']).optional(),
605
+ origin: z.enum(['deterministic', 'llm', 'user', 'session']).optional(),
461
606
  },
462
607
  },
463
608
  async (args) => {
@@ -525,11 +670,89 @@ export async function runServer(version) {
525
670
  },
526
671
  )
527
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
+
528
751
  server.registerTool(
529
752
  'intake_cleanup_status',
530
753
  {
531
754
  title: 'Private intake cleanup status',
532
- 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.',
533
756
  inputSchema: {},
534
757
  },
535
758
  async () => {
@@ -1489,14 +1712,16 @@ export async function runServer(version) {
1489
1712
  project: z.string().optional().describe('project key to filter to (e.g. "cortex")'),
1490
1713
  since_days: z.number().optional().describe('only records from the last N days'),
1491
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'),
1492
1716
  },
1493
1717
  },
1494
- async ({ type, project, since_days, limit }) => {
1718
+ async ({ type, project, since_days, limit, session }) => {
1495
1719
  const qs = new URLSearchParams()
1496
1720
  if (type) qs.set('type', type)
1497
1721
  if (project) qs.set('project', project)
1498
1722
  if (since_days != null) qs.set('since_days', String(since_days))
1499
1723
  if (limit != null) qs.set('limit', String(limit))
1724
+ if (session) qs.set('session', session)
1500
1725
  let res
1501
1726
  try {
1502
1727
  res = await fetchCortex(`${BASE}/api/records?${qs}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
@@ -1792,7 +2017,16 @@ export async function runServer(version) {
1792
2017
  const out = await res.json().catch(() => null)
1793
2018
  if (!res.ok) {
1794
2019
  if (out?.error === 'already_claimed') {
1795
- return toolError(`Another live session is already holding that record (${String(out.heldBy).slice(0, 16)}…). Leave it to them.`)
2020
+ // Name the holder when the server knows it. The fallback matters: `heldBy` is absent when
2021
+ // nothing holds a live lease, which means the record was not claimable rather than taken —
2022
+ // printing "another session has it" there sends the reader chasing a session that does not
2023
+ // exist. (This branch printed a bare `undefined` until 2026-08-14; the field was renamed
2024
+ // server-side and the tool was never updated, which is the whole reason it says both now.)
2025
+ if (out.heldBy) {
2026
+ const until = out.heldUntil ? `, lease to ${out.heldUntil}` : ''
2027
+ return toolError(`Session ${String(out.heldBy).slice(0, 16)}… is holding that record${until}. Leave it to them.`)
2028
+ }
2029
+ return toolError(out.detail || 'Could not claim that record and no session holds it — re-run pending_records; it may have been resolved already.')
1796
2030
  }
1797
2031
  return toolError(`Could not claim record: ${out?.error ?? res.status}`)
1798
2032
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.88",
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": {
@@ -36,9 +36,22 @@ No arguments. Read the conversation context.
36
36
  session's authoritative Agnoclast record (`capture_source='skill'`). The background auto-capture is a
37
37
  fallback and will not overwrite it; passing the same `sessionId` the auto-capture uses dedupes them
38
38
  onto one record. This — not the raw-transcript re-derivation — is the canonical record going forward.
39
- 4. **Confirm + flag privacy** — `log_session` returns a confirmation; if it errors, tell the user to
40
- run `npx -y @theronap/cortex-mcp doctor`. If any record from this session should be confidential,
41
- note it so the user can mark it (`set_record_privacy`). Default is org-visible under access rules.
39
+
40
+ **If you belong to more than one brain, pass `brain`.** ADR-0022 deleted the write pointer, so a
41
+ session-class source routes only by an explicit brain or a sole membership omit it and the log is
42
+ **STAGED, not recorded**, and staged session logs are not drainable by `/api/staged/promote`. Pick the
43
+ brain the work was actually in (`my_brains` shows what each holds). This silently swallowed 86 close-outs
44
+ before it was caught on 2026-08-09.
45
+ 4. **Confirm + flag privacy** — **read the result text, do not assume it succeeded.** `log_session` now
46
+ answers `NOT LOGGED — STAGED…` or `NOT LOGGED — the server skipped…` when no record was written; only a
47
+ message carrying a record id means it landed. (It previously printed "Logged … updated existing" for a
48
+ staged write, because `inserted` is merely falsy when nothing is recorded — an agent reported a session
49
+ as saved when it was not.) If it errors, tell the user to run `npx -y @theronap/cortex-mcp doctor`.
50
+
51
+ If any record from this session should be confidential, prefer passing `privacy: "confidential"` on the
52
+ `log_session` call itself so it is tiered **at write time** rather than landing org-visible and being
53
+ corrected after. Otherwise note it so the user can mark it (`set_record_privacy`). Default is org-visible
54
+ under access rules.
42
55
  5. **Sweep the wiki (author what you now understand)** — the HARD backstop for live authoring
43
56
  ([[cortex-wiki-authoring-spec]] D2). For each node whose understanding meaningfully advanced this
44
57
  session (the project(s) worked on, people you coordinated with, and yourself when your own focus