@theronap/cortex-mcp 0.9.90 → 0.9.91

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 +90 -16
  2. package/package.json +1 -1
package/lib/server.mjs CHANGED
@@ -40,6 +40,38 @@ async function redLinkTriage(BASE, TOKEN, name) {
40
40
  // file-request tools, and a module-level `fail` would be silently shadowed by it from that point on.
41
41
  const toolError = (text) => ({ content: [{ type: 'text', text }], isError: true })
42
42
 
43
+ // SECTION CURRENCY (gate 3) — ONE renderer, used by BOTH read_page and project_status.
44
+ //
45
+ // Extracted as a PURE function on the pickBrainMatch precedent: that fix pulled a shared rule out of two
46
+ // resolvers specifically so they could not drift, after they drifted. This is the same situation found
47
+ // 2026-08-15. THREE surfaces render authored sections from the same server-computed fields:
48
+ // • renderAuthoredNodeBody (web/lib/engine/authored_page_tiers.ts) — console/web
49
+ // • read_page here — printed a date and "⚠ Undated section" as two adjacent lines
50
+ // • project_status here — printed NO currency at all: no as-of, no warning, nothing
51
+ // PR #558 fixed only the first. project_status is the tool the routing docs reach for FIRST, and gate 3
52
+ // reads "a reader can date any claim without a second query" — a reader there could date nothing.
53
+ //
54
+ // The two dates are DIFFERENT facts and both true: `asOf` is when the section's text last CHANGED; an
55
+ // explicit date in the prose is when the CLAIM was true. Stated as one sentence they inform; stacked as
56
+ // two lines they read as the page contradicting itself.
57
+ //
58
+ // ⚠ Do NOT "simplify" this by keying on `asOf` — it is set on every section always, so the detector
59
+ // would go silent everywhere. The branch must key on `hasExplicitDate`, and on `=== false` rather than
60
+ // falsy: `undefined` is an older server mid-rolling-deploy that has computed no verdict, and inventing
61
+ // one there is the 0093 don't-impute violation.
62
+ export const sectionCurrencyStamp = (s, day) => {
63
+ const on = s.asOf ? day(s.asOf) : ''
64
+ return s.hasExplicitDate === false
65
+ ? `${on ? ` · text last written ${on} —` : ' —'} ⚠ the claim itself carries no date; verify before relying on it`
66
+ : (on ? ` · as of ${on}` : '')
67
+ }
68
+
69
+ // ⚠ The newline is a FIX, not cosmetics. read_page's old form was `${asOf}${currency}${s.body}`, where
70
+ // only the UNDATED branch contributed a trailing \n — so every DATED section ran its body straight onto
71
+ // the header line ("· as of 2026-08-14Identifiers are candidates, never publication."). The defect was
72
+ // invisible on exactly the sections that were healthy.
73
+ export const renderSection = (s, day) => `### ${s.heading}${sectionCurrencyStamp(s, day)}\n${s.body}`
74
+
43
75
  // The Agnoclast MCP server (stdio). Serves the signed-in employee's scoped org
44
76
  // context to their AI assistant. CORTEX_TOKEN identifies the user + org.
45
77
 
@@ -539,7 +571,7 @@ export async function runServer(version) {
539
571
  {
540
572
  title: 'Check private intake changes',
541
573
  description:
542
- 'Session heartbeat for Gate 4 private intake. Returns an operational delta since your cursor (no ciphertext) plus cleanupDueCount. Call at session start, before each user turn, after long actions, and at least every two minutes while actively working. If cleanupDueCount > 0, call intake_claim with claimKind=cleanup before ordinary work.',
574
+ 'Session heartbeat for Gate 4 private intake. Returns an operational delta since your cursor (no ciphertext) plus cleanupDueCount. Call at session start, before each user turn, after long actions, and at least every two minutes while actively working. cleanupDueCount is advisory: prefer to service it with claimKind=cleanup when you reach a natural break, but never park the work you were actually asked to do in order to drain the queue first. NOTE afterSeq is this feed\'s own sequence — the changeSeq returned by ingest is a different counter, and passing it here seeks past the end and looks like a dead feed.',
543
575
  inputSchema: {
544
576
  afterSeq: z.number().optional().describe('cursor from the previous call (default 0)'),
545
577
  limit: z.number().optional().describe('max change rows (default 100)'),
@@ -565,25 +597,66 @@ export async function runServer(version) {
565
597
  {
566
598
  title: 'Claim private intake items',
567
599
  description:
568
- 'Claim a lease on private intake items (relevance or cleanup). Returns decrypted payloads for the lease holder only. If cleanup is due, relevance claims return 409 — process cleanup first. Requires x-cortex-session-key (set automatically by this MCP server).',
600
+ 'Claim a lease on private intake items (relevance or cleanup). Returns decrypted payloads for the lease holder only. Requires x-cortex-session-key (set automatically by this MCP server). Aged work never blocks a claim: the response reports cleanupDueCount and how far behind the oldest unit is, and servicing it is expected but always your call. Claiming is a commitment to process — hand back anything you will not finish with intake_release, or intake_defer if it is the owner\'s decision to make.',
569
601
  inputSchema: {
570
602
  claimKind: z.enum(['relevance', 'cleanup']).optional().describe('default relevance'),
571
- limit: z.number().optional().describe('max items (default 10)'),
603
+ limit: z.number().optional().describe('max items (default 10). Ignored when intakeItemIds is given.'),
604
+ intakeItemIds: z.array(z.string()).optional().describe(
605
+ 'claim these specific units instead of the head of the queue (max 50). Without it you get FIFO, ' +
606
+ 'so reaching one known unit means claiming everything ahead of it. Ids come from intake_changes ' +
607
+ 'or from an ingest response. The reply adds an `outcomes` entry per requested id — claimed, ' +
608
+ 'held_by_you (you already hold a live lease on it — proceed, do not re-claim), ' +
609
+ 'held (someone else has a live lease; the holder is a stable hash, never their session key), ' +
610
+ 'ineligible (already resolved or deferred to the owner), not_found, or unavailable (a momentary ' +
611
+ 'lock — retrying is reasonable). Outcomes are best-effort, not a snapshot you can rely on.',
612
+ ),
572
613
  },
573
614
  },
574
- async ({ claimKind, limit }) => {
615
+ async ({ claimKind, limit, intakeItemIds }) => {
575
616
  const res = await fetchCortex(`${BASE}/api/intake/claim`, {
576
617
  method: 'POST',
577
618
  headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
578
- body: JSON.stringify({ claimKind: claimKind ?? 'relevance', limit, includePayload: true }),
619
+ body: JSON.stringify({
620
+ claimKind: claimKind ?? 'relevance',
621
+ limit,
622
+ includePayload: true,
623
+ // Forwarded only when present. An empty array is a real request to claim nothing and must
624
+ // survive as one; sending `[]` where the caller sent nothing would silently switch a FIFO
625
+ // claim into a no-op.
626
+ ...(intakeItemIds ? { intakeItemIds } : {}),
627
+ }),
579
628
  })
580
629
  if (!res.ok) {
581
630
  const body = await res.text()
582
631
  if (res.status === 403) return toolError('Private intake is not enabled for this account.')
632
+ // Kept deliberately after the server stopped sending it. A published MCP build outlives any
633
+ // one deployment, so this client will meet servers that still refuse relevance claims while
634
+ // cleanup is due. Surfacing that as its own message beats a generic HTTP failure.
583
635
  if (res.status === 409) return toolError(body)
584
636
  throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
585
637
  }
586
- return { content: [{ type: 'text', text: JSON.stringify(await res.json(), null, 2) }] }
638
+
639
+ const j = await res.json()
640
+
641
+ // The nudge that replaced the 409. It has to be legible without doing arithmetic on a
642
+ // timestamp, so say how far behind rather than printing an ISO string and hoping — "6 days"
643
+ // is a reason to act and "2026-08-09T…" is a field to skim past.
644
+ let nudge = ''
645
+ if ((j?.cleanupDueCount ?? 0) > 0) {
646
+ const n = j.cleanupDueCount
647
+ let behind = ''
648
+ const oldest = j?.oldestCleanupDueAt ? Date.parse(j.oldestCleanupDueAt) : NaN
649
+ if (Number.isFinite(oldest)) {
650
+ const mins = Math.max(0, Math.floor((Date.now() - oldest) / 60000))
651
+ behind = mins >= 1440 ? `, oldest ${Math.floor(mins / 1440)}d overdue`
652
+ : mins >= 60 ? `, oldest ${Math.floor(mins / 60)}h overdue`
653
+ : `, oldest ${mins}m overdue`
654
+ }
655
+ nudge = `\n\n⏳ ${n} intake unit${n === 1 ? '' : 's'} past the cleanup deadline${behind}. `
656
+ + `Nothing is blocked — run intake_claim with claimKind:"cleanup" when you reach a natural break.`
657
+ }
658
+
659
+ return { content: [{ type: 'text', text: JSON.stringify(j, null, 2) + nudge }] }
587
660
  },
588
661
  )
589
662
 
@@ -679,15 +752,16 @@ export async function runServer(version) {
679
752
  inputSchema: {
680
753
  intakeItemId: z.string().describe('intake item uuid from intake_claim'),
681
754
  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"'),
755
+ options: z.array(z.string()).optional().describe('the concrete choices, when the decision is a pick rather than an open question — e.g. ["Personal","TTO","Discard — nothing to record"]. Stored on the question and rendered by intake_cleanup_status, so the owner can answer with a choice instead of prose.'),
682
756
  },
683
757
  },
684
- async ({ intakeItemId, question }) => {
758
+ async ({ intakeItemId, question, options }) => {
685
759
  let res
686
760
  try {
687
761
  res = await fetchCortex(`${BASE}/api/intake/defer`, {
688
762
  method: 'POST',
689
763
  headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
690
- body: JSON.stringify({ intakeItemId, question }),
764
+ body: JSON.stringify({ intakeItemId, question, ...(options?.length ? { options } : {}) }),
691
765
  })
692
766
  } catch (e) {
693
767
  return toolError(`Could not defer: ${e.message}`)
@@ -874,7 +948,11 @@ export async function runServer(version) {
874
948
  const day = (d) => (d ? String(d).slice(0, 10) : '')
875
949
  const renderMatch = (m) => {
876
950
  const blocks = m.tiers.map((t) => {
877
- const secs = (t.sections ?? []).map((s) => `### ${s.heading}\n${s.body}`).join('\n\n')
951
+ // Was heading-then-body with NO currency at all — no as-of, no warning — while
952
+ // read_page showed it. project_status is what the routing docs reach for first, so a
953
+ // reader here could date nothing. Same renderer as read_page now, so the two cannot
954
+ // drift again.
955
+ const secs = (t.sections ?? []).map((s) => renderSection(s, day)).join('\n\n')
878
956
  const head = `[${t.tier}${day(t.updated_at) ? ` · authored ${day(t.updated_at)}` : ''}${t.validity && t.validity !== 'current' ? ` · ${t.validity}` : ''}]`
879
957
  return [head, t.summary, secs].filter(Boolean).join('\n')
880
958
  })
@@ -1011,13 +1089,9 @@ export async function runServer(version) {
1011
1089
  const renderMatch = (m, tagBrain) => {
1012
1090
  const blocks = m.tiers.map((t) => {
1013
1091
  const secs = (t.sections ?? []).map((s) => {
1014
- // Undefined is an older-server response during a rolling deploy: do
1015
- // not invent a currency verdict until this server has computed one.
1016
- const currency = s.hasExplicitDate === false
1017
- ? '\n⚠ **Undated section — verify before relying on its claims.**\n'
1018
- : ''
1019
- const asOf = s.asOf ? ` · as of ${day(s.asOf)}` : ''
1020
- return `### ${s.heading}${asOf}${currency}${s.body}`
1092
+ // Shared with project_status via renderSection see its definition for why this is one
1093
+ // function and not two (they drifted; #558 fixed one of three surfaces).
1094
+ return renderSection(s, day)
1021
1095
  }).join('\n\n')
1022
1096
  // ADR-0018: a null version isn't "nothing to show" — it means this variant predates content-
1023
1097
  // hash tracking (a 2026-06-29 import scar) and CANNOT be re-authored via base_version until an
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.90",
3
+ "version": "0.9.91",
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": {