/** * agentdox REST client. * * Deliberately tiny and total: every method resolves to a value or null and * never throws. agentdox is an ENRICHMENT, not a dependency — if it is down, * slow, or unauthorized, the turn must still route and dispatch normally. */ import type { Logger } from "../util/log.ts"; export interface AgentDoxClientOptions { baseUrl: string; token: string; timeoutMs: number; log: Logger; } /** Bounds on what agentdox may select for one block. */ export interface AssembleLimits { memoryLimit: number; docsLimit: number; sessionLimit: number; /** Character budget for the project brief; 0 omits it (pre-brief servers ignore it). */ briefChars: number; } /** * The layers around the project scope (project memory, phase one). Each is a * name the team derives; empty means "not present" and is NOT sent, so a lone * router's request body — and therefore its block — is byte-identical to * before. An agentdox that predates layers ignores the keys it does not know. */ export interface AssembleLayers { /** Scope whose brief and top memory render first, as group context. */ group: string; /** Scope whose memory (handoff note first) renders last, as the member's own thread. */ personal: string; /** The member: the project layer's recent tail is filtered to messages tagged `user:`. */ user: string; } export interface AgentDoxClient { /** * Assembles a context slice for `scope`, biased by `query`. Falls back to * the server's pre-assembled baseline when assembly is unavailable (older * server, or no query-relevant content). `layers` adds the group and * personal scopes around it; absent or all-empty sends nothing extra. */ assemble(scope: string, query: string, limits: AssembleLimits, layers?: AssembleLayers): Promise; createSession(scope: string, title: string): Promise; append(sessionId: string, role: "user" | "assistant", content: string, refs: string[]): Promise; } export function createAgentDoxClient(opts: AgentDoxClientOptions): AgentDoxClient { const { baseUrl, token, timeoutMs, log } = opts; const root = baseUrl.replace(/\/+$/, ""); const request = async ( method: string, path: string, body?: unknown, ): Promise<{ status: number; json: unknown } | null> => { const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), timeoutMs); try { const headers: Record = { authorization: `Bearer ${token}` }; if (body !== undefined) headers["content-type"] = "application/json"; const res = await fetch(`${root}${path}`, { method, headers, signal: ctl.signal, ...(body === undefined ? {} : { body: JSON.stringify(body) }), }); // A 404 is meaningful data (no snapshot/brief yet), not a failure. const text = await res.text(); let parsed: unknown = null; try { parsed = text === "" ? null : JSON.parse(text); } catch { parsed = null; } return { status: res.status, json: parsed }; } catch (err) { // Timeouts, connection refused, DNS — all the same to the caller. log.debug("agentdox request failed", { method, path, error: err instanceof Error ? err.message : String(err), }); return null; } finally { clearTimeout(timer); } }; const promptOf = (json: unknown): string | null => { if (typeof json !== "object" || json === null) return null; const p = (json as { prompt?: unknown }).prompt; return typeof p === "string" && p.trim() !== "" ? p : null; }; return { async assemble(scope, query, limits, layers) { // camelCase: the REST endpoint ignores snake_case limit keys entirely, // which silently reads as "unbounded". briefChars is sent even when 0: // an older server ignores the unknown key, and 0 is the documented // "no brief" value there. The layer keys are the opposite: only sent // when non-empty, so a router without a team posts exactly what it // always did. const res = await request("POST", "/context/assemble", { scope, query, memoryLimit: limits.memoryLimit, docsLimit: limits.docsLimit, sessionLimit: limits.sessionLimit, briefChars: limits.briefChars, ...(layers === undefined || layers.group === "" ? {} : { group: layers.group }), ...(layers === undefined || layers.personal === "" ? {} : { personal: layers.personal }), ...(layers === undefined || layers.user === "" ? {} : { user: layers.user }), }); if (res !== null && res.status === 200) { const prompt = promptOf(res.json); if (prompt !== null) return prompt; } if (res !== null && (res.status === 401 || res.status === 403)) { log.warn("agentdox rejected the router token; context injection is off for this scope", { scope, status: res.status, }); return null; } // Baseline fallback: the server-side auto-context job keeps this fresh. const snap = await request("GET", `/context/snapshot?scope=${encodeURIComponent(scope)}`); if (snap === null || snap.status !== 200) return null; return promptOf(snap.json); }, async createSession(scope, title) { const res = await request("POST", "/sessions", { scope, title }); if (res === null || (res.status !== 200 && res.status !== 201)) return null; const id = (res.json as { id?: unknown } | null)?.id; return typeof id === "string" ? id : null; }, async append(sessionId, role, content, refs) { const res = await request("POST", `/sessions/${encodeURIComponent(sessionId)}/messages`, { role, content, refs, }); return res !== null && (res.status === 200 || res.status === 201); }, }; }