@theronap/agnoclast-mcp 0.9.215 → 0.9.217
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/editors/claude.mjs +3 -1
- package/lib/record_render.mjs +53 -0
- package/lib/sends_render.mjs +69 -0
- package/lib/server.mjs +129 -1
- package/package.json +1 -1
package/lib/editors/claude.mjs
CHANGED
|
@@ -30,8 +30,10 @@ 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
|
+
// the questions agents used to answer with SQL (2026-10-07) — read-only, the caller's own view
|
|
36
|
+
'record_status', 'my_connectors', 'brain_stats', 'my_sends',
|
|
35
37
|
// live authoring core
|
|
36
38
|
'authoring_context', 'author', 'log_session',
|
|
37
39
|
// routine, reversible maintenance
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// `my_sends` — "did my email / text actually go out?" Rendered from GET /api/gmail/send-requests, which
|
|
2
|
+
// lists only the CALLER's own requests (owner_auth_id), email and texts alike (ADR-0076: one table).
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS EXISTS. Agents answered it with SQL against outbound_email_requests (Agnoclast page "Agent SQL
|
|
5
|
+
// gap", 2026-10-07), because request_send_email hands back a review link and then goes silent: nothing
|
|
6
|
+
// an agent could call said whether the send was approved, went out, failed, or expired.
|
|
7
|
+
//
|
|
8
|
+
// The message copy (`snapshot`) is deleted after 7 days (Privacy §11), so an old request legitimately
|
|
9
|
+
// has no recipients or subject. That is said, not left blank.
|
|
10
|
+
|
|
11
|
+
const STATE_WORD = {
|
|
12
|
+
pending: 'WAITING FOR YOUR APPROVAL',
|
|
13
|
+
approved: 'approved, about to send',
|
|
14
|
+
sending: 'sending now',
|
|
15
|
+
sent: 'SENT',
|
|
16
|
+
unconfirmed: 'sent, but delivery could not be confirmed',
|
|
17
|
+
refused: 'REFUSED by the send checks',
|
|
18
|
+
denied: 'you said no',
|
|
19
|
+
expired: 'EXPIRED unanswered',
|
|
20
|
+
discarded: 'discarded',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const OPEN = new Set(['pending', 'approved', 'sending'])
|
|
24
|
+
const FAILED = new Set(['refused', 'unconfirmed', 'expired'])
|
|
25
|
+
|
|
26
|
+
const when = (iso) => (iso ? String(iso).slice(0, 16).replace('T', ' ') + 'Z' : '—')
|
|
27
|
+
|
|
28
|
+
/** Pure. Narrow the list the way the tool's arguments ask. */
|
|
29
|
+
export function filterSends(requests, { state, channel } = {}) {
|
|
30
|
+
const list = Array.isArray(requests) ? requests : []
|
|
31
|
+
return list.filter((r) => {
|
|
32
|
+
if (channel && (r.channel ?? 'email') !== channel) return false
|
|
33
|
+
if (state === 'open') return OPEN.has(r.state)
|
|
34
|
+
if (state === 'failed') return FAILED.has(r.state)
|
|
35
|
+
if (state) return r.state === state
|
|
36
|
+
return true
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Pure. One line per request, newest first, with what the person actually needs: where it stands. */
|
|
41
|
+
export function formatSends(requests, opts = {}) {
|
|
42
|
+
const list = filterSends(requests, opts)
|
|
43
|
+
if (!list.length) {
|
|
44
|
+
return opts.state || opts.channel
|
|
45
|
+
? 'No sends of yours match that filter.'
|
|
46
|
+
: 'You have no email or text send requests through Agnoclast.'
|
|
47
|
+
}
|
|
48
|
+
const waiting = list.filter((r) => r.state === 'pending').length
|
|
49
|
+
const lines = []
|
|
50
|
+
if (waiting) lines.push(`⚠ ${waiting} waiting for your approval — open https://agnoclast.com/email to decide.`, '')
|
|
51
|
+
for (const r of list) {
|
|
52
|
+
const s = r.snapshot
|
|
53
|
+
const kind = r.channel === 'imessage' ? 'text' : 'email'
|
|
54
|
+
const to = s?.to?.length ? s.to.join(', ') : `${r.recipientCount ?? '?'} recipient(s)`
|
|
55
|
+
const what = s
|
|
56
|
+
? (kind === 'text' ? `"${String(s.text ?? '').replace(/\s+/g, ' ').slice(0, 60)}"` : `"${s.subject || '(no subject)'}"`)
|
|
57
|
+
: '(message copy deleted after 7 days)'
|
|
58
|
+
const head = `- [${kind}] ${STATE_WORD[r.state] ?? r.state} · to ${to} · ${what}`
|
|
59
|
+
const meta = [`asked ${when(r.createdAt)}`]
|
|
60
|
+
if (r.sentAt) meta.push(`sent ${when(r.sentAt)}`)
|
|
61
|
+
else if (r.state === 'pending') meta.push(`expires ${when(r.expiresAt)}`)
|
|
62
|
+
if (r.autoRule) meta.push(`auto-approved by your rule (${r.autoRule})`)
|
|
63
|
+
if (r.claimedBy) meta.push(`via ${r.claimedBy}`)
|
|
64
|
+
if (r.error) meta.push(`error: ${String(r.error).slice(0, 160)}`)
|
|
65
|
+
meta.push(`from ${r.account}`, `id:${r.id}`)
|
|
66
|
+
lines.push(head, ` ${meta.join(' · ')}`)
|
|
67
|
+
}
|
|
68
|
+
return lines.join('\n')
|
|
69
|
+
}
|
package/lib/server.mjs
CHANGED
|
@@ -26,7 +26,9 @@ 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
28
|
import { obligationsForUnits, renderFilingObligations, filingObligationsBrief, OBLIGATIONS_UNANSWERED_TEXT } from './filing_obligations.mjs'
|
|
29
|
+
import { renderRecord, READ_RECORD_DEFAULT_CHARS } from './record_render.mjs'
|
|
29
30
|
import { formatCreated, formatDryRun, formatSources, homeFromArgs, pickSource, renderHomeProblem } from './custom_sources.mjs'
|
|
31
|
+
import { formatSends } from './sends_render.mjs'
|
|
30
32
|
|
|
31
33
|
// PAGE tiers (ADR-0071). Six for a PAGE; a RECORD keeps three (`privacy` on log_session and
|
|
32
34
|
// set_record_privacy below) — an owner-relative tier is applied to a page's records when they are read
|
|
@@ -4522,10 +4524,14 @@ function renderNudge(payload) {
|
|
|
4522
4524
|
order: z.enum(['newest', 'oldest']).optional().describe('newest first (default), or oldest first to read a series in sequence'),
|
|
4523
4525
|
offset: z.number().optional().describe('skip this many records — page through more than 50 by calling again with offset 50, 100…'),
|
|
4524
4526
|
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'),
|
|
4527
|
+
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'),
|
|
4528
|
+
person: z.string().optional().describe('a member\'s exact display name or email — only the records they created (within what you can see)'),
|
|
4525
4529
|
},
|
|
4526
4530
|
},
|
|
4527
|
-
async ({ type, project, since_days, limit, session, page, source, order, offset, match }) => {
|
|
4531
|
+
async ({ type, project, since_days, limit, session, page, source, order, offset, match, container, person }) => {
|
|
4528
4532
|
const qs = new URLSearchParams()
|
|
4533
|
+
if (container) qs.set('container', container)
|
|
4534
|
+
if (person) qs.set('person', person)
|
|
4529
4535
|
if (page) qs.set('page', page)
|
|
4530
4536
|
if (match) qs.set('match', match)
|
|
4531
4537
|
if (source) qs.set('source', source)
|
|
@@ -4551,6 +4557,128 @@ function renderNudge(payload) {
|
|
|
4551
4557
|
},
|
|
4552
4558
|
)
|
|
4553
4559
|
|
|
4560
|
+
// read_record — ONE record in full, with what it sits inside and what sits inside it (Theron,
|
|
4561
|
+
// 2026-10-07: "there's really no way to see what's inside a container"). Rendering: record_render.mjs.
|
|
4562
|
+
server.registerTool(
|
|
4563
|
+
'read_record',
|
|
4564
|
+
{
|
|
4565
|
+
title: 'Read one record in full',
|
|
4566
|
+
description:
|
|
4567
|
+
'Read ONE record by id: its full content as captured (a lecture or meeting transcript, a text thread, ' +
|
|
4568
|
+
'an email\'s headers, a commit), the pages it is filed on, the container it sits inside (a class ' +
|
|
4569
|
+
'session, a meeting), and, when it IS a container, every record inside it. Use it to open what ' +
|
|
4570
|
+
'list_records, my_day or timeline_pull only summarize, and to walk a class session: read the session, ' +
|
|
4571
|
+
'then read_record each id it lists. A record you may not read answers "not found", exactly like one ' +
|
|
4572
|
+
'that does not exist.',
|
|
4573
|
+
inputSchema: {
|
|
4574
|
+
id: z.string().describe('the record id, from list_records, my_day, timeline_pull or another read_record'),
|
|
4575
|
+
max_chars: z.number().optional().describe(`cap on the content shown (default ${READ_RECORD_DEFAULT_CHARS}); the reply says how much was left out`),
|
|
4576
|
+
},
|
|
4577
|
+
},
|
|
4578
|
+
async ({ id, max_chars }) => {
|
|
4579
|
+
let res
|
|
4580
|
+
try {
|
|
4581
|
+
res = await fetchCortex(`${BASE}/api/records/${encodeURIComponent(id)}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
4582
|
+
} catch (e) {
|
|
4583
|
+
return toolError(`Could not read the record: ${e.message}`)
|
|
4584
|
+
}
|
|
4585
|
+
if (res.status === 404 || res.status === 400) return toolError(`No record ${id} that you can read.`)
|
|
4586
|
+
if (!res.ok) {
|
|
4587
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
4588
|
+
return toolError(`Could not read the record: ${d.message}`)
|
|
4589
|
+
}
|
|
4590
|
+
const body = await res.json().catch(() => null)
|
|
4591
|
+
return { content: [{ type: 'text', text: withAskNotice(renderRecord(body?.record, max_chars)) }] }
|
|
4592
|
+
},
|
|
4593
|
+
)
|
|
4594
|
+
|
|
4595
|
+
// ── the questions agents used to answer with SQL (Agnoclast page "Agent SQL gap", 2026-10-07) ──────
|
|
4596
|
+
// Theron, 2026-10-07: "any information that I have felt the need to get about my own brains, but was
|
|
4597
|
+
// not able to do it with Agnoclast tools … we either need to make tools for that, or …". Measured the
|
|
4598
|
+
// same day: 3,967 owner-connection execute_sql calls since 2026-07-04, a recurring minority of them
|
|
4599
|
+
// these four questions. Each tool answers as the caller (RLS), never wider.
|
|
4600
|
+
const readText = async (what, url) => {
|
|
4601
|
+
let res
|
|
4602
|
+
try {
|
|
4603
|
+
res = await fetchCortex(url, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
4604
|
+
} catch (e) {
|
|
4605
|
+
return { error: toolError(`Could not ${what}: ${e.message}`) }
|
|
4606
|
+
}
|
|
4607
|
+
if (!res.ok) {
|
|
4608
|
+
const body = await res.text()
|
|
4609
|
+
let j = null
|
|
4610
|
+
try { j = JSON.parse(body) } catch { /* not JSON */ }
|
|
4611
|
+
if (res.status === 404 && j) return { error: toolError(`Could not ${what}: ${j.detail ?? j.error ?? 'not found'}`) }
|
|
4612
|
+
return { error: toolError(`Could not ${what}: ${classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message}`) }
|
|
4613
|
+
}
|
|
4614
|
+
return { json: await res.json() }
|
|
4615
|
+
}
|
|
4616
|
+
|
|
4617
|
+
server.registerTool(
|
|
4618
|
+
'record_status',
|
|
4619
|
+
{
|
|
4620
|
+
title: 'One record: where it landed and who can read it',
|
|
4621
|
+
description: 'Where ONE record you can see LANDED and WHO CAN READ IT: the pages it is filed on and which one governs who can read it, who can read it in plain words and why YOU can, the class session or meeting it sits inside (or, if it IS one, how many records and recordings it holds), whether anything replaced it, and whether anyone has claimed it. Use it to answer "did my capture / filing land, and where?", "why can I see this?", "who else can read this?". To READ a record\'s text, use read_record. A record you cannot read is reported as not found — the same answer as one that does not exist.',
|
|
4622
|
+
inputSchema: {
|
|
4623
|
+
record_id: z.string().describe('the record id (uuid)'),
|
|
4624
|
+
},
|
|
4625
|
+
},
|
|
4626
|
+
async ({ record_id }) => {
|
|
4627
|
+
const r = await readText('read that record', `${BASE}/api/records/${encodeURIComponent(String(record_id).trim())}/status`)
|
|
4628
|
+
if (r.error) return r.error
|
|
4629
|
+
return { content: [{ type: 'text', text: r.json?.text ?? '' }] }
|
|
4630
|
+
},
|
|
4631
|
+
)
|
|
4632
|
+
|
|
4633
|
+
server.registerTool(
|
|
4634
|
+
'my_connectors',
|
|
4635
|
+
{
|
|
4636
|
+
title: 'Is my data flowing? Connectors, what is landing, what is held',
|
|
4637
|
+
description: 'Your data sources and whether they are working: each connector YOU connected (Gmail, Outlook, calendar, GitHub, Slack…) with its status, last sync and — leading the reply — any that are NOT syncing and how to fix it; what has actually LANDED per source per brain in the last 24h / 7d / 30d (counted over records you can read); and what ARRIVED but is held in no brain yet (staged or sealed). Use it for "is my email coming in?", "did Outlook come back after I reconnected?", "why haven\'t my texts shown up?". Counts and times only — never a title or body.',
|
|
4638
|
+
inputSchema: {},
|
|
4639
|
+
},
|
|
4640
|
+
async () => {
|
|
4641
|
+
const r = await readText('check your connectors', `${BASE}/api/connectors/health`)
|
|
4642
|
+
if (r.error) return r.error
|
|
4643
|
+
return { content: [{ type: 'text', text: r.json?.text ?? '' }] }
|
|
4644
|
+
},
|
|
4645
|
+
)
|
|
4646
|
+
|
|
4647
|
+
server.registerTool(
|
|
4648
|
+
'brain_stats',
|
|
4649
|
+
{
|
|
4650
|
+
title: 'Usage stats for one brain',
|
|
4651
|
+
description: 'The numbers about ONE brain you are in — the same ones its /stats page shows: pages and records, which features are used (meetings recorded, calendar, obligations, email and texts captured or sent, AI sessions…) all-time and in the last 30 days, what each member has done, obligations on time vs late vs overdue, and your own sends. Every number is counted over what YOU can open there; a brain owner/admin also gets whole-brain totals that include everyone\'s private items as numbers, never names. Use it for "how much have I captured this month?", "how many obligations did I finish late?", "who is actually using this brain?".',
|
|
4652
|
+
inputSchema: {
|
|
4653
|
+
brain: z.string().describe('the brain\'s name or org id, from my_brains'),
|
|
4654
|
+
},
|
|
4655
|
+
},
|
|
4656
|
+
async ({ brain }) => {
|
|
4657
|
+
const r = await readText('read brain stats', `${BASE}/api/brain-stats?brain=${encodeURIComponent(String(brain).trim())}`)
|
|
4658
|
+
if (r.error) return r.error
|
|
4659
|
+
return { content: [{ type: 'text', text: r.json?.text ?? '' }] }
|
|
4660
|
+
},
|
|
4661
|
+
)
|
|
4662
|
+
|
|
4663
|
+
server.registerTool(
|
|
4664
|
+
'my_sends',
|
|
4665
|
+
{
|
|
4666
|
+
title: 'Did my email / text go out?',
|
|
4667
|
+
description: 'Your send requests through Agnoclast — emails from your mailbox and texts from your Mac — and where each stands: waiting for your approval, sent (and when), refused by the checks, expired unanswered, or denied. Use it after request_send_email or send_imessage, or for "did that email to Dave actually go out?". Only your own requests. The message copy is deleted after 7 days, so older ones show recipients as a count.',
|
|
4668
|
+
inputSchema: {
|
|
4669
|
+
state: z.string().optional().describe("'open' (waiting / about to send), 'failed' (refused, unconfirmed, expired), or one exact state: pending, sent, denied, expired, refused…"),
|
|
4670
|
+
channel: z.enum(['email', 'imessage']).optional().describe('only emails, or only texts'),
|
|
4671
|
+
limit: z.number().optional().describe('how many recent requests to look through (default 50, max 200)'),
|
|
4672
|
+
},
|
|
4673
|
+
},
|
|
4674
|
+
async ({ state, channel, limit }) => {
|
|
4675
|
+
const n = Math.max(1, Math.min(Math.trunc(Number(limit ?? 50)) || 50, 200))
|
|
4676
|
+
const r = await readText('list your sends', `${BASE}/api/gmail/send-requests?limit=${n}`)
|
|
4677
|
+
if (r.error) return r.error
|
|
4678
|
+
return { content: [{ type: 'text', text: formatSends(r.json?.requests, { state, channel }) }] }
|
|
4679
|
+
},
|
|
4680
|
+
)
|
|
4681
|
+
|
|
4554
4682
|
// ── custom sources: set up, list, re-home (Theron, 2026-09-29) ─────────────────────────────────
|
|
4555
4683
|
// "I don't think it's that good of an idea to let the user do the whole setup without having an agent
|
|
4556
4684
|
// involved." The agent can read the system being connected, describe what its data is ABOUT, and judge
|
package/package.json
CHANGED