@theronap/cortex-mcp 0.9.149 → 0.9.151
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/capture.mjs +235 -135
- package/lib/capture_queue.mjs +876 -0
- package/lib/diagnose.mjs +176 -32
- package/lib/doctor.mjs +32 -4
- package/lib/edge_extract.mjs +41 -24
- package/lib/hydrate.mjs +40 -13
- package/lib/propose_obligation.mjs +99 -0
- package/lib/redact.mjs +2 -1
- package/lib/server.mjs +89 -17
- package/package.json +1 -1
package/lib/redact.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// redact.mjs — strip high-confidence credential patterns from text BEFORE it leaves
|
|
2
|
-
// the machine
|
|
2
|
+
// the machine or is stored (hydrate sends the prompt; capture feeds the transcript tail to
|
|
3
|
+
// the local summarizer and the local queue — since D8 it never POSTs the tail). A leaked secret in a
|
|
3
4
|
// transcript — a user pastes an API key, a tool reads a config file holding one, or
|
|
4
5
|
// (the 2026-06-22 finding) a login token sits inlined in a hook command — must never
|
|
5
6
|
// be transmitted or stored. Conservative by design: targets known secret SHAPES so it
|
package/lib/server.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { folderClaimLine } from './folder_claims.mjs'
|
|
|
10
10
|
import { resolveSessionKey, resolveLogSessionId } from './session_key.mjs'
|
|
11
11
|
import { runSendImessage } from './imessage_send.mjs'
|
|
12
12
|
import { formatGrepHits } from './grep_cli.mjs'
|
|
13
|
+
import { buildProposal, renderProposal, withAskNotice } from './propose_obligation.mjs'
|
|
13
14
|
import { renderCaptureStatus } from './capture_status.mjs'
|
|
14
15
|
import { renderTriage } from './red_link_triage.mjs'
|
|
15
16
|
import { runCodeGraphQuery } from './code_graph_cli.mjs'
|
|
@@ -77,6 +78,23 @@ export const sectionCurrencyStamp = (s, day) => {
|
|
|
77
78
|
// invisible on exactly the sections that were healthy.
|
|
78
79
|
export const renderSection = (s, day) => `### ${s.heading}${sectionCurrencyStamp(s, day)}\n${s.body}`
|
|
79
80
|
|
|
81
|
+
// TOP-OF-PAGE NOTES — ADR-0065's size note, and ADR-0064 §3's "Also visible to you" callout.
|
|
82
|
+
//
|
|
83
|
+
// ⚠ PRINTED VERBATIM, NEVER COMPOSED HERE. The server decides whether a note fires and what it says
|
|
84
|
+
// (`headerNotes` on /api/brain/page), so changing a threshold or a sentence is a deploy, not an npm release
|
|
85
|
+
// plus a desktop pin bump. An older server sends no field, and this prints nothing.
|
|
86
|
+
//
|
|
87
|
+
// ⚠ AT THE TOP, NOT IN THE FOOTER. Claude Code saves an over-cap tool result to a file and the agent reads
|
|
88
|
+
// it from offset 0, so the footer of a huge page is the part nobody reaches — and these notes exist for
|
|
89
|
+
// exactly those pages. Shared by read_page and project_status so the two cannot drift, which is how every
|
|
90
|
+
// earlier fix to this file's page rendering landed on one surface and not the other.
|
|
91
|
+
export const renderHeaderNotes = (notes) => {
|
|
92
|
+
const lines = (Array.isArray(notes) ? notes : [])
|
|
93
|
+
.filter((n) => typeof n === 'string' && n.trim() !== '')
|
|
94
|
+
.map((n) => n.replace(/\s*\n\s*/g, ' ').trim())
|
|
95
|
+
return lines.length ? `\n\n${lines.join('\n')}` : ''
|
|
96
|
+
}
|
|
97
|
+
|
|
80
98
|
// gate5_status's transport half, pulled out of the tool handler so it can be exercised directly rather
|
|
81
99
|
// than only string-matched (gate5_status_transport.drift.test.mjs). Takes the raw fetch Response from
|
|
82
100
|
// /api/gates/5/status; the registerTool callback's only remaining job is fetching it. Same three
|
|
@@ -823,7 +841,7 @@ function renderNudge(payload) {
|
|
|
823
841
|
sections.push(`Unclaimed records (${records.length}) from other sources — newest first:\n${lines.join('\n')}`)
|
|
824
842
|
}
|
|
825
843
|
|
|
826
|
-
return { content: [{ type: 'text', text: sections.join('\n\n') }] }
|
|
844
|
+
return { content: [{ type: 'text', text: withAskNotice(sections.join('\n\n')) }] }
|
|
827
845
|
},
|
|
828
846
|
)
|
|
829
847
|
|
|
@@ -1169,7 +1187,7 @@ function renderNudge(payload) {
|
|
|
1169
1187
|
if (out?.error === 'not_found') return toolError('No such intake unit in your account.')
|
|
1170
1188
|
return toolError(`Could not look: ${out?.error ?? res.status}${out?.detail ? ` — ${out.detail}` : ''}`)
|
|
1171
1189
|
}
|
|
1172
|
-
return { content: [{ type: 'text', text: JSON.stringify(out, null, 2) }] }
|
|
1190
|
+
return { content: [{ type: 'text', text: withAskNotice(JSON.stringify(out, null, 2)) }] }
|
|
1173
1191
|
},
|
|
1174
1192
|
)
|
|
1175
1193
|
|
|
@@ -1347,7 +1365,7 @@ function renderNudge(payload) {
|
|
|
1347
1365
|
return [head, secs].filter(Boolean).join('\n')
|
|
1348
1366
|
})
|
|
1349
1367
|
const brainTag = matches.length > 1 ? ` · brain: ${m.brain}` : ''
|
|
1350
|
-
return `# ${m.title ?? key} (authored page${brainTag})\n\n${blocks.join('\n\n---\n\n')}`
|
|
1368
|
+
return `# ${m.title ?? key} (authored page${brainTag})${renderHeaderNotes(m.headerNotes)}\n\n${blocks.join('\n\n---\n\n')}`
|
|
1351
1369
|
}
|
|
1352
1370
|
return { content: [{ type: 'text', text: matches.map(renderMatch).join('\n\n═══\n\n') }] }
|
|
1353
1371
|
}
|
|
@@ -1578,7 +1596,7 @@ function renderNudge(payload) {
|
|
|
1578
1596
|
// Always emitted, not only in the multi-brain case: the ref is what makes the brain question
|
|
1579
1597
|
// moot, so withholding it until brains collide is exactly backwards.
|
|
1580
1598
|
const refLine = m.ref ? `\nref: ${m.ref}` : ''
|
|
1581
|
-
return `# ${m.title ?? name} (full authored page${brainTag})${refLine}\n\n${blocks.join('\n\n---\n\n')}\n\n${footer}`
|
|
1599
|
+
return `# ${m.title ?? name} (full authored page${brainTag})${refLine}${renderHeaderNotes(m.headerNotes)}\n\n${blocks.join('\n\n---\n\n')}\n\n${footer}`
|
|
1582
1600
|
}
|
|
1583
1601
|
// ── THE PRE-CLAIM NUDGE ───────────────────────────────────────────────────────────────────
|
|
1584
1602
|
//
|
|
@@ -1866,15 +1884,15 @@ function renderNudge(payload) {
|
|
|
1866
1884
|
'split_page',
|
|
1867
1885
|
{
|
|
1868
1886
|
title: 'Move sections onto a new child page',
|
|
1869
|
-
description: 'SPLIT a page: move whole sections onto a NEW page, leaving the original in place. Use it when a page has
|
|
1887
|
+
description: 'SPLIT a page: move whole sections onto a NEW page, leaving the original in place. Use it when a page has outgrown one read — an agent that cannot read the whole authority answers from part of it. It targets the tail (5.2% of pages hold a third of all authored text), not routine hygiene, and it does not make pages go wrong less often: it makes each fix cheaper (~42,000 tokens to read a 165k-char page, ~5,000 for a 20k child). It NEVER retires the original, moves governance, rewrites inbound [[links]] (the reader following one knows which half it meant), or links the child FROM the source. EVERY REFUSAL IS DECIDED BEFORE ANYTHING IS WRITTEN (ADR-0064 §7): you need EDIT access to the version you read (edit_forbidden); pages with several tier versions are paused (multi_version_page); `headings` must match the STORED heading exactly, INCLUDING any `· as of <date>` suffix, and a heading two sections share is refused (ambiguous_heading); moving every section is refused as a rename; `new_title` must not name any existing or archived page (title_taken); a STRICTER child is refused, and a LOOSER one needs the source page\'s owner (widening_requires_owner); oversize sections and identity identifiers are refused (too_large, identity_claim). The child keeps the source page\'s OWNER, tier and grants, so the same people can read it, and gets an `In short` section. If a split stops after the child exists (source_trim_failed), finish with `merge_sections` (`from` only) for each heading in `remaining` — never re-run split_page. Reversible via `page_history` + `rollback_page`.',
|
|
1870
1888
|
inputSchema: {
|
|
1871
1889
|
name: z.string().describe('the exact page name to split, as read_page shows it'),
|
|
1872
|
-
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.'),
|
|
1873
|
-
new_title: z.string().describe('the title of the new child page. It must not
|
|
1890
|
+
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. A heading two sections share is refused: rename one with rename_section first. Everything not listed stays on the original.'),
|
|
1891
|
+
new_title: z.string().describe('the title of the new child page. It must not name ANY existing page in this brain, or an archived project: a split never adopts or revives one.'),
|
|
1874
1892
|
base_version: z.string().describe('the `version` read_page prints for the SOURCE page (64-hex). REQUIRED — it is the concurrency check AND how the right brain is resolved.'),
|
|
1875
1893
|
reason: z.string().describe('WHY you are splitting, in one short phrase — recorded in page_history. Say what outgrew the page.'),
|
|
1876
|
-
owner: z.string().optional().describe('
|
|
1877
|
-
tier: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('omit to COPY the source page tier, which is almost always right. A LOOSER tier
|
|
1894
|
+
owner: z.string().optional().describe('omit to keep the SOURCE page\'s owner, so the child has exactly the same audience — almost always right. Naming a different user is an ownership change: it needs the page owner, or someone above them who can read the page, and must be an active member of this brain.'),
|
|
1895
|
+
tier: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('omit to COPY the source page tier, which is almost always right. A LOOSER tier widens who can read the moved text, so only the source page\'s owner may choose it; a STRICTER one is refused, since a split cannot tighten access.'),
|
|
1878
1896
|
},
|
|
1879
1897
|
},
|
|
1880
1898
|
async ({ name, headings, new_title, base_version, reason, owner, tier }) => {
|
|
@@ -1894,20 +1912,25 @@ function renderNudge(payload) {
|
|
|
1894
1912
|
const out = await res.json().catch(() => null)
|
|
1895
1913
|
if (!res.ok) {
|
|
1896
1914
|
// `child_created` + `remaining` is the one failure worth reading carefully: it means the split
|
|
1897
|
-
// is HALF DONE and nothing was lost. Surface it first so the caller
|
|
1898
|
-
// from
|
|
1915
|
+
// is HALF DONE and nothing was lost. Surface it first so the caller FINISHES with merge_sections
|
|
1916
|
+
// (`from` only) rather than re-running split_page, which refuses the now-taken title (ADR-0064 §7.4).
|
|
1899
1917
|
const extra = [
|
|
1900
1918
|
out?.child_created ? `the child "${out.child_created}" EXISTS and holds every moved section — nothing was lost` : '',
|
|
1901
|
-
Array.isArray(out?.remaining) ? `still on BOTH pages, re-run
|
|
1919
|
+
Array.isArray(out?.remaining) ? `still on BOTH pages — finish with merge_sections (\`from\` only) for each, do NOT re-run split_page: ${out.remaining.join(' · ')}` : '',
|
|
1902
1920
|
Array.isArray(out?.detail) ? `detail: ${out.detail.join(' · ')}` : '',
|
|
1903
1921
|
out?.message ?? '',
|
|
1904
1922
|
].filter(Boolean).join('\n')
|
|
1905
1923
|
const hint = out?.hint ? `\n${out.hint}` : ''
|
|
1906
1924
|
return toolError(`Could not split "${name}": ${out?.error ?? res.status}${extra ? `\n${extra}` : ''}${hint}`)
|
|
1907
1925
|
}
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1926
|
+
// ADR-0064 §7.5: an omitted owner now keeps the SOURCE page's owner, and the server's `note` says so.
|
|
1927
|
+
// Only a NAMED owner changes the audience, so that is the one worth a warning. `owner_defaulted` is
|
|
1928
|
+
// still read for a server older than #1041, where it meant the child fell to the caller.
|
|
1929
|
+
const ownerNote = out.owner_changed
|
|
1930
|
+
? '\n⚠ The child is owned by the member you named, which changes who can read it if it is scoped or confidential.'
|
|
1931
|
+
: out.owner_defaulted
|
|
1932
|
+
? '\n⚠ The child is owned by you because no `owner` was given. Set one deliberately if it should belong to someone else — transfer is unreachable once an owner is deactivated.'
|
|
1933
|
+
: ''
|
|
1911
1934
|
const grants = out.grants_copied ? ` ${out.grants_copied} access grant(s) copied.` : ''
|
|
1912
1935
|
return { content: [{ type: 'text', text: `Split "${name}" (${out.brain} · ${out.tier} tier) → new page "${out.child}". Moved: ${out.moved.join(' · ')}.${grants} Child version: ${out.version}\n${out.note}\nNEXT: ${out.next}${ownerNote}\nReversible: \`page_history "${name}"\` then \`rollback_page\` restores the source; the child can be retired with set_page_validity.` }] }
|
|
1913
1936
|
},
|
|
@@ -2036,8 +2059,14 @@ function renderNudge(payload) {
|
|
|
2036
2059
|
}
|
|
2037
2060
|
lines.push('', 'Claim with set_routing_identifier on the page whose subject HAS that identifier.')
|
|
2038
2061
|
}
|
|
2062
|
+
// THREE OUTCOMES, ALL LEGITIMATE (ADR-0038 amendment, Theron 2026-09-11: "we need to make it an
|
|
2063
|
+
// option for the agent to discard emails entirely or to create pages"). Naming only placement left an
|
|
2064
|
+
// agent with no honest move for junk mail or for a subject that has no page yet — so it either
|
|
2065
|
+
// mis-filed onto the nearest page or left the queue to grow. 6 of 19 arrivals sat exactly there.
|
|
2039
2066
|
lines.push('', 'Place one with place_staged_record (staged_id + pages). The pages decide the brain, which decides who can read it.')
|
|
2040
|
-
|
|
2067
|
+
lines.push('No page fits? `author` the page it needs, then place onto it. Junk? `discard_staged` (reversible, dry-run by default).')
|
|
2068
|
+
lines.push('A default brain is NOT the answer for content — one standing rule cannot decide disclosure for mail nobody has read.')
|
|
2069
|
+
return { content: [{ type: 'text', text: withAskNotice(lines.join('\n')) }] }
|
|
2041
2070
|
},
|
|
2042
2071
|
)
|
|
2043
2072
|
|
|
@@ -2202,6 +2231,49 @@ function renderNudge(payload) {
|
|
|
2202
2231
|
},
|
|
2203
2232
|
)
|
|
2204
2233
|
|
|
2234
|
+
server.registerTool(
|
|
2235
|
+
'propose_obligation',
|
|
2236
|
+
{
|
|
2237
|
+
title: 'Propose something a record asks of this person',
|
|
2238
|
+
description:
|
|
2239
|
+
'Propose an obligation you found while READING — with the sentence that asks for it. This is the verb for ' +
|
|
2240
|
+
'"this email asks Theron for a call on Friday": it lands as a PROPOSAL the person confirms or dismisses, never ' +
|
|
2241
|
+
'as a commitment they made. Use it whenever something you read asks this person for something and no obligation ' +
|
|
2242
|
+
'covers it yet. ⚠ NOT the same tool as `track_obligation`, which records what the PERSON says they owe and ' +
|
|
2243
|
+
'carries no evidence — proposing through that verb makes a machine guess indistinguishable from their own word. ' +
|
|
2244
|
+
'⚠ `evidence` must be the VERBATIM sentence from that record, not your paraphrase: it is the whole reviewable ' +
|
|
2245
|
+
'unit, and the person decides on the strength of it. ⚠ NEVER INVENT A DATE. Copy the words that say when into ' +
|
|
2246
|
+
'`due_phrase` ("by Tuesday the 22nd", "this week") and the server works the date out against the record\'s own ' +
|
|
2247
|
+
'date; pass `due_at` only when you are reading a date the record states. No date is a proposal with no date, ' +
|
|
2248
|
+
'which is honest and still shows up.',
|
|
2249
|
+
inputSchema: {
|
|
2250
|
+
record: z.string().describe('the record the ask came from — an id from my_day, timeline_pull, list_records or find_sessions'),
|
|
2251
|
+
subject: z.string().describe('what must be done, one short phrase'),
|
|
2252
|
+
evidence: z.string().describe('the VERBATIM sentence from that record that asks for it'),
|
|
2253
|
+
due_phrase: z.string().optional().describe('the words that say WHEN, copied from the evidence sentence — the server resolves them'),
|
|
2254
|
+
due_at: z.string().optional().describe('YYYY-MM-DD, or YYYY-MM-DDTHH:MM when a time is stated. Only a date the record states.'),
|
|
2255
|
+
obligated_party: z.enum(['self', 'other', 'none']).optional().describe('self (default) = this person owes it; other = someone else; none = nobody'),
|
|
2256
|
+
},
|
|
2257
|
+
},
|
|
2258
|
+
async ({ record, subject, evidence, due_phrase, due_at, obligated_party }) => {
|
|
2259
|
+
const built = buildProposal({ subject, evidence, due_phrase, due_at, obligated_party })
|
|
2260
|
+
if (built.error) return toolError(`Not proposed: ${built.error}`)
|
|
2261
|
+
let res
|
|
2262
|
+
try {
|
|
2263
|
+
res = await fetchCortex(`${BASE}/api/obligations/candidates`, {
|
|
2264
|
+
method: 'POST',
|
|
2265
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
2266
|
+
body: JSON.stringify({ recordId: record, obligations: [built.candidate] }),
|
|
2267
|
+
})
|
|
2268
|
+
} catch (e) { return toolError(`Could not reach obligations: ${e.message}`) }
|
|
2269
|
+
const out = await res.json().catch(() => null)
|
|
2270
|
+
if (res.status === 404) return toolError('No such record of yours — pass a record id you can see (my_day, timeline_pull, list_records).')
|
|
2271
|
+
if (res.status === 409) return toolError('That capture is still sealed in private intake; materialize it first, then propose against its record.')
|
|
2272
|
+
if (!res.ok) return toolError(`Could not propose: ${out?.error ?? res.status}`)
|
|
2273
|
+
return { content: [{ type: 'text', text: renderProposal(out, built.candidate, built.note) }] }
|
|
2274
|
+
},
|
|
2275
|
+
)
|
|
2276
|
+
|
|
2205
2277
|
server.registerTool(
|
|
2206
2278
|
'resolve_obligation',
|
|
2207
2279
|
{
|
|
@@ -3157,7 +3229,7 @@ function renderNudge(payload) {
|
|
|
3157
3229
|
return toolError(`Could not build daily log: ${d.message}`)
|
|
3158
3230
|
}
|
|
3159
3231
|
const { text } = await res.json()
|
|
3160
|
-
return { content: [{ type: 'text', text }] }
|
|
3232
|
+
return { content: [{ type: 'text', text: withAskNotice(text) }] }
|
|
3161
3233
|
},
|
|
3162
3234
|
)
|
|
3163
3235
|
|
package/package.json
CHANGED