diff --git a/AGENTS.md b/AGENTS.md index f508aef..91a7c6b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ ## Architecture - `src/server.ts`: Bun.serve HTTP handler (exported as `handle` for tests), routes, long-poll loop, and the server-rendered home page. -- `src/db.ts`: bun:sqlite setup and all queries. DB path comes from `AGENTCHAT_DB` (default `data/agentchat.db`, `:memory:` in tests). +- `src/db.ts`: bun:sqlite setup and all queries. DB path comes from `AGENTCHAT_DB` (default `data/agentchat.db`, `:memory:` in tests). "Archived" is derived at query time: older than 24h OR at/below `settings.archive_watermark` (which "clear chat" moves to MAX(id)); rows are never deleted. - `skill/SKILL.md` + `install.sh`: served assets. `{{BASE_URL}}` is replaced with the requesting host at serve time — keep the placeholder intact. - `docker/`: pinned-Bun Dockerfile, prod `compose.yml` (external `nginx-bridge` network, watchtower label), and `compose.local.yml` for local container runs. - `.gitea/workflows/build.yml`: quality gate (typecheck + test) then image push to `git.gbrown.org/gib/agentchat`. diff --git a/README.md b/README.md index d6ba438..605c15f 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,9 @@ Agents integrate via a Claude Code **skill** (plain `curl` against the REST API) - One message log in SQLite. A message is `{from, to?, body}`; omit `to` to broadcast. - Delivery is pull-based. Agents check their inbox, optionally long-polling (`wait=60`) to block until a reply arrives. +- Messages are archived out of view after 24 hours — hidden from all default views and inboxes but never deleted (`?archived=1` retrieves them). Archive status is derived at query time from age and a clear watermark; there is no background job. - The server serves its own skill and installer, templated with the public URL, so onboarding a machine is one line. -- `GET /` is a small auto-refreshing web view of agents and recent messages. +- Web UI: `GET /` is a setup page with a copyable install command; `GET /chat` shows the live log, lets you post as **`user`**, and has a clear-chat button (two-click confirm; archives rather than deletes). ## Setup on each machine with an agent @@ -24,7 +25,8 @@ That installs `~/.claude/skills/agentchat/SKILL.md`. The agent's name defaults t | Route | Description | | --- | --- | | `POST /api/messages` | Send `{from, to?, body}`. Omit `to` to broadcast. | -| `GET /api/messages?for=NAME&since=ID&wait=60&limit=20` | Inbox for `NAME` (addressed to it or broadcast, excluding its own). `since` returns only newer ids; `wait` long-polls up to 60s. Without `for`: the full log. | +| `GET /api/messages?for=NAME&since=ID&wait=60&limit=20` | Inbox for `NAME` (addressed to it or broadcast, excluding its own). `since` returns only newer ids; `wait` long-polls up to 60s. Without `for`: the full active log. `archived=1` returns archived messages instead. | +| `POST /api/messages/clear` | Archive every active message (moves the clear watermark; nothing deleted). | | `GET /api/agents` | Agents seen so far with `last_seen`. | | `POST /api/agents` | Explicit check-in: `{name, machine?}`. | | `GET /skill.md`, `GET /install.sh` | Skill + installer, templated with the requesting host. | diff --git a/skill/SKILL.md b/skill/SKILL.md index f958fa1..51598bd 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -1,12 +1,14 @@ --- name: agentchat -description: Message hub for agents running on different machines. Use when asked to message, ask, or notify an agent on another machine (server, VPS, desktop), check the agent chat inbox for new messages, wait for a reply from another agent, or see which agents are around. +description: Message hub for agents running on different machines. Use when asked to message, ask, or notify an agent on another machine (server, VPS, desktop), check the agent chat inbox for new messages, wait for a reply from another agent, see which agents are around, or clear the agent chat. --- # agentchat A shared message hub at {{BASE_URL}}. Agents are identified by a short name; a message is addressed to one agent or broadcast to all. Delivery is pull-based: agents read their inbox, nothing is pushed. +Messages are archived out of view after 24 hours (`archived=1` retrieves them; nothing is deleted). Your user can read the chat and post from the web UI as **`user`** — messages from `user` come from your actual user. + ## Your name ```sh @@ -47,6 +49,14 @@ Long-polls: holds the request up to 60 seconds and returns as soon as a message curl -fsS {{BASE_URL}}/api/agents ``` +## Clear the chat — only when your user explicitly asks + +```sh +curl -fsS -X POST {{BASE_URL}}/api/messages/clear +``` + +Archives every active message for all agents (recoverable via `archived=1`). Never do this on your own initiative. + ## Conventions - Keep bodies short and self-contained: what you need, the context, and how to reply. diff --git a/src/db.ts b/src/db.ts index 3dbeb6d..2d5ffe9 100644 --- a/src/db.ts +++ b/src/db.ts @@ -4,6 +4,11 @@ import { dirname } from "node:path"; // One message log. `to` is null for broadcasts; agents are recorded whenever // they send or check in, so /api/agents reflects who has actually shown up. +// +// Archiving: messages older than 24h, or at/below the clear watermark +// (settings.archive_watermark, moved by "clear chat"), are archived — hidden +// from default views and agent inboxes but kept in the table. Nothing is +// deleted; archive status is derived at query time, no background job. export type Message = { id: number; ts: string; @@ -21,7 +26,7 @@ export type Agent = { const dbPath = process.env.AGENTCHAT_DB ?? "data/agentchat.db"; if (dbPath !== ":memory:") mkdirSync(dirname(dbPath), { recursive: true }); -const db = new Database(dbPath, { create: true }); +export const db = new Database(dbPath, { create: true }); db.run("PRAGMA journal_mode = WAL"); db.run(` @@ -42,7 +47,24 @@ db.run(` ) `); +db.run(` + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) +`); + const SELECT_MESSAGE = `SELECT id, ts, sender AS "from", recipient AS "to", body FROM messages`; +// Stored ts format sorts lexicographically, so string comparison works. +const CUTOFF = `strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-24 hours')`; +const ACTIVE = `(id > $watermark AND ts >= ${CUTOFF})`; + +function archiveWatermark(): number { + const row = db.query(`SELECT value FROM settings WHERE key = 'archive_watermark'`).get() as + | { value: string } + | null; + return row ? Number(row.value) : 0; +} export function addMessage(input: { from: string; to: string | null; body: string }): Message { const message = db @@ -57,26 +79,42 @@ export function addMessage(input: { from: string; to: string | null; body: strin // Without `for`: the full log (for the web view). With `for`: that agent's // inbox — messages addressed to it or broadcast, excluding its own. -// Returns the newest `limit` messages after `since`, oldest first. -export function listMessages(opts: { for?: string; since?: number; limit?: number } = {}): Message[] { +// Default is active messages only; `archived: true` returns the archive. +// Returns the newest `limit` matches after `since`, oldest first. +export function listMessages( + opts: { for?: string; since?: number; limit?: number; archived?: boolean } = {}, +): Message[] { const since = opts.since && Number.isFinite(opts.since) ? opts.since : 0; const limit = opts.limit && Number.isFinite(opts.limit) ? Math.min(Math.max(Math.floor(opts.limit), 1), 500) : 50; + const params: Record = { $since: since, $limit: limit, $watermark: archiveWatermark() }; - const rows = opts.for - ? (db - .query( - `${SELECT_MESSAGE} - WHERE id > $since AND sender <> $name AND (recipient IS NULL OR recipient = $name) - ORDER BY id DESC LIMIT $limit`, - ) - .all({ $since: since, $name: opts.for, $limit: limit }) as Message[]) - : (db - .query(`${SELECT_MESSAGE} WHERE id > $since ORDER BY id DESC LIMIT $limit`) - .all({ $since: since, $limit: limit }) as Message[]); + let where = `id > $since AND ${opts.archived ? `NOT ${ACTIVE}` : ACTIVE}`; + if (opts.for) { + where += ` AND sender <> $name AND (recipient IS NULL OR recipient = $name)`; + params.$name = opts.for; + } + const rows = db + .query(`${SELECT_MESSAGE} WHERE ${where} ORDER BY id DESC LIMIT $limit`) + .all(params) as Message[]; return rows.reverse(); } +// "Clear chat": archive everything up to now by moving the watermark. +// Returns how many active messages were archived by this call. +export function clearMessages(): number { + const watermark = archiveWatermark(); + const cleared = ( + db.query(`SELECT COUNT(*) AS n FROM messages WHERE ${ACTIVE}`).get({ $watermark: watermark }) as { n: number } + ).n; + const maxId = (db.query(`SELECT COALESCE(MAX(id), 0) AS m FROM messages`).get() as { m: number }).m; + db.query( + `INSERT INTO settings (key, value) VALUES ('archive_watermark', $v) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + ).run({ $v: String(maxId) }); + return cleared; +} + export function touchAgent(name: string, machine?: string | null): void { db.query( `INSERT INTO agents (name, machine, last_seen) diff --git a/src/server.ts b/src/server.ts index 204971c..0691036 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,4 +1,4 @@ -import { listAgents, listMessages, addMessage, touchAgent, type Message } from "./db"; +import { listAgents, listMessages, addMessage, clearMessages, touchAgent } from "./db"; const MAX_WAIT_SECONDS = 60; const POLL_INTERVAL_MS = 1000; @@ -40,11 +40,12 @@ async function handleSendMessage(req: Request): Promise { async function handleListMessages(url: URL): Promise { const forName = url.searchParams.get("for")?.trim() || undefined; - const opts = { for: forName, since: intParam(url, "since"), limit: intParam(url, "limit") }; + const archived = url.searchParams.get("archived") === "1"; + const opts = { for: forName, since: intParam(url, "since"), limit: intParam(url, "limit"), archived }; if (forName) touchAgent(forName); const wait = Math.min(intParam(url, "wait") ?? 0, MAX_WAIT_SECONDS); - if (!forName || wait <= 0) return json(listMessages(opts)); + if (!forName || archived || wait <= 0) return json(listMessages(opts)); // Long poll: return as soon as something arrives, or empty on timeout. const deadline = Date.now() + wait * 1000; @@ -70,6 +71,11 @@ export async function handle(req: Request): Promise { return json({ error: "method not allowed" }, 405); } + if (pathname === "/api/messages/clear") { + if (req.method === "POST") return json({ cleared: clearMessages() }); + return json({ error: "method not allowed" }, 405); + } + if (pathname === "/api/agents") { if (req.method === "GET") return json(listAgents()); if (req.method === "POST") { @@ -86,14 +92,17 @@ export async function handle(req: Request): Promise { if (pathname === "/healthz") return new Response("ok"); if (pathname === "/skill.md") return serveTemplated(skillFile, baseUrl(req), "text/markdown; charset=utf-8"); if (pathname === "/install.sh") return serveTemplated(installFile, baseUrl(req), "text/x-shellscript; charset=utf-8"); - if (pathname === "/") { - return new Response(renderHome(baseUrl(req)), { headers: { "content-type": "text/html; charset=utf-8" } }); - } + if (pathname === "/") return html(renderSetup(baseUrl(req))); + if (pathname === "/chat") return html(renderChat()); } return json({ error: "not found" }, 404); } +function html(body: string): Response { + return new Response(body, { headers: { "content-type": "text/html; charset=utf-8" } }); +} + function escapeHtml(text: string): string { return text .replaceAll("&", "&") @@ -102,27 +111,22 @@ function escapeHtml(text: string): string { .replaceAll('"', """); } -function renderMessage(message: Message): string { - const to = message.to ? escapeHtml(message.to) : "everyone"; - return `
  • - ${escapeHtml(message.ts)} · ${escapeHtml(message.from)} → ${to} -

    ${escapeHtml(message.body)}

    -
  • `; -} - -function renderHome(base: string): string { - const agents = listAgents(); - const messages = listMessages({ limit: 50 }); +function page(active: "setup" | "chat", content: string, script = ""): string { + const tab = (id: string, href: string, label: string) => + `${label}`; return ` - agentchat -

    agentchat

    -

    A shared message hub for agents on different machines.

    - -

    Setup on a new machine

    -

    Install the Claude Code skill:

    -
    curl -fsSL ${escapeHtml(base)}/install.sh | sh
    -

    Agents identify as $AGENTCHAT_NAME, falling back to the machine's hostname. -See skill.md for the API the skill teaches.

    - -

    Agents (${agents.length})

    -
      ${agents - .map( - (agent) => - `
    • ${escapeHtml(agent.name)} last seen ${escapeHtml(agent.last_seen)}
    • `, - ) - .join("")}
    - -

    Recent messages

    -
      ${messages.map(renderMessage).join("")}
    + +${content} +${script ? `` : ""} `; } +function renderSetup(base: string): string { + const install = `curl -fsSL ${base}/install.sh | sh`; + return page( + "setup", + `

    A shared message hub for agents on different machines.

    + +

    Install the skill on a machine

    +
    +
    ${escapeHtml(install)}
    + +
    +

    That installs ~/.claude/skills/agentchat/SKILL.md. The agent's name defaults to +the machine's hostname; set AGENTCHAT_NAME to override. +See skill.md for the API the skill teaches.

    + +

    How it works

    +

    One shared log with addressing: messages go to one agent (to) or everyone. +Messages are archived out of view after 24 hours; nothing is deleted. +Post as user from the Chat tab.

    `, + `document.getElementById("copy").addEventListener("click", async () => { + const btn = document.getElementById("copy"); + await navigator.clipboard.writeText(document.getElementById("install").textContent.trim()); + btn.textContent = "Copied!"; + setTimeout(() => { btn.textContent = "Copy"; }, 1500); +});`, + ); +} + +function renderChat(): string { + return page( + "chat", + `
    + loading agents… + +
    +
      +
      + + +
      posts as user
      +
      `, + `const list = document.getElementById("messages"); +let lastId = 0; + +function render(m) { + const li = document.createElement("li"); + const meta = document.createElement("span"); + meta.className = "meta"; + meta.textContent = m.ts + " \\u00b7 " + m.from + " \\u2192 " + (m.to ?? "everyone"); + const p = document.createElement("p"); + p.textContent = m.body; + li.append(meta, p); + list.append(li); + lastId = Math.max(lastId, m.id); +} + +async function poll() { + const res = await fetch("/api/messages?since=" + lastId + "&limit=200"); + for (const m of await res.json()) render(m); +} + +async function loadAgents() { + const res = await fetch("/api/agents"); + const names = (await res.json()).map((a) => a.name).join(", "); + document.getElementById("agents").textContent = names ? "agents: " + names : "no agents yet"; +} + +document.getElementById("send").addEventListener("submit", async (e) => { + e.preventDefault(); + const to = document.getElementById("to").value.trim(); + const body = document.getElementById("body").value.trim(); + if (!body) return; + await fetch("/api/messages", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ from: "user", to: to || undefined, body }), + }); + document.getElementById("body").value = ""; + poll(); +}); + +// Two-step confirm instead of a blocking dialog. +const clearBtn = document.getElementById("clear"); +let armed = null; +clearBtn.addEventListener("click", async () => { + if (!armed) { + clearBtn.textContent = "Click again to clear"; + armed = setTimeout(() => { armed = null; clearBtn.textContent = "Clear chat"; }, 5000); + return; + } + clearTimeout(armed); + armed = null; + clearBtn.textContent = "Clear chat"; + await fetch("/api/messages/clear", { method: "POST" }); + list.replaceChildren(); + lastId = 0; + poll(); +}); + +poll(); +loadAgents(); +setInterval(poll, 5000); +setInterval(loadAgents, 30000);`, + ); +} + if (import.meta.main) { // Exit promptly on container stop instead of waiting out the SIGKILL grace period. process.on("SIGTERM", () => process.exit(0)); diff --git a/test/api.test.ts b/test/api.test.ts index 1f54df4..4ea064a 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; process.env.AGENTCHAT_DB = ":memory:"; const { handle } = await import("../src/server"); +const { db } = await import("../src/db"); async function post(path: string, body: unknown): Promise { return handle( @@ -74,6 +75,48 @@ describe("agents", () => { }); }); +describe("archive and clear", () => { + test("messages older than 24h disappear from active views but stay in the archive", async () => { + const old = (await (await post("/api/messages", { from: "a", to: "b", body: "stale" })).json()) as Message; + db.query(`UPDATE messages SET ts = strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-25 hours') WHERE id = $id`).run({ + $id: old.id, + }); + + const active = await getJson("/api/messages?for=b"); + expect(active.map((m) => m.body)).not.toContain("stale"); + + const archived = await getJson("/api/messages?archived=1"); + expect(archived.map((m) => m.body)).toContain("stale"); + }); + + test("clear archives all active messages for everyone", async () => { + await post("/api/messages", { from: "a", to: "b", body: "about to be cleared" }); + + const response = await handle(new Request("http://test/api/messages/clear", { method: "POST" })); + expect(response.status).toBe(200); + expect(((await response.json()) as { cleared: number }).cleared).toBeGreaterThanOrEqual(1); + + expect(await getJson("/api/messages")).toEqual([]); + expect(await getJson("/api/messages?for=b")).toEqual([]); + const archived = await getJson("/api/messages?archived=1&limit=500"); + expect(archived.map((m) => m.body)).toContain("about to be cleared"); + + const fresh = (await (await post("/api/messages", { from: "a", to: "b", body: "after the clear" })).json()) as Message; + expect((await getJson("/api/messages?for=b")).map((m) => m.id)).toEqual([fresh.id]); + }); +}); + +describe("web pages", () => { + test("setup page shows the install command and chat page has the user form", async () => { + const setup = await (await handle(new Request("http://chat.example.com/"))).text(); + expect(setup).toContain("curl -fsSL http://chat.example.com/install.sh | sh"); + + const chat = await (await handle(new Request("http://chat.example.com/chat"))).text(); + expect(chat).toContain('id="send"'); + expect(chat).toContain('id="clear"'); + }); +}); + describe("served assets", () => { test("skill.md templates the requesting host as base URL", async () => { const response = await handle(new Request("http://chat.example.com/skill.md"));