@theronap/agnoclast-mcp 0.9.214 → 0.9.216

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.
@@ -30,7 +30,7 @@ export function mergeClaudeMcp(existing, spec, token) {
30
30
  export const ALLOWED_TOOL_NAMES = [
31
31
  // read surface
32
32
  'grep', 'read_page', 'my_context', 'project_status', 'session_context', 'search_org',
33
- 'list_records', 'page_history', 'page_diff', 'timeline_pull', 'my_brains', 'my_sessions', 'writing_style',
33
+ 'list_records', 'read_record', 'page_history', 'page_diff', 'timeline_pull', 'my_brains', 'my_sessions', 'writing_style',
34
34
  'code_graph_query', 'my_retier_notices', 'list_page_grants', 'list_project_members', 'file_requests',
35
35
  // live authoring core
36
36
  'authoring_context', 'author', 'log_session',
@@ -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
+ }
@@ -0,0 +1,53 @@
1
+ // read_record — ONE record in full (Theron, 2026-10-07: "there's really no way to see what's inside a
2
+ // container like a class for the transcript or any other associated records").
3
+ //
4
+ // list_records gives a title and a summary per row. That is the right shape for a list and the wrong one
5
+ // for "what did the professor say": the transcript itself was not on the record at all until 2026-10-07,
6
+ // and even now it lives in the record's content, which only GET /api/records/:id returns. This renders
7
+ // that response — content, the pages it is filed on, the container around it, and, for a container, what
8
+ // is inside — so an agent can walk a class session the way a person does on /record.
9
+
10
+ /** Default cap on the content shown. A lecture runs ~50,000 characters; the client's reply budget is finite. */
11
+ export const READ_RECORD_DEFAULT_CHARS = 40_000
12
+
13
+ const SOURCE_LABEL = {
14
+ github: 'GitHub', email: 'Email', 'email-in': 'Email', gmail: 'Email', 'claude-code': 'Claude Code session',
15
+ imessage: 'Text messages', 'google-calendar': 'Calendar', 'agnoclast-calendar': 'Calendar',
16
+ handoff: 'Transcript or document', meeting: 'Meeting',
17
+ }
18
+
19
+ const label = (source) => SOURCE_LABEL[source] ?? source
20
+ const line = (e) => ` · ${e.title || '(untitled)'} — ${label(e.source)} · ${e.occurredAt} — id ${e.id}`
21
+
22
+ /** PURE. The tool's reply for one record. `maxChars` caps the content and says how much was left out. */
23
+ export function renderRecord(rec, maxChars = READ_RECORD_DEFAULT_CHARS) {
24
+ if (!rec || typeof rec !== 'object') return 'No record came back.'
25
+ const cap = Number.isFinite(maxChars) && maxChars > 0 ? Math.trunc(maxChars) : READ_RECORD_DEFAULT_CHARS
26
+ const out = []
27
+ out.push(`# ${rec.title || '(untitled record)'}`)
28
+ out.push(`${label(rec.source)} · ${rec.occurredAt} · brain ${rec.brain?.name ?? rec.brain ?? '?'}${rec.superseded ? ' · ⚠ superseded by a newer version' : ''}`)
29
+ out.push(`id: ${rec.id}`)
30
+ out.push('')
31
+ const content = typeof rec.content === 'string' ? rec.content : ''
32
+ if (!content.trim()) {
33
+ out.push('(No content was captured for this record.)')
34
+ } else if (content.length > cap) {
35
+ out.push(content.slice(0, cap))
36
+ out.push(`\n… ${content.length - cap} more characters. Call read_record again with max_chars: ${content.length} to read all of it.`)
37
+ } else {
38
+ out.push(content)
39
+ }
40
+ out.push('')
41
+ const pages = Array.isArray(rec.pages) ? rec.pages : []
42
+ out.push(pages.length
43
+ ? `Filed on: ${pages.map((p) => `“${p.title}” (ref ${p.ref})`).join(' · ')}`
44
+ : 'Filed on: no page you can see.')
45
+ const inside = Array.isArray(rec.containedIn) ? rec.containedIn : []
46
+ if (inside.length) out.push(`Inside:\n${inside.map(line).join('\n')}`)
47
+ if (Array.isArray(rec.contains)) {
48
+ out.push(rec.contains.length
49
+ ? `Contains (${rec.contains.length}) — read any with read_record:\n${rec.contains.map(line).join('\n')}`
50
+ : 'Contains: nothing has been filed inside this container yet.')
51
+ }
52
+ return out.join('\n')
53
+ }
package/lib/server.mjs CHANGED
@@ -25,6 +25,8 @@ 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'
29
+ import { renderRecord, READ_RECORD_DEFAULT_CHARS } from './record_render.mjs'
28
30
  import { formatCreated, formatDryRun, formatSources, homeFromArgs, pickSource, renderHomeProblem } from './custom_sources.mjs'
29
31
 
30
32
  // PAGE tiers (ADR-0071). Six for a PAGE; a RECORD keeps three (`privacy` on log_session and
@@ -1273,6 +1275,18 @@ function renderNudge(payload) {
1273
1275
  'ADR-0077: one answer per open folder question from a refused call — exactly one of claim, decline or defer each. ' +
1274
1276
  'Send only after the server has listed the questions; claims and declines are written when the unit is filed.',
1275
1277
  ),
1278
+ obligations: z.array(z.object({
1279
+ intakeItemId: z.string().optional().describe('which unit the quote is from — required when this call files more than one'),
1280
+ subject: z.string().describe('what must be done, one short phrase'),
1281
+ evidence: z.string().describe('the sentence that asks for it, copied WORD FOR WORD from that text — the server checks'),
1282
+ due_phrase: z.string().optional().describe('the words in the evidence that say WHEN ("before 5 tomorrow") — the server dates it from the text'),
1283
+ due_at: z.string().optional().describe('YYYY-MM-DD only when the text states a date'),
1284
+ obligated_party: z.enum(['self', 'other', 'none']).optional().describe('self (default) = the person whose texts these are owes it'),
1285
+ })).optional().describe(
1286
+ 'REQUIRED FOR TEXTS: does this ask anyone for anything? One entry per ask — a request to the person, a ' +
1287
+ 'yes they gave, a time they agreed to. `[]` when it asks nothing. A text filed without it is refused. ' +
1288
+ 'Lands as PROPOSALS; a quote the text does not contain is reported and not written. Any other unit may send it too.',
1289
+ ),
1276
1290
  },
1277
1291
  },
1278
1292
  async (args) => {
@@ -1280,6 +1294,11 @@ function renderNudge(payload) {
1280
1294
  // are looked up once and handled together, but "it shouldn't file all of them as one record". The
1281
1295
  // loop lives here, as it does for place_staged_record's batch: the route stays one unit per call.
1282
1296
  const batch = [...new Set([args.intakeItemId, ...(args.intakeItemIds ?? [])])].slice(0, 50)
1297
+ // Obligation 53bfd283 — each unit's own answer. Refused HERE, before anything is filed, rather than
1298
+ // sent for the server to drop: an agent told nothing believes the ask was surfaced.
1299
+ const ob = obligationsForUnits(batch, args.obligations)
1300
+ if (ob.errors.length) return toolError(`Nothing was filed — fix these obligations first:\n${ob.errors.map((e) => ` · ${e}`).join('\n')}`)
1301
+ const obligationsFor = (id) => (ob.byUnit ? { obligations: ob.byUnit.get(id) ?? [] } : {})
1283
1302
  if (batch.length > 1 && args.mode !== 'preview') {
1284
1303
  const results = []
1285
1304
  for (const id of batch) {
@@ -1298,15 +1317,20 @@ function renderNudge(payload) {
1298
1317
  // One placement is a decision about ONE conversation: the server refuses any id whose
1299
1318
  // batch differs from the first id's, so a stray id is never filed onto these pages.
1300
1319
  sameBatchAs: batch[0],
1320
+ ...obligationsFor(id),
1301
1321
  }),
1302
1322
  })
1303
1323
  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}`) })
1324
+ const why = o?.error === 'obligations_unanswered'
1325
+ ? 'not filed — a text needs `obligations` ([] when it asks nothing)'
1326
+ : (o?.error ?? `HTTP ${r.status}`)
1327
+ results.push({ id, ok: r.ok, detail: r.ok ? `${o?.recordId ?? 'filed'}${filingObligationsBrief(o?.obligations)}` : why })
1305
1328
  } catch (e) {
1306
1329
  results.push({ id, ok: false, detail: e.message })
1307
1330
  }
1308
1331
  }
1309
- return { content: [{ type: 'text', text: renderMaterializeBatch(results) }] }
1332
+ const notes = ob.notes.length ? `\n${ob.notes.map((n) => `⚠ ${n}`).join('\n')}` : ''
1333
+ return { content: [{ type: 'text', text: `${renderMaterializeBatch(results)}${notes}` }] }
1310
1334
  }
1311
1335
  // A preview goes to its OWN route: a server too old to know it answers 404, where the materialize
1312
1336
  // route would ignore a `mode` it does not know and publish.
@@ -1329,6 +1353,7 @@ function renderNudge(payload) {
1329
1353
  ...(args.governingPage ? { governingDocumentId: args.governingPage } : {}),
1330
1354
  ...(args.searchedForOtherHomes ? { searchedForOtherHomes: true } : {}),
1331
1355
  ...(args.folderDecisions?.length ? { folderDecisions: args.folderDecisions } : {}),
1356
+ ...obligationsFor(args.intakeItemId),
1332
1357
  }),
1333
1358
  })
1334
1359
  if (!res.ok) {
@@ -1348,6 +1373,7 @@ function renderNudge(payload) {
1348
1373
  'Not filed yet — every page named is one this unit gets anyway, so this would publish it without adding anything of your judgment.',
1349
1374
  ))
1350
1375
  }
1376
+ if (err === 'obligations_unanswered') return toolError(OBLIGATIONS_UNANSWERED_TEXT)
1351
1377
  }
1352
1378
  if (preview && res.status === 404) {
1353
1379
  return toolError('This server cannot preview a materialization yet — nothing was written. Materialize without mode, or wait for the server to update.')
@@ -1413,6 +1439,7 @@ function renderNudge(payload) {
1413
1439
  // filed onto. Never claimed by the server; the session decides.
1414
1440
  lines.push(...renderFolderDecisionsApplied(body?.folderDecisions))
1415
1441
  lines.push(...renderFolderClaimOffers(body?.folderClaimOffers))
1442
+ lines.push(...renderFilingObligations(body?.obligations), ...ob.notes.map((n) => `⚠ ${n}`))
1416
1443
  if (body?.containerSkipped) {
1417
1444
  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
1445
  } else if (body?.contained === true) {
@@ -4496,10 +4523,12 @@ function renderNudge(payload) {
4496
4523
  order: z.enum(['newest', 'oldest']).optional().describe('newest first (default), or oldest first to read a series in sequence'),
4497
4524
  offset: z.number().optional().describe('skip this many records — page through more than 50 by calling again with offset 50, 100…'),
4498
4525
  match: z.string().optional().describe('only records whose title or text contains this (case-insensitive, literal) — e.g. one book on a channel that carries several, one ticket in a queue'),
4526
+ container: z.string().optional().describe('a container\'s record id (a class session, a meeting) — lists the records INSIDE it. read_record on the container shows the same list with its own content'),
4499
4527
  },
4500
4528
  },
4501
- async ({ type, project, since_days, limit, session, page, source, order, offset, match }) => {
4529
+ async ({ type, project, since_days, limit, session, page, source, order, offset, match, container }) => {
4502
4530
  const qs = new URLSearchParams()
4531
+ if (container) qs.set('container', container)
4503
4532
  if (page) qs.set('page', page)
4504
4533
  if (match) qs.set('match', match)
4505
4534
  if (source) qs.set('source', source)
@@ -4525,6 +4554,41 @@ function renderNudge(payload) {
4525
4554
  },
4526
4555
  )
4527
4556
 
4557
+ // read_record — ONE record in full, with what it sits inside and what sits inside it (Theron,
4558
+ // 2026-10-07: "there's really no way to see what's inside a container"). Rendering: record_render.mjs.
4559
+ server.registerTool(
4560
+ 'read_record',
4561
+ {
4562
+ title: 'Read one record in full',
4563
+ description:
4564
+ 'Read ONE record by id: its full content as captured (a lecture or meeting transcript, a text thread, ' +
4565
+ 'an email\'s headers, a commit), the pages it is filed on, the container it sits inside (a class ' +
4566
+ 'session, a meeting), and, when it IS a container, every record inside it. Use it to open what ' +
4567
+ 'list_records, my_day or timeline_pull only summarize, and to walk a class session: read the session, ' +
4568
+ 'then read_record each id it lists. A record you may not read answers "not found", exactly like one ' +
4569
+ 'that does not exist.',
4570
+ inputSchema: {
4571
+ id: z.string().describe('the record id, from list_records, my_day, timeline_pull or another read_record'),
4572
+ max_chars: z.number().optional().describe(`cap on the content shown (default ${READ_RECORD_DEFAULT_CHARS}); the reply says how much was left out`),
4573
+ },
4574
+ },
4575
+ async ({ id, max_chars }) => {
4576
+ let res
4577
+ try {
4578
+ res = await fetchCortex(`${BASE}/api/records/${encodeURIComponent(id)}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
4579
+ } catch (e) {
4580
+ return toolError(`Could not read the record: ${e.message}`)
4581
+ }
4582
+ if (res.status === 404 || res.status === 400) return toolError(`No record ${id} that you can read.`)
4583
+ if (!res.ok) {
4584
+ const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
4585
+ return toolError(`Could not read the record: ${d.message}`)
4586
+ }
4587
+ const body = await res.json().catch(() => null)
4588
+ return { content: [{ type: 'text', text: withAskNotice(renderRecord(body?.record, max_chars)) }] }
4589
+ },
4590
+ )
4591
+
4528
4592
  // ── custom sources: set up, list, re-home (Theron, 2026-09-29) ─────────────────────────────────
4529
4593
  // "I don't think it's that good of an idea to let the user do the whole setup without having an agent
4530
4594
  // involved." The agent can read the system being connected, describe what its data is ABOUT, and judge
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/agnoclast-mcp",
3
- "version": "0.9.214",
3
+ "version": "0.9.216",
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": {