@theronap/cortex-mcp 0.9.124 → 0.9.126

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 +111 -1
  2. package/package.json +1 -1
package/lib/server.mjs CHANGED
@@ -1883,13 +1883,123 @@ function renderNudge(payload) {
1883
1883
  },
1884
1884
  )
1885
1885
 
1886
+ server.registerTool(
1887
+ 'staged_records',
1888
+ {
1889
+ title: 'List staged arrivals — the ones in no brain yet, with their ids',
1890
+ description: "List arrivals that are STAGED: held in no brain at all, because nothing could decide which brain they belong to, or because they carry identifiers the graph has never seen. This is the ONLY way to obtain a `staged_id`. `pending_records` structurally cannot see these (it reads records, and a staged row is not one); `intake_changes` returns ids from a different queue that `place_staged_record` rejects; and the session-start block prints titles without ids. Until this existed, placing a staged arrival required a human to read a uuid off a screen. Returns each arrival's id, title, age and the identifiers BLOCKING it, plus `blocking_summary` — the unclaimed identifiers ranked by how many arrivals each one holds, so you can see which single `set_routing_identifier` releases the most. ⚠ Claiming an identifier UNBLOCKS but does not place: which pages an arrival lands on decides its brain, and therefore who can read it, so that stays a decision someone makes.",
1891
+ inputSchema: {
1892
+ limit: z.number().optional().describe('max arrivals to return (default 50, max 200). `total` always reports the WHOLE queue regardless.'),
1893
+ source: z.string().optional().describe("narrow to one connector kind, e.g. 'email' or 'github'"),
1894
+ },
1895
+ },
1896
+ async ({ limit, source }) => {
1897
+ const qs = new URLSearchParams()
1898
+ if (limit) qs.set('limit', String(limit))
1899
+ if (source) qs.set('source', source)
1900
+ let res
1901
+ try {
1902
+ res = await fetchCortex(`${BASE}/api/staged${qs.toString() ? `?${qs}` : ''}`, {
1903
+ headers: { Authorization: `Bearer ${TOKEN}` },
1904
+ })
1905
+ } catch (e) {
1906
+ return toolError(`Could not list staged arrivals: ${e.message}`)
1907
+ }
1908
+ const out = await res.json().catch(() => null)
1909
+ if (!res.ok) return toolError(`Could not list staged arrivals: ${out?.error ?? res.status}`)
1910
+ if (!out) return toolError('The server returned no body.')
1911
+
1912
+ if (!out.total) {
1913
+ return { content: [{ type: 'text', text: 'Nothing staged — every arrival has reached a brain.' }] }
1914
+ }
1915
+ const lines = [`${out.total} staged arrival(s) — in NO brain yet:`, '']
1916
+ for (const a of out.arrivals ?? []) {
1917
+ // The id goes FIRST on its own line. It is the argument place_staged_record needs and the
1918
+ // entire reason this tool exists; burying it after prose is how the session-start block
1919
+ // managed to list these rows for weeks without making one of them actionable.
1920
+ lines.push(` ${a.stagedId}`)
1921
+ lines.push(` ${a.title ?? '(no subject)'} · ${a.sourceType} · ${a.occurredAt?.slice(0, 10) ?? ''}`)
1922
+ if (a.blockingIdentifiers?.length) {
1923
+ lines.push(` blocked by: ${a.blockingIdentifiers.join(', ')}`)
1924
+ } else {
1925
+ lines.push(' held because no brain could be determined (no unknown identifiers)')
1926
+ }
1927
+ }
1928
+ if ((out.arrivals ?? []).length < out.total) {
1929
+ lines.push('', ` … ${out.total - out.arrivals.length} more not shown — raise \`limit\` to see them.`)
1930
+ }
1931
+ if (out.blockingSummary?.length) {
1932
+ lines.push('', 'Unclaimed identifiers holding the most arrivals — claim one and that many unblock:')
1933
+ for (const b of out.blockingSummary.slice(0, 10)) {
1934
+ lines.push(` ${b.arrivals}x ${b.identifier}`)
1935
+ }
1936
+ lines.push('', 'Claim with set_routing_identifier on the page whose subject HAS that identifier.')
1937
+ }
1938
+ lines.push('', 'Place one with place_staged_record (staged_id + pages). The pages decide the brain, which decides who can read it.')
1939
+ return { content: [{ type: 'text', text: lines.join('\n') }] }
1940
+ },
1941
+ )
1942
+
1943
+ server.registerTool(
1944
+ 'discard_staged',
1945
+ {
1946
+ title: 'Discard staged arrivals in bulk — reversibly',
1947
+ description: "Mark staged arrivals as discarded, addressed BY IDENTIFIER (every arrival that address is holding) or by explicit staged ids. The counterpart to `place_staged_record`, which takes one row at a time and needs pages — right for an arrival worth keeping, wrong for a queue where one unclaimed address holds 68-199 arrivals and most of them are simply not wanted. ⚠ REVERSIBLE, unlike `intake_discard`: this sets a state and destroys NOTHING (the row keeps its raw_body and the message is still in the source mailbox), so pass `restore: true` to undo. That is what makes a bulk verb safe here — classifying thousands of correspondents guarantees being wrong about some. ⚠ DRY-RUN BY DEFAULT: it reports what it WOULD change and changes nothing until you pass `apply: true`, and the dry-run count is the same set the apply writes. Use `staged_records` first to see the ranking.",
1948
+ inputSchema: {
1949
+ identifiers: z.array(z.string()).optional().describe('discard every pending arrival blocked on any of these, e.g. ["email:venmo@venmo.com"]'),
1950
+ staged_ids: z.array(z.string()).optional().describe('discard these specific arrivals'),
1951
+ apply: z.boolean().optional().describe('false/absent = dry run (default). true actually writes.'),
1952
+ restore: z.boolean().optional().describe('true = put previously discarded arrivals back in the queue'),
1953
+ },
1954
+ },
1955
+ async ({ identifiers, staged_ids, apply, restore }) => {
1956
+ if (!identifiers?.length && !staged_ids?.length) {
1957
+ return toolError('Pass `identifiers` or `staged_ids` — an empty call is refused rather than treated as "everything".')
1958
+ }
1959
+ let res
1960
+ try {
1961
+ res = await fetchCortex(`${BASE}/api/staged/discard`, {
1962
+ method: 'POST',
1963
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
1964
+ body: JSON.stringify({ identifiers, stagedIds: staged_ids, apply: apply === true, restore: restore === true }),
1965
+ })
1966
+ } catch (e) {
1967
+ return toolError(`Could not reach the staged queue: ${e.message}`)
1968
+ }
1969
+ const out = await res.json().catch(() => null)
1970
+ if (!res.ok) return toolError(`Could not ${restore ? 'restore' : 'discard'}: ${out?.hint ?? out?.error ?? res.status}`)
1971
+ if (!out) return toolError('The server returned no body.')
1972
+
1973
+ const verb = out.restore ? 'restore' : 'discard'
1974
+ const lines = []
1975
+ if (out.mode === 'dry-run') {
1976
+ lines.push(`DRY RUN — nothing changed. Would ${verb} ${out.matched} arrival(s).`)
1977
+ lines.push('Re-run with apply: true to do it.')
1978
+ } else {
1979
+ lines.push(`${out.changed} arrival(s) ${out.restore ? 'restored to the queue' : 'discarded'}.`)
1980
+ if (!out.restore) lines.push('Reversible — discard_staged with restore: true puts them back.')
1981
+ }
1982
+ if (out.unmatchedIdentifiers?.length) {
1983
+ lines.push('', `⚠ matched NOTHING (already drained, or a typo): ${out.unmatchedIdentifiers.join(', ')}`)
1984
+ }
1985
+ if (out.sample?.length) {
1986
+ lines.push('', 'Sample:')
1987
+ for (const r of out.sample.slice(0, 10)) {
1988
+ lines.push(` ${r.occurredAt?.slice(0, 10) ?? ''} ${r.title ?? '(no subject)'}`)
1989
+ }
1990
+ if (out.matched > out.sample.length) lines.push(` … and ${out.matched - out.sample.length} more`)
1991
+ }
1992
+ return { content: [{ type: 'text', text: lines.join('\n') }] }
1993
+ },
1994
+ )
1995
+
1886
1996
  server.registerTool(
1887
1997
  'place_staged_record',
1888
1998
  {
1889
1999
  title: 'Place a staged arrival onto pages — the pages decide its brain',
1890
2000
  description: "Place a STAGED arrival — one that is in no brain at all — onto the pages it belongs to, which is also what decides its brain. Session-start lists these separately as `[staged]`, and they are the only rows route_record CANNOT take, because there is no record yet to route: nothing upstream chose a brain for them, deliberately. That is the point (ADR-0038) — the connector used to pick the brain from which mailbox the message arrived through, which is a fact about your email plumbing rather than about the message, and it decided WHO COULD READ IT before anyone had read it. Here the pages decide instead. ⚠ PLACING IS A DISCLOSURE DECISION, not just filing: a brain is the confidentiality boundary, so putting a staged message into a shared brain makes it readable by every member of that brain. Say so when you offer, and never place a personal message into a shared brain without the owner\'s explicit answer. All the pages must live in ONE brain — a record exists in exactly one — and pages spanning two brains are refused by name rather than resolved by picking. On success the content is replayed through the real ingest pipeline into that brain, so the record it produces is identical to one that had landed there directly, and then it is attached.",
1891
2001
  inputSchema: {
1892
- staged_id: z.string().describe('the staged id, as the [staged] rows at session start show it'),
2002
+ staged_id: z.string().describe('the staged id — get it from `staged_records`, which is the only surface that prints one (session-start lists these arrivals but not their ids)'),
1893
2003
  pages: z.array(z.string()).describe("pages to place it on — a brain_documents id or the `ref:` read_page prints. They must all be in ONE brain; that brain is where the record lands."),
1894
2004
  reason: z.string().describe('WHY these pages — recorded with the attachment, and the one thing that cannot be inferred later'),
1895
2005
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.124",
3
+ "version": "0.9.126",
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": {