import { agentClientId, encodeAwareness } from "./room-awareness"; const AGENT_PRESENCE_MS = 30 * 1000; const AGENT_COLOR = "#a371f7"; /** Agents are keyed by (user, name) so two people's "claude" don't overwrite each other's presence. */ export function agentKey(userId: string, agent: string): string { return `${userId}:${agent}`; } export type TextEdit = { from: number; to: number; insert: string }; export type ApplyResult = | { ok: true; version: number; text: string } | { ok: false; error: "stale" | string; version: number; text: string }; type AgentRow = { name: string; user_name: string; path: string | null; clock: number }; /** Records an agent's latest activity and returns the awareness update announcing its cursor. */ export function touchAgent(sql: SqlStorage, agent: string, user: string, path: string, cursor: number): Uint8Array { const row = sql.exec<{ clock: number }>("SELECT clock FROM agents WHERE name = ?", agent).toArray()[0]; const clock = (row?.clock ?? 0) + 1; sql.exec( `INSERT INTO agents (name, user_name, path, clock, expires_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(name) DO UPDATE SET user_name = excluded.user_name, path = excluded.path, clock = excluded.clock, expires_at = excluded.expires_at`, agent, user, path, clock, Date.now() + AGENT_PRESENCE_MS, ); return agentUpdate({ name: agent, user_name: user, path, clock, cursor }); } export function liveAgents(sql: SqlStorage): AgentRow[] { return sql .exec("SELECT name, user_name, path, clock FROM agents WHERE expires_at > ?", Date.now()) .toArray(); } export function expireAgents(sql: SqlStorage): Array<{ name: string; clock: number }> { const expired = sql .exec<{ name: string; clock: number }>("SELECT name, clock FROM agents WHERE expires_at <= ?", Date.now()) .toArray(); for (const agent of expired) sql.exec("DELETE FROM agents WHERE name = ?", agent.name); return expired; } export function removeAgent(sql: SqlStorage, agent: string): number | null { const row = sql.exec<{ clock: number }>("SELECT clock FROM agents WHERE name = ?", agent).toArray()[0]; if (!row) return null; sql.exec("DELETE FROM agents WHERE name = ?", agent); return row.clock; } export function agentUpdate(agent: AgentRow & { cursor?: number }): Uint8Array { const display = agent.name.slice(agent.name.indexOf(":") + 1); const state = { user: { name: `${display} (agent for ${agent.user_name})`, id: `agent:${agent.name}`, kind: "agent", color: AGENT_COLOR, }, path: agent.path, selections: agent.cursor === undefined ? [] : [{ anchor: agent.cursor, head: agent.cursor }], }; return encodeAwareness([{ clientId: agentClientId(agent.name), clock: agent.clock, state }]); } /** Edits apply back to front so earlier offsets stay valid. */ export function sortEdits(edits: TextEdit[]): TextEdit[] { return [...edits].sort((left, right) => right.from - left.from); } export function checkEdits(sorted: TextEdit[], length: number, maxLength: number): string | null { let previousFrom = Number.POSITIVE_INFINITY; let next = length; for (const edit of sorted) { if (edit.from < 0 || edit.to < edit.from || edit.to > length) return "Edit range out of bounds"; if (edit.to > previousFrom) return "Edits overlap"; previousFrom = edit.from; next += edit.insert.length - (edit.to - edit.from); } return next > maxLength ? "File too large" : null; } export function editCursor(sorted: TextEdit[]): number { const first = sorted[sorted.length - 1]; return first ? first.from + first.insert.length : 0; }