@theronap/cortex-mcp 0.9.87 → 0.9.89
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 +131 -7
- package/package.json +1 -1
- package/skills/log/SKILL.md +16 -3
package/lib/server.mjs
CHANGED
|
@@ -192,15 +192,17 @@ 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) if you have it so this dedupes with the auto-capture of the same session. If you belong to more than one brain, pass `brain` — without it a session log has no route and is STAGED rather than recorded.',
|
|
196
196
|
inputSchema: {
|
|
197
197
|
summary: z.string().describe('the curated session summary (what was done, decided, left open) — becomes the durable record'),
|
|
198
198
|
project: z.string().optional().describe('project key/name this session worked in'),
|
|
199
199
|
title: z.string().optional().describe('short title for the session'),
|
|
200
200
|
sessionId: z.string().optional().describe('the Claude Code session id (dedupes with the auto-capture hook of the same session)'),
|
|
201
|
+
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.'),
|
|
202
|
+
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
203
|
},
|
|
202
204
|
},
|
|
203
|
-
async ({ summary, project, title, sessionId }) => {
|
|
205
|
+
async ({ summary, project, title, sessionId, brain, privacy }) => {
|
|
204
206
|
const res = await fetchCortex(`${BASE}/api/ingest`, {
|
|
205
207
|
method: 'POST',
|
|
206
208
|
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
@@ -211,6 +213,13 @@ export async function runServer(version) {
|
|
|
211
213
|
...(project ? { project } : {}),
|
|
212
214
|
...(title ? { title } : {}),
|
|
213
215
|
...(sessionId ? { sessionId } : {}),
|
|
216
|
+
// ADR-0022 deleted the write pointer, so a session-class source routes ONLY by an explicit
|
|
217
|
+
// brain or a sole membership — anything else STAGES. This tool never sent one, so every
|
|
218
|
+
// close-out from a multi-brain member landed in staged_records instead of the org. Measured
|
|
219
|
+
// 2026-08-09: 86 staged rows, and the pile is not drainable by /api/staged/promote because
|
|
220
|
+
// promote resolves through the same branch session-class sources skip.
|
|
221
|
+
...(brain ? { brain } : {}),
|
|
222
|
+
...(privacy ? { privacy } : {}),
|
|
214
223
|
payload: { via: 'log_session' },
|
|
215
224
|
}),
|
|
216
225
|
})
|
|
@@ -219,7 +228,26 @@ export async function runServer(version) {
|
|
|
219
228
|
throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
|
|
220
229
|
}
|
|
221
230
|
const j = await res.json().catch(() => ({}))
|
|
222
|
-
|
|
231
|
+
|
|
232
|
+
// NEVER report a non-record as "logged". `staged` and `skipped` are 200 OK responses that wrote
|
|
233
|
+
// no record, and this line used to print "Logged to Agnoclast (authoritative) … updated existing"
|
|
234
|
+
// for both — `j.inserted` is merely falsy on a staged write, which reads as an UPDATE. That is
|
|
235
|
+
// how a session close-out was reported as saved when it had not been (2026-08-09), and it is the
|
|
236
|
+
// same failure shape the cortex-log skill warns about for `author`: a success string over a write
|
|
237
|
+
// that did not land. The caller cannot tell the difference, so the message has to.
|
|
238
|
+
if (j.staged) {
|
|
239
|
+
const why = j.reason === 'no_route_for_source' && !brain
|
|
240
|
+
? 'no brain was named and you belong to more than one, so it had nowhere to route'
|
|
241
|
+
: `reason: ${j.reason ?? 'unknown'}`
|
|
242
|
+
return { content: [{ type: 'text', text: `NOT LOGGED — STAGED, not recorded (${why}). Re-run log_session with brain:"<name>" to record it. Staged session logs cannot currently be drained by /api/staged/promote.` }] }
|
|
243
|
+
}
|
|
244
|
+
if (j.skipped) {
|
|
245
|
+
return { content: [{ type: 'text', text: `NOT LOGGED — the server skipped this write: ${j.skipped}` }] }
|
|
246
|
+
}
|
|
247
|
+
if (!j.id) {
|
|
248
|
+
return { content: [{ type: 'text', text: `NOT LOGGED — the server returned no record id. Raw response: ${JSON.stringify(j).slice(0, 300)}` }] }
|
|
249
|
+
}
|
|
250
|
+
return { content: [{ type: 'text', text: `Logged to Agnoclast (authoritative): "${j.title ?? title ?? 'session'}" — ${j.inserted ? 'new record' : 'updated existing'} (id ${j.id}).` }] }
|
|
223
251
|
},
|
|
224
252
|
)
|
|
225
253
|
|
|
@@ -486,6 +514,45 @@ export async function runServer(version) {
|
|
|
486
514
|
},
|
|
487
515
|
)
|
|
488
516
|
|
|
517
|
+
server.registerTool(
|
|
518
|
+
'intake_discard',
|
|
519
|
+
{
|
|
520
|
+
title: 'Discard a private intake item',
|
|
521
|
+
description:
|
|
522
|
+
'The OTHER terminal outcome for a claimed intake unit: this is nothing, drop it. Use it for content that should never become a record — unsubscribe receipts, empty greetings, marketing blasts, a stranger\'s photo — instead of materializing junk into a brain because materialize was the only verb available. IRREVERSIBLE: the ciphertext and nonces are destroyed in the same transaction, and a content-free tombstone stops the connector re-delivering the unit. You must already hold the claim (discarding something you never read is refused), `reason` is required and is stored, and it is one item per call — a loop discarding a whole pile on one decision is a bulk job, not judgment.',
|
|
523
|
+
inputSchema: {
|
|
524
|
+
intakeItemId: z.string().describe('intake item uuid from intake_claim'),
|
|
525
|
+
reason: z.string().describe('why this is nothing — recorded on the claim, and the only surviving trace of the decision'),
|
|
526
|
+
},
|
|
527
|
+
},
|
|
528
|
+
async ({ intakeItemId, reason }) => {
|
|
529
|
+
let res
|
|
530
|
+
try {
|
|
531
|
+
res = await fetchCortex(`${BASE}/api/intake/discard`, {
|
|
532
|
+
method: 'POST',
|
|
533
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
534
|
+
body: JSON.stringify({ intakeItemId, reason }),
|
|
535
|
+
})
|
|
536
|
+
} catch (e) {
|
|
537
|
+
return toolError(`Could not discard: ${e.message}`)
|
|
538
|
+
}
|
|
539
|
+
const out = await res.json().catch(() => null)
|
|
540
|
+
if (!res.ok) {
|
|
541
|
+
if (out?.error === 'not_claimed') {
|
|
542
|
+
return toolError('You do not hold a claim on that item — intake_claim it first, so the discard follows from having read it.')
|
|
543
|
+
}
|
|
544
|
+
if (out?.error === 'already_materialized') {
|
|
545
|
+
return toolError('That unit already became a record. Discarding it now would orphan the record from its source — detach or retier the record instead.')
|
|
546
|
+
}
|
|
547
|
+
return toolError(`Could not discard: ${out?.error ?? res.status}${out?.detail ? ` — ${out.detail}` : ''}`)
|
|
548
|
+
}
|
|
549
|
+
if (out?.alreadyDiscarded) {
|
|
550
|
+
return { content: [{ type: 'text', text: 'Already discarded — nothing to do.' }] }
|
|
551
|
+
}
|
|
552
|
+
return { content: [{ type: 'text', text: 'Discarded. Content destroyed, tombstone written so the source cannot re-deliver it.' }] }
|
|
553
|
+
},
|
|
554
|
+
)
|
|
555
|
+
|
|
489
556
|
server.registerTool(
|
|
490
557
|
'intake_cleanup_status',
|
|
491
558
|
{
|
|
@@ -1654,16 +1721,19 @@ export async function runServer(version) {
|
|
|
1654
1721
|
},
|
|
1655
1722
|
)
|
|
1656
1723
|
|
|
1657
|
-
// ── Gate 4 record triage
|
|
1724
|
+
// ── Gate 4 record triage, over timeline_claims ────────────────────────────────────────────
|
|
1658
1725
|
// A connector event materializes in seconds and has no idea what the work WAS. The session that
|
|
1659
1726
|
// did the work knows exactly, and arrives later. These three tools are that handoff: look at what
|
|
1660
1727
|
// landed, claim what is yours, route it when you know where it goes.
|
|
1728
|
+
//
|
|
1729
|
+
// The ledger is `timeline_claims` (0105) — one claim discipline over one stream. Unclaimed means
|
|
1730
|
+
// NO claim row: absence IS the backlog, and nothing is written at ingest.
|
|
1661
1731
|
|
|
1662
1732
|
server.registerTool(
|
|
1663
1733
|
'pending_records',
|
|
1664
1734
|
{
|
|
1665
1735
|
title: 'Records waiting for a home',
|
|
1666
|
-
description: 'List connector records (GitHub pushes, PRs, email) that
|
|
1736
|
+
description: 'List recent connector records (GitHub pushes, PRs, email) that NOBODY HAS ATTENDED TO yet — no claim row in the Gate 4 ledger. Check this when your session starts if the headline count sounds related to what you are about to work on; records from your own recent commits are usually in here, and you are the only one who can recognize them as yours. Returns titles and current homes only, never payloads. Use view "sweep" on one record to get graded page-name candidates without reading any pages.',
|
|
1667
1737
|
inputSchema: {
|
|
1668
1738
|
view: z.enum(['digest', 'sweep']).optional().describe('digest = what is waiting (default); sweep = cheap graded candidates for one record'),
|
|
1669
1739
|
recordId: z.string().optional().describe('required for view "sweep"'),
|
|
@@ -1728,7 +1798,7 @@ export async function runServer(version) {
|
|
|
1728
1798
|
'claim_record',
|
|
1729
1799
|
{
|
|
1730
1800
|
title: 'Claim a pending record as your work',
|
|
1731
|
-
description: 'Say "this record is mine, I will route it once I know where it goes." Use it as soon as you recognize your own work in pending_records, even before you know the destination page — the claim
|
|
1801
|
+
description: 'Say "this record is mine, I will route it once I know where it goes." Use it as soon as you recognize your own work in pending_records, even before you know the destination page — the claim takes it out of the backlog so nothing else guesses at something you have real context on. Claims are leased and expire, so a dead session never holds a record hostage, and a record held by another LIVE session cannot be taken. Pass release=true to give one back when it turns out not to be yours — that deletes the claim, so the record looks untouched again rather than attended-to.',
|
|
1732
1802
|
inputSchema: {
|
|
1733
1803
|
recordId: z.string().describe('record id from pending_records'),
|
|
1734
1804
|
note: z.string().optional().describe('what you think this is — kept for audit'),
|
|
@@ -1750,7 +1820,16 @@ export async function runServer(version) {
|
|
|
1750
1820
|
const out = await res.json().catch(() => null)
|
|
1751
1821
|
if (!res.ok) {
|
|
1752
1822
|
if (out?.error === 'already_claimed') {
|
|
1753
|
-
|
|
1823
|
+
// Name the holder when the server knows it. The fallback matters: `heldBy` is absent when
|
|
1824
|
+
// nothing holds a live lease, which means the record was not claimable rather than taken —
|
|
1825
|
+
// printing "another session has it" there sends the reader chasing a session that does not
|
|
1826
|
+
// exist. (This branch printed a bare `undefined` until 2026-08-14; the field was renamed
|
|
1827
|
+
// server-side and the tool was never updated, which is the whole reason it says both now.)
|
|
1828
|
+
if (out.heldBy) {
|
|
1829
|
+
const until = out.heldUntil ? `, lease to ${out.heldUntil}` : ''
|
|
1830
|
+
return toolError(`Session ${String(out.heldBy).slice(0, 16)}… is holding that record${until}. Leave it to them.`)
|
|
1831
|
+
}
|
|
1832
|
+
return toolError(out.detail || 'Could not claim that record and no session holds it — re-run pending_records; it may have been resolved already.')
|
|
1754
1833
|
}
|
|
1755
1834
|
return toolError(`Could not claim record: ${out?.error ?? res.status}`)
|
|
1756
1835
|
}
|
|
@@ -1790,6 +1869,51 @@ export async function runServer(version) {
|
|
|
1790
1869
|
},
|
|
1791
1870
|
)
|
|
1792
1871
|
|
|
1872
|
+
server.registerTool(
|
|
1873
|
+
'unroute_record',
|
|
1874
|
+
{
|
|
1875
|
+
title: 'Remove pages a record should not be on',
|
|
1876
|
+
description:
|
|
1877
|
+
'Detach pages a record does not belong on — the inverse of route_record, and the only way a wrong placement can be undone. route_record is purely ADDITIVE, so attaching more pages can never fix a bad one. Use this when you can see a record sitting on a page it has no real relationship to — the classic case is a fuzzy title match, e.g. a commit attached to a page merely because both contain a common word. Two things it will refuse rather than surprise you: it will not remove every attachment (a record with no home is invisible, which is worse than a wrong home — route or park it instead), and detaching the page that GOVERNS the tier can tighten the record but never republish it, since a widening is pinned and proposed for a human to confirm.',
|
|
1878
|
+
inputSchema: {
|
|
1879
|
+
recordId: z.string().describe('record id from pending_records'),
|
|
1880
|
+
documentIds: z.array(z.string()).describe('page document ids to REMOVE from this record'),
|
|
1881
|
+
reason: z.string().describe('why these placements are wrong — recorded with the removal'),
|
|
1882
|
+
},
|
|
1883
|
+
},
|
|
1884
|
+
async ({ recordId, documentIds, reason }) => {
|
|
1885
|
+
let res
|
|
1886
|
+
try {
|
|
1887
|
+
res = await fetchCortex(`${BASE}/api/brain/triage`, {
|
|
1888
|
+
method: 'POST',
|
|
1889
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
1890
|
+
body: JSON.stringify({ action: 'detach', recordId, documentIds, reason }),
|
|
1891
|
+
})
|
|
1892
|
+
} catch (e) {
|
|
1893
|
+
return toolError(`Could not detach: ${e.message}`)
|
|
1894
|
+
}
|
|
1895
|
+
const out = await res.json().catch(() => null)
|
|
1896
|
+
if (!res.ok) {
|
|
1897
|
+
// These two are guard rails, not faults — say what to do instead of just naming the code.
|
|
1898
|
+
if (out?.error === 'would_strand') {
|
|
1899
|
+
return toolError(`Refused: ${out.detail ?? 'that would leave the record with no pages at all.'}`)
|
|
1900
|
+
}
|
|
1901
|
+
if (out?.error === 'not_attached') {
|
|
1902
|
+
return toolError(`Nothing removed: ${out.detail ?? 'those pages are not attached to this record.'}`)
|
|
1903
|
+
}
|
|
1904
|
+
return toolError(`Could not detach: ${out?.error ?? res.status}${out?.detail ? ` — ${out.detail}` : ''}`)
|
|
1905
|
+
}
|
|
1906
|
+
const n = out?.detached?.length ?? 0
|
|
1907
|
+
const left = out?.remaining ?? 0
|
|
1908
|
+
return {
|
|
1909
|
+
content: [{
|
|
1910
|
+
type: 'text',
|
|
1911
|
+
text: `Detached ${n} page(s); ${left} attachment(s) remain. Record tier: ${out?.privacy ?? 'unchanged'}. Recorded as a session judgment.`,
|
|
1912
|
+
}],
|
|
1913
|
+
}
|
|
1914
|
+
},
|
|
1915
|
+
)
|
|
1916
|
+
|
|
1793
1917
|
server.registerTool(
|
|
1794
1918
|
'snooze_red_link',
|
|
1795
1919
|
{
|
package/package.json
CHANGED
package/skills/log/SKILL.md
CHANGED
|
@@ -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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|