@theronap/agnoclast-mcp 0.9.242 → 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.
- package/lib/messages_render.mjs +48 -0
- package/lib/release_gate.mjs +44 -0
- package/lib/server.mjs +94 -0
- package/package.json +1 -1
|
@@ -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
|
|
@@ -4208,6 +4212,95 @@ function renderNudge(payload) {
|
|
|
4208
4212
|
},
|
|
4209
4213
|
)
|
|
4210
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
|
+
|
|
4211
4304
|
server.registerTool(
|
|
4212
4305
|
'contact',
|
|
4213
4306
|
{
|
|
@@ -6958,6 +7051,7 @@ function renderNudge(payload) {
|
|
|
6958
7051
|
},
|
|
6959
7052
|
)
|
|
6960
7053
|
|
|
7054
|
+
applyRelease(toolHandles, await releaseStateWithin(releaseP))
|
|
6961
7055
|
lockedTools = applyLock(toolHandles, await lockStateWithin(lockP))
|
|
6962
7056
|
await server.connect(new StdioServerTransport())
|
|
6963
7057
|
}
|
package/package.json
CHANGED