@theronap/agnoclast-mcp 0.9.213 → 0.9.215
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/diagnose.mjs +9 -2
- package/lib/doctor.mjs +1 -1
- package/lib/filing_obligations.mjs +96 -0
- package/lib/server.mjs +28 -2
- package/package.json +1 -1
package/lib/diagnose.mjs
CHANGED
|
@@ -679,7 +679,14 @@ export async function fetchCortex(url, opts = {}, policy = {}) {
|
|
|
679
679
|
|
|
680
680
|
// Live health probe: hit /api/mcp-context with the token and classify the result.
|
|
681
681
|
// Returns { ok, status, projectCount?, requestId?, diagnosis? }.
|
|
682
|
-
|
|
682
|
+
//
|
|
683
|
+
// `probe: true` (the SessionStart status line) asks the server for the token check and the capture
|
|
684
|
+
// notice ONLY, not the context. That hook fires at the same moment as `snapshot-context`, which fetches
|
|
685
|
+
// the full context, so without it every session start assembled the context twice, concurrently, on a
|
|
686
|
+
// max:1 pool: a ~1.6 s start measured ~5 s in prod (2026-10-06). doctor/setup/install keep the full
|
|
687
|
+
// fetch on purpose; they exist to prove the context itself comes back. An older server ignores the
|
|
688
|
+
// parameter and returns the full context, which this still parses, so the two can ship in either order.
|
|
689
|
+
export async function checkToken(token, base, { probe = false } = {}) {
|
|
683
690
|
if (!token) {
|
|
684
691
|
return { ok: false, status: 0, diagnosis: { kind: 'config', retriable: false,
|
|
685
692
|
message: 'No CORTEX_TOKEN found. Run: npx -y @theronap/cortex-mcp login (opens your browser; or `setup <token>` with a token from the console → Connect your AI)' } }
|
|
@@ -689,7 +696,7 @@ export async function checkToken(token, base) {
|
|
|
689
696
|
message: `Token "${String(token).slice(0, 8)}…" is not a valid Agnoclast token (expected a UUID). ` +
|
|
690
697
|
`Re-run setup with the token from the console.` } }
|
|
691
698
|
}
|
|
692
|
-
const url = `${(base ?? CANONICAL_BASE).replace(/\/$/, '')}/api/mcp-context`
|
|
699
|
+
const url = `${(base ?? CANONICAL_BASE).replace(/\/$/, '')}/api/mcp-context${probe ? '?probe=1' : ''}`
|
|
693
700
|
let res
|
|
694
701
|
try {
|
|
695
702
|
res = await fetchCortex(url, { headers: { Authorization: `Bearer ${token}` } })
|
package/lib/doctor.mjs
CHANGED
|
@@ -71,7 +71,7 @@ async function statusLine(out, { capturesStuck = false } = {}) {
|
|
|
71
71
|
return
|
|
72
72
|
}
|
|
73
73
|
try {
|
|
74
|
-
const r = await checkToken(token, base)
|
|
74
|
+
const r = await checkToken(token, base, { probe: true })
|
|
75
75
|
if (r.ok) {
|
|
76
76
|
// ⚠ THE NOTICE REPLACES THE HAPPY LINE RATHER THAN FOLLOWING IT.
|
|
77
77
|
// "connected — sessions on this machine are captured to your org" was printed truthfully to
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// OBLIGATION 53bfd283 — A TEXT ANSWERS "DOES THIS ASK ANYONE FOR ANYTHING?" WHEN IT IS FILED.
|
|
2
|
+
//
|
|
3
|
+
// No extractor reads a text: extraction runs on session transcripts and handoffs (ADR-0059 §5.1), and a
|
|
4
|
+
// text is opened for the first time by the agent that files it. Volunteering `propose_obligation` from
|
|
5
|
+
// there was measured at ~10% for the same kind of ask (§5.6). So `intake_materialize` now carries the
|
|
6
|
+
// answer itself, and the server refuses to file a text from this release on until it has one. `[]` is a
|
|
7
|
+
// complete answer; silence is what is refused.
|
|
8
|
+
//
|
|
9
|
+
// The server checks every quote against the text it just unsealed, so a paraphrase is never written. This
|
|
10
|
+
// file only does what the server cannot: split one call's list across the units of a batch, and refuse
|
|
11
|
+
// loudly — with the reason — what the server would otherwise drop silently.
|
|
12
|
+
|
|
13
|
+
import { buildProposal } from './propose_obligation.mjs'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* PURE. `obligations` as passed to intake_materialize → each unit's own list.
|
|
17
|
+
*
|
|
18
|
+
* `byUnit` is null when the caller passed no `obligations` at all, so the request carries no field and an
|
|
19
|
+
* older server behaves exactly as before. Otherwise EVERY unit gets a list, `[]` included: answering for
|
|
20
|
+
* the batch answers for each text in it. A quote must name its unit when the call files more than one —
|
|
21
|
+
* guessing which text a sentence came from is how it would be checked against the wrong one and dropped.
|
|
22
|
+
*/
|
|
23
|
+
export function obligationsForUnits(batch, list) {
|
|
24
|
+
if (list === undefined || list === null) return { byUnit: null, errors: [], notes: [] }
|
|
25
|
+
const byUnit = new Map(batch.map((id) => [id, []]))
|
|
26
|
+
const errors = []
|
|
27
|
+
const notes = []
|
|
28
|
+
for (const [i, o] of (Array.isArray(list) ? list : []).entries()) {
|
|
29
|
+
const id = o?.intakeItemId ?? (batch.length === 1 ? batch[0] : null)
|
|
30
|
+
if (!id) {
|
|
31
|
+
errors.push(`obligations[${i}]: name its intakeItemId — this call files ${batch.length} texts and a quote belongs to exactly one`)
|
|
32
|
+
continue
|
|
33
|
+
}
|
|
34
|
+
if (!byUnit.has(id)) {
|
|
35
|
+
errors.push(`obligations[${i}]: ${id} is not one of the units this call files`)
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
const built = buildProposal(o)
|
|
39
|
+
if (built.error) {
|
|
40
|
+
errors.push(`obligations[${i}]: ${built.error}`)
|
|
41
|
+
continue
|
|
42
|
+
}
|
|
43
|
+
if (built.note) notes.push(`obligations[${i}]: ${built.note}`)
|
|
44
|
+
byUnit.get(id).push(built.candidate)
|
|
45
|
+
}
|
|
46
|
+
return { byUnit, errors, notes }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** What the server says when a text was filed with no answer. Written for the agent holding the text. */
|
|
50
|
+
export const OBLIGATIONS_UNANSWERED_TEXT =
|
|
51
|
+
'Not filed yet — a text must answer whether it asks anyone for anything. Read it, then call again with ' +
|
|
52
|
+
'`obligations`: one entry per ask, with `evidence` copied word for word from the text and `obligated_party` ' +
|
|
53
|
+
'self (the person whose texts these are), other, or none. Pass `[]` when it asks nothing. Nothing was written.'
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* PURE. What a filing reports about its obligations answer, in words. Empty when the server sent nothing
|
|
57
|
+
* (no answer was given, or the server predates this), so an older server never reads as "asks nothing".
|
|
58
|
+
*/
|
|
59
|
+
export function renderFilingObligations(o) {
|
|
60
|
+
if (!o || typeof o !== 'object') return []
|
|
61
|
+
const lines = []
|
|
62
|
+
if (o.write === null) {
|
|
63
|
+
lines.push('⚠ The obligations write FAILED — the record was filed but nothing was proposed. Propose them again with propose_obligation.')
|
|
64
|
+
}
|
|
65
|
+
const proposed = o.write?.proposed ?? 0
|
|
66
|
+
const refreshed = o.write?.refreshed ?? 0
|
|
67
|
+
if (o.sent === 0) {
|
|
68
|
+
lines.push('Obligations: none — answered that this asks nothing of anyone.')
|
|
69
|
+
} else if (proposed || refreshed) {
|
|
70
|
+
lines.push(
|
|
71
|
+
`Obligations: proposed ${proposed}${refreshed ? `, updated ${refreshed}` : ''} — PROPOSALS the person confirms or ` +
|
|
72
|
+
'dismisses (my_obligations), never commitments they made.',
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
if (o.write?.undated) lines.push(`⚠ ${o.write.undated} of them has no date, so nothing will raise it by time.`)
|
|
76
|
+
if (o.write?.unhomed) lines.push(`⚠ ${o.write.unhomed} could not be placed: the record is on no page.`)
|
|
77
|
+
if (Array.isArray(o.unverified) && o.unverified.length) {
|
|
78
|
+
lines.push(
|
|
79
|
+
`⚠ NOT written — not in this text word for word: ${o.unverified.map((e) => `"${e}"`).join('; ')}. ` +
|
|
80
|
+
"Quote the text's own sentence (propose_obligation with this record id).",
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
if (o.malformed) lines.push(`⚠ ${o.malformed} malformed obligation(s) were dropped.`)
|
|
84
|
+
return lines
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** PURE. The same, folded onto one line for a batch's per-unit detail. */
|
|
88
|
+
export function filingObligationsBrief(o) {
|
|
89
|
+
if (!o || typeof o !== 'object') return ''
|
|
90
|
+
if (o.write === null) return ' · ⚠ obligations write FAILED'
|
|
91
|
+
const bits = []
|
|
92
|
+
if (o.sent === 0) bits.push('asks nothing')
|
|
93
|
+
else if (o.write?.proposed) bits.push(`${o.write.proposed} obligation(s) proposed`)
|
|
94
|
+
if (Array.isArray(o.unverified) && o.unverified.length) bits.push(`⚠ ${o.unverified.length} quote(s) not in the text, not written`)
|
|
95
|
+
return bits.length ? ` · ${bits.join(', ')}` : ''
|
|
96
|
+
}
|
package/lib/server.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import { renderObligations, renderPageObligations, tierReads, who } from './obli
|
|
|
25
25
|
import { renderRemovalQuestion, renderRemoved } from './member_remove_render.mjs'
|
|
26
26
|
import { renderSettings } from './settings_render.mjs'
|
|
27
27
|
import { renderConversationOffer, renderMaterializeBatch } from './intake_batch_render.mjs'
|
|
28
|
+
import { obligationsForUnits, renderFilingObligations, filingObligationsBrief, OBLIGATIONS_UNANSWERED_TEXT } from './filing_obligations.mjs'
|
|
28
29
|
import { formatCreated, formatDryRun, formatSources, homeFromArgs, pickSource, renderHomeProblem } from './custom_sources.mjs'
|
|
29
30
|
|
|
30
31
|
// PAGE tiers (ADR-0071). Six for a PAGE; a RECORD keeps three (`privacy` on log_session and
|
|
@@ -1273,6 +1274,18 @@ function renderNudge(payload) {
|
|
|
1273
1274
|
'ADR-0077: one answer per open folder question from a refused call — exactly one of claim, decline or defer each. ' +
|
|
1274
1275
|
'Send only after the server has listed the questions; claims and declines are written when the unit is filed.',
|
|
1275
1276
|
),
|
|
1277
|
+
obligations: z.array(z.object({
|
|
1278
|
+
intakeItemId: z.string().optional().describe('which unit the quote is from — required when this call files more than one'),
|
|
1279
|
+
subject: z.string().describe('what must be done, one short phrase'),
|
|
1280
|
+
evidence: z.string().describe('the sentence that asks for it, copied WORD FOR WORD from that text — the server checks'),
|
|
1281
|
+
due_phrase: z.string().optional().describe('the words in the evidence that say WHEN ("before 5 tomorrow") — the server dates it from the text'),
|
|
1282
|
+
due_at: z.string().optional().describe('YYYY-MM-DD only when the text states a date'),
|
|
1283
|
+
obligated_party: z.enum(['self', 'other', 'none']).optional().describe('self (default) = the person whose texts these are owes it'),
|
|
1284
|
+
})).optional().describe(
|
|
1285
|
+
'REQUIRED FOR TEXTS: does this ask anyone for anything? One entry per ask — a request to the person, a ' +
|
|
1286
|
+
'yes they gave, a time they agreed to. `[]` when it asks nothing. A text filed without it is refused. ' +
|
|
1287
|
+
'Lands as PROPOSALS; a quote the text does not contain is reported and not written. Any other unit may send it too.',
|
|
1288
|
+
),
|
|
1276
1289
|
},
|
|
1277
1290
|
},
|
|
1278
1291
|
async (args) => {
|
|
@@ -1280,6 +1293,11 @@ function renderNudge(payload) {
|
|
|
1280
1293
|
// are looked up once and handled together, but "it shouldn't file all of them as one record". The
|
|
1281
1294
|
// loop lives here, as it does for place_staged_record's batch: the route stays one unit per call.
|
|
1282
1295
|
const batch = [...new Set([args.intakeItemId, ...(args.intakeItemIds ?? [])])].slice(0, 50)
|
|
1296
|
+
// Obligation 53bfd283 — each unit's own answer. Refused HERE, before anything is filed, rather than
|
|
1297
|
+
// sent for the server to drop: an agent told nothing believes the ask was surfaced.
|
|
1298
|
+
const ob = obligationsForUnits(batch, args.obligations)
|
|
1299
|
+
if (ob.errors.length) return toolError(`Nothing was filed — fix these obligations first:\n${ob.errors.map((e) => ` · ${e}`).join('\n')}`)
|
|
1300
|
+
const obligationsFor = (id) => (ob.byUnit ? { obligations: ob.byUnit.get(id) ?? [] } : {})
|
|
1283
1301
|
if (batch.length > 1 && args.mode !== 'preview') {
|
|
1284
1302
|
const results = []
|
|
1285
1303
|
for (const id of batch) {
|
|
@@ -1298,15 +1316,20 @@ function renderNudge(payload) {
|
|
|
1298
1316
|
// One placement is a decision about ONE conversation: the server refuses any id whose
|
|
1299
1317
|
// batch differs from the first id's, so a stray id is never filed onto these pages.
|
|
1300
1318
|
sameBatchAs: batch[0],
|
|
1319
|
+
...obligationsFor(id),
|
|
1301
1320
|
}),
|
|
1302
1321
|
})
|
|
1303
1322
|
const o = await r.json().catch(() => null)
|
|
1304
|
-
|
|
1323
|
+
const why = o?.error === 'obligations_unanswered'
|
|
1324
|
+
? 'not filed — a text needs `obligations` ([] when it asks nothing)'
|
|
1325
|
+
: (o?.error ?? `HTTP ${r.status}`)
|
|
1326
|
+
results.push({ id, ok: r.ok, detail: r.ok ? `${o?.recordId ?? 'filed'}${filingObligationsBrief(o?.obligations)}` : why })
|
|
1305
1327
|
} catch (e) {
|
|
1306
1328
|
results.push({ id, ok: false, detail: e.message })
|
|
1307
1329
|
}
|
|
1308
1330
|
}
|
|
1309
|
-
|
|
1331
|
+
const notes = ob.notes.length ? `\n${ob.notes.map((n) => `⚠ ${n}`).join('\n')}` : ''
|
|
1332
|
+
return { content: [{ type: 'text', text: `${renderMaterializeBatch(results)}${notes}` }] }
|
|
1310
1333
|
}
|
|
1311
1334
|
// A preview goes to its OWN route: a server too old to know it answers 404, where the materialize
|
|
1312
1335
|
// route would ignore a `mode` it does not know and publish.
|
|
@@ -1329,6 +1352,7 @@ function renderNudge(payload) {
|
|
|
1329
1352
|
...(args.governingPage ? { governingDocumentId: args.governingPage } : {}),
|
|
1330
1353
|
...(args.searchedForOtherHomes ? { searchedForOtherHomes: true } : {}),
|
|
1331
1354
|
...(args.folderDecisions?.length ? { folderDecisions: args.folderDecisions } : {}),
|
|
1355
|
+
...obligationsFor(args.intakeItemId),
|
|
1332
1356
|
}),
|
|
1333
1357
|
})
|
|
1334
1358
|
if (!res.ok) {
|
|
@@ -1348,6 +1372,7 @@ function renderNudge(payload) {
|
|
|
1348
1372
|
'Not filed yet — every page named is one this unit gets anyway, so this would publish it without adding anything of your judgment.',
|
|
1349
1373
|
))
|
|
1350
1374
|
}
|
|
1375
|
+
if (err === 'obligations_unanswered') return toolError(OBLIGATIONS_UNANSWERED_TEXT)
|
|
1351
1376
|
}
|
|
1352
1377
|
if (preview && res.status === 404) {
|
|
1353
1378
|
return toolError('This server cannot preview a materialization yet — nothing was written. Materialize without mode, or wait for the server to update.')
|
|
@@ -1413,6 +1438,7 @@ function renderNudge(payload) {
|
|
|
1413
1438
|
// filed onto. Never claimed by the server; the session decides.
|
|
1414
1439
|
lines.push(...renderFolderDecisionsApplied(body?.folderDecisions))
|
|
1415
1440
|
lines.push(...renderFolderClaimOffers(body?.folderClaimOffers))
|
|
1441
|
+
lines.push(...renderFilingObligations(body?.obligations), ...ob.notes.map((n) => `⚠ ${n}`))
|
|
1416
1442
|
if (body?.containerSkipped) {
|
|
1417
1443
|
lines.push(`⚠ CONTAINER NOT APPLIED — ${body.containerSkipped}. The record exists but is not inside the event you named; place it with contain_record or leave it out.`)
|
|
1418
1444
|
} else if (body?.contained === true) {
|
package/package.json
CHANGED