@theronap/agnoclast-mcp 0.9.257 → 0.9.258

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.
@@ -1304,12 +1323,12 @@ function renderNudge(payload) {
1304
1323
  {
1305
1324
  title: 'Materialize a private intake item',
1306
1325
  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.',
1326
+ '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. ' +
1327
+ 'Name the governing page with governingPage (one of documentIds, never a person page), else the ranking picks it. ' +
1328
+ '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. ' +
1329
+ '⚠ 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. ' +
1330
+ '⚠ 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. ' +
1331
+ '⚠ 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
1332
  inputSchema: {
1314
1333
  intakeItemId: z.string().optional().describe('intake item uuid (required — intake_item_id is accepted too)'),
1315
1334
  intake_item_id: z.string().optional().describe(INTAKE_ID_ALIAS_NOTE),
@@ -1318,7 +1337,7 @@ function renderNudge(payload) {
1318
1337
  'Any units you hold a live claim on may be batched, even from separate intake_claim calls — except that texts from ' +
1319
1338
  'DIFFERENT text conversations never share a batch. Claim them with `intake_claim`, then pass ' +
1320
1339
  '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.',
1340
+ '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
1341
  ),
1323
1342
  orgId: z.string().describe('destination brain org uuid'),
1324
1343
  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 +1401,43 @@ function renderNudge(payload) {
1382
1401
  const ob = obligationsForUnits(batch, args.obligations)
1383
1402
  if (ob.errors.length) return toolError(`Nothing was filed — fix these obligations first:\n${ob.errors.map((e) => ` · ${e}`).join('\n')}`)
1384
1403
  const obligationsFor = (id) => (ob.byUnit ? { obligations: ob.byUnit.get(id) ?? [] } : {})
1404
+ // A BATCH PREVIEW (2026-10-23): every id, each on its own preview call with ITS OWN obligations — the
1405
+ // same body the batch apply below sends, so what it reports is what apply would do. Until then a preview
1406
+ // covered intakeItemId only, and the other units' obligations were silently never checked.
1407
+ if (batch.length > 1 && args.mode === 'preview') {
1408
+ const results = []
1409
+ for (const id of batch) {
1410
+ try {
1411
+ const r = await fetchCortex(`${BASE}${intakeMaterializePath('preview')}`, {
1412
+ method: 'POST',
1413
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
1414
+ body: JSON.stringify({
1415
+ intakeItemId: id,
1416
+ orgId: args.orgId,
1417
+ documentIds: args.documentIds ?? [],
1418
+ record: { source: args.source, record_type: args.recordType },
1419
+ attachmentMeta: (args.documentIds ?? []).map(() => ({ origin: args.origin ?? 'llm' })),
1420
+ ...(args.governingPage ? { governingDocumentId: args.governingPage } : {}),
1421
+ ...(args.searchedForOtherHomes ? { searchedForOtherHomes: true } : {}),
1422
+ sameBatchAs: batch[0],
1423
+ ...obligationsFor(id),
1424
+ }),
1425
+ })
1426
+ const o = await r.json().catch(() => null)
1427
+ if (r.status === 404 && !o?.error) {
1428
+ results.push({ id, ok: false, detail: 'this server cannot preview a materialization yet — nothing was written' })
1429
+ } else if (!r.ok) {
1430
+ results.push({ id, ok: false, detail: `${o?.error ?? `HTTP ${r.status}`}${o?.hint ? ` — ${o.hint}` : o?.detail ? ` — ${o.detail}` : ''}` })
1431
+ } else {
1432
+ results.push({ id, ok: true, wouldApply: o?.wouldApply, line: renderIntakePreviewLine(o), body: o })
1433
+ }
1434
+ } catch (e) {
1435
+ results.push({ id, ok: false, detail: e.message })
1436
+ }
1437
+ }
1438
+ const notes = ob.notes.length ? `\n${ob.notes.map((n) => `⚠ ${n}`).join('\n')}` : ''
1439
+ 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)}` }] }
1440
+ }
1385
1441
  if (batch.length > 1 && args.mode !== 'preview') {
1386
1442
  const results = []
1387
1443
  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.258",
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": {