@theronap/agnoclast-mcp 0.9.177 → 0.9.178

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.
@@ -67,18 +67,26 @@ export function formatNotice(signals) {
67
67
  }
68
68
  if (offers.length) {
69
69
  lines.push(`⚡ ${total} arrival${total === 1 ? '' : 's'} asking THIS session to decide:`)
70
- for (const o of offers.slice(0, 6)) {
71
- // via_reason is carried through verbatim rather than flattened to "matched": an agent — or a
72
- // person — who learns the stated reason is unreliable stops reading the reason at all.
73
- const why = o.via_reason === 'matched'
74
- ? 'matches your work'
75
- : o.via_reason === 'declined'
76
- ? 'every other matching session declined it'
77
- : 'nothing matched it anywhere'
78
- lines.push(` · ${o.source} · ${o.hoursAgo}h ago — ${why}`)
79
- lines.push(` ${o.intakeItemId}`)
70
+ // ⚠ EVERY OFFER, GROUPED BY CASCADE STAGE — changed 2026-09-25 from the first SIX.
71
+ //
72
+ // Six of a deterministically-ordered twelve showed the SAME handful every turn while the rest of
73
+ // the pile stayed unreachable, and a session cannot claim what it is never shown. via_reason is
74
+ // still carried through verbatim rather than flattened — a reader who learns the stated reason is
75
+ // unreliable stops reading it — but it is printed ONCE PER GROUP instead of on every row, which
76
+ // is what makes listing all of them fit at all.
77
+ const STAGES = [
78
+ ['matched', 'matches your work'],
79
+ ['orphan', 'nothing matched them anywhere'],
80
+ ['declined', 'every other matching session declined them'],
81
+ ]
82
+ for (const [key, why] of STAGES) {
83
+ const group = offers.filter((o) => o.via_reason === key)
84
+ if (!group.length) continue
85
+ lines.push(` ${why} (${group.length}):`)
86
+ for (const o of group) lines.push(` ${o.intakeItemId} ${o.source} · ${o.hoursAgo}h`)
80
87
  }
81
- if (total > offers.length) lines.push(` … and ${total - offers.length} more`)
88
+ // Only when the pile genuinely exceeds the server's scan window; `total` is counted before it.
89
+ if (total > offers.length) lines.push(` … and ${total - offers.length} beyond the scan window`)
82
90
  lines.push(' (Claim + route them, or say they are not yours — they stay unclaimed until someone decides.)')
83
91
  }
84
92
  return lines.join('\n')
package/lib/server.mjs CHANGED
@@ -1428,6 +1428,197 @@ function renderNudge(payload) {
1428
1428
  },
1429
1429
  )
1430
1430
 
1431
+ // ── GMAIL WRITES — V5 / W5 of the Google write-scopes plan ───────────────────────────────────────
1432
+ //
1433
+ // Three tools over the W3 engine and its routes: change labels, save a reply draft, ask to send.
1434
+ // Modelled on search_spam_email and send_imessage, as W5 specifies, and they inherit that pair's two
1435
+ // habits — the 409 "which mailbox?" answer, and saying plainly what was NOT done.
1436
+ //
1437
+ // ⚠ THE OWNER CHECK IS THE SERVER'S, NOT OURS (review D14). Every route resolves the mailbox through
1438
+ // loadOwnerGmailConnectors + canAccessMailbox, so being able to READ an email record in a shared brain
1439
+ // never authorizes a write to it. These tools add no authorization of their own and must not imply any.
1440
+ //
1441
+ // ⚠ DARK UNTIL GOOGLE APPROVES THE WRITE SCOPES. In production GOOGLE_WRITE_SCOPES_LIVE is unset and
1442
+ // every Gmail connector is read-only, so these return the auth/upgrade path. That is correct behaviour,
1443
+ // not something to route around — they ship now so they are ready the day the scopes land.
1444
+ //
1445
+ // ⚠ NEVER SAY "NOTHING CHANGED" ON A FAILURE. modifyMessages chunks by BATCH_LIMIT and loops, and a
1446
+ // throw in a later chunk discards the results already collected — so a 5xx can follow real changes. V4
1447
+ // fixed exactly this lie in the web UI; the wording below is what keeps it fixed here.
1448
+
1449
+ /** ONE place a Gmail write failure becomes agent-readable text, so three tools cannot drift on it. */
1450
+ const gmailWriteError = (res, body, tool) => {
1451
+ const msg = typeof body?.error === 'string' ? body.error : null
1452
+ if (res.status === 409 && Array.isArray(body?.accounts) && body.accounts.length) {
1453
+ return toolError(`More than one connected Gmail account. Call ${tool} again with account: ${body.accounts.join(', ')}`)
1454
+ }
1455
+ if (res.status === 401 || res.status === 403) {
1456
+ return toolError(
1457
+ `${msg ?? 'Gmail refused that write.'} Gmail write access may not be granted on this account yet — ` +
1458
+ `the person can check Connectors in Agnoclast. Nothing was changed.`)
1459
+ }
1460
+ if (res.status >= 500) {
1461
+ return toolError(
1462
+ `${msg ?? `Agnoclast could not finish that (${res.status}).`} ⚠ Part of it MAY already have happened. ` +
1463
+ `Do not call ${tool} again — ask the person to check their mailbox first.`)
1464
+ }
1465
+ return toolError(msg ?? `${tool} failed (${res.status}).`)
1466
+ }
1467
+
1468
+ server.registerTool(
1469
+ 'gmail_modify',
1470
+ {
1471
+ title: 'Change labels on the user\'s own Gmail messages',
1472
+ description:
1473
+ 'Mark read or unread, archive, move back to the inbox, or add/remove a label on messages in the USER\'S OWN connected Gmail. Only the mailbox owner can act on a message — being able to read an email record in a shared brain does NOT authorize changing it, and the server enforces that. Identify messages with recordIds (Agnoclast email record ids) or messageIds (Gmail ids); they must all be in ONE mailbox. For add_label/remove_label pass labelId — one of the user\'s own labels (Label_…), or STARRED or IMPORTANT; call without labelId to get the list. THIS CHANGES THE USER\'S MAILBOX: confirm what they want before calling unless they just asked for it plainly. move_to_inbox is the undo for archive.',
1474
+ inputSchema: {
1475
+ action: z.enum(['mark_read', 'mark_unread', 'archive', 'move_to_inbox', 'add_label', 'remove_label'])
1476
+ .describe('what to do to every message named; move_to_inbox undoes archive'),
1477
+ recordIds: z.array(z.string()).optional().describe('Agnoclast email record ids, all in the same mailbox'),
1478
+ messageIds: z.array(z.string()).optional().describe('Gmail message ids, all in the same mailbox — an alternative to recordIds'),
1479
+ labelId: z.string().optional().describe('required for add_label / remove_label: one of the user\'s own labels (Label_…), or STARRED or IMPORTANT. Omit to be shown the available labels.'),
1480
+ account: z.string().email().optional().describe('which connected Gmail account, when the user has more than one'),
1481
+ },
1482
+ },
1483
+ async ({ action, recordIds, messageIds, labelId, account }) => {
1484
+ if (!recordIds?.length && !messageIds?.length) {
1485
+ return toolError('Name the messages to change: recordIds (Agnoclast email records) or messageIds (Gmail ids).')
1486
+ }
1487
+ // A label action with no id is not an error worth bouncing — answer it with the list, so the agent
1488
+ // can act on the reply instead of guessing an id or asking the person for one.
1489
+ if ((action === 'add_label' || action === 'remove_label') && !labelId) {
1490
+ const lr = await fetchCortex(`${BASE}/api/gmail/labels${account ? `?account=${encodeURIComponent(account)}` : ''}`,
1491
+ { headers: { Authorization: `Bearer ${TOKEN}` } })
1492
+ const lb = await lr.json().catch(() => null)
1493
+ if (!lr.ok) return gmailWriteError(lr, lb, 'gmail_modify')
1494
+ const labels = Array.isArray(lb?.labels) ? lb.labels : []
1495
+ if (!labels.length) return toolError(`${lb?.account ?? 'That mailbox'} has no labels you can add or remove. Nothing was changed.`)
1496
+ return toolError(
1497
+ `labelId is required for ${action}. Labels in ${lb.account}:\n` +
1498
+ labels.map((l) => `- ${l.name} → ${l.id}${l.type === 'system' ? ' (system)' : ''}`).join('\n') +
1499
+ `\nCall gmail_modify again with one of those ids. Nothing was changed.`)
1500
+ }
1501
+ const res = await fetchCortex(`${BASE}/api/gmail/messages/modify`, {
1502
+ method: 'POST',
1503
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
1504
+ body: JSON.stringify({
1505
+ action,
1506
+ ...(labelId ? { labelId } : {}),
1507
+ ...(account ? { account } : {}),
1508
+ ...(recordIds?.length ? { recordIds } : { messageIds }),
1509
+ }),
1510
+ })
1511
+ const body = await res.json().catch(() => null)
1512
+ if (!res.ok) return gmailWriteError(res, body, 'gmail_modify')
1513
+ // Per-message results, because a batch can partly fail and the person needs to know which.
1514
+ const results = Array.isArray(body?.results) ? body.results : []
1515
+ const failed = results.filter((r) => r && r.ok === false)
1516
+ const changed = results.length - failed.length
1517
+ const lines = [`${action} in ${body.account}: ${changed} of ${results.length} message${results.length === 1 ? '' : 's'} changed.`]
1518
+ if (failed.length) {
1519
+ lines.push(`⚠ ${failed.length} did NOT change:`)
1520
+ for (const f of failed.slice(0, 10)) lines.push(`- ${f.id}: ${f.error ?? 'unknown error'}`)
1521
+ if (failed.length > 10) lines.push(`- …and ${failed.length - 10} more`)
1522
+ }
1523
+ return { content: [{ type: 'text', text: lines.join('\n') }] }
1524
+ },
1525
+ )
1526
+
1527
+ server.registerTool(
1528
+ 'draft_email_reply',
1529
+ {
1530
+ title: 'Save a reply as a draft in the user\'s Gmail',
1531
+ description:
1532
+ 'Write a reply to one of the USER\'S OWN emails and save it as a DRAFT in their Gmail, threaded under the original. NOTHING IS SENT — the user reviews and sends it themselves from Gmail, so this is the safe way to help with a reply. Identify the email by recordId (an Agnoclast email record) or messageId (a Gmail id). Only the mailbox owner can draft against a message; the server enforces that. Use request_send_email instead when the user wants it actually sent.',
1533
+ inputSchema: {
1534
+ recordId: z.string().optional().describe('the Agnoclast email record to reply to'),
1535
+ messageId: z.string().optional().describe('the Gmail message id to reply to — an alternative to recordId'),
1536
+ text: z.string().min(1).describe('the reply body, as plain text'),
1537
+ account: z.string().email().optional().describe('which connected Gmail account, when the user has more than one'),
1538
+ },
1539
+ },
1540
+ async ({ recordId, messageId, text, account }) => {
1541
+ if (!recordId && !messageId) return toolError('Name the email to reply to: recordId or messageId.')
1542
+ const res = await fetchCortex(`${BASE}/api/gmail/drafts`, {
1543
+ method: 'POST',
1544
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
1545
+ body: JSON.stringify({ ...(recordId ? { recordId } : { messageId }), text, ...(account ? { account } : {}) }),
1546
+ })
1547
+ const body = await res.json().catch(() => null)
1548
+ if (!res.ok) return gmailWriteError(res, body, 'draft_email_reply')
1549
+ return { content: [{ type: 'text', text:
1550
+ `Draft saved in ${body.account} — NOT sent. The user reviews and sends it from Gmail: ${body.openUrl}` }] }
1551
+ },
1552
+ )
1553
+
1554
+ // request_send_email — the one tool here that can put mail in front of a person's contacts.
1555
+ //
1556
+ // ⚠ D5: THIS POST IS NOT IDEMPOTENT AND MUST NEVER BE RETRIED. `idempotent: false` is passed
1557
+ // EXPLICITLY rather than relying on fetchCortex's GET/HEAD-only default, so that widening the default
1558
+ // later cannot silently make sending an email retriable. A 5xx here may mean the mail went out.
1559
+ //
1560
+ // ⚠ THE reviewUrl CONTRACT (the V5 hand-off documented in send-requests/route.ts). The route OMITS the
1561
+ // key when there is nothing to approve, so PRESENCE is the signal and `in` must not be used. Present →
1562
+ // pending, and the sentence below is relayed verbatim. ABSENT → the send rule allowed it and the mail
1563
+ // has ALREADY GONE; never invent a link or claim an approval is pending in that case.
1564
+ server.registerTool(
1565
+ 'request_send_email',
1566
+ {
1567
+ title: 'Ask to send an email from the user\'s Gmail',
1568
+ description:
1569
+ 'Send an email from the USER\'S OWN connected Gmail. By default this does NOT send immediately: it creates a request that WAITS for the user to approve it in Agnoclast, and it goes out at once only if their own send rule allows it. The message is frozen when the request is made, so write the final wording. Always show the user what you are about to send and get their agreement first. Returns either an approval link to relay to them, or confirmation that it already went. ⚠ NEVER call this twice for the same email — it is not idempotent, and a retry can send twice.',
1570
+ inputSchema: {
1571
+ to: z.array(z.string()).min(1).describe('recipient email addresses'),
1572
+ subject: z.string().min(1).describe('the subject line'),
1573
+ text: z.string().min(1).describe('the message body, as plain text'),
1574
+ cc: z.array(z.string()).optional().describe('cc addresses'),
1575
+ bcc: z.array(z.string()).optional().describe('bcc addresses'),
1576
+ replyToRecordId: z.string().optional().describe('reply in the thread of this Agnoclast email record'),
1577
+ replyToMessageId: z.string().optional().describe('reply in the thread of this Gmail message id'),
1578
+ account: z.string().email().optional().describe('which connected Gmail account to send from, when the user has more than one'),
1579
+ },
1580
+ },
1581
+ async ({ to, subject, text, cc, bcc, replyToRecordId, replyToMessageId, account }) => {
1582
+ let res
1583
+ try {
1584
+ res = await fetchCortex(`${BASE}/api/gmail/send-requests`, {
1585
+ method: 'POST',
1586
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
1587
+ body: JSON.stringify({
1588
+ to, subject, text,
1589
+ ...(cc?.length ? { cc } : {}), ...(bcc?.length ? { bcc } : {}),
1590
+ ...(replyToRecordId ? { replyToRecordId } : {}),
1591
+ ...(replyToMessageId ? { replyToMessageId } : {}),
1592
+ ...(account ? { account } : {}),
1593
+ }),
1594
+ }, { idempotent: false, retries: 0 })
1595
+ } catch (e) {
1596
+ // No response at all: the request may still have reached the server. Say so and stop.
1597
+ return toolError(
1598
+ `Could not confirm the send request: ${e.message} ⚠ It may have been created anyway. ` +
1599
+ `Do NOT call request_send_email again — ask the user to check Email in Agnoclast.`)
1600
+ }
1601
+ const body = await res.json().catch(() => null)
1602
+ if (!res.ok) return gmailWriteError(res, body, 'request_send_email')
1603
+
1604
+ // PRESENCE, not `in` — the route omits the key rather than sending null (pinned by
1605
+ // src/send_request_verbs_test.ts). Absent means it already sent.
1606
+ const reviewUrl = body?.reviewUrl
1607
+ const acct = body?.request?.account ?? 'your Gmail'
1608
+ const n = body?.request?.recipientCount ?? to.length
1609
+ if (reviewUrl) {
1610
+ // Relayed VERBATIM — this exact sentence is the contract the create route records.
1611
+ return { content: [{ type: 'text', text:
1612
+ `Send request created from ${acct} to ${n} recipient${n === 1 ? '' : 's'}. It is NOT sent yet. ` +
1613
+ `Tell the user, in these words: It's waiting for your OK: ${reviewUrl}` }] }
1614
+ }
1615
+ const state = body?.request?.state ?? 'sent'
1616
+ return { content: [{ type: 'text', text:
1617
+ `Sent from ${acct} to ${n} recipient${n === 1 ? '' : 's'} — the user's send rule allowed it, so there is ` +
1618
+ `nothing to approve (state: ${state}). Do not offer an approval link; the email has already gone.` }] }
1619
+ },
1620
+ )
1621
+
1431
1622
  server.registerTool(
1432
1623
  'grep',
1433
1624
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/agnoclast-mcp",
3
- "version": "0.9.177",
3
+ "version": "0.9.178",
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": {