@theronap/cortex-mcp 0.9.137 → 0.9.139
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/bin/cortex-mcp.mjs +30 -0
- package/lib/edge_extract.mjs +87 -5
- package/package.json +1 -1
package/bin/cortex-mcp.mjs
CHANGED
|
@@ -28,6 +28,36 @@ const VERSION = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.u
|
|
|
28
28
|
const cmd = process.argv[2]
|
|
29
29
|
const rest = process.argv.slice(3)
|
|
30
30
|
|
|
31
|
+
// ── The summarizer subprocess runs NO context hooks ──────────────────────────────────────────────
|
|
32
|
+
//
|
|
33
|
+
// Edge extraction shells out to `claude --print` with CORTEX_SUMMARIZING=1. That headless session
|
|
34
|
+
// fires the SAME hook chain as a real one, and on a wired seat that is five SessionStart commands
|
|
35
|
+
// plus hydrate on UserPromptSubmit — four of which make network calls to Agnoclast.
|
|
36
|
+
//
|
|
37
|
+
// `capture` (Stop) has guarded on this env var since the recursion fix. Nothing else did, so every
|
|
38
|
+
// summarizer call still paid for snapshot-context + status + skills + hydrate before the model saw
|
|
39
|
+
// a single token of the prompt. Two consequences, both measured live 2026-09-08:
|
|
40
|
+
//
|
|
41
|
+
// 1. LATENCY. Extraction ran 45-68s against a 45s timeout and failed as often as it succeeded.
|
|
42
|
+
// A one-off `claude --print 'say ok'` on the same credential answers in about a second.
|
|
43
|
+
// 2. POLLUTED OUTPUT. `status` prints the pending-records block to stdout, so the summarizer
|
|
44
|
+
// answered THAT before the prompt: its stdout began "You have 17 intake units in the cleanup
|
|
45
|
+
// queue ... Want me to help with either?" ahead of the JSON. The extractor asks for "ONLY
|
|
46
|
+
// minified JSON"; a hook prepending prose to the model's input is how that instruction gets
|
|
47
|
+
// overridden by the system itself.
|
|
48
|
+
//
|
|
49
|
+
// The guard is here, at the dispatcher, rather than in each subcommand: it is one rule about what a
|
|
50
|
+
// summarizer subprocess IS, and putting it in five places is how the sixth gets forgotten. Exit 0
|
|
51
|
+
// and silent — a hook that errors is noise, and there is genuinely nothing to do.
|
|
52
|
+
//
|
|
53
|
+
// ⚠ NOT guarded: `capture` (has its own, with a diagnostic), and anything a human might run by hand
|
|
54
|
+
// while this var happens to be set. Only the ambient context commands are listed.
|
|
55
|
+
const CONTEXT_HOOK_COMMANDS = new Set(['snapshot-context', 'status', 'hydrate', 'skills'])
|
|
56
|
+
if (process.env.CORTEX_SUMMARIZING && CONTEXT_HOOK_COMMANDS.has(cmd)) {
|
|
57
|
+
process.stderr.write(`cortex: ${cmd} skipped — summarizer subprocess (CORTEX_SUMMARIZING)\n`)
|
|
58
|
+
process.exit(0)
|
|
59
|
+
}
|
|
60
|
+
|
|
31
61
|
if (cmd === '--version' || cmd === '-v') {
|
|
32
62
|
process.stdout.write(`cortex-mcp ${VERSION}\n`)
|
|
33
63
|
process.exit(0)
|
package/lib/edge_extract.mjs
CHANGED
|
@@ -216,22 +216,104 @@ export function extractSession(transcript) {
|
|
|
216
216
|
'\n\n--- SESSION ---\n' + text.slice(0, 12000) + '\n--- END ---'
|
|
217
217
|
// Single-flight: if another session already has a summarizer running, skip (caller ships the tail;
|
|
218
218
|
// server summarizes). Prevents the concurrent-`claude -p` stampede that deadlocks the OAuth refresh.
|
|
219
|
-
if (!acquireSummaryLock()) return
|
|
219
|
+
if (!acquireSummaryLock()) return edgeSkip('busy', 'another session is summarizing; the tail ships instead')
|
|
220
220
|
try {
|
|
221
221
|
const r = spawnSync(
|
|
222
222
|
'claude',
|
|
223
223
|
['--print', '--model', process.env.CORTEX_SUMMARY_MODEL ?? 'claude-haiku-4-5', prompt],
|
|
224
224
|
{ env: edgeSafeEnv(process.env, { CORTEX_SUMMARIZING: '1' }), encoding: 'utf8', timeout: summaryTimeoutMs(), maxBuffer: 4 * 1024 * 1024 },
|
|
225
225
|
)
|
|
226
|
-
|
|
227
|
-
return
|
|
228
|
-
|
|
229
|
-
return
|
|
226
|
+
const why = classifyEdgeFailure(r)
|
|
227
|
+
if (why) return edgeSkip(why.reason, why.detail)
|
|
228
|
+
const parsed = parseEdgeJson(r.stdout.trim())
|
|
229
|
+
if (!parsed) return edgeSkip('unparseable', 'the model returned output that is not the expected JSON')
|
|
230
|
+
return parsed
|
|
231
|
+
} catch (e) {
|
|
232
|
+
return edgeSkip('threw', e instanceof Error ? e.message : String(e))
|
|
230
233
|
} finally {
|
|
231
234
|
releaseSummaryLock()
|
|
232
235
|
}
|
|
233
236
|
}
|
|
234
237
|
|
|
238
|
+
// ── WHY THE EXTRACTION DID NOT RUN ───────────────────────────────────────────────────────────────
|
|
239
|
+
//
|
|
240
|
+
// Every failure path here returns null, and the caller correctly treats null as "ship the transcript
|
|
241
|
+
// tail and let the server summarize". That fallback is right. What was wrong is that FIVE different
|
|
242
|
+
// failures were indistinguishable, and one of them never fixes itself.
|
|
243
|
+
//
|
|
244
|
+
// 🔴 THE EVIDENCE. Found 2026-09-08: `claude --print` had been exiting 1 with
|
|
245
|
+
// "Failed to authenticate: OAuth session expired and could not be refreshed" — so edge extraction
|
|
246
|
+
// returned null for EVERY session, on every seat with an expired token. Nothing said so anywhere.
|
|
247
|
+
// It was invisible because the fallback works: 51 of 51 `claude-code` records over the preceding ten
|
|
248
|
+
// days carried a summary, because the SERVER wrote them. A green outcome over a dead mechanism.
|
|
249
|
+
//
|
|
250
|
+
// Two things were silently untrue for however long that lasted:
|
|
251
|
+
// 1. this module's own header — "the cloud then receives only the derived digest, never the raw
|
|
252
|
+
// transcript" — since the fallback ships the (redacted) tail instead;
|
|
253
|
+
// 2. anything downstream of the extraction, including ADR-0055's page proposals, which cannot
|
|
254
|
+
// exist if the call that would make them never returns.
|
|
255
|
+
//
|
|
256
|
+
// A busy lock and an expired credential are not the same event: the first resolves itself on the
|
|
257
|
+
// next session, the second needs a human to run `claude setup-token` and will otherwise never
|
|
258
|
+
// recover. Collapsing them into one silent `return null` is the same defect family as
|
|
259
|
+
// claimRecordForTriage's "probably in the future" — a message that names one cause for many.
|
|
260
|
+
function edgeSkip(reason, detail) {
|
|
261
|
+
// stderr, not stdout: stdout of a Stop hook is not read, and anything written there would land in
|
|
262
|
+
// the transcript of the NEXT capture. Prefixed so `cortex doctor` and a log grep can find it.
|
|
263
|
+
process.stderr.write(`cortex: edge extraction SKIPPED [${reason}] ${detail}\n`)
|
|
264
|
+
return null
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* PURE (unit-tested): classify a finished `claude --print` result, or null when it succeeded.
|
|
269
|
+
*
|
|
270
|
+
* Exported so the failure taxonomy is testable without spawning anything — the auth case in
|
|
271
|
+
* particular could not otherwise be covered, and it is the one that matters most.
|
|
272
|
+
*/
|
|
273
|
+
export function classifyEdgeFailure(r) {
|
|
274
|
+
if (!r) return { reason: 'no-result', detail: 'spawn returned nothing' }
|
|
275
|
+
// spawn itself failed — almost always ENOENT, i.e. `claude` is not on the hook's PATH. A Stop hook
|
|
276
|
+
// runs with a login shell's PATH, which is not always the interactive one.
|
|
277
|
+
if (r.error) {
|
|
278
|
+
if (r.error.code === 'ENOENT') {
|
|
279
|
+
return { reason: 'no-claude', detail: '`claude` is not on PATH for this hook; edge extraction cannot run here' }
|
|
280
|
+
}
|
|
281
|
+
// ⚠ A spawnSync TIMEOUT surfaces here, as error.code ETIMEDOUT — not only as a signal. Observed
|
|
282
|
+
// live 2026-09-08: `[spawn-failed] spawnSync claude ETIMEDOUT` at 47.3s against a 45s limit,
|
|
283
|
+
// which reads as "the spawn broke" when the truth is "it ran and was too slow". Checking the
|
|
284
|
+
// signal alone missed it, so both are checked; this classifier's own first outing found this.
|
|
285
|
+
if (r.error.code === 'ETIMEDOUT') {
|
|
286
|
+
return { reason: 'timeout', detail: `exceeded ${summaryTimeoutMs()}ms` }
|
|
287
|
+
}
|
|
288
|
+
return { reason: 'spawn-failed', detail: r.error.message }
|
|
289
|
+
}
|
|
290
|
+
// The other shape of the same event: spawnSync killed the child, so status is null and signal set.
|
|
291
|
+
if (r.signal) return { reason: 'timeout', detail: `killed by ${r.signal} after ${summaryTimeoutMs()}ms` }
|
|
292
|
+
|
|
293
|
+
const out = `${r.stdout ?? ''}\n${r.stderr ?? ''}`
|
|
294
|
+
// ⚠ THE AUTH MESSAGE ARRIVES ON **STDOUT**, WITH EXIT 1 — not on stderr, which is where a reader
|
|
295
|
+
// would look for it. Verified against the live failure 2026-09-08:
|
|
296
|
+
// status 1, stderr "", stdout "Failed to authenticate: OAuth session expired and could not be
|
|
297
|
+
// refreshed". Matching only stderr would classify this as a plain non-zero exit and lose the one
|
|
298
|
+
// fact that tells a human what to do.
|
|
299
|
+
if (/oauth|authenticat|not logged in|401|unauthorized/i.test(out)) {
|
|
300
|
+
return {
|
|
301
|
+
reason: 'auth-expired',
|
|
302
|
+
detail: 'the subscription login for headless `claude --print` is dead. Run `claude setup-token`, '
|
|
303
|
+
+ 'then set CLAUDE_CODE_OAUTH_TOKEN in ~/.claude/settings.json (env). '
|
|
304
|
+
+ 'Until then EVERY session ships its transcript tail to the server instead of a local digest, '
|
|
305
|
+
+ 'and no page proposals are made.',
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (r.status !== 0) return { reason: 'exit', detail: `claude exited ${r.status}: ${firstLine(out)}` }
|
|
309
|
+
if (!r.stdout) return { reason: 'no-output', detail: 'claude exited 0 with empty stdout' }
|
|
310
|
+
return null
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function firstLine(s) {
|
|
314
|
+
return String(s ?? '').split('\n').map((l) => l.trim()).filter(Boolean)[0] ?? '(no output)'
|
|
315
|
+
}
|
|
316
|
+
|
|
235
317
|
// Tolerant JSON extraction: the model may wrap output in prose / ```json fences, so grab the first
|
|
236
318
|
// balanced-looking {...} span. Arrays pass through untouched — the SERVER validates them.
|
|
237
319
|
export function parseEdgeJson(out) {
|
package/package.json
CHANGED