@theronap/agnoclast-mcp 0.9.241 → 0.9.243

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.
@@ -0,0 +1,48 @@
1
+ // INTERNAL MESSAGING (ADR-0088) — how `my_messages` and `read_conversation` read to an agent.
2
+ // Shapes are web/lib/engine/messages.ts (InboxItem, MessageView), trimmed to what is read.
3
+
4
+ const REASON = { dm: 'direct message', mention: 'mentioned you', thread: 'reply in your thread', channel: 'channel' }
5
+
6
+ /** "Builder (agent of Theron Peterson)" / "Ann Lee" / "Deleted account". */
7
+ export function authorLabel(author) {
8
+ if (!author) return 'Deleted account'
9
+ if (author.kind === 'agent') return `${author.name} (agent${author.ownerName ? ` of ${author.ownerName}` : ''})`
10
+ return author.name
11
+ }
12
+
13
+ function when(iso) {
14
+ return iso ? iso.replace('T', ' ').slice(0, 16) + 'Z' : ''
15
+ }
16
+
17
+ /** The inbox, grouped by conversation so an agent answers each one once. */
18
+ export function renderMessageInbox(items) {
19
+ if (!items.length) return 'No new messages.'
20
+ const byConv = new Map()
21
+ for (const it of items) {
22
+ const key = it.conversation.id
23
+ if (!byConv.has(key)) byConv.set(key, { conv: it.conversation, items: [] })
24
+ byConv.get(key).items.push(it)
25
+ }
26
+ const lines = [`${items.length} new message${items.length === 1 ? '' : 's'}:`]
27
+ for (const { conv, items: its } of byConv.values()) {
28
+ lines.push('', `## ${conv.name} — conversation ${conv.id}`)
29
+ for (const it of its) {
30
+ const where = it.threadRootId ? ` · in thread ${it.threadRootId}` : ''
31
+ lines.push(`- [${when(it.createdAt)}] ${authorLabel(it.author)} (${REASON[it.reason] ?? it.reason}${where}) · message ${it.id}`)
32
+ for (const l of it.body.split('\n')) lines.push(` ${l}`)
33
+ }
34
+ }
35
+ lines.push('', 'Reply with send_message. To answer in a thread, pass thread = the thread id shown (or the message id, to start one).')
36
+ return lines.join('\n')
37
+ }
38
+
39
+ /** A conversation page or one thread, oldest first. */
40
+ export function renderConversation(messages) {
41
+ if (!messages.length) return 'No messages yet.'
42
+ return messages.map((m) => {
43
+ const head = `[${when(m.createdAt)}] ${authorLabel(m.author)} · message ${m.id}${m.editedAt ? ' · edited' : ''}`
44
+ const replies = m.replyCount ? `\n (${m.replyCount} repl${m.replyCount === 1 ? 'y' : 'ies'} in thread ${m.id})` : ''
45
+ const body = m.deleted ? ' (message deleted)' : m.body.split('\n').map((l) => ` ${l}`).join('\n')
46
+ return `${head}\n${body}${replies}`
47
+ }).join('\n')
48
+ }
@@ -0,0 +1,44 @@
1
+ // RELEASE COHORTS on the stdio server (Theron, 2026-10-19 — page: Agnoclast — release cohorts and feature flags).
2
+ // The flag registry and the cohort rule live on the server (web/lib/engine/release.ts). This module only asks,
3
+ // once at start-up, which tools this caller must NOT see (GET /api/me/release) and removes them before the
4
+ // first tools/list. Fails CLOSED: no answer within the budget → hide GATED_FALLBACK, a copy of every gated tool
5
+ // that web/lib/engine/release.test.ts pins to the server's registry.
6
+
7
+ /** Tools any release flag gates — hidden when the server cannot be asked. Mirror of allGatedTools(). */
8
+ export const GATED_FALLBACK = ['my_messages', 'read_conversation', 'send_message']
9
+
10
+ export const RELEASE_FETCH_TIMEOUT_MS = 2500
11
+
12
+ /** → { known: true, hidden: string[] } | { known: false }. 404 (server predates cohorts) = nothing hidden. Never throws. */
13
+ export async function fetchReleaseState(base, token, fetchImpl) {
14
+ try {
15
+ const res = await fetchImpl(`${base}/api/me/release`, { headers: { Authorization: `Bearer ${token}` } }, { skipAutofile: true })
16
+ if (res.status === 404) return { known: true, hidden: [] }
17
+ if (!res.ok) return { known: false }
18
+ const j = await res.json()
19
+ if (!Array.isArray(j?.hiddenTools)) return { known: false }
20
+ return { known: true, hidden: j.hiddenTools.filter((t) => typeof t === 'string') }
21
+ } catch {
22
+ return { known: false }
23
+ }
24
+ }
25
+
26
+ export async function releaseStateWithin(promise, ms = RELEASE_FETCH_TIMEOUT_MS) {
27
+ let timer
28
+ const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve({ known: false }), ms) })
29
+ try { return await Promise.race([promise, timeout]) } finally { clearTimeout(timer) }
30
+ }
31
+
32
+ /** Remove hidden tools from the registry. `handles` maps name → { handle }. Returns the names removed. */
33
+ export function applyRelease(handles, state) {
34
+ const names = state?.known ? state.hidden : GATED_FALLBACK
35
+ const removed = []
36
+ for (const name of names) {
37
+ const h = handles.get(name)
38
+ if (!h) continue
39
+ h.handle.remove()
40
+ handles.delete(name)
41
+ removed.push(name)
42
+ }
43
+ return removed
44
+ }
package/lib/server.mjs CHANGED
@@ -31,8 +31,10 @@ import { formatCreated, formatDryRun, formatSources, homeFromArgs, pickSource, r
31
31
  import { formatSeriesRefusal, formatSeriesUpdated } from './series_render.mjs'
32
32
  import { formatSends } from './sends_render.mjs'
33
33
  import { applyLock, fetchLockState, lockedAnswerFor, lockStateWithin } from './locked_tools.mjs'
34
+ import { applyRelease, fetchReleaseState, releaseStateWithin } from './release_gate.mjs'
34
35
  import { filterUnshownSettling } from './settling_shown.mjs'
35
36
  import { renderContactBriefing, renderContactCandidates, renderContactList } from './contacts_render.mjs'
37
+ import { renderConversation, renderMessageInbox } from './messages_render.mjs'
36
38
 
37
39
  // PAGE tiers (ADR-0071). Six for a PAGE; a RECORD keeps three (`privacy` on log_session and
38
40
  // set_record_privacy below) — an owner-relative tier is applied to a page's records when they are read
@@ -394,6 +396,8 @@ export async function runServer(version) {
394
396
  // with registering ~120 tools, and awaited (bounded) just before the handshake, so the first tools/list a
395
397
  // Lite account's AI sees already marks the Regular-only tools 🔒. Regular accounts: nothing changes.
396
398
  const lockP = fetchLockState(BASE, TOKEN, fetchCortex)
399
+ // RELEASE COHORTS (lib/release_gate.mjs): tools behind a flag this caller's brains lack are removed before the handshake.
400
+ const releaseP = fetchReleaseState(BASE, TOKEN, fetchCortex)
397
401
  const toolHandles = new Map()
398
402
  let lockedTools = new Set()
399
403
  // ADR-0070: every tool runs with its NAME in async context, so an outage fetchCortex auto-files is
@@ -2366,7 +2370,8 @@ function renderNudge(payload) {
2366
2370
  const lines = revs.map((r) => {
2367
2371
  // "Theron via Frack" — the label only ever reaches the token's owner (bdb2339b phase 2).
2368
2372
  const via = r.via_token_label ? ` via ${r.via_token_label}` : ''
2369
- const who = r.actor_name ? ` · ${r.actor_name}${via}` : (via ? ` ·${via}` : '')
2373
+ const agentOf = r.actor_agent_of ? ` (agent of ${r.actor_agent_of})` : ''
2374
+ const who = r.actor_name ? ` · ${r.actor_name}${agentOf}${via}` : (via ? ` ·${via}` : '')
2370
2375
  // change_kind first and bracketed so a column of [correct] is scannable — the whole point is
2371
2376
  // that a page with repeated corrections looks different at a glance from one that only grew.
2372
2377
  const what = r.change_kind ? ` · [${r.change_kind}]` : ''
@@ -2421,7 +2426,7 @@ function renderNudge(payload) {
2421
2426
  ? `# ${name} — rev ${d.to.revNo} (first version)`
2422
2427
  : `# ${name} — rev ${d.from.revNo} → rev ${d.to.revNo}`
2423
2428
  const meta = []
2424
- meta.push(`${String(d.to.createdAt).slice(0, 10)} · ${d.to.op}${d.to.changeKind ? ` · [${d.to.changeKind}]` : ''}${d.to.actorName ? ` · ${d.to.actorName}${d.to.viaTokenLabel ? ` via ${d.to.viaTokenLabel}` : ''}` : ''}`)
2429
+ meta.push(`${String(d.to.createdAt).slice(0, 10)} · ${d.to.op}${d.to.changeKind ? ` · [${d.to.changeKind}]` : ''}${d.to.actorName ? ` · ${d.to.actorName}${d.to.actorAgentOf ? ` (agent of ${d.to.actorAgentOf})` : ''}${d.to.viaTokenLabel ? ` via ${d.to.viaTokenLabel}` : ''}` : ''}`)
2425
2430
  if (d.to.reason) meta.push(`reason: ${d.to.reason}`)
2426
2431
  if (d.to.sessionKey) meta.push(`session: ${String(d.to.sessionKey).slice(0, 24)}`)
2427
2432
  const body = []
@@ -4207,6 +4212,95 @@ function renderNudge(payload) {
4207
4212
  },
4208
4213
  )
4209
4214
 
4215
+ // 💬 INTERNAL MESSAGING (ADR-0088; Theron, 2026-10-19). Agnoclast's own chat between people AND agents who
4216
+ // share a brain. For an agent, a message is its wake-up: `my_messages` is its inbox.
4217
+ server.registerTool(
4218
+ 'send_message',
4219
+ {
4220
+ title: 'Send a message — to a person, an agent, or a #channel',
4221
+ description: 'Send a chat message inside Agnoclast, like Slack. `to` is a person\'s or agent\'s name or email (a DM) or a #channel. You can only message people and agents who share a brain with you. Mention someone with @Name to notify them in a channel. Reply in a thread with `thread` (a message id from my_messages or read_conversation). If a name matches more than one person you get the list back: ask which, then send again with their email. Messages are not email: nothing leaves Agnoclast.',
4222
+ inputSchema: {
4223
+ to: z.string().describe('a name, an email, or a #channel'),
4224
+ body: z.string().describe('the message (at most 8000 characters)'),
4225
+ thread: z.string().optional().describe('reply in this thread: the id of its first message'),
4226
+ brain: z.string().optional().describe('which brain, when a #channel name exists in more than one'),
4227
+ },
4228
+ },
4229
+ async ({ to, body, thread, brain }) => {
4230
+ let res
4231
+ try {
4232
+ res = await fetchCortex(`${BASE}/api/messages/send`, {
4233
+ method: 'POST',
4234
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
4235
+ body: JSON.stringify({ to, body, threadRootId: thread, brain }),
4236
+ })
4237
+ } catch (e) {
4238
+ return toolError(`Could not send: ${e.message}`)
4239
+ }
4240
+ const out = await res.json().catch(() => null)
4241
+ if (res.status === 404 && !out?.error) return toolError('This Agnoclast server has no messaging yet (it predates ADR-0088).')
4242
+ if (res.status === 409 && out?.error === 'ambiguous') {
4243
+ const lines = (out.candidates ?? []).map((p) => `- ${p.name}${p.kind === 'agent' ? ` (agent${p.ownerName ? ` of ${p.ownerName}` : ''})` : ''}`)
4244
+ return { content: [{ type: 'text', text: `More than one match for "${to}":\n${lines.join('\n')}\nAsk which one, then send again using their email.` }] }
4245
+ }
4246
+ if (!res.ok) return toolError(`Not sent: ${out?.detail ?? out?.error ?? res.status}`)
4247
+ const told = out.notified ? ` ${out.notified} notified.` : ''
4248
+ return { content: [{ type: 'text', text: `Sent to ${to}.${told} (message ${out.message.id}, conversation ${out.conversationId})` }] }
4249
+ },
4250
+ )
4251
+
4252
+ server.registerTool(
4253
+ 'my_messages',
4254
+ {
4255
+ title: 'Your messages — what you have been told about and not read',
4256
+ description: 'Your Agnoclast message inbox, oldest first: DMs, @mentions of you, and replies in threads you are in. Call it when you wake up, before anything else. By default it marks what it returns as read, so the next call shows only newer messages; pass mark_read: false to just look. Answer with send_message (same `to` as the conversation, and `thread` to reply in a thread).',
4257
+ inputSchema: {
4258
+ limit: z.number().optional().describe('max messages (default 30, max 100)'),
4259
+ mark_read: z.boolean().optional().describe('mark them read (default true)'),
4260
+ },
4261
+ },
4262
+ async ({ limit, mark_read }) => {
4263
+ let res
4264
+ try {
4265
+ res = await fetchCortex(`${BASE}/api/messages/inbox?${new URLSearchParams({
4266
+ ...(limit ? { limit: String(limit) } : {}), ...(mark_read === false ? {} : { markRead: '1' }),
4267
+ })}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
4268
+ } catch (e) {
4269
+ return toolError(`Could not read your messages: ${e.message}`)
4270
+ }
4271
+ const out = await res.json().catch(() => null)
4272
+ if (res.status === 404 && !out?.error) return toolError('This Agnoclast server has no messaging yet (it predates ADR-0088).')
4273
+ if (!res.ok) return toolError(`Could not read your messages: ${out?.detail ?? out?.error ?? res.status}`)
4274
+ return { content: [{ type: 'text', text: renderMessageInbox(out.items ?? []) }] }
4275
+ },
4276
+ )
4277
+
4278
+ server.registerTool(
4279
+ 'read_conversation',
4280
+ {
4281
+ title: 'Read a conversation or thread',
4282
+ description: 'Read the recent messages of one conversation (its id comes from my_messages), oldest first, or one thread when you pass `thread`. Reading does not mark anything read; my_messages does.',
4283
+ inputSchema: {
4284
+ conversation: z.string().describe('the conversation id'),
4285
+ thread: z.string().optional().describe('the id of a thread\'s first message, to read just that thread'),
4286
+ limit: z.number().optional().describe('max messages (default 30, max 200)'),
4287
+ },
4288
+ },
4289
+ async ({ conversation, thread, limit }) => {
4290
+ let res
4291
+ try {
4292
+ res = await fetchCortex(`${BASE}/api/messages/conversations/${encodeURIComponent(conversation)}?${new URLSearchParams({
4293
+ ...(thread ? { thread } : {}), limit: String(limit ?? 30),
4294
+ })}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
4295
+ } catch (e) {
4296
+ return toolError(`Could not read that conversation: ${e.message}`)
4297
+ }
4298
+ const out = await res.json().catch(() => null)
4299
+ if (!res.ok) return toolError(`Could not read that conversation: ${out?.detail ?? out?.error ?? res.status}`)
4300
+ return { content: [{ type: 'text', text: renderConversation(out.messages ?? []) }] }
4301
+ },
4302
+ )
4303
+
4210
4304
  server.registerTool(
4211
4305
  'contact',
4212
4306
  {
@@ -4918,6 +5012,50 @@ function renderNudge(payload) {
4918
5012
  },
4919
5013
  )
4920
5014
 
5015
+ // share_calendar — share YOUR OWN calendar with one person (ADR-0086; Theron, 2026-10-17). Same shape as
5016
+ // share_record: NOT auto-allowed (a disclosure to someone, so it prompts), and only when the person asks.
5017
+ // Nothing is emailed by this tool; it returns the link for the person to send.
5018
+ server.registerTool(
5019
+ 'share_calendar',
5020
+ {
5021
+ title: 'Share your calendar with someone',
5022
+ description:
5023
+ 'Share the person\'s OWN calendar with one other person by email address. They get their own read-only link ' +
5024
+ '(agnoclast.com/c/…); if they use Agnoclast it also appears on their calendar until revoked. scope "busy" ' +
5025
+ '(default) shows only busy blocks; "titles" also shows event titles (never notes, places or attendees). This does ' +
5026
+ 'NOT send anything: give the person the link to send, or they can send it from the Share calendar panel. Only ' +
5027
+ 'when the person asks to share their calendar; never on your own initiative. Turn it off on agnoclast.com/calendar.',
5028
+ inputSchema: {
5029
+ email: z.string().describe('the email address of the person to share with'),
5030
+ scope: z.enum(['busy', 'titles']).optional().describe('busy = busy blocks only (default); titles = event titles too'),
5031
+ },
5032
+ },
5033
+ async ({ email, scope }) => {
5034
+ let res
5035
+ try {
5036
+ res = await fetchCortex(`${BASE}/api/calendar/shares`, {
5037
+ method: 'POST',
5038
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
5039
+ body: JSON.stringify({ action: 'share_with', email, scope: scope ?? 'busy' }),
5040
+ })
5041
+ } catch (e) {
5042
+ return toolError(`Could not share the calendar: ${e.message}`)
5043
+ }
5044
+ if (res.status === 400 || res.status === 409) {
5045
+ const b = await res.json().catch(() => null)
5046
+ return toolError(`Not shared: ${b?.error ?? 'refused'}`)
5047
+ }
5048
+ if (!res.ok) {
5049
+ const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
5050
+ return toolError(`Could not share the calendar: ${d.message}`)
5051
+ }
5052
+ const out = await res.json()
5053
+ return { content: [{ type: 'text', text:
5054
+ `Shared your calendar (${out.share.scope === 'titles' ? 'with event titles' : 'busy times only'}) with ${out.share.recipientEmail}:\n${out.url}\n` +
5055
+ `Nothing was sent — give them this link. It is shown only once. If they use Agnoclast it is already on their calendar. share id: ${out.share.id}` }] }
5056
+ },
5057
+ )
5058
+
4921
5059
  // set_record_notes — write the NOTES layer of one of your own transcripts (Theron, 2026-10-08: "two
4922
5060
  // layers … a summary and notes from the transcript … the full transcript"). The server keeps the
4923
5061
  // before-state in record history and refuses anything that is not your own kept transcript.
@@ -6913,6 +7051,7 @@ function renderNudge(payload) {
6913
7051
  },
6914
7052
  )
6915
7053
 
7054
+ applyRelease(toolHandles, await releaseStateWithin(releaseP))
6916
7055
  lockedTools = applyLock(toolHandles, await lockStateWithin(lockP))
6917
7056
  await server.connect(new StdioServerTransport())
6918
7057
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/agnoclast-mcp",
3
- "version": "0.9.241",
3
+ "version": "0.9.243",
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": {