@theronap/cortex-mcp 0.9.117 → 0.9.119

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 +76 -2
  2. package/package.json +1 -1
package/lib/server.mjs CHANGED
@@ -1702,7 +1702,7 @@ export async function runServer(version) {
1702
1702
  'split_page',
1703
1703
  {
1704
1704
  title: 'Move sections onto a new child page',
1705
- description: 'SPLIT a page: move whole sections onto a NEW page, leaving the original in place. Use it when a page has grown past what can be read in one turn — that is a correctness problem, not just a cost one, because an agent that cannot read the whole authority answers from part of it (a head page and its governing page disagreed about a gate status for a week that way). Measured across 480 pages: the median page is ~3,500 chars, but 5.2% of pages hold a third of all authored text, so this is a targeted tool for the tail, not routine hygiene. It does NOT make pages go wrong less often — corrections scale roughly linearly with size — it changes what each correction COSTS to make: fixing one claim on a 165k-char page means reading ~42,000 tokens; on a 20k child, ~5,000. WHAT IT NEVER DOES, each for a measured reason: it never retires the original (a split is 1→2 and `superseded_by` holds one successor, so naming one would be false; the original also keeps receiving traffic that has no narrower match); it never moves governance (attaching a record to the child does not change its tier — reassigning governance is a privacy act and stays separate); it never rewrites inbound [[links]] (the splitter cannot know which half a link meant, the reader following it does — so the child says where it came from and lets them decide); and it never adds a link on the SOURCE, because where that link goes is prose — the response tells you to add one. GUARDS: the child is created BEFORE the source is trimmed, so a mid-way failure duplicates sections rather than losing them; `headings` must match the STORED heading exactly, INCLUDING any `· as of <date>` suffix (the rendered page can show a second `as of` stamp that is not part of it); moving every section is refused as a rename; and a child STRICTER than its parent is refused outright, because access is the union of attachments capped by the governing page — records attached to a stricter child keep their audience through the original, so it would look private while its evidence stayed readable. The child inherits the parent tier and its access grants, and gets an `In short` section rather than a summary. Fully reversible: `page_history` + `rollback_page` restore the source, and the child can be retired.',
1705
+ description: 'SPLIT a page: move whole sections onto a NEW page, leaving the original in place. Use it when a page has grown past what can be read in one turn — a correctness problem, not just a cost one, because an agent that cannot read the whole authority answers from part of it (a head page and its governing page disagreed about a gate status for a week that way). Measured across 480 pages: 5.2% of them hold a third of all authored text, so this targets the tail, not routine hygiene. It does NOT make pages go wrong less often — corrections scale roughly linearly with size — it changes what each one COSTS: fixing a claim on the 165k-char page means reading ~42,000 tokens; on a 20k child, ~5,000. WHAT IT NEVER DOES, each for a measured reason: never retires the original (a split is 1→2 and `superseded_by` holds one successor, and the original keeps receiving traffic with no narrower match); never moves governance (attaching a record to the child does not change its tier); never rewrites inbound [[links]] (the splitter cannot know which half a link meant the reader following it does); never links the child FROM the source, because where that link goes is prose. GUARDS: the child is created BEFORE the source is trimmed, so a mid-way failure duplicates sections rather than losing them; `headings` must match the STORED heading exactly, INCLUDING any `· as of <date>` suffix (the rendered page shows a second `as of` stamp that is not part of it); moving every section is refused as a rename; and a STRICTER child is refused, because access is the union of attachments capped by the governing page — the child would look private while its evidence stayed readable. The child copies the parent tier and grants and gets an `In short` section. Reversible via `page_history` + `rollback_page`.',
1706
1706
  inputSchema: {
1707
1707
  name: z.string().describe('the exact page name to split, as read_page shows it'),
1708
1708
  headings: z.array(z.string()).min(1).describe('the headings of the sections to MOVE, matched EXACTLY against the stored heading — include any `· as of <date>` suffix. Everything not listed stays on the original.'),
@@ -1782,6 +1782,80 @@ export async function runServer(version) {
1782
1782
  },
1783
1783
  )
1784
1784
 
1785
+ server.registerTool(
1786
+ 'move_record',
1787
+ {
1788
+ title: 'Move a misfiled record into the brain it belongs in',
1789
+ description: "Move ONE record to another of your brains, as a historical amendment. Use it when you recognise that a record landed in the wrong brain — the connector chose the brain from which account it was pointed at, not from what the message is about, so mail about a project routinely lands somewhere the project's page does not exist. Until this existed a session could recognise the mistake and be unable to act: route_record refuses to attach a record to a page in another brain. THE RECORD KEEPS ITS REAL DATE. A message from May is still from May; the move stamps a separate arrival time so the record surfaces in your arrivals queue as something new to place, instead of being buried in the aged bucket on the strength of its original date — which is what a plain move would have done. WHAT DOES NOT COME WITH IT, and why: its project link is cleared, because the project lives in the old brain and a record pointing at another brain's project is a foreign-key violation that has taken production down; and its page attachments are dropped and counted, because pages do not move between brains, so the record arrives UNATTACHED and needs routing in its new home. Its revision history stays where it is — those revisions genuinely happened in the old brain, and rewriting them would falsify history to tidy the present. REFUSES rather than guessing when: you are not a member of the target (moving a record somewhere you cannot see hides it from you), the target already holds the same message, or decisions/open threads/status events reference it and point at entities in the old brain.",
1790
+ inputSchema: {
1791
+ record_id: z.string().describe('record id, as pending_records or my_records shows it'),
1792
+ brain: z.string().describe('the destination brain — its name, or its org id when two of your brains share a name'),
1793
+ reason: z.string().describe('WHY it belongs there — recorded, and the one thing about this move that cannot be inferred from the data'),
1794
+ },
1795
+ },
1796
+ async ({ record_id, brain, reason }) => {
1797
+ let res
1798
+ try {
1799
+ res = await fetchCortex(`${BASE}/api/brain/move-record`, {
1800
+ method: 'POST',
1801
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
1802
+ body: JSON.stringify({ record_id, brain, reason }),
1803
+ })
1804
+ } catch (e) {
1805
+ return toolError(`Could not move the record: ${e.message}`)
1806
+ }
1807
+ const out = await res.json().catch(() => null)
1808
+ if (!res.ok) {
1809
+ const extra = Array.isArray(out?.brains)
1810
+ ? `\nyour brains with that name: ${out.brains.map((b) => `${b.name} (${b.org_id})`).join(' · ')}`
1811
+ : ''
1812
+ const detail = out?.detail ? `\n${out.detail}` : ''
1813
+ const hint = out?.hint ? `\n${out.hint}` : ''
1814
+ return toolError(`Could not move ${record_id}: ${out?.error ?? res.status}${detail}${extra}${hint}`)
1815
+ }
1816
+ const dropped = out.attachments_dropped
1817
+ ? `\n${out.attachments_dropped} attachment(s) to pages in the old brain were dropped — route_record it here.`
1818
+ : ''
1819
+ return { content: [{ type: 'text', text: `Moved to ${out.moved_to} as a historical amendment.${dropped}\n${out.note}` }] }
1820
+ },
1821
+ )
1822
+
1823
+ server.registerTool(
1824
+ 'place_staged_record',
1825
+ {
1826
+ title: 'Place a staged arrival onto pages — the pages decide its brain',
1827
+ 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.",
1828
+ inputSchema: {
1829
+ staged_id: z.string().describe('the staged id, as the [staged] rows at session start show it'),
1830
+ 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."),
1831
+ reason: z.string().describe('WHY these pages — recorded with the attachment, and the one thing that cannot be inferred later'),
1832
+ },
1833
+ },
1834
+ async ({ staged_id, pages, reason }) => {
1835
+ let res
1836
+ try {
1837
+ res = await fetchCortex(`${BASE}/api/staged/place`, {
1838
+ method: 'POST',
1839
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
1840
+ body: JSON.stringify({ stagedId: staged_id, documentIds: pages, reason }),
1841
+ })
1842
+ } catch (e) {
1843
+ return toolError(`Could not place the staged record: ${e.message}`)
1844
+ }
1845
+ const out = await res.json().catch(() => null)
1846
+ if (!res.ok && res.status !== 207) {
1847
+ const detail = out?.detail ? `\n${out.detail}` : ''
1848
+ return toolError(`Could not place ${staged_id}: ${out?.error ?? res.status}${detail}`)
1849
+ }
1850
+ // 207 and the no-record branch both mean the content LANDED and the attach did not. Report
1851
+ // that precisely rather than as success or failure — the follow-up differs for each.
1852
+ if (out?.placed === false) {
1853
+ return { content: [{ type: 'text', text: `Promoted into the target brain but NOT attached.\n${out.detail ?? ''}` }] }
1854
+ }
1855
+ return { content: [{ type: 'text', text: `Placed — promoted into its brain and attached to ${out.attached?.length ?? 0} page(s). The pages decided the brain; recorded as a session judgment.` }] }
1856
+ },
1857
+ )
1858
+
1785
1859
  server.registerTool(
1786
1860
  'set_summary',
1787
1861
  {
@@ -3275,7 +3349,7 @@ export async function runServer(version) {
3275
3349
  {
3276
3350
  title: 'Author a wiki node (live, while it is hot)',
3277
3351
  description:
3278
- 'Write your CURRENT understanding of a project/person/org/you into the org wiki as a maintained page. Call `authoring_context` FIRST. Author from your own synthesis of the session — the compiled mental model, not a transcript dump: what it IS, where it stands, dated decisions, open threads, key people. ⚠ EVERY STATUS CLAIM CARRIES AN EXPLICIT INLINE DATE (KWA-26): a sentence asserting what IS or IS NOT true right now — "X is live", "Y is not merged", "Z is blocked" — must say WHEN, in the prose, the way PRD items do. Section-level stamps are NOT enough: they record when the TEXT was written, so a section authored today can carry a six-week-old status claim and still read as current — exactly what made KWA-24 and TML-18 wrong. The response names any section that landed undated so you can fix it in-turn; it never blocks the write. Weave inline [[links]] to other nodes (canonical names from the namespace; red-links for wanted-but-absent nodes). The server re-authorizes the tier and resolves links. CREATES the node if it does not exist yet (project/person/org) — the conversation IS the evidence, so a brand-new entity that surfaced only in this session is authorable on the spot; you do NOT need prior records. Because such a node has nothing external to corroborate it, author it DELIBERATELY: only when you genuinely understand it is a real, distinct entity, and use its exact canonical name so it does not duplicate one already in the namespace (`user` nodes are never created). Use this continuously whenever your understanding of a node meaningfully advanced, and at session end (/log). Authoring is PRE-AUTHORIZED — never ask the user "should I update the page?" before calling this (every edit is versioned + reversible via page_history/rollback_page); update, then briefly report what you updated. ⚠ WHICH BRAIN A NEW PAGE GOES IN IS A CONTENT DECISION, SO MAKE IT FROM THE CONTENT. Only a page that exists in NO brain needs this an update resolves its brain from the page itself. If you hold more than one brain, the server REFUSES a create it cannot attribute (409 `create_needs_brain`) rather than letting the write pointer decide — the pointer is stale out-of-band state that knows nothing about what you are writing. So call `my_brains` (it returns each brain\'s name, page count and sample titles, which is enough to tell what each one is FOR) and pass `brain` up front; that turns a refused round trip into a single call. State which brain you picked and why in one short line, then proceed — do NOT ask when the answer is obvious from the content. DO ask when it is genuinely ambiguous: brains are a confidentiality boundary, so a page born in the wrong one can expose private work to a teammate, and that is not a filing error you can quietly fix later.',
3352
+ 'Write your CURRENT understanding of a project/person/org/you into the org wiki as a maintained page. Call `authoring_context` FIRST. Author from your own synthesis — the compiled mental model, not a transcript dump: what it IS, where it stands, dated decisions, open threads, key people. ⚠ EVERY STATUS CLAIM CARRIES AN EXPLICIT INLINE DATE (KWA-26): \"X is live\", \"Y is not merged\" must say WHEN, in the prose. Section-level stamps are NOT enough they record when the TEXT was written, so a section authored today can carry a six-week-old claim and still read as current (KWA-24, TML-18). The response names undated sections; it never blocks the write. Weave inline [[links]]; red-link what is wanted but absent. CREATES the node if it does not exist (project/person/org) — the conversation IS the evidence, so an entity first seen this session is authorable now. Nothing external corroborates such a node, so author DELIBERATELY: only when you understand it is a real, distinct entity, under its exact canonical name so it does not duplicate one in the namespace (`user` nodes are never created). Use as understanding advances, and at session end (/log). PRE-AUTHORIZED — never ask \"should I update the page?\" (versioned and reversible via page_history/rollback_page); update, then briefly report. ⚠ WHICH BRAIN A NEW PAGE GOES IN IS A CONTENT DECISION, so make it from the content. Only a page in NO brain needs this; an update resolves its brain from the page. With several brains the server REFUSES a create it cannot attribute (409 `create_needs_brain`) rather than letting the write pointer decide. Call `my_brains` and pass `brain` up front. Say which you picked and why; do NOT ask when the answer is obvious. DO ask when it is genuinely ambiguous: brains are a confidentiality boundary, so a page born in the wrong one can expose private work to a teammate not a filing error you can quietly fix later.',
3279
3353
  inputSchema: {
3280
3354
  kind: z.enum(['project', 'person', 'org', 'user']).describe('the node type'),
3281
3355
  name: z.string().describe('the canonical node name — an EXACT existing name from the namespace to update it, or a new name to create the node (project/person/org). e.g. "Agnoclast" or "Theron Peterson"'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.117",
3
+ "version": "0.9.119",
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": {