import { randomUUID } from "node:crypto"; import { existsSync, openSync, watch } from "node:fs"; import { promises as fsp } from "node:fs"; import { spawn, spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { z } from "zod"; import path from "node:path"; import { AGENTS_FILE, CURSOR_DIR, DEFAULT_ROOM, INBOX_DIR, ROOT, ROOM_FILE, ROOMS_DIR, ROOMS_FILE, STATUS_FILE, addMember, appendJsonl, cursorFile, deleteFile, ensureRoom, fileSize, getRooms, inboxFile, listCursorFiles, listInboxFiles, listTransportFiles, logFile, memberRooms, normalizeRoom, pidFile, readJson, readJsonl, receiptFile, listReceiptFiles, removeMember, rewriteJsonl, roomFile, rotateAgentToken, setRoomMeta, stashHistory, retrieveHistory, pruneHistory, transportFile, TRANSPORT_DIR, updateJson, type RoomRegistry, } from "../store.js"; import { type AgentEntry, type AgentRegistry, type Message, type StatusEntry, type Cursor, type Source, type TransportMarker, sourceFile, getOffset, setOffset, sysMsg, moveFile, agentIdFromCursorFilename, STALE_MS, EVICT_MS, MAX_WAIT_MS, } from "./shared.js"; // ---------- room / channel tools ---------- export const listRoomsSchema = {} as const; export async function listRoomsTool() { const reg = await getRooms(); const rooms = []; for (const [room, e] of Object.entries(reg)) { const msgs = await readJsonl(roomFile(room)); rooms.push({ room, topic: e.topic, motd: e.motd, members: e.members ?? [], memberCount: (e.members ?? []).length, messageCount: msgs.length, lastTs: msgs.length ? msgs[msgs.length - 1].ts : undefined, createdAt: e.createdAt, createdBy: e.createdBy, }); } return { rooms }; } export const joinRoomSchema = { agentId: z.string().min(1), room: z.string().min(1), }; export async function joinRoomTool(args: { agentId: string; room: string }) { const chan = normalizeRoom(args.room); await ensureRoom(chan, args.agentId); await addMember(chan, args.agentId); await appendJsonl(roomFile(chan), sysMsg(args.agentId, chan, `${args.agentId} has joined`)); const reg = await getRooms(); const e = reg[chan]; const all = await readJsonl(roomFile(chan)); const cursor = await readJson(cursorFile(args.agentId), {}); const unread = Math.max(0, all.length - getOffset(cursor, "room", chan)); return { ok: true, room: chan, topic: e?.topic, motd: e?.motd, members: e?.members ?? [], unread, }; } export const leaveRoomSchema = { agentId: z.string().min(1), room: z.string().min(1), }; export async function leaveRoomTool(args: { agentId: string; room: string }) { const chan = normalizeRoom(args.room); if (chan === DEFAULT_ROOM) { return { ok: false, error: "cannot leave the default channel" }; } await removeMember(chan, args.agentId); return { ok: true, room: chan }; } export const setRoomTopicSchema = { agentId: z.string().min(1), room: z.string().min(1), topic: z.string(), }; export async function setRoomTopicTool(args: { agentId: string; room: string; topic: string }) { const chan = normalizeRoom(args.room); await ensureRoom(chan, args.agentId); await setRoomMeta(chan, { topic: args.topic }, args.agentId); await appendJsonl(roomFile(chan), sysMsg(args.agentId, chan, `changed topic to: ${args.topic}`)); return { ok: true, room: chan, topic: args.topic }; } // Whole-string-only writes made a one-clause edit cost a full rewrite, and the // bill was paid in STALE DOCTRINE, not tokens. One room's MOTD reached 33,870 // bytes (every other room on the bus: 0–739). At that size a one-line rule // change costs ~34KB in AND ~34KB back out in the echoed response, so a rule // routing escalations to a role that had just been deleted was LEFT STANDING — // another 34KB write "wasn't worth one stale clause". A MOTD nobody can afford // to correct is worse than a short one that is merely incomplete. // // So: append and find/replace, and the response no longer echoes the body back. const MOTD_SOFT_LIMIT = 4096; export const setRoomMotdSchema = { agentId: z.string().min(1), room: z.string().min(1), // Exactly one of these. `motd` stays first and stays supported — this is an // addition, not a migration. motd: z.string().optional(), // Append a clause. Joined with a newline; no separator is invented. motdAppend: z.string().min(1).optional(), // Replace the FIRST occurrence of `find`. Refuses on no match and on multiple // matches: a patch that silently hits nothing is the failure mode this whole // item exists to remove, and one that hits the wrong clause of several is // worse than refusing. motdPatch: z.object({ find: z.string().min(1), replace: z.string() }).optional(), }; type MotdArgs = { agentId: string; room: string; motd?: string; motdAppend?: string; motdPatch?: { find: string; replace: string }; }; export async function setRoomMotdTool(args: MotdArgs) { const chan = normalizeRoom(args.room); const given = ["motd", "motdAppend", "motdPatch"].filter( (k) => args[k as keyof MotdArgs] !== undefined, ); if (given.length === 0) { return { ok: false as const, error: "pass exactly one of motd (replace whole), motdAppend (add a clause), motdPatch {find, replace}", }; } if (given.length > 1) { return { ok: false as const, error: `pass ONE of motd/motdAppend/motdPatch — got ${given.join(", ")}` }; } await ensureRoom(chan, args.agentId); const rooms = await getRooms(); const before = rooms[chan]?.motd ?? ""; let next: string; let how: string; if (args.motd !== undefined) { next = args.motd; how = "replaced"; } else if (args.motdAppend !== undefined) { next = before ? `${before}\n${args.motdAppend}` : args.motdAppend; how = "appended to"; } else { const { find, replace } = args.motdPatch!; const first = before.indexOf(find); if (first === -1) { return { ok: false as const, error: `motdPatch found no match for ${JSON.stringify(find)} — the MOTD is unchanged. Read it with list_rooms and patch a string that is actually in it.`, }; } if (before.indexOf(find, first + find.length) !== -1) { return { ok: false as const, error: `motdPatch matched ${JSON.stringify(find)} more than once — refusing rather than guessing which clause you meant. Extend 'find' until it is unique.`, }; } next = before.slice(0, first) + replace + before.slice(first + find.length); how = "patched"; } await setRoomMeta(chan, { motd: next }, args.agentId); await appendJsonl(roomFile(chan), sysMsg(args.agentId, chan, `updated the room rules (MOTD)`)); // Deliberately NOT echoing `motd` back. It cost as much as the write itself, // for a value the caller either just supplied or can read with list_rooms. return { ok: true as const, room: chan, how, bytes: Buffer.byteLength(next, "utf8"), bytesBefore: Buffer.byteLength(before, "utf8"), ...(Buffer.byteLength(next, "utf8") > MOTD_SOFT_LIMIT ? { warning: `MOTD is ${Buffer.byteLength(next, "utf8")} bytes (soft limit ${MOTD_SOFT_LIMIT}). Every agent reads this on join. ` + `Past this size the doctrine belongs in a repo file with a short pointer here — an MOTD too expensive to correct goes stale, ` + `which costs more than it saves. motdAppend/motdPatch make small corrections cheap either way.`, } : {}), }; } // ---------- delete_room ---------- export const deleteRoomSchema = { agentId: z.string().min(1), room: z.string().min(1), // When true, kick remaining members instead of refusing. force: z.boolean().optional(), }; export async function deleteRoomTool(args: { agentId: string; room: string; force?: boolean }) { const chan = normalizeRoom(args.room); if (chan === DEFAULT_ROOM) return { ok: false, error: "cannot delete the default channel" }; const rooms = await getRooms(); const entry = rooms[chan]; if (!entry) return { ok: false, error: `room '${chan}' does not exist` }; const members = entry.members ?? []; if (members.length > 0 && !args.force) { return { ok: false, error: `room '${chan}' still has ${members.length} member(s): ${members.join(", ")}. Pass force=true to delete anyway.`, members, }; } // Remove from rooms registry. await updateJson(ROOMS_FILE, {}, (current) => { delete current[chan]; return current; }); // Delete the backing JSONL file. const file = roomFile(chan); const fileDeleted = await deleteFile(file); // Drop the channel offset from every agent's cursor so no offset points into // a now-deleted file. const cursorsAdjusted: string[] = []; for (const fname of await listCursorFiles()) { const { id } = agentIdFromCursorFilename(fname); const cursorPath = path.join(CURSOR_DIR, fname); let touched = false; await updateJson(cursorPath, {}, (current) => { if (current.roomOffsets?.[chan] !== undefined) { delete current.roomOffsets[chan]; touched = true; } return current; }); if (touched) cursorsAdjusted.push(id); } await appendJsonl(ROOM_FILE, sysMsg(args.agentId, DEFAULT_ROOM, `deleted channel #${chan}`)); return { ok: true, room: chan, fileDeleted, members, cursorsAdjusted }; }