@theronap/agnoclast-mcp 0.9.212 → 0.9.214

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 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
- export async function checkToken(token, base) {
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,26 @@
1
+ // Rendering for ADR-0076 §4.5 — a conversation's texts, decided together and filed one record each.
2
+ // Kept out of server.mjs so it can be tested on its output, not on its source text.
3
+
4
+ /**
5
+ * A batch materialization, per id. ⚠ PARTIAL FAILURE IS REPORTED PER ID, never summarised as success — a
6
+ * batch that half landed is the one outcome an agent must not mistake for done (same rule as
7
+ * place_staged_record's batch).
8
+ */
9
+ export function renderMaterializeBatch(results) {
10
+ const good = results.filter((r) => r.ok)
11
+ const bad = results.filter((r) => !r.ok)
12
+ return `Batch of ${results.length}: ${good.length} filed, ${bad.length} failed — one record each.\n` +
13
+ results.map((r) => ` ${r.ok ? '✓' : '✗'} ${r.id} — ${r.detail}`).join('\n') +
14
+ (bad.length ? `\n\n⚠ ${bad.length} did NOT land. They are still in the pile; fix the cause and file those ids again (a unit must be claimed first).` : '')
15
+ }
16
+
17
+ /**
18
+ * One settling offer that stands for a whole text conversation (the server collapsed its waiting texts into
19
+ * it), or null when the offer is a single arrival and renders the ordinary way.
20
+ */
21
+ export function renderConversationOffer(x, why = '', looked = '') {
22
+ if (!x?.conversation || !(x.conversation.count > 1)) return null
23
+ const ids = x.conversation.ids.slice(0, 12).join(', ') + (x.conversation.ids.length > 12 ? ', …' : '')
24
+ return ` - 📱 ${x.conversation.count} texts in one conversation · newest ${x.hoursAgo}h ago · ${ids}${why}${looked}\n` +
25
+ ' decide them together: `intake_look` the newest, `intake_claim` them all, then file them in one `intake_materialize` (intakeItemIds) — one record each'
26
+ }
package/lib/server.mjs CHANGED
@@ -24,6 +24,7 @@ import { spawnObligationWorker } from './obligations_worker.mjs'
24
24
  import { renderObligations, renderPageObligations, tierReads, who } from './obligations_render.mjs'
25
25
  import { renderRemovalQuestion, renderRemoved } from './member_remove_render.mjs'
26
26
  import { renderSettings } from './settings_render.mjs'
27
+ import { renderConversationOffer, renderMaterializeBatch } from './intake_batch_render.mjs'
27
28
  import { formatCreated, formatDryRun, formatSources, homeFromArgs, pickSource, renderHomeProblem } from './custom_sources.mjs'
28
29
 
29
30
  // PAGE tiers (ADR-0071). Six for a PAGE; a RECORD keeps three (`privacy` on log_session and
@@ -428,6 +429,9 @@ function renderSettling(payload) {
428
429
  ? ' — ⚠ NO session of yours matches this; it is here because it reached nobody'
429
430
  : ' — every session that matched it declined, so it is back to everyone'
430
431
  const looked = x.looks > 0 ? ` [looked at ${x.looks}x, still unclaimed]` : ''
432
+ // ADR-0076 §4.5: a conversation's waiting texts arrive one per message and are offered as ONE line.
433
+ const conversationLine = renderConversationOffer(x, why, looked)
434
+ if (conversationLine) return conversationLine
431
435
  return ` - ${x.source} · ${x.hoursAgo}h ago · ${x.intakeItemId}${why}${looked}`
432
436
  })
433
437
 
@@ -1230,6 +1234,12 @@ function renderNudge(payload) {
1230
1234
  '⚠ ADR-0077: a GitHub commit or PR does not finish until every changed path has an answer. If a changed file\'s deepest folder (or, in a grab-bag folder like web/lib/engine/ or db/supabase/, the file itself) has no claim and no decline, the call is REFUSED with the open questions; answer each in folderDecisions and call again.',
1231
1235
  inputSchema: {
1232
1236
  intakeItemId: z.string().describe('intake item uuid'),
1237
+ intakeItemIds: z.array(z.string()).optional().describe(
1238
+ 'MORE units to file exactly the same way, in one call — one record EACH, never merged (ADR-0076 §4.5). ' +
1239
+ 'For a conversation\'s waiting texts that you decided together: claim them all with `intake_claim`, then pass ' +
1240
+ 'the first as intakeItemId and the rest here. title/summary/dedupeKey are ignored in a batch (each unit keeps its ' +
1241
+ 'own), and a preview covers intakeItemId only. Each id is reported separately; a failure never hides behind a success.',
1242
+ ),
1233
1243
  orgId: z.string().describe('destination brain org uuid'),
1234
1244
  documentIds: z.array(z.string()).optional().describe('additional pages in orgId — each a page `ref:` exactly as read_page shows it, or a brain_documents id (deterministic homes are merged automatically)'),
1235
1245
  title: z.string().optional(),
@@ -1266,6 +1276,38 @@ function renderNudge(payload) {
1266
1276
  },
1267
1277
  },
1268
1278
  async (args) => {
1279
+ // ADR-0076 §4.5 — A BATCH: one decision, N records. Theron, 2026-10-05: a conversation's waiting texts
1280
+ // are looked up once and handled together, but "it shouldn't file all of them as one record". The
1281
+ // loop lives here, as it does for place_staged_record's batch: the route stays one unit per call.
1282
+ const batch = [...new Set([args.intakeItemId, ...(args.intakeItemIds ?? [])])].slice(0, 50)
1283
+ if (batch.length > 1 && args.mode !== 'preview') {
1284
+ const results = []
1285
+ for (const id of batch) {
1286
+ try {
1287
+ const r = await fetchCortex(`${BASE}${intakeMaterializePath(undefined)}`, {
1288
+ method: 'POST',
1289
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
1290
+ body: JSON.stringify({
1291
+ intakeItemId: id,
1292
+ orgId: args.orgId,
1293
+ documentIds: args.documentIds ?? [],
1294
+ record: { source: args.source, record_type: args.recordType },
1295
+ attachmentMeta: (args.documentIds ?? []).map(() => ({ origin: args.origin ?? 'llm' })),
1296
+ ...(args.governingPage ? { governingDocumentId: args.governingPage } : {}),
1297
+ ...(args.searchedForOtherHomes ? { searchedForOtherHomes: true } : {}),
1298
+ // One placement is a decision about ONE conversation: the server refuses any id whose
1299
+ // batch differs from the first id's, so a stray id is never filed onto these pages.
1300
+ sameBatchAs: batch[0],
1301
+ }),
1302
+ })
1303
+ const o = await r.json().catch(() => null)
1304
+ results.push({ id, ok: r.ok, detail: r.ok ? (o?.recordId ?? 'filed') : (o?.error ?? `HTTP ${r.status}`) })
1305
+ } catch (e) {
1306
+ results.push({ id, ok: false, detail: e.message })
1307
+ }
1308
+ }
1309
+ return { content: [{ type: 'text', text: renderMaterializeBatch(results) }] }
1310
+ }
1269
1311
  // A preview goes to its OWN route: a server too old to know it answers 404, where the materialize
1270
1312
  // route would ignore a `mode` it does not know and publish.
1271
1313
  const preview = args.mode === 'preview'
@@ -1312,8 +1354,10 @@ function renderNudge(payload) {
1312
1354
  }
1313
1355
  // Same redirect-not-dispatch rule as place_staged_record. A staged arrival needs no claim and
1314
1356
  // no unseal, so the fix is a different verb rather than a retry of this one.
1315
- if (res.status === 404 && (await pileHolding(intakeItemId)) === 'staged') {
1316
- return toolError(`${intakeItemId} is a STAGED arrival, not an intake unit — \`intake_materialize\` cannot act on it. Use \`place_staged_record\` with the pages it belongs to; staged arrivals take no lease, so there is nothing to claim or release. (\`staged_look\` reads it first, and carries the brain-ladder proposal.)`)
1357
+ // ⚠ `args.intakeItemId`, not a bare `intakeItemId`: this handler never declared one, so a 404 here
1358
+ // threw a ReferenceError instead of saying which verb to use (found 2026-10-05 adding batches).
1359
+ if (res.status === 404 && (await pileHolding(args.intakeItemId)) === 'staged') {
1360
+ return toolError(`${args.intakeItemId} is a STAGED arrival, not an intake unit — \`intake_materialize\` cannot act on it. Use \`place_staged_record\` with the pages it belongs to; staged arrivals take no lease, so there is nothing to claim or release. (\`staged_look\` reads it first, and carries the brain-ladder proposal.)`)
1317
1361
  }
1318
1362
  return toolError(body)
1319
1363
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/agnoclast-mcp",
3
- "version": "0.9.212",
3
+ "version": "0.9.214",
4
4
  "description": "Connect your AI assistant to Cortex — your org's projects, activity, gaps, and directives, scoped to you.",
5
5
  "type": "module",
6
6
  "bin": {