// @generated — DO NOT EDIT. Source: packages/shared/external-annotation.ts /** * External Annotations — shared types, store logic, and SSE helpers. * * Runtime-agnostic: no node:fs, no node:http, no Bun APIs. * Both the Bun server handler and Pi server handler import this module * and wrap it with their respective HTTP transport layers. * * The store is generic — plan servers store Annotation objects, * review servers store CodeAnnotation objects. The mode-specific * input transformers handle validation and field assignment. */ // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- /** Constraint for any annotation type the store can hold. */ export type StorableAnnotation = { id: string; source?: string }; export type ExternalAnnotationEvent = | { type: "snapshot"; annotations: T[] } | { type: "add"; annotations: T[] } | { type: "remove"; ids: string[] } | { type: "clear"; source?: string } | { type: "update"; id: string; annotation: T }; // --------------------------------------------------------------------------- // SSE helpers // --------------------------------------------------------------------------- /** Heartbeat comment to keep SSE connections alive (sent every 30s). */ export const HEARTBEAT_COMMENT = ":\n\n"; /** Interval in ms between heartbeat comments. */ export const HEARTBEAT_INTERVAL_MS = 30_000; /** Encode an event as an SSE `data:` line. */ export function serializeSSEEvent(event: ExternalAnnotationEvent): string { return `data: ${JSON.stringify(event)}\n\n`; } // --------------------------------------------------------------------------- // Input validation — shared helpers // --------------------------------------------------------------------------- export interface ParseError { error: string; } /** * Unwrap a POST body into an array of raw input objects. * * Accepts either: * - A single annotation object: `{ source: "...", ... }` * - A batch wrapper: `{ annotations: [{ source: "...", ... }, ...] }` */ function unwrapBody(body: unknown): Record[] | ParseError { if (!body || typeof body !== "object") { return { error: "Request body must be a JSON object" }; } const obj = body as Record; // Batch format: { annotations: [...] } if (Array.isArray(obj.annotations)) { if (obj.annotations.length === 0) { return { error: "annotations array must not be empty" }; } const items: Record[] = []; for (let i = 0; i < obj.annotations.length; i++) { const item = obj.annotations[i]; if (!item || typeof item !== "object") { return { error: `annotations[${i}] must be an object` }; } items.push(item as Record); } return items; } // Single format: { source: "...", ... } if (typeof obj.source === "string") { return [obj as Record]; } return { error: 'Missing required "source" field or "annotations" array' }; } function requireString(obj: Record, field: string, index: number): string | ParseError { const val = obj[field]; if (typeof val !== "string" || val.length === 0) { return { error: `annotations[${index}] missing required "${field}" field` }; } return val; } // --------------------------------------------------------------------------- // Plan mode transformer — produces Annotation objects // --------------------------------------------------------------------------- /** The Annotation type shape for plan mode (mirrors packages/ui/types.ts). */ interface PlanAnnotation { id: string; blockId: string; startOffset: number; endOffset: number; type: string; // AnnotationType value text?: string; originalText: string; createdA: number; author?: string; source?: string; } const VALID_PLAN_TYPES = ["DELETION", "COMMENT", "GLOBAL_COMMENT"]; export function transformPlanInput( body: unknown, ): { annotations: PlanAnnotation[] } | ParseError { const items = unwrapBody(body); if ("error" in items) return items; const annotations: PlanAnnotation[] = []; for (let i = 0; i < items.length; i++) { const obj = items[i]; const source = requireString(obj, "source", i); if (typeof source !== "string") return source; // Must have text content if (typeof obj.text !== "string" || obj.text.length === 0) { return { error: `annotations[${i}] missing required "text" field` }; } // Validate type if provided, default to GLOBAL_COMMENT const type = typeof obj.type === "string" ? obj.type : "GLOBAL_COMMENT"; if (!VALID_PLAN_TYPES.includes(type)) { return { error: `annotations[${i}] invalid type "${type}". Must be one of: ${VALID_PLAN_TYPES.join(", ")}`, }; } // DELETION requires originalText (the text to remove) if (type === "DELETION" && (typeof obj.originalText !== "string" || obj.originalText.length === 0)) { return { error: `annotations[${i}] DELETION type requires non-empty "originalText" field` }; } // COMMENT requires originalText so the renderer can pin it to a phrase. // External agents that want sidebar-only feedback should use GLOBAL_COMMENT // instead — without a phrase to anchor to, a COMMENT renders as an empty // quote bubble in the sidebar and exports as `Feedback on: ""`. if (type === "COMMENT" && (typeof obj.originalText !== "string" || obj.originalText.length === 0)) { return { error: `annotations[${i}] COMMENT requires non-empty "originalText" field. Use GLOBAL_COMMENT for sidebar-only feedback.`, }; } annotations.push({ id: crypto.randomUUID(), blockId: "external", startOffset: 0, endOffset: 0, type, text: String(obj.text), originalText: typeof obj.originalText === "string" ? obj.originalText : "", createdA: Date.now(), author: typeof obj.author === "string" ? obj.author : undefined, source, }); } return { annotations }; } // --------------------------------------------------------------------------- // Review mode transformer — produces CodeAnnotation objects // --------------------------------------------------------------------------- /** The CodeAnnotation type shape for review mode (mirrors packages/ui/types.ts). */ interface ReviewAnnotation { id: string; type: string; // CodeAnnotationType value scope?: string; filePath: string; lineStart: number; lineEnd: number; side: string; text?: string; suggestedCode?: string; originalCode?: string; createdAt: number; author?: string; source?: string; // Agent review metadata (optional — only set by Claude review findings) severity?: string; // "important" | "nit" | "pre_existing" reasoning?: string; // Validation chain explaining how the issue was confirmed } const VALID_REVIEW_TYPES = ["comment", "suggestion", "concern"]; const VALID_SIDES = ["old", "new"]; const VALID_SCOPES = ["line", "file"]; export function transformReviewInput( body: unknown, ): { annotations: ReviewAnnotation[] } | ParseError { const items = unwrapBody(body); if ("error" in items) return items; const annotations: ReviewAnnotation[] = []; for (let i = 0; i < items.length; i++) { const obj = items[i]; const source = requireString(obj, "source", i); if (typeof source !== "string") return source; const filePath = requireString(obj, "filePath", i); if (typeof filePath !== "string") return filePath; if (typeof obj.lineStart !== "number") { return { error: `annotations[${i}] missing required "lineStart" field` }; } if (typeof obj.lineEnd !== "number") { return { error: `annotations[${i}] missing required "lineEnd" field` }; } // side: optional, defaults to "new" const side = typeof obj.side === "string" ? obj.side : "new"; if (!VALID_SIDES.includes(side)) { return { error: `annotations[${i}] invalid side "${side}". Must be one of: ${VALID_SIDES.join(", ")}`, }; } // type: optional, defaults to "comment" const type = typeof obj.type === "string" ? obj.type : "comment"; if (!VALID_REVIEW_TYPES.includes(type)) { return { error: `annotations[${i}] invalid type "${type}". Must be one of: ${VALID_REVIEW_TYPES.join(", ")}`, }; } // scope: optional, defaults to "line" const scope = typeof obj.scope === "string" ? obj.scope : "line"; if (!VALID_SCOPES.includes(scope)) { return { error: `annotations[${i}] invalid scope "${scope}". Must be one of: ${VALID_SCOPES.join(", ")}`, }; } // Must have at least text or suggestedCode if (typeof obj.text !== "string" && typeof obj.suggestedCode !== "string") { return { error: `annotations[${i}] must have at least one of: text, suggestedCode`, }; } annotations.push({ id: crypto.randomUUID(), type, scope, filePath, lineStart: obj.lineStart, lineEnd: obj.lineEnd, side, text: typeof obj.text === "string" ? obj.text : undefined, suggestedCode: typeof obj.suggestedCode === "string" ? obj.suggestedCode : undefined, originalCode: typeof obj.originalCode === "string" ? obj.originalCode : undefined, createdAt: Date.now(), author: typeof obj.author === "string" ? obj.author : undefined, source, // Agent review metadata (optional — only set by Claude review findings) ...(typeof obj.severity === "string" && { severity: obj.severity }), ...(typeof obj.reasoning === "string" && { reasoning: obj.reasoning }), }); } return { annotations }; } // --------------------------------------------------------------------------- // Annotation Store (generic) // --------------------------------------------------------------------------- type MutationListener = (event: ExternalAnnotationEvent) => void; export interface AnnotationStore { /** Add fully-formed annotations. Returns the added annotations. */ add(items: T[]): T[]; /** Remove an annotation by ID. Returns true if found. */ remove(id: string): boolean; /** Remove all annotations from a specific source. Returns count removed. */ clearBySource(source: string): number; /** Update an annotation by ID. Returns the updated annotation, or null if not found. */ update(id: string, fields: Partial): T | null; /** Remove all annotations. Returns count removed. */ clearAll(): number; /** Get all annotations (snapshot). */ getAll(): T[]; /** Monotonic version counter — incremented on every mutation. */ readonly version: number; /** Register a listener for mutation events. Returns unsubscribe function. */ onMutation(listener: MutationListener): () => void; } /** * Create an in-memory annotation store. * * The store is runtime-agnostic — it holds data and emits events. * HTTP transport (SSE broadcasting, request parsing) is handled by * the server-specific adapter (Bun or Pi). */ export function createAnnotationStore(): AnnotationStore { const annotations: T[] = []; const listeners = new Set>(); let version = 0; function emit(event: ExternalAnnotationEvent): void { for (const listener of listeners) { try { listener(event); } catch { // Don't let a failing listener break the store } } } return { add(items) { if (items.length > 0) { for (const item of items) { annotations.push(item); } version++; emit({ type: "add", annotations: items }); } return items; }, remove(id) { const idx = annotations.findIndex((a) => a.id === id); if (idx === -1) return false; annotations.splice(idx, 1); version++; emit({ type: "remove", ids: [id] }); return true; }, update(id, fields) { const idx = annotations.findIndex((a) => a.id === id); if (idx === -1) return null; const merged = { ...annotations[idx], ...fields, id } as T; annotations[idx] = merged; version++; emit({ type: "update", id, annotation: merged }); return merged; }, clearBySource(source) { const before = annotations.length; for (let i = annotations.length - 1; i >= 0; i--) { if (annotations[i].source === source) { annotations.splice(i, 1); } } const removed = before - annotations.length; if (removed > 0) { version++; emit({ type: "clear", source }); } return removed; }, clearAll() { const count = annotations.length; if (count > 0) { annotations.length = 0; version++; emit({ type: "clear" }); } return count; }, getAll() { return [...annotations]; }, get version() { return version; }, onMutation(listener) { listeners.add(listener); return () => { listeners.delete(listener); }; }, }; }