@theronap/cortex-mcp 0.9.99 → 0.9.101
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 +22 -13
- package/lib/edge_extract.mjs +61 -2
- package/lib/server.mjs +37 -1
- package/package.json +2 -2
package/lib/capture.mjs
CHANGED
|
@@ -232,7 +232,7 @@ export async function runCapture() {
|
|
|
232
232
|
// Recursion guard: edge extraction shells out to `claude --print` with CORTEX_SUMMARIZING=1. That
|
|
233
233
|
// headless session fires its own Stop hook → this same capture command. Without this guard it would
|
|
234
234
|
// recurse (and re-ingest the summarizer's prompt as a phantom session). Bail immediately.
|
|
235
|
-
if (process.env.CORTEX_SUMMARIZING) { process.stderr.write(
|
|
235
|
+
if (process.env.CORTEX_SUMMARIZING) { process.stderr.write(`cortex: ${new Date().toISOString()} (summarizer subprocess) skipping — recursion guard\n`); return }
|
|
236
236
|
|
|
237
237
|
const stdinRaw = readStdin()
|
|
238
238
|
|
|
@@ -291,14 +291,29 @@ function spawnDetachedWorker(stdinRaw) {
|
|
|
291
291
|
}
|
|
292
292
|
|
|
293
293
|
async function captureWork(stdinRaw) {
|
|
294
|
+
// Parse the hook payload FIRST so every diagnostic below can name its session.
|
|
295
|
+
//
|
|
296
|
+
// This used to sit after the token check, which meant the `no CORTEX_TOKEN` line — the one that
|
|
297
|
+
// fires on a whole misconfigured machine — was the only failure that could not say which session
|
|
298
|
+
// it lost. Parsing stdin depends on nothing, so the old order bought nothing.
|
|
299
|
+
let hook = {}
|
|
300
|
+
try { hook = JSON.parse(stdinRaw) } catch { /* no/invalid stdin */ }
|
|
301
|
+
|
|
302
|
+
// Every line in ~/.cortex/capture.log carries an ISO timestamp and the session id, so a silent
|
|
303
|
+
// stretch can be reconstructed afterwards and matched against `records` and
|
|
304
|
+
// `page_revisions.session_key`. A line without a session id is a line you cannot act on: it tells
|
|
305
|
+
// you something was lost and not what. That was the state of all five early returns below until
|
|
306
|
+
// now — visible since the log landed, but unattributable, which is the same shape as the defect
|
|
307
|
+
// this whole file has been chasing.
|
|
308
|
+
const stamp = new Date().toISOString()
|
|
309
|
+
const sid = hook.session_id ?? '(no session_id)'
|
|
310
|
+
|
|
294
311
|
// Env first (explicit override / legacy inlined hooks), else the token wired into this machine's
|
|
295
312
|
// MCP config — so hook commands carry no secret (token-hygiene, 2026-07-02).
|
|
296
313
|
const token = resolveTokenSource().token
|
|
297
|
-
if (!token) { process.stderr.write(
|
|
314
|
+
if (!token) { process.stderr.write(`cortex: ${stamp} ${sid} NOT RECORDED — no CORTEX_TOKEN in env or wired config\n`); return }
|
|
298
315
|
const base = resolveBase(process.env.CORTEX_URL)
|
|
299
316
|
|
|
300
|
-
let hook = {}
|
|
301
|
-
try { hook = JSON.parse(stdinRaw) } catch { /* no/invalid stdin */ }
|
|
302
317
|
const repo = projectFrom(hook.cwd)
|
|
303
318
|
// Redact credential-shaped strings BEFORE anything leaves the machine — this `transcript`
|
|
304
319
|
// feeds local edge/typed extraction AND the fallback POST to the org. A secret in a
|
|
@@ -322,11 +337,11 @@ async function captureWork(stdinRaw) {
|
|
|
322
337
|
// unreadable for a moment is picked up by the next turn, so a transient miss self-heals; only a
|
|
323
338
|
// genuinely persistence-disabled session is dropped, and that session has no content to capture.
|
|
324
339
|
if (hook.transcript_path && !transcriptReadable(hook.transcript_path)) {
|
|
325
|
-
process.stderr.write(
|
|
340
|
+
process.stderr.write(`cortex: ${stamp} ${sid} NOT RECORDED — transcript_path unreadable (session persistence disabled?)\n`)
|
|
326
341
|
return
|
|
327
342
|
}
|
|
328
343
|
|
|
329
|
-
if (!transcript && !hook.session_id) { process.stderr.write(
|
|
344
|
+
if (!transcript && !hook.session_id) { process.stderr.write(`cortex: ${stamp} ${sid} NOT RECORDED — empty session\n`); return }
|
|
330
345
|
|
|
331
346
|
// T11: digest node refs surfaced to this session (stashed by the MCP server's my_context, keyed by
|
|
332
347
|
// cwd). Forwarded as hydratedFrom so the materializer excludes this session from the digests it
|
|
@@ -370,7 +385,7 @@ async function captureWork(stdinRaw) {
|
|
|
370
385
|
...(hydratedFrom.length ? { hydratedFrom } : {}),
|
|
371
386
|
}
|
|
372
387
|
const extracted = transcript ? extractSession(transcript) : null
|
|
373
|
-
if (extracted && /^NOOP\b/i.test(extracted.summary)) { process.stderr.write(
|
|
388
|
+
if (extracted && /^NOOP\b/i.test(extracted.summary)) { process.stderr.write(`cortex: ${stamp} ${sid} NOT RECORDED — summarizer classified it a no-op session\n`); return }
|
|
374
389
|
const ingestBody = extracted
|
|
375
390
|
? { ...common, summary: extracted.summary, people: extracted.people, entities: extracted.namedEntities }
|
|
376
391
|
: { ...common, transcript }
|
|
@@ -388,12 +403,6 @@ async function captureWork(stdinRaw) {
|
|
|
388
403
|
} catch { /* best-effort — never block capture */ }
|
|
389
404
|
}
|
|
390
405
|
|
|
391
|
-
// Defined before the fetch so the throw path below can stamp its line the same way. Every line in
|
|
392
|
-
// capture.log carries an ISO timestamp and the session id, so a silent day can be reconstructed
|
|
393
|
-
// afterwards and matched against `records` / `page_revisions.session_key`.
|
|
394
|
-
const stamp = new Date().toISOString()
|
|
395
|
-
const sid = hook.session_id ?? '(no session_id)'
|
|
396
|
-
|
|
397
406
|
let res
|
|
398
407
|
try {
|
|
399
408
|
// Best-effort background ingest: bound it tight and retry once so a hanging/504-ing server
|
package/lib/edge_extract.mjs
CHANGED
|
@@ -105,9 +105,67 @@ const ENTITY_FRAGMENT =
|
|
|
105
105
|
'"importance" ("high" if central to the work, "low" if incidental). ' +
|
|
106
106
|
'Do NOT include people or companies (those go in "people"). Omit vague/generic mentions. Empty array is fine.)'
|
|
107
107
|
|
|
108
|
+
// PURE. The scheduled task this transcript is an unattended run of, or null for a human session.
|
|
109
|
+
// Claude Code wraps a scheduled run's instructions in <scheduled-task name="..." file="...">.
|
|
110
|
+
export function scheduledTaskName(transcript) {
|
|
111
|
+
const m = String(transcript ?? '').match(/<scheduled-task\s+[^>]*name="([^"]+)"/)
|
|
112
|
+
return m ? m[1] : null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// PURE. Drop the <scheduled-task>…</scheduled-task> blocks — they are the task's OWN INSTRUCTIONS,
|
|
116
|
+
// not anything the run observed, and Claude Code emits them TWICE per transcript. Measured
|
|
117
|
+
// 2026-08-19 on real units: ~2.1KB of instructions repeated, so ~4.2KB of the summarizer's 12KB
|
|
118
|
+
// window went to text describing what the agent was told to do before a single line of what it
|
|
119
|
+
// found. Stripping them spends the budget on the run's actual output.
|
|
120
|
+
export function stripScheduledTaskBlocks(transcript) {
|
|
121
|
+
return String(transcript ?? '').replace(/<scheduled-task\b[\s\S]*?<\/scheduled-task>/g, '').trim()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// The NOOP bar for an unattended run. The generic bar ("greetings, no tasks") does not fit these and
|
|
125
|
+
// was never meant to: a scheduled run DID do something — it read a file, evaluated a condition,
|
|
126
|
+
// maybe sent a notification — so a summarizer following the generic instruction correctly writes
|
|
127
|
+
// "Automated scripture study check ran; no confirmation for 2026-08-14; reminder sent." That is a
|
|
128
|
+
// faithful summary of a heartbeat, and it is why 36% of session records since 2026-08-01 are
|
|
129
|
+
// automation reporting its own execution. The summarizer was not malfunctioning; it was answering
|
|
130
|
+
// the question it was asked.
|
|
131
|
+
//
|
|
132
|
+
// The distinction that matters is not attended-vs-unattended, it is CHANGED-vs-UNCHANGED. A routine
|
|
133
|
+
// run that found the expected state is a heartbeat. The SAME task finding the domain finally
|
|
134
|
+
// reactivated, or the backlog crossing a threshold, is exactly the thing the knowledge base exists
|
|
135
|
+
// to hold — so this deliberately does not blanket-skip scheduled tasks, which would throw away the
|
|
136
|
+
// one run in a hundred that matters. There are 12 tasks configured and one of them alone fires up to
|
|
137
|
+
// 11 times a day.
|
|
138
|
+
// ⚠ THE WORDING HERE IS LOAD-BEARING AND WAS WRONG ON THE FIRST ATTEMPT. Verified 2026-08-19 against
|
|
139
|
+
// two real transcripts: a first draft ended "when it is genuinely borderline, prefer NOOP" and
|
|
140
|
+
// correctly suppressed the heartbeat — but ALSO suppressed a run of the same task that had flagged a
|
|
141
|
+
// live read path into the frozen Robin archive, a genuine finding. Every unit test still passed,
|
|
142
|
+
// because they cover the stripping, not the judgement. The prompt was killing signal with the noise.
|
|
143
|
+
//
|
|
144
|
+
// Two fixes: the borderline tilt is gone (it resolved every mixed case toward silence), and an
|
|
145
|
+
// incidental observation now counts explicitly — these findings arrive as an aside at the END of an
|
|
146
|
+
// otherwise routine report, which is exactly where a "was this routine?" reading drops them.
|
|
147
|
+
const scheduledTaskFragment = (name) =>
|
|
148
|
+
'\n\nIMPORTANT — this session is an UNATTENDED run of the scheduled task "' + name + '". ' +
|
|
149
|
+
'Its instructions have been removed; what remains is only what the run reported. ' +
|
|
150
|
+
'Judge what it FOUND and what it CHANGED — never merely that it executed. ' +
|
|
151
|
+
'If the run only confirmed the state it expected and changed nothing, set summary to exactly ' +
|
|
152
|
+
'"NOOP": a routine run that found nothing new is a heartbeat, not knowledge, and these fire many ' +
|
|
153
|
+
'times a day. ' +
|
|
154
|
+
'BUT summarize whenever the run found something changed, hit an error, took an action with a ' +
|
|
155
|
+
'lasting effect, OR raised a problem, risk or observation the operator would not already know — ' +
|
|
156
|
+
'INCLUDING one mentioned only in passing at the very end of an otherwise routine report. ' +
|
|
157
|
+
'One genuine finding outweighs an otherwise unremarkable run; summarize the FINDING, not the run.'
|
|
158
|
+
|
|
108
159
|
export function extractSession(transcript) {
|
|
109
|
-
const
|
|
110
|
-
if (!
|
|
160
|
+
const raw = (transcript ?? '').trim()
|
|
161
|
+
if (!raw || process.env.CORTEX_SUMMARIZE_DISABLED) return null
|
|
162
|
+
const task = scheduledTaskName(raw)
|
|
163
|
+
// Strip instructions only for scheduled runs; a human transcript has no such blocks and is passed
|
|
164
|
+
// through untouched, so this cannot change how ordinary sessions are summarized.
|
|
165
|
+
const text = task ? stripScheduledTaskBlocks(raw) : raw
|
|
166
|
+
// A run whose entire transcript WAS the instruction block leaves nothing to judge. That is a
|
|
167
|
+
// heartbeat by definition — skip without spending a `claude -p` call on it.
|
|
168
|
+
if (task && !text) return { summary: 'NOOP', people: [], namedEntities: [] }
|
|
111
169
|
const prompt =
|
|
112
170
|
'You are processing a Claude Code work session for a knowledge base. Return ONLY minified JSON ' +
|
|
113
171
|
'(no prose, no markdown fences) with EXACTLY these three keys:\n' +
|
|
@@ -115,6 +173,7 @@ export function extractSession(transcript) {
|
|
|
115
173
|
'had no real work — greetings, no tasks — set summary to exactly "NOOP"),\n' +
|
|
116
174
|
PEOPLE_FRAGMENT + ',\n' +
|
|
117
175
|
ENTITY_FRAGMENT +
|
|
176
|
+
(task ? scheduledTaskFragment(task) : '') +
|
|
118
177
|
'\n\n--- SESSION ---\n' + text.slice(0, 12000) + '\n--- END ---'
|
|
119
178
|
// Single-flight: if another session already has a summarizer running, skip (caller ships the tail;
|
|
120
179
|
// server summarizes). Prevents the concurrent-`claude -p` stampede that deadlocks the OAuth refresh.
|
package/lib/server.mjs
CHANGED
|
@@ -662,6 +662,7 @@ export async function runServer(version) {
|
|
|
662
662
|
inputSchema: {
|
|
663
663
|
claimKind: z.enum(['relevance', 'cleanup']).optional().describe('default relevance'),
|
|
664
664
|
limit: z.number().optional().describe('max items (default 10). Ignored when intakeItemIds is given.'),
|
|
665
|
+
leaseMinutes: z.number().optional().describe('lease length in minutes, 5-60 (default 30). Prefer intake_look for read-only inspection — a short lease is only for briefly exclusive work.'),
|
|
665
666
|
intakeItemIds: z.array(z.string()).optional().describe(
|
|
666
667
|
'claim these specific units instead of the head of the queue (max 50). Without it you get FIFO, ' +
|
|
667
668
|
'so reaching one known unit means claiming everything ahead of it. Ids come from intake_changes ' +
|
|
@@ -673,7 +674,7 @@ export async function runServer(version) {
|
|
|
673
674
|
),
|
|
674
675
|
},
|
|
675
676
|
},
|
|
676
|
-
async ({ claimKind, limit, intakeItemIds }) => {
|
|
677
|
+
async ({ claimKind, limit, intakeItemIds, leaseMinutes }) => {
|
|
677
678
|
const res = await fetchCortex(`${BASE}/api/intake/claim`, {
|
|
678
679
|
method: 'POST',
|
|
679
680
|
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
@@ -681,6 +682,7 @@ export async function runServer(version) {
|
|
|
681
682
|
claimKind: claimKind ?? 'relevance',
|
|
682
683
|
limit,
|
|
683
684
|
includePayload: true,
|
|
685
|
+
...(leaseMinutes ? { leaseMinutes } : {}),
|
|
684
686
|
// Forwarded only when present. An empty array is a real request to claim nothing and must
|
|
685
687
|
// survive as one; sending `[]` where the caller sent nothing would silently switch a FIFO
|
|
686
688
|
// claim into a no-op.
|
|
@@ -883,6 +885,40 @@ export async function runServer(version) {
|
|
|
883
885
|
},
|
|
884
886
|
)
|
|
885
887
|
|
|
888
|
+
server.registerTool(
|
|
889
|
+
'intake_look',
|
|
890
|
+
{
|
|
891
|
+
title: 'Read an intake unit without claiming it',
|
|
892
|
+
description:
|
|
893
|
+
'Read a PENDING intake unit\'s FULL decrypted body without taking a lease — a read RECEIPT, not a claim (ADR-0035). Use it when the settling feed\'s headline + candidate identifiers cannot tell you whether the unit belongs to your current work; looking is non-exclusive (any number of sessions may inspect the same unit) and always logged, so look freely rather than guessing from the headline — diligence is subsidized here. After looking: claim it if it is yours to process, or simply move on — no release needed, you never held it. The response includes how many sessions have looked (`looks`/`distinctLookers`): several looks and no claim is a sign the unit needs the triage agent or the owner, not another look. Resolved units are refused — read those as records.',
|
|
894
|
+
inputSchema: {
|
|
895
|
+
intakeItemId: z.string().describe('intake item uuid, from the settling feed (intake_changes) or intake_claim'),
|
|
896
|
+
workingIdentifiers: z.array(z.string()).optional().describe('the identifiers your current work touches (repo:…, file:…, project:…) — stored on the receipt; feeds look→claim conversion analysis'),
|
|
897
|
+
},
|
|
898
|
+
},
|
|
899
|
+
async ({ intakeItemId, workingIdentifiers }) => {
|
|
900
|
+
let res
|
|
901
|
+
try {
|
|
902
|
+
res = await fetchCortex(`${BASE}/api/intake/look`, {
|
|
903
|
+
method: 'POST',
|
|
904
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
905
|
+
body: JSON.stringify({ intakeItemId, workingIdentifiers: workingIdentifiers ?? [] }),
|
|
906
|
+
})
|
|
907
|
+
} catch (e) {
|
|
908
|
+
return toolError(`Could not look: ${e.message}`)
|
|
909
|
+
}
|
|
910
|
+
const out = await res.json().catch(() => null)
|
|
911
|
+
if (!res.ok) {
|
|
912
|
+
if (out?.error === 'not_lookable') {
|
|
913
|
+
return toolError(`Not lookable: the unit is ${out?.state ?? 'resolved'} — resolved units are read as records, not looks.`)
|
|
914
|
+
}
|
|
915
|
+
if (out?.error === 'not_found') return toolError('No such intake unit in your account.')
|
|
916
|
+
return toolError(`Could not look: ${out?.error ?? res.status}${out?.detail ? ` — ${out.detail}` : ''}`)
|
|
917
|
+
}
|
|
918
|
+
return { content: [{ type: 'text', text: JSON.stringify(out, null, 2) }] }
|
|
919
|
+
},
|
|
920
|
+
)
|
|
921
|
+
|
|
886
922
|
server.registerTool(
|
|
887
923
|
'intake_cleanup_status',
|
|
888
924
|
{
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@theronap/cortex-mcp",
|
|
3
|
-
"version": "0.9.
|
|
4
|
-
"description": "Connect your AI assistant to Cortex
|
|
3
|
+
"version": "0.9.101",
|
|
4
|
+
"description": "Connect your AI assistant to Cortex \u2014 your org's projects, activity, gaps, and directives, scoped to you.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"cortex-mcp": "bin/cortex-mcp.mjs"
|