@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.
@@ -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)
@@ -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 null
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
- if (r.status !== 0 || !r.stdout) return null
227
- return parseEdgeJson(r.stdout.trim())
228
- } catch {
229
- return null
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.137",
3
+ "version": "0.9.139",
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": {