/** * Annotation state on disk + composed agent message. No pi imports — pure node, * so `smoke.mjs` can drive it directly against `dist/`. */ import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; import { createHash, randomUUID } from 'node:crypto'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { annotationsPath, artifactDir, artifactPath, copyFileToClipboard, copyImageToClipboard, copyToClipboard, createGist, isSafeSlug, pdfToFile, readArtifact, screenshotUrl, sourcePath, } from './utils.js'; import { injectAnnotations } from './annotate.js'; export interface TextQuoteAnchor { exact: string; prefix?: string; suffix?: string; } export interface Annotation { id: string; quote?: TextQuoteAnchor; element?: { selector: string; label: string }; intent?: 'comment' | 'keep' | 'question' | 'decision'; decisionId?: string; decisionValues?: string[]; comment: string; createdAt: string; sentAt?: string; reply?: string; } interface Sidecar { version: 1; annotations: Annotation[]; } /** Collapse all whitespace runs to a single space and trim. */ function normalize(s: string): string { return s.replace(/\s+/g, ' ').trim(); } /** Rendering caches and unknown browser fields never cross the persistence boundary. */ function cleanAnnotation(a: Annotation): Annotation { const out: Annotation = { id: a.id, comment: a.comment, createdAt: a.createdAt }; if (a.quote) out.quote = { exact: a.quote.exact, ...(a.quote.prefix !== undefined ? { prefix: a.quote.prefix } : {}), ...(a.quote.suffix !== undefined ? { suffix: a.quote.suffix } : {}), }; if (a.element) out.element = { selector: a.element.selector, label: a.element.label }; if (a.intent !== undefined) out.intent = a.intent; if (a.decisionId !== undefined) out.decisionId = a.decisionId; if (a.decisionValues !== undefined) out.decisionValues = [...a.decisionValues]; if (a.sentAt !== undefined) out.sentAt = a.sentAt; if (a.reply !== undefined) out.reply = a.reply; return out; } /** Validate persisted and browser-provided records before they enter the review flow. */ export function validAnnotation(value: unknown): value is Annotation { if (!value || typeof value !== 'object') return false; const a = value as Record; const text = (v: unknown, max: number) => typeof v === 'string' && v.trim().length > 0 && v.length <= max; if (!text(a.id, 200) || !text(a.comment, 20000) || !text(a.createdAt, 100)) return false; if (a.intent !== undefined && !['comment', 'keep', 'question', 'decision'].includes(String(a.intent))) return false; if (a.quote !== undefined) { if (!a.quote || typeof a.quote !== 'object' || a.element !== undefined) return false; const q = a.quote as Record; if (!text(q.exact, 20000)) return false; if ([q.prefix, q.suffix].some((v) => v !== undefined && (typeof v !== 'string' || v.length > 1000))) return false; } if (a.element !== undefined) { if (!a.element || typeof a.element !== 'object') return false; const e = a.element as Record; if (!text(e.selector, 2000) || !text(e.label, 1000)) return false; } if (a.intent === 'decision' && (!text(a.decisionId, 200) || !Array.isArray(a.decisionValues))) return false; if ( a.decisionValues !== undefined && (!Array.isArray(a.decisionValues) || a.decisionValues.length > 100 || a.decisionValues.some((v) => !text(v, 1000))) ) return false; if (a.decisionId !== undefined && !text(a.decisionId, 200)) return false; if (a.sentAt !== undefined && !text(a.sentAt, 100)) return false; if (a.reply !== undefined && !text(a.reply, 20000)) return false; return true; } /** Read the annotation list for a slug; [] when missing or slug is unsafe. */ export function readAnnotations(slug: string): Annotation[] { if (!isSafeSlug(slug)) return []; const path = annotationsPath(slug); if (!existsSync(path)) return []; try { const parsed = JSON.parse(readFileSync(path, 'utf-8')) as Partial; if (!Array.isArray(parsed.annotations) || !parsed.annotations.every(validAnnotation)) { throw new Error('invalid annotation data'); } return parsed.annotations.map(cleanAnnotation); } catch { throw new Error(`Could not read annotations for ${slug}. The saved file has been left unchanged.`); } } /** Replace the annotation list for a slug. Throws (surfaced as 500) on write failure. */ export function writeAnnotations(slug: string, list: Annotation[]): void { if (!isSafeSlug(slug)) throw new Error(`invalid slug: ${slug}`); if (!list.every(validAnnotation) || new Set(list.map((a) => a.id)).size !== list.length) { throw new Error('invalid annotations'); } const sidecar: Sidecar = { version: 1, annotations: list.map(cleanAnnotation) }; const path = annotationsPath(slug); const temporary = `${path}.${randomUUID()}.tmp`; try { writeFileSync(temporary, JSON.stringify(sidecar, null, 2), 'utf-8'); renameSync(temporary, path); } finally { rmSync(temporary, { force: true }); } } /** Revision tokens prevent an old browser tab from overwriting a newer review. */ export function annotationState(slug: string): { annotations: Annotation[]; revision: string } { const annotations = readAnnotations(slug); return { annotations, revision: createHash('sha256').update(JSON.stringify(annotations)).digest('hex') }; } /** Add an answer to a sent question without touching the artifact itself. */ export function answerQuestion(slug: string, id: string, reply: string): void { if (!reply.trim() || reply.length > 20000) throw new Error('answer must contain 1 to 20000 characters'); const list = readAnnotations(slug); const question = list.find((a) => a.id === id && a.intent === 'question' && a.sentAt); if (!question) throw new Error('no sent question with that annotationId'); question.reply = reply.trim(); writeAnnotations(slug, list); } /** Remove a sidecar explicitly. No-op if absent. */ export function deleteAnnotations(slug: string): void { if (!isSafeSlug(slug)) return; rmSync(annotationsPath(slug), { force: true }); } /** * Inline tags: stripped with NO separator, so a quote spanning `x,` * still matches ("x," not "x ,"). Every other tag is a block boundary → a space. * br/hr are separators, not inline. Mirrors the client seam rule in annotate.ts. */ const INLINE_TAGS = new Set([ 'a', 'abbr', 'b', 'bdi', 'bdo', 'cite', 'code', 'data', 'del', 'em', 'i', 'ins', 'kbd', 'mark', 'q', 's', 'small', 'span', 'strong', 'sub', 'sup', 'time', 'u', 'wbr', ]); /** * Whitespace-normalized visible text of the current artifact, for anchoring * checks. Strips comments/doctype/script/style and all tags, decodes the 5 basic * entities. Naive by design — this checks quote presence, not structure. */ export function artifactText(slug: string): string | null { const html = readArtifact(slug); if (html == null) return null; const stripped = html .replace(//g, ' ') .replace(/]*>/gi, ' ') .replace(/]*>[\s\S]*?<\/script>/gi, ' ') .replace(/]*>[\s\S]*?<\/style>/gi, ' ') .replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\b[^>]*>/g, (_m, tag: string) => INLINE_TAGS.has(tag.toLowerCase()) ? '' : ' ', ); const decoded = stripped .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/&/g, '&'); return normalize(decoded); } /** * Stale = the quote is not findable in the current artifact text. When `exact` * occurs more than once, prefix/suffix (when provided) must match around at * least one occurrence for the anchor to count as found. */ export function isStale(ann: Annotation, text: string): boolean { return ann.quote ? !findAnchor(ann.quote, text) : false; } /** True if the anchor resolves somewhere in the normalized text. */ function findAnchor(quote: TextQuoteAnchor, text: string): boolean { const exact = normalize(quote.exact); if (!exact) return false; const hits: number[] = []; let from = 0; for (;;) { const idx = text.indexOf(exact, from); if (idx === -1) break; hits.push(idx); from = idx + 1; } if (hits.length === 0) return false; const prefix = quote.prefix ? normalize(quote.prefix) : ''; const suffix = quote.suffix ? normalize(quote.suffix) : ''; // Context is captured from a live DOM and is best-effort: it disambiguates // duplicate quotes, but a unique occurrence stands on its own — a context // mismatch there says the capture was noisy, not that the passage is gone. if (hits.length === 1 || (!prefix && !suffix)) return true; return hits.some((idx) => { const before = normalize(text.slice(0, idx)); const after = normalize(text.slice(idx + exact.length)); return (!prefix || before.endsWith(prefix)) && (!suffix || after.startsWith(suffix)); }); } /** * Baked share render: the artifact with its annotations embedded and the layer * in static (read-only) mode. Null when there's nothing to bake — callers fall * back to the clean stored file. */ export function bakeAnnotations(slug: string): { html: string; count: number } | null { const anns = readAnnotations(slug); if (anns.length === 0) return null; const html = readArtifact(slug); if (html == null) return null; const offline = html.replace(/