@theronap/cortex-mcp 0.9.136 → 0.9.138
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 +10 -1
- package/lib/capture_status.mjs +68 -0
- package/lib/edge_extract.mjs +125 -8
- package/lib/grep_cli.mjs +20 -2
- package/lib/server.mjs +60 -20
- package/lib/use_brain.mjs +45 -13
- package/package.json +1 -1
package/lib/capture.mjs
CHANGED
|
@@ -386,8 +386,17 @@ async function captureWork(stdinRaw) {
|
|
|
386
386
|
}
|
|
387
387
|
const extracted = transcript ? extractSession(transcript) : null
|
|
388
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 }
|
|
389
|
+
// `pages` is the edge's PROPOSAL of where this record belongs (ADR-0055). Free-text names, resolved
|
|
390
|
+
// against real pages server-side and dropped when they match nothing — the same untrusted-edge
|
|
391
|
+
// contract people/entities use. Omitted entirely when empty so an older server sees no new field.
|
|
389
392
|
const ingestBody = extracted
|
|
390
|
-
? {
|
|
393
|
+
? {
|
|
394
|
+
...common,
|
|
395
|
+
summary: extracted.summary,
|
|
396
|
+
people: extracted.people,
|
|
397
|
+
entities: extracted.namedEntities,
|
|
398
|
+
...(extracted.pages?.length ? { pages: extracted.pages } : {}),
|
|
399
|
+
}
|
|
391
400
|
: { ...common, transcript }
|
|
392
401
|
|
|
393
402
|
// Parallel typed extraction (OPT-IN via CORTEX_TYPED): registry-driven typed notes, sent ALONGSIDE the
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// How "where do my captures land?" is answered — ONE renderer, two callers.
|
|
2
|
+
//
|
|
3
|
+
// WHY IT IS EXTRACTED. `set_capture_brain` (MCP) and `use-brain` (CLI) each had their own copy of
|
|
4
|
+
// this answer, and the copies had already drifted: the CLI said captures land somewhere "only if you
|
|
5
|
+
// belong to exactly one" brain, the tool said they "are being HELD" if you belong to more than one.
|
|
6
|
+
// Both were the same guess wearing different words. diagnose.mjs' token resolver carries the same
|
|
7
|
+
// warning for the same reason — "a third copy is how a machine ends up 'connected' to one command
|
|
8
|
+
// and 'no token found' to another".
|
|
9
|
+
//
|
|
10
|
+
// WHY IT TAKES DATA INSTEAD OF ASKING. Every branch below is decided by a fact the server already
|
|
11
|
+
// knows: what is staging, and how many brains you are in. Until 2026-09-08 neither surface fetched
|
|
12
|
+
// either, so both could only offer the reader a CONDITION — and on that day a person with one brain
|
|
13
|
+
// and nothing held was told his setup was broken, while a person with 52 held `email` records was
|
|
14
|
+
// told a number that named no source and a remedy that could not reach it.
|
|
15
|
+
//
|
|
16
|
+
// Pure: no I/O, no token, no formatting of anything it was not given.
|
|
17
|
+
|
|
18
|
+
/** The remedy, phrased for where it will be read. Same decision tree, same facts — only the thing
|
|
19
|
+
* the reader would type differs, and getting that wrong is not cosmetic: a CLI user pasting
|
|
20
|
+
* `source="email"` gets an error, and an agent told to run a shell command does the wrong thing.
|
|
21
|
+
* @type {Record<string, (source: string) => string>} */
|
|
22
|
+
const FIX = {
|
|
23
|
+
tool: (source) => `call this tool again with brain=<brain> source="${source}"`,
|
|
24
|
+
cli: (source) => `npx -y @theronap/cortex-mcp use-brain "<brain>" --source ${source}`,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** @param {{defaults?: Array, held?: Array, brainCount?: number}} state
|
|
28
|
+
* @param {'tool'|'cli'} mode */
|
|
29
|
+
export function renderCaptureStatus(state, mode = 'tool') {
|
|
30
|
+
const defaults = state?.defaults ?? []
|
|
31
|
+
const held = state?.held ?? []
|
|
32
|
+
const brainCount = typeof state?.brainCount === 'number' ? state.brainCount : null
|
|
33
|
+
const fix = FIX[mode] ?? FIX.tool
|
|
34
|
+
const lines = []
|
|
35
|
+
|
|
36
|
+
for (const d of defaults) {
|
|
37
|
+
lines.push(`${d.sourceType} captures land in "${d.orgName}" (${d.orgId})${d.updatedAt ? `, set ${String(d.updatedAt).slice(0, 10)}` : ''}`)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The actionable case, and it is stated whether or not other defaults exist: a set `claude-code`
|
|
41
|
+
// default says nothing about `email`, and reading the first as coverage for the second is exactly
|
|
42
|
+
// how 52 records stayed held for ten days behind a green-looking setting.
|
|
43
|
+
if (held.length) {
|
|
44
|
+
if (lines.length) lines.push('')
|
|
45
|
+
lines.push(`⚠ NOT being saved — held outside every brain, one line per source:`)
|
|
46
|
+
for (const h of held) {
|
|
47
|
+
lines.push(` · ${h.held} ${h.source}${h.oldest ? ` (oldest ${h.oldest})` : ''} — ${fix(h.source)}`)
|
|
48
|
+
}
|
|
49
|
+
lines.push('Each source routes on its own; setting one does nothing for the others.')
|
|
50
|
+
return lines.join('\n')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (lines.length) {
|
|
54
|
+
lines.push('')
|
|
55
|
+
lines.push('Nothing is currently held.')
|
|
56
|
+
return lines.join('\n')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// No defaults AND nothing held. Whether that is fine or a latent problem is decided by the brain
|
|
60
|
+
// count, so say which — never both, and never as a question for the reader.
|
|
61
|
+
if (brainCount === 1) {
|
|
62
|
+
return 'No capture brain is set, and none is needed: you belong to exactly one brain, so captures route there automatically. Nothing is held. Nothing to do.'
|
|
63
|
+
}
|
|
64
|
+
if (brainCount !== null && brainCount > 1) {
|
|
65
|
+
return `No capture brain is set. Nothing is held right now, but you belong to ${brainCount} brains — a capture from a source with no default cannot be routed, so it will be held rather than saved. Set one per source you use, e.g. ${fix('claude-code')}`
|
|
66
|
+
}
|
|
67
|
+
return 'No capture brain is set, and nothing is currently held.'
|
|
68
|
+
}
|
package/lib/edge_extract.mjs
CHANGED
|
@@ -96,6 +96,44 @@ const PEOPLE_FRAGMENT =
|
|
|
96
96
|
'"importance" ("high" for people central to the work, "low" for incidental). ' +
|
|
97
97
|
'Only real named humans; omit anonymous or incidental mentions. Empty array is fine.)'
|
|
98
98
|
|
|
99
|
+
// WHERE THIS RECORD BELONGS — the one question the extraction never asked.
|
|
100
|
+
//
|
|
101
|
+
// Measured 2026-09-05..08: of 221 page attachments since the ADR-0051 gate shipped, 215 were
|
|
102
|
+
// mechanical (`deterministic_attach` 197, `session_attach` 18) and 6 were judgment (`route_record`).
|
|
103
|
+
// 2.7%. A session's record lands on the author's profile and any page the session EDITED, and
|
|
104
|
+
// nowhere else — so four hours of work on ADR-0051 is invisible from the ADR-0051 page. ADR-0053
|
|
105
|
+
// measures the consequence: 20% of sessions are findable by subject for a developer, ~0% for a
|
|
106
|
+
// non-developer.
|
|
107
|
+
//
|
|
108
|
+
// Asking a later agent to fix this was tried and rejected: by the time the record exists the session
|
|
109
|
+
// that did the work is gone, and the agent inherits a title. Naive agents given exactly that job
|
|
110
|
+
// parked records because "only the title survived". THIS call already holds the whole transcript,
|
|
111
|
+
// already runs detached with nobody waiting, and already makes judgement calls (people, entities).
|
|
112
|
+
// It is the only place in the system where the context and the question are in the same room.
|
|
113
|
+
//
|
|
114
|
+
// ⚠ "MENTIONED" IS NOT "BELONGS ON", and that distinction is the entire fragment. A session that
|
|
115
|
+
// deploys to Vercel mentions Vercel; it does not belong on the Vercel page. Filing on every mention
|
|
116
|
+
// is worse than filing on none — it makes every page's record list noise, and noise is unreadable in
|
|
117
|
+
// exactly the way an empty list is not. `namedEntities` above is the MENTION list and already exists;
|
|
118
|
+
// this is deliberately a different, smaller question, which is why it is a separate key rather than
|
|
119
|
+
// a flag on that one.
|
|
120
|
+
//
|
|
121
|
+
// Names are proposed as free text and RESOLVED SERVER-SIDE against real pages, the same trust model
|
|
122
|
+
// people/entities already use — the edge is not trusted and does not hold the page list. A name that
|
|
123
|
+
// matches nothing is dropped, so a hallucinated page cannot create one.
|
|
124
|
+
const PAGES_FRAGMENT =
|
|
125
|
+
'"pages" (array of wiki page names this work should be FILED UNDER — the pages whose reader would ' +
|
|
126
|
+
'want to know this session happened. Each an object with ' +
|
|
127
|
+
'"name" (the page name exactly as written in the session — prefer a name that appears verbatim, ' +
|
|
128
|
+
'especially inside [[double brackets]], an ADR number and title, a project name, or a person\'s ' +
|
|
129
|
+
'full name), ' +
|
|
130
|
+
'"why" (one short phrase: what this session did TO or ABOUT that subject). ' +
|
|
131
|
+
'Include a page ONLY if the work was genuinely about that subject — something was decided, built, ' +
|
|
132
|
+
'measured, fixed or discovered concerning it. EXCLUDE anything merely used, referenced in passing, ' +
|
|
133
|
+
'or named as background: a session that deploys to a platform does not belong on that platform\'s ' +
|
|
134
|
+
'page. Most sessions belong on one to three pages. Empty array is correct for a session with no ' +
|
|
135
|
+
'durable subject, and is much better than a loose guess.)'
|
|
136
|
+
|
|
99
137
|
const ENTITY_FRAGMENT =
|
|
100
138
|
'"namedEntities" (array of important NON-PERSON things this content is about — concrete, named ' +
|
|
101
139
|
'projects, processes, systems, products, documents, teams, tools, events, places, or topics. ' +
|
|
@@ -165,34 +203,109 @@ export function extractSession(transcript) {
|
|
|
165
203
|
const text = task ? stripScheduledTaskBlocks(raw) : raw
|
|
166
204
|
// A run whose entire transcript WAS the instruction block leaves nothing to judge. That is a
|
|
167
205
|
// heartbeat by definition — skip without spending a `claude -p` call on it.
|
|
168
|
-
if (task && !text) return { summary: 'NOOP', people: [], namedEntities: [] }
|
|
206
|
+
if (task && !text) return { summary: 'NOOP', people: [], namedEntities: [], pages: [] }
|
|
169
207
|
const prompt =
|
|
170
208
|
'You are processing a Claude Code work session for a knowledge base. Return ONLY minified JSON ' +
|
|
171
|
-
'(no prose, no markdown fences) with EXACTLY these
|
|
209
|
+
'(no prose, no markdown fences) with EXACTLY these four keys:\n' +
|
|
172
210
|
'"summary" (ONE concrete sentence under 20 words: what was worked on or decided. If the session ' +
|
|
173
211
|
'had no real work — greetings, no tasks — set summary to exactly "NOOP"),\n' +
|
|
174
212
|
PEOPLE_FRAGMENT + ',\n' +
|
|
175
|
-
ENTITY_FRAGMENT +
|
|
213
|
+
ENTITY_FRAGMENT + ',\n' +
|
|
214
|
+
PAGES_FRAGMENT +
|
|
176
215
|
(task ? scheduledTaskFragment(task) : '') +
|
|
177
216
|
'\n\n--- SESSION ---\n' + text.slice(0, 12000) + '\n--- END ---'
|
|
178
217
|
// Single-flight: if another session already has a summarizer running, skip (caller ships the tail;
|
|
179
218
|
// server summarizes). Prevents the concurrent-`claude -p` stampede that deadlocks the OAuth refresh.
|
|
180
|
-
if (!acquireSummaryLock()) return
|
|
219
|
+
if (!acquireSummaryLock()) return edgeSkip('busy', 'another session is summarizing; the tail ships instead')
|
|
181
220
|
try {
|
|
182
221
|
const r = spawnSync(
|
|
183
222
|
'claude',
|
|
184
223
|
['--print', '--model', process.env.CORTEX_SUMMARY_MODEL ?? 'claude-haiku-4-5', prompt],
|
|
185
224
|
{ env: edgeSafeEnv(process.env, { CORTEX_SUMMARIZING: '1' }), encoding: 'utf8', timeout: summaryTimeoutMs(), maxBuffer: 4 * 1024 * 1024 },
|
|
186
225
|
)
|
|
187
|
-
|
|
188
|
-
return
|
|
189
|
-
|
|
190
|
-
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))
|
|
191
233
|
} finally {
|
|
192
234
|
releaseSummaryLock()
|
|
193
235
|
}
|
|
194
236
|
}
|
|
195
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
|
+
return r.error.code === 'ENOENT'
|
|
279
|
+
? { reason: 'no-claude', detail: '`claude` is not on PATH for this hook; edge extraction cannot run here' }
|
|
280
|
+
: { reason: 'spawn-failed', detail: r.error.message }
|
|
281
|
+
}
|
|
282
|
+
// timeout: spawnSync kills the child, so status is null and signal is set.
|
|
283
|
+
if (r.signal) return { reason: 'timeout', detail: `killed by ${r.signal} after ${summaryTimeoutMs()}ms` }
|
|
284
|
+
|
|
285
|
+
const out = `${r.stdout ?? ''}\n${r.stderr ?? ''}`
|
|
286
|
+
// ⚠ THE AUTH MESSAGE ARRIVES ON **STDOUT**, WITH EXIT 1 — not on stderr, which is where a reader
|
|
287
|
+
// would look for it. Verified against the live failure 2026-09-08:
|
|
288
|
+
// status 1, stderr "", stdout "Failed to authenticate: OAuth session expired and could not be
|
|
289
|
+
// refreshed". Matching only stderr would classify this as a plain non-zero exit and lose the one
|
|
290
|
+
// fact that tells a human what to do.
|
|
291
|
+
if (/oauth|authenticat|not logged in|401|unauthorized/i.test(out)) {
|
|
292
|
+
return {
|
|
293
|
+
reason: 'auth-expired',
|
|
294
|
+
detail: 'the subscription login for headless `claude --print` is dead. Run `claude setup-token`, '
|
|
295
|
+
+ 'then set CLAUDE_CODE_OAUTH_TOKEN in ~/.claude/settings.json (env). '
|
|
296
|
+
+ 'Until then EVERY session ships its transcript tail to the server instead of a local digest, '
|
|
297
|
+
+ 'and no page proposals are made.',
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (r.status !== 0) return { reason: 'exit', detail: `claude exited ${r.status}: ${firstLine(out)}` }
|
|
301
|
+
if (!r.stdout) return { reason: 'no-output', detail: 'claude exited 0 with empty stdout' }
|
|
302
|
+
return null
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function firstLine(s) {
|
|
306
|
+
return String(s ?? '').split('\n').map((l) => l.trim()).filter(Boolean)[0] ?? '(no output)'
|
|
307
|
+
}
|
|
308
|
+
|
|
196
309
|
// Tolerant JSON extraction: the model may wrap output in prose / ```json fences, so grab the first
|
|
197
310
|
// balanced-looking {...} span. Arrays pass through untouched — the SERVER validates them.
|
|
198
311
|
export function parseEdgeJson(out) {
|
|
@@ -208,6 +321,10 @@ export function parseEdgeJson(out) {
|
|
|
208
321
|
summary: summary.slice(0, 200),
|
|
209
322
|
people: Array.isArray(o.people) ? o.people : [],
|
|
210
323
|
namedEntities: Array.isArray(o.namedEntities) ? o.namedEntities : [],
|
|
324
|
+
// ⚠ This function WHITELISTS keys — a key absent here is silently dropped no matter what the
|
|
325
|
+
// model returned and no matter what the prompt asked for. Adding a prompt fragment without
|
|
326
|
+
// adding it here yields a feature that ships, runs, and does nothing.
|
|
327
|
+
pages: Array.isArray(o.pages) ? o.pages : [],
|
|
211
328
|
}
|
|
212
329
|
} catch {
|
|
213
330
|
return null
|
package/lib/grep_cli.mjs
CHANGED
|
@@ -30,6 +30,9 @@ export function parseGrepArgs(argv = []) {
|
|
|
30
30
|
return out
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
// How many [[links]] to print per hit before summarising the rest as "(+N more)".
|
|
34
|
+
export const LINKS_PER_HIT = 8
|
|
35
|
+
|
|
33
36
|
// Pure: render a /api/grep payload to readable ASCII text.
|
|
34
37
|
export function formatGrepHits(payload, query) {
|
|
35
38
|
const hits = (payload && payload.hits) || []
|
|
@@ -41,12 +44,27 @@ export function formatGrepHits(payload, query) {
|
|
|
41
44
|
? `No matches for "${query}". If you expected a page here, it may be an unauthored red-link — \`read_page "${query}"\` to triage it (author it, or alias it to an existing page).`
|
|
42
45
|
: `No matches for "${query}".`
|
|
43
46
|
}
|
|
44
|
-
|
|
47
|
+
// `total` (server, 2026-09-05) is the pre-cap match count. Say so when the list was truncated:
|
|
48
|
+
// a capped list presented as a complete one is how "nothing else mentions this" gets concluded
|
|
49
|
+
// from a partial answer -- the same failure the per-brain status list exists to prevent.
|
|
50
|
+
const total = typeof payload.total === 'number' ? payload.total : hits.length
|
|
51
|
+
const truncated = total > hits.length
|
|
52
|
+
const header = truncated
|
|
53
|
+
? `${hits.length} of ${total} matches for "${query}" (best-ranked first; narrow the query or raise max to see the rest):`
|
|
54
|
+
: `${hits.length} match${hits.length === 1 ? '' : 'es'} for "${query}":`
|
|
55
|
+
const lines = [header, '']
|
|
45
56
|
for (const h of hits) {
|
|
46
57
|
const head = h.heading ? ` > ${h.heading}` : ''
|
|
47
58
|
lines.push(`- ${h.title}${head} [${h.tier}]`)
|
|
48
59
|
if (h.snippet) lines.push(` ${String(h.snippet).replace(/\s+/g, ' ').trim()}`)
|
|
49
|
-
|
|
60
|
+
// Cap the link list per hit. Median is 2 but the tail reaches 32, and at ~126 chars/hit the
|
|
61
|
+
// link lists were the second-largest cost in the payload after snippets. The count is kept so
|
|
62
|
+
// a trimmed list never reads as the page's complete outbound set.
|
|
63
|
+
if (h.links && h.links.length) {
|
|
64
|
+
const shown = h.links.slice(0, LINKS_PER_HIT)
|
|
65
|
+
const more = h.links.length - shown.length
|
|
66
|
+
lines.push(` -> ${shown.map((l) => `[[${l}]]`).join(' ')}${more > 0 ? ` (+${more} more)` : ''}`)
|
|
67
|
+
}
|
|
50
68
|
}
|
|
51
69
|
return lines.join('\n')
|
|
52
70
|
}
|
package/lib/server.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { folderClaimLine } from './folder_claims.mjs'
|
|
|
10
10
|
import { resolveSessionKey, resolveLogSessionId } from './session_key.mjs'
|
|
11
11
|
import { runSendImessage } from './imessage_send.mjs'
|
|
12
12
|
import { formatGrepHits } from './grep_cli.mjs'
|
|
13
|
+
import { renderCaptureStatus } from './capture_status.mjs'
|
|
13
14
|
import { renderTriage } from './red_link_triage.mjs'
|
|
14
15
|
import { runCodeGraphQuery } from './code_graph_cli.mjs'
|
|
15
16
|
import { repoFullNameFrom } from './capture.mjs'
|
|
@@ -230,12 +231,13 @@ function renderNudge(payload) {
|
|
|
230
231
|
{
|
|
231
232
|
title: 'Choose where your session captures are saved',
|
|
232
233
|
description:
|
|
233
|
-
'Set which brain THIS PERSON\'s unattended session captures (the automatic end-of-session record) land in, or read the current setting by omitting `brain`. WHEN TO USE: whenever the user says their sessions are not being saved, asks where their work is going, or a session-start notice says captures are being HELD. WHY IT IS NEEDED: with more than one brain, a capture that names no brain cannot be routed and is held outside every brain — correct, but invisible, so it accumulates silently. This is per-PERSON and applies to their own captures only; it cannot be set for someone else. ⚠ PASS THE ORG ID when the user has two brains with the SAME NAME (e.g. two called "Personal") — a name matching more than one is REFUSED rather than guessed, and the error lists the ids to choose from. Setting this does NOT file already-held captures; those keep their original dates and may belong in different brains, so sort them deliberately rather than dumping them into the new default.',
|
|
234
|
+
'Set which brain THIS PERSON\'s unattended session captures (the automatic end-of-session record) land in, or read the current setting by omitting `brain`. WHEN TO USE: whenever the user says their sessions are not being saved, asks where their work is going, or a session-start notice says captures are being HELD. WHY IT IS NEEDED: with more than one brain, a capture that names no brain cannot be routed and is held outside every brain — correct, but invisible, so it accumulates silently. This is per-PERSON and applies to their own captures only; it cannot be set for someone else. ⚠ PASS THE ORG ID when the user has two brains with the SAME NAME (e.g. two called "Personal") — a name matching more than one is REFUSED rather than guessed, and the error lists the ids to choose from. Setting this does NOT file already-held captures; those keep their original dates and may belong in different brains, so sort them deliberately rather than dumping them into the new default. ⚠ A default is PER SOURCE: setting `claude-code` does NOTHING for `email` or `imessage`, which keep staging until each gets its own. If a notice reports held captures, set the source it names.',
|
|
234
235
|
inputSchema: {
|
|
235
236
|
brain: z.string().optional().describe('the brain name or org id (from my_brains) where this person\'s session captures should land. Omit to read the current setting instead of changing it.'),
|
|
237
|
+
source: z.string().optional().describe("which connector's captures this routes, e.g. 'claude-code', 'email', 'imessage', 'cursor'. Defaults to 'claude-code'. Each source routes independently — set the one the held-captures notice names."),
|
|
236
238
|
},
|
|
237
239
|
},
|
|
238
|
-
async ({ brain }) => {
|
|
240
|
+
async ({ brain, source }) => {
|
|
239
241
|
const url = `${BASE}/api/brain/capture-default`
|
|
240
242
|
if (!brain?.trim()) {
|
|
241
243
|
const res = await fetchCortex(url, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
@@ -243,17 +245,16 @@ function renderNudge(payload) {
|
|
|
243
245
|
const body = await res.text()
|
|
244
246
|
throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
|
|
245
247
|
}
|
|
246
|
-
|
|
247
|
-
if (!defaults?.length) {
|
|
248
|
-
return { content: [{ type: 'text', text: 'No capture brain is set. If you belong to more than one brain, your session captures are being HELD outside every brain until you set one. Call this tool again with `brain` to fix it.' }] }
|
|
249
|
-
}
|
|
250
|
-
const lines = defaults.map((d) => `${d.sourceType} captures land in "${d.orgName}" (${d.orgId}), set ${String(d.updatedAt).slice(0, 10)}`)
|
|
251
|
-
return { content: [{ type: 'text', text: lines.join('\n') }] }
|
|
248
|
+
return { content: [{ type: 'text', text: renderCaptureStatus(await res.json()) }] }
|
|
252
249
|
}
|
|
253
250
|
const res = await fetchCortex(url, {
|
|
254
251
|
method: 'POST',
|
|
255
252
|
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
256
|
-
|
|
253
|
+
// The source was pinned to 'claude-code' here and in use-brain from the day both shipped,
|
|
254
|
+
// while the server has always keyed capture_defaults on (auth_id, source_type). The result:
|
|
255
|
+
// the remedy the held-captures notice names could not reach any source but one. Defaulted,
|
|
256
|
+
// not required — the bare call keeps its old meaning exactly.
|
|
257
|
+
body: JSON.stringify({ sourceType: source?.trim() || 'claude-code', brain: brain.trim() }),
|
|
257
258
|
})
|
|
258
259
|
const body = await res.text()
|
|
259
260
|
if (!res.ok) {
|
|
@@ -264,7 +265,7 @@ function renderNudge(payload) {
|
|
|
264
265
|
throw new Error(msg ?? classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
|
|
265
266
|
}
|
|
266
267
|
const j = JSON.parse(body)
|
|
267
|
-
return { content: [{ type: 'text', text: `✓ Your
|
|
268
|
+
return { content: [{ type: 'text', text: `✓ Your ${j.sourceType} captures now land in "${j.brain}" (${j.orgId}). This covers ${j.sourceType} ONLY — every other source keeps its own default and keeps staging until set. Captures from before now are still held and keep their original dates — file those deliberately, they may not all belong in this brain.` }] }
|
|
268
269
|
},
|
|
269
270
|
)
|
|
270
271
|
|
|
@@ -937,15 +938,22 @@ function renderNudge(payload) {
|
|
|
937
938
|
const lines = []
|
|
938
939
|
const sa = body?.sessionAttachments
|
|
939
940
|
if (sa === null) {
|
|
940
|
-
lines.push('⚠ Session-page attachment FAILED for this record — it was written, but not filed under the pages this session
|
|
941
|
+
lines.push('⚠ Session-page attachment FAILED for this record — it was written, but not filed under the pages this session worked on. Re-run the reconciler.')
|
|
941
942
|
} else if (sa && Array.isArray(sa.attached)) {
|
|
942
943
|
if (sa.attached.length > 0) {
|
|
943
|
-
|
|
944
|
+
// ⚠ "worked on", not "wrote" — [[ADR-0053]] made this line wrong on 2026-09-08.
|
|
945
|
+
// Attachments now come from two places: pages the session AUTHORED (ADR-0032, via
|
|
946
|
+
// page_revisions.session_key) and pages it merely OPENED (ADR-0053, via read_page). The
|
|
947
|
+
// server returns one flat `attached` list and does not say which is which, so this cannot
|
|
948
|
+
// report the split without a server change — but it must not assert the narrower of the two.
|
|
949
|
+
// Observed live the day ADR-0053 shipped: a session that authored nothing and only read
|
|
950
|
+
// [[Travis Peterson]] was told "Filed under 1 page(s) this session wrote."
|
|
951
|
+
lines.push(`Filed under ${sa.attached.length} page(s) this session worked on.`)
|
|
944
952
|
} else {
|
|
945
|
-
lines.push('No pages filed: this session
|
|
953
|
+
lines.push('No pages filed: this session neither wrote nor opened a page in this brain (the normal case).')
|
|
946
954
|
}
|
|
947
955
|
if (sa.droppedCrossBrain > 0) {
|
|
948
|
-
lines.push(`${sa.droppedCrossBrain} page(s) this session
|
|
956
|
+
lines.push(`${sa.droppedCrossBrain} page(s) this session worked on live in another brain and were skipped — a record belongs to one brain.`)
|
|
949
957
|
}
|
|
950
958
|
}
|
|
951
959
|
// Containment carried from capture. Three states, and they are not interchangeable:
|
|
@@ -1217,14 +1225,16 @@ function renderNudge(payload) {
|
|
|
1217
1225
|
{
|
|
1218
1226
|
title: 'Grep the brain wiki',
|
|
1219
1227
|
description:
|
|
1220
|
-
'Ranked keyword search across your visible brain wiki pages. Multi-word natural-language queries work (results are ranked by relevance). Pass mode:"substring" for an exact literal match of symbols, identifiers, or [[links]]. Returns matching sections with a context snippet and their outbound [[links]].',
|
|
1228
|
+
'Ranked keyword search across your visible brain wiki pages. Multi-word natural-language queries work (results are ranked by relevance). Pass mode:"substring" for an exact literal match of symbols, identifiers, or [[links]] — and for an English STOPWORD ("own", "not"), which ranked search drops. Returns matching sections with a context snippet and their outbound [[links]]. A broad query fans out across every brain you can see, so pass `max` when you only need to locate a page; the header always says how many matched in total, so a capped list is never mistaken for the whole answer.',
|
|
1221
1229
|
inputSchema: {
|
|
1222
1230
|
query: z.string().describe('search terms (natural language is fine)'),
|
|
1223
1231
|
mode: z.enum(['substring', 'fts']).optional().describe("'fts' (default, ranked keyword) or 'substring' (exact literal — for identifiers / [[links]] / code)"),
|
|
1232
|
+
max: z.number().int().optional().describe('how many matches to return, best-ranked first (default 50, max 200). Lower it when you only need to LOCATE a page; a broad query across many brains can otherwise return well over a hundred matches in one call.'),
|
|
1224
1233
|
},
|
|
1225
1234
|
},
|
|
1226
|
-
async ({ query, mode }) => {
|
|
1235
|
+
async ({ query, mode, max }) => {
|
|
1227
1236
|
const qs = new URLSearchParams({ q: query, mode: mode === 'substring' ? 'substring' : 'fts' })
|
|
1237
|
+
if (max) qs.set('max', String(max))
|
|
1228
1238
|
const res = await fetchCortex(`${BASE}/api/grep?${qs.toString()}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
1229
1239
|
if (!res.ok) {
|
|
1230
1240
|
const body = await res.text()
|
|
@@ -1313,7 +1323,7 @@ function renderNudge(payload) {
|
|
|
1313
1323
|
'READ the full authored wiki page for one node (project/person/org/you) by its canonical name — every section, every tier you can see. This is how you READ a node; `grep` only LOCATES pages (snippets + their [[links]]), it does not read them. Navigate like a researcher: read the page you need, then FOLLOW its inline [[links]] by calling read_page on each linked name — keep following while the linked pages stay relevant, stop when they do not. You decide how deep to go. Returns only what you are permitted to see.',
|
|
1314
1324
|
inputSchema: {
|
|
1315
1325
|
name: z.string().describe('the canonical node name exactly as written (e.g. "Agnoclast", "Ben", or a [[link]] target) — identifier links ([[repo:owner/name]]) resolve to their authored HOME + a visible-event count'),
|
|
1316
|
-
kind: z.enum(['project', 'person', 'org', 'user']).optional().describe('node kind
|
|
1326
|
+
kind: z.enum(['project', 'person', 'org', 'user']).optional().describe('node kind — a PREFERENCE, not a filter. The lookup matches every kind by title and only prefers this one when several share a name, so omitting it never hides a person/org/user page. Pass it to disambiguate a name that exists as more than one kind.'),
|
|
1317
1327
|
expand: z.boolean().optional().describe('identifier names only: also list recent visible timeline events for this identifier (default: home + count)'),
|
|
1318
1328
|
history: z.boolean().optional().describe('node names only: return the node\'s TIMELINE (events joined via its [[repo:…]] stamps, reverse-chron, viewer-visible) instead of the page body. The page is the present; this is the history.'),
|
|
1319
1329
|
version: z.string().optional().describe('read a HISTORICAL version of this page instead of the current one: a rev_no (e.g. "3") or a content_hash from page_history. Use page_history first to see the versions, then rollback_page to restore one.'),
|
|
@@ -1445,7 +1455,16 @@ function renderNudge(payload) {
|
|
|
1445
1455
|
matches = [{ brain: null, authored: true, ref: page.ref, title: page.title, tiers: page.tiers }]
|
|
1446
1456
|
}
|
|
1447
1457
|
if (!matches.length) {
|
|
1448
|
-
|
|
1458
|
+
// ⚠ This used to say: `No authored ${k} page named "X" ... If it's a person or team, pass
|
|
1459
|
+
// kind (person/org).` Both halves misled. resolveNodeByTitle matches EVERY kind and uses the
|
|
1460
|
+
// requested one only as an ORDER BY preference (resolve_node.ts: `order by (bd.engram_kind =
|
|
1461
|
+
// ${kind}) desc, ...`), so (a) naming the kind implies a kind-scoped search that never
|
|
1462
|
+
// happened, and (b) retrying with kind:'person' re-runs the identical query and returns the
|
|
1463
|
+
// identical nothing. That is worse than unhelpful: it hands the caller an experiment that
|
|
1464
|
+
// cannot disconfirm "the page does not exist", so the empty retry reads as CORROBORATION.
|
|
1465
|
+
// Two sessions ran that loop and reported read_page false negatives that did not exist.
|
|
1466
|
+
// Say what was actually searched, and point at a check that can actually come back different.
|
|
1467
|
+
return { content: [{ type: 'text', text: `No authored page named "${name}" — of ANY kind (project/person/org/user), in any brain you are an active member of. Passing \`kind\` will not change this: kind only orders the candidates, it does not narrow the search. If you expected a page here, the name may differ (\`grep "${name}"\` finds pages that MENTION it, and \`mode:"substring"\` matches an exact literal), or it may be an alias.${await redLinkTriage(BASE, TOKEN, name)}` }] }
|
|
1449
1468
|
}
|
|
1450
1469
|
const day = (d) => (d ? String(d).slice(0, 10) : '')
|
|
1451
1470
|
const renderMatch = (m, tagBrain) => {
|
|
@@ -3398,14 +3417,35 @@ function renderNudge(payload) {
|
|
|
3398
3417
|
if (view === 'sweep') {
|
|
3399
3418
|
const cands = out?.candidates ?? []
|
|
3400
3419
|
if (cands.length === 0) {
|
|
3401
|
-
|
|
3420
|
+
// ⚠ A NULL RESULT MEANS "NO TITLE OVERLAP", NOT "NO HOME". Measured 2026-09-05 across four
|
|
3421
|
+
// naive agents working the backlog: this line recommended parking 4/5, 5/6, 6/6 and 4/5
|
|
3422
|
+
// records, and was WRONG almost every time — the records had correct homes that simply did
|
|
3423
|
+
// not share words with their subject lines. One agent said it plainly: "I'd treat sweep as
|
|
3424
|
+
// a hint, never as the recommendation it phrases itself as." So it no longer phrases itself
|
|
3425
|
+
// as one. This matcher compares TITLES; it cannot see a page whose subject is the record's
|
|
3426
|
+
// subject under a different name.
|
|
3427
|
+
return { content: [{ type: 'text', text:
|
|
3428
|
+
`No page-title overlap for "${out?.title ?? recordId}".\n\n`
|
|
3429
|
+
+ `⚠ That means this matcher found nothing — NOT that no home exists. It compares titles only, so it is blind\n`
|
|
3430
|
+
+ `whenever the right page is named differently from the record. Measured 2026-09-05: it recommended parking\n`
|
|
3431
|
+
+ `records that were already correctly filed, in four sessions out of four.\n\n`
|
|
3432
|
+
+ `If you have context on this record, use it — search_org or read_page will beat this matcher.\n`
|
|
3433
|
+
+ `Park only once YOU have looked and found nothing.` }] }
|
|
3402
3434
|
}
|
|
3403
3435
|
const lines = cands.map((c) => ` ${c.strength === 'strong' ? '●' : '○'} ${c.title} (${c.strength}) — ${c.documentId}`)
|
|
3404
3436
|
const rec = out?.recommendation
|
|
3405
3437
|
return {
|
|
3406
3438
|
content: [{
|
|
3407
3439
|
type: 'text',
|
|
3408
|
-
|
|
3440
|
+
// "Recommended" overstated what a title match knows. Renamed to what it actually is —
|
|
3441
|
+
// the matcher's own reading — and the weak rung no longer ends in "otherwise park",
|
|
3442
|
+
// which is the phrasing that pushed agents to park correctly-homed records.
|
|
3443
|
+
text: `Candidates for "${out?.title}":\n${lines.join('\n')}\n\n`
|
|
3444
|
+
+ `Title matcher says: ${rec?.action} — ${rec?.why}\n`
|
|
3445
|
+
+ `● strong = titles contain each other, safe to route. ○ weak = one generic word matched.\n`
|
|
3446
|
+
+ `⚠ This ranks TITLE OVERLAP ONLY. A page whose subject is this record's subject under a different name\n`
|
|
3447
|
+
+ `scores nothing here. Absence of candidates is not evidence of absence of a home — if you have context,\n`
|
|
3448
|
+
+ `trust it over this list.`,
|
|
3409
3449
|
}],
|
|
3410
3450
|
}
|
|
3411
3451
|
}
|
package/lib/use_brain.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { fetchCortex, classify, resolveBase } from './diagnose.mjs'
|
|
2
2
|
import { resolveToken } from './doctor.mjs'
|
|
3
|
+
import { renderCaptureStatus } from './capture_status.mjs'
|
|
3
4
|
|
|
4
5
|
// `use-brain` — set (or show) which brain this machine's unattended session captures land in.
|
|
5
6
|
//
|
|
@@ -14,6 +15,32 @@ import { resolveToken } from './doctor.mjs'
|
|
|
14
15
|
|
|
15
16
|
function out(m) { process.stdout.write(m + '\n') }
|
|
16
17
|
|
|
18
|
+
/** Split `--source <type>` / `--source=<type>` out of the argv, leaving the brain words.
|
|
19
|
+
*
|
|
20
|
+
* WHY THIS EXISTS. The POST body pinned `sourceType: 'claude-code'` from the day this command
|
|
21
|
+
* shipped, while the server has always keyed capture_defaults on (auth_id, source_type). So a
|
|
22
|
+
* person told by the SessionStart notice that N captures were being held could run exactly the
|
|
23
|
+
* command the notice named, watch it succeed, and fix only their claude-code captures — every
|
|
24
|
+
* `email` row kept staging and the warning kept firing. Measured 2026-09-08: one account had 52
|
|
25
|
+
* email records held for 10 days behind a correctly-followed remedy that could not reach them.
|
|
26
|
+
*
|
|
27
|
+
* `--source` DEFAULTS rather than being required: the bare form is what the notice, the docs and
|
|
28
|
+
* two months of muscle memory all say, and breaking it to fix this would trade one silent failure
|
|
29
|
+
* for a loud one. The old invocation keeps its old meaning exactly. */
|
|
30
|
+
export function parseUseBrainArgs(argv) {
|
|
31
|
+
const words = []
|
|
32
|
+
let source = 'claude-code'
|
|
33
|
+
let sawFlag = false
|
|
34
|
+
for (let i = 0; i < (argv ?? []).length; i++) {
|
|
35
|
+
const a = argv[i]
|
|
36
|
+
if (a === '--source') { source = (argv[++i] ?? '').trim(); sawFlag = true; continue }
|
|
37
|
+
if (a.startsWith('--source=')) { source = a.slice('--source='.length).trim(); sawFlag = true; continue }
|
|
38
|
+
if (a.startsWith('--')) continue
|
|
39
|
+
words.push(a)
|
|
40
|
+
}
|
|
41
|
+
return { source, brain: words.join(' ').trim(), sawFlag }
|
|
42
|
+
}
|
|
43
|
+
|
|
17
44
|
export async function runUseBrain(args) {
|
|
18
45
|
const base = resolveBase(process.env.CORTEX_URL)
|
|
19
46
|
const { token } = resolveToken()
|
|
@@ -22,10 +49,14 @@ export async function runUseBrain(args) {
|
|
|
22
49
|
return 1
|
|
23
50
|
}
|
|
24
51
|
|
|
25
|
-
// Everything
|
|
52
|
+
// Everything that is not a flag is the brain, joined — so an unquoted multi-word name still
|
|
26
53
|
// works. `use-brain Real estate` is what a person actually types; refusing it over a missing pair
|
|
27
54
|
// of quotes would be the same species of unhelpfulness this command exists to remove.
|
|
28
|
-
const
|
|
55
|
+
const { source, brain: wanted, sawFlag } = parseUseBrainArgs(args)
|
|
56
|
+
if (sawFlag && !source) {
|
|
57
|
+
out('Agnoclast: --source needs a value, e.g. --source email')
|
|
58
|
+
return 1
|
|
59
|
+
}
|
|
29
60
|
const url = `${base}/api/brain/capture-default`
|
|
30
61
|
|
|
31
62
|
if (!wanted) {
|
|
@@ -38,14 +69,11 @@ export async function runUseBrain(args) {
|
|
|
38
69
|
out(`Agnoclast: could not read your capture settings — ${classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message}`)
|
|
39
70
|
return 1
|
|
40
71
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
return 0
|
|
47
|
-
}
|
|
48
|
-
for (const d of defaults) out(`Agnoclast: ${d.sourceType} captures land in "${d.orgName}" (${d.orgId})`)
|
|
72
|
+
// One renderer, shared with the `set_capture_brain` MCP tool — see capture_status.mjs for why
|
|
73
|
+
// these two answers must not be written twice.
|
|
74
|
+
const rendered = renderCaptureStatus(await res.json(), 'cli').split('\n')
|
|
75
|
+
out(`Agnoclast: ${rendered[0]}`)
|
|
76
|
+
for (const line of rendered.slice(1)) out(line)
|
|
49
77
|
return 0
|
|
50
78
|
} catch (e) {
|
|
51
79
|
out(`Agnoclast: could not reach the server (${e?.message ?? String(e)})`)
|
|
@@ -57,7 +85,7 @@ export async function runUseBrain(args) {
|
|
|
57
85
|
const res = await fetchCortex(url, {
|
|
58
86
|
method: 'POST',
|
|
59
87
|
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
60
|
-
body: JSON.stringify({ sourceType:
|
|
88
|
+
body: JSON.stringify({ sourceType: source, brain: wanted }),
|
|
61
89
|
})
|
|
62
90
|
const body = await res.text()
|
|
63
91
|
if (!res.ok) {
|
|
@@ -69,10 +97,14 @@ export async function runUseBrain(args) {
|
|
|
69
97
|
return 1
|
|
70
98
|
}
|
|
71
99
|
const j = JSON.parse(body)
|
|
72
|
-
|
|
100
|
+
// Name the SOURCE this actually set. "your Claude Code sessions now land in X" was printed
|
|
101
|
+
// verbatim after setting an `email` default too, because the source was pinned — so the one
|
|
102
|
+
// line that could have revealed the pin instead concealed it.
|
|
103
|
+
out(`Agnoclast: ✓ your ${j.sourceType} captures now land in "${j.brain}".`)
|
|
73
104
|
// Say plainly what this does NOT do. The setter's own server-side note makes the same point,
|
|
74
105
|
// because "I fixed it" reading as "and the backlog is handled" is how held records stay held.
|
|
75
|
-
out(
|
|
106
|
+
out(` Only ${j.sourceType} — every other connector keeps its own default (use --source).`)
|
|
107
|
+
out(' Captures from BEFORE now are still held — they keep their original dates until sorted.')
|
|
76
108
|
out(' Ask your assistant to file them (they may not all belong in the same brain).')
|
|
77
109
|
return 0
|
|
78
110
|
} catch (e) {
|
package/package.json
CHANGED