@theronap/cortex-mcp 0.9.89 → 0.9.91
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/server.mjs +334 -63
- package/package.json +1 -1
package/lib/server.mjs
CHANGED
|
@@ -40,6 +40,38 @@ async function redLinkTriage(BASE, TOKEN, name) {
|
|
|
40
40
|
// file-request tools, and a module-level `fail` would be silently shadowed by it from that point on.
|
|
41
41
|
const toolError = (text) => ({ content: [{ type: 'text', text }], isError: true })
|
|
42
42
|
|
|
43
|
+
// SECTION CURRENCY (gate 3) — ONE renderer, used by BOTH read_page and project_status.
|
|
44
|
+
//
|
|
45
|
+
// Extracted as a PURE function on the pickBrainMatch precedent: that fix pulled a shared rule out of two
|
|
46
|
+
// resolvers specifically so they could not drift, after they drifted. This is the same situation found
|
|
47
|
+
// 2026-08-15. THREE surfaces render authored sections from the same server-computed fields:
|
|
48
|
+
// • renderAuthoredNodeBody (web/lib/engine/authored_page_tiers.ts) — console/web
|
|
49
|
+
// • read_page here — printed a date and "⚠ Undated section" as two adjacent lines
|
|
50
|
+
// • project_status here — printed NO currency at all: no as-of, no warning, nothing
|
|
51
|
+
// PR #558 fixed only the first. project_status is the tool the routing docs reach for FIRST, and gate 3
|
|
52
|
+
// reads "a reader can date any claim without a second query" — a reader there could date nothing.
|
|
53
|
+
//
|
|
54
|
+
// The two dates are DIFFERENT facts and both true: `asOf` is when the section's text last CHANGED; an
|
|
55
|
+
// explicit date in the prose is when the CLAIM was true. Stated as one sentence they inform; stacked as
|
|
56
|
+
// two lines they read as the page contradicting itself.
|
|
57
|
+
//
|
|
58
|
+
// ⚠ Do NOT "simplify" this by keying on `asOf` — it is set on every section always, so the detector
|
|
59
|
+
// would go silent everywhere. The branch must key on `hasExplicitDate`, and on `=== false` rather than
|
|
60
|
+
// falsy: `undefined` is an older server mid-rolling-deploy that has computed no verdict, and inventing
|
|
61
|
+
// one there is the 0093 don't-impute violation.
|
|
62
|
+
export const sectionCurrencyStamp = (s, day) => {
|
|
63
|
+
const on = s.asOf ? day(s.asOf) : ''
|
|
64
|
+
return s.hasExplicitDate === false
|
|
65
|
+
? `${on ? ` · text last written ${on} —` : ' —'} ⚠ the claim itself carries no date; verify before relying on it`
|
|
66
|
+
: (on ? ` · as of ${on}` : '')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ⚠ The newline is a FIX, not cosmetics. read_page's old form was `${asOf}${currency}${s.body}`, where
|
|
70
|
+
// only the UNDATED branch contributed a trailing \n — so every DATED section ran its body straight onto
|
|
71
|
+
// the header line ("· as of 2026-08-14Identifiers are candidates, never publication."). The defect was
|
|
72
|
+
// invisible on exactly the sections that were healthy.
|
|
73
|
+
export const renderSection = (s, day) => `### ${s.heading}${sectionCurrencyStamp(s, day)}\n${s.body}`
|
|
74
|
+
|
|
43
75
|
// The Agnoclast MCP server (stdio). Serves the signed-in employee's scoped org
|
|
44
76
|
// context to their AI assistant. CORTEX_TOKEN identifies the user + org.
|
|
45
77
|
|
|
@@ -192,62 +224,179 @@ export async function runServer(version) {
|
|
|
192
224
|
'log_session',
|
|
193
225
|
{
|
|
194
226
|
title: 'Log this session to Agnoclast',
|
|
195
|
-
description: 'Persist a CURATED summary of this work session as its durable Agnoclast record (authoritative — supersedes the auto-capture hook). Call at session close after composing the summary. Pass sessionId (the Claude Code session id)
|
|
227
|
+
description: 'Persist a CURATED summary of this work session as its durable Agnoclast record (authoritative — supersedes the auto-capture hook). Call at session close after composing the summary. Pass sessionId (the Claude Code session id) so this dedupes with the auto-capture of the same session. If you belong to more than one brain you MUST name one — without a brain a session log has no route and is STAGED rather than recorded. **If the session touched work belonging to different brains, pass `segments` instead of `summary` and split it** — one segment per brain, each summary standing on its own and never alluding to the others. Reports every segment individually; a partial result is reported as PARTIAL, never as success.',
|
|
196
228
|
inputSchema: {
|
|
197
|
-
summary: z.string().describe('the curated session summary
|
|
229
|
+
summary: z.string().optional().describe('the curated session summary — the single-brain form. Omit when passing `segments`'),
|
|
230
|
+
segments: z.array(z.object({
|
|
231
|
+
brain: z.string().describe('destination brain for this half of the session (name or org id)'),
|
|
232
|
+
summary: z.string().describe('a summary that stands ON ITS OWN. It must not mention, allude to, or imply that other segments exist — "the rest of this session covered personal projects" leaks in prose exactly what splitting was meant to contain'),
|
|
233
|
+
title: z.string().optional(),
|
|
234
|
+
project: z.string().optional(),
|
|
235
|
+
})).optional().describe('SPLIT the log, one entry per brain. Use whenever a session touched work belonging to different brains: a session is a container of time, not a topic, and every single-brain answer is wrong — filing it all in the org brain exposes personal work to colleagues, filing it all in the personal one denies the org its record, and summarizing half silently drops the other half. Max ONE segment per brain (they share this session\'s dedupe key, so two aimed at the same brain would overwrite each other). When a chunk is ambiguous, put it in the MORE PRIVATE brain — a misfile there is private, a misfile the other way is visible to everyone in the org.'),
|
|
198
236
|
project: z.string().optional().describe('project key/name this session worked in'),
|
|
199
237
|
title: z.string().optional().describe('short title for the session'),
|
|
200
|
-
sessionId: z.string().optional().describe('the Claude Code session id
|
|
238
|
+
sessionId: z.string().optional().describe('the Claude Code session id — shared by every segment, and the only thing pairing them. Deliberately NOT a link: segments never reference each other, so a reader cleared for one brain cannot tell the others exist, while you can join on it across brains'),
|
|
201
239
|
brain: z.string().optional().describe('which brain to record this session in (name or org id, one of your own). REQUIRED IN EFFECT for a multi-brain member: session-class sources route only by an explicit brain or a sole membership, so omitting it stages the log instead of recording it.'),
|
|
202
240
|
privacy: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('tier this record AT WRITE TIME. Use when the summary names confidential work (a candidate evaluation, a security finding) — safer than letting it land org-visible and re-tiering after, which leaves it readable in between.'),
|
|
203
241
|
},
|
|
204
242
|
},
|
|
205
|
-
async ({ summary, project, title, sessionId, brain, privacy }) => {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
243
|
+
async ({ summary, segments, project, title, sessionId, brain, privacy }) => {
|
|
244
|
+
// A session is a container of TIME, not a topic (ADR-0029 step 4). Normalize to a list of
|
|
245
|
+
// segments; the single-brain call is just a one-segment list.
|
|
246
|
+
const list = Array.isArray(segments) && segments.length
|
|
247
|
+
? segments.map((s) => ({ brain: s.brain, summary: s.summary, title: s.title ?? title, project: s.project ?? project }))
|
|
248
|
+
: (summary ? [{ brain, summary, title, project }] : null)
|
|
249
|
+
if (!list) {
|
|
250
|
+
return toolError('Pass `summary` (single brain) or a non-empty `segments` array (one entry per brain).')
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Records are unique on the ORG-SCOPED (org_id, dedupe_key) and every segment carries this
|
|
254
|
+
// session's dedupe key. Cross-brain segments therefore never collide — that constraint is what
|
|
255
|
+
// makes the whole design work — but two aimed at the SAME brain would silently merge and lose
|
|
256
|
+
// one. Refuse instead of letting that happen quietly.
|
|
257
|
+
const seenBrain = new Set()
|
|
258
|
+
for (const s of list) {
|
|
259
|
+
const k = String(s.brain ?? '').trim().toLowerCase()
|
|
260
|
+
if (seenBrain.has(k)) {
|
|
261
|
+
return toolError(`Two segments target the same brain (${s.brain}). They share this session's dedupe key, so the second would overwrite the first — merge them into one segment.`)
|
|
262
|
+
}
|
|
263
|
+
seenBrain.add(k)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
let brainIndex = null
|
|
267
|
+
const orgIdFor = async (nameOrId) => {
|
|
268
|
+
if (!nameOrId) return null
|
|
269
|
+
if (!brainIndex) {
|
|
270
|
+
try {
|
|
271
|
+
const r = await fetchCortex(`${BASE}/api/brains`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
272
|
+
brainIndex = r.ok ? ((await r.json().catch(() => ({}))).brains ?? []) : []
|
|
273
|
+
} catch {
|
|
274
|
+
brainIndex = []
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const q = String(nameOrId).trim().toLowerCase()
|
|
278
|
+
const hit = brainIndex.find(
|
|
279
|
+
(b) => String(b.orgId ?? '').toLowerCase() === q || String(b.name ?? '').toLowerCase() === q,
|
|
280
|
+
)
|
|
281
|
+
return hit?.orgId ?? null
|
|
229
282
|
}
|
|
230
|
-
const j = await res.json().catch(() => ({}))
|
|
231
283
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
284
|
+
const lines = []
|
|
285
|
+
let failures = 0
|
|
286
|
+
|
|
287
|
+
for (const seg of list) {
|
|
288
|
+
const where = seg.brain ?? '(no brain named)'
|
|
289
|
+
let res
|
|
290
|
+
try {
|
|
291
|
+
res = await fetchCortex(`${BASE}/api/ingest`, {
|
|
292
|
+
method: 'POST',
|
|
293
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
294
|
+
body: JSON.stringify({
|
|
295
|
+
source: 'claude-code',
|
|
296
|
+
captureSource: 'skill',
|
|
297
|
+
summary: seg.summary,
|
|
298
|
+
...(seg.project ? { project: seg.project } : {}),
|
|
299
|
+
...(seg.title ? { title: seg.title } : {}),
|
|
300
|
+
...(sessionId ? { sessionId } : {}),
|
|
301
|
+
// ADR-0022 deleted the write pointer, so a session-class source routes ONLY by an
|
|
302
|
+
// explicit brain or a sole membership — anything else STAGES. This tool never sent one,
|
|
303
|
+
// so every close-out from a multi-brain member landed in staged_records instead of the
|
|
304
|
+
// org. Measured 2026-08-09: 86 staged rows, not drainable by /api/staged/promote.
|
|
305
|
+
...(seg.brain ? { brain: seg.brain } : {}),
|
|
306
|
+
...(privacy ? { privacy } : {}),
|
|
307
|
+
payload: { via: 'log_session' },
|
|
308
|
+
}),
|
|
309
|
+
})
|
|
310
|
+
} catch (e) {
|
|
311
|
+
failures++
|
|
312
|
+
lines.push(`✗ ${where}: ${e.message}`)
|
|
313
|
+
continue
|
|
314
|
+
}
|
|
315
|
+
if (!res.ok) {
|
|
316
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
317
|
+
failures++
|
|
318
|
+
lines.push(`✗ ${where}: ${d.message}`)
|
|
319
|
+
continue
|
|
320
|
+
}
|
|
321
|
+
const j = await res.json().catch(() => ({}))
|
|
322
|
+
|
|
323
|
+
// NEVER report a non-record as "logged". `staged` and `skipped` are 200 OK responses that
|
|
324
|
+
// wrote no record, and this used to print "Logged … updated existing" for both, because
|
|
325
|
+
// `j.inserted` is merely falsy on a staged write.
|
|
326
|
+
if (j.staged) {
|
|
327
|
+
const why = j.reason === 'no_route_for_source' && !seg.brain
|
|
328
|
+
? 'no brain named and you belong to more than one, so it had nowhere to route'
|
|
329
|
+
: `reason: ${j.reason ?? 'unknown'}`
|
|
330
|
+
failures++
|
|
331
|
+
lines.push(`✗ ${where}: NOT LOGGED — staged, not recorded (${why}). Re-run this segment with brain:"<name>" — staged session logs cannot be drained by /api/staged/promote.`)
|
|
332
|
+
continue
|
|
333
|
+
}
|
|
334
|
+
if (j.skipped) {
|
|
335
|
+
failures++
|
|
336
|
+
lines.push(`✗ ${where}: NOT LOGGED — server skipped the write: ${j.skipped}`)
|
|
337
|
+
continue
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Gate 4 routes a session log into PRIVATE INTAKE rather than straight to records, answering
|
|
341
|
+
// `via:"private_intake"` with an intakeItemId and no id. The previous check fell through to
|
|
342
|
+
// "no record id" and reported NOT LOGGED over a write that had landed — a false negative that
|
|
343
|
+
// drives retries, and retries against ingest make duplicates.
|
|
344
|
+
//
|
|
345
|
+
// Materializing here is not an optimization. Close-out is the ONLY moment the destination
|
|
346
|
+
// brain is known; a unit left in intake goes cleanup-due in a day and becomes an item no later
|
|
347
|
+
// session has the authority to route, because deciding which brain half of someone's session
|
|
348
|
+
// belongs in is exactly the judgment a stranger cannot make.
|
|
349
|
+
if (!j.id && j.via === 'private_intake' && j.intakeItemId) {
|
|
350
|
+
const orgId = await orgIdFor(seg.brain)
|
|
351
|
+
if (!orgId) {
|
|
352
|
+
failures++
|
|
353
|
+
lines.push(`✗ ${where}: captured to intake as ${j.intakeItemId}, but that brain did not resolve to an org id — materialize it by hand`)
|
|
354
|
+
continue
|
|
355
|
+
}
|
|
356
|
+
let m
|
|
357
|
+
try {
|
|
358
|
+
m = await fetchCortex(`${BASE}/api/intake/materialize`, {
|
|
359
|
+
method: 'POST',
|
|
360
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
361
|
+
body: JSON.stringify({
|
|
362
|
+
intakeItemId: j.intakeItemId,
|
|
363
|
+
orgId,
|
|
364
|
+
title: seg.title,
|
|
365
|
+
summary: seg.summary,
|
|
366
|
+
source: 'claude-code',
|
|
367
|
+
recordType: 'ai_session',
|
|
368
|
+
origin: 'session',
|
|
369
|
+
}),
|
|
370
|
+
})
|
|
371
|
+
} catch (e) {
|
|
372
|
+
failures++
|
|
373
|
+
lines.push(`✗ ${where}: intake ${j.intakeItemId} NOT materialized — ${e.message}`)
|
|
374
|
+
continue
|
|
375
|
+
}
|
|
376
|
+
const mj = await m.json().catch(() => ({}))
|
|
377
|
+
if (!m.ok || !mj.recordId) {
|
|
378
|
+
failures++
|
|
379
|
+
lines.push(`✗ ${where}: intake ${j.intakeItemId} NOT materialized — ${mj.error ?? m.status}`)
|
|
380
|
+
continue
|
|
381
|
+
}
|
|
382
|
+
lines.push(`✓ ${where}: record ${mj.recordId}`)
|
|
383
|
+
continue
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (!j.id) {
|
|
387
|
+
failures++
|
|
388
|
+
lines.push(`✗ ${where}: NOT LOGGED — no record id. Raw: ${JSON.stringify(j).slice(0, 200)}`)
|
|
389
|
+
continue
|
|
390
|
+
}
|
|
391
|
+
lines.push(`✓ ${where}: record ${j.id}${j.inserted ? '' : ' (updated existing)'}`)
|
|
249
392
|
}
|
|
250
|
-
|
|
393
|
+
|
|
394
|
+
// Partial success is a real outcome once there is more than one segment, and "3 of 4 landed"
|
|
395
|
+
// must never read as done — that is the same silent-loss shape this whole path was repaired for.
|
|
396
|
+
const head = failures === 0
|
|
397
|
+
? `Logged ${lines.length} segment${lines.length === 1 ? '' : 's'}.`
|
|
398
|
+
: `PARTIAL — ${lines.length - failures} of ${lines.length} segments logged, ${failures} FAILED. A partly-logged session is not a logged session; re-run the failed segments.`
|
|
399
|
+
return { content: [{ type: 'text', text: `${head}\n${lines.join('\n')}` }] }
|
|
251
400
|
},
|
|
252
401
|
)
|
|
253
402
|
|
|
@@ -422,7 +571,7 @@ export async function runServer(version) {
|
|
|
422
571
|
{
|
|
423
572
|
title: 'Check private intake changes',
|
|
424
573
|
description:
|
|
425
|
-
'Session heartbeat for Gate 4 private intake. Returns an operational delta since your cursor (no ciphertext) plus cleanupDueCount. Call at session start, before each user turn, after long actions, and at least every two minutes while actively working.
|
|
574
|
+
'Session heartbeat for Gate 4 private intake. Returns an operational delta since your cursor (no ciphertext) plus cleanupDueCount. Call at session start, before each user turn, after long actions, and at least every two minutes while actively working. cleanupDueCount is advisory: prefer to service it with claimKind=cleanup when you reach a natural break, but never park the work you were actually asked to do in order to drain the queue first. NOTE afterSeq is this feed\'s own sequence — the changeSeq returned by ingest is a different counter, and passing it here seeks past the end and looks like a dead feed.',
|
|
426
575
|
inputSchema: {
|
|
427
576
|
afterSeq: z.number().optional().describe('cursor from the previous call (default 0)'),
|
|
428
577
|
limit: z.number().optional().describe('max change rows (default 100)'),
|
|
@@ -448,25 +597,66 @@ export async function runServer(version) {
|
|
|
448
597
|
{
|
|
449
598
|
title: 'Claim private intake items',
|
|
450
599
|
description:
|
|
451
|
-
'Claim a lease on private intake items (relevance or cleanup). Returns decrypted payloads for the lease holder only.
|
|
600
|
+
'Claim a lease on private intake items (relevance or cleanup). Returns decrypted payloads for the lease holder only. Requires x-cortex-session-key (set automatically by this MCP server). Aged work never blocks a claim: the response reports cleanupDueCount and how far behind the oldest unit is, and servicing it is expected but always your call. Claiming is a commitment to process — hand back anything you will not finish with intake_release, or intake_defer if it is the owner\'s decision to make.',
|
|
452
601
|
inputSchema: {
|
|
453
602
|
claimKind: z.enum(['relevance', 'cleanup']).optional().describe('default relevance'),
|
|
454
|
-
limit: z.number().optional().describe('max items (default 10)'),
|
|
603
|
+
limit: z.number().optional().describe('max items (default 10). Ignored when intakeItemIds is given.'),
|
|
604
|
+
intakeItemIds: z.array(z.string()).optional().describe(
|
|
605
|
+
'claim these specific units instead of the head of the queue (max 50). Without it you get FIFO, ' +
|
|
606
|
+
'so reaching one known unit means claiming everything ahead of it. Ids come from intake_changes ' +
|
|
607
|
+
'or from an ingest response. The reply adds an `outcomes` entry per requested id — claimed, ' +
|
|
608
|
+
'held_by_you (you already hold a live lease on it — proceed, do not re-claim), ' +
|
|
609
|
+
'held (someone else has a live lease; the holder is a stable hash, never their session key), ' +
|
|
610
|
+
'ineligible (already resolved or deferred to the owner), not_found, or unavailable (a momentary ' +
|
|
611
|
+
'lock — retrying is reasonable). Outcomes are best-effort, not a snapshot you can rely on.',
|
|
612
|
+
),
|
|
455
613
|
},
|
|
456
614
|
},
|
|
457
|
-
async ({ claimKind, limit }) => {
|
|
615
|
+
async ({ claimKind, limit, intakeItemIds }) => {
|
|
458
616
|
const res = await fetchCortex(`${BASE}/api/intake/claim`, {
|
|
459
617
|
method: 'POST',
|
|
460
618
|
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
461
|
-
body: JSON.stringify({
|
|
619
|
+
body: JSON.stringify({
|
|
620
|
+
claimKind: claimKind ?? 'relevance',
|
|
621
|
+
limit,
|
|
622
|
+
includePayload: true,
|
|
623
|
+
// Forwarded only when present. An empty array is a real request to claim nothing and must
|
|
624
|
+
// survive as one; sending `[]` where the caller sent nothing would silently switch a FIFO
|
|
625
|
+
// claim into a no-op.
|
|
626
|
+
...(intakeItemIds ? { intakeItemIds } : {}),
|
|
627
|
+
}),
|
|
462
628
|
})
|
|
463
629
|
if (!res.ok) {
|
|
464
630
|
const body = await res.text()
|
|
465
631
|
if (res.status === 403) return toolError('Private intake is not enabled for this account.')
|
|
632
|
+
// Kept deliberately after the server stopped sending it. A published MCP build outlives any
|
|
633
|
+
// one deployment, so this client will meet servers that still refuse relevance claims while
|
|
634
|
+
// cleanup is due. Surfacing that as its own message beats a generic HTTP failure.
|
|
466
635
|
if (res.status === 409) return toolError(body)
|
|
467
636
|
throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
|
|
468
637
|
}
|
|
469
|
-
|
|
638
|
+
|
|
639
|
+
const j = await res.json()
|
|
640
|
+
|
|
641
|
+
// The nudge that replaced the 409. It has to be legible without doing arithmetic on a
|
|
642
|
+
// timestamp, so say how far behind rather than printing an ISO string and hoping — "6 days"
|
|
643
|
+
// is a reason to act and "2026-08-09T…" is a field to skim past.
|
|
644
|
+
let nudge = ''
|
|
645
|
+
if ((j?.cleanupDueCount ?? 0) > 0) {
|
|
646
|
+
const n = j.cleanupDueCount
|
|
647
|
+
let behind = ''
|
|
648
|
+
const oldest = j?.oldestCleanupDueAt ? Date.parse(j.oldestCleanupDueAt) : NaN
|
|
649
|
+
if (Number.isFinite(oldest)) {
|
|
650
|
+
const mins = Math.max(0, Math.floor((Date.now() - oldest) / 60000))
|
|
651
|
+
behind = mins >= 1440 ? `, oldest ${Math.floor(mins / 1440)}d overdue`
|
|
652
|
+
: mins >= 60 ? `, oldest ${Math.floor(mins / 60)}h overdue`
|
|
653
|
+
: `, oldest ${mins}m overdue`
|
|
654
|
+
}
|
|
655
|
+
nudge = `\n\n⏳ ${n} intake unit${n === 1 ? '' : 's'} past the cleanup deadline${behind}. `
|
|
656
|
+
+ `Nothing is blocked — run intake_claim with claimKind:"cleanup" when you reach a natural break.`
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
return { content: [{ type: 'text', text: JSON.stringify(j, null, 2) + nudge }] }
|
|
470
660
|
},
|
|
471
661
|
)
|
|
472
662
|
|
|
@@ -485,7 +675,7 @@ export async function runServer(version) {
|
|
|
485
675
|
source: z.string().optional(),
|
|
486
676
|
recordType: z.string().optional(),
|
|
487
677
|
dedupeKey: z.string().optional(),
|
|
488
|
-
origin: z.enum(['deterministic', 'llm', 'user']).optional(),
|
|
678
|
+
origin: z.enum(['deterministic', 'llm', 'user', 'session']).optional(),
|
|
489
679
|
},
|
|
490
680
|
},
|
|
491
681
|
async (args) => {
|
|
@@ -553,11 +743,90 @@ export async function runServer(version) {
|
|
|
553
743
|
},
|
|
554
744
|
)
|
|
555
745
|
|
|
746
|
+
server.registerTool(
|
|
747
|
+
'intake_defer',
|
|
748
|
+
{
|
|
749
|
+
title: 'Defer a private intake item to the owner',
|
|
750
|
+
description:
|
|
751
|
+
'The THIRD terminal outcome, and the right one whenever the honest answer is "this is not mine to decide." Use it when a claimed unit is someone else\'s private content, when the destination brain is a real judgment call rather than a lookup, or when publishing and destroying are both wrong — a stranger\'s message thread, a photo you cannot place, an email whose brain depends on context only the owner has. Destroys NOTHING: the unit stays encrypted and intact, its state becomes `awaiting_user`, and your `question` is what the owner actually sees. Prefer this over letting a lease lapse — a lapsed lease says nothing, increments `attempts`, and hands the identical dead end to the next session. Requires the claim, one item per call.',
|
|
752
|
+
inputSchema: {
|
|
753
|
+
intakeItemId: z.string().describe('intake item uuid from intake_claim'),
|
|
754
|
+
question: z.string().describe('what you need the owner to decide, in their words not yours — this is the entire message they get, so "which brain should this iMessage thread go to, if any?" beats "needs triage"'),
|
|
755
|
+
options: z.array(z.string()).optional().describe('the concrete choices, when the decision is a pick rather than an open question — e.g. ["Personal","TTO","Discard — nothing to record"]. Stored on the question and rendered by intake_cleanup_status, so the owner can answer with a choice instead of prose.'),
|
|
756
|
+
},
|
|
757
|
+
},
|
|
758
|
+
async ({ intakeItemId, question, options }) => {
|
|
759
|
+
let res
|
|
760
|
+
try {
|
|
761
|
+
res = await fetchCortex(`${BASE}/api/intake/defer`, {
|
|
762
|
+
method: 'POST',
|
|
763
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
764
|
+
body: JSON.stringify({ intakeItemId, question, ...(options?.length ? { options } : {}) }),
|
|
765
|
+
})
|
|
766
|
+
} catch (e) {
|
|
767
|
+
return toolError(`Could not defer: ${e.message}`)
|
|
768
|
+
}
|
|
769
|
+
const out = await res.json().catch(() => null)
|
|
770
|
+
if (!res.ok) {
|
|
771
|
+
if (out?.error === 'not_claimed') {
|
|
772
|
+
return toolError('You do not hold a claim on that item — intake_claim it first, so the question follows from having read it.')
|
|
773
|
+
}
|
|
774
|
+
if (out?.error === 'already_terminal') {
|
|
775
|
+
return toolError(`Nothing left to ask about: ${out?.detail ?? 'the unit is already materialized or discarded'}.`)
|
|
776
|
+
}
|
|
777
|
+
return toolError(`Could not defer: ${out?.error ?? res.status}${out?.detail ? ` — ${out.detail}` : ''}`)
|
|
778
|
+
}
|
|
779
|
+
if (out?.alreadyDeferred) {
|
|
780
|
+
return { content: [{ type: 'text', text: 'Already awaiting the owner — nothing to do.' }] }
|
|
781
|
+
}
|
|
782
|
+
return { content: [{ type: 'text', text: 'Deferred to the owner. Content preserved, question queued in intake_cleanup_status.openQuestions, and the unit no longer gates cleanup.' }] }
|
|
783
|
+
},
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
server.registerTool(
|
|
787
|
+
'intake_release',
|
|
788
|
+
{
|
|
789
|
+
title: 'Hand an intake claim back',
|
|
790
|
+
description:
|
|
791
|
+
'Give a claimed unit back to the queue WITHOUT deciding anything. This is not a fourth outcome — materialize, discard and defer resolve a unit; release just ends your hold on it. Use it when you claimed more than you can act on, or when the unit turns out to belong to work you are not doing. Prefer it over letting the lease expire: a lapse and a crash are indistinguishable in the ledger, so silently timing out costs the pile the one signal that says a session looked and chose to pass. Use intake_defer instead when the unit needs the OWNER to decide — release puts it back in front of the next session, which will hit whatever wall you did.',
|
|
792
|
+
inputSchema: {
|
|
793
|
+
intakeItemId: z.string().describe('intake item uuid from intake_claim'),
|
|
794
|
+
reason: z.string().describe('why you are handing it back — "claimed too broadly", "not related to this session\'s work". Recorded on the claim, and the only thing distinguishing this from a lapsed lease'),
|
|
795
|
+
},
|
|
796
|
+
},
|
|
797
|
+
async ({ intakeItemId, reason }) => {
|
|
798
|
+
let res
|
|
799
|
+
try {
|
|
800
|
+
res = await fetchCortex(`${BASE}/api/intake/release`, {
|
|
801
|
+
method: 'POST',
|
|
802
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
803
|
+
body: JSON.stringify({ intakeItemId, reason }),
|
|
804
|
+
})
|
|
805
|
+
} catch (e) {
|
|
806
|
+
return toolError(`Could not release: ${e.message}`)
|
|
807
|
+
}
|
|
808
|
+
const out = await res.json().catch(() => null)
|
|
809
|
+
if (!res.ok) {
|
|
810
|
+
if (out?.error === 'not_claimed') {
|
|
811
|
+
return toolError('You do not hold a claim on that item — there is nothing to hand back.')
|
|
812
|
+
}
|
|
813
|
+
if (out?.error === 'awaiting_user') {
|
|
814
|
+
return toolError('That unit is already deferred to the owner. Releasing it would orphan the open question — leave it, or have the owner answer it.')
|
|
815
|
+
}
|
|
816
|
+
if (out?.error === 'already_terminal') {
|
|
817
|
+
return toolError(`Nothing to release: ${out?.detail ?? 'the unit is already materialized or discarded'}.`)
|
|
818
|
+
}
|
|
819
|
+
return toolError(`Could not release: ${out?.error ?? res.status}${out?.detail ? ` — ${out.detail}` : ''}`)
|
|
820
|
+
}
|
|
821
|
+
return { content: [{ type: 'text', text: 'Released. The unit is back in the queue as pending, and your lease is gone.' }] }
|
|
822
|
+
},
|
|
823
|
+
)
|
|
824
|
+
|
|
556
825
|
server.registerTool(
|
|
557
826
|
'intake_cleanup_status',
|
|
558
827
|
{
|
|
559
828
|
title: 'Private intake cleanup status',
|
|
560
|
-
description: 'Counts of pending/claimed/awaiting/cleanup-due intake items
|
|
829
|
+
description: 'Counts of pending/claimed/awaiting/cleanup-due intake items, open clarifying questions for the owner, and `needsAttention` — units that keep coming back, with attempts / releases / deferrals / lapses each broken out. A high `lapses` means sessions claimed that unit and neither resolved it nor handed it back; a `deferrals` above 1 means the owner has already been asked more than once. Both are the OWNER\'s signal to act on, not yours — surface them rather than trying to clear them yourself.',
|
|
561
830
|
inputSchema: {},
|
|
562
831
|
},
|
|
563
832
|
async () => {
|
|
@@ -679,7 +948,11 @@ export async function runServer(version) {
|
|
|
679
948
|
const day = (d) => (d ? String(d).slice(0, 10) : '')
|
|
680
949
|
const renderMatch = (m) => {
|
|
681
950
|
const blocks = m.tiers.map((t) => {
|
|
682
|
-
|
|
951
|
+
// Was heading-then-body with NO currency at all — no as-of, no warning — while
|
|
952
|
+
// read_page showed it. project_status is what the routing docs reach for first, so a
|
|
953
|
+
// reader here could date nothing. Same renderer as read_page now, so the two cannot
|
|
954
|
+
// drift again.
|
|
955
|
+
const secs = (t.sections ?? []).map((s) => renderSection(s, day)).join('\n\n')
|
|
683
956
|
const head = `[${t.tier}${day(t.updated_at) ? ` · authored ${day(t.updated_at)}` : ''}${t.validity && t.validity !== 'current' ? ` · ${t.validity}` : ''}]`
|
|
684
957
|
return [head, t.summary, secs].filter(Boolean).join('\n')
|
|
685
958
|
})
|
|
@@ -816,13 +1089,9 @@ export async function runServer(version) {
|
|
|
816
1089
|
const renderMatch = (m, tagBrain) => {
|
|
817
1090
|
const blocks = m.tiers.map((t) => {
|
|
818
1091
|
const secs = (t.sections ?? []).map((s) => {
|
|
819
|
-
//
|
|
820
|
-
// not
|
|
821
|
-
|
|
822
|
-
? '\n⚠ **Undated section — verify before relying on its claims.**\n'
|
|
823
|
-
: ''
|
|
824
|
-
const asOf = s.asOf ? ` · as of ${day(s.asOf)}` : ''
|
|
825
|
-
return `### ${s.heading}${asOf}${currency}${s.body}`
|
|
1092
|
+
// Shared with project_status via renderSection — see its definition for why this is one
|
|
1093
|
+
// function and not two (they drifted; #558 fixed one of three surfaces).
|
|
1094
|
+
return renderSection(s, day)
|
|
826
1095
|
}).join('\n\n')
|
|
827
1096
|
// ADR-0018: a null version isn't "nothing to show" — it means this variant predates content-
|
|
828
1097
|
// hash tracking (a 2026-06-29 import scar) and CANNOT be re-authored via base_version until an
|
|
@@ -1517,14 +1786,16 @@ export async function runServer(version) {
|
|
|
1517
1786
|
project: z.string().optional().describe('project key to filter to (e.g. "cortex")'),
|
|
1518
1787
|
since_days: z.number().optional().describe('only records from the last N days'),
|
|
1519
1788
|
limit: z.number().optional().describe('max rows (1-50, default 20)'),
|
|
1789
|
+
session: z.string().optional().describe('a session id — returns every record that session produced, INCLUDING the separate halves of a log that was split across brains. Segmented logs share a sessionId and never link to each other, so this is the only way to reassemble one. RLS-scoped: you get the halves you are cleared for and cannot tell whether others exist'),
|
|
1520
1790
|
},
|
|
1521
1791
|
},
|
|
1522
|
-
async ({ type, project, since_days, limit }) => {
|
|
1792
|
+
async ({ type, project, since_days, limit, session }) => {
|
|
1523
1793
|
const qs = new URLSearchParams()
|
|
1524
1794
|
if (type) qs.set('type', type)
|
|
1525
1795
|
if (project) qs.set('project', project)
|
|
1526
1796
|
if (since_days != null) qs.set('since_days', String(since_days))
|
|
1527
1797
|
if (limit != null) qs.set('limit', String(limit))
|
|
1798
|
+
if (session) qs.set('session', session)
|
|
1528
1799
|
let res
|
|
1529
1800
|
try {
|
|
1530
1801
|
res = await fetchCortex(`${BASE}/api/records?${qs}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|