// Helpers with no VS Code in them, so they can be tested with node:test. /** Server-sent events: feed chunks, get back each event's data. */ export class SseParser { private buf = ""; private data: string[] = []; push(chunk: string): string[] { this.buf += chunk; const out: string[] = []; let i: number; while ((i = this.buf.indexOf("\n")) >= 0) { const line = this.buf.slice(0, i).replace(/\r$/, ""); this.buf = this.buf.slice(i + 1); if (line === "") { if (this.data.length) out.push(this.data.join("\n")); this.data = []; } else if (line.startsWith("data:")) { this.data.push(line.slice(5).replace(/^ /, "")); } } return out; } } export const KINDS: { kind: string; label: string; prefix: string }[] = [ { kind: "claude-code", label: "Claude Code", prefix: "claude" }, { kind: "antigravity", label: "Antigravity", prefix: "agy" }, { kind: "kimi-code", label: "Kimi Code", prefix: "kimi" }, { kind: "codex", label: "Codex", prefix: "codex" }, { kind: "other", label: "Another MCP client", prefix: "agent" }, ]; /** A name Spectra accepts: lower-case letters, digits and dashes. */ export function validName(n: string): boolean { return /^[a-z0-9][a-z0-9-]{0,63}$/.test(n); } /** A project name from a folder name. */ export function nameFromFolder(folder: string): string { const base = folder.replace(/[\\/]+$/, "").split(/[\\/]/).pop() ?? ""; const n = base.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+/, "").slice(0, 64); return n || "project"; } /** The suggested agent name for a kind on a project, not already taken. */ export function suggestAgentName(kind: string, project: string, taken: string[]): string { const prefix = KINDS.find((k) => k.kind === kind)?.prefix ?? "agent"; const base = `${prefix}-${project}`.slice(0, 60); if (!taken.includes(base)) return base; for (let i = 2; ; i++) if (!taken.includes(`${base}-${i}`)) return `${base}-${i}`; } export const EXPIRIES: { label: string; arg: string | undefined }[] = [ { label: "8 hours", arg: "8h" }, { label: "A day", arg: "1d" }, { label: "A week", arg: "7d" }, { label: "Until I disable it", arg: undefined }, ]; /** `~/x` with the home directory. */ export function expandHome(p: string, home: string): string { return p === "~" ? home : p.startsWith("~/") ? home.replace(/\/$/, "") + p.slice(1) : p; } /** The arguments of the stdio shim for an agent. */ export function shimArgs(agent: string, home: string): string[] { return ["mcp", "--agent", agent, "--home", home]; } /** Antigravity's mcp_config.json with Spectra's server set, keeping the rest. */ export function mergeMcpConfig(existing: string, command: string, args: string[]): string { let cfg: Record = {}; if (existing.trim()) { const parsed = JSON.parse(existing); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) cfg = parsed as Record; } const servers = (cfg.mcpServers && typeof cfg.mcpServers === "object" ? cfg.mcpServers : {}) as Record; servers.spectra = { command, args }; cfg.mcpServers = servers; return JSON.stringify(cfg, null, 2) + "\n"; } /** The project whose folder holds `folder`, the deepest if several do. */ export function projectForFolder(projects: { name: string; path?: string | null }[], folder: string): string | undefined { const norm = (p: string) => p.replace(/[\\/]+$/, ""); const f = norm(folder); let best: { name: string; len: number } | undefined; for (const p of projects) { if (!p.path) continue; const q = norm(p.path); if ((f === q || f.startsWith(q + "/")) && (!best || q.length > best.len)) best = { name: p.name, len: q.length }; } return best?.name; } /** `12:04` today, else `3 Nov 12:04`. */ export function when(ms: number, now = Date.now()): string { const d = new Date(ms); const t = d.toTimeString().slice(0, 5); return new Date(now).toDateString() === d.toDateString() ? t : `${d.getDate()} ${d.toLocaleString("en-GB", { month: "short" })} ${t}`; } /** Text for HTML. */ export function esc(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); }