// XML parser for observer output. // Mirrors claude-mem's src/sdk/parser.ts but adapted for our XML schema. import type { ObservationType } from "../types.js"; export interface ParsedObservation { type: ObservationType; title: string; subtitle: string | null; facts: string[]; narrative: string | null; concepts: string[]; filesRead: string[]; filesModified: string[]; } export interface ParsedSummary { request: string | null; investigated: string | null; learned: string | null; completed: string | null; nextSteps: string | null; notes: string | null; } export type ParseResult = { valid: true; value: T } | { valid: false; reason: string }; const VALID_TYPES = new Set([ "bugfix", "feature", "refactor", "change", "discovery", "decision", ]); const VALID_CONCEPTS = new Set([ "how-it-works", "why-it-exists", "what-changed", "problem-solution", "gotcha", "pattern", "trade-off", ]); function stripCodeFences(text: string): string { const match = text.match(/^\s*```(?:xml)?\s*\n([\s\S]*?)\n```\s*$/i); return match ? match[1] : text; } function extractTag(xml: string, tag: string): string | null { // Match first occurrence, allow self-closing. const re = new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)`, "i"); const m = xml.match(re); if (m) return m[1].trim(); // Self-closing: const selfClose = new RegExp(`<${tag}\\b[^>]*/>`, "i"); if (selfClose.test(xml)) return ""; return null; } function extractList(xml: string, tag: string): string[] { const block = extractTag(xml, tag); if (block === null) return []; const re = new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)`, "gi"); const items: string[] = []; let m: RegExpExecArray | null; while ((m = re.exec(block)) !== null) { const v = m[1].trim(); if (v) items.push(v); } return items; } export function parseObservationXml(raw: string): ParseResult { if (typeof raw !== "string") return { valid: false, reason: "not a string" }; const trimmed = raw.trim(); if (!trimmed) return { valid: true, value: null }; // empty → skip const stripped = stripCodeFences(trimmed); // Allow empty skip summary embedded. if (/^ root tag" }; } const typeRaw = (extractTag(block, "type") ?? "").toLowerCase().trim(); if (!VALID_TYPES.has(typeRaw as ObservationType)) { return { valid: false, reason: `invalid type: ${typeRaw}` }; } const title = (extractTag(block, "title") ?? "").trim(); if (!title) return { valid: false, reason: "missing title" }; const subtitleRaw = extractTag(block, "subtitle"); const subtitle = subtitleRaw && subtitleRaw.trim() ? subtitleRaw.trim() : null; const facts = extractList(block, "fact").slice(0, 5); const narrativeRaw = extractTag(block, "narrative"); const narrative = narrativeRaw && narrativeRaw.trim() ? narrativeRaw.trim() : null; const conceptsRaw = extractList(block, "concept"); const concepts = conceptsRaw .map((c) => c.toLowerCase().trim()) .filter((c) => VALID_CONCEPTS.has(c)) .slice(0, 5); const filesRead = extractList(block, "file").filter((p) => !filesModifiedHasPath(block, p)); const filesModified = extractFilesIn(block, "files_modified"); return { valid: true, value: { type: typeRaw as ObservationType, title, subtitle, facts, narrative, concepts, filesRead, filesModified, }, }; } function filesModifiedHasPath(block: string, _path: string): boolean { // Used to avoid double-counting paths. Currently we assume files_read and files_modified // tags are separate — left as a hook for future de-duplication. return false; } function extractFilesIn(block: string, parentTag: string): string[] { const inner = extractTag(block, parentTag); if (inner === null) return []; return extractList(inner, "file"); } export function parseSummaryXml(raw: string): ParseResult { if (typeof raw !== "string") return { valid: false, reason: "not a string" }; const trimmed = raw.trim(); if (!trimmed) return { valid: false, reason: "empty summary" }; const stripped = stripCodeFences(trimmed); // Allow self-closing empty summary. if (/]*\/>/.test(stripped)) { return { valid: true, value: { request: null, investigated: null, learned: null, completed: null, nextSteps: null, notes: null, }, }; } const block = extractTag(stripped, "summary"); if (block === null) return { valid: false, reason: "no root tag" }; const get = (tag: string): string | null => { const v = extractTag(block, tag); return v && v.trim() ? v.trim() : null; }; return { valid: true, value: { request: get("request"), investigated: get("investigated"), learned: get("learned"), completed: get("completed"), nextSteps: get("next_steps"), notes: get("notes"), }, }; } // Extract any text content from an LLM message. export function extractText(content: unknown): string { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; const parts: string[] = []; for (const part of content) { if (part && typeof part === "object") { const p = part as { type?: string; text?: string }; if (p.type === "text" && typeof p.text === "string") { parts.push(p.text); } } } return parts.join(""); }