@theronap/cortex-mcp 0.9.139 โ 0.9.141
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 +4 -0
- package/lib/edge_extract.mjs +203 -14
- package/lib/setup.mjs +27 -0
- package/package.json +1 -1
package/lib/capture.mjs
CHANGED
|
@@ -396,6 +396,10 @@ async function captureWork(stdinRaw) {
|
|
|
396
396
|
people: extracted.people,
|
|
397
397
|
entities: extracted.namedEntities,
|
|
398
398
|
...(extracted.pages?.length ? { pages: extracted.pages } : {}),
|
|
399
|
+
// ADR-0059. Obligation candidates the extraction found AND verified verbatim against the
|
|
400
|
+
// transcript it was shown โ the server cannot re-check the quote (it never receives the
|
|
401
|
+
// transcript), so these arrive already filtered. Omitted when empty, same as `pages`.
|
|
402
|
+
...(extracted.obligations?.length ? { obligations: extracted.obligations } : {}),
|
|
399
403
|
}
|
|
400
404
|
: { ...common, transcript }
|
|
401
405
|
|
package/lib/edge_extract.mjs
CHANGED
|
@@ -12,9 +12,31 @@ import { homedir } from 'os'
|
|
|
12
12
|
// timeout + grace, or whose PID is dead) is reclaimed.
|
|
13
13
|
const LOCK_PATH = join(homedir(), '.cortex', 'summarize.lock')
|
|
14
14
|
|
|
15
|
+
// ๐ด THE TIMEOUT MUST COVER THE TAIL OF A WIDE DISTRIBUTION, NOT ITS MIDDLE. Every full extraction
|
|
16
|
+
// timed 2026-09-10 on two real transcripts: 56, 68, 77, 86, 93, 102s โ and one run that exceeded 150s
|
|
17
|
+
// and was killed. The old 45s default was below nearly all of them; a first raise to 150s still sat
|
|
18
|
+
// inside the tail. A killed call returns null, which skips EVERYTHING downstream: the summary, people,
|
|
19
|
+
// entities, ADR-0055's `pages`, and ADR-0059's `obligations`. The fallback then ships the transcript
|
|
20
|
+
// tail and the server writes a summary, so the record lands and nothing looks broken โ a green outcome
|
|
21
|
+
// over a dead mechanism, which is this file's own recorded failure family (see edgeSkip's header).
|
|
22
|
+
//
|
|
23
|
+
// โ LATENCY TRACKS OUTPUT, NOT INPUT โ measured, and it is why length is a poor predictor. A plain
|
|
24
|
+
// one-key summary of the full 50k-char CSG transcript returns in ~12s. The five-key extraction of the
|
|
25
|
+
// same text takes 93s+. And ENT 425, three times LONGER than CSG, ran FASTER (77s) โ because CSG names
|
|
26
|
+
// ~35 people in its team roster and each person is a six-field object. The cost is generating the JSON.
|
|
27
|
+
// So a record dense with names is slow regardless of its length, and no length-scaled timeout would
|
|
28
|
+
// have predicted it.
|
|
29
|
+
//
|
|
30
|
+
// โ RAISING IT COSTS THE USER NOTHING. capture.mjs re-invokes itself as a fully DETACHED child
|
|
31
|
+
// (`detached: true`, `child.unref()`), so nobody waits on this call. The one real cost is the
|
|
32
|
+
// single-flight lock held longer, during which a concurrent session ships its tail instead โ the
|
|
33
|
+
// existing graceful fallback. Too short loses the whole extraction; too long only degrades a neighbour
|
|
34
|
+
// to the fallback it already has. The stale-lock reclaim scales off this same value.
|
|
35
|
+
//
|
|
36
|
+
// โ Its effect on capture_route (1 attachment in its first two days) is a candidate, NOT proven.
|
|
15
37
|
function summaryTimeoutMs() {
|
|
16
38
|
const n = Number(process.env.CORTEX_SUMMARY_TIMEOUT_MS)
|
|
17
|
-
return Number.isFinite(n) && n > 0 ? n :
|
|
39
|
+
return Number.isFinite(n) && n > 0 ? n : 300_000
|
|
18
40
|
}
|
|
19
41
|
|
|
20
42
|
// Try to acquire the lock. Returns true if acquired (caller must call releaseSummaryLock in a finally),
|
|
@@ -134,6 +156,32 @@ const PAGES_FRAGMENT =
|
|
|
134
156
|
'page. Most sessions belong on one to three pages. Empty array is correct for a session with no ' +
|
|
135
157
|
'durable subject, and is much better than a loose guess.)'
|
|
136
158
|
|
|
159
|
+
// โโ ADR-0059 โ obligations โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
160
|
+
//
|
|
161
|
+
// A DIFFERENT QUESTION FROM `pages`, AND DELIBERATELY NOT AS CONSERVATIVE. The pages prompt says an
|
|
162
|
+
// empty array is "much better than a loose guess", which is right for attachment โ a missed page
|
|
163
|
+
// costs retrieval quality โ and measured: capture_route produced 1 attachment across 10 sessions on
|
|
164
|
+
// a capable client (2026-09-09). A missed DEADLINE is a different cost, in the other direction, so
|
|
165
|
+
// this fragment asks for the same care about applicability without importing the same reluctance.
|
|
166
|
+
//
|
|
167
|
+
// โ `evidence` IS VERIFIED HERE, NOT SERVER-SIDE (ADR-0059 ยง4.1). The cloud receives only the derived
|
|
168
|
+
// digest and never the transcript, so there is nothing server-side to compare a quote against. The
|
|
169
|
+
// check is `verbatimIn()` below, run against the SAME slice the model was shown.
|
|
170
|
+
const OBLIGATIONS_FRAGMENT =
|
|
171
|
+
'"obligations" (array of things someone must DO by a date, stated in this session. Each an object ' +
|
|
172
|
+
'with "subject" (what must be done, one short phrase), ' +
|
|
173
|
+
'"due_at" (ISO 8601 date or datetime if one is stated, otherwise null โ do NOT invent or infer a ' +
|
|
174
|
+
'date that was not said), ' +
|
|
175
|
+
'"evidence" (the sentence from the session that says so, copied EXACTLY and VERBATIM โ it is ' +
|
|
176
|
+
'checked against the transcript and the whole item is discarded if it does not match), ' +
|
|
177
|
+
'"obligated_party" (exactly one of: "self" if the person whose session this is must do it; ' +
|
|
178
|
+
'"other" if someone else must; "none" if nobody must). ' +
|
|
179
|
+
'The hard part is "none", so read it carefully: a real date about a real person is still "none" ' +
|
|
180
|
+
'when nobody owes anything โ "so-and-so\'s birthday is today" has every surface feature of a ' +
|
|
181
|
+
'deadline and is not one. Historical and course-content dates are "none" too. ' +
|
|
182
|
+
'Include an item when the session genuinely states an obligation, and do not stretch to find one; ' +
|
|
183
|
+
'an empty array is correct for a session that contains no deadlines.)'
|
|
184
|
+
|
|
137
185
|
const ENTITY_FRAGMENT =
|
|
138
186
|
'"namedEntities" (array of important NON-PERSON things this content is about โ concrete, named ' +
|
|
139
187
|
'projects, processes, systems, products, documents, teams, tools, events, places, or topics. ' +
|
|
@@ -145,6 +193,108 @@ const ENTITY_FRAGMENT =
|
|
|
145
193
|
|
|
146
194
|
// PURE. The scheduled task this transcript is an unattended run of, or null for a human session.
|
|
147
195
|
// Claude Code wraps a scheduled run's instructions in <scheduled-task name="..." file="...">.
|
|
196
|
+
// โโ THE SUMMARIZER'S WINDOW โ the whole record, unless it is enormous โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
197
|
+
//
|
|
198
|
+
// ๐ด NO FIXED WINDOW WORKS, AND TWO REAL TRANSCRIPTS PROVED IT FROM OPPOSITE DIRECTIONS (2026-09-10).
|
|
199
|
+
//
|
|
200
|
+
// ENT 425 lecture (146,890 chars): the one assignment sits at char 147,121 โ the LAST line. A head
|
|
201
|
+
// window cannot see it. A tail window can.
|
|
202
|
+
//
|
|
203
|
+
// CSG kickoff (50,241 chars): all three dated deadlines sit at chars 8,027โ11,390 โ the EARLY
|
|
204
|
+
// MIDDLE, in the "upcoming schedule" segment. A head window sees all three. A head+tail window,
|
|
205
|
+
// which is what this function did for about twenty minutes, sees NONE of them.
|
|
206
|
+
//
|
|
207
|
+
// Each placement fixed one transcript by breaking the other. The head-only slice this replaced was
|
|
208
|
+
// wrong; the head+tail "fix" was a second guess about where content lives, validated against n=1 and
|
|
209
|
+
// refuted by n=2. Where obligations appear depends on how a particular meeting was run, so any rule
|
|
210
|
+
// about which part to keep is a bet on the next transcript's structure.
|
|
211
|
+
//
|
|
212
|
+
// โ
THE ACTUAL CONSTRAINT WAS NEVER THE MODEL. Measured: full text of the 146,890-char transcript ran
|
|
213
|
+
// in 77.0s, faster than the 12k-window runs of the same file (56โ86s). Latency barely tracks length
|
|
214
|
+
// at this scale. And on CSG, full text found 2 of the 3 dated deadlines on BOTH runs, with correct ISO
|
|
215
|
+
// dates and zero false positives, where the head+tail window found 0 of 3.
|
|
216
|
+
//
|
|
217
|
+
// So the default is effectively "send everything". The head+tail fallback survives only for records
|
|
218
|
+
// so long they approach the model's context, where SOME window is unavoidable. At that size the
|
|
219
|
+
// placement bet returns, and it should be treated as a known-lossy last resort, not a design.
|
|
220
|
+
//
|
|
221
|
+
// โ THIS REQUIRES THE PROMPT TO TRAVEL ON STDIN (see extractSession). Full text exceeds Linux's
|
|
222
|
+
// per-argument limit and Windows' command-line limit; as argv it would fail everywhere but macOS.
|
|
223
|
+
//
|
|
224
|
+
// โ Env override CORTEX_SUMMARY_WINDOW is for MEASUREMENT. The default is what ships.
|
|
225
|
+
const SUMMARY_WINDOW = Number(process.env.CORTEX_SUMMARY_WINDOW) > 0 ? Number(process.env.CORTEX_SUMMARY_WINDOW) : 400_000
|
|
226
|
+
const ELISION = '\n\n[โฆ middle of the session elided for length โฆ]\n\n'
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* PURE, exported for tests. The text the summarizer actually sees โ the whole record, unless it
|
|
230
|
+
* exceeds the budget, in which case both ends are kept around a MARKED elision.
|
|
231
|
+
*
|
|
232
|
+
* โ WHATEVER THIS RETURNS IS ALSO THE CORPUS THE VERBATIM GATE CHECKS AGAINST (see `extractSession`).
|
|
233
|
+
* That coupling is required, not incidental: checking a quote against text the model was never shown
|
|
234
|
+
* would reject valid quotes, and checking against MORE than it was shown would accept a fabrication
|
|
235
|
+
* that happens to appear in the discarded middle. A quote spanning the elision fails closed, which is
|
|
236
|
+
* the correct outcome โ those two passages were never adjacent.
|
|
237
|
+
*/
|
|
238
|
+
export function summaryWindow(text, budget = SUMMARY_WINDOW) {
|
|
239
|
+
const s = String(text ?? '')
|
|
240
|
+
if (s.length <= budget) return s
|
|
241
|
+
const headBudget = Math.max(0, Math.floor((budget - ELISION.length) * 0.4))
|
|
242
|
+
const tailBudget = Math.max(0, budget - ELISION.length - headBudget)
|
|
243
|
+
return s.slice(0, headBudget) + ELISION + s.slice(s.length - tailBudget)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// โโ ADR-0059 ยง4.1 โ the verbatim gate โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
247
|
+
|
|
248
|
+
const OBLIGATION_PARTIES = new Set(['self', 'other', 'none'])
|
|
249
|
+
|
|
250
|
+
/** A guard against a runaway answer, not a target โ the same framing as MAX_CAPTURE_PAGES. */
|
|
251
|
+
const MAX_OBLIGATIONS = 5
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Is `needle` actually present in `haystack`, allowing only whitespace to differ?
|
|
255
|
+
*
|
|
256
|
+
* โ WHITESPACE IS NORMALISED AND NOTHING ELSE IS. A transcript wraps lines; a model reproducing a
|
|
257
|
+
* sentence will not reproduce the wrapping. Collapsing runs of whitespace is the difference between
|
|
258
|
+
* a check that passes on real quotes and one that rejects nearly all of them. Case, punctuation and
|
|
259
|
+
* wording are NOT normalised โ loosening those turns "is this sentence in the source" into "is this
|
|
260
|
+
* roughly like something in the source", which is the question this gate exists to refuse.
|
|
261
|
+
*
|
|
262
|
+
* ๐ด AND THE MINIMUM LENGTH IS LOAD-BEARING, NOT TIDINESS. Without it the gate is defeated by a
|
|
263
|
+
* one-word quote: `evidence: "Friday"` appears verbatim in almost any transcript that mentions a
|
|
264
|
+
* day, so a fabricated obligation would pass the check that exists to catch fabrication. A quote
|
|
265
|
+
* short enough to be accidentally present proves nothing about the claim attached to it.
|
|
266
|
+
*/
|
|
267
|
+
export function verbatimIn(needle, haystack) {
|
|
268
|
+
const norm = (s) => String(s ?? '').replace(/\s+/g, ' ').trim()
|
|
269
|
+
const n = norm(needle)
|
|
270
|
+
if (n.length < 16) return false
|
|
271
|
+
return norm(haystack).includes(n)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Drop every obligation candidate that cannot be verified against the text the model was shown.
|
|
276
|
+
*
|
|
277
|
+
* Shape first, then the quote. A candidate missing a subject, missing evidence, or carrying an
|
|
278
|
+
* `obligated_party` outside the preset is malformed and goes โ the server's CHECK constraint would
|
|
279
|
+
* reject it anyway, and failing here means the record still lands with its other keys intact.
|
|
280
|
+
*/
|
|
281
|
+
export function keepVerifiableObligations(items, shown) {
|
|
282
|
+
if (!Array.isArray(items)) return []
|
|
283
|
+
const out = []
|
|
284
|
+
for (const it of items) {
|
|
285
|
+
if (!it || typeof it !== 'object') continue
|
|
286
|
+
const subject = typeof it.subject === 'string' ? it.subject.trim() : ''
|
|
287
|
+
const evidence = typeof it.evidence === 'string' ? it.evidence.trim() : ''
|
|
288
|
+
const party = typeof it.obligated_party === 'string' ? it.obligated_party.trim() : ''
|
|
289
|
+
if (!subject || !evidence || !OBLIGATION_PARTIES.has(party)) continue
|
|
290
|
+
if (!verbatimIn(evidence, shown)) continue
|
|
291
|
+
const due = typeof it.due_at === 'string' && it.due_at.trim() ? it.due_at.trim() : null
|
|
292
|
+
out.push({ subject: subject.slice(0, 200), due_at: due, evidence: evidence.slice(0, 500), obligated_party: party })
|
|
293
|
+
if (out.length >= MAX_OBLIGATIONS) break
|
|
294
|
+
}
|
|
295
|
+
return out
|
|
296
|
+
}
|
|
297
|
+
|
|
148
298
|
export function scheduledTaskName(transcript) {
|
|
149
299
|
const m = String(transcript ?? '').match(/<scheduled-task\s+[^>]*name="([^"]+)"/)
|
|
150
300
|
return m ? m[1] : null
|
|
@@ -203,31 +353,57 @@ export function extractSession(transcript) {
|
|
|
203
353
|
const text = task ? stripScheduledTaskBlocks(raw) : raw
|
|
204
354
|
// A run whose entire transcript WAS the instruction block leaves nothing to judge. That is a
|
|
205
355
|
// heartbeat by definition โ skip without spending a `claude -p` call on it.
|
|
206
|
-
if (task && !text) return { summary: 'NOOP', people: [], namedEntities: [], pages: [] }
|
|
356
|
+
if (task && !text) return { summary: 'NOOP', people: [], namedEntities: [], pages: [], obligations: [] }
|
|
357
|
+
// ONE window, named, used by both the prompt and the verbatim check โ see `summaryWindow`.
|
|
358
|
+
const shown = summaryWindow(text)
|
|
359
|
+
// โ THE OPENING FRAMING WAS "a Claude Code work session", AND IT CLASSIFIED A LECTURE AS NOOP.
|
|
360
|
+
// Measured 2026-09-10: the tail of the ENT 425 transcript came back `summary: "NOOP"` because the
|
|
361
|
+
// old no-op test โ "no real work โ greetings, no tasks" โ reads "no tasks" as "no code was
|
|
362
|
+
// written". A NOOP here does not merely lose the summary: capture.mjs returns before ingest, so the
|
|
363
|
+
// record is never created at all.
|
|
364
|
+
//
|
|
365
|
+
// The new bar is "no DURABLE content", which still catches the case the rule exists for โ a
|
|
366
|
+
// greeting, an interrupted session, a run that did nothing โ and stops equating "nothing was built"
|
|
367
|
+
// with "nothing happened". Unattended scheduled runs are unaffected: `scheduledTaskFragment` is
|
|
368
|
+
// appended below and carries its own, stricter changed-vs-unchanged bar, which is where the
|
|
369
|
+
// heartbeat suppression that matters actually lives. โ That fragment's own header records a NOOP
|
|
370
|
+
// wording change that killed a real finding on its first attempt โ so this one is asserted by a
|
|
371
|
+
// negative control (a bare greeting must still NOOP), not just by the case it was written for.
|
|
207
372
|
const prompt =
|
|
208
|
-
'You are processing a
|
|
209
|
-
'(no prose, no markdown fences) with EXACTLY these
|
|
210
|
-
'"summary" (ONE concrete sentence under 20 words: what was worked on
|
|
211
|
-
'
|
|
373
|
+
'You are processing a captured record โ a work session, a meeting, or a lecture transcript โ for a ' +
|
|
374
|
+
'knowledge base. Return ONLY minified JSON (no prose, no markdown fences) with EXACTLY these five keys:\n' +
|
|
375
|
+
'"summary" (ONE concrete sentence under 20 words: what was worked on, decided, discussed or taught. ' +
|
|
376
|
+
'If the record holds NO durable content at all โ only greetings, small talk, or a run that did ' +
|
|
377
|
+
'nothing โ set summary to exactly "NOOP". A lecture, meeting or discussion with real content is ' +
|
|
378
|
+
'NOT a no-op merely because nothing was built),\n' +
|
|
212
379
|
PEOPLE_FRAGMENT + ',\n' +
|
|
213
380
|
ENTITY_FRAGMENT + ',\n' +
|
|
214
|
-
PAGES_FRAGMENT +
|
|
381
|
+
PAGES_FRAGMENT + ',\n' +
|
|
382
|
+
OBLIGATIONS_FRAGMENT +
|
|
215
383
|
(task ? scheduledTaskFragment(task) : '') +
|
|
216
|
-
'\n\n--- SESSION ---\n' +
|
|
384
|
+
'\n\n--- SESSION ---\n' + shown + '\n--- END ---'
|
|
217
385
|
// Single-flight: if another session already has a summarizer running, skip (caller ships the tail;
|
|
218
386
|
// server summarizes). Prevents the concurrent-`claude -p` stampede that deadlocks the OAuth refresh.
|
|
219
387
|
if (!acquireSummaryLock()) return edgeSkip('busy', 'another session is summarizing; the tail ships instead')
|
|
220
388
|
try {
|
|
389
|
+
// โ THE PROMPT GOES ON STDIN, NOT ARGV โ AND THE WINDOW BELOW DEPENDS ON IT. A command-line
|
|
390
|
+
// argument has an OS size limit: Linux caps a SINGLE argument at 131,072 bytes (MAX_ARG_STRLEN)
|
|
391
|
+
// and Windows caps the WHOLE command line at 32,767 characters. The full-text window can exceed
|
|
392
|
+
// both โ ENT 425 alone is 146,890 characters. As argv it would work on macOS (ARG_MAX 1MB, the
|
|
393
|
+
// only machine that tests this) and fail with E2BIG everywhere else, green on the dev box and
|
|
394
|
+
// broken for the user. `claude --print` reads its prompt from stdin; verified live 2026-09-10.
|
|
221
395
|
const r = spawnSync(
|
|
222
396
|
'claude',
|
|
223
|
-
['--print', '--model', process.env.CORTEX_SUMMARY_MODEL ?? 'claude-haiku-4-5'
|
|
224
|
-
{ env: edgeSafeEnv(process.env, { CORTEX_SUMMARIZING: '1' }), encoding: 'utf8', timeout: summaryTimeoutMs(), maxBuffer: 4 * 1024 * 1024 },
|
|
397
|
+
['--print', '--model', process.env.CORTEX_SUMMARY_MODEL ?? 'claude-haiku-4-5'],
|
|
398
|
+
{ input: prompt, env: edgeSafeEnv(process.env, { CORTEX_SUMMARIZING: '1' }), encoding: 'utf8', timeout: summaryTimeoutMs(), maxBuffer: 4 * 1024 * 1024 },
|
|
225
399
|
)
|
|
226
400
|
const why = classifyEdgeFailure(r)
|
|
227
401
|
if (why) return edgeSkip(why.reason, why.detail)
|
|
228
402
|
const parsed = parseEdgeJson(r.stdout.trim())
|
|
229
403
|
if (!parsed) return edgeSkip('unparseable', 'the model returned output that is not the expected JSON')
|
|
230
|
-
|
|
404
|
+
// ADR-0059 ยง4.1 โ the verbatim gate. This is the ONLY place it can run: the server never receives
|
|
405
|
+
// the transcript, so a quote it cannot see is a quote it cannot check.
|
|
406
|
+
return { ...parsed, obligations: keepVerifiableObligations(parsed.obligations, shown) }
|
|
231
407
|
} catch (e) {
|
|
232
408
|
return edgeSkip('threw', e instanceof Error ? e.message : String(e))
|
|
233
409
|
} finally {
|
|
@@ -290,6 +466,18 @@ export function classifyEdgeFailure(r) {
|
|
|
290
466
|
// The other shape of the same event: spawnSync killed the child, so status is null and signal set.
|
|
291
467
|
if (r.signal) return { reason: 'timeout', detail: `killed by ${r.signal} after ${summaryTimeoutMs()}ms` }
|
|
292
468
|
|
|
469
|
+
// ๐ด EVERYTHING BELOW APPLIES ONLY TO A FAILED CALL. Exit 0 means the model answered; its answer
|
|
470
|
+
// is CONTENT, not a status report, and must never be scanned for failure keywords.
|
|
471
|
+
//
|
|
472
|
+
// This ordering was wrong on the first version and produced a false `auth-expired` on a call that
|
|
473
|
+
// had returned status 0 โ because the session being summarized was itself about fixing an OAuth
|
|
474
|
+
// bug, so the model's own summary contained the word. A working extraction was thrown away for
|
|
475
|
+
// discussing the wrong subject. Any session about auth, 401s or logins would have hit it.
|
|
476
|
+
//
|
|
477
|
+
// The general form of the mistake: matching a symptom string across a channel that carries both
|
|
478
|
+
// diagnostics AND payload. The exit status is the only trustworthy separator, so it goes first.
|
|
479
|
+
if (r.status === 0) return r.stdout ? null : { reason: 'no-output', detail: 'claude exited 0 with empty stdout' }
|
|
480
|
+
|
|
293
481
|
const out = `${r.stdout ?? ''}\n${r.stderr ?? ''}`
|
|
294
482
|
// โ THE AUTH MESSAGE ARRIVES ON **STDOUT**, WITH EXIT 1 โ not on stderr, which is where a reader
|
|
295
483
|
// would look for it. Verified against the live failure 2026-09-08:
|
|
@@ -305,9 +493,7 @@ export function classifyEdgeFailure(r) {
|
|
|
305
493
|
+ 'and no page proposals are made.',
|
|
306
494
|
}
|
|
307
495
|
}
|
|
308
|
-
|
|
309
|
-
if (!r.stdout) return { reason: 'no-output', detail: 'claude exited 0 with empty stdout' }
|
|
310
|
-
return null
|
|
496
|
+
return { reason: 'exit', detail: `claude exited ${r.status}: ${firstLine(out)}` }
|
|
311
497
|
}
|
|
312
498
|
|
|
313
499
|
function firstLine(s) {
|
|
@@ -333,6 +519,9 @@ export function parseEdgeJson(out) {
|
|
|
333
519
|
// model returned and no matter what the prompt asked for. Adding a prompt fragment without
|
|
334
520
|
// adding it here yields a feature that ships, runs, and does nothing.
|
|
335
521
|
pages: Array.isArray(o.pages) ? o.pages : [],
|
|
522
|
+
// ADR-0059. Unverified at this point โ parseEdgeJson has no transcript to check a quote
|
|
523
|
+
// against. `extractSession` filters these through `verbatimIn()` before returning.
|
|
524
|
+
obligations: Array.isArray(o.obligations) ? o.obligations : [],
|
|
336
525
|
}
|
|
337
526
|
} catch {
|
|
338
527
|
return null
|
package/lib/setup.mjs
CHANGED
|
@@ -168,6 +168,33 @@ export async function runSetup(argv, version) {
|
|
|
168
168
|
log(` โ skills install skipped: ${e.message} (will retry on next session)`)
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
// โโ 3b. Stamp what installed this machine โ ADR-0057 โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
172
|
+
// Nothing recorded which CLI last ran setup, and the desktop app needs exactly that to decide
|
|
173
|
+
// whether a seat's on-disk artifacts are stale.
|
|
174
|
+
//
|
|
175
|
+
// โ WHY A STAMP AND NOT THE WIRED SPEC. The config holds a moving dist-tag (see pickSpec above:
|
|
176
|
+
// "never a version"), so it says nothing about WHEN this machine was installed or by what. The
|
|
177
|
+
// desktop's autoHealMcp() used to read a version out of it and, once this file started writing
|
|
178
|
+
// `@stable`, silently returned on every launch for three weeks โ the skills/hooks refresh simply
|
|
179
|
+
// stopped. This file is the signal that replaces it.
|
|
180
|
+
//
|
|
181
|
+
// Written AFTER the artifacts it describes (config, hooks, skills) and BEFORE the token check on
|
|
182
|
+
// purpose: a bad token means the connection is wrong, not that the files failed to install.
|
|
183
|
+
// Claiming otherwise would leave a correctly-installed machine looking permanently stale.
|
|
184
|
+
//
|
|
185
|
+
// Fail-soft: a stamp we cannot write must never break an otherwise successful setup. A missing
|
|
186
|
+
// stamp reads as stale downstream, so the cost of failing here is one redundant heal, not a break.
|
|
187
|
+
try {
|
|
188
|
+
const cortexDir = join(homedir(), '.cortex')
|
|
189
|
+
if (!existsSync(cortexDir)) mkdirSync(cortexDir, { recursive: true })
|
|
190
|
+
writeFileSync(
|
|
191
|
+
join(cortexDir, 'setup.json'),
|
|
192
|
+
JSON.stringify({ version, spec, at: new Date().toISOString() }, null, 2),
|
|
193
|
+
)
|
|
194
|
+
} catch (e) {
|
|
195
|
+
log(` โ could not record the setup stamp: ${e.message} (harmless; the app may re-run setup once)`)
|
|
196
|
+
}
|
|
197
|
+
|
|
171
198
|
// โโ 4. Self-verify โ writing config proves "files written", NOT "connection works".
|
|
172
199
|
// Actually call the API so a bad/expired token is caught HERE, not 40 minutes into debugging.
|
|
173
200
|
log('')
|
package/package.json
CHANGED