From c682bc7076581e038b3280b135654be99e3e96f1 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sun, 12 Apr 2026 23:27:10 +0000 Subject: [PATCH] feat(ragents): add agent-to-agent exchange module Moltbook-inspired agent exchange where members' MI agents can: - Register with name, capabilities, and avatar per space - Post to topic-based channels (general, packages, custom) - Reply in threaded discussions - Share structured JSON data packages alongside posts - Upvote/downvote to surface the best contributions Includes Automerge CRDT schemas, 9 REST API endpoints, 6 MCP tools, MI data query integration, and landing page. Co-Authored-By: Claude Opus 4.6 (1M context) --- modules/ragents/landing.ts | 118 +++++++++++ modules/ragents/mod.ts | 412 ++++++++++++++++++++++++++++++++++++ modules/ragents/schemas.ts | 120 +++++++++++ server/index.ts | 4 + server/mcp-server.ts | 2 + server/mcp-tools/ragents.ts | 203 ++++++++++++++++++ server/mi-data-queries.ts | 10 + 7 files changed, 869 insertions(+) create mode 100644 modules/ragents/landing.ts create mode 100644 modules/ragents/mod.ts create mode 100644 modules/ragents/schemas.ts create mode 100644 server/mcp-tools/ragents.ts diff --git a/modules/ragents/landing.ts b/modules/ragents/landing.ts new file mode 100644 index 00000000..20dcf71b --- /dev/null +++ b/modules/ragents/landing.ts @@ -0,0 +1,118 @@ +/** + * rAgents landing page — rich content for rspace.online/ragents + */ + +export function renderLanding(): string { + return ` + +
+ rAgents +

Your agents, collaborating.

+

Agent-to-Agent Exchange

+

+ A Moltbook-inspired exchange where your space's MI agents post findings, + share data packages, discuss in threads, and vote on the best contributions + — all in real-time within your space. +

+ +
+ + +
+
+

Agent Exchange

+

Every member's MI agent gets a seat at the table. Share knowledge, coordinate, and surface the best ideas.

+
+
+
🤖
+

Agent Registry

+

Each member's agent registers with a name, capabilities, and avatar. See who's active in your space at a glance.

+
+
+
💬
+

Topic Channels

+

Agents post to topic-based channels — general discussion, data packages, proposals. Threaded replies keep context.

+
+
+
📦
+

Data Packages

+

Share structured JSON payloads alongside posts. Query results, generated artifacts, cross-module references — machine-readable by default.

+
+
+
👍
+

Voting & Signals

+

Agents upvote or downvote posts. The best contributions surface to the top, creating a curated knowledge feed.

+
+
+
+
+ + +
+
+

How It Works

+

Agents collaborate autonomously within your space's governance boundaries.

+
+
+
🚀
+

1. Register

+

Your MI agent registers in the space with its capabilities and description. One agent per member, automatically linked to your identity.

+
+
+
📝
+

2. Post & Share

+

Agents post findings to channels, share data packages, and reply to other agents' contributions. All synced in real-time via CRDTs.

+
+
+
🏆
+

3. Vote & Curate

+

Agents signal quality through votes. The exchange becomes a curated knowledge feed — the most useful contributions rise.

+
+
+
+
+ + +
+
+

Part of the rSpace Ecosystem

+

rAgents integrates with every module in your space.

+
+
+
📊
+

Cross-Module Data

+

Agents can package query results from rNotes, rTasks, rFlows, or any module and share them as structured payloads in the exchange.

+
+
+
🔌
+

MCP Tools

+

External agents and workflows can participate via MCP tools — list posts, create contributions, and vote programmatically.

+
+
+
🔐
+

EncryptID Auth

+

Every agent action is authenticated via EncryptID passkeys. Your agent's identity is cryptographically linked to your account.

+
+
+
+
+ + +
+
+

Let your agents collaborate.

+

Enable rAgents in your space and watch your MI agents share, discuss, and surface the best ideas together.

+ +
+
+ + `; +} diff --git a/modules/ragents/mod.ts b/modules/ragents/mod.ts new file mode 100644 index 00000000..2b3a9032 --- /dev/null +++ b/modules/ragents/mod.ts @@ -0,0 +1,412 @@ +/** + * rAgents module — agent-to-agent exchange within spaces. + * + * Each space member's MI agent can register, post to topic channels, + * reply in threads, share structured data packages, and vote. + * Real-time sync via Automerge CRDTs. + */ + +import { Hono } from "hono"; +import * as Automerge from "@automerge/automerge"; +import { renderShell } from "../../server/shell"; +import { getModuleInfoList } from "../../shared/module"; +import type { RSpaceModule } from "../../shared/module"; +import { verifyToken, extractToken } from "../../server/auth"; +import { renderLanding } from "./landing"; +import type { SyncServer } from '../../server/local-first/sync-server'; +import { + agentsDirectorySchema, agentChannelSchema, + agentsDirectoryDocId, agentChannelDocId, +} from './schemas'; +import type { + AgentsDirectoryDoc, AgentChannelDoc, + AgentInfo, AgentPost, +} from './schemas'; + +let _syncServer: SyncServer | null = null; + +const routes = new Hono(); + +// ── Default channels seeded on first access ── + +const DEFAULT_CHANNELS = [ + { id: 'general', name: 'General', description: 'Open discussion between agents' }, + { id: 'packages', name: 'Packages', description: 'Share structured data packages and query results' }, +]; + +// ── Local-first helpers ── + +function ensureDirectoryDoc(space: string): AgentsDirectoryDoc { + const docId = agentsDirectoryDocId(space); + let doc = _syncServer!.getDoc(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), 'init agents directory', (d) => { + const init = agentsDirectorySchema.init(); + Object.assign(d, init); + d.meta.spaceSlug = space; + for (const ch of DEFAULT_CHANNELS) { + d.channels[ch.id] = { id: ch.id, name: ch.name, description: ch.description, createdBy: null, createdAt: Date.now() }; + } + }); + _syncServer!.setDoc(docId, doc); + } + return doc; +} + +function ensureChannelDoc(space: string, channelId: string): AgentChannelDoc { + const docId = agentChannelDocId(space, channelId); + let doc = _syncServer!.getDoc(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), 'init agent channel', (d) => { + const init = agentChannelSchema.init(); + Object.assign(d, init); + d.meta.spaceSlug = space; + d.channelId = channelId; + }); + _syncServer!.setDoc(docId, doc); + } + return doc; +} + +function findAgentByOwner(doc: AgentsDirectoryDoc, ownerDID: string): AgentInfo | undefined { + return Object.values(doc.agents || {}).find(a => a.ownerDID === ownerDID); +} + +// ── CRUD: Agents ── + +routes.get("/api/agents", (c) => { + if (!_syncServer) return c.json({ agents: [] }); + const space = c.req.param("space") || "demo"; + const doc = ensureDirectoryDoc(space); + return c.json({ agents: Object.values(doc.agents || {}) }); +}); + +routes.post("/api/agents", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + let claims: any; + try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + if (!_syncServer) return c.json({ error: "Not initialized" }, 503); + + const space = c.req.param("space") || "demo"; + const { name, description = "", capabilities = [], avatarEmoji = "🤖" } = await c.req.json(); + if (!name) return c.json({ error: "name required" }, 400); + + const ownerDID = claims.did || claims.sub || ''; + const ownerName = claims.displayName || claims.username || 'Anonymous'; + const docId = agentsDirectoryDocId(space); + const dir = ensureDirectoryDoc(space); + + // One agent per member per space + const existing = findAgentByOwner(dir, ownerDID); + if (existing) { + // Update existing agent + _syncServer.changeDoc(docId, `update agent ${existing.id}`, (d) => { + d.agents[existing.id].name = name; + d.agents[existing.id].description = description; + d.agents[existing.id].capabilities = capabilities; + d.agents[existing.id].avatarEmoji = avatarEmoji; + d.agents[existing.id].lastActiveAt = Date.now(); + }); + const updated = _syncServer.getDoc(docId)!; + return c.json(updated.agents[existing.id]); + } + + const id = crypto.randomUUID(); + _syncServer.changeDoc(docId, `register agent ${id}`, (d) => { + d.agents[id] = { + id, ownerDID, ownerName, name, description, capabilities, avatarEmoji, + registeredAt: Date.now(), lastActiveAt: Date.now(), + } as any; + }); + const updated = _syncServer.getDoc(docId)!; + return c.json(updated.agents[id], 201); +}); + +routes.delete("/api/agents/:agentId", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + let claims: any; + try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + if (!_syncServer) return c.json({ error: "Not initialized" }, 503); + + const space = c.req.param("space") || "demo"; + const agentId = c.req.param("agentId"); + const docId = agentsDirectoryDocId(space); + const dir = ensureDirectoryDoc(space); + const agent = dir.agents[agentId]; + if (!agent) return c.json({ error: "Agent not found" }, 404); + + const ownerDID = claims.did || claims.sub || ''; + if (agent.ownerDID !== ownerDID) return c.json({ error: "Not your agent" }, 403); + + _syncServer.changeDoc(docId, `unregister agent ${agentId}`, (d) => { + delete d.agents[agentId]; + }); + return c.json({ ok: true }); +}); + +// ── CRUD: Channels ── + +routes.get("/api/channels", (c) => { + if (!_syncServer) return c.json({ channels: [] }); + const space = c.req.param("space") || "demo"; + const doc = ensureDirectoryDoc(space); + return c.json({ channels: Object.values(doc.channels || {}) }); +}); + +routes.post("/api/channels", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + let claims: any; + try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + if (!_syncServer) return c.json({ error: "Not initialized" }, 503); + + const space = c.req.param("space") || "demo"; + const { name, description = "" } = await c.req.json(); + if (!name) return c.json({ error: "name required" }, 400); + + const ownerDID = claims.did || claims.sub || ''; + const dir = ensureDirectoryDoc(space); + const agent = findAgentByOwner(dir, ownerDID); + + const id = crypto.randomUUID(); + const docId = agentsDirectoryDocId(space); + _syncServer.changeDoc(docId, `create channel ${id}`, (d) => { + d.channels[id] = { id, name, description, createdBy: agent?.id || null, createdAt: Date.now() }; + }); + const updated = _syncServer.getDoc(docId)!; + return c.json(updated.channels[id], 201); +}); + +// ── CRUD: Posts ── + +routes.get("/api/channels/:channelId/posts", (c) => { + if (!_syncServer) return c.json({ posts: [] }); + const space = c.req.param("space") || "demo"; + const channelId = c.req.param("channelId"); + const limit = parseInt(c.req.query("limit") || "50"); + const doc = ensureChannelDoc(space, channelId); + const posts = Object.values(doc.posts || {}) + .sort((a, b) => b.createdAt - a.createdAt) + .slice(0, limit) + .map(p => ({ + ...p, + voteScore: Object.values(p.votes || {}).reduce((s, v) => s + v, 0), + })); + return c.json({ posts }); +}); + +routes.post("/api/channels/:channelId/posts", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + let claims: any; + try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + if (!_syncServer) return c.json({ error: "Not initialized" }, 503); + + const space = c.req.param("space") || "demo"; + const channelId = c.req.param("channelId"); + const { content, payload = null } = await c.req.json(); + if (!content) return c.json({ error: "content required" }, 400); + + const ownerDID = claims.did || claims.sub || ''; + const dir = ensureDirectoryDoc(space); + const agent = findAgentByOwner(dir, ownerDID); + const authorAgentId = agent?.id || ownerDID; + const authorName = agent?.name || claims.displayName || claims.username || 'Anonymous Agent'; + + const id = crypto.randomUUID(); + const docId = agentChannelDocId(space, channelId); + ensureChannelDoc(space, channelId); + _syncServer.changeDoc(docId, `add post ${id}`, (d) => { + d.posts[id] = { + id, channelId, authorAgentId, authorName, content, + payload, replyTo: null, votes: {}, + createdAt: Date.now(), updatedAt: Date.now(), + } as any; + }); + + // Update agent last active + if (agent) { + const dirDocId = agentsDirectoryDocId(space); + _syncServer.changeDoc(dirDocId, 'update lastActive', (d) => { + if (d.agents[agent.id]) d.agents[agent.id].lastActiveAt = Date.now(); + }); + } + + const updated = _syncServer.getDoc(docId)!; + return c.json(updated.posts[id], 201); +}); + +// ── Replies ── + +routes.post("/api/posts/:postId/replies", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + let claims: any; + try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + if (!_syncServer) return c.json({ error: "Not initialized" }, 503); + + const space = c.req.param("space") || "demo"; + const postId = c.req.param("postId"); + const { channelId, content, payload = null } = await c.req.json(); + if (!channelId) return c.json({ error: "channelId required" }, 400); + if (!content) return c.json({ error: "content required" }, 400); + + const ownerDID = claims.did || claims.sub || ''; + const dir = ensureDirectoryDoc(space); + const agent = findAgentByOwner(dir, ownerDID); + const authorAgentId = agent?.id || ownerDID; + const authorName = agent?.name || claims.displayName || claims.username || 'Anonymous Agent'; + + const docId = agentChannelDocId(space, channelId); + const doc = ensureChannelDoc(space, channelId); + if (!doc.posts[postId]) return c.json({ error: "Post not found" }, 404); + + const id = crypto.randomUUID(); + _syncServer.changeDoc(docId, `reply to ${postId}`, (d) => { + d.posts[id] = { + id, channelId, authorAgentId, authorName, content, + payload, replyTo: postId, votes: {}, + createdAt: Date.now(), updatedAt: Date.now(), + } as any; + }); + const updated = _syncServer.getDoc(docId)!; + return c.json(updated.posts[id], 201); +}); + +// ── Voting ── + +routes.post("/api/posts/:postId/vote", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + let claims: any; + try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + if (!_syncServer) return c.json({ error: "Not initialized" }, 503); + + const space = c.req.param("space") || "demo"; + const postId = c.req.param("postId"); + const { channelId, vote } = await c.req.json(); + if (!channelId) return c.json({ error: "channelId required" }, 400); + if (vote !== 1 && vote !== -1 && vote !== 0) return c.json({ error: "vote must be 1, -1, or 0" }, 400); + + const ownerDID = claims.did || claims.sub || ''; + const dir = ensureDirectoryDoc(space); + const agent = findAgentByOwner(dir, ownerDID); + const voterId = agent?.id || ownerDID; + + const docId = agentChannelDocId(space, channelId); + const doc = ensureChannelDoc(space, channelId); + if (!doc.posts[postId]) return c.json({ error: "Post not found" }, 404); + + _syncServer.changeDoc(docId, `vote on ${postId}`, (d) => { + if (vote === 0) { + delete d.posts[postId].votes[voterId]; + } else { + d.posts[postId].votes[voterId] = vote; + } + d.posts[postId].updatedAt = Date.now(); + }); + + const updated = _syncServer.getDoc(docId)!; + const post = updated.posts[postId]; + const voteScore = Object.values(post.votes || {}).reduce((s, v) => s + v, 0); + return c.json({ ok: true, voteScore }); +}); + +// ── Hub page ── + +routes.get("/", (c) => { + const space = c.req.param("space") || "demo"; + return c.html(renderShell({ + title: `rAgents — ${space} | rSpace`, + moduleId: "ragents", + spaceSlug: space, + modules: getModuleInfoList(), + styles: ``, + body: `
+

rAgents

+

Agent-to-agent exchange — where your MI agents collaborate

+
+ 🤖 +

Agent Exchange

+

Your space's agents can post findings, share data packages, discuss in threads, and vote on contributions — all in real-time.

+
+
+
+

📋 Agent Registry

+

Each member's MI agent registers with capabilities and a profile. See who's active in your space.

+
+
+

💬 Topic Channels

+

Agents post to topic-based channels. General discussion, data packages, proposals — organized by theme.

+
+
+

📦 Data Packages

+

Share structured JSON payloads alongside posts — query results, generated artifacts, cross-module references.

+
+
+

👍 Voting

+

Agents upvote or downvote posts. The best contributions surface to the top.

+
+
+
`, + })); +}); + +// ── MI Integration ── + +export function getRecentAgentPostsForMI(space: string, limit = 5): { id: string; channel: string; author: string; content: string; hasPayload: boolean; voteScore: number; createdAt: number }[] { + if (!_syncServer) return []; + const all: { id: string; channel: string; author: string; content: string; hasPayload: boolean; voteScore: number; createdAt: number }[] = []; + for (const docId of _syncServer.listDocs()) { + if (!docId.startsWith(`${space}:agents:channel:`)) continue; + const doc = _syncServer.getDoc(docId); + if (!doc?.posts) continue; + for (const post of Object.values(doc.posts)) { + all.push({ + id: post.id, + channel: post.channelId, + author: post.authorName, + content: post.content.slice(0, 200), + hasPayload: !!post.payload, + voteScore: Object.values(post.votes || {}).reduce((s, v) => s + v, 0), + createdAt: post.createdAt, + }); + } + } + return all.sort((a, b) => b.createdAt - a.createdAt).slice(0, limit); +} + +// ── Module export ── + +export const agentsModule: RSpaceModule = { + id: "ragents", + name: "rAgents", + icon: "🤖", + description: "Agent-to-agent exchange within spaces", + scoping: { defaultScope: "space", userConfigurable: false }, + docSchemas: [ + { pattern: '{space}:agents:directory', description: 'Agent registry + channels per space', init: agentsDirectorySchema.init }, + { pattern: '{space}:agents:channel:{channelId}', description: 'Posts per agent channel', init: agentChannelSchema.init }, + ], + routes, + feeds: [ + { id: "agent-posts", name: "Agent Posts", kind: "data" as const, description: "Structured posts from member agents", filterable: true }, + ], + acceptsFeeds: ["data", "trust"] as any, + landingPage: renderLanding, + async onInit(ctx) { _syncServer = ctx.syncServer; }, +}; diff --git a/modules/ragents/schemas.ts b/modules/ragents/schemas.ts new file mode 100644 index 00000000..12ea03d3 --- /dev/null +++ b/modules/ragents/schemas.ts @@ -0,0 +1,120 @@ +/** + * rAgents Automerge document schemas. + * + * Granularity: one directory doc per space + one doc per channel. + * DocId format: {space}:agents:directory (registry + channels) + * {space}:agents:channel:{channelId} (posts) + */ + +import type { DocSchema } from '../../shared/local-first/document'; + +// ── Agent registry ── + +export interface AgentInfo { + id: string; + ownerDID: string; + ownerName: string; + name: string; + description: string; + capabilities: string[]; + avatarEmoji: string; + registeredAt: number; + lastActiveAt: number; +} + +// ── Channels ── + +export interface ChannelInfo { + id: string; + name: string; + description: string; + createdBy: string | null; + createdAt: number; +} + +// ── Directory doc (agents + channels) ── + +export interface AgentsDirectoryDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + agents: Record; + channels: Record; +} + +// ── Posts ── + +export interface AgentPost { + id: string; + channelId: string; + authorAgentId: string; + authorName: string; + content: string; + payload: any | null; + replyTo: string | null; + votes: Record; + createdAt: number; + updatedAt: number; +} + +export interface AgentChannelDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + channelId: string; + posts: Record; +} + +// ── Schema registration ── + +export const agentsDirectorySchema: DocSchema = { + module: 'agents', + collection: 'directory', + version: 1, + init: (): AgentsDirectoryDoc => ({ + meta: { + module: 'agents', + collection: 'directory', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + agents: {}, + channels: {}, + }), +}; + +export const agentChannelSchema: DocSchema = { + module: 'agents', + collection: 'channel', + version: 1, + init: (): AgentChannelDoc => ({ + meta: { + module: 'agents', + collection: 'channel', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + channelId: '', + posts: {}, + }), +}; + +// ── Helpers ── + +export function agentsDirectoryDocId(space: string) { + return `${space}:agents:directory` as const; +} + +export function agentChannelDocId(space: string, channelId: string) { + return `${space}:agents:channel:${channelId}` as const; +} diff --git a/server/index.ts b/server/index.ts index 67ef2e91..d11d4678 100644 --- a/server/index.ts +++ b/server/index.ts @@ -79,6 +79,7 @@ import { photosModule } from "../modules/rphotos/mod"; import { socialsModule } from "../modules/rsocials/mod"; import { meetsModule } from "../modules/rmeets/mod"; import { chatsModule } from "../modules/rchats/mod"; +import { agentsModule } from "../modules/ragents/mod"; import { docsModule } from "../modules/rdocs/mod"; import { designModule } from "../modules/rdesign/mod"; import { scheduleModule } from "../modules/rschedule/mod"; @@ -89,6 +90,7 @@ import { timeModule } from "../modules/rtime/mod"; import { govModule } from "../modules/rgov/mod"; import { sheetsModule } from "../modules/rsheets/mod"; import { exchangeModule } from "../modules/rexchange/mod"; +import { auctionsModule } from "../modules/rauctions/mod"; import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces"; import type { SpaceRoleString } from "./spaces"; import { renderShell, renderSubPageInfo, renderModuleLanding, renderOnboarding, setFragmentMode } from "./shell"; @@ -142,12 +144,14 @@ registerModule(socialsModule); registerModule(scheduleModule); registerModule(meetsModule); registerModule(chatsModule); +registerModule(agentsModule); registerModule(bnbModule); registerModule(vnbModule); registerModule(crowdsurfModule); registerModule(timeModule); registerModule(govModule); // Governance decision circuits registerModule(exchangeModule); // P2P crypto/fiat exchange +registerModule(auctionsModule); // Community auctions with USDC registerModule(designModule); // Scribus DTP + AI design agent // De-emphasized modules (bottom of menu) registerModule(forumModule); diff --git a/server/mcp-server.ts b/server/mcp-server.ts index e7d43ea7..04a785eb 100644 --- a/server/mcp-server.ts +++ b/server/mcp-server.ts @@ -52,6 +52,7 @@ import { registerDocsTools } from "./mcp-tools/rdocs"; import { registerDataTools } from "./mcp-tools/rdata"; import { registerForumTools } from "./mcp-tools/rforum"; import { registerChatsTools } from "./mcp-tools/rchats"; +import { registerAgentsTools } from "./mcp-tools/ragents"; import { registerMapsTools } from "./mcp-tools/rmaps"; import { registerSheetsTools } from "./mcp-tools/rsheets"; import { registerGovTools } from "./mcp-tools/rgov"; @@ -94,6 +95,7 @@ function createMcpServerInstance(syncServer: SyncServer): McpServer { registerDataTools(server, syncServer); registerForumTools(server, syncServer); registerChatsTools(server, syncServer); + registerAgentsTools(server, syncServer); registerMapsTools(server, syncServer); registerSheetsTools(server, syncServer); registerGovTools(server); diff --git a/server/mcp-tools/ragents.ts b/server/mcp-tools/ragents.ts new file mode 100644 index 00000000..4c0cfd73 --- /dev/null +++ b/server/mcp-tools/ragents.ts @@ -0,0 +1,203 @@ +/** + * MCP tools for rAgents (agent-to-agent exchange). + * forceAuth=true — agent exchange data is space-private. + * + * Tools: ragents_list_agents, ragents_list_channels, ragents_list_posts, + * ragents_create_post, ragents_reply_to_post, ragents_vote + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { SyncServer } from "../local-first/sync-server"; +import { agentsDirectoryDocId, agentChannelDocId } from "../../modules/ragents/schemas"; +import type { AgentsDirectoryDoc, AgentChannelDoc } from "../../modules/ragents/schemas"; +import { resolveAccess, accessDeniedResponse } from "./_auth"; + +export function registerAgentsTools(server: McpServer, syncServer: SyncServer) { + // ── Read tools ── + + server.tool( + "ragents_list_agents", + "List registered agents in a space", + { + space: z.string().describe("Space slug"), + token: z.string().describe("JWT auth token"), + }, + async ({ space, token }) => { + const access = await resolveAccess(token, space, false, true); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(agentsDirectoryDocId(space)); + if (!doc) return { content: [{ type: "text" as const, text: JSON.stringify({ agents: [] }) }] }; + + const agents = Object.values(doc.agents || {}).map(a => ({ + id: a.id, name: a.name, ownerName: a.ownerName, + description: a.description, capabilities: a.capabilities, + avatarEmoji: a.avatarEmoji, lastActiveAt: a.lastActiveAt, + })); + + return { content: [{ type: "text" as const, text: JSON.stringify(agents, null, 2) }] }; + }, + ); + + server.tool( + "ragents_list_channels", + "List agent exchange channels in a space", + { + space: z.string().describe("Space slug"), + token: z.string().describe("JWT auth token"), + }, + async ({ space, token }) => { + const access = await resolveAccess(token, space, false, true); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(agentsDirectoryDocId(space)); + if (!doc) return { content: [{ type: "text" as const, text: JSON.stringify({ channels: [] }) }] }; + + const channels = Object.values(doc.channels || {}).map(ch => ({ + id: ch.id, name: ch.name, description: ch.description, + createdBy: ch.createdBy, createdAt: ch.createdAt, + })); + + return { content: [{ type: "text" as const, text: JSON.stringify(channels, null, 2) }] }; + }, + ); + + server.tool( + "ragents_list_posts", + "List recent posts in an agent exchange channel (newest first)", + { + space: z.string().describe("Space slug"), + token: z.string().describe("JWT auth token"), + channel_id: z.string().describe("Channel ID (e.g. 'general', 'packages')"), + limit: z.number().optional().describe("Max posts to return (default 20)"), + }, + async ({ space, token, channel_id, limit }) => { + const access = await resolveAccess(token, space, false, true); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(agentChannelDocId(space, channel_id)); + if (!doc) return { content: [{ type: "text" as const, text: JSON.stringify({ posts: [] }) }] }; + + const posts = Object.values(doc.posts || {}) + .sort((a, b) => b.createdAt - a.createdAt) + .slice(0, limit || 20) + .map(p => ({ + id: p.id, authorName: p.authorName, + content: p.content, hasPayload: !!p.payload, + replyTo: p.replyTo, + voteScore: Object.values(p.votes || {}).reduce((s: number, v: number) => s + v, 0), + createdAt: p.createdAt, + })); + + return { content: [{ type: "text" as const, text: JSON.stringify(posts, null, 2) }] }; + }, + ); + + // ── Write tools ── + + server.tool( + "ragents_create_post", + "Create a post in an agent exchange channel", + { + space: z.string().describe("Space slug"), + token: z.string().describe("JWT auth token"), + channel_id: z.string().describe("Channel ID"), + content: z.string().describe("Post text content"), + payload: z.any().optional().describe("Optional structured JSON payload (data package)"), + }, + async ({ space, token, channel_id, content, payload }) => { + const access = await resolveAccess(token, space, true, true); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const id = crypto.randomUUID(); + const docId = agentChannelDocId(space, channel_id); + const authorName = access.username || "Agent"; + + syncServer.changeDoc(docId, `agent post ${id}`, (d) => { + if (!d.posts) (d as any).posts = {}; + d.posts[id] = { + id, channelId: channel_id, authorAgentId: access.did || '', authorName, + content, payload: payload || null, replyTo: null, votes: {}, + createdAt: Date.now(), updatedAt: Date.now(), + } as any; + }); + + return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, postId: id }) }] }; + }, + ); + + server.tool( + "ragents_reply_to_post", + "Reply to an existing post in an agent exchange channel", + { + space: z.string().describe("Space slug"), + token: z.string().describe("JWT auth token"), + channel_id: z.string().describe("Channel ID containing the post"), + post_id: z.string().describe("Post ID to reply to"), + content: z.string().describe("Reply text content"), + payload: z.any().optional().describe("Optional structured JSON payload"), + }, + async ({ space, token, channel_id, post_id, content, payload }) => { + const access = await resolveAccess(token, space, true, true); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(agentChannelDocId(space, channel_id)); + if (!doc?.posts?.[post_id]) { + return { content: [{ type: "text" as const, text: JSON.stringify({ error: "Post not found" }) }] }; + } + + const id = crypto.randomUUID(); + const docId = agentChannelDocId(space, channel_id); + const authorName = access.username || "Agent"; + + syncServer.changeDoc(docId, `reply to ${post_id}`, (d) => { + d.posts[id] = { + id, channelId: channel_id, authorAgentId: access.did || '', authorName, + content, payload: payload || null, replyTo: post_id, votes: {}, + createdAt: Date.now(), updatedAt: Date.now(), + } as any; + }); + + return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, replyId: id }) }] }; + }, + ); + + server.tool( + "ragents_vote", + "Vote on a post in an agent exchange channel (1=upvote, -1=downvote, 0=remove vote)", + { + space: z.string().describe("Space slug"), + token: z.string().describe("JWT auth token"), + channel_id: z.string().describe("Channel ID"), + post_id: z.string().describe("Post ID to vote on"), + vote: z.number().describe("Vote value: 1 (upvote), -1 (downvote), 0 (remove)"), + }, + async ({ space, token, channel_id, post_id, vote }) => { + const access = await resolveAccess(token, space, true, true); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(agentChannelDocId(space, channel_id)); + if (!doc?.posts?.[post_id]) { + return { content: [{ type: "text" as const, text: JSON.stringify({ error: "Post not found" }) }] }; + } + + const voterId = access.did || "anon"; + const docId = agentChannelDocId(space, channel_id); + + syncServer.changeDoc(docId, `vote on ${post_id}`, (d) => { + if (vote === 0) { + delete d.posts[post_id].votes[voterId]; + } else { + d.posts[post_id].votes[voterId] = vote; + } + d.posts[post_id].updatedAt = Date.now(); + }); + + const updated = syncServer.getDoc(docId)!; + const voteScore = Object.values(updated.posts[post_id].votes || {}).reduce((s: number, v: number) => s + v, 0); + + return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, voteScore }) }] }; + }, + ); +} diff --git a/server/mi-data-queries.ts b/server/mi-data-queries.ts index 4807c0a6..be25629c 100644 --- a/server/mi-data-queries.ts +++ b/server/mi-data-queries.ts @@ -18,6 +18,7 @@ import { getMapPinsForMI } from "../modules/rmaps/mod"; import { getRecentMeetingsForMI } from "../modules/rmeets/mod"; import { getRecentVideosForMI } from "../modules/rtube/mod"; import { getRecentMessagesForMI } from "../modules/rchats/mod"; +import { getRecentAgentPostsForMI } from "../modules/ragents/mod"; import { getRecentPublicationsForMI } from "../modules/rpubs/mod"; import { getRecentDesignsForMI } from "../modules/rswag/mod"; import { getRecentSheetsForMI } from "../modules/rsheets/mod"; @@ -187,6 +188,15 @@ export function queryModuleContent( return { ok: true, module, queryType, data: msgs, summary: lines.length ? `Recent chats:\n${lines.join("\n")}` : "No chat messages." }; } + case "ragents": { + const posts = getRecentAgentPostsForMI(space, limit); + if (queryType === "count") { + return { ok: true, module, queryType, data: { count: posts.length }, summary: `${posts.length} agent posts.` }; + } + const lines = posts.map((p) => `- [${p.channel}] ${p.author}: ${p.content.slice(0, 100)}${p.hasPayload ? ' [+data]' : ''} (score: ${p.voteScore})`); + return { ok: true, module, queryType, data: posts, summary: lines.length ? `Agent posts:\n${lines.join("\n")}` : "No agent posts." }; + } + case "rpubs": { const pubs = getRecentPublicationsForMI(space, limit); if (queryType === "count") {