@theronap/agnoclast-mcp 0.9.257 → 0.9.259

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.
@@ -74,16 +74,42 @@ export function renderFilingObligations(o) {
74
74
  }
75
75
  if (o.write?.undated) lines.push(`⚠ ${o.write.undated} of them has no date, so nothing will raise it by time.`)
76
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) {
77
+ // 2026-10-23: the server reports EACH candidate. Name every one it will not write as sent, and why.
78
+ const notAsSent = renderObligationItems(o.items, { onlyNotProposed: true })
79
+ if (notAsSent.length) {
80
+ lines.push(...notAsSent)
81
+ } else if (Array.isArray(o.unverified) && o.unverified.length) {
78
82
  lines.push(
79
83
  `⚠ NOT written — not in this text word for word: ${o.unverified.map((e) => `"${e}"`).join('; ')}. ` +
80
84
  "Quote the text's own sentence (propose_obligation with this record id).",
81
85
  )
82
86
  }
83
- if (o.malformed) lines.push(`⚠ ${o.malformed} malformed obligation(s) were dropped.`)
87
+ if (o.malformed && !Array.isArray(o.items)) lines.push(`⚠ ${o.malformed} malformed obligation(s) were dropped.`)
88
+ if (o.write?.kept) lines.push(`${o.write.kept} matched an obligation the person already acted on — left as they left it, not written again.`)
84
89
  return lines
85
90
  }
86
91
 
92
+ /**
93
+ * PURE. One line per obligation the server judged (`items`, server 2026-10-23): proposed, deduped (and against
94
+ * what), or rejected (and why). `onlyNotProposed` keeps just the ones that will NOT be written as sent. Empty
95
+ * for a server that sends no `items`, so an older server never reads as "nothing dropped".
96
+ */
97
+ export function renderObligationItems(items, { onlyNotProposed = false } = {}) {
98
+ if (!Array.isArray(items) || items.length === 0) return []
99
+ const shown = onlyNotProposed ? items.filter((x) => x?.outcome !== 'proposed') : items
100
+ if (shown.length === 0) return []
101
+ const label = (x) => `obligations[${x.index}]${x.subject ? ` "${x.subject}"` : ''}`
102
+ const line = (x) => x.outcome === 'proposed'
103
+ ? ` · ${label(x)} — proposed`
104
+ : x.outcome === 'deduped'
105
+ ? ` · ${label(x)} — deduped: same sentence as ${x.against ?? 'another in this filing'} (one proposal, not two)`
106
+ : ` ⚠ ${label(x)} — REJECTED, not written: ${x.reason ?? 'refused'}`
107
+ const p = items.filter((x) => x?.outcome === 'proposed').length
108
+ const d = items.filter((x) => x?.outcome === 'deduped').length
109
+ const r = items.filter((x) => x?.outcome === 'rejected').length
110
+ return [`Obligations sent: ${items.length} — ${p} proposed, ${d} deduped, ${r} rejected:`, ...shown.map(line)]
111
+ }
112
+
87
113
  /** PURE. The same, folded onto one line for a batch's per-unit detail. */
88
114
  export function filingObligationsBrief(o) {
89
115
  if (!o || typeof o !== 'object') return ''
@@ -91,6 +117,15 @@ export function filingObligationsBrief(o) {
91
117
  const bits = []
92
118
  if (o.sent === 0) bits.push('asks nothing')
93
119
  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`)
120
+ if (Array.isArray(o.items)) {
121
+ // 2026-10-23: name each one not written as sent, so a batch never hides which was dropped.
122
+ for (const x of o.items) {
123
+ if (x?.outcome === 'deduped') bits.push(`obligations[${x.index}] deduped (same sentence as ${x.against ?? 'another'})`)
124
+ if (x?.outcome === 'rejected') bits.push(`⚠ obligations[${x.index}] rejected: ${x.reason ?? 'refused'}`)
125
+ }
126
+ } else if (Array.isArray(o.unverified) && o.unverified.length) {
127
+ bits.push(`⚠ ${o.unverified.length} quote(s) not in the text, not written`)
128
+ }
129
+ if (o.write?.kept) bits.push(`${o.write.kept} already acted on, left as is`)
95
130
  return bits.length ? ` · ${bits.join(', ')}` : ''
96
131
  }
@@ -24,3 +24,15 @@ export function renderConversationOffer(x, why = '', looked = '') {
24
24
  return ` - 📱 ${x.conversation.count} texts in one conversation · newest ${x.hoursAgo}h ago · ${ids}${why}${looked}\n` +
25
25
  ' decide them together: `intake_look` the newest, `intake_claim` them all, then file them in one `intake_materialize` (intakeItemIds) — one record each'
26
26
  }
27
+
28
+ /**
29
+ * A batch PREVIEW (2026-10-23), per id: until then a preview with intakeItemIds covered only the first id, so
30
+ * the other units' obligations were never checked and nothing said so. Each unit is previewed on its own call
31
+ * with its own obligations; `line` is that unit's rendered preview. Nothing is written by any of them.
32
+ */
33
+ export function renderPreviewBatch(results) {
34
+ const refuse = results.filter((r) => !r.ok || r.wouldApply === false)
35
+ return `PREVIEW of a batch of ${results.length} — nothing was written. ` +
36
+ `${results.length - refuse.length} would file as sent, ${refuse.length} would not.\n` +
37
+ results.map((r) => `\n── ${r.id}\n${r.ok ? r.line : `✗ preview failed — ${r.detail}`}`).join('\n')
38
+ }
@@ -22,7 +22,7 @@ export const REGULAR_ONLY_TOOLS = [
22
22
  'list_records', 'read_record', 'timeline_pull', 'record_status', 'set_record_notes', 'set_record_privacy',
23
23
  'relabel_record_people', 'supersede_record', 'move_record', 'contain_record', 'pending_records', 'claim_record',
24
24
  'route_record', 'unroute_record', 'request_file', 'get_file', 'create_custom_source', 'search_spam_email',
25
- 'gmail_modify', 'draft_email_reply', 'request_send_email', 'send_imessage', 'create_brain', 'add_to_brain',
25
+ 'gmail_modify', 'draft_email_reply', 'request_send_email', 'send_imessage', 'add_to_brain',
26
26
  ]
27
27
 
28
28
  /** What a locked tool answers when the check itself could not be made. Nothing was run. */
package/lib/server.mjs CHANGED
@@ -24,9 +24,9 @@ import { spawnObligationWorker } from './obligations_worker.mjs'
24
24
  import { renderObligations, renderPageObligations, tierReads, who } from './obligations_render.mjs'
25
25
  import { renderLeaveQuestion, renderLeft, 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
+ import { renderConversationOffer, renderMaterializeBatch, renderPreviewBatch } from './intake_batch_render.mjs'
28
28
  import { intakeIdFrom, requireIntakeId, INTAKE_ID_ALIAS_NOTE } from './intake_id_alias.mjs'
29
- import { obligationsForUnits, renderFilingObligations, filingObligationsBrief, OBLIGATIONS_UNANSWERED_TEXT } from './filing_obligations.mjs'
29
+ import { obligationsForUnits, renderFilingObligations, renderObligationItems, filingObligationsBrief, OBLIGATIONS_UNANSWERED_TEXT } from './filing_obligations.mjs'
30
30
  import { renderRecord, READ_RECORD_DEFAULT_CHARS, READ_RECORD_PARTS } from './record_render.mjs'
31
31
  import { formatCreated, formatDryRun, formatSources, homeFromArgs, pickSource, renderHomeProblem } from './custom_sources.mjs'
32
32
  import { formatSeriesRefusal, formatSeriesUpdated } from './series_render.mjs'
@@ -183,7 +183,26 @@ export function renderIntakePreviewLine(body) {
183
183
  const pages = Array.isArray(p.pages) ? p.pages.length : 0
184
184
  const g = p.governing
185
185
  const gov = g ? `${g.title || g.kind} (${g.tier})${p.governingJudged ? ', your choice' : ''}` : 'none'
186
- return `PREVIEW — nothing was written. Would attach ${pages} page(s); governing: ${gov}; born ${p.tier ?? 'unknown'}.`
186
+ const lines = [`PREVIEW — nothing was written. Would attach ${pages} page(s); governing: ${gov}; born ${p.tier ?? 'unknown'}.`]
187
+ // 2026-10-23: whether apply would take THIS body, and what it would do with each answer sent. Every line
188
+ // below is printed only when the server sent its field, so an older server renders exactly as before.
189
+ if (body?.wouldApply === false && Array.isArray(body.applyWouldRefuse)) {
190
+ for (const r of body.applyWouldRefuse) lines.push(`⚠ apply would REFUSE this call: ${r?.error}${r?.detail ? ` — ${r.detail}` : ''}`)
191
+ } else if (body?.wouldApply === true) {
192
+ lines.push('Apply would accept this call as sent.')
193
+ }
194
+ const fd = body?.folderDecisions
195
+ if (fd && Array.isArray(fd.answers)) {
196
+ const ok = fd.answers.filter((a) => a.verdict === 'accepted')
197
+ const bad = fd.answers.filter((a) => a.verdict === 'rejected')
198
+ const open = Array.isArray(fd.open) ? fd.open : []
199
+ lines.push(`Folder answers: ${ok.length} accepted, ${bad.length} rejected; ${open.length} question(s) still open.`)
200
+ for (const a of ok) lines.push(` ✓ folderDecisions[${a.index}] ${a.action} \`${a.identifier}\`${a.page ? ` onto "${a.page.title}" (${a.page.tier})` : ''} — answers ${(a.answers ?? []).length}`)
201
+ for (const a of bad) lines.push(` ✗ folderDecisions[${a.index}] ${a.action ?? '?'} \`${a.identifier ?? '?'}\` — ${a.error}${a.detail ? `: ${a.detail}` : ''}`)
202
+ for (const q of open) lines.push(` ? still open: \`${q.identifier}\``)
203
+ }
204
+ lines.push(...renderObligationItems(body?.obligations?.items))
205
+ return lines.join('\n')
187
206
  }
188
207
 
189
208
  // TOP-OF-PAGE NOTES — ADR-0065's size note, and ADR-0064 §3's "Also visible to you" callout.
@@ -1206,12 +1225,13 @@ function renderNudge(payload) {
1206
1225
  description:
1207
1226
  '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. ' +
1208
1227
  'YOU ARE THE JUDGE of every unit you claim (ADR-0062): nothing is published until you materialize, and materializing is when it becomes visible. Each unit carries a `proposal` — the brain its signals point to and, for that brain, the pages it would attach to, the page that would govern it, and the `tier` it would be born with — computed without writing anything. Decide the brain, the pages (confirm, drop or add), which page should govern (the page whose reasons for restricting its audience fit the content, not a person page), and obligations. Pages only tighten a tier; if a record should be LOOSER than `tierReason.baseline`, that is the owner\'s call — intake_defer with the question. ' +
1209
- 'view "compact" (trial, only where the server enables it — otherwise you get the full response): the judge rules, your brains, the lease and the cleanup count come ONCE per response in `rules`/`brains`, not on every unit; each unit carries its full content, only the proposed brain\'s pages as one line each, and up front what intake_materialize would refuse it for (`materialize.otherHomes`, `materialize.folderQuestions`). Read `rules` before judging.',
1228
+ 'DEFAULT VIEW IS "compact" where the server enables it (otherwise the server answers with the full response, whatever you ask): the judge rules, your brains, the lease and the cleanup count come ONCE per response in `rules`/`brains`, not on every unit; the proposal is flattened onto each unit — `brain`, the proposed brain\'s `pages` as one line each, `governing`, `tier` (and `baseline` when it differs) — or `candidates` when no brain is proposed; each unit carries its full content, and up front what intake_materialize would refuse it for (`materialize.otherHomes`, `materialize.folderQuestions`). Read `rules` before judging. Pass view "full" for the per-unit `proposal` object with every brain\'s placement.',
1210
1229
  inputSchema: {
1211
1230
  claimKind: z.enum(['relevance', 'cleanup']).optional().describe('default relevance'),
1212
1231
  view: z.enum(['full', 'compact']).optional().describe(
1213
- 'default "full". "compact" sends the rules once per response instead of on every unit, and only the proposed ' +
1214
- 'brain\'s placement. Honoured only where the server enables it; otherwise the response is the full one.',
1232
+ 'default "compact": the rules once per response instead of on every unit, and only the proposed ' +
1233
+ 'brain\'s placement. Honoured only where the server enables it; otherwise the response is the full one. ' +
1234
+ '"full" asks for the full per-unit `proposal` (every brain\'s placement, the judge text on each unit).',
1215
1235
  ),
1216
1236
  limit: z.number().optional().describe('max items (default 10). Ignored when intakeItemIds is given.'),
1217
1237
  source: z.string().optional().describe('claim only the oldest units of ONE source — e.g. "imessage", "meeting", "email", "github", "claude-code". Reaches texts without first claiming every older GitHub/session unit ahead of them. Ignored when intakeItemIds is given.'),
@@ -1259,8 +1279,11 @@ function renderNudge(payload) {
1259
1279
  // claim into a no-op.
1260
1280
  ...(intakeItemIds ? { intakeItemIds } : {}),
1261
1281
  ...(source ? { source } : {}),
1262
- // Sent only when asked for: a default claim's request body is exactly what it was.
1263
- ...(view === 'compact' ? { view } : {}),
1282
+ // Compact by DEFAULT (Theron, 2026-10-23, after a watched real-filing trial: 26 units, 25 filed
1283
+ // correctly, 0 refusals, ~3.9K vs ~10K chars per unit). Always sent, so an explicit "full" is
1284
+ // honoured. The server still gates compact on RELEASE_FLAGS.compact_claim — an account outside
1285
+ // that cohort gets the full response even though this asks for compact.
1286
+ view: view ?? 'compact',
1264
1287
  }),
1265
1288
  })
1266
1289
  if (!res.ok) {
@@ -1304,12 +1327,12 @@ function renderNudge(payload) {
1304
1327
  {
1305
1328
  title: 'Materialize a private intake item',
1306
1329
  description:
1307
- 'Atomically publish a claimed intake item into one brain — the disclosure moment (ADR-0062). Deterministic identifier homes in that brain are always attached; documentIds may add further pages. The record is born at the strictest of its source baseline, what the producer asked for, and every attached page; a source floor (iMessage, claude-code) pins it. ' +
1308
- 'Name the governing page with governingPage (one of documentIds, never a person page) — otherwise the ranking picks it. ' +
1309
- 'Pass mode "preview" first when your choice differs from the claim\'s proposal: it returns the pages, the governing page and the tier this exact call would produce, and writes nothing. Never writes private intake into search/history before this call. ' +
1310
- '⚠ ADR-0051 — BEFORE you file, look for this unit\'s OTHER homes: the pages it is about beyond what an identifier matched (the design it implements, the project, the person, the decision it bears on). grep or read for them, then name them in documentIds. A call that names nothing beyond the deterministic homes still FILES, on those homes only, and says so — add pages later with route_record. ' +
1311
- '⚠ A meeting or lecture TRANSCRIPT files with NOTES in `summary` (Theron, 2026-10-08): the gist, decisions, action items and dates, names spelled right. Without them the call is REFUSED; the transcript itself is kept on the record unchanged. ' +
1312
- '⚠ ADR-0077: a GitHub commit or PR does not finish until every changed AREA FOLDER has an answer. If a changed file\'s deepest folder has no claim and no decline, the call is REFUSED with the open questions; answer each in folderDecisions (a page to claim it onto, or decline "never") and call again. Questions are about folders, never single files: a file directly in a grab-bag folder (e.g. db/supabase/) is never asked about — it files onto its nearest claimed ancestor\'s page, or the repo\'s page.',
1330
+ 'Atomically publish a claimed intake item into one brain — the disclosure moment (ADR-0062). Deterministic identifier homes there are always attached; documentIds add pages. The record is born at the strictest of its source baseline, the producer\'s ask and every attached page; a source floor (iMessage, claude-code) pins it. ' +
1331
+ 'Name the governing page with governingPage (one of documentIds, never a person page), else the ranking picks it. ' +
1332
+ 'Pass mode "preview" when your choice differs from the proposal: it returns the pages, governing page and tier this exact call would produce, whether apply would accept it (folderDecisions and obligations included), and writes nothing. Never writes private intake into search/history before this call. ' +
1333
+ '⚠ ADR-0051 — BEFORE you file, look for this unit\'s OTHER homes: the pages it is about beyond what an identifier matched (the design, project, person or decision). grep or read for them and name them in documentIds. A call naming nothing beyond the deterministic homes still FILES on those only, and says so — add pages later with route_record. ' +
1334
+ '⚠ A meeting or lecture TRANSCRIPT files with NOTES in `summary`: gist, decisions, action items, dates, names spelled right — else REFUSED; the transcript is kept unchanged. ' +
1335
+ '⚠ ADR-0077: a GitHub commit or PR does not finish until every changed AREA FOLDER has an answer; unanswered, the call is REFUSED with the open questions. Answer each in folderDecisions (a page to claim it onto, or decline "never") and call again. A "never" on a folder also silences every unclaimed subfolder beneath it, so use it only when nothing under that folder deserves its own page. An earlier "not_yet" (`earlier`) no longer answers a GitHub folder question: claim the folder onto a page about it, or decline "never". A file directly in a grab-bag folder (e.g. db/supabase/) is never asked about.',
1313
1336
  inputSchema: {
1314
1337
  intakeItemId: z.string().optional().describe('intake item uuid (required — intake_item_id is accepted too)'),
1315
1338
  intake_item_id: z.string().optional().describe(INTAKE_ID_ALIAS_NOTE),
@@ -1318,7 +1341,7 @@ function renderNudge(payload) {
1318
1341
  'Any units you hold a live claim on may be batched, even from separate intake_claim calls — except that texts from ' +
1319
1342
  'DIFFERENT text conversations never share a batch. Claim them with `intake_claim`, then pass ' +
1320
1343
  'the first as intakeItemId and the rest here. title/summary/dedupeKey are ignored in a batch (each unit keeps its ' +
1321
- 'own), and a preview covers intakeItemId only. Each id is reported separately; a failure never hides behind a success.',
1344
+ 'own), and a preview covers every id, each with its own obligations. Each id is reported separately; a failure never hides behind a success.',
1322
1345
  ),
1323
1346
  orgId: z.string().describe('destination brain org uuid'),
1324
1347
  documentIds: z.array(z.string()).optional().describe('additional pages in orgId — each a page TITLE exactly as read_page shows it, its `ref:`, or a brain_documents id (deterministic homes are merged automatically). A title that matches no page, or more than one, is refused with the candidates listed'),
@@ -1382,6 +1405,43 @@ function renderNudge(payload) {
1382
1405
  const ob = obligationsForUnits(batch, args.obligations)
1383
1406
  if (ob.errors.length) return toolError(`Nothing was filed — fix these obligations first:\n${ob.errors.map((e) => ` · ${e}`).join('\n')}`)
1384
1407
  const obligationsFor = (id) => (ob.byUnit ? { obligations: ob.byUnit.get(id) ?? [] } : {})
1408
+ // A BATCH PREVIEW (2026-10-23): every id, each on its own preview call with ITS OWN obligations — the
1409
+ // same body the batch apply below sends, so what it reports is what apply would do. Until then a preview
1410
+ // covered intakeItemId only, and the other units' obligations were silently never checked.
1411
+ if (batch.length > 1 && args.mode === 'preview') {
1412
+ const results = []
1413
+ for (const id of batch) {
1414
+ try {
1415
+ const r = await fetchCortex(`${BASE}${intakeMaterializePath('preview')}`, {
1416
+ method: 'POST',
1417
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
1418
+ body: JSON.stringify({
1419
+ intakeItemId: id,
1420
+ orgId: args.orgId,
1421
+ documentIds: args.documentIds ?? [],
1422
+ record: { source: args.source, record_type: args.recordType },
1423
+ attachmentMeta: (args.documentIds ?? []).map(() => ({ origin: args.origin ?? 'llm' })),
1424
+ ...(args.governingPage ? { governingDocumentId: args.governingPage } : {}),
1425
+ ...(args.searchedForOtherHomes ? { searchedForOtherHomes: true } : {}),
1426
+ sameBatchAs: batch[0],
1427
+ ...obligationsFor(id),
1428
+ }),
1429
+ })
1430
+ const o = await r.json().catch(() => null)
1431
+ if (r.status === 404 && !o?.error) {
1432
+ results.push({ id, ok: false, detail: 'this server cannot preview a materialization yet — nothing was written' })
1433
+ } else if (!r.ok) {
1434
+ results.push({ id, ok: false, detail: `${o?.error ?? `HTTP ${r.status}`}${o?.hint ? ` — ${o.hint}` : o?.detail ? ` — ${o.detail}` : ''}` })
1435
+ } else {
1436
+ results.push({ id, ok: true, wouldApply: o?.wouldApply, line: renderIntakePreviewLine(o), body: o })
1437
+ }
1438
+ } catch (e) {
1439
+ results.push({ id, ok: false, detail: e.message })
1440
+ }
1441
+ }
1442
+ const notes = ob.notes.length ? `\n${ob.notes.map((n) => `⚠ ${n}`).join('\n')}` : ''
1443
+ return { content: [{ type: 'text', text: `${renderPreviewBatch(results)}${notes}\n\n${JSON.stringify(results.map((r) => r.body ?? { id: r.id, error: r.detail }), null, 2)}` }] }
1444
+ }
1385
1445
  if (batch.length > 1 && args.mode !== 'preview') {
1386
1446
  const results = []
1387
1447
  for (const id of batch) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/agnoclast-mcp",
3
- "version": "0.9.257",
3
+ "version": "0.9.259",
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": {