diff --git a/apps/docs/.github/CODEOWNERS b/apps/docs/.github/CODEOWNERS new file mode 100644 index 0000000..f476239 --- /dev/null +++ b/apps/docs/.github/CODEOWNERS @@ -0,0 +1 @@ +* @nextconsensus/engineering diff --git a/apps/docs/.github/dependabot.yml b/apps/docs/.github/dependabot.yml new file mode 100644 index 0000000..5a98ffc --- /dev/null +++ b/apps/docs/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "monthly" + commit-message: + prefix: "chore" + open-pull-requests-limit: 10 + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + commit-message: + prefix: "chore" diff --git a/apps/docs/.github/workflows/ci.yml b/apps/docs/.github/workflows/ci.yml new file mode 100644 index 0000000..3aa6654 --- /dev/null +++ b/apps/docs/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run lint + - run: npm test + - run: npm run build diff --git a/apps/docs/.github/workflows/deploy.yml b/apps/docs/.github/workflows/deploy.yml new file mode 100644 index 0000000..bfe7ffe --- /dev/null +++ b/apps/docs/.github/workflows/deploy.yml @@ -0,0 +1,33 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run build + - uses: actions/configure-pages@v6 + - uses: actions/upload-pages-artifact@v5 + with: + path: dist + - id: deploy + uses: actions/deploy-pages@v5 diff --git a/apps/docs/.gitignore b/apps/docs/.gitignore new file mode 100644 index 0000000..edf41fe --- /dev/null +++ b/apps/docs/.gitignore @@ -0,0 +1,24 @@ +node_modules/ +dist/ +.env +.env.local +.DS_Store +opencode.json +/.sisyphus + +# Tooling +.cmux/ +.codex/ +.agent/ +.wrangler/ + +# Tooling +.cmux/ +.codex/ +.agent/ + +# Tooling +.cmux/ +.codex/ +.agent/ +.wrangler/ diff --git a/apps/docs/.husky/pre-commit b/apps/docs/.husky/pre-commit new file mode 100755 index 0000000..2312dc5 --- /dev/null +++ b/apps/docs/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged diff --git a/apps/docs/.lintstagedrc.json b/apps/docs/.lintstagedrc.json new file mode 100644 index 0000000..b14a26d --- /dev/null +++ b/apps/docs/.lintstagedrc.json @@ -0,0 +1,6 @@ +{ + "*.{js,ts,tsx,jsx,mjs,cjs}": [ + "npx biome check --write --no-errors-on-unmatched" + ], + "*.{json,yaml,yml}": ["npx biome check --write --no-errors-on-unmatched"] +} diff --git a/apps/docs/.opencode/commands/build.md b/apps/docs/.opencode/commands/build.md new file mode 100644 index 0000000..f177ba0 --- /dev/null +++ b/apps/docs/.opencode/commands/build.md @@ -0,0 +1,5 @@ +--- +description: "Run the full build" +agent: build +--- +npx biome check .||node build.mjs||vitest run diff --git a/apps/docs/.opencode/commands/check.md b/apps/docs/.opencode/commands/check.md new file mode 100644 index 0000000..8039710 --- /dev/null +++ b/apps/docs/.opencode/commands/check.md @@ -0,0 +1,5 @@ +--- +description: "Run typecheck + lint" +agent: build +--- +npx biome check .||node build.mjs||vitest run diff --git a/apps/docs/.opencode/modes/deploy.md b/apps/docs/.opencode/modes/deploy.md new file mode 100644 index 0000000..1436453 --- /dev/null +++ b/apps/docs/.opencode/modes/deploy.md @@ -0,0 +1,9 @@ +# Deploy Mode + +You are in **deploy mode**. Process: + +1. **Run full QA** — `bun run qa` (typecheck + test + build) +2. **If QA fails** — report failures and stop. Do not deploy. +3. **If QA passes** — run `bun run deploy` +4. **Verify** — confirm the deploy succeeded, note the URL +5. **Report** — what was deployed, commit hash, any notable changes diff --git a/apps/docs/.opencode/modes/refactor.md b/apps/docs/.opencode/modes/refactor.md new file mode 100644 index 0000000..13528aa --- /dev/null +++ b/apps/docs/.opencode/modes/refactor.md @@ -0,0 +1,11 @@ +# Refactor Mode + +You are in **refactoring mode**. Process: + +1. **Read first** — understand the full module before changing anything +2. **Scope tightly** — one concern per refactor, never mix with feature work +3. **Preserve contracts** — public API signatures, exports, and types must not change unless planned +4. **Leverage LSP** — use rename, find references, and diagnostics to validate +5. **Verify after each change** — `bun run typecheck` then `bun run test` + +If the refactor touches 5+ files, stop and explain the plan first. diff --git a/apps/docs/.opencode/modes/review.md b/apps/docs/.opencode/modes/review.md new file mode 100644 index 0000000..0fef67b --- /dev/null +++ b/apps/docs/.opencode/modes/review.md @@ -0,0 +1,11 @@ +# Review Mode + +You are in **code review mode**. Focus on: + +1. **Read the changed files** — understand what the diff achieves +2. **Check for bugs** — edge cases, null safety, error paths, race conditions +3. **Verify patterns** — does the change follow existing codebase conventions? +4. **Security** — no secrets, no injection vectors, proper input validation +5. **Run verification** — `bun run typecheck && bun run test` before approving + +Output a structured review: what's good, what needs changing, and why. Be specific with file paths and line suggestions. diff --git a/apps/docs/AGENTS.md b/apps/docs/AGENTS.md new file mode 100644 index 0000000..182b5e9 --- /dev/null +++ b/apps/docs/AGENTS.md @@ -0,0 +1,73 @@ +# Agent instructions for refract-docs + +Refract-docs is the public documentation site for the Refract project — an open-source deterministic observation engine for public revision histories. + +## Quick Start + +```bash +npm install # Install dependencies +npm run build # Build static HTML — docs/ → dist/ +npm run lint # Biome check +npm run test # Vitest (verifies docs compile) +``` + +## Tooling + +- **Package manager:** npm +- **Linter/Formatter:** Biome +- **Tests:** Vitest +- **Pre-commit:** Husky runs Biome on staged files +- **CI:** ci.yml (main) + deploy.yml (GitHub Pages) +- **Commit style:** Conventional Commits + +## Verification + +```bash +npm run lint # Biome check +npm run test # Build verification +npm run build # Production build +``` + +## Repository structure + +``` +README.md # Landing page +AGENTS.md # This file +docs/ + index.md # Homepage + quickstart.md # Quick start guide + concepts.md # Architecture concepts + schema.md # Event schema reference + cli.md # CLI command reference + sdk.md # SDK / package reference + boundary.md # What Refract is and is not + tutorials/ + wikipedia-history.md # Tutorial: Track Wikipedia changes + fandom-canon.md # Tutorial: Track Fandom canon changes + citation-churn.md # Tutorial: Monitor citation churn + dispute-timeline.md # Tutorial: Build a dispute timeline +``` + +## Documentation principles + +- **Tagline**: "Refract answers: 'What changed?'" +- **Boundary statement**: "Refract does not decide what is true. Refract makes visible how public knowledge changes." +- **Open source framing**: "What changed?" for what Refract does. "Not included: Domain-specific decision judgment." for what Refract excludes. +- **Tone**: technical, neutral, precise. No marketing language. +- **CLI examples**: always use `refract` as the command name (`wikihistory` works as a backward-compat alias). + +## Copy conventions + +- Use `Refract` (capital R) as the project name. Always use `Refract`, never `Varia`. +- Reference packages as `@refract-org/`. +- Reference the CLI as `refract` (`wikihistory` works as a backward-compat alias). +- Use `refract` as the command name in all code examples. +- Describe Refract as a deterministic observation engine. +- L1/L2/L3 references are legacy — prefer "deterministic" / "observation layer". + +## Build + +```bash +node build.mjs # builds static HTML from docs/ → dist/ +npm test # runs build (verifies docs compile without errors) +``` diff --git a/apps/docs/LICENSE b/apps/docs/LICENSE new file mode 100644 index 0000000..2e05cf3 --- /dev/null +++ b/apps/docs/LICENSE @@ -0,0 +1,14 @@ +Creative Commons Attribution 4.0 International (CC BY 4.0) + +https://creativecommons.org/licenses/by/4.0/ + +You are free to: +- Share — copy and redistribute the material in any medium or format +- Adapt — remix, transform, and build upon the material for any purpose, even commercially + +Under the following terms: +- Attribution — You must give appropriate credit, provide a link to the license, + and indicate if changes were made. + +No additional restrictions — You may not apply legal terms or technological +measures that legally restrict others from doing anything the license permits. diff --git a/apps/docs/README.md b/apps/docs/README.md new file mode 100644 index 0000000..88a2cc7 --- /dev/null +++ b/apps/docs/README.md @@ -0,0 +1,36 @@ +# Refract Docs + +Public documentation for [Refract](https://github.com/refract-org/refract), a deterministic observation engine for public revision histories. + +--- + +**Live at:** [refract-org.github.io/refract-docs](https://refract-org.github.io/refract-docs/) + +## Quick start + +```bash +npx @refract-org/cli analyze "Earth" --depth brief +``` + +See [quickstart](https://refract-org.github.io/refract-docs/quickstart/) for a full walkthrough. + +## Packages + +| Package | Status | License | Description | +|---|---|---|---| +| `@refract-org/evidence-graph` | [![npm](https://img.shields.io/npm/v/@refract-org/evidence-graph)](https://www.npmjs.com/package/@refract-org/evidence-graph) | CC0-1.0 | Core types, schemas, BYO-inference boundaries | +| `@refract-org/ingestion` | [![npm](https://img.shields.io/npm/v/@refract-org/ingestion)](https://www.npmjs.com/package/@refract-org/ingestion) | AGPL-3.0 | Wikimedia API adapters | +| `@refract-org/analyzers` | [![npm](https://img.shields.io/npm/v/@refract-org/analyzers)](https://www.npmjs.com/package/@refract-org/analyzers) | AGPL-3.0 | Deterministic analyzers | +| `@refract-org/cli` | [![npm](https://img.shields.io/npm/v/@refract-org/cli)](https://www.npmjs.com/package/@refract-org/cli) | AGPL-3.0 | CLI tool (`refract` / `wikihistory`) | +| `@refract-org/mcp` | [![npm](https://img.shields.io/npm/v/@refract-org/mcp)](https://www.npmjs.com/package/@refract-org/mcp) | AGPL-3.0 | MCP tool definitions and server; the executable is `refract mcp` in `@refract-org/cli` | +| `@refract-org/eval` | [![npm](https://img.shields.io/npm/v/@refract-org/eval)](https://www.npmjs.com/package/@refract-org/eval) | AGPL-3.0 | Evaluation harness | +| `@refract-org/persistence` | Not published | AGPL-3.0 | SQLite storage | +| Observable recipe | — | — | [Copy-paste pattern](./docs/sdk.md#observable-framework-data-loader) for Observable Framework data loaders | + +## Project status + +Refract is actively developed by [NextConsensus](https://nextconsensus.com). As of 2026-09-29 every package above except `@refract-org/persistence` is on npm, and the CLI, 0.5.17, installs and runs. + +## License + +CC-BY-4.0 diff --git a/apps/docs/assets/architecture-flow.svg b/apps/docs/assets/architecture-flow.svg new file mode 100644 index 0000000..a84e38d --- /dev/null +++ b/apps/docs/assets/architecture-flow.svg @@ -0,0 +1,116 @@ + + Refract data flow + + + + + + + + + + + + + + + + + + + + + + + + + + + Refract — Data Flow + Two-Knowledge-Split Architecture + + + + + LAYER 1 — DETERMINISTIC + + + Wikipedia API + + + + + Fetch + Extract + Revisions, diffs, sections, citations + + + Diff + Analyze + Template tracking, revert detection + + + + L1 Invariant: No model calls. Byte-for-byte reproducible. + Output: deterministic_facts[] arrays — evidence events with provenance + + + + Evidence Objects (L1) + + + + evidence events + + + + Report Assembly + L1 facts → layered, timestamped output + + + + report + + + + + LAYER 2 — INDEPENDENT GROUND TRUTH + + + + Validate + Measure + Talk page consensus · RFC closures · ArbCom decisions · Page protection events + + + + L2 Invariant: Independently sourced, timestamped. Never redefined by L1 output. + Stored in @refract-org/eval — compared via validation harness + + + + REFRACT ENGINE + + + + + DOWNSTREAM + + Model-assisted interpretation lives outside Refract + + + + Consumer Interpretation + Pluggable model adapters · BYO-inference · Confidence-scored output + NextConsensus · Fact-checkers · RAG · Monitoring dashboards · Research + + Refract provides the deterministic record. The consumer provides the judgment. + diff --git a/apps/docs/assets/boundary.svg b/apps/docs/assets/boundary.svg new file mode 100644 index 0000000..e7f5e18 --- /dev/null +++ b/apps/docs/assets/boundary.svg @@ -0,0 +1,85 @@ + + Refract observation boundary + + + + + + + + + + + + + + + + + Refract Boundary + What's inside the engine, what's outside + + + + INSIDE REFRACT + + + + MediaWiki revision ingestion, diff computation + → deterministic, byte-reproducible events + + + Claim provenance: first seen, removed, modified, reintroduced + → structured event stream with 26 event types + + + Source lineage, template tracking, revert detection + → auditor: "which analyzer? what version? what inputs?" + + + Ground truth validation: talk page consensus, RFCs, ArbCom + → independently sourced, never redefined by pipeline + + + Eval harness: benchmark pages, precision/recall, baseline + → verifies analyzer accuracy without feedback loops + + + + OUTSIDE REFRACT + + + Truth claims ("this is accurate") + → Outside boundary → downstream + + + Model interpretation (LLM calls, confidence) + → Downstream consumer responsibility + + + Sentiment, editor scoring, toxicity + → Explicitly excluded by design + + + Prediction, forecasting, trend extrapolation + → Observation only, not prediction + + + Healthcare-specific logic, clinical rules + → Private repos, not open-source engine + + + + deterministic + event stream → + + + Refract answers "What changed?" Downstream systems answer "What does it mean?" + diff --git a/apps/docs/assets/byo-inference-bounds.svg b/apps/docs/assets/byo-inference-bounds.svg new file mode 100644 index 0000000..b458e04 --- /dev/null +++ b/apps/docs/assets/byo-inference-bounds.svg @@ -0,0 +1,121 @@ + + Refract inference boundaries + + + + + + + + + + + + + + + + + BYO-Inference Boundary Flow + Every analyzer threshold is a typed function signature — default or model, tracked by provenance + + + + + Extracted Evidence + section_diff, wikitext, citation_refs… + + + + + + + Boundary: Which path? + Model configured? + reflect classify <boundary> --input '...' + + + + yes + + + + no + + + + + Model Path + LLM classifies boundary input + source: "model", confidence: 0.95 + + + + + Mechanical Path (default) + Pure function — word overlap, regex, thresholds + source: "default", deterministic + + + + + + + + + Result + Provenance + + + + + + + <div hljs language-json> + "source": "default" | "model", + "parameters": { "similarityThreshold": 0.85 } + + + PLUGGABLE BOUNDARIES + + + + Sentence Similarity + Default: Word overlap (0.8) + Model: "Same claim?" + + + Revert Detection + Default: 6 regex patterns + Model: "Is this a revert?" + + + Template Classify + Default: Name lookup + Model: "Which policy?" + + + Edit Cluster + Default: Time window + Model: "Related?" + + + Default: Time window + + + + Activity Spike + Default: 3x moving avg + Model: "Meaningful spike?" + + Run: refract classify <boundary> --input '{"...":"..."}' --model gpt-4o-mini + diff --git a/apps/docs/assets/claim-lifecycle.svg b/apps/docs/assets/claim-lifecycle.svg new file mode 100644 index 0000000..ef3e752 --- /dev/null +++ b/apps/docs/assets/claim-lifecycle.svg @@ -0,0 +1,95 @@ + + Claim lifecycle + + + + + + + + + + + + + + Claim Lifecycle Across Revisions + How Refract tracks a single claim from first appearance through removal and reintroduction + + + + TIMELINE (revision order) + + + + Rev 100 + + + Rev 105 + + + Rev 110 + + + Rev 115 + + + Rev 130 + + + + + sentence_first_seen + "Bitcoin is a digital currency..." + + + + + citation_added + Nakamoto, 2008 whitepaper + + + + + sentence_modified + "...decentralized digital currency..." + + + + + sentence_removed + Claim deleted from article + + + + + sentence_reintroduced + Claim restored by later edit + + + + + + + + + + Claim ID: + a3f5c2e1b7d409fa + Identity: + SHA-256("Bitcoin: a digital currency...") → truncated to 16 hex chars + Provenance: + section-differ v0.5.1 — deterministic, no model, byte-reproducible + Every event carries this claimId. + Chronological sort + group by claimId + → full claim lifecycle visible. + diff --git a/apps/docs/assets/concept-overview.svg b/apps/docs/assets/concept-overview.svg new file mode 100644 index 0000000..4423e5c --- /dev/null +++ b/apps/docs/assets/concept-overview.svg @@ -0,0 +1,85 @@ + + Refract concept overview + + + + + + + + + + + + + + + + + + Evidence, not truth. + A deterministic claim-provenance engine for Wikipedia page histories + + + + + + + Input + Wikipedia Page URL + Article title or full URL + + + + + + + + Provenance Engine + + + + L1: Deterministic + + → + + + L2: Model + + → + + + L3 + + Evidence graph, claim lineage, source lineage, policy signals + + + + + + + + Output + Layered Report + Evidence with provenance labels + + + + + + WHAT IT IS NOT + + ✗ A truth detector + ✗ An editor quality judge + ✗ A prediction engine + ✗ A sentiment analyzer + ✗ A Wikipedia monitoring dashboard + diff --git a/apps/docs/assets/docs.js b/apps/docs/assets/docs.js new file mode 100644 index 0000000..307b50c --- /dev/null +++ b/apps/docs/assets/docs.js @@ -0,0 +1,307 @@ +// 1. Scroll Progress Bar fallback +if (!CSS.supports("animation-timeline", "scroll()")) { + const progress = document.querySelector("#progress"); + if (progress) { + window.addEventListener( + "scroll", + () => { + const scrollable = + document.documentElement.scrollHeight - window.innerHeight; + const scrolled = window.scrollY; + const progressPercentage = scrollable > 0 ? scrolled / scrollable : 0; + progress.style.transform = `scaleX(${progressPercentage})`; + }, + { passive: true }, + ); + } +} + +// 2. Scroll-Spy TOC highlighting +const links = document.querySelectorAll(".toc-link"); +const headings = document.querySelectorAll(".content h2, .content h3"); +let activeHeadingId = null; + +if (links.length > 0 && headings.length > 0) { + const observerOptions = { + root: null, + rootMargin: "0px 0px -60% 0px", + threshold: 0, + }; + + const observer = new IntersectionObserver((entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + activeHeadingId = entry.target.id; + updateActiveTocLink(); + } + } + }, observerOptions); + + headings.forEach((h) => { + if (h.id) observer.observe(h); + }); + + function updateActiveTocLink() { + if (!activeHeadingId) return; + links.forEach((link) => { + const href = link.getAttribute("href"); + if (href === `#${activeHeadingId}`) { + link.classList.add("active"); + } else { + link.classList.remove("active"); + } + }); + } + + window.addEventListener( + "scroll", + () => { + let current = ""; + for (const h of headings) { + if (window.scrollY >= h.offsetTop - 120) { + current = h.id; + } + } + if (current && current !== activeHeadingId) { + activeHeadingId = current; + updateActiveTocLink(); + } + }, + { passive: true }, + ); +} + +// 3. Floating Copy Button on Code Blocks +document.querySelectorAll(".content pre").forEach((preBlock) => { + const code = preBlock.querySelector("code"); + if (!code) return; + + const btn = document.createElement("button"); + btn.className = "copy-btn"; + btn.innerHTML = + 'Copy'; + + btn.addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(code.innerText); + btn.classList.add("copied"); + btn.innerHTML = + 'Copied'; + setTimeout(() => { + btn.classList.remove("copied"); + btn.innerHTML = + 'Copy'; + }, 2000); + } catch (err) { + console.error("Failed to copy text: ", err); + } + }); + + preBlock.appendChild(btn); +}); + +// 4. Interactive Search +const backdrop = document.getElementById("search-modal-backdrop"); +const trigger = document.getElementById("search-trigger"); +const closeBtn = document.getElementById("search-modal-close"); +const searchInput = document.getElementById("search-modal-input"); +const resultsContainer = document.getElementById("search-modal-results"); + +let indexLoaded = false; +let searchData = []; +let selectedIndex = -1; +let currentResults = []; + +// Base path retrieved from global config +const BASE = window.REFRACT_BASE || "/refract-docs/"; + +async function loadSearchIndex() { + if (indexLoaded) return; + try { + const resp = await fetch(`${BASE}search-index.json`); + searchData = await resp.json(); + indexLoaded = true; + } catch (err) { + console.error("Failed to load search index:", err); + } +} + +function openSearch() { + if (!backdrop || !searchInput) return; + backdrop.style.display = "flex"; + backdrop.offsetHeight; // Force layout reflow + backdrop.classList.add("open"); + searchInput.focus(); + loadSearchIndex(); + document.body.style.overflow = "hidden"; +} + +function closeSearch() { + if (!backdrop || !searchInput || !resultsContainer) return; + backdrop.classList.remove("open"); + setTimeout(() => { + backdrop.style.display = "none"; + }, 200); + document.body.style.overflow = ""; + searchInput.value = ""; + resultsContainer.innerHTML = + '
Type something to search...
'; + currentResults = []; + selectedIndex = -1; +} + +trigger?.addEventListener("click", openSearch); +closeBtn?.addEventListener("click", closeSearch); +backdrop?.addEventListener("click", (e) => { + if (e.target === backdrop) closeSearch(); +}); + +window.addEventListener("keydown", (e) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + openSearch(); + } else if ( + e.key === "/" && + document.activeElement !== searchInput && + document.activeElement?.tagName !== "INPUT" && + document.activeElement?.tagName !== "TEXTAREA" + ) { + e.preventDefault(); + openSearch(); + } else if (e.key === "Escape" && backdrop?.classList.contains("open")) { + closeSearch(); + } +}); + +searchInput?.addEventListener("input", (e) => { + const query = e.target.value.trim().toLowerCase(); + if (!query) { + resultsContainer.innerHTML = + '
Type something to search...
'; + currentResults = []; + selectedIndex = -1; + return; + } + performSearch(query); +}); + +function performSearch(query) { + if (!resultsContainer) return; + const results = []; + for (const page of searchData) { + const titleMatch = page.title.toLowerCase().includes(query); + const excerptMatch = page.excerpt.toLowerCase().includes(query); + const matchedHeadings = page.headings.filter((h) => + h.text.toLowerCase().includes(query), + ); + + if (titleMatch || excerptMatch || matchedHeadings.length > 0) { + results.push({ + page, + titleMatch, + excerptMatch, + matchedHeadings, + }); + } + } + + if (results.length === 0) { + resultsContainer.innerHTML = `
No results found for "${escapeHtml(query)}"
`; + currentResults = []; + selectedIndex = -1; + return; + } + + currentResults = []; + let html = ""; + for (const res of results) { + html += '
'; + html += `
${escapeHtml(res.page.title)}
`; + + const href = res.page.slug === "index" ? BASE : `${BASE}${res.page.slug}/`; + const itemIndex = currentResults.length; + currentResults.push({ href, title: res.page.title }); + + html += `
`; + html += `
${highlightText(res.page.title, query)}
`; + if (res.page.excerpt) { + html += `
${highlightText(res.page.excerpt, query)}
`; + } + html += "
"; + + for (const h of res.matchedHeadings) { + const hHref = `${href}#${h.id}`; + const hIndex = currentResults.length; + currentResults.push({ + href: hHref, + title: `${res.page.title} > ${h.text}`, + }); + + html += `
`; + html += + '
'; + html += ` # ${highlightText(h.text, query)}`; + html += "
"; + html += "
"; + } + html += "
"; + } + + resultsContainer.innerHTML = html; + selectedIndex = 0; + updateSelection(); + + document.querySelectorAll(".search-result-item").forEach((item) => { + item.addEventListener("click", () => { + const href = item.getAttribute("data-href"); + if (href) window.location.href = href; + }); + }); +} + +function updateSelection() { + const items = document.querySelectorAll(".search-result-item"); + items.forEach((item, idx) => { + if (idx === selectedIndex) { + item.classList.add("selected"); + item.scrollIntoView({ block: "nearest" }); + } else { + item.classList.remove("selected"); + } + }); +} + +function escapeHtml(str) { + return str.replace(/&/g, "&").replace(//g, ">"); +} + +function highlightText(text, query) { + const idx = text.toLowerCase().indexOf(query); + if (idx === -1) return escapeHtml(text); + const before = text.slice(0, idx); + const match = text.slice(idx, idx + query.length); + const after = text.slice(idx + query.length); + return `${escapeHtml(before)}${escapeHtml(match)}${escapeHtml(after)}`; +} + +searchInput?.addEventListener("keydown", (e) => { + if (e.key === "ArrowDown") { + e.preventDefault(); + if (currentResults.length > 0) { + selectedIndex = (selectedIndex + 1) % currentResults.length; + updateSelection(); + } + } else if (e.key === "ArrowUp") { + e.preventDefault(); + if (currentResults.length > 0) { + selectedIndex = + (selectedIndex - 1 + currentResults.length) % currentResults.length; + updateSelection(); + } + } else if (e.key === "Enter") { + e.preventDefault(); + if (selectedIndex >= 0 && selectedIndex < currentResults.length) { + window.location.href = currentResults[selectedIndex].href; + } + } +}); diff --git a/apps/docs/assets/evidence-labels.svg b/apps/docs/assets/evidence-labels.svg new file mode 100644 index 0000000..f21f4eb --- /dev/null +++ b/apps/docs/assets/evidence-labels.svg @@ -0,0 +1,83 @@ + + Evidence labels + + + + + + + + + + + Evidence Label Taxonomy + Every interpretation carries a provenance label + + + + + + ✓ + Observed + Deterministic, byte-for-byte reproducible + Source: L1 deterministic + + L1 + + Reproducible ✓ + + + + + + ⚖ + Policy-coded + Matches known Wikipedia policy signals + Source: L1 + policy ontology + + L1 + + Rules-based ✓ + + + + + + ◉ + Model interpretation + LLM-assisted, with confidence score + Source: L2 LLM + + L2 + + Bounded ✓ + + + + + + ~ + Speculative + Below confidence threshold (<0.5) + Source: L2 low-confidence + + L2 + + Flagged ⚠ + + + + + + ? + Unknown + Insufficient evidence to interpret + Source: N/A + diff --git a/apps/docs/assets/explore-ui.svg b/apps/docs/assets/explore-ui.svg new file mode 100644 index 0000000..fa3c6f8 --- /dev/null +++ b/apps/docs/assets/explore-ui.svg @@ -0,0 +1,111 @@ + + Refract explorer interface + + + + + + + + + + + refract explore — Web UI + Local web server (default port 8899) — timeline, evidence table, diff viewer + + + + + + + + + + Refract Explorer — Bitcoin (330 events) + + + + Timeline + Events + Diff + Claims + Depth: detailed ▼ + Section: all ▼ + Export ▼ + + + + + + + sentence_first_seen + "Bitcoin is a digital currency introduced in 2009" + 2009-03-08 · rev 275832690 + +new + + + citation_added + sourceforge.net/projects/bitcoin/ + 2009-12-10 · rev 308164529 + +src + + + sentence_modified + "...introduced in 2009 by Satoshi Nakamoto" + 2009-12-12 · rev 308180771 + ~chg + + + revert_detected + Content reverted: "decentralized" → "not decentralized" → "decentralized" + 2010-01-18 · rev 312000000 + ↺rv + + + template_added + {{citations needed}} + 2010-02-22 · rev 315000000 + ▸tmpl + + + category_added + Category:Cryptocurrencies + 2010-03-12 · rev 318000000 + +cat + + + + + + + + Selected: sentence_modified + + + + BEFORE (rev 308164529) + Bitcoin is a digital currency introduced + in 2009 as open-source software. + − in 2009 as open-source software. + + + AFTER (rev 308180771) + Bitcoin is a digital currency introduced + in 2009 by Satoshi Nakamoto as + + in 2009 by Satoshi Nakamoto as open-source software. + + + Start the explorer: refract explore "Bitcoin"  |  Opens browser at http://localhost:8899 + diff --git a/apps/docs/assets/mcp-sequence.svg b/apps/docs/assets/mcp-sequence.svg new file mode 100644 index 0000000..fd5efa9 --- /dev/null +++ b/apps/docs/assets/mcp-sequence.svg @@ -0,0 +1,105 @@ + + Refract MCP protocol sequence + + + + + + + + + + + + + + + + + MCP Protocol Flow — refract mcp + stdin/stdout JSON-RPC 2.0 transport + + + + + + + + + AI Agent + + + MCP Client + + + refract mcp + + + + Startup + CLI: refract mcp → starts JSON-RPC server on stdio + Refract announces protocol version "2025-06-18", capabilities: [tools, sampling] + + + + initialize + {"jsonrpc":"2.0","method":"initialize"...} + + + initialized + + + + agent: "list available tools" + + + tools/list + {"jsonrpc":"2.0","method":"tools/list"...} + + + tools response + →"tools":[{name:"analyze"...},{name:"claim"...},...] + + + tools list + + + + agent: "analyze Bitcoin" + + + tools/call + {"jsonrpc":"2.0","method":"tools/call", + "params":{"name":"analyze","arguments": + {"page":"Bitcoin","depth":"detailed"}} + + + + → Wikipedia API + Fetch revisions, run analyzers + + + + tools/call response + → "content":[{"text":"Analysis of Bitcoin", + "type":"text"}], "structuredContent": { + "events": [...evidence events...]}} + + + analysis results + + + + Request + + Response + Transport: stdin/stdout · Format: JSON-RPC 2.0 · Protocol version: 2025-06-18 + diff --git a/apps/docs/assets/merkle-tree.svg b/apps/docs/assets/merkle-tree.svg new file mode 100644 index 0000000..b2c69ab --- /dev/null +++ b/apps/docs/assets/merkle-tree.svg @@ -0,0 +1,133 @@ + + Merkle tree integrity verification + + + + + + + + + + + + + + Merkle Tree Verification + How a replay manifest (--manifest) lets a consumer check the events it received + + + + 1. BUILD (producer) — refract export --manifest + + + + E1 + h1 + + + E2 + h2 + + + E3 + h3 + + + E4 + h4 + + Leaves (SHA-256 of event content) + + + + + + + + + H(h1+h2) + + + H(h3+h4) + + + + + + + ROOT HASH + a1b2c3... + + + + 2. VERIFY (consumer) — independent re-hash + + + + E1 + h1' + + + E2 + h2' + + + E3 + h3' + + + E4 + h4' + + + + + + + + H(h1'+h2') + + + H(h3'+h4') + + + + + + COMPUTED + a1b2c3... + + + + compare → + + + + ✓ VERIFIED + + + + How it works + + 1. Each event's identity fields are hashed with SHA-256, truncated to 16 hex characters → leaf hash + 2. Pairs of hashes are concatenated and re-hashed → internal nodes + 3. Internal nodes are paired and re-hashed recursively → merkleRoot + 4. The root hash is recorded in the ObservationReport or replay manifest + 5. A consumer re-hashes the events they received → computed root + 6. If computed_root == stored_root → the events match those the stored root was computed from + 7. Merkle proofs can verify a single event without the full dataset + + Used for: + — Verification bundles (--proof): the manifest, its events and a Merkle proof per event hash + — Replay manifests (--manifest): lightweight integrity check without re-downloading all events + — ObservationReport.merkleRoot (analyze --report): the same root, carried in the report + diff --git a/apps/docs/assets/observation-report.svg b/apps/docs/assets/observation-report.svg new file mode 100644 index 0000000..3f68d7a --- /dev/null +++ b/apps/docs/assets/observation-report.svg @@ -0,0 +1,86 @@ + + ObservationReport structure + + + + + + + + + + + + + + ObservationReport Structure + Events grouped by claim into a ClaimLedger, with Merkle root for integrity + + + + + Raw EvidenceEvent Stream (26 event types, NDJSON) + + sentence_first_seen + + citation_added + + sentence_modified + + revert_detected + ... + + + + --report flag triggers grouping + + + + + ClaimLedger — grouped by claimId + + + + Claim A: "Bitcoin is a digital currency" + + → sentence_first_seen (rev 100) + + → sentence_modified (rev 110) + + → state: STABLE (present at end) + + + + Claim B: "Bitcoin is anonymous" + + → sentence_first_seen (rev 105) + + → revert_detected (rev 108) + + → state: REMOVED (absent at end) + + + + aggregated into + + + + + ObservationReport + + { + "pageTitle": "Bitcoin", + "observedAt": "2025-05-16T12:00:00Z", + "revisionRange": { "from": 100, "to": 200 }, + "merkleRoot": "a1b2c3d4e5f6...", + "eventCount": 5, + "claims": [ /* ClaimLedger entries */ ] + } + diff --git a/apps/docs/assets/packages.svg b/apps/docs/assets/packages.svg new file mode 100644 index 0000000..c1bcdae --- /dev/null +++ b/apps/docs/assets/packages.svg @@ -0,0 +1,97 @@ + + Refract package dependencies + + + + + + + + + + + + + + + + + Package Dependency Graph + All packages under @refract-org scope. Arrows show runtime dependencies. + + + + + @refract-org/cli + refract / wikihistory commands + Published · AGPL-3.0 + + + + + + + + + + @refract-org/analyzers + Section diff, citation, revert, template + Published · AGPL-3.0 + + + + @refract-org/ingestion + MediaWiki API adapters, Wikidata + Published · AGPL-3.0 + + + + evidence-graph + Core types, schemas + Published · AGPL-3.0 + + + + + + → + ← + + + + + @refract-org/persistence + SQLite cache (bun:sqlite) + Not published + + + + @refract-org/eval + Evaluation harness, benchmarks + Not published + + + + optional + + optional + + + + @refract-org/observable + Not published + + + + Published on npm · AGPL-3.0 + + Internal (not published) + diff --git a/apps/docs/assets/style.css b/apps/docs/assets/style.css new file mode 100644 index 0000000..c86d13e --- /dev/null +++ b/apps/docs/assets/style.css @@ -0,0 +1,948 @@ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --sidebar-w: 260px; + --bg-deep: #07090f; + --bg-surface: #0c0f17; + --bg-elevated: #11151f; + --border: rgba(255, 255, 255, 0.06); + --border-strong: rgba(255, 255, 255, 0.12); + --text: #e2e4ed; + --text-muted: #7c82a0; + --text-dim: #4a4f6a; + --accent: #2dd4bf; + --accent-dim: #0d9488; + --accent-soft: rgba(45, 212, 191, 0.1); + --amber: #f59e0b; + --amber-soft: rgba(245, 158, 11, 0.1); + --code-bg: #0d111c; + --code-text: #e2e4ed; + --radius-sm: 4px; + --radius-md: 8px; + --radius: 12px; + --font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --font-heading: var(--font); + --mono: + ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; +} + +html { + font-size: 16px; + scroll-behavior: smooth; +} + +body { + font-family: var(--font); + color: var(--text); + background: var(--bg-deep); + line-height: 1.7; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.layout { + display: flex; + min-height: 100vh; + position: relative; + z-index: 1; +} + +/* ── Sidebar ── */ +.sidebar { + width: var(--sidebar-w); + min-width: var(--sidebar-w); + background: var(--bg-surface); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + position: fixed; + top: 0; + left: 0; + bottom: 0; + z-index: 40; + overflow-y: auto; + padding: 0; +} + +.sidebar-header { + padding: 28px 24px 20px; + border-bottom: 1px solid var(--border); +} + +.brand { + font-family: var(--font-heading); + font-size: 1.35rem; + font-weight: 700; + color: #fff; + text-decoration: none; + letter-spacing: -0.02em; +} + +.brand:hover { + color: var(--accent); +} + +.tagline { + font-size: 0.8125rem; + color: var(--text-muted); + margin-top: 4px; + line-height: 1.5; +} + +/* ── Navigation ── */ +.sidebar-nav { + flex: 1; + padding: 12px 16px; + display: flex; + flex-direction: column; + gap: 1px; +} + +.nav-section { + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-dim); + padding: 20px 8px 6px; +} + +.nav-section:first-child { + padding-top: 4px; +} + +.nav-link { + display: block; + padding: 7px 10px; + border-radius: var(--radius-sm); + color: var(--text-muted); + text-decoration: none; + font-size: 0.875rem; + font-weight: 400; + transition: + background 0.15s ease, + color 0.15s ease; + margin: 0; +} + +.nav-link:hover { + background: var(--accent-soft); + color: var(--accent); +} + +.nav-link.active { + background: var(--accent-soft); + color: var(--accent); + font-weight: 600; + border-left: 2px solid var(--accent); + padding-left: 8px; +} + +.nav-group { + display: block; +} + +.nav-group .nav-link { + padding-left: 20px; + font-size: 0.8125rem; +} + +/* ── Sidebar Footer ── */ +.sidebar-footer { + margin-top: auto; + padding: 16px 24px 24px; + border-top: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 8px; +} + +.sidebar-link { + color: var(--text-dim); + text-decoration: none; + font-size: 0.8125rem; + font-weight: 400; + transition: color 0.15s ease; +} + +.sidebar-link:hover { + color: var(--accent); +} + +/* ── Content Area ── */ +.content-container { + margin-left: var(--sidebar-w); + flex: 1; + display: flex; + justify-content: space-between; + min-width: 0; + position: relative; +} + +.content { + flex: 1; + min-width: 0; + padding: 48px 48px 96px 64px; + max-width: 820px; + position: relative; +} + +/* ── Mobile Menu ── */ +.menu-toggle { + display: none; +} + +.menu-btn { + display: none; + flex-direction: column; + gap: 5px; + cursor: pointer; + padding: 8px; + margin-bottom: 12px; + width: 36px; +} + +.menu-btn span { + display: block; + height: 2px; + background: var(--text); + border-radius: 2px; + transition: + transform 0.2s, + opacity 0.2s; +} + +.menu-overlay { + display: none; +} + +/* ── Typography ── */ +.content h1 { + font-family: var(--font-heading); + font-size: 2.25rem; + font-weight: 700; + letter-spacing: -0.03em; + margin-bottom: 0.5em; + line-height: 1.2; + color: #f0f2f8; +} + +.content h2 { + font-family: var(--font-heading); + font-size: 1.4rem; + font-weight: 600; + margin: 2.5em 0 0.5em; + padding-bottom: 0.4em; + border-bottom: 1px solid var(--border); + color: #f0f2f8; + letter-spacing: -0.01em; +} + +.content h3 { + font-family: var(--font-heading); + font-size: 1.1rem; + font-weight: 600; + margin: 1.8em 0 0.4em; + color: #f0f2f8; +} + +.content h4 { + font-size: 1rem; + font-weight: 600; + margin: 1.5em 0 0.3em; + color: #f0f2f8; +} + +.content p { + margin: 0.8em 0; + color: var(--text); +} + +.content a { + color: var(--accent); + text-decoration: none; + transition: color 0.15s ease; + border-bottom: 1px solid transparent; +} + +.content a:hover { + color: #5eead4; + border-bottom-color: var(--accent-dim); +} + +.content strong { + font-weight: 600; + color: #f0f2f8; +} + +.content ul, +.content ol { + padding-left: 1.5em; + margin: 0.8em 0; +} +.content li { + margin: 0.25em 0; +} +.content li > p { + margin: 0.2em 0; +} + +.content blockquote { + border-left: 2px solid var(--accent-dim); + margin: 1.2em 0; + padding: 0.75em 1.25em; + color: var(--text-muted); + background: var(--bg-elevated); + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; +} + +.content blockquote p { + margin: 0.3em 0; +} + +.content hr { + border: none; + border-top: 1px solid var(--border); + margin: 2.5em 0; +} + +/* ── Code ── */ +.content code { + font-family: var(--mono); + font-size: 0.85em; + background: var(--code-bg); + color: var(--accent); + padding: 0.15em 0.45em; + border-radius: 4px; + font-weight: 450; + border: 1px solid rgba(45, 212, 191, 0.12); +} + +.content pre { + font-family: var(--mono); + background: var(--code-bg); + color: var(--code-text); + padding: 20px 24px; + border-radius: var(--radius); + border: 1px solid var(--border); + max-width: 100%; + overflow-x: auto; + margin: 1.2em 0; + font-size: 0.85rem; + line-height: 1.7; +} + +.content pre code { + display: block; + min-width: 0; + background: none; + color: inherit; + padding: 0; + font-size: inherit; + font-weight: 400; + border: none; +} + +@media (max-width: 640px) { + .content pre { + padding: 16px; + } + + .content pre code { + overflow-wrap: anywhere; + white-space: pre-wrap; + } +} + +/* ── Tables ── */ +/* build.mjs wraps every table in .table-scroll: a table wider than the + column scrolls inside it instead of widening the page. */ +.content .table-scroll { + max-width: 100%; + overflow-x: auto; + margin: 1.2em 0; +} + +.content table { + width: 100%; + border-collapse: collapse; + margin: 1.2em 0; + font-size: 0.9375rem; +} + +.content .table-scroll > table { + margin: 0; +} + +.content th { + text-align: left; + font-weight: 600; + padding: 10px 14px; + border-bottom: 2px solid var(--border-strong); + color: var(--text-muted); + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.content td { + padding: 10px 14px; + border-bottom: 1px solid var(--border); + vertical-align: top; +} + +.content tr:hover td { + background: rgba(255, 255, 255, 0.02); +} + +/* ── Images ── */ +.content img { + max-width: 100%; + border-radius: var(--radius-md); + border: 1px solid var(--border); +} + +/* ── Hero / Buttons ── */ +.hero-links { + margin: 1.5rem 0; + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +.button { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0.65rem 1.35rem; + border-radius: var(--radius-sm); + text-decoration: none; + font-weight: 600; + font-size: 0.9rem; + transition: + background 0.15s ease, + border-color 0.15s ease, + color 0.15s ease; +} + +.button.primary { + background: var(--accent-dim); + color: #fff; +} + +.button.primary:hover { + background: #0f766e; +} + +.button.secondary { + border: 1px solid var(--border-strong); + color: var(--text-muted); +} + +.button.secondary:hover { + border-color: var(--accent); + color: var(--accent); + background: var(--accent-soft); +} + +/* ── Responsive ── */ +@media (max-width: 768px) { + :root { + --sidebar-w: 240px; + } + + .sidebar { + transform: translateX(-100%); + transition: transform 0.25s ease; + } + + .menu-toggle:checked ~ .layout .sidebar { + transform: translateX(0); + } + + .menu-toggle:checked ~ .layout .menu-overlay { + display: block; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + z-index: 30; + } + + .content-container { + margin-left: 0; + } + + .content { + padding: 24px 20px 64px; + max-width: 100%; + } + + .menu-btn { + display: flex; + } + + .content h1 { + font-size: 1.75rem; + } + .content h2 { + font-size: 1.25rem; + } +} + +/* Hide TOC on screens narrower than 1200px */ +@media (max-width: 1200px) { + .toc-sidebar { + display: none; + } + .content { + padding: 48px 32px 96px; + } +} + +/* ── Theme Compatibility map for Demo Widgets ── */ +:root { + --surface-1: var(--bg-surface); + --line: var(--border); + --line-strong: var(--border-strong); + --ink-soft: var(--text-muted); +} + +/* ── Scroll Progress Bar ── */ +#progress { + position: fixed; + left: 0; + top: 0; + width: 100%; + height: 3px; + background: var(--accent); + z-index: 100; + transform-origin: 0 50%; + transform: scaleX(0); +} + +@media (prefers-reduced-motion: no-preference) { + @supports (animation-timeline: scroll()) { + @keyframes grow-progress { + from { + transform: scaleX(0); + } + to { + transform: scaleX(1); + } + } + #progress { + animation: grow-progress auto linear; + animation-timeline: scroll(); + } + } +} + +/* ── Table of Contents ── */ +.toc-sidebar { + width: 240px; + min-width: 240px; + position: sticky; + top: 0; + height: 100vh; + overflow-y: auto; + padding: 48px 24px 96px 16px; + border-left: 1px solid var(--border); +} + +.toc-title { + font-family: var(--font-heading); + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-dim); + margin-bottom: 12px; +} + +.toc-links { + display: flex; + flex-direction: column; + gap: 6px; +} + +.toc-link { + font-size: 0.8rem; + color: var(--text-muted); + text-decoration: none; + line-height: 1.4; + transition: + color 0.15s ease, + border-color 0.15s ease; + border-left: 1.5px solid transparent; + padding: 2px 0 2px 10px; + display: block; +} + +.toc-link:hover { + color: var(--accent); +} + +.toc-link.active { + color: var(--accent); + font-weight: 500; + border-left-color: var(--accent); +} + +.toc-link.depth-3 { + padding-left: 20px; +} + +/* ── Code Block Floating Copy Button ── */ +.content pre { + position: relative; +} + +.copy-btn { + position: absolute; + top: 8px; + right: 8px; + background: var(--bg-surface); + border: 1px solid var(--border); + color: var(--text-muted); + padding: 4px 8px; + border-radius: var(--radius-sm); + font-family: var(--font); + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + opacity: 0; + transition: + opacity 0.2s ease, + background 0.2s ease, + border-color 0.2s ease, + color 0.2s ease; + z-index: 10; + display: flex; + align-items: center; + gap: 4px; +} + +.content pre:hover .copy-btn { + opacity: 1; +} + +.copy-btn:hover { + background: var(--bg-elevated); + border-color: var(--accent); + color: var(--text); +} + +.copy-btn.copied { + border-color: var(--accent-dim); + color: var(--accent); + background: var(--accent-soft); +} + +/* ── Sidebar Search Trigger ── */ +.sidebar-search { + padding: 12px 16px 8px; +} + +.search-trigger { + width: 100%; + display: flex; + align-items: center; + gap: 10px; + background: var(--code-bg); + border: 1px solid var(--border); + color: var(--text-muted); + padding: 8px 12px; + border-radius: var(--radius-md); + font-family: var(--font); + font-size: 0.8125rem; + text-align: left; + cursor: pointer; + transition: + border-color 0.15s ease, + background 0.15s ease, + color 0.15s ease; + position: relative; +} + +.search-trigger:hover { + border-color: var(--border-strong); + background: var(--bg-elevated); + color: var(--text); +} + +.search-trigger .search-icon { + color: var(--text-dim); + flex-shrink: 0; +} + +.search-trigger:hover .search-icon { + color: var(--text-muted); +} + +.search-trigger .search-shortcut { + position: absolute; + right: 12px; + font-size: 0.65rem; + background: var(--bg-surface); + border: 1px solid var(--border); + padding: 2px 4px; + border-radius: 3px; + color: var(--text-dim); +} + +/* ── Global Search Modal ── */ +.search-modal-backdrop { + position: fixed; + inset: 0; + background: rgba(3, 5, 10, 0.7); + z-index: 1000; + display: none; + justify-content: center; + padding-top: 10vh; + opacity: 0; + transition: opacity 0.2s ease; +} + +.search-modal-backdrop.open { + display: flex; + opacity: 1; +} + +.search-modal { + background: var(--bg-surface); + border: 1px solid var(--border-strong); + width: 90%; + max-width: 580px; + max-height: 70vh; + border-radius: var(--radius); + display: flex; + flex-direction: column; + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6); + overflow: hidden; + transform: translateY(-20px); + transition: transform 0.2s ease; +} + +.search-modal-backdrop.open .search-modal { + transform: translateY(0); +} + +.search-modal-header { + padding: 16px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + gap: 12px; + background: var(--bg-elevated); +} + +.search-modal-input-wrapper { + flex: 1; + display: flex; + align-items: center; + position: relative; +} + +.search-modal-input-wrapper .search-icon { + position: absolute; + left: 0; + color: var(--text-dim); + pointer-events: none; +} + +.search-modal-input { + width: 100%; + background: none; + border: none; + color: var(--text); + font-family: var(--font); + font-size: 1rem; + padding: 6px 12px 6px 28px; +} + +.search-modal-input:focus { + outline: none; +} + +.search-modal-close { + background: none; + color: var(--text-muted); + cursor: pointer; + font-size: 0.8125rem; + padding: 4px 8px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + font-family: var(--font); +} + +.search-modal-close:hover { + color: var(--text); + background: var(--bg-deep); +} + +.search-modal-results { + flex: 1; + overflow-y: auto; + padding: 8px 0; +} + +.search-modal-footer { + padding: 12px 16px; + border-top: 1px solid var(--border); + background: var(--bg-elevated); + display: flex; + gap: 16px; + font-size: 0.7rem; + color: var(--text-dim); +} + +.search-modal-footer kbd { + background: var(--bg-surface); + border: 1px solid var(--border-strong); + padding: 2px 5px; + border-radius: 3px; + font-family: var(--mono); + font-size: 0.65rem; + color: var(--text-muted); +} + +/* ── Features Grid ── */ +.features-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 1.25rem; + margin: 2rem 0; +} + +.feature-card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 1.5rem; +} + +.feature-card h3 { + font-family: var(--font-heading); + font-size: 1rem; + font-weight: 600; + color: #f0f2f8; + margin: 0 0 0.5rem; + border-bottom: none; + padding-bottom: 0; +} + +.feature-card p { + font-size: 0.8125rem; + color: var(--text-muted); + margin: 0; + line-height: 1.5; +} + +/* ── Use Case Grid ── */ +.usecase-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1.25rem; + margin: 2rem 0; +} + +.usecase-card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 1.5rem; + display: flex; + flex-direction: column; + min-height: 100%; +} + +.usecase-card h3 { + font-family: var(--font-heading); + font-size: 1.05rem; + font-weight: 600; + color: #f0f2f8; + margin: 0 0 0.5rem; + border-bottom: none; + padding-bottom: 0; +} + +.usecase-card p { + font-size: 0.8125rem; + color: var(--text-muted); + margin: 0 0 1.25rem; + line-height: 1.5; + flex: 1; +} + +.usecase-steps { + font-size: 0.78rem; + color: var(--text-dim); + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + padding-top: 0.85rem; + border-top: 1px solid var(--border); +} + +.usecase-steps a { + color: var(--accent); + text-decoration: none; + font-weight: 500; + transition: color 0.15s ease; +} + +.usecase-steps a:hover { + color: #5eead4; +} + +/* ── Split Columns ── */ +.split-columns { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1.5rem; + margin: 2rem 0; +} + +.column-card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 1.5rem; +} + +.column-card.focus { + border-left: 3px solid var(--accent); +} + +.column-card.out-of-scope { + border-left: 3px solid var(--amber); +} + +.column-card h4 { + margin: 0 0 1rem; + font-size: 1.05rem; + font-weight: 600; + color: #f0f2f8; +} + +.column-card ul { + margin: 0; + padding-left: 1.25rem; +} + +.column-card li { + margin-bottom: 0.5rem; + font-size: 0.85rem; + color: var(--text-muted); +} + +.column-card li strong { + color: var(--text); +} diff --git a/apps/docs/biome.json b/apps/docs/biome.json new file mode 100644 index 0000000..710e06f --- /dev/null +++ b/apps/docs/biome.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.15/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": ["**", "!!**/dist"] + }, + "formatter": { + "enabled": true, + "indentStyle": "tab" + }, + "linter": { + "enabled": true, + "rules": { + "preset": "recommended" + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/apps/docs/build.mjs b/apps/docs/build.mjs new file mode 100644 index 0000000..63567bb --- /dev/null +++ b/apps/docs/build.mjs @@ -0,0 +1,802 @@ +import { execFileSync } from "node:child_process"; +import { realpathSync } from "node:fs"; +import { cp, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { basename, dirname, join, posix } from "node:path"; +import { fileURLToPath } from "node:url"; +import { marked } from "marked"; +import { CARD_HEIGHT, CARD_WIDTH, renderSocialCard } from "./social-card.mjs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DOCS_DIR = join(__dirname, "docs"); +const DIST_DIR = join(__dirname, "dist"); +const ASSETS_DIR = join(__dirname, "assets"); +const BASE = process.env.BASE || "/refract-docs/"; +// Absolute address of the published site. Canonical URLs, og:url, JSON-LD +// and the sitemap point here even when BASE is overridden for a local build. +const SITE_URL = ( + process.env.SITE_URL || "https://refract-org.github.io/refract-docs/" +).replace(/\/?$/, "/"); +const SITE_NAME = "Refract"; +const SITE_DESCRIPTION = + "A deterministic observation engine for revision histories. It reads a page's edits and emits a typed event for each change."; +const SOURCE_REPOSITORY = "https://github.com/refract-org/refract"; +// One Open Graph card for every page, rendered at build time from the name +// and description above. +const SOCIAL_CARD_FILE = "social-card.png"; +const SOCIAL_CARD_ALT = `${SITE_NAME}. ${SITE_DESCRIPTION}`; + +function assetVersion() { + if (process.env.GITHUB_SHA) return process.env.GITHUB_SHA.slice(0, 12); + try { + return execFileSync("git", ["rev-parse", "--short=12", "HEAD"], { + cwd: __dirname, + encoding: "utf-8", + }) + .trim() + .replace(/[^\w.-]/g, ""); + } catch { + return "dev"; + } +} + +const ASSET_VERSION = assetVersion(); + +let NAV = []; + +function resolveTitle(slug, fallback = basename(slug)) { + for (const item of NAV) { + if (item.slug === slug) return item.title; + if (item.children) { + for (const child of item.children) { + if (child.slug === slug) return child.title; + } + } + } + return fallback; +} + +// GitHub Pages serves dist//index.html at / and 301-redirects the +// slash-less form, so the trailing-slash URL is the one to link and canonicalize. +function slugHref(slug, base = BASE) { + return slug === "index" ? base : `${base}${slug}/`; +} + +// Safe in HTML attribute values and in XML text alike. +function escapeMarkup(text) { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function structuredData(title, description, slug) { + const url = slugHref(slug, SITE_URL); + const website = { + "@type": "WebSite", + "@id": `${SITE_URL}#website`, + name: SITE_NAME, + url: SITE_URL, + }; + if (slug === "index") { + return { + "@context": "https://schema.org", + "@graph": [ + { + ...website, + description, + inLanguage: "en", + about: { "@id": `${SITE_URL}#source` }, + }, + { + "@type": "SoftwareSourceCode", + "@id": `${SITE_URL}#source`, + name: SITE_NAME, + codeRepository: SOURCE_REPOSITORY, + }, + ], + }; + } + return { + "@context": "https://schema.org", + "@graph": [ + { + "@type": "TechArticle", + "@id": `${url}#article`, + headline: title, + description, + url, + mainEntityOfPage: url, + inLanguage: "en", + isPartOf: website, + }, + { + "@type": "BreadcrumbList", + itemListElement: [ + { + "@type": "ListItem", + position: 1, + name: resolveTitle("index"), + item: SITE_URL, + }, + { + "@type": "ListItem", + position: 2, + name: resolveTitle(slug, title), + item: url, + }, + ], + }, + ], + }; +} + +function renderSeoHead(title, description, slug) { + const url = escapeMarkup(slugHref(slug, SITE_URL)); + // "<" is escaped so no title can close the script element early. + const jsonLd = JSON.stringify( + structuredData(title, description, slug), + ).replace(/ + + + + + + + + + + + `; +} + +function renderSitemap(slugs) { + const urls = slugs.map((slug) => slugHref(slug, SITE_URL)).sort(); + const entries = urls + .map((url) => ` ${escapeMarkup(url)}`) + .join("\n"); + return ` + +${entries} + +`; +} + +function renderNav(currentSlug) { + function link(item) { + const isActive = item.slug === currentSlug; + const cls = isActive ? "nav-link active" : "nav-link"; + return `${item.title}`; + } + + let html = ""; + for (const item of NAV) { + if (item.children) { + const parentActive = item.children.some((c) => c.slug === currentSlug); + html += ``; + html += `"; + } else { + html += link(item); + } + } + return html; +} + +function plainText(tokens, skipTypes = []) { + return tokens + .map((token) => { + if (skipTypes.includes(token.type)) return ""; + if (token.tokens) return plainText(token.tokens, skipTypes); + return token.text ?? token.raw ?? ""; + }) + .join(""); +} + +// Inline text of a heading or paragraph with markdown, HTML tags and images +// removed and whitespace collapsed. +function inlineText(tokens) { + return plainText(tokens, ["html", "image"]).replace(/\s+/g, " ").trim(); +} + +function pageTitle(tokens, slug) { + const h1 = tokens.find((t) => t.type === "heading" && t.depth === 1); + return h1 ? inlineText(h1.tokens) : resolveTitle(slug); +} + +const DESCRIPTION_LENGTH = 155; + +function truncateAtWord(text, max) { + if (text.length <= max) return text; + const cut = text.slice(0, max + 1); + const end = cut.lastIndexOf(" "); + return `${cut.slice(0, end > 0 ? end : max).replace(/[\s,;:—–-]+$/, "")}…`; +} + +// A paragraph can stand in for the page when it is prose that ends a sentence. +// That rules out lead-ins to the next block ("Use environment variables +// instead:"), bold-label callouts ("**Note**: …", glossary-style +// "**Term** — …"), parenthetical asides, "See [page]" pointers and images. +function isSummaryParagraph(token, text) { + if (!/[.!?]["'”’)]*$/.test(text)) return false; + if (/^\(.*\)$/.test(text)) return false; + const [first, second] = token.tokens; + if ( + first?.type === "strong" && + second && + (/:$/.test(first.text) || /^\s*[:—–]|^\s+-\s/.test(second.raw)) + ) { + return false; + } + return !(/^See\s/.test(text) && token.tokens.some((t) => t.type === "link")); +} + +// The first prose paragraph after the H1, trimmed for a meta description. +// Only top-level paragraphs count, so lists, tables, code blocks, blockquotes +// and HTML blocks are skipped. Returns "" when the page has none. +function pageSummary(tokens) { + const h1 = tokens.findIndex((t) => t.type === "heading" && t.depth === 1); + for (const token of tokens.slice(h1 + 1)) { + if (token.type !== "paragraph") continue; + const text = inlineText(token.tokens); + if (isSummaryParagraph(token, text)) { + return truncateAtWord(text, DESCRIPTION_LENGTH); + } + } + return ""; +} + +function slugifyHeading(text) { + return text + .toLowerCase() + .trim() + .replace(/[`]/g, "") + .replace(/[^\w\s-]/g, "") + .replace(/\s/g, "-"); +} + +// A title that already leads with the name doesn't repeat it as a suffix. +function documentTitle(title) { + return title.startsWith(SITE_NAME) ? title : `${title} — ${SITE_NAME}`; +} + +function wrapHTML(title, description, content, currentSlug, headings = []) { + let tocHtml = ""; + if (headings.length > 0) { + tocHtml += `
On this page
`; + tocHtml += ``; + } + const hasToc = headings.length > 0; + const tocStyle = hasToc ? "" : ' style="display: none;"'; + + return ` + + + + + ${escapeMarkup(documentTitle(title))} + + + + ${renderSeoHead(title, description, currentSlug)} + + + + +
+ + +
+
+ + ${content} +
+ +
+
+ + +
+
+
+
+ + + + +
+ +
+
+
Type something to search...
+
+ +
+
+ + + +`; +} + +// Resolves a markdown link against the source file's directory. A link to a +// page (`cli.md`, `cli`, `./cli/`, `../index.md`) becomes the page's +// trailing-slash URL, so it never 301-redirects. A path whose last segment has +// a file extension (`.svg`, `.png`, `.ipynb`) is an asset and keeps its form. +export function rewriteLink(href, sourceDir = "") { + if (!href) return href; + if (/^[a-z][a-z\d+.-]*:/i.test(href) || href.startsWith("#")) return href; + + const [, rawPath = "", suffix = ""] = href.match(/^([^?#]*)([?#].*)?$/) ?? []; + if (!rawPath) return href; + + const path = posix + .normalize( + rawPath.startsWith("/") ? rawPath : posix.join("/", sourceDir, rawPath), + ) + .replace(/^\/+|\/+$/g, ""); + if (posix.extname(path) && !path.endsWith(".md")) { + return `${BASE}${path}${suffix}`; + } + const slug = path.replace(/\.md$/, "").replace(/(^|\/)index$/, ""); + return `${slugHref(slug || "index")}${suffix}`; +} + +async function collectFiles(dir, base = "") { + const entries = await readdir(dir, { withFileTypes: true }); + const files = []; + const assets = []; + for (const entry of entries) { + const fp = join(dir, entry.name); + const rel = base ? `${base}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + const res = await collectFiles(fp, rel); + files.push(...res.files); + assets.push(...res.assets); + } else if (entry.name.endsWith(".md")) { + files.push({ path: fp, slug: rel.replace(/\.md$/, "") }); + } else { + assets.push({ path: fp, rel }); + } + } + return { files, assets }; +} + +async function build() { + NAV = JSON.parse(await readFile(join(DOCS_DIR, "nav.json"), "utf-8")); + + await rm(DIST_DIR, { recursive: true, force: true }); + await mkdir(DIST_DIR, { recursive: true }); + + const { files, assets } = await collectFiles(DOCS_DIR); + + const searchIndex = []; + const fallbackPages = []; + const renderer = new marked.Renderer(); + let currentSourceDir = ""; + let currentHeadingCounts = new Map(); + let currentHeadings = []; + + renderer.link = ({ href, title, text }) => { + const h = rewriteLink(href, currentSourceDir); + const t = title ? ` title="${title}"` : ""; + return `${text}`; + }; + renderer.image = ({ href, title, text }) => { + const h = rewriteLink(href, currentSourceDir); + const t = title ? ` title="${title}"` : ""; + return `${text}`; + }; + // A table wider than the column scrolls inside this box, so a narrow + // screen never has to scroll the whole page sideways to read it. + const renderTable = renderer.table; + renderer.table = function (token) { + return `
${renderTable.call(this, token)}
\n`; + }; + renderer.heading = function ({ tokens, depth }) { + const text = this.parser.parseInline(tokens); + const baseSlug = slugifyHeading(plainText(tokens)); + const count = currentHeadingCounts.get(baseSlug) ?? 0; + currentHeadingCounts.set(baseSlug, count + 1); + const slug = count === 0 ? baseSlug : `${baseSlug}-${count}`; + if (depth === 2 || depth === 3) { + currentHeadings.push({ text: plainText(tokens), id: slug, depth }); + } + return `${text}`; + }; + + marked.use({ gfm: true, breaks: false }); + + for (const file of files) { + const raw = await readFile(file.path, "utf-8"); + currentSourceDir = dirname(file.slug); + if (currentSourceDir === ".") currentSourceDir = ""; + currentHeadingCounts = new Map(); + currentHeadings = []; + const body = marked.parse(raw, { renderer }); + const tokens = marked.lexer(raw); + const title = pageTitle(tokens, file.slug); + // The home page keeps the site-wide positioning line. + const summary = file.slug === "index" ? "" : pageSummary(tokens); + if (file.slug !== "index" && !summary) fallbackPages.push(file.slug); + const description = summary || SITE_DESCRIPTION; + const html = wrapHTML(title, description, body, file.slug, currentHeadings); + + if (file.slug === "index") { + await writeFile(join(DIST_DIR, "index.html"), html); + } else { + const outDir = join(DIST_DIR, file.slug); + await mkdir(outDir, { recursive: true }); + await writeFile(join(outDir, "index.html"), html); + } + + searchIndex.push({ + title, + slug: file.slug, + excerpt: description, + headings: currentHeadings.map((h) => ({ text: h.text, id: h.id })), + }); + } + + await writeFile( + join(DIST_DIR, "search-index.json"), + JSON.stringify(searchIndex, null, 2), + ); + // No : the deploy checkout is shallow, so git history cannot date + // individual pages there. + await writeFile( + join(DIST_DIR, "sitemap.xml"), + renderSitemap(files.map((file) => file.slug)), + ); + await writeFile( + join(DIST_DIR, SOCIAL_CARD_FILE), + renderSocialCard({ + name: SITE_NAME, + description: SITE_DESCRIPTION, + address: SITE_URL.replace(/^https?:\/\//, "").replace(/\/$/, ""), + }), + ); + + for (const asset of assets) { + const dest = join(DIST_DIR, asset.rel); + await mkdir(dirname(dest), { recursive: true }); + await cp(asset.path, dest); + } + + await cp(ASSETS_DIR, DIST_DIR, { recursive: true }); + console.log( + `Built ${files.length} pages and copied ${assets.length} assets to dist/`, + ); + if (fallbackPages.length > 0) { + console.log( + `No prose paragraph to describe, so these pages use the site description: ${fallbackPages.sort().join(", ")}`, + ); + } +} + +// Build only when run as a script, so tests can import rewriteLink. Both sides +// are real paths, so a symlinked checkout still builds. +if ( + process.argv[1] && + realpathSync(process.argv[1]) === fileURLToPath(import.meta.url) +) { + build().catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/apps/docs/bun.lock b/apps/docs/bun.lock new file mode 100644 index 0000000..fa9969b --- /dev/null +++ b/apps/docs/bun.lock @@ -0,0 +1,199 @@ +{ + "lockfileVersion": 1, + "configVersion": 0, + "workspaces": { + "": { + "name": "refract-docs", + "devDependencies": { + "@biomejs/biome": "^2.5.8", + "husky": "^9.1.7", + "lint-staged": "^17.0.5", + "marked": "^15.0.0", + "vitest": "^4.1.10", + }, + }, + }, + "packages": { + "@biomejs/biome": ["@biomejs/biome@2.5.15", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.15", "@biomejs/cli-darwin-x64": "2.5.15", "@biomejs/cli-linux-arm64": "2.5.15", "@biomejs/cli-linux-arm64-musl": "2.5.15", "@biomejs/cli-linux-x64": "2.5.15", "@biomejs/cli-linux-x64-musl": "2.5.15", "@biomejs/cli-win32-arm64": "2.5.15", "@biomejs/cli-win32-x64": "2.5.15" }, "bin": { "biome": "bin/biome" } }, "sha512-WZTW4slm/pdkh92K6t/3aEN++44JD1PQ7squ7RCMLI1flHGl43DVOIGXvCjHiZgBMO1V4uYxoFNtqrIA4qGuIQ=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BZVzFhJ/mUvTLMYbc9x6el0o2Uv5zP7bACYyhFb6fSc1giroXm7PL0a5KqMJ9F+/BnkYRjagD/ROSDVOZ6eapg=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-V5Kw63V+fVGNFhFrizDxMbGXAzZCh8kYzJpy3GI6gTapWDg7bZK9oYC9CbVgQSEdTnupz0U5Efz8Ev+5vdBBXw=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-XaG7P7eeSLYETD3K9grfB0mQmpLQZjygdbsxekWBJAg4am79fGAtRpfRyNnTvXB2fZFNnJZG3nXm7HBat0+VUw=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-tGzZUTcJCV7tj5Adh/Gn6nR4MycqA3iohq2LEcrarAgRBkU0h0OKd2UzCRkOsOYOuUGaUJb4YmqRqyy2feGGSw=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.15", "", { "os": "linux", "cpu": "x64" }, "sha512-xE4iEW/3LqlYj9GFgGrFsFSH51dEEpUbYWBfeOv0q87WkUDxK2o/HhreSb7qMJqck70RVM6Lg96hgedcAYcOkA=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.15", "", { "os": "linux", "cpu": "x64" }, "sha512-IlxUcyxilVGPsE008x13pWdkTbU3nQpP2i9b5UrbOYj6goBkKsRX1XB7yJLcV+O1H9LBchIIm4adOaVLZBX0ZQ=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-2kPKzhNlm8C+Ru3GcO0Me2ODkCBLrVOUNuyi84RJyWDVKAA4+Kjj3jMjL938lF6BRfvltC4vB2nPY9SvN51Ovg=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.15", "", { "os": "win32", "cpu": "x64" }, "sha512-yAzh4UqEImV6Hcy0zjUqNfJAsUhoD64FBQCdTG8Md/Z2wQeVP/ZmJ3XRYAs1g8J9fjTFyl2fCtc5FB5til47+g=="], + + "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + + "@oxc-project/types": ["@oxc-project/types@0.132.0", "", {}, "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.2", "", { "os": "android", "cpu": "arm64" }, "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.2", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@vitest/expect": ["@vitest/expect@4.1.11", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.11", "", { "dependencies": { "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.11", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw=="], + + "@vitest/runner": ["@vitest/runner@4.1.11", "", { "dependencies": { "@vitest/utils": "4.1.11", "pathe": "^2.0.3" } }, "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog=="], + + "@vitest/spy": ["@vitest/spy@4.1.11", "", {}, "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA=="], + + "@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "husky": ["husky@9.1.7", "", { "bin": "bin.js" }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], + + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "lint-staged": ["lint-staged@17.6.0", "", { "dependencies": { "picomatch": "^4.0.7", "string-argv": "^0.3.2", "tinyexec": "^1.3.1" }, "optionalDependencies": { "yaml": "^2.9.1" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-nOhBfYkZEwkFtNzD5pK9s9LkcWoxJ+whLzEnhqi9nl7zTB5n7tg8+9iYgMGx2Wm3VlLu8cbNj0RACzY+nWi5iQ=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "marked": ["marked@15.0.12", "", { "bin": "bin/marked.js" }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + + "nanoid": ["nanoid@3.3.12", "", { "bin": "bin/nanoid.cjs" }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + + "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + + "rolldown": ["rolldown@1.0.2", "", { "dependencies": { "@oxc-project/types": "=0.132.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.2", "@rolldown/binding-darwin-arm64": "1.0.2", "@rolldown/binding-darwin-x64": "1.0.2", "@rolldown/binding-freebsd-x64": "1.0.2", "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", "@rolldown/binding-linux-arm64-gnu": "1.0.2", "@rolldown/binding-linux-arm64-musl": "1.0.2", "@rolldown/binding-linux-ppc64-gnu": "1.0.2", "@rolldown/binding-linux-s390x-gnu": "1.0.2", "@rolldown/binding-linux-x64-gnu": "1.0.2", "@rolldown/binding-linux-x64-musl": "1.0.2", "@rolldown/binding-openharmony-arm64": "1.0.2", "@rolldown/binding-wasm32-wasi": "1.0.2", "@rolldown/binding-win32-arm64-msvc": "1.0.2", "@rolldown/binding-win32-x64-msvc": "1.0.2" }, "bin": "bin/cli.mjs" }, "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], + + "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], + + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + + "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "vite": ["vite@8.0.14", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.2", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx"], "bin": "bin/vite.js" }, "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw=="], + + "vitest": ["vitest@4.1.11", "", { "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", "@vitest/pretty-format": "4.1.11", "@vitest/runner": "4.1.11", "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.11", "@vitest/browser-preview": "4.1.11", "@vitest/browser-webdriverio": "4.1.11", "@vitest/coverage-istanbul": "4.1.11", "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": "cli.js" }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="], + + "tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "vite/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + } +} diff --git a/apps/docs/docs/analytics.md b/apps/docs/docs/analytics.md new file mode 100644 index 0000000..4efc3e5 --- /dev/null +++ b/apps/docs/docs/analytics.md @@ -0,0 +1,170 @@ +# Analytics with DuckDB + +Refract's event output is newline-delimited JSON (NDJSON). [DuckDB](https://duckdb.org) +can query it directly with SQL — no loading step, no schema definition, no extensions +needed. + +**Quick start**: Refract ships a [`refract-analytics.sql`](https://github.com/refract-org/refract/blob/main/refract-analytics.sql) file with pre-built views. +Load it and query immediately: + +```bash +duckdb -c ".read refract-analytics.sql" -c "SELECT * FROM contested_claims;" +``` + +Views included: `contested_claims`, `citation_churn_by_month`, `section_activity`, +`event_type_distribution`, `talk_content_ratio`. + +## Install DuckDB + +```bash +brew install duckdb +``` + +Or download from [duckdb.org](https://duckdb.org/docs/installation/). + +## Export events and query + +```bash +# Export events +refract export "Bitcoin" --format ndjson > bitcoin-events.jsonl + +# Quick count +duckdb -c "SELECT count(*) FROM 'bitcoin-events.jsonl'" +``` + +DuckDB's JSON scanner detects column names and types automatically from the first +batch of records. No CREATE TABLE or INSERT step. + +## Event type distribution + +```sql +SELECT "eventType", count(*) as cnt +FROM 'events.jsonl' +GROUP BY "eventType" +ORDER BY cnt DESC; +``` + +Expected output for a typical Wikipedia page at `detailed` depth: + +| "eventType" | cnt | +|------------|-----| +| sentence_modified | 85 | +| citation_added | 34 | +| sentence_first_seen | 28 | +| revert_detected | 15 | +| template_added | 12 | +| ... | ... | + +## Citation churn over time + +```sql +SELECT strftime(timestamp, '%Y-%m') as month, + count(*) FILTER (WHERE "eventType" = 'citation_added') as added, + count(*) FILTER (WHERE "eventType" = 'citation_removed') as removed, + count(*) FILTER (WHERE "eventType" = 'citation_replaced') as replaced +FROM 'bitcoin-events.jsonl' +WHERE "eventType" LIKE 'citation_%' +GROUP BY month +ORDER BY month; +``` + +Months with more removals than additions indicate net citation loss — the page +is losing sources. Months with high replacement counts indicate active source +updating, not necessarily instability. + +## Find the most contested sections + +Contested sections have both reverts and edit clusters: + +```sql +SELECT section, + count(*) FILTER (WHERE "eventType" = 'revert_detected') as reverts, + count(*) FILTER (WHERE "eventType" = 'edit_cluster_detected') as clusters, + count(*) FILTER (WHERE "eventType" LIKE 'sentence_%') as sentence_events, + count(*) as total_events +FROM 'bitcoin-events.jsonl' +GROUP BY section +HAVING reverts > 0 OR clusters > 0 +ORDER BY (reverts + clusters) DESC; +``` + +## Track a specific claim's lifecycle + +```sql +SELECT "eventType", timestamp, section, + before, after +FROM 'bitcoin-events.jsonl' +WHERE after LIKE '%decentralized%' + OR before LIKE '%decentralized%' +ORDER BY timestamp; +``` + +## Section-level timeline + +```sql +SELECT section, timestamp, "eventType", + count(*) OVER (PARTITION BY section ORDER BY to_revision_id) + as cumulative_events +FROM 'bitcoin-events.jsonl' +ORDER BY section, timestamp; +``` + +## Talk page correlation + +Pages with active talk page discussions alongside content changes suggest editorial +deliberation rather than edit-warring: + +```sql +SELECT strftime(timestamp, '%Y-%m-%d') as day, + count(*) FILTER (WHERE "eventType" LIKE 'talk_%') as talk_events, + count(*) FILTER (WHERE "eventType" = 'revert_detected') as reverts +FROM 'bitcoin-events.jsonl' +GROUP BY day +HAVING talk_events > 0 OR reverts > 0 +ORDER BY day; +``` + +## Multiple pages at once + +```bash +# Create a pages file +echo "Bitcoin" > pages.txt +echo "Ethereum" >> pages.txt +echo "Dogecoin" >> pages.txt + +# Batch analyze +refract analyze --pages-file pages.txt --depth detailed -c + +# Export all +refract export "Bitcoin" --format ndjson > events.jsonl +refract export "Ethereum" --format ndjson >> events.jsonl +refract export "Dogecoin" --format ndjson >> events.jsonl +``` + +Then compare across pages: + +```sql +SELECT page_title, + count(*) as events, + count(*) FILTER (WHERE "eventType" = 'revert_detected') as reverts, + count(*) FILTER (WHERE "eventType" LIKE 'citation_%') as citation_churn +FROM 'events.jsonl' +GROUP BY page_title +ORDER BY events DESC; +``` + +## Validation + +```bash +# Count events — should match refract analyze output +duckdb -c "SELECT count(*) FROM 'events.jsonl'" + +# Verify event types are valid +duckdb -c "SELECT DISTINCT \"eventType\" FROM 'events.jsonl' ORDER BY \"eventType\"" + +# Check for empty sections +duckdb -c "SELECT count(*) FROM 'events.jsonl' WHERE section = ''" +``` + +All queries run on DuckDB 1.0+ with no extensions. Refract NDJSON follows the standard +format that DuckDB's JSON scanner reads out of the box. diff --git a/apps/docs/docs/architecture-decisions.md b/apps/docs/docs/architecture-decisions.md new file mode 100644 index 0000000..0a6d258 --- /dev/null +++ b/apps/docs/docs/architecture-decisions.md @@ -0,0 +1,133 @@ +# Architecture decisions + +This page explains the rationale behind key architecture choices in Refract. Knowing +why decisions were made helps contributors understand the boundaries and constraints. + +## Two-knowledge split (not three) + +**Decision**: Two layers — deterministic observation + independent ground truth. +Model interpretation happens downstream, not inside the engine. + +**Why**: Models introduce non-determinism, drift, and vendor dependency. If the +observation layer calls a model, the output is no longer byte-reproducible. If the +ground truth layer calls a model, the "independent" validation becomes circular. +By keeping both layers purely mechanical and placing model interpretation downstream +(in NextConsensus, in consumer applications), Refract preserves its core guarantee: +deterministic, reproducible, auditable output. + +**Alternative considered**: A three-layer architecture (deterministic → model → validation). +Rejected because the model layer would introduce irreproducibility into the engine's +output, making it impossible to audit the pipeline independently. + +## NDJSON as the event format + +**Decision**: Newline-delimited JSON (NDJSON) for all event output. + +**Why**: NDJSON is the lowest-common-denominator format. Every language can parse it +without schema libraries. DuckDB reads it natively. Unix tools (`grep`, `head`, `wc`, +`jq`) can filter and query it. It streams — a million events don't require holding +the full payload in memory. It's human-readable for debugging. There's no schema +registry, no version negotiation overhead. + +**Alternative considered**: Protocol Buffers/Avro/Parquet (faster, smaller, but +require schema libraries and are not human-readable), CSV (loses nesting, can't +represent arrays or nested objects), plain JSON array (must be fully loaded in memory). + +Parquet export is available via `--format parquet` for analytical workloads. + +## SQLite for caching (via bun:sqlite) + +**Decision**: SQLite as the local cache backend, accessed through Bun's built-in +`bun:sqlite` module. + +**Why**: SQLite is zero-config, self-contained, and runs in-process. No server to +manage. No port to open. The cache is a local database (`~/.wikihistory/refract.db`) +that users can delete, copy, or inspect. Bun's `bun:sqlite` module is compiled into +the runtime — no native addons, no node-gyp, no install failures. + +**Alternative considered**: LevelDB (requires C++ toolchain), PostgreSQL/MySQL +(requires external server — breaks zero-config), plain JSON files (no transactional +integrity, slow query performance for large caches). + +## TypeScript via tsc (no bundler) + +**Decision**: TypeScript compiled with `tsc --build` using project references. +No webpack, esbuild, rollup, or vite. + +**Why**: Bun resolves TypeScript directly, so the published packages don't need +bundling — they ship as compiled `.js` + `.d.ts` files. Project references (`tsc -b`) +handle monorepo dependency ordering. No bundler means no sourcemap issues, no chunk +splitting bugs, no plugin compatibility problems. + +## ESM-only + +**Decision**: All packages are `"type": "module"` (ESM). No CommonJS compatibility. + +**Why**: ESM is the future of the Node.js ecosystem. Bun 1.x supports ESM natively. +Maintaining dual CJS/ESM exports doubles the complexity of `package.json` exports, +type resolution, and interop. Refract targets Bun and modern Node.js — both support +ESM without polyfills. + +## Bun as the primary runtime + +**Decision**: Bun is the required runtime for development, testing, and building. +Node.js 20+ is supported for running the CLI and importing packages. + +**Why**: Bun ships with a fast package manager (`bun install`), a built-in SQLite +binding (`bun:sqlite`), native TypeScript support (no `ts-node`, no `tsx` needed for +scripts), and faster test execution than Jest or Mocha. The persistence layer depends +on `bun:sqlite`, which is only available in Bun. + +**Alternative considered**: Node.js with `better-sqlite3` (requires native addon +compilation, `node-gyp`, and OS-specific build tools — breaks zero-config), +Node.js with `sql.js` (WebAssembly SQLite, slower and more complex). + +## AGPL-3.0 license + +**Decision**: Affero GPL v3 for all published packages. + +**Why**: AGPL requires anyone who modifies the software and deploys it as a network +service to release their modifications. This prevents a cloud provider from taking +the open-source engine, adding proprietary improvements, and offering a competing +service without contributing back. It also creates a natural commercial licensing +path — enterprises that can't accept AGPL obligations can purchase a commercial license. + +**Alternative considered**: MIT (too permissive — allows proprietary forks with no +contribution back, undermines the open-core business model), Apache 2.0 (no network +service copyleft), GPLv3 (doesn't cover SaaS/network use — AGPL closes this gap). + +## No model in the pipeline + +**Decision**: The deterministic pipeline never calls a model. No LLM, no ML, no +randomness. Pure functions of wikitext. + +**Why**: Reproducibility is the foundation. If the engine calls a model, the same +revision range produces different output on different runs (different model versions, +different hyperparameters, model deprecation). This makes the output non-auditable. +Deterministic analyzers (regex, word overlap, structural parsing) produce identical +output every time. The model-only boundaries (BYO-inference) are explicit and auditable: +every event records whether it used the default heuristic or a model, and with what +parameters. + +## BYO-inference boundaries (not pluggable model layer) + +**Decision**: Configurable thresholds at specific analyzer boundaries, not a +general-purpose model adapter layer. + +**Why**: A generic "model adapter" would encourage shipping model calls as part of +the core pipeline. By keeping each boundary as a typed function signature with a +mechanical default, Refract forces every model call to be explicit: "I am replacing +the revert detector heuristic with a model call, and this will be recorded." This +prevents model calls from entering the pipeline unnoticed. + +## Synthetic benchmark data + +**Decision**: The eval harness uses synthetic revision histories (Xanoleptin pages), +not real Wikipedia data, for benchmarking. + +**Why**: Real Wikipedia data has no ground truth — we can't know whether a revert +should have been detected or a citation change should have been classified as +"replaced." Synthetic data gives us controlled, hand-labeled ground truth for every +event. It also avoids licensing and attribution issues when publishing benchmark data. + +The synthetic pages are available in [refract-demo-data](https://github.com/refract-org/refract-demo-data). diff --git a/apps/docs/docs/assets/refract-docs-demo.gif b/apps/docs/docs/assets/refract-docs-demo.gif new file mode 100644 index 0000000..bc337e0 Binary files /dev/null and b/apps/docs/docs/assets/refract-docs-demo.gif differ diff --git a/apps/docs/docs/assets/refract-ui-diff.png b/apps/docs/docs/assets/refract-ui-diff.png new file mode 100644 index 0000000..908a566 Binary files /dev/null and b/apps/docs/docs/assets/refract-ui-diff.png differ diff --git a/apps/docs/docs/assets/refract-ui-screenshot.png b/apps/docs/docs/assets/refract-ui-screenshot.png new file mode 100644 index 0000000..c89d29a Binary files /dev/null and b/apps/docs/docs/assets/refract-ui-screenshot.png differ diff --git a/apps/docs/docs/boundary.md b/apps/docs/docs/boundary.md new file mode 100644 index 0000000..0040838 --- /dev/null +++ b/apps/docs/docs/boundary.md @@ -0,0 +1,30 @@ +# Boundary + +**Refract does not decide what is true. Refract makes visible how public knowledge changes.** + +![Refract boundary diagram](boundary.svg) + +## What Refract is + +- A deterministic observation engine +- An audit trail for revision histories +- A framework for extracting structured evidence from unstructured diffs +- A platform for evaluating model-assisted interpretations against ground truth + +## What Refract is not + +- A fact-checker or truth-verification system +- A moderation or content policing tool +- A predictive model for future edits +- A judgment engine for healthcare, finance, or legal decisions +- A replacement for domain expertise + +## Open source + +**What changed?** — Everything Refract produces is auditable, reproducible, and inspectable. + +**Not included** — Domain-specific decision judgment. Refract observes change. It does not tell you what to do about it. + +## The line + +Refract stops at the boundary between observability and judgment. If a tool needs to decide whether a change is *good*, *bad*, *true*, or *false*, that tool lives outside Refract. Refract answers only: "What changed?" diff --git a/apps/docs/docs/bundle-manifest.md b/apps/docs/docs/bundle-manifest.md new file mode 100644 index 0000000..44e6f72 --- /dev/null +++ b/apps/docs/docs/bundle-manifest.md @@ -0,0 +1,106 @@ +# Export formats: bundles and manifests + +## Evidence bundle (`--bundle`) + +An evidence bundle contains the input revisions and output events for a page observation, with a SHA-256 hash (`bundleHash`) of the rest of the bundle. The hash is not a signature: it catches a copy that was damaged or edited without recomputing it, but anyone who edits the bundle can recompute it. To detect deliberate edits, send or publish the hash separately from the file. + +```bash +refract export "Earth" --bundle > earth-bundle.json +``` + +```json +{ + "format": "refract-evidence-bundle/v1", + "generatedAt": "2025-05-15T10:00:00.000Z", + "pageTitle": "Earth", + "revisionRange": { "from": 1289400000, "to": 1290100000 }, + "inputRevisions": [ ... ], + "outputEvents": [ ... ], + "bundleHash": "a1b2c3d4e5f6..." +} +``` + +Use bundles to pass events together with the revisions they were derived from. + +## Replay manifest (`--manifest`) + +A replay manifest is a Merkle tree of event hashes that lets you verify the exact set of events without sending the full payload. Each event's hash is a leaf in the tree; the root hash represents the complete observation. + +![Merkle tree verification](merkle-tree.svg) + +```bash +refract export "Earth" --manifest > earth-manifest.json +``` + +```json +{ + "format": "refract-replay-manifest/v1", + "generatedAt": "2025-05-15T10:00:00.000Z", + "pageTitle": "Earth", + "analyzerVersions": { "refract": "0.5.17" }, + "inputRevisionHashes": [ ... ], + "outputEventHashes": [ "0737234eb11ab883", ... ], + "merkleRoot": "abc...", + "manifestHash": "def..." +} +``` + +Each event hash is the event's `createEventIdentity` (or its `eventId`, if it carries one): the first 16 hex characters of a SHA-256 over its type, revision IDs, section, before and after text, timestamp and facts. The same Refract version over the same revision range (`--from`, `--to`) gives the same event hashes and the same `merkleRoot`; `manifestHash` also covers `generatedAt`, so it differs between runs. + +Use manifests when you need lightweight integrity verification — for example, checking whether an observation has changed without re-downloading all events. The `@refract-org/evidence-graph` package exports `createReplayManifest`, `buildMerkleTree`, `getMerkleProof`, and `verifyMerkleProof` for programmatic use. + +## Verification bundle with Merkle proofs (`--proof`) + +A verification bundle puts three things in one JSON file: + +1. The replay manifest (input revision hashes, analyzer versions, event hashes, their Merkle root, and the manifest hash). +2. The events. +3. A Merkle inclusion proof for each event hash: the leaf hash, the sibling hashes up the tree, and the root they produce. + +```bash +refract export "Earth" --proof > earth-proof.json +``` + +```json +{ + "format": "refract-verification-bundle/v1", + "exportedAt": "2026-09-26T00:00:00Z", + "manifest": { + "pageTitle": "Earth", + "merkleRoot": "a3b4c5...", + "manifestHash": "d6e7f8...", + "inputRevisionHashes": [ ... ], + "outputEventHashes": [ ... ] + }, + "events": [ ... ], + "proofs": [ + { + "leafHash": "e1...", + "leafIndex": 0, + "siblings": [ ... ], + "rootHash": "a3b4c5..." + } + ] +} +``` + +### Checking a bundle + +`refract verify` checks a bundle offline: + +```bash +refract verify earth-proof.json + +# Also write the result as an HTML file +refract verify earth-proof.json --html receipt.html +``` + +It recomputes the manifest hash and recomputes the Merkle root from the manifest's event hashes. It rehashes each event the way the export did and compares the result with the hash listed at the event's index; if an event carries an `eventId`, its content must hash to that ID. It checks that each listed hash has one proof, for that hash and index, that hashes up to the manifest's Merkle root. It reports each failure with its index and exits 1 if any check fails. `receipt.html` is a single HTML file with the result, the manifest's hashes, and a row per event showing its hash check and its proof check. + +Up to 0.5.17, `verify` did not rehash the events or compare a proof's root with the manifest's, so a bundle with an edited event, a missing proof, or a proof ending at another root passed. + +What it does not check: + +- Event fields outside the event hash: `layer`, `claimId`, `schemaVersion`, the semantic enrichment fields (`editMagnitude`, `contentChange`, `keyTerms`, `certaintyProfile`, `directionSignal`, `quantitativeFindings`), each fact's `provenance` and `sourceSpan`, and `modelInterpretation`. Edits to them pass. +- That the events are what Refract derives from the page's revisions. The bundle holds only the revisions' hashes. +- Who made the bundle. Nothing in it is signed, and anyone who edits it can recompute every hash. A pass shows the bundle is internally consistent. To detect edits, compare its Merkle root with one you received separately, or re-run the same Refract version over the same revision range and compare roots. diff --git a/apps/docs/docs/cli.md b/apps/docs/docs/cli.md new file mode 100644 index 0000000..270b91a --- /dev/null +++ b/apps/docs/docs/cli.md @@ -0,0 +1,341 @@ +# CLI command reference + +All commands work with `wikihistory` as a backward-compatible alias (e.g., `wikihistory analyze`). + +## `refract init` + +Onboarding — runs a quick analysis and shows what Refract can do. + +```bash +refract init +``` + +Fetches recent revisions of "Earth," runs the section differ, and prints next steps: +analyze, explore, claim, snapshot, monitor, mcp. No flags needed — a zero-config +first experience. + +## `refract analyze` + +Analyze full edit history of a page. + +```bash +refract analyze [page] [options] +``` + +| Flag | Default | Description | +|---|---|---| +| `page` | required (positional) | Page title | +| `-d, --depth ` | `detailed` | Analysis depth: `brief`, `detailed`, `forensic`. See [depth levels](depth.md). | +| `--from ` | — | Start revision ID | +| `--to ` | — | End revision ID | +| `--since ` | — | Analyze revisions from this ISO timestamp. The first revision in the window is not diffed against the one before it. | +| `-j, --json` | off | Print events as JSON, one per line (NDJSON), instead of the interactive view | +| `--brief-limit ` | `20` | Revisions read at `brief` depth: the latest N | +| `-c, --cache` | off | Cache revisions in SQLite (`~/.wikihistory/refract.db`). Needs Bun and `@refract-org/persistence`, which is only available from a source checkout. | +| `--pages-file ` | — | Batch file of page titles (one per line). Prints a total, not the events. | +| `--batch-concurrency ` | `4` | Pages analyzed at once with `--pages-file` | +| `--api ` | `en.wikipedia.org` | MediaWiki API base URL | +| `--cache-dir ` | `~/.wikihistory` | Cache directory path | +| `-r, --report` | off | Output `ObservationReport` instead of raw events | +| `--config ` | — | JSON file with analyzer config overrides | +| `--similarity ` | `0.8` | Sentence matching threshold (0–1) | +| `--revert-patterns ` | — | File of revert regex patterns | +| `--cluster-window ` | `60` | Edit cluster window in minutes | +| `--spike-factor ` | `3` | Talk activity spike multiplier | +| `--talk-window ` | `7/3` | Talk correlation window as "before/after" in days | +| `--section-rename ` | `similarity` | Section rename detection: `exact`\|`similarity`\|`none` | + +## `refract claim` + +Track a specific claim across revisions. + +```bash +refract claim [options] +``` + +| Flag | Default | Description | +|---|---|---| +| `page` | required (positional) | Page title | +| `-t, --text ` | required | Claim text to track | +| `--limit ` | `50` | Revisions read: the latest N | +| `-c, --cache` | off | Cache revisions in SQLite | +| `--api ` | `en.wikipedia.org` | MediaWiki API base URL | + +## `refract snapshot` + +Reconstruct page state at a point in time. + +```bash +refract snapshot [options] +``` + +| Flag | Default | Description | +|---|---|---| +| `page` | required (positional) | Page title | +| `--at ` | today | Target date (ISO 8601, e.g. `2024-01-15`). Finds the closest revision at or before this date. | +| `-c, --cache` | off | Cache revisions in SQLite | +| `--api ` | `en.wikipedia.org` | MediaWiki API base URL | + +Displays section list, plain text content, and metadata (revision ID, editor, size, +edit comment). Content is truncated at 2000 characters. + +## `refract cron` + +One-shot re-observation for cron scheduling. + +```bash +refract cron [options] +``` + +| Flag | Default | Description | +|---|---|---| +| `pages-file` | required (positional) | Batch file of page titles (one per line) | +| `-i, --interval ` | from last observation | Lookback window in hours | +| `--notify-slack` | off | Send Slack notification on changes | +| `--notify-email` | off | Send email notification on changes | +| `--notify-webhook ` | — | Send generic webhook POST on changes | +| `--cache-dir ` | `~/.wikihistory` | Where each page's previous observation is kept; persist it between runs | +| `--api ` | `en.wikipedia.org` | MediaWiki API base URL | + +Prints a per-page report and `Total new events: N`, and exits 1 when N > 0 — or on any +error. See [scheduled monitoring](cron.md) for the comparison, state and scheduling. + +## `refract diff` + +Cross-wiki comparison (2+ wikis). + +```bash +refract diff [options] +``` + +| Flag | Default | Description | +|---|---|---| +| `topic` | required (positional) | Page title to compare across wikis | +| `--wiki-a ` | required | First wiki API URL | +| `--wiki-b ` | required | Second wiki API URL | +| `--wiki-c ` | optional | Third wiki API URL | +| `-d, --depth ` | `detailed` | Analysis depth. See [depth levels](depth.md) | + +## `refract eval` + +Run the evaluation harness. + +```bash +refract eval [options] +``` + +| Flag | Default | Description | +|---|---|---| +| `--page ` | — | Run only for a specific page | +| `--ground-truth <path\|builtin>` | — | Validate against ground truth labels | + +## `refract classify` + +Classify a single observation boundary using an inference provider. + +```bash +refract classify <boundary> --input '<json>' +``` + +| Boundary | What it decides | Default | Model question | +|----------|----------------|---------|----------------| +| `revert` | Is this edit comment a revert? | 6 regex patterns | "Does this comment indicate a revert?" | +| `sentence_similarity` | Are these two sentences the same claim? | Word-overlap ratio (0.8) | "Are these the same claim?" | +| `heuristic` | What kind of edit is this? | Size thresholds + comment patterns | "Classify this edit by type" | +| `template_signal` | What policy signal does this template represent? | Name-to-type lookup | "What signal does this template represent?" | +| `activity_spike` | Is this day's talk activity meaningful? | 3x moving average | "Is this a meaningful spike?" | + +| Flag | Default | Description | +|------|---------|-------------| +| `--input <json>` | — | Input data as JSON string | +| `--api-key <key>` | env `REFRACT_INFERENCE_API_KEY` | API key for inference provider | +| `--endpoint <url>` | env `REFRACT_INFERENCE_ENDPOINT` | OpenAI-compatible endpoint (OpenAI, DeepSeek, Ollama, etc.) | +| `--model <name>` | `gpt-4o-mini` | Model name | + +Each boundary's default is the mechanical heuristic. Pass an API key or set `REFRACT_INFERENCE_API_KEY` to call a model instead. Works with any OpenAI-compatible provider — DeepSeek, Ollama, Anthropic, Together, Anyscale, or local servers. + +```bash +# OpenAI +refract classify revert --input '{"comment":"reverted vandalism"}' + +# DeepSeek +refract classify revert --input '{"comment":"..."}' \ + --endpoint https://api.deepseek.com/v1/chat/completions \ + --model deepseek-chat + +# Local Ollama +refract classify revert --input '{"comment":"..."}' \ + --endpoint http://localhost:11434/v1/chat/completions \ + --model llama3 +``` + +The output includes `source: "model"` or `"default"` for auditability. + +## `refract export` + +Export analysis as structured data. + +```bash +refract export <page> [options] +``` + +| Flag | Default | Description | +|---|---|---| +| `page` | required (positional) | Page title | +| `-f, --format <fmt>` | `json` | Output format: `json`, `csv`, `ndjson`, `html`, `parquet` | +| `--bundle` | off | Export as evidence bundle with a SHA-256 hash of its contents (not signed). See [bundle format](bundle-manifest.md). | +| `--manifest` | off | Export as replay manifest with all hashes. See [manifest format](bundle-manifest.md). | +| `--proof` | off | Export as self-contained verification bundle with Merkle inclusion proofs. | +| `--flatten` | off | Flatten nested fields into columns (`csv`) | +| `--similarity <n>` | `0.8` | Sentence matching threshold (0–1) | +| `--api <url>` | `en.wikipedia.org` | MediaWiki API base URL | + +`export` analyzes at `detailed` depth: the page's full history, or the range that `--from`, `--to` or `--since` selects. With no events it +writes nothing to stdout (the notice goes to stderr), so an empty NDJSON file stays +empty. + +## `refract verify` + +Check a verification bundle's events, manifest hash, Merkle root and inclusion proofs, and optionally write the result as an HTML file. It rehashes each event and checks each proof against the manifest's root, which 0.5.17 and earlier did not. Nothing in a bundle is signed; see [what it checks](bundle-manifest.md#checking-a-bundle). + +```bash +refract verify <bundle.json> [options] +``` + +| Flag | Default | Description | +|---|---|---| +| `bundlePath` | required (positional) | Path to verification bundle JSON file | +| `--html <outPath>` | — | Also write the result as an HTML file | + +```bash +refract verify earth-proof.json +refract verify earth-proof.json --html receipt.html +``` + +## `refract delegation` + +Write [STD-07](https://ethotechnics.org/standards/std-07-revisable-delegation-record) +discrepancy records for another system to read. Refract does not decide which change +matters: the operator states it, and none of the three required options has a default. + +```bash +refract delegation <page> --subject <id> --expected <clause> --when <rule> [options] +``` + +| Flag | Default | Description | +|---|---|---| +| `page` | required (positional) | Page title | +| `--subject <id>` | required | What the receiving system calls the thing this is about | +| `--expected <clause>` | required | The assumption these changes would violate | +| `--when <rule>` | required | Which events count: `weakening`, `strengthening`, `direction-changed`, `citation-removed`, `any` | +| `-s, --section <name>` | — | Only events in this section | +| `-o, --out <file>` | stdout | Write the records to a file | + +Exits 2 on missing or invalid input. + +## `refract explore` + +Start a local web explorer with timeline, evidence table, and diff viewer. + +![refract explore web UI](explore-ui.svg) + +```bash +refract explore <page> [options] +``` + +| Flag | Default | Description | +|---|---|---| +| `page` | required (positional) | Page title | +| `-p, --port <n>` | `8899` | Server port | +| `--no-open` | off | Don't open browser automatically | +| `-r, --report` | off | Output `ObservationReport` instead of raw events | +| `--similarity <n>` | `0.8` | Sentence matching threshold (0–1) | +| `--config <path>` | — | JSON file with analyzer config overrides | +| `--api <url>` | `en.wikipedia.org` | MediaWiki API base URL | + +## `refract visualize` + +Export evidence graph as a diagram. + +```bash +refract visualize <page> [options] +``` + +| Flag | Default | Description | +|---|---|---| +| `page` | required (positional) | Page title | +| `-f, --format <fmt>` | `mermaid` | Output format: `mermaid`, `dot` | +| `--all` | off | Show all event types (default: claim events only) | +| `--api <url>` | `en.wikipedia.org` | MediaWiki API base URL | + +## `refract watch` + +Live polling daemon for new edits. + +```bash +refract watch <page> [options] +``` + +| Flag | Default | Description | +|---|---|---| +| `page` | required (positional) | Page title | +| `-s, --section <name>` | — | Watch a specific section only | +| `-i, --interval <ms>` | `60000` | Poll interval in milliseconds | +| `--api <url>` | `en.wikipedia.org` | MediaWiki API base URL | + +## `refract mcp` + +Start MCP server for AI agent integration. + +```bash +refract mcp +``` + +## `refract stream` + +Live stream of Wikipedia edits via EventStreams. Connects to Wikimedia's real-time +edit feed and prints events as they happen. + +```bash +refract stream [page] [options] +``` + +| Flag | Default | Description | +|---|---|---| +| `page` | optional | Page title to watch. Omit to stream all edits. | +| `--wiki <wiki>` | `enwiki` | Wiki to stream (e.g., `enwiki`, `dewiki`, `frwiki`) | + +Filters out bot edits and non-main-namespace changes automatically. Press Ctrl+C +to stop. + +```bash +# Watch all edits on English Wikipedia +refract stream + +# Watch a specific page +refract stream "COVID-19" + +# Watch German Wikipedia +refract stream "COVID-19" --wiki dewiki +``` + +The stream provides revision metadata (ID, user, comment, timestamp) but not full +revision content. Use `refract analyze` for full content analysis. + +## Global options + +These options apply to most commands: + +| Flag | Description | +|---|---| +| `--api <url>` | MediaWiki API base URL (default: `en.wikipedia.org`) | +| `--cache-dir <path>` | Cache directory path (default: `~/.wikihistory`) | +| `--api-key <token>` | API key for bearer token auth | +| `--api-user <user>` | Username for basic auth | +| `--api-password <pass>` | Password for basic auth | + +Client-credential headers (`X-OAuth-Client-Id`, `X-OAuth-Client-Secret`) come from the +`REFRACT_OAUTH_CLIENT_ID` and `REFRACT_OAUTH_CLIENT_SECRET` environment variables +(0.5.17+). Up to 0.5.7 they were read from `OAUTH_CLIENT_ID` and +`OAUTH_CLIENT_SECRET` and sent to whatever wiki was queried, Wikipedia included; do +not set those names for Refract. diff --git a/apps/docs/docs/compare.md b/apps/docs/docs/compare.md new file mode 100644 index 0000000..41024d1 --- /dev/null +++ b/apps/docs/docs/compare.md @@ -0,0 +1,89 @@ +# Wikipedia history tools compared + +This page compares tools for reading a Wikipedia article's history: the built-in page history, XTools, WikiBlame, Who Wrote That? and Refract. + +Each answers a different question: + +- the page history lists every edit with its editor, timestamp, edit summary and diff; +- XTools reports statistics about a page's edits and each editor's share of the current text; +- WikiBlame finds the revision in which a given text was inserted or removed; +- Who Wrote That? highlights who wrote each part of the current text; +- Refract reports each change between revisions as a typed event (sentences, citations, templates, reverts, sections), without editor identity. + +## Wikipedia's page history + +Sources: [Help:Page history](https://en.wikipedia.org/wiki/Help:Page_history), +[Manual:Reverts](https://www.mediawiki.org/wiki/Manual:Reverts), +[API:Revisions](https://www.mediawiki.org/wiki/API:Revisions), +[Help:Watchlist](https://en.wikipedia.org/wiki/Help:Watchlist). + +| Capability | Page history | Refract | +|---|---|---| +| **View a single revision diff** | Yes — click "prev" on any revision | Yes — every event carries `before`/`after` snapshots | +| **See who edited what** | Yes — username + timestamp per revision | No — Refract observes document change, not editor identity | +| **Find when a sentence first appeared** | Not built in — on English Wikipedia, the history page's "Find addition/removal" link opens WikiBlame | `refract claim "Page" --text "sentence"` → exact revision + timestamp | +| **Track a sentence across its entire lifecycle** | Manual — follow the page history | Automatic — first seen, modified, removed, reintroduced, all timestamped | +| **Detect citation swapping** | Manual — compare each diff's reference section | `citation_replaced` event — `before`/`after` show the old and new source | +| **Detect edit wars** | Partly — undos, rollbacks and manual reverts are tagged, as are the edits they revert; spotting back-and-forth is manual | `revert_detected` + `edit_cluster_detected` — automatic structural detection | +| **Correlate article edits with talk page discussion** | Manual — check Talk tab separately | `talk_page_correlated` — Refract checks 7 days before / 3 days after each edit | +| **Compare the same topic across language editions** | Manual — open each wiki separately | `refract diff` — cross-wiki comparison with z-score outlier detection | +| **Query with SQL** | No | DuckDB: `SELECT "eventType", count(*) FROM 'events.jsonl' GROUP BY 1` | +| **Hash an export so others can check it** | Per revision — each revision has a permanent link, and the API returns a SHA-1 of its content | `refract export --manifest` → Merkle root over the event hashes; the same version over the same revision range reproduces it | +| **Automated monitoring** | Watchlist, with optional email when any watched page changes | `refract cron` + `refract watch` → Slack, email, webhook alerts | +| **AI agent integration** | No | `refract mcp` → Claude Code, Cursor, VS Code can call Refract tools directly | + +## Refract vs. other tools + +| Tool | What it does | Refract's difference | +|---|---|---| +| **[XTools](https://www.mediawiki.org/wiki/XTools)** | [Page History](https://www.mediawiki.org/wiki/XTools/Page_History): statistics about a page's edits, such as top editors and edits per year and month. [Authorship](https://www.mediawiki.org/wiki/XTools/Authorship): each editor's share of the current text, by character count. [Blame](https://www.mediawiki.org/wiki/XTools/Blame): the edits that added a given text. Authorship and Blame use WikiWho | Refract reports each change between revisions as a typed event and does not attribute text to editors | +| **[WikiBlame](https://en.wikipedia.org/wiki/User:Flominator/WikiBlame)** | Uses binary or linear search over a page's revisions to find the one in which a given text was inserted or removed. Works on MediaWiki wikis | `refract claim` follows one sentence through every revision: first seen, modified, removed, reintroduced | +| **[Who Wrote That?](https://www.mediawiki.org/wiki/Who_Wrote_That%3F)** | Browser extension for Chrome and Firefox that uses WikiWho to highlight who wrote each part of an article's current text | Refract does not identify editors; it reports what changed at each revision | +| **[WikiWho](https://www.mediawiki.org/wiki/WikiWho)** | Token-level provenance for about 70 Wikipedia editions: who added, removed or reinserted each token, and in which revision | Refract emits events for sentences, citations, templates, reverts and sections rather than tokens, and does not attribute editors | +| **[WhoColor](https://github.com/wikiwho/WhoColor)** | Userscript that colors article text by author, with conflict and age views, built on WikiWho | Refract structures the data for querying, not just viewing | +| **[Wikimedia Enterprise](https://meta.wikimedia.org/wiki/Wikimedia_Enterprise/FAQ)** | APIs that deliver Wikimedia project content: daily project snapshots, single articles on demand, and a real-time stream of updates. Free access, and paid plans with service-level agreements | Refract analyzes a page's past revisions, is open-source, and runs locally | +| **[MediaWiki API](https://www.mediawiki.org/wiki/API:Revisions)** | Raw revision data: metadata, content and a SHA-1 of each revision | Refract adds deterministic analysis, event typing, provenance metadata | +| **[Internet Archive](https://help.archive.org/help/wayback-machine-general-information/)** | Archived captures of web pages from its crawls | Refract produces structured, queryable event streams, not page captures | +| **Custom scrapers** | Ad-hoc revision analysis | Refract has 26 event types, deterministic hashing, and a published SDK | + +## What Refract deliberately doesn't do + +| Capability | Why not | +|---|---| +| **Truth/fact-checking** | Refract observes change, not correctness. It answers "what changed?", not "is this true?" | +| **Sentiment analysis** | Refract doesn't judge editor intent or tone | +| **Editor scoring** | Refract tracks document change, not editor behavior | +| **Prediction** | Refract reports what happened, not what might happen | +| **Automated editing** | Refract is read-only observation | + +## When to use Refract + +- You need to **prove** when a claim appeared, not just screenshot it +- You're analyzing **patterns across many revisions** (citation churn, edit clusters, talk correlation) +- You need **exports others can reproduce and compare** (a Merkle root over the event hashes) +- You want to **monitor pages automatically** (cron + notifications) +- You're building a **RAG pipeline** that needs claim stability signals +- You want **AI agents** to reason about page history with structured data + +## Refract vs. AI evaluation tools + +Refract's model evaluation capability — temporal leakage detection, provenance hallucination checking, retrieval quality scoring — has no direct competitor. Existing tools evaluate models on accuracy, safety, or reasoning. None evaluate models against deterministic ground truth about what was public knowledge and when. + +| Capability | Existing tools | Refract | +|---|---|---| +| **Temporal leakage detection** | Heuristic: compare model output to training cutoff dates. No deterministic proof. | `refract_eval.build_leakage_benchmark()` — exact revision ID, timestamp, SHA-256 hash. Proves leakage deterministically. | +| **Provenance hallucination** | Manual: check model citations against sources one at a time. | `refract_eval.check_provenance()` — query citation_added/removed/replaced events. Classify: verified, outdated, hallucinated. | +| **Retrieval quality (stability-weighted)** | Embedding similarity only. Contested and stable passages score identically. | `refract_eval.score_retrieval_quality()` — each passage scored by revert count, citation churn, talk activity. | +| **Knowledge recency** | No standard tooling. Ad-hoc: "ask the model what date it thinks it is." | `refract snapshot "Page" --at <date>` — deterministic page state at any point. Compare model answer against ground truth. | +| **Standard benchmark** | No open benchmark for temporal ground truth. | `BENCHMARK.md` — 10 standard pages, submission format, reproducibility requirements. | +| **Reproducibility** | Most eval suites: "run our script, trust our numbers." | Every event has a deterministic SHA-256 hash. Reviewer runs same command, gets same hash. | + +**The gap Refract fills**: every eval suite tests whether a model is *accurate*. None test whether a model *knows things it shouldn't*. Refract provides the ground truth for that test — and makes it reproducible. + +## When Wikipedia's UI is enough + +- You're checking **one revision diff** quickly +- You need to see **who** made an edit +- You're browsing page history casually + +Refract doesn't replace the page history. It adds typed change events, SQL queries over them, exports that others can reproduce from the same revisions, and scheduled re-observation with Slack, email or webhook notifications. diff --git a/apps/docs/docs/complete-workflow.md b/apps/docs/docs/complete-workflow.md new file mode 100644 index 0000000..3bc358e --- /dev/null +++ b/apps/docs/docs/complete-workflow.md @@ -0,0 +1,182 @@ +# Complete workflow: From nothing to insight + +This page ties together every part of Refract into a single end-to-end workflow. +Follow along to go from zero install to a data-backed conclusion about how a Wikipedia +page has changed. + +## What we're doing + +We'll analyze the Bitcoin Wikipedia page, track a specific claim across its revision +history, export the data, query it with DuckDB, and draw a conclusion about claim +stability — all using deterministic, byte-reproducible output. + +## Step 1: Zero-install analysis + +```bash +npx @refract-org/cli analyze "Bitcoin" --depth forensic +``` + +This fetches 20 recent revisions and runs all 26 deterministic analyzers, including wikilink and category diffing, talk page correlation, and edit cluster detection. +Output is one JSON event per line — the full event stream: + +``` +Analysis of "Bitcoin" at depth detailed found 330 events across 20 revisions. + +[2009-03-08T16:41:44Z] wikilink_added (rev 275832581→275832690) + Section: body + • target: cryptography + +[2009-12-10T14:15:09Z] citation_added (rev 308164432→308164529) + Section: (lead) + • ref: href=http://sourceforge.net/projects/bitcoin/ + +... +``` + +Every event has a type, a revision range, a section, before/after snapshots, and +deterministic facts explaining why it was produced. + +## Step 2: Track a specific claim + +Now let's trace a specific claim through the page's history: + +```bash +refract claim "Bitcoin" --text "decentralized" -c +``` + +Refract finds every revision where the word "decentralized" appears in the page text, +tracks when it was first added, modified, or removed, and prints a claim lifecycle: + +``` +Claim: "decentralized" on Bitcoin + • first_seen: 2009-01-03 (rev 275832581) + • revisions present: 18 of 20 + • modifications: 2 (spelling correction, phrasing change) + • removed: never + • status: STABLE +``` + +This claim is stable — it appeared early, persisted through most revisions, and was +never removed. Contrast with a contested claim: + +``` +Claim: "completely anonymous" on Bitcoin + • first_seen: 2010-04-15 (rev 308200000) + • revisions present: 3 of 20 + • removed: 2010-05-01 (rev 308350000) + • reintroduced: never + • status: REMOVED +``` + +![Claim lifecycle](claim-lifecycle.svg) + +## Step 3: Export for analysis + +```bash +refract export "Bitcoin" --format ndjson > bitcoin-events.jsonl +``` + +Now we have a portable, queryable file of the complete event stream. + +## Step 4: Query with DuckDB + +```bash +duckdb -c " +SELECT "eventType", count(*) as cnt +FROM 'bitcoin-events.jsonl' +GROUP BY "eventType" +ORDER BY cnt DESC; +" +``` + +Output: + +| event_type | cnt | +|------------|-----| +| sentence_modified | 85 | +| citation_added | 34 | +| sentence_first_seen | 28 | +| revert_detected | 15 | +| template_added | 12 | +| citation_removed | 8 | + +The page has more `citation_added` (34) than `citation_removed` (8) — net source +accumulation, the page is becoming better-sourced over time. + +Find the most contested section: + +```sql +SELECT section, + count(*) FILTER (WHERE "eventType" = 'revert_detected') as reverts, + count(*) FILTER (WHERE "eventType" = 'edit_cluster_detected') as clusters +FROM 'bitcoin-events.jsonl' +GROUP BY section +HAVING reverts > 0 OR clusters > 0 +ORDER BY reverts DESC; +``` + +| section | reverts | clusters | +|---------|---------|----------| +| Regulation | 6 | 2 | +| Scalability debate | 4 | 1 | +| History | 3 | 0 | + +The "Regulation" section has the most reverts (6) and edit clusters (2) — this is the +most actively contested part of the page. + +## Step 5: Correlate with talk page activity + +Run the talk page analysis: + +```bash +refract analyze "Talk:Bitcoin" --depth detailed +``` + +Compare revert days with talk activity days. Days with high reverts and no talk +activity suggest edit-warring. Days with reverts and active discussion suggest +genuine editorial deliberation: + +``` +2025-01-15: 2 reverts, 5 talk replies → deliberation +2025-02-03: 4 reverts, 0 talk replies → edit-warring +``` + +## The complete picture + +After these 5 steps you know: + +1. **What changed** on the Bitcoin page (330 deterministic events across 20 revisions) +2. **Which claims are stable** ("decentralized" — present in 18 of 20 revisions, never removed) +3. **Which claims were contested** ("completely anonymous" — added, then removed permanently) +4. **Which sections are most disputed** (Regulation: 6 reverts, 2 edit clusters) +5. **Whether disputes were discussed** (talk page correlation shows deliberation vs. edit-warring) +6. **How sourcing evolved** (net source accumulation — page is improving) + +All of this is deterministic — run the same commands a year from now on the same +revision range and you get identical output. + +## What to do next + +- **Automate monitoring**: Set up `refract cron` to re-observe daily ([cron guide](cron.md)) +- **Compare across wikis**: Run `refract diff` on Bitcoin across English and Simple + Wikipedia ([cross-wiki tutorial](tutorials/cross-wiki-diff.md)) +- **Build a dashboard**: Load events into DuckDB and connect to Grafana or + Observable ([analytics guide](analytics.md)) +- **Integrate with RAG**: Use claim stability signals to filter retrieval results + ([downstream guide](downstream.md)) +- **Verify accuracy**: Run `refract eval` to benchmark analyzer precision + ([eval guide](eval.md)) + +## The same workflow for any page + +This workflow works identically on any MediaWiki page — Wikipedia, Fandom, or a +private wiki: + +```bash +refract analyze "Darth_Vader" --api https://starwars.fandom.com/api.php --depth detailed +refract claim "Darth_Vader" --text "midichlorians" --api https://starwars.fandom.com/api.php +refract export "Darth_Vader" --api https://starwars.fandom.com/api.php --format ndjson > vader.jsonl +``` + +The commands are the same. The output format is the same. The deterministic guarantee +is the same. Only the API endpoint changes. diff --git a/apps/docs/docs/concepts.md b/apps/docs/docs/concepts.md new file mode 100644 index 0000000..2f934e1 --- /dev/null +++ b/apps/docs/docs/concepts.md @@ -0,0 +1,145 @@ +# Concepts + +Refract is a deterministic observation engine for revision histories. It ingests them +from MediaWiki instances and produces an event stream showing how claims +enter, change, stabilize, and exit the public record — where they came from, what supported +them, what challenged them, and when context altered their meaning. + +Every run against the same revision range produces identical output: byte-for-byte reproducible. + +## How determinism works + +Refract never calls an LLM, never samples randomly, and never depends on external state. Given revision text from a MediaWiki API, each analyzer applies a pure function: `(wikitext) → events`. The same wikitext always produces the same events. This holds across machines, OS versions, and time. + +Deterministic identity (`eventId`) extends this guarantee: events with identical content produce identical hashes, so deduplication and cross-reference are reliable even when the same observation is run independently. + +## Architecture + +![Concept overview](concept-overview.svg) + +The diagram shows how Refract ingests revision histories from MediaWiki APIs, runs deterministic analyzers, and produces a structured event stream with provenance tags. + +## Pipeline stages + +``` +Wikipedia API → Fetch → Analyze → Report → (optional) Validate +``` + +| Stage | What it does | Reproducible? | +|-------|-------------|---------------| +| **Fetch** | Retrieves revisions from the MediaWiki API, handles rate limits, pagination | Yes — same API + same page → same revisions | +| **Analyze** | Runs deterministic analyzers — section diffs, citation tracking, revert detection, template classification, category/wikilink extraction | Yes — pure functions, no external state | +| **Report** | Assembles structured events with revision, section, and timestamp provenance | Yes — deterministic ID via SHA-256 content hash | +| **Validate** | Optional: compares pipeline output against ground truth labels (eval package) | Yes — deterministic comparison | + +![ObservationReport structure](observation-report.svg) + +![Architecture data flow](architecture-flow.svg) + +![BYO-inference boundaries](byo-inference-bounds.svg) + +## Depth levels + +The `--depth` flag controls how many analyzers run: + +- **`brief`** — section differ only. Quick check for any change. +- **`detailed`** (default) — all standard analyzers (section, citation, revert, template). +- **`forensic`** — includes cross-revision correlation, metadata trackers, and cluster detection. Use for full audit or export. + +See [depth reference](depth.md). + +## Output model + +The pipeline produces timed `EvidenceEvent` objects with 26 event types, all mechanical: + +- **Sentence events**: `sentence_first_seen`, `sentence_removed`, `sentence_modified`, `sentence_reintroduced` +- **Citation events**: `citation_added`, `citation_removed`, `citation_replaced` +- **Section events**: `section_reorganized` +- **Template events**: `template_added`, `template_removed`, `template_parameter_changed` +- **Revert events**: `revert_detected` +- **Cluster/activity events**: `edit_cluster_detected`, `talk_activity_spike` +- **Page events**: `lead_promotion`, `lead_demotion`, `page_moved` +- **Link/category events**: `wikilink_added`, `wikilink_removed`, `category_added`, `category_removed` +- **Protection events**: `protection_changed` +- **Talk page events**: `talk_page_correlated`, `talk_thread_opened`, `talk_thread_archived`, `talk_reply_added` + +The event schema includes a `modelInterpretation` field and `model_interpretation` evidence layer — these are never set by Refract's deterministic engine. They exist for downstream consumers to attach semantic analysis without modifying the event stream. + +Analyzers accept configurable parameters (similarity thresholds, time windows, revert patterns) via `AnalyzerConfig`, passed from the CLI or SDK. This allows users to tune detection sensitivity without modifying analyzer code. + +The pipeline can also produce an `ObservationReport` — a structured aggregate that groups events by claim with a `ClaimLedger` tracking each claim's lifecycle across revisions. + +See [schema.md](schema.md) for the full reference. + +![Evidence labels diagram](evidence-labels.svg) + +## Configurable heuristics / BYO-inference boundaries + +Every analyzer threshold encodes an interpretive judgment — what counts as a revert, how close edits must be to form a cluster, what sentence similarity means "modified" vs "removed and added." These thresholds accept optional overrides via `AnalyzerConfig`, and the effective parameters are recorded in `FactProvenance.parameters` when non-default values are used. + +Each threshold is a **BYO-inference boundary** — a typed function signature where a model can replace the default heuristic: + +| Boundary | Default (mechanical) | Pluggable (model) | +|----------|----------------------|--------------------| +| Sentence similarity | Word-overlap ratio (0.8) | "Are these two sentences the same claim?" | +| Revert detection | 6 regex patterns | "Is this edit comment a revert?" | +| Template classification | Name-to-type lookup | "What policy signal does this template represent?" | +| Edit cluster detection | Time window + min size | "Are these edits semantically related?" | +| Heuristic classification | Size thresholds + comment patterns | "What kind of edit is this?" | + +The defaults work offline with no configuration required. Consumers supply a model at any boundary, and the event's `FactProvenance.parameters` records the path taken — `similaritySource: "model"` or `"default"` — keeping the audit trail transparent regardless. + +This is the architectural spine: Refract stays purely mechanical, but the boundaries where judgment enters are explicit, typed, and versioned. Foundation model improvements automatically benefit consumers without Refract changing. + +## Two-knowledge split + +Refract separates mechanical observation from ground truth verification: + +1. **Deterministic layer** — what changed, byte-for-byte reproducible. All analyzers, all events. No model involved. +2. **Ground truth layer** — independently verified outcome labels (talk page consensus, RFC closures, ArbCom decisions) stored in `@refract-org/eval`. Used for validation, never redefined by pipeline output. + +Model-assisted interpretation happens downstream — in NextConsensus, in consumer applications, or anywhere else that builds on Refract's event stream. The diagram above shows this boundary: everything inside the dashed box is Refract. Everything labeled "Downstream" is external. + +## What Refract does not do + +- No model calls in the deterministic pipeline — all analyzers are byte-reproducible without LLMs +- No truth claims — Refract reports what changed, not whether the change is accurate +- No prediction, sentiment analysis, or editor scoring +- No claims about compliance, policy violations, or decision relevance + +Model-assisted classification is available at explicit BYO-inference boundaries (via `refract classify` or boundary config). When a model is used, the event records it — the deterministic pipeline itself never calls one. + +## Independent ground truth + +The eval package stores outcome labels independently from pipeline output. These labels are collected from talk page discussions, RFC closures, and arbitration decisions — human consensus about what happened. The evaluation harness compares pipeline events against these labels to measure analyzer accuracy without feedback loops. + +## References + +Refract builds on research in peer production, computational social science, and knowledge evolution. The following work informs its architecture and assumptions: + +### Wikipedia and peer production + +- **Viégas, Wattenberg, & McKeon (2007)** — *The Hidden Order of Wikipedia*. Visualizing the edit history of Wikipedia articles reveals patterns of cooperation and conflict. +- **Halfaker, Geiger, Morgan, & Riedl (2013)** — *The Rise and Decline of an Open Collaboration System: How Wikipedia's reaction to popularity is causing its decline*. American Behavioral Scientist. Documents the systemic pressures that shape Wikipedia's editorial dynamics. +- **Kittur & Kraut (2008)** — *Harnessing the Wisdom of Crowds in Wikipedia: Quality through Coordination*. Proceedings of CSCW. Shows that coordination structures, not just contributor count, determine article quality. +- **Keegan, Gergle, & Contractor (2013)** — *Hot Off the Wiki: Structures and Dynamics of Wikipedia's Coverage of Breaking News Events*. American Behavioral Scientist. Analyzes how Wikipedia responds to real-world events — the pattern Refract's edit cluster detector captures. +- **Müller-Birn, Dobusch, & Herbsleb (2013)** — *Work-to-rule: The emergence of algorithmic regulation in Wikipedia*. Proceedings of OpenSym. How policy templates and automated tools enforce editorial norms. + +### Content dynamics and claim evolution + +- **Adler, de Alfaro, Pye, & Raman (2008)** — *Measuring Author Contributions to the Wikipedia*. WikiWho system for sentence-level authorship attribution. Refract differs by tracking sentence *lifecycle* rather than who wrote it. +- **Ferschke, Gurevych, & Rittberger (2013)** — *The Impact of Topic Bias on Quality Flaw Prediction in Wikipedia*. Proceedings of ACL. Analyzes how cleanup templates correlate with content quality and how topic bias affects quality assessment. + +### Provenance and evidence infrastructure + +- **Buneman, Khanna, & Tan (2001)** — *Why and Where: A Characterization of Data Provenance*. ICDT. Foundational work on data provenance that motivates Refract's `FactProvenance` and `inputHashes` design. +- **Carroll, Bizer, Hayes, & Stickler (2005)** — *Named Graphs, Provenance and Trust*. WWW. Semantic web provenance patterns that influenced Refract's `EvidenceLayer` taxonomy (`observed`, `policy_coded`, `model_interpretation`). +- **Merkle (1980)** — *Protocols for Public Key Cryptosystems*. IEEE S&P. Merkle tree construction used in Refract's replay manifests for observation integrity verification. +- **Moreau et al. (2011)** — *The Open Provenance Model core specification (v1.1)*. Future Generation Computer Systems. Provenance modeling patterns that informed `FactProvenance` design (analyzer, version, inputHashes). + +### Decision intelligence (relevant to downstream consumers) + +- **Kahneman & Tversky (1979)** — *Prospect Theory: An Analysis of Decision under Risk*. Econometrica. Foundational work on how humans make judgments under uncertainty — the cognitive context Refract's deterministic event stream is designed to anchor. +- **Tetlock & Gardner (2015)** — *Superforecasting: The Art and Science of Prediction*. Calibration and debiasing techniques that downstream consumers can apply to model interpretation built on top of Refract's event stream. +- **Sackett et al. (1996)** — *Evidence Based Medicine: What it is and what it isn't*. BMJ. Defines the evidence hierarchy that healthcare downstream consumers (e.g., NextConsensus) use to weight source types. +- **Guyatt et al. (2008)** — *GRADE: an emerging consensus on rating quality of evidence and strength of recommendations*. BMJ. The GRADE framework for evidence quality assessment — a model for how downstream consumers might score source reliability. diff --git a/apps/docs/docs/contributing-docs.md b/apps/docs/docs/contributing-docs.md new file mode 100644 index 0000000..36e9a35 --- /dev/null +++ b/apps/docs/docs/contributing-docs.md @@ -0,0 +1,131 @@ +# Contributing to Refract documentation + +Documentation changes are contributions. This page covers conventions, structure, +and process for improving Refract docs. + +## What to contribute + +| Contribution | Example | +|-------------|---------| +| Fix a typo or stale reference | Package names, env vars, URLs | +| Improve a tutorial | Add a real walkthrough with actual output | +| Add a diagram | SVG diagrams in `assets/` | +| Add a new page | New use case, integration guide, or reference | +| Improve the build system | build.mjs, CSS, rendering | + +## Repository structure + +``` +refract-docs/ +├── docs/ # Markdown source (one .md per page) +│ ├── tutorials/ # Tutorial pages +│ └── ... +├── assets/ # Static assets (SVGs, CSS) +├── build.mjs # Static site builder +├── dist/ # Built output (gitignored, CI-only) +└── package.json +``` + +## Page conventions + +### File naming +- Lowercase, hyphenated: `frontier-use-cases.md`, `bundle-manifest.md` +- Tutorials go in `docs/tutorials/` + +### Front matter +No YAML front matter needed. The first `# Heading` in the file becomes the page +title. The page slug is the filename minus `.md`. + +### Tone +- Technical, neutral, precise. No marketing language. +- One idea per paragraph. One sentence per idea when possible. +- Use `Refract` (capital R) as the project name. +- Reference packages as `@refract-org/<name>`. +- Reference the CLI as `refract` (`wikihistory` is mentioned once as an alias). + +### Code blocks +Use fenced code blocks with language tags: + +```markdown +\`\`\`bash +refract analyze "Earth" --depth detailed +\`\`\` + +\`\`\`python +import pandas as pd +\`\`\` +``` + +### Links +Use relative links to other docs pages: + +```markdown +[Concepts](../concepts.md) +[CLI reference](../cli.md) +``` + +External links use full URLs. + +### Diagrams +SVG diagrams go in `assets/`. Reference them with a relative path: + +```markdown +![Architecture data flow](architecture-flow.svg) +``` + +The build script copies `assets/` into `dist/` so relative paths work. + +## Adding a new page + +1. Create `docs/<slug>.md` with the page content +2. Add the page to the `NAV` array in `build.mjs`: + ```javascript + { + title: 'Appendix', + slug: null, + children: [ + // ... existing entries ... + { title: 'Your New Page', slug: 'your-new-page' }, + ], + }, + ``` +3. Run `npm run build` to verify +4. Open a PR + +## Diagram style + +All diagrams use consistent styling: +- **Font**: System font stack (`-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`) +- **Colors**: Tailwind gray scale for text (`#111827` for headings, `#4b5563` for body) +- **Layer colors**: Blue for deterministic (`#2563eb`, `#1d4ed8`), green for ground truth (`#059669`, `#047857`), purple for downstream/model (`#7c3aed`, `#6d28d9`) +- **Card style**: White background, rounded corners (rx="8"), subtle shadow via `<filter>` +- **Dimensions**: Match existing diagram widths (720px or 760px for wider diagrams) + +## Tutorial checklist + +A good tutorial: +- [ ] Has a clear goal statement +- [ ] Shows commands the user can copy-paste and run +- [ ] Shows expected output for each step +- [ ] Explains how to interpret what the user sees +- [ ] Includes troubleshooting for common problems +- [ ] Links to next steps + +## Build and preview + +```bash +npm run build # Build static site to dist/ +``` + +The site is plain HTML — open `dist/index.html` in a browser, or serve with any static +file server: + +```bash +npx serve dist +``` + +## Review process + +- PRs to `main` trigger CI build (see `.github/workflows/ci.yml`) +- Merged PRs deploy to GitHub Pages automatically (see `.github/workflows/deploy.yml`) +- Documentation PRs follow the same [code of conduct](https://github.com/refract-org/refract/blob/main/.github/CODE_OF_CONDUCT.md) as code PRs diff --git a/apps/docs/docs/cron.md b/apps/docs/docs/cron.md new file mode 100644 index 0000000..b6f7d01 --- /dev/null +++ b/apps/docs/docs/cron.md @@ -0,0 +1,225 @@ +# Scheduled monitoring with cron + +`refract cron` re-observes every page in a list once and reports, per page, how many +events are new since the previous run. It is built to be started by a scheduler — +system cron, a CI workflow, a job runner — and to exit. It keeps its own state between +runs, so the scheduler has to keep that state too (see [where state lives](#where-state-lives)). + +## Basic usage + +```bash +refract cron pages.txt +``` + +`pages.txt` holds one page title per line. Blank lines and lines starting with `#` +are skipped. + +``` +# Observed daily +Bitcoin +COVID-19 +Climate change +``` + +For a wiki other than English Wikipedia, add `--api https://example.org/w/api.php`. + +## What a run does + +For each page: + +1. Reads the page's previous observation, if there is one. +2. Analyzes the revisions since the lookback start (below) at `detailed` depth. +3. Compares: an event is **new** if the previous observation did not contain it, and + **resolved** if the previous observation had it inside this run's window and this + run does not. An event that is merely older than the window is neither. +4. Writes this run's events as the page's observation — unless the run found none, in + which case the previous observation is kept, so the next run still has something + to compare against. +5. Merges a claim-level report into `reports/<page>.json`. + +The first run for a page has nothing to compare with. It records a baseline and +reports no new events. + +## Lookback window + +| Flag | Window | +|---|---| +| (none) | From the newest event in the previous observation; 24 hours when there is none | +| `-i, --interval <hours>` | The last N hours, whatever the previous observation holds | + +```bash +refract cron pages.txt --interval 24 +``` + +Run the scheduler at least as often as the interval. A window shorter than the gap +between runs leaves edits no run looked at. + +## Output and exit code + +The output is a report for people, not a data format: + +``` +Cron: 3 pages from pages.txt + + Bitcoin: observing since 2026-09-23T06:00:00.000Z... + 2 new events, 0 resolved + COVID-19: observing since 2026-09-23T06:00:00.000Z... + No changes + Climate change: observing since 2026-09-23T06:00:00.000Z... + No changes + +=== Cron Summary === +Pages: 3 +Total new events: 2 +``` + +`refract cron` exits **1 when there are new events** — and also 1 when anything fails, +so the exit code alone cannot tell a change from an error. Read the +`Total new events: N` line: a run that printed it finished; a run that did not, failed. + +For the events themselves, use `analyze` over the same window, which prints one JSON +event per line: + +```bash +refract analyze "Bitcoin" --since 2026-09-23T06:00:00Z --json > bitcoin-events.ndjson +``` + +## Where state lives + +Everything `cron` remembers is in one directory: `--cache-dir <dir>`, or +`~/.wikihistory` without it. + +| Path | Holds | +|---|---| +| `<dir>/observations/<page>.json` | The page's last observation — the events the next run compares against | +| `<dir>/reports/<page>.json` | The merged claim-level report | + +`<page>` is the title with every character outside `A–Z a–z 0–9 _ -` replaced by `_`. + +**On a fresh machine every run is a first run.** A CI runner starts empty, so unless +the directory is carried from one run to the next, every run records a baseline and +reports no new events. Pass `--cache-dir` and persist that directory. + +## Scheduling with system cron + +```bash +# /etc/cron.d/refract-observation — every 6 hours +0 */6 * * * user refract cron /srv/refract/pages.txt --interval 6 --cache-dir /srv/refract/state +``` + +## Scheduling with GitHub Actions + +The state directory is restored from the Actions cache at the start of each run and +saved under a new key at the end, so each run compares against the one before it. + +```yaml +name: Refract observation +on: + schedule: + - cron: "0 */6 * * *" + workflow_dispatch: + +jobs: + observe: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: 22 } + - uses: actions/cache@v4 + with: + path: .refract + key: refract-state-${{ github.run_id }} + restore-keys: refract-state- + - name: Observe + run: | + set +e + npx -y @refract-org/cli cron pages.txt --interval 6 --cache-dir .refract | tee cron.log + status=${PIPESTATUS[0]} + set -e + # Exit 1 means new events only when the run printed its summary. + if ! grep -q '^Total new events:' cron.log; then exit "$status"; fi +``` + +Committing `.refract/` back to the repository instead of caching it gives the same +continuity plus a history of every observation. + +## Notifications + +A notification is sent only when at least one page has new or resolved events. + +### Slack + +```bash +SLACK_WEBHOOK_URL="https://hooks.slack.com/services/..." \ + refract cron pages.txt --notify-slack +``` + +Posts a message listing each changed page with its new and resolved counts. + +### Email + +```bash +SMTP_TO="alerts@example.com" refract cron pages.txt --notify-email +``` + +Sends through the local `sendmail` (`/usr/sbin/sendmail`, or the path in +`SMTP_SENDMAIL`). `SMTP_TO` is required; there is no SMTP host or password setting. + +### Webhook + +```bash +refract cron pages.txt --notify-webhook https://hooks.example.com/refract +``` + +POSTs: + +```json +{ + "event": "refract.observation", + "pages": [ + { + "pageTitle": "Bitcoin", + "eventsNew": 2, + "eventsResolved": 0, + "deltaSummary": "2 new, 0 resolved" + } + ], + "totalNewEvents": 2, + "totalResolved": 0, + "generatedAt": "2026-09-24T06:00:04.000Z" +} +``` + +With `--api`, each page also carries `wikiUrl`, the API it was observed through. + +## Serverless platforms + +The CLI needs Node.js or Bun and a filesystem, so it cannot run inside a Cloudflare +Worker. Two patterns work: + +- Run `refract cron` (or `analyze --since … --json`) on a scheduled runner, and send + the results to the platform — D1, R2, a queue. +- Import the library packages (`@refract-org/ingestion`, `@refract-org/analyzers`) in + the Worker and fetch and diff revisions there, keeping the previous observation in + D1 or KV. The Worker needs `nodejs_compat`: the packages use `node:crypto`. + +A function platform with a filesystem and a Node runtime (AWS Lambda, Cloud Run jobs) +can run the CLI directly; keep `--cache-dir` on durable storage. + +## In CI for documentation checks + +`analyze` compares one window of one page; with `--json` its output is one event per +line, ready for `jq` or DuckDB: + +```yaml +- name: Events on the observed page this week + run: | + npx -y @refract-org/cli analyze "Page_Name" --depth detailed \ + --since "$(date -u -d '8 days ago' +%Y-%m-%dT%H:%M:%SZ)" --json > events.ndjson + duckdb -c "SELECT eventType, count(*) FROM 'events.ndjson' GROUP BY 1 ORDER BY 2 DESC" +``` + +A `--since` window diffs each revision in it against the one before — except the +first, whose parent lies outside the window. Open the window a little before the +period you care about (eight days for a week, above). diff --git a/apps/docs/docs/demo.md b/apps/docs/docs/demo.md new file mode 100644 index 0000000..e697dc5 --- /dev/null +++ b/apps/docs/docs/demo.md @@ -0,0 +1,183 @@ +# Refract Demo + +Try Refract in your browser. All data is pre-computed — no installation needed. + +<noscript> + <div style="padding: 2rem; background: #fef2f2; border: 1px solid #fecaca; border-radius: 8px; margin: 1rem 0;"> + <p style="font-weight: 600; color: #dc2626;">JavaScript required</p> + <p style="color: #4b5563;">This demo renders interactive charts from pre-computed Refract event data. Enable JavaScript to explore the Xanoleptin (synthetic drug), Luke Skywalker (Fandom), and Bitcoin (Wikipedia) datasets — each containing 30–50 structured events showing claim lifecycles, citation churn, and edit clusters.</p> + <p style="color: #4b5563;">For a text-based walkthrough without JavaScript, see the <a href="../tutorials/wikipedia-history/">Wikipedia History tutorial</a> or run Refract locally:</p> + <pre style="background: #f3f4f6; padding: 1rem; margin: 0.5rem 0;">npx @refract-org/cli analyze "Bitcoin" --depth detailed</pre> + </div> +</noscript> + +<div id="demo-controls"> + <label>Dataset: <select id="dataset-select"> + <option value="xanoleptin">Xanoleptin (synthetic drug)</option> + <option value="luke-skywalker">Luke Skywalker (Fandom)</option> + <option value="bitcoin">Bitcoin (Wikipedia)</option> + </select></label> + <span id="event-count">0 events</span> +</div> + +<div id="demo-layout"> + <div class="demo-panel" id="panel-timeline"> + <h3>Event Timeline</h3> + <div id="timeline-chart"><p class="loading">Loading...</p></div> + </div> + <div class="demo-panel" id="panel-claim"> + <h3>Claim Lifecycle</h3> + <div id="claim-lifecycle"><p class="loading">Select a claim...</p></div> + </div> + <div class="demo-panel" id="panel-churn"> + <h3>Citation Churn</h3> + <div id="citation-chart"><p class="loading">Loading...</p></div> + </div> + <div class="demo-panel" id="panel-clusters"> + <h3>Edit Clusters</h3> + <div id="cluster-chart"><p class="loading">Loading...</p></div> + </div> +</div> + +<div id="demo-json" class="demo-panel"> + <h3>Raw Events (first 3)</h3> + <pre id="json-output"><p class="loading">Loading...</p></pre> +</div> + +<style> +#demo-controls { margin: 1rem 0; display: flex; gap: 1rem; align-items: center; } +#demo-controls select { padding: 0.3rem 0.6rem; } +#event-count { color: var(--ink-soft); font-size: 0.9rem; } +#demo-layout { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin: 1rem 0; } +.demo-panel { border: 1px solid var(--line); padding: 1rem; background: var(--surface-1); } +.demo-panel h3 { margin: 0 0 0.75rem; font-size: 1rem; } +#demo-json pre { max-height: 300px; overflow: auto; font-size: 0.8rem; } +.loading { color: var(--ink-soft); font-style: italic; } +.timeline-bar { display: flex; align-items: center; gap: 0.25rem; margin: 0.15rem 0; font-size: 0.78rem; } +.timeline-label { min-width: 7rem; text-align: right; color: var(--ink-soft); } +.timeline-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } +.timeline-line { height: 1px; flex: 1; min-width: 20px; } +.claim-card { border: 1px solid var(--line-strong); padding: 0.75rem; margin: 0.5rem 0; } +.claim-card strong { display: block; margin-bottom: 0.25rem; } +.claim-stat { font-size: 0.85rem; color: var(--ink-soft); } +.churn-bar { display: flex; align-items: center; gap: 0.5rem; margin: 0.2rem 0; } +.churn-label { min-width: 6rem; font-size: 0.78rem; color: var(--ink-soft); } +.churn-value { height: 16px; border-radius: 2px; transition: width 0.3s; } +@media (max-width: 720px) { #demo-layout { grid-template-columns: 1fr; } } +</style> + +<script> +const DATASETS = { + xanoleptin: "https://raw.githubusercontent.com/refract-org/refract-demo-data/main/synthetic-healthcare-like/xanoleptin.jsonl", + "luke-skywalker": "https://raw.githubusercontent.com/refract-org/refract-demo-data/main/fandom/star-wars/luke-skywalker.jsonl", + bitcoin: "https://raw.githubusercontent.com/refract-org/refract-demo-data/main/historical-wikipedia/events.jsonl", +}; + +const EVENT_COLORS = { + sentence_first_seen: "#4caf50", + sentence_removed: "#f44336", + sentence_modified: "#ff9800", + sentence_reintroduced: "#2196f3", + citation_added: "#9c27b0", + citation_removed: "#e91e63", + citation_replaced: "#3f51b5", + template_added: "#ff5722", + template_removed: "#795548", + revert_detected: "#f44336", + edit_cluster_detected: "#607d8b", +}; + +let currentEvents = []; + +async function loadDataset(name) { + const url = DATASETS[name]; + const resp = await fetch(url); + const text = await resp.text(); + const events = text.split("\n").filter(l => l.trim() && !l.startsWith("#")).map(l => JSON.parse(l)); + currentEvents = events; + document.getElementById("event-count").textContent = `${events.length} events`; + renderTimeline(events); + renderClaims(events); + renderCitations(events); + renderClusters(events); + renderJSON(events); +} + +function renderTimeline(events) { + const sorted = [...events].sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp)); + const types = [...new Set(sorted.map(e => e.eventType))]; + const html = types.map(type => { + const count = sorted.filter(e => e.eventType === type).length; + const color = EVENT_COLORS[type] || "#9e9e9e"; + return `<div class="timeline-bar"> + <span class="timeline-label">${type}</span> + <span class="timeline-dot" style="background:${color}"></span> + <span class="timeline-line" style="background:${color};width:${Math.min(count * 20, 200)}px"></span> + <span>${count}</span> + </div>`; + }).join(""); + document.getElementById("timeline-chart").innerHTML = html; +} + +function renderClaims(events) { + const claimEvents = events.filter(e => e.eventType.startsWith("sentence_")); + if (claimEvents.length === 0) { + document.getElementById("claim-lifecycle").innerHTML = '<p class="loading">No claim events in this dataset.</p>'; + return; + } + const claimIds = [...new Set(claimEvents.map(e => e.claimId || e.eventType + "_" + e.toRevisionId).filter(Boolean))]; + const html = claimIds.slice(0, 3).map(id => { + const evts = claimEvents.filter(e => (e.claimId || e.eventType + "_" + e.toRevisionId) === id); + const firstSeen = evts.find(e => e.eventType === "sentence_first_seen"); + const lastEvt = evts[evts.length - 1]; + const text = firstSeen?.after || evts[0]?.after || "(no text)"; + const state = lastEvt?.eventType === "sentence_removed" ? "Removed" : "Active"; + return `<div class="claim-card"> + <strong>${text.slice(0, 100)}${text.length > 100 ? "..." : ""}</strong> + <div class="claim-stat">Events: ${evts.length} | State: ${state} | First: ${firstSeen?.timestamp?.slice(0, 10) || "?"}</div> + </div>`; + }).join(""); + document.getElementById("claim-lifecycle").innerHTML = html; +} + +function renderCitations(events) { + const citEvents = events.filter(e => e.eventType.startsWith("citation_")); + if (citEvents.length === 0) { + document.getElementById("citation-chart").innerHTML = '<p class="loading">No citation events in this dataset.</p>'; + return; + } + const added = citEvents.filter(e => e.eventType === "citation_added").length; + const removed = citEvents.filter(e => e.eventType === "citation_removed").length; + const replaced = citEvents.filter(e => e.eventType === "citation_replaced").length; + const max = Math.max(added, removed, replaced, 1); + document.getElementById("citation-chart").innerHTML = ` + <div class="churn-bar"><span class="churn-label">Added</span><span class="churn-value" style="width:${(added/max)*100}%;background:#4caf50"></span><span>${added}</span></div> + <div class="churn-bar"><span class="churn-label">Removed</span><span class="churn-value" style="width:${(removed/max)*100}%;background:#f44336"></span><span>${removed}</span></div> + <div class="churn-bar"><span class="churn-label">Replaced</span><span class="churn-value" style="width:${(replaced/max)*100}%;background:#3f51b5"></span><span>${replaced}</span></div> + `; +} + +function renderClusters(events) { + const clusterEvents = events.filter(e => e.eventType === "edit_cluster_detected"); + if (clusterEvents.length === 0) { + document.getElementById("cluster-chart").innerHTML = '<p class="loading">No cluster events in this dataset.</p>'; + return; + } + const html = clusterEvents.map((e, i) => { + const detail = e.deterministicFacts?.[0]?.detail || ""; + return `<div class="timeline-bar"> + <span class="timeline-label">Cluster ${i + 1}</span> + <span class="timeline-dot" style="background:#607d8b"></span> + <span style="font-size:0.78rem;color:var(--ink-soft)">${detail.slice(0, 80)}</span> + </div>`; + }).join(""); + document.getElementById("cluster-chart").innerHTML = html; +} + +function renderJSON(events) { + document.getElementById("json-output").textContent = JSON.stringify(events.slice(0, 3), null, 2); +} + +document.getElementById("dataset-select").addEventListener("change", (e) => loadDataset(e.target.value)); +loadDataset("xanoleptin"); +</script> diff --git a/apps/docs/docs/depth.md b/apps/docs/docs/depth.md new file mode 100644 index 0000000..1a1ca29 --- /dev/null +++ b/apps/docs/docs/depth.md @@ -0,0 +1,21 @@ +# Analysis depth levels + +The `--depth` flag controls how many analyzers run and how aggressively they parse. Applies to `analyze`, `diff`, and `explore` commands. + +| Level | Analyzers | Use case | +|-------|-----------|----------| +| `brief` | Section differ only | Quick check — did anything change on this page? | +| `detailed` | Section differ, citation tracker, revert detector, template tracker | Standard analysis — most common | +| `forensic` | All analyzers including protection tracker, page move detector, category diff, wikilink diff, talk page correlation, edit cluster detection, sentence modification detection | Full audit — all 26 event types | + +`brief` skips citation parsing, revert pattern matching, and template extraction. It only compares section headings between revisions. + +`detailed` (default) runs the full set of deterministic analyzers. Sufficient for most workflows. + +`forensic` enables cross-revision correlation (talk page matching, edit cluster detection), metadata trackers (protection changes, page moves, category and wikilink diffs), and sentence modification detection. Use when you need the complete event stream — all 26 event types — for export or visualization. + +Example: + +```bash +refract analyze "Earth" --depth forensic --cache +``` diff --git a/apps/docs/docs/downstream.md b/apps/docs/docs/downstream.md new file mode 100644 index 0000000..4e92b13 --- /dev/null +++ b/apps/docs/docs/downstream.md @@ -0,0 +1,209 @@ +# Downstream integration + +Refract produces a deterministic event stream. Downstream systems consume that stream to interpret what the changes mean for their domain. + +## Integration surfaces + +### 1. Structured events via CLI + +```bash +# Export events as NDJSON +refract export "Bitcoin" --format ndjson > bitcoin-events.jsonl + +# Export as ObservationReport with Merkle root +refract analyze "Bitcoin" --report > bitcoin-report.json +``` + +Each event carries `schemaVersion` and an `eventId`, a deterministic hash of the event's type, revisions, section, text, timestamp and facts. + +### 2. SDK events from your own revisions + +```typescript +import { annotateEvents, buildRevisionEvents } from "@refract-org/analyzers"; +import { createEventIdentity } from "@refract-org/evidence-graph"; + +const events = annotateEvents(buildRevisionEvents(revisions)); +for (const event of events) event.eventId = createEventIdentity(event); +``` + +These are the events the CLI derives from each pair of revisions, from the same code: the CLI calls `buildRevisionEvents` too. Both functions are exported from `@refract-org/analyzers` 0.5.1 on. Before it, a consumer had to copy the CLI's diff code, and a copy drifts: import the export rather than keeping one. See the [SDK reference](sdk.md#basic-pipeline). + +### 3. FactProvenance for auditability + +`FactProvenance` in `@refract-org/evidence-graph` is the shape for recording which analyzer, at which version and with which parameters, produced a fact: + +```json +{ + "analyzer": "section-differ", + "version": "0.5.1", + "inputHashes": [], + "parameters": { "similarityThreshold": 0.8 } +} +``` + +Refract's own events do not fill it in: their facts carry `fact` and `detail`. A consumer that needs per-fact provenance records it when it stores the events, taking the version from the installed `@refract-org/analyzers` and the parameters from the options it passed. + +### 4. Schema versioning + +Every event carries `schemaVersion` matching `EVENT_SCHEMA_VERSION`. This prevents silent invalidation of historical observations when `EventType` gains new members. + +### 5. Config version pinning + +`AnalyzerConfig.$version` is pinned from the CLI version. Downstream systems can prove which configuration was used for a given run. + +```bash +refract analyze "Bitcoin" --similarity 0.85 +# config.$version = "0.5.1" +# config.section.similarityThreshold = 0.85 +``` + +### 6. ObservationReport for chain-of-custody + +```json +{ + "pageTitle": "Bitcoin", + "observedAt": "2026-05-15T10:00:00Z", + "revisionRange": { "from": 100, "to": 200 }, + "merkleRoot": "a1b2c3d4...", + "eventCount": 47, + "analyzerVersion": "0.5.1" +} +``` + +The Merkle root is from the replay manifest. A downstream system can verify events match the observation. + +## Baseline superiority validation + +When validating an integrated signal, supply a Refract event summary to `computeBaselineSuperiority()`. The function adds Refract-derived baselines (revert count, citation flux, edit clusters) that the signal must outperform. + +```typescript +const result = computeBaselineSuperiority({ + integratedLeadTimeDays: signal.leadTimeDays, + mentionCount: snapshot.metrics.mentionCount, + revertCount: snapshot.metrics.revertCount, + refractEventSummary: { totalEvents: 47, revertCount: 12 }, +}); +``` + +## Principle + +Refract's event stream is purely mechanical. All interpretation happens downstream. Refract provides the deterministic record; the consumer provides the judgment. + +## What downstream systems build + +| Consumer | Builds on Refract | +|----------|-------------------| +| **Healthcare claim review** | Feed structured events into a review pipeline that checks whether a claim still holds up against the current evidence. Store the analyzer version and thresholds with each event, since the events do not carry them. | +| **AI training data curation** | Score each claim by revert count, citation churn, talk page correlation, and template dispute history. Include only stable, well-sourced claims in training data. | +| **Provenance-aware RAG** | Enrich each retrieved chunk with its claim history — stable, recently changed, source-fragile, contested. Use the signal to weight or filter results. | +| **Regulatory monitoring** | Run `refract cron` on drug pages, guidelines, and regulatory topics. Alert on citation removal, template disputes, or section reorganization. | +| **Competitive intelligence** | Use `refract diff` to compare how the same topic is framed across wikis (English vs German Wikipedia, Fandom vs independent wiki). Track divergence over time. | +| **Fact-checking** | Given a claim, query its lifecycle — first appearance, source additions, revert history, talk page activity. Detect citation laundering (same claim, different source). Return a verifiable provenance timeline. See [tutorial](tutorials/combating-revisionism.md). | +| **Academic research** | Export `ObservationReport` with Merkle-verifiable claim histories. Combat revisionism with deterministic event streams that anyone can reproduce independently. Analyze claim stability across topics, time periods, and editorial environments. | +| **Journalism forensics** | Track how a specific claim about a person evolved. Detect coordinated editing (edit clusters), source softening (citation laundering), or removal without replacement. See [tutorial](tutorials/combating-revisionism.md). | +| **Fan wiki canon tracking** | Compare the same fictional universe across competing wikis. Detect retcon divergence and measure by how much. | +| **Knowledge graph engineering** | Use `--depth forensic` to capture category and wikilink change events. Build an entity graph that evolves with the public record. | + +## Complementary technologies + +Refract pairs naturally with these modern tools. The event stream is standard JSON/NDJSON — anything that reads JSON or speaks HTTP can consume it. + +| Category | Technology | How they fit | +|----------|-----------|-------------| +| **Vector databases** | Pinecone, Weaviate, pgvector, Chroma | Store claim embeddings alongside stability metadata. Query: "find claims similar to X that are stable and well-sourced." | +| **RAG frameworks** | LangChain, LlamaIndex, Vercel AI SDK | Use Refract's stability/contestation signals as retrieval filters or reranking features. A [LangChain document loader](https://github.com/refract-org/refract-py/blob/main/src/refract_langchain.py) is available in the `refract-py` package. | +| **AI coding agents** | Claude Code, Cline, Codex CLI, OpenClaw | Agents connect via Refract's built-in MCP server (`refract mcp`) to read claim histories, track changes, and cite provenance in their reasoning. | +| **Python SDK** | `refract-py` ([GitHub](https://github.com/refract-org/refract-py)) | Typed dataclasses, pandas DataFrame integration, `RefractError` handling. Install: `pip install git+https://github.com/refract-org/refract-py.git` (not on PyPI; needs the Refract CLI on `PATH`). | +| **MCP (Model Context Protocol)** | Any MCP client (Claude Desktop, VS Code, Cursor, ChatGPT) | `refract mcp` is a native MCP server exposing tools for analyze, claim, export, cron, and classify. AI agents use these tools to retrieve claim history directly. | +| **Data lakes & query** | DuckDB, Apache Parquet, ClickHouse | Query `refract export --format ndjson` output with SQL. DuckDB can query JSONL files directly: `SELECT "eventType", count(*) FROM 'events.jsonl' GROUP BY "eventType";` | +| **Streaming** | Apache Kafka, Redpanda, Cloudflare Queues | Feed event streams into real-time claim monitoring pipelines. Each `EvidenceEvent` is a Kafka message with key by claimId for stateful processing. | +| **Visualization** | Observable Framework, Mermaid, D3 | `refract visualize --format mermaid` produces Mermaid diagrams. Observable Framework has a [data loader recipe](./sdk.md#observable-framework-data-loader). D3 reads event JSONL directly. | +| **Knowledge graphs** | RDF, SPARQL, Neo4j | Convert `wikilink_added`/`category_added` events into triple statements. Build an evolving entity graph where each edge has a revision timestamp. | +| **Model serving** | OpenAI API, DeepSeek, Ollama, vLLM, Workers AI | Plug any OpenAI-compatible endpoint into `refract classify` at each BYO-inference boundary. Workers AI runs models at the edge without managing servers. | +| **Local inference** | WebGPU, MLX, llama.cpp | Run detection models directly on-device — no API key needed. Refract defaults are mechanical (zero inference), but any boundary can be replaced with a local model via MCP sampling or Ollama. | +| **Notebooks** | Jupyter, Marimo, Observable notebooks | Load event JSONL into a DataFrame: `pd.read_json("events.jsonl", lines=True)`. Analyze claim stability, citation churn, and edit cluster patterns interactively. Marimo's reactive runtime is particularly well-suited for live event stream analysis. | +| **Serverless** | Cloudflare Workers, D1, R2, Queues | Import the library packages in a Worker (`nodejs_compat`; they use `node:crypto`) to fetch and diff revisions at the edge, store events in D1, export to R2, queue re-observations. The CLI needs Node or Bun and a filesystem, so it runs on a scheduled runner, not in a Worker — see [cron](cron.md#serverless-platforms). | + +## Production ingestion + +When consuming Refract events in a production pipeline, persist them to a queryable table. The schema below is a reference DDL that works with any relational database (D1, PostgreSQL, SQLite): + +```sql +CREATE TABLE refract_events ( + event_id TEXT PRIMARY KEY, + "eventType" TEXT NOT NULL, + schema_version TEXT NOT NULL, + from_revision_id INTEGER NOT NULL, + to_revision_id INTEGER NOT NULL, + section TEXT NOT NULL, + fact TEXT NOT NULL, + fact_detail TEXT, + analyzer_name TEXT, + analyzer_version TEXT, + input_hashes TEXT, -- JSON array of input hashes + parameters_json TEXT, -- JSON: effective FactProvenance parameters + observed_at TEXT NOT NULL, + batch_id TEXT NOT NULL, + page_title TEXT NOT NULL, + entity_id TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_refract_events_batch ON refract_events(batch_id); +CREATE INDEX idx_refract_events_type ON refract_events("eventType"); +CREATE INDEX idx_refract_events_page ON refract_events(page_title); +CREATE INDEX idx_refract_events_observed ON refract_events(observed_at); +``` + +### Repository pattern + +Use a single adapter file as the import boundary between Refract and your codebase. The adapter re-exports Refract functions and types; no other file imports from `@refract-org/*` directly. + +```typescript +// adapter.ts — single import boundary +export type { EvidenceEvent, FactProvenance, AnalyzerConfig } from '@refract-org/evidence-graph'; +export { EVENT_SCHEMA_VERSION, DEFAULT_ANALYZER_CONFIG, createEventIdentity } from '@refract-org/evidence-graph'; +export { sectionDiffer, citationTracker, revertDetector, detectEditClusters } from '@refract-org/analyzers'; +export { computeCertaintyProfile, computeDirectionSignal, extractQuantitativeFindings } from '@refract-org/analyzers'; +export { annotateEvents, buildRevisionEvents } from '@refract-org/analyzers'; // 0.5.1+ + +// repository.ts — D1 insert +export async function insertRefractEvents( + db: D1Database, + events: EvidenceEvent[], + batchId: string, + pageTitle: string, +): Promise<number> { + let count = 0; + for (const event of events) { + const fact = event.deterministicFacts?.[0]; + await db.prepare(` + INSERT OR IGNORE INTO refract_events (...) VALUES (...) + `).bind(/* event fields */).run(); + count++; + } + return count; +} +``` + +### Migration guide (0.3.x → 0.4.x) + +When upgrading from `@refract-org/evidence-graph@0.3.x` to `0.4.x`: + +1. Add `"sentence_modified"` to any `EventType` whitelists in your code +2. The `FactProvenance` interface now has an optional `parameters` field — consumers that read it gain provenance transparency but are not required to +3. The `EVENT_SCHEMA_VERSION` constant (`"0.4.0"`) and `CLAIM_IDENTITY_VERSION` (`"claimidentityv1"`) are new exports +4. Every event now carries `schemaVersion` — verify your DDL can store this field +5. `AnalyzerConfig` now supports `$version` for config pinning — optional, no migration action needed + +### Migration guide (0.4.x → 0.5.x) + +When upgrading from `@refract-org/cli@0.4.x` to `0.5.x`: + +1. The CLI binary is now `refract` (primary) and `wikihistory` (alias). Both work; `refract` is preferred. +2. New commands: `refract explore` (web UI), `refract mcp` (MCP server), `refract visualize` (diagram export) +3. `refract export --flatten` produces flat column-oriented CSV for analytical tools +4. Optional dependencies: `apache-arrow` (Parquet export), `parquet-wasm` (WASM Parquet reader) +5. No schema changes to `EvidenceEvent` — existing event consumers are forward-compatible + +See the [version compatibility table](schema#version-compatibility) for the full matrix. diff --git a/apps/docs/docs/eval.md b/apps/docs/docs/eval.md new file mode 100644 index 0000000..145b09a --- /dev/null +++ b/apps/docs/docs/eval.md @@ -0,0 +1,138 @@ +# Evaluation harness + +`@refract-org/eval` is the evaluation harness for measuring how accurately Refract's +deterministic analyzers detect real-world revision-history events. It compares the +pipeline's output against independently verified ground truth labels and produces +precision, recall, and F1 scores per analyzer. + +The eval package is designed for two purposes: +1. **Benchmarking**: Run against built-in benchmark pages to verify analyzer accuracy +2. **Extension**: Add new ground truth labels for new pages or new analyzer features + +## Quick start + +```bash +refract eval +``` + +This runs the evaluation harness against all built-in benchmark pages and prints +a summary table with per-analyzer precision, recall, and F1 scores. + +## Available benchmark pages + +| Page | Domain | What it tests | +|------|--------|---------------| +| `Xanoleptin` | Synthetic healthcare-like | Sentence lifecycle, template tracking, dispute signals | +| `Xanoleptin_guideline` | Synthetic guideline | Policy-linked page with sourcing and template patterns | +| `Luke_Skywalker` (Fandom) | Fictional canon | Cross-wiki detection, category shifts, retcon patterns | + +The Xanoleptin pages are synthetic — they contain controlled revision histories with +known ground truth labels. This means the eval produces deterministic, reproducible +scores. No model. No sampling. No variance. + +## Reading the output + +``` +=== Refract Eval Results === +Benchmark pages: 3 +Total events analyzed: 487 + +Analyzer precision/recall: + section-differ P=0.98 R=0.97 F1=0.975 + citation-tracker P=1.00 R=0.94 F1=0.969 + revert-detector P=0.91 R=0.89 F1=0.900 + template-tracker P=1.00 R=0.96 F1=0.980 +``` + +- **Precision (P)**: Of the events the analyzer produced, what fraction matched a + ground truth label? 1.00 means every event was verified. +- **Recall (R)**: Of all ground truth events, what fraction did the analyzer detect? + 0.89 means the analyzer missed 11% of real revert events. +- **F1**: Harmonic mean of precision and recall. Balances the tradeoff between + over-producing events and missing real ones. + +## Running against a specific page + +```bash +refract eval --page "Xanoleptin" +``` + +## Running against custom ground truth + +```bash +refract eval --ground-truth path/to/labels.json +``` + +Ground truth labels use this format: + +```json +[ + { + "id": "xanoleptin:rev-50:citation-added-geology", + "pageTitle": "Xanoleptin", + "eventType": "citation_added", + "fromRevisionId": 50, + "toRevisionId": 51, + "section": "Geology", + "fact": "Citation added in section Geology", + "source": "hand-labeled", + "labeler": "your-name", + "labeledAt": "2025-01-15T00:00:00Z" + } +] +``` + +## Adding a new benchmark page + +1. Create a synthetic revision history (see `demo-data/` in the refract monorepo for examples) +2. Hand-label ground truth events for every deterministic change in the revision range +3. Add the page title and ground truth labels to the eval package's benchmark registry +4. Run `refract eval --page "NewPage"` to verify the analyzer detects your labeled events + +## Programmatic use + +```typescript +import { createEvalHarness, validateAgainstGroundTruth } from "@refract-org/eval"; +import type { EvalHarness, EvalResult } from "@refract-org/eval"; + +const harness = createEvalHarness(); +const result: EvalResult = await harness.evaluate({ + pageTitle: "Xanoleptin", + events: pipelineOutput, + groundTruth: labels, +}); + +console.log(`F1: ${result.f1.toFixed(3)}`); +``` + +## Architecture + +The eval harness operates independently: +- **Input**: Pipeline events (from deterministic analyzers) + ground truth labels (hand-labeled) +- **Process**: Matches events to labels by page title, revision range, and fact content +- **Output**: Precision, recall, F1 per analyzer — no feedback into the pipeline + +This separation prevents the eval from becoming a self-fulfilling prophecy: the +analyzers are not tuned to match the eval labels, and the eval labels are never +derived from analyzer output. + +## Baseline superiority + +When integrating Refract signals with other systems, use `computeBaselineSuperiority()` +to validate that your integrated signal outperforms simple Refract-derived baselines: + +```typescript +import { computeBaselineSuperiority } from "@refract-org/eval"; + +const result = computeBaselineSuperiority({ + integratedLeadTimeDays: signal.leadTimeDays, + mentionCount: snapshot.metrics.mentionCount, + revertCount: snapshot.metrics.revertCount, + refractEventSummary: { totalEvents: 47, revertCount: 12 }, +}); +// → { beatsBaseline: true, margin: 3.2 } +``` + +This prevents overfitting: if your downstream system's signals don't beat simple +counts from Refract's deterministic event stream, your interpretation layer adds +no value. diff --git a/apps/docs/docs/events.md b/apps/docs/docs/events.md new file mode 100644 index 0000000..3fc79b4 --- /dev/null +++ b/apps/docs/docs/events.md @@ -0,0 +1,87 @@ +# Event taxonomy + +Each Refract event describes what changed at a revision boundary. Events are deterministic — the same input always produces the same output. Events chain into claim-state timelines across revisions — see [Temporal Model](./temporal-model.md) for how events form replayable history. + +## Claim lifecycle + +| Event type | Trigger | Example | +|---|---|---| +| `sentence_first_seen` | New sentence text appears | A sentence about a company appears for the first time | +| `sentence_removed` | Existing sentence deleted entirely | A controversial paragraph is removed | +| `sentence_modified` | Same sentence text changed (similarity threshold match) | Sentence rewording detected by word-overlap ratio | +| `sentence_reintroduced` | Previously removed sentence returns | A deleted sentence is restored in a later edit | + +## Citation changes + +| Event type | Trigger | +|---|---| +| `citation_added` | New `<ref>` tag added | +| `citation_removed` | Existing `<ref>` tag removed | +| `citation_replaced` | One citation swapped for another in the same position | + +## Template changes + +| Event type | Trigger | +|---|---| +| `template_added` | New `{{template}}` added (e.g., `{{citation needed}}`, `{{NPOV}}`) | +| `template_removed` | Template removed | +| `template_parameter_changed` | Template parameters modified (e.g., date updated on a maintenance tag) | + +## Section and page structure + +| Event type | Trigger | +|---|---| +| `section_reorganized` | Sections added, removed, or reordered | +| `lead_promotion` | Content moves from body into the lead section | +| `lead_demotion` | Content moves from lead into the body | +| `page_moved` | Page renamed (detected via logevents API) | + +## Links and categories + +| Event type | Trigger | +|---|---| +| `wikilink_added` | New `[[internal link]]` added | +| `wikilink_removed` | Internal link removed | +| `category_added` | New `[[Category:...]]` added | +| `category_removed` | Category removed | + +## Content conflict + +| Event type | Trigger | +|---|---| +| `revert_detected` | Edit summary matches revert pattern (e.g., "revert", "rv", "undo") | +| `edit_cluster_detected` | 3+ edits within a configurable time window (default: 1 hour). **`editorCount`**: number of unique editors in the cluster (no identities recorded) | + +## Talk page activity + +| Event type | Trigger | +|---|---| +| `talk_page_correlated` | Article revision has a nearby talk page revision (default: 7 days before, 3 days after) | +| `talk_thread_opened` | New discussion thread created on talk page | +| `talk_thread_archived` | Thread closed/archived | +| `talk_reply_added` | Reply posted in an existing thread | +| `talk_activity_spike` | Talk page edits exceed 3x the moving average on a given day | + +## Access control + +| Event type | Trigger | +|---|---| +| `protection_changed` | Page protection level changed (protect, unprotect, modify) | + +## Event envelope + +All events share a common structure: + +```typescript +interface EvidenceEvent { + eventType: EventType; // one of the types above + fromRevisionId: number; // parent revision + toRevisionId: number; // revision where the change occurred + section: string; // section name + before: string; // text/state before + after: string; // text/state after + deterministicFacts: DeterministicFact[]; // why this event was produced + layer: EvidenceLayer; // observed, policy_coded, model_interpretation, speculative, unknown + timestamp: string; // ISO 8601 +} +``` diff --git a/apps/docs/docs/faq.md b/apps/docs/docs/faq.md new file mode 100644 index 0000000..70a28b2 --- /dev/null +++ b/apps/docs/docs/faq.md @@ -0,0 +1,103 @@ +# Troubleshooting / FAQ + +## Rate limits + +Refract spaces its requests to the MediaWiki API (100 ms apart by default) and retries a `429` or `5xx` up to three times, waiting for the server's `Retry-After`. If you still see `429 Too Many Requests`, wait a few minutes before retrying: Wikimedia rate-limits by client, and a shared IP (a CI runner, a cloud host) can exhaust it for everyone on it. The CLI does not send `maxlag`; the `MediaWikiClient` library takes a `maxlag` option (`@refract-org/ingestion` 0.3.2+). `-c` / `--cache` avoids re-fetching, but needs `@refract-org/persistence`, which is only available from a source checkout. + +## "Page too large" errors + +Wikipedia pages with thousands of revisions may exceed the default fetch limit. Use `--from <revId> --to <revId>` to scope to a specific range, or increase depth gradually: + +```bash +# Start with the last 50 revisions +refract analyze "Earth" --from <recent-rev-id> +``` + +## Authentication errors + +| Error | Likely cause | Fix | +|-------|-------------|-----| +| `401 Unauthorized` | Missing or invalid `--api-key` | Check the API token is correct | +| `403 Forbidden` | Token lacks permission | Verify token scope with wiki admin | +| Connection refused | Wrong API URL | Ensure URL ends in `/api.php` | + +## Private wikis + +For private or authenticated MediaWiki instances, provide credentials: + +```bash +refract analyze "Page" \ + --api https://internal.wiki/api.php \ + --api-key <token> +``` + +Supported auth methods: bearer token (`--api-key`), basic auth (`--api-user` + `--api-password`), OAuth2. + +## Cache issues + +If results seem stale, clear the cache: + +```bash +rm -rf ~/.wikihistory/ +``` + +Or use `--cache-dir` to point at a fresh location. The default cache directory is `~/.wikihistory/`. + +## No events produced + +If `refract analyze` returns no events, the page may not have changed in the requested revision range. Try expanding the range or removing `--from`/`--to` to fetch the most recent revisions. + +## Cross-wiki diff returns no results + +`refract diff` compares a topic across two MediaWiki instances. Each wiki must have a page with the given title. Verify: + +```bash +refract analyze "Topic" --api <wiki-a-url> +refract analyze "Topic" --api <wiki-b-url> +``` + +## Performance and scaling + +### How many revisions per page? + +`brief` depth (section differ only) processes ~50 revisions per second. `detailed` +depth (all standard analyzers) processes ~20 revisions per second. `forensic` depth +(with talk page correlation and edit cluster detection) processes ~10 revisions per +second. These are single-core measurements on an M-series Mac; results scale linearly +with additional pages (Refract processes pages sequentially). + +### Memory usage + +Memory is proportional to revision count, not revision size. Each revision's wikitext +is held in memory during diff comparison, then discarded. A page with 500 revisions at +`detailed` depth uses ~200 MB peak memory. For very large pages (5,000+ revisions), +use `--from` / `--to` to paginate into batches of ~500 revisions. + +### When to paginate + +Use `--from` / `--to` or `--since` when: +- The page has more than 1,000 revisions +- You need faster processing (narrower ranges process faster) +- You're running on a resource-constrained machine +- You want to process the page in parallel across multiple ranges + +For batch processing many pages, use `--pages-file`: + +```bash +refract analyze --pages-file topics.txt --depth detailed -c +``` + +### Cache behavior + +The cache stores fetched revisions in `~/.wikihistory/` (default: `~/.wikihistory/refract.db`). Each revision costs +~2–10 KB of disk space. A page with 500 revisions uses ~5 MB of cache. The cache is +persistent — clear it with `rm -rf ~/.wikihistory/` when disk space is tight. + +### API rate limits + +Refract sends at most one request per 100 ms from a client (about 10 per second) and +retries a `429` or `5xx` after the server's `Retry-After`. `maxlag` is a parameter a +client chooses to send; the CLI does not send it, and library users can set it on +`MediaWikiClient` (`@refract-org/ingestion` 0.3.2+). For non-Wikipedia MediaWiki instances, rate limits +vary. If you hit 429 errors consistently, increase poll intervals or contact the +wiki's administrator. diff --git a/apps/docs/docs/frontier-use-cases.md b/apps/docs/docs/frontier-use-cases.md new file mode 100644 index 0000000..71b1da8 --- /dev/null +++ b/apps/docs/docs/frontier-use-cases.md @@ -0,0 +1,326 @@ +# Frontier Use Cases for Claim-Provenance Infrastructure + +## Core Primitive + +Refract turns version history into: + +**claim + source + wording + placement + stability + time** + +It does not report what a document says now. It explains how a claim entered, changed, was sourced, challenged, moved, stabilized, weakened, or disappeared. + +--- + +## Retrieval & Search + +### AI Retrieval Provenance + +AI systems retrieve text as if every sentence is equally stable. They lack signals about what has been contested, recently changed, or stabilized through editorial scrutiny. + +Refract exposes per-claim metadata: + +```json +{ + "claim": "Entity X knew about the defect before launch", + "status": "included_with_attribution", + "first_seen": "2021-04-03", + "stability": "stable_after_regulatory_source_added", + "contestation": "previously_reverted_twice", + "placement": "body_not_lead", + "source_support": "regulatory_report_and_secondary_news" +} +``` + +This helps retrieval systems decide whether to present a claim directly, hedge it, attribute it, surface uncertainty, or deprioritize it. + +### Claim-Level Search + +Search across claim histories, not documents. + +Example queries Refract can support: + +- Claims about an entity that stabilized after a given date +- Claims removed as unsourced +- Claims supported by a specific source type +- Claims that moved from lead to body +- Claims that softened after external events + +This is a new search primitive over version-history data. + +### AI Evaluation & Temporal Leakage Detection + +Use claim histories to test AI systems for temporal leakage. + +Questions answerable with Refract data: + +- Was this claim public before the model's knowledge cutoff? +- Did the supporting source appear later? +- Did the wording exist at the time? +- Is the model using future knowledge? + +Refract provides temporal ground truth for benchmarking retrieval truthfulness, training-data audits, and knowledge-cutoff testing. + +--- + +## Claim History & Memory + +### Public Claim Memory + +Create a temporal knowledge graph for claims. + +Not: What does the article say now? +But: What has this claim said over time? + +Refract tracks across revisions: + +- Origin (first appearance) +- Source support evolution +- Wording changes +- Removals and restorations +- Disputes and challenges +- Current status + +### Knowable-at-the-Time Reconstruction + +Reconstruct what was publicly knowable at a specific date. + +Example: As of March 1, 2023, what sources supported a given claim? + +Refract's revision-level timestamping and deterministic extraction make this auditable, with no hindsight leakage. + +### Institutionalization of Knowledge + +Detect when a claim moves from rumor into durable public reference. + +Pattern Refract can surface: + +rumor → local reporting → national reporting → institutional source → article body → article lead → stable public reference + +Core question: When did this stop being chatter and become part of the durable public record? + +### Claim Survivorship + +Measure which claims survive scrutiny. + +Refract can classify claims by survival pattern: + +- Failed claim (removed and never restored) +- Temporary claim (appeared briefly) +- Stable claim (survives extended period unchanged) +- Stable only with attribution +- Stable only in body, not lead +- Repeatedly contested +- Removed under policy concern + +--- + +## Source & Evidence Integrity + +### Source Escalation & Degradation + +Track how source support changes across revisions. + +Escalation patterns Refract detects: + +unsourced → local source → national source → regulatory source → court ruling → academic analysis + +Degradation patterns: + +secondary source removed → primary source remains → citation-needed tag → claim removed + +### Article Voice & Attribution + +Distinguish who is making a claim — not just what the claim says. + +States Refract detects: + +- Direct assertion in article voice +- Attributed to a named source +- Attributed to critics or regulators +- Reported allegation +- Disputed claim +- Removed claim + +Example: "The company concealed the defect" versus "Regulators alleged that the company failed to disclose the defect" should not be treated as equivalent. + +### Semantic Intensity Drift + +Detect language getting stronger, weaker, broader, or narrower. + +Example shifts Refract can surface: + +- caused → contributed to +- concealed → failed to disclose +- proved → alleged +- will → may +- riot → protest → unrest + +### Misinformation & Circular Sourcing + +Track weak, false, or fringe claims as they move through public systems. + +Patterns Refract detects: + +- Claim introduced without source +- Reverted as unsourced +- Reintroduced with weak source +- Challenged and removed +- Returns in softened form + +Citogenesis detection: a claim originates on Wikipedia, gets cited by an external source, and Wikipedia later cites that source to verify the original claim — creating a self-referential loop. + +### Source Dependency Mapping + +Map which claims depend on which sources. + +If a source is corrected, discredited, or retracted, Refract can identify all affected claims across the knowledge base, enabling cascade analysis. + +--- + +## Editorial Mechanics + +### Editorial Consensus Mapping + +Show how a page or claim reaches a stable form. + +Signals Refract surfaces: + +- Talk-page discussion activity +- Revert cycles +- Policy references (BLP, NPOV, RS, UNDUE) +- Source replacement patterns +- Compromise wording emergence +- Page protection events +- Post-discussion stabilization + +### Prominence & Placement + +Track not just whether a claim exists, but where it appears. + +States Refract detects: lead, infobox, body, controversy section, footnote, caption, table, category, talk page only, removed. + +Meaningful transitions: body → lead (increased prominence), lead → body (reduced prominence), removed → restored (narrative resurrection). + +### Cross-Language Narrative Divergence + +Compare the same claim across language editions. + +Refract can surface differences in: wording, sources cited, prominence, stability timelines, and terminology across language versions of the same topic. + +--- + +## Change Monitoring + +### Public Reference Monitoring + +Monitor how an entity or topic changes in public reference sources. + +What Refract surfaces: + +- New claims added +- Claims moving into prominent positions +- Regulatory or institutional sources added or removed +- Policy templates applied (BLP, NPOV) +- Revert chains triggered +- Page protections activated +- Cross-language spread detected + +### Knowledge Volatility Metrics + +Create quantitative metrics from claim-history data: + +- Claim volatility over time +- Source churn rate +- Lead section instability +- Controversy intensity +- Cross-language divergence rate +- Edit-burst frequency + +### Change Summaries + +Generate structured summaries from claim-history analysis. + +Refract can produce outputs like: + +Three public claims changed materially this period. +1. One stabilized after regulatory sourcing. +2. One moved from body to lead. +3. One remains disputed after repeated reverts. + +Each statement links to the supporting evidence. + +--- + +## Domain Extensions + +### Policy, Regulation & Standards Tracking + +Apply the same engine to public rules and guidance documents. + +Changes Refract can detect: requirements added or weakened, mandatory → optional transitions, deadline extensions, scope narrowing, enforcement language removed. + +### Enterprise Knowledge Governance + +Apply claim provenance to internal knowledge bases. + +Detect across internal documentation: unsupported claims, stale procedures, contradictions between docs, obsolete product claims, policy drift, claims without sources. + +### Corporate Disclosure Tracking + +Track changes in publicly filed corporate documents. + +Refract can surface when commitments, risk factors, deadlines, or claims change in investor presentations, earnings materials, ESG reports, terms of service, or privacy policies. + +### Legal-Document History + +Apply the same engine to contracts, policies, and versioned legal documents. + +Detect: obligations added or narrowed, warranties softened, termination rights expanded, indemnity strengthened, compliance requirements removed. + +Example shifts: shall → may, all damages → direct damages, must notify within 24 hours → must notify without undue delay. + +### Policy and Legislative Text Reuse + +Track copied boilerplate and model legislation across jurisdictions. + +`detectTextPropagation(source, target)` compares two texts you supply: the Jaccard similarity of their 5-token shingles, the verbatim spans of 8 or more tokens they share, and a flag when similarity reaches 0.15 or the shared spans total 16 tokens. It does not find candidate sources or say which text came first. +- Find clauses copied verbatim from one jurisdiction's text into another's. +- Measure how much of a bill's text it shares with a model bill. + +### Citation Concentration + +`analyzeCitationNetwork(citations)` counts a page's citations per domain and per source and computes a Herfindahl-Hirschman index over the domain shares, flagging concentration above 0.4 when there are at least three citations. +- Flag pages where most citations come from one domain. +- Run it on each revision's citations to see when a page's sourcing narrowed. + +### Verification Bundles + +`refract export --proof` writes a replay manifest, its events and a Merkle inclusion proof per event hash to one JSON file. `refract verify` rehashes each event against the manifest and checks the manifest hash, the Merkle root and each proof, and `--html` writes the result as an HTML file. +- Publish a Merkle root; anyone who runs the same Refract version over the same revision range gets the same root. +- Nothing in a bundle is signed: a pass shows the events match the manifest, not who made the bundle, and fields outside the event hash are not covered. See [what it checks](bundle-manifest.md#checking-a-bundle). + +--- + +## Universal Document-History + +### General Document-History Operating System + +The broadest application. For any versioned document system, Refract answers: + +- What changed? +- What claim or obligation did it affect? +- What source or evidence supported it? +- Was it challenged? +- Did it stabilize? +- What is its current status? + +This applies across: public wikis, policy drafts, standards documents, open-source documentation, collaborative prose, and any versioned text corpus. + +--- + +## Synthesis + +The reusable primitive is: + +**claim + source + wording + placement + stability + time** + +That primitive can power use cases across retrieval, research, monitoring, governance, and knowledge management — without deciding what is true. diff --git a/apps/docs/docs/glossary.md b/apps/docs/docs/glossary.md new file mode 100644 index 0000000..ff93cbc --- /dev/null +++ b/apps/docs/docs/glossary.md @@ -0,0 +1,33 @@ +# Glossary + +**Claim** — A distinct statement of fact within an article, identified by its text content. Claims are tracked across revisions via `createClaimIdentity` in `@refract-org/evidence-graph`. Not to be confused with "claim" in a legal or insurance context. + +**BYO-inference boundary** — A point in the pipeline where a heuristic (e.g., regex revert detection, word-overlap threshold) can be replaced by a model call. Each boundary is a typed function signature with a mechanical default. When a model is plugged in, the event's `FactProvenance.parameters` records which path was taken. This is the architectural spine: Refract stays mechanical, but the points where judgment enters are explicit, typed, and auditable. + +**Deterministic fact** — A structured statement produced by an analyzer explaining why an event was emitted. Each fact includes the analyzer name, version, input hashes for reproducibility, and optional effective parameters (when non-default config was used). + +**Deterministic observation engine** — Refract's core design: given the same revision range, the same events are always produced. No model, no randomness, no variance. + +**Downstream consumer** — A system that consumes Refract's event stream without modifying it, to attach domain-specific interpretation. Examples include healthcare claim review (NextConsensus), AI training data curation pipelines, provenance-aware RAG systems, regulatory/policy change monitors, competitive intelligence platforms, fact-checking tools, academic research on knowledge dynamics, journalism forensics tooling, and fan wiki canon tracking. + +**Edit cluster** — A group of 3+ edits within a configurable time window, detected by `detectEditClusters()`. High cluster activity signals concentrated contestation — the same claim being edited repeatedly by one or more editors. Window size and minimum group size are configurable via `AnalyzerConfig`. + +**Event** — A unit of change between two revisions. Every event has an `eventType`, revision range (`fromRevisionId` → `toRevisionId`), section context, before/after snapshots, and deterministic facts. See [schema](schema.md). + +**Evidence layer** — A classification of how an event was produced: `observed` (directly from diff), `policy_coded` (matched against a rule), `model_interpretation` (set by downstream), `speculative` (inferred), or `unknown`. + +**Ground truth** — Independently verified outcome labels (talk page consensus, RFC closures, ArbCom decisions) stored in the `@refract-org/eval` package. Used to validate pipeline output, never redefined by it. + +**L1 / L2** — Legacy terminology for Refract's two architectural layers: deterministic analysis (formerly L1 — what changed, byte-for-byte reproducible) and independent ground truth (formerly L2 — talk page consensus, RFC closures, ArbCom decisions). The preferred terms are "deterministic" and "ground truth." + +**MediaWiki** — The open-source wiki engine used by Wikipedia, Fandom, and thousands of other wikis. Refract works with any MediaWiki instance that exposes an `api.php` endpoint. + +**ObservationReport** — A structured aggregate produced by the pipeline that groups events by claim, with a `ClaimLedger` tracking each claim's lifecycle (first seen, removed, modified, reintroduced) across revision history. Output via `--report` flag. + +**`sentence_modified`** — An event type emitted when sentence text changes but remains above the similarity threshold (word-overlap ratio, default 0.8). Captures rewording, minor edits, and phrasing changes without treating the sentence as removed-and-reintroduced. + +**Provenance** — Metadata attached to each deterministic fact recording which analyzer produced it, what version, and which input data was used. Enables auditability. + +**Revision** — A single version of a page. Revisions are identified by numeric IDs and ordered by timestamp. + +**Revision range** — A contiguous span of revisions between a `fromRevisionId` and `toRevisionId`. Refract diffs adjacent revisions within the range to produce events. diff --git a/apps/docs/docs/index.md b/apps/docs/docs/index.md new file mode 100644 index 0000000..8081a4b --- /dev/null +++ b/apps/docs/docs/index.md @@ -0,0 +1,167 @@ +# Refract: deterministic observation engine for revision histories + +**Refract reveals how claims change across public revision histories — and gives AI researchers reproducible evidence for model evaluation.** + +```bash +npx @refract-org/cli analyze "Earth" --depth brief +``` + +<div class="hero-links"> + <a href="demo/" class="button primary">Demo</a> + <a href="quickstart/" class="button secondary">Quick start</a> +</div> + +Node.js 20+ or Bun 1.2+ · Git 2.x · Any MediaWiki instance + +Refract reads a page's revision history and emits a typed event for each change it finds: sentences first seen, modified, removed or reintroduced; citations added, removed or replaced; template, link, category and section changes; reverts; talk page activity. + +## Features + +<div class="features-grid"> + <div class="feature-card"> + <h3>Deterministic</h3> + <p>The same revisions produce byte-identical events on every run. No model is called.</p> + </div> + <div class="feature-card"> + <h3>Provenance-tagged</h3> + <p>Every event carries revision, section, timestamp, and analyzer identity.</p> + </div> + <div class="feature-card"> + <h3>BYO-inference boundaries</h3> + <p>Every threshold is a configurable boundary. Plug a model where you need one; defaults run offline.</p> + </div> + <div class="feature-card"> + <h3>26 event types</h3> + <p>Sentence lifecycles, citations, reverts, talk pages, protection levels, and edit clusters.</p> + </div> + <div class="feature-card"> + <h3>Hash-verifiable exports</h3> + <p>Evidence bundles carry a SHA-256 hash and replay manifests a Merkle root, so a recipient can check that events were not altered.</p> + </div> + <div class="feature-card"> + <h3>MCP server</h3> + <p><code>refract mcp</code> serves six tools — analyze, claim, export, cron, classify and get_statement_history — to any MCP client over stdio.</p> + </div> +</div> + +## Quick start + +```bash +# 1. Analyze a page +npx @refract-org/cli analyze "Earth" --depth brief + +# 2. Explore results in the web UI +refract explore "Earth" + +# 3. Connect an AI agent +refract mcp + +# 4. Export as structured data +refract export "Earth" --format ndjson > earth-events.jsonl + +# 5. Save an evidence bundle (revisions, events and a SHA-256 hash) +refract export "Earth" --bundle > earth-bundle.json + +# 6. Output an ObservationReport with claim lifecycle +refract analyze "Earth" --report > earth-report.json +``` + +<div class="split-columns"> + <div class="column-card focus"> + <h4>What Refract is</h4> + <ul> + <li><strong>Deterministic:</strong> The same input gives the same output.</li> + <li><strong>Provenance-tagged:</strong> Identifies source revision, timestamp, and analyzer version.</li> + <li><strong>Verifiable:</strong> Replay manifests carry a Merkle root over the event hashes.</li> + <li><strong>Open layer:</strong> A raw observation feed designed for downstream processing.</li> + </ul> + </div> + <div class="column-card out-of-scope"> + <h4>What Refract is not</h4> + <ul> + <li><strong>No model interpretation:</strong> Does not decide semantic meaning or intent.</li> + <li><strong>No truth claims:</strong> Observes <em>what</em> changed, not <em>which</em> version is correct.</li> + <li><strong>No editor profiles:</strong> Does not rank, grade, score, or track editors.</li> + <li><strong>No policy judgments:</strong> Leaves decision relevance and rules to downstream tools.</li> + </ul> + </div> +</div> + +## By use case + +<div class="usecase-grid"> + <div class="usecase-card"> + <h3>Journalist / Researcher</h3> + <p>Trace claim evolution and sources across revision history.</p> + <div class="usecase-steps"> + <a href="demo/">Demo</a> → <a href="quickstart/">Quick start</a> → <a href="tutorials/wikipedia-history/">Wikipedia history</a> + </div> + </div> + <div class="usecase-card"> + <h3>Data scientist / OSINT</h3> + <p>Extract NDJSON events and run columnar SQL analysis in DuckDB.</p> + <div class="usecase-steps"> + <a href="tutorials/python-sdk/">Python SDK</a> → <a href="python-sdk/">SDK reference</a> → <a href="notebooks/">Notebooks</a> + </div> + </div> + <div class="usecase-card"> + <h3>ML / RAG engineer</h3> + <p>Score retrieved texts by stability and provenance quality indicators.</p> + <div class="usecase-steps"> + <a href="tutorials/rag-provenance/">RAG provenance</a> → <a href="tutorials/python-sdk/">Python SDK</a> → <a href="tutorials/byo-inference/">BYO-inference</a> + </div> + </div> + <div class="usecase-card"> + <h3>Policy / Compliance</h3> + <p>Re-check pages on a schedule and send Slack, email or webhook notifications.</p> + <div class="usecase-steps"> + <a href="tutorials/scheduled-monitoring/">Monitoring</a> → <a href="cli/">CLI cron</a> → <a href="tutorials/citation-churn/">Citation churn</a> + </div> + </div> + <div class="usecase-card"> + <h3>AI agent developer</h3> + <p>Give agents Refract's tools through the built-in MCP server.</p> + <div class="usecase-steps"> + <a href="tutorials/mcp-agent/">MCP tutorial</a> → <a href="mcp/">MCP reference</a> → <a href="tutorials/byo-inference/">BYO-inference</a> + </div> + </div> + <div class="usecase-card"> + <h3>AI model evaluator</h3> + <p>Test for temporal leakage and recency cutoffs against revision histories.</p> + <div class="usecase-steps"> + <a href="tutorials/model-evaluation/">Model evaluation</a> → <a href="tutorials/rag-provenance/">RAG provenance</a> → <a href="frontier-use-cases/">Frontier use cases</a> + </div> + </div> +</div> + +## Ecosystem + +Refract is one tool in a family of three: + +| Tool | What it does | Install | +|------|-------------|---------| +| **Refract** | CLI + TypeScript SDK — the deterministic observation engine | `npm install -g @refract-org/cli` | +| **[Python SDK](python-sdk)** | Typed Python wrapper — pandas DataFrames, notebooks, LangChain | `pip install git+https://github.com/refract-org/refract-py.git` | +| **[Refract UI](visualizer)** | Browser visualizer — drag-and-drop JSONL, timelines, word-level diffs | `git clone refract-ui && bun run dev` | + +A typical workflow: **analyze** with Refract, **export** as NDJSON, then explore in Python or the UI. + +<p style="font-size: 0.875rem; color: var(--text-muted); margin-top: 1rem;"> + <strong>Other pathways:</strong> + System Integrators (<a href="sdk/">SDK Reference</a> · <a href="downstream/">Production DDL</a> · <a href="tutorials/private-wiki/">Private Wikis</a>) · + Engine Contributors (<a href="tutorials/custom-analyzer/">Custom Analyzer</a> · <a href="tutorials/custom-eval/">Custom Eval</a> · <a href="architecture-decisions/">Architecture Decisions</a>) +</p> + +## Further uses + +Tutorials, and a longer list of use cases, built on the event stream: + +| Capability | Read | +|---|---| +| **Claim-level search** | [Frontier use cases](frontier-use-cases.md) — Search claim histories, not documents. "Claims removed as unsourced." "Claims that softened after events." | +| **LLM summarization** | [Summarization tutorial](tutorials/summarization.md) — Pipe events through any model. Get human-readable change reports with audit trail. | +| **Non-Wikipedia sources** | [Custom adapter tutorial](tutorials/custom-adapter.md) — Confluence, GitHub wikis, Notion. Same analyzers, different data. | + +## License + +AGPL-3.0. Built and maintained by [NextConsensus](https://nextconsensus.com). diff --git a/apps/docs/docs/install.md b/apps/docs/docs/install.md new file mode 100644 index 0000000..7981cf6 --- /dev/null +++ b/apps/docs/docs/install.md @@ -0,0 +1,61 @@ +# Installation + +## System requirements + +- **Node.js** 20+ or **Bun** v1.2+ +- **macOS** or **Linux** (Windows not yet supported) +- **Bun and a source checkout** for SQLite persistence (`--cache`): it needs `@refract-org/persistence`, which uses `bun:sqlite` and is not published to npm. Analysis-only workflows work with Node.js. + +## Zero install + +No download needed — runs directly from npm: + +```bash +npx @refract-org/cli analyze "Earth" --depth brief +``` + +With bun (if installed): + +```bash +bunx @refract-org/cli analyze "Earth" --depth brief +``` + +## Local install + +```bash +# with bun +bun add -g @refract-org/cli + +# with npm +npm install -g @refract-org/cli +``` + +Then use the `refract` command directly (or `wikihistory` as an alias): + +```bash +refract analyze "Earth" --depth brief +``` + +## From source + +```bash +git clone https://github.com/refract-org/refract.git +cd refract +bun install +bun run build +node packages/cli/dist/src/cli.js --version +``` + +Run the CLI as `node packages/cli/dist/src/cli.js <command>`, or put it on your +`PATH` with `bun link` inside `packages/cli`. + +## Verify installation + +```bash +refract --version +``` + +A version number means the CLI started; on 2026-09-29 npm's is 0.5.17. The CLI +before it on npm, 0.5.7, does not start and fails here with `SyntaxError: The +requested module '@refract-org/analyzers' does not provide an export named …`; +`npm install -g @refract-org/cli` replaces it. diff --git a/apps/docs/docs/integrations.md b/apps/docs/docs/integrations.md new file mode 100644 index 0000000..5fd9520 --- /dev/null +++ b/apps/docs/docs/integrations.md @@ -0,0 +1,124 @@ +# Integrations + +Refract produces a deterministic event stream — standard NDJSON with typed schemas, deterministic hashes, and provenance metadata. Anything that reads JSON or speaks HTTP can consume it. This page catalogs every supported integration. + +## By pattern + +| Pattern | Best for | Start here | +|---|---|---| +| **CLI pipeline** | Shell scripts, cron jobs, CI/CD | [CLI reference](cli.md) | +| **SDK (TypeScript)** | Node.js apps, custom analyzers | [SDK reference](sdk.md) | +| **SDK (Python)** | Jupyter, pandas, data science | [Python SDK tutorial](tutorials/python-sdk.md) | +| **MCP server** | AI coding agents (Claude Code, Cursor, VS Code) | [MCP agent tutorial](tutorials/mcp-agent.md) | +| **Direct NDJSON** | DuckDB, SQL, shell pipelines | [Analytics with DuckDB](analytics.md) | +| **HTTP/webhook** | Slack, email, custom alerting | [Scheduled monitoring tutorial](tutorials/scheduled-monitoring.md) | + +## By tool + +### Databases and query engines + +| Tool | How | Docs | +|---|---|---| +| **DuckDB** | Query NDJSON directly: `SELECT * FROM 'events.jsonl'` | [Analytics](analytics.md) | +| **SQLite** | `@refract-org/persistence` wraps `bun:sqlite`. Stores events + revisions locally. | [SDK](sdk.md) | +| **PostgreSQL / D1** | Reference DDL with indexes for production ingestion. | [Downstream](downstream.md#production-ingestion) | +| **ClickHouse** | Columnar analytics on event streams. Query NDJSON via `file()` table function. | [Downstream](downstream.md#complementary-technologies) | + +### AI and ML + +| Tool | How | Docs | +|---|---|---| +| **LangChain** | `refract-py/src/refract_langchain.py` — loads events as `Document` objects with stability metadata for provenance-aware RAG. | [RAG tutorial](tutorials/rag-provenance.md) | +| **LlamaIndex** | Load events as nodes, use stability score as metadata filter. | [RAG tutorial](tutorials/rag-provenance.md) | +| **Vercel AI SDK** | Use stability signals as reranking features in retrieval chains. | [RAG tutorial](tutorials/rag-provenance.md) | +| **OpenAI / DeepSeek / Ollama** | Plug any OpenAI-compatible endpoint into `refract classify` at BYO-inference boundaries. | [BYO-inference tutorial](tutorials/byo-inference.md) | +| **MCP clients** | Claude Code, VS Code, Cursor, Claude Desktop connect via `refract mcp`. Agents call Refract tools directly. | [MCP agent tutorial](tutorials/mcp-agent.md) | + +### Model evaluation + +| Tool | How | Docs | +|---|---|---| +| **Temporal leakage detection** | `refract_eval.build_leakage_benchmark()` — find claims that appeared after a model's cutoff | [Model evaluation tutorial](tutorials/model-evaluation.md) | +| **Retrieval quality scoring** | `refract_eval.score_retrieval_quality()` — score passages by stability | [RAG tutorial](tutorials/rag-provenance.md) | +| **Provenance hallucination** | `refract_eval.check_provenance()` — verify if a source ever existed | [Model evaluation tutorial](tutorials/model-evaluation.md) | +| **Benchmark submission** | Standard 10-page benchmark, submission format, reproducibility requirements | [BENCHMARK.md](https://github.com/refract-org/refract/blob/main/BENCHMARK.md) | +| **Colab notebook** | Ready-to-run notebook — `pip install git+https://github.com/refract-org/refract-py.git` and go | [notebooks/model-evaluation.ipynb](notebooks/model-evaluation.ipynb) | + +### Notebooks and visualization + +| Tool | How | Docs | +|---|---|---| +| **Jupyter** | `refract-py` → pandas DataFrame → matplotlib/Altair. | [Python SDK tutorial](tutorials/python-sdk.md) | +| **Observable Framework** | [Data loader recipe](./sdk.md#observable-framework-data-loader). | [Notebooks](notebooks.md) | +| **Marimo** | Reactive notebook runtime for live event stream analysis. | [Notebooks](notebooks.md) | +| **Mermaid / Graphviz** | `refract visualize --format mermaid` produces diagrams. | [CLI](cli.md#refract-visualize) | + +### Monitoring and alerting + +| Tool | How | Docs | +|---|---|---| +| **Cron / systemd timers** | `refract cron` — one-shot re-observation for scheduled monitoring. | [Scheduled monitoring tutorial](tutorials/scheduled-monitoring.md) | +| **GitHub Actions** | `.github/workflows/observe.yml` — daily scheduled observation with auto-commit. | [Scheduled monitoring tutorial](tutorials/scheduled-monitoring.md) | +| **Slack** | `--notify-slack` posts Block Kit-formatted messages with event summaries. | [Scheduled monitoring tutorial](tutorials/scheduled-monitoring.md) | +| **Email** | `--notify-email` sends SMTP alerts. | [Scheduled monitoring tutorial](tutorials/scheduled-monitoring.md) | +| **Webhooks** | `--notify-webhook` POSTs JSON event summaries to any endpoint. | [Scheduled monitoring tutorial](tutorials/scheduled-monitoring.md) | + +### Infrastructure + +| Tool | How | Docs | +|---|---|---| +| **Docker** | `Dockerfile` in the refract repo. `docker build -t refract .` | [Install](install.md) | +| **AWS Lambda** | Run `refract cron` in a Lambda function with a 15-minute timeout. | [Cron](cron.md) | +| **Cloudflare Workers** | Import the library packages in a Worker (`nodejs_compat`); the CLI cannot run there. Store events in D1, export to R2. | [Cron: serverless](cron.md#serverless-platforms) | +| **Private wikis** | Bearer token, basic auth, OAuth2. `--api-key`, `--api-user`, `--api-password`. | [Private wiki tutorial](tutorials/private-wiki.md) | + +### Knowledge graphs and semantic web + +| Tool | How | Docs | +|---|---|---| +| **RDF / SPARQL** | Convert `wikilink_added`/`category_added` events into triples. | [Downstream](downstream.md#complementary-technologies) | +| **Neo4j** | Build an evolving entity graph where each edge has a revision timestamp. | [Downstream](downstream.md#complementary-technologies) | +| **Vector databases** | Store claim embeddings alongside stability metadata for filtered retrieval. | [RAG tutorial](tutorials/rag-provenance.md) | + +## Production patterns + +### Adapter pattern + +Use a single adapter file as the import boundary between Refract and your codebase: + +```typescript +// adapter.ts — single import boundary +export type { EvidenceEvent, FactProvenance } from '@refract-org/evidence-graph'; +export { sectionDiffer, citationTracker } from '@refract-org/analyzers'; +export { computeCertaintyProfile, computeDirectionSignal, extractQuantitativeFindings } from '@refract-org/analyzers'; +``` + +No other file imports from `@refract-org/*` directly. This isolates version upgrades to one file. + +### Batch processing + +```bash +refract analyze --pages-file topics.txt --depth detailed -c +``` + +Process hundreds of pages sequentially. Cache prevents re-fetching. Combine with `refract export` for structured output. + +### Scheduled re-observation + +```bash +refract cron pages.txt --interval 24 -c --notify-slack +``` + +Re-observe on a schedule. Detect changes since last run. Notify on new events. + +### Hashed export + +```bash +refract export "Page" --bundle > bundle.json +``` + +The bundle carries a SHA-256 hash of its contents. It is not signed: a downstream system that receives the hash separately can check that the file matches it. `--manifest` gives a Merkle root over the event hashes instead. + +## Complementary technologies + +A full list of compatible tools is in the [downstream integration](downstream.md#complementary-technologies) reference. The Refract event stream is standard JSON/NDJSON — anything that reads JSON or speaks HTTP can consume it. diff --git a/apps/docs/docs/interpretation.md b/apps/docs/docs/interpretation.md new file mode 100644 index 0000000..3bf75d1 --- /dev/null +++ b/apps/docs/docs/interpretation.md @@ -0,0 +1,140 @@ +# Interpreting Refract output + +Refract emits a deterministic event stream. This page explains how to read it. + +## The event envelope + +Every event has the same structure: + +```json +{ + "eventId": "a3f5c2e1b7d409fa", + "eventType": "sentence_first_seen", + "claimId": "c7d8e9f0a1b23456", + "fromRevisionId": 1280110001, + "toRevisionId": 1280110100, + "section": "Geology", + "before": "", + "after": "Earth's crust consists of tectonic plates...", + "deterministicFacts": [ + { + "fact": "New sentence detected", + "detail": "First appearance of this text across revision range", + "provenance": { + "analyzer": "section-differ", + "version": "0.5.1", + "inputHashes": ["sha256:abc123..."] + } + } + ], + "layer": "observed", + "timestamp": "2024-11-25T12:00:00Z", + "schemaVersion": "0.4.0" +} +``` + +### Field meanings + +| Field | What it tells you | +|-------|-------------------| +| `eventType` | Kind of change — sentence, citation, template, revert, section, etc. See [event taxonomy](events.md). | +| `fromRevisionId` / `toRevisionId` | The revision boundary where this change occurred. `from` is the parent revision; `to` is the revision containing the change. | +| `section` | Which section the change belongs to. `(lead)` means the page introduction. Not all event types use this field. | +| `before` / `after` | The text before and after the change. For additions, `before` is empty. For removals, `after` is empty. For modifications, both are present. | +| `deterministicFacts` | Why the engine produced this event. Always mechanical — pure functions of the wikitext. The `provenance` field identifies which analyzer, what version, and what parameters. | +| `layer` | Where the evidence came from: `"observed"` (deterministic), `"policy_coded"` (rules-based), `"model_interpretation"` (downstream only), `"speculative"` (low confidence), `"unknown"`. | +| `schemaVersion` | Which schema version produced this event. Use this for compatibility checks when consuming events across package versions. | + +## Event types by category + +### Claim lifecycle (`sentence_*`) + +These events track propositional content across revisions. A sentence is identified +by its text; Refract detects when the same sentence appears, disappears, changes, or +returns. + +![Claim lifecycle across revisions](claim-lifecycle.svg) + +- **`sentence_first_seen`**: This text has never appeared before in the revision range. +- **`sentence_removed`**: This text was present in the previous revision and is absent now. +- **`sentence_modified`**: The text changed but is similar enough (above the similarity threshold) to be considered the same sentence. +- **`sentence_reintroduced`**: Text that was previously removed has returned. + +### Source changes (`citation_*`) + +Citations are extracted from `<ref>` tags in wikitext. + +- **`citation_added`**: A new reference was added. +- **`citation_removed`**: An existing reference was deleted. +- **`citation_replaced`**: One reference was swapped for another at the same position. + +### Policy signals (`template_*`) + +Templates like `{{citation needed}}`, `{{NPOV}}`, `{{BLP sources}}` are Wikipedia's +dispute signals. Refract tracks them mechanically. + +- **`template_added`**: A template was inserted into the page. +- **`template_removed`**: A template was taken out. +- **`template_parameter_changed`**: A template's parameters were modified (e.g., date updated on a maintenance tag). + +### Structural changes (`section_*`, `lead_*`, `page_moved`) + +- **`section_reorganized`**: Sections were added, removed, or reordered. +- **`lead_promotion`**: Content was moved from the body into the lead section. +- **`lead_demotion`**: Content was moved from the lead into the body. +- **`page_moved`**: The entire page was renamed. + +### Dispute signals (`revert_*`, `edit_cluster_*`) + +- **`revert_detected`**: An edit was reverted, detected from the edit summary matching revert patterns. +- **`edit_cluster_detected`**: Multiple edits occurred within a tight time window (default: 3+ edits in 1 hour). + +### Talk page activity (`talk_*`) + +These events correlate article revisions with their associated talk pages. + +- **`talk_page_correlated`**: An article revision has a nearby talk page revision. +- **`talk_thread_opened`**: A new discussion thread was started. +- **`talk_thread_archived`**: A thread was closed or archived. +- **`talk_reply_added`**: A reply was posted in an existing thread. +- **`talk_activity_spike`**: Talk page activity exceeded the threshold (default: 3× the moving average). + +## Reading patterns across events + +### Stability signal + +A claim that has `sentence_first_seen` early in the timeline, no `sentence_modified` +events, and is still present at the end of the revision range is stable. + +### Contestation signal + +A claim with frequent `sentence_modified` events, `revert_detected` in its section, +and `template_added` with dispute templates (`{{NPOV}}`, `{{citation needed}}`) is +actively contested. + +### Source fragility signal + +A citation that was added and then removed within a short revision window (or was +replaced multiple times) is source-fragile — the page's evidence base for that claim +is unstable. + +### Canon change signal (Fandom wikis) + +`category_removed` immediately followed by `category_added` with a different category +in the same revision boundary is a canon reclassification. `sentence_removed` on a +long-standing claim followed by `sentence_first_seen` with different text in the same +section is a retcon. + +## Downstream consumption + +When building on Refract's event stream: + +1. **Filter by `eventType`** to isolate the signal you care about (e.g., citation + events for source churn analysis). +2. **Group by `section`** to find which parts of the page are most active. +3. **Chain by `claimId`** to trace a specific proposition across its full lifecycle. +4. **Sort by `timestamp`** to reconstruct the chronological sequence. +5. **Never interpret `layer` values you didn't set** — `model_interpretation` is for + downstream use only. Refract never sets it. + +The event stream is pure NDJSON — pipe it, filter it, join it, archive it. diff --git a/apps/docs/docs/mcp.md b/apps/docs/docs/mcp.md new file mode 100644 index 0000000..d4a54ef --- /dev/null +++ b/apps/docs/docs/mcp.md @@ -0,0 +1,168 @@ +# Wikipedia & MediaWiki revision-history MCP server + +`refract mcp` starts a stdio Model Context Protocol server that lets AI agents analyze page revision histories from Wikipedia or any MediaWiki API. + +The MCP server supports **sampling** — it can request the host's LLM to interpret events at any BYO-inference boundary without managing API keys. + +![MCP protocol sequence](mcp-sequence.svg) + +## Quick start + +```bash +refract mcp +``` + +The server starts and waits for MCP client connections on stdio (`wikihistory mcp` also works). Connect any MCP-compatible client. + +## Connecting AI coding agents + +The server is the `mcp` command of the `@refract-org/cli` package, so that is the +package a client configuration names. `@refract-org/mcp` is a library with no +executable, and the unscoped npm package `refract` is an unrelated project — +`npx refract mcp` runs someone else's code. + +### Claude Code + +```json +{ + "mcpServers": { + "refract": { + "command": "npx", + "args": ["-y", "@refract-org/cli", "mcp"] + } + } +} +``` + +Then prompt Claude Code: + +> "Analyze the Wikipedia page for Bitcoin and tell me when the most controversial edits happened." + +Claude Code calls Refract's MCP tools to fetch the analysis, reads the structured events, and summarizes the findings. + +### VS Code / Cursor / GitHub Copilot + +```json +{ + "mcpServers": { + "refract": { + "command": "npx", + "args": ["-y", "@refract-org/cli", "mcp"] + } + } +} +``` + +### Claude Desktop + +```json +{ + "mcpServers": { + "refract": { + "command": "npx", + "args": ["-y", "@refract-org/cli", "mcp"] + } + } +} +``` + +## Tutorial: Ask an AI agent about claim history + +1. **Start the MCP server** in one terminal: + ```bash + refract mcp + ``` + +2. **Connect your AI agent** using one of the configs above. + +3. **Ask the agent:** + > "Use Refract to analyze the Wikipedia page for ChatGPT. Show me a timeline of when citations were added and removed. Which citations have survived the longest?" + + The agent calls `refract analyze` via MCP, receives structured events, and interprets the citation lifecycle. + +4. **Track a specific claim:** + > "Using Refract, check the claim 'GPT-4 was trained on 45TB of text data' on the ChatGPT Wikipedia page. When was it first added? Has it been removed or rewritten?" + + The agent calls `refract claim` via MCP, gets the claim's revision history, and reports. + +5. **Use model-assisted classification:** + > "Classify the most recent edit to the ChatGPT page — is it a revert, a major addition, or a minor edit?" + + The agent calls `refract classify heuristic` via MCP with the edit data. + +## Available tools + +### `analyze` + +Analyze a MediaWiki page's full edit history. Returns structured events with `schemaVersion`, `FactProvenance`, and deterministic hashes. + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `page` | string | required | Page title | +| `depth` | enum | | `brief`, `detailed`, `forensic` | +| `api` | string | | MediaWiki API base URL | +| `from` | string | | Start revision ID | +| `to` | string | | End revision ID | +| `since` | string | | Re-observe from ISO timestamp | + +### `claim` + +Track a specific sentence across revisions. Shows first seen, modifications, removals, and reintroductions. + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `page` | string | required | Page title | +| `text` | string | required | Sentence text to track (partial match) | + +### `export` + +Export page analysis as structured JSON. + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `page` | string | required | Page title | +| `depth` | enum | | `brief`, `detailed`, `forensic` | +| `api` | string | | MediaWiki API base URL | +| `since` | string | | Re-observe from ISO timestamp | + +### `cron` + +Re-observe pages for scheduled monitoring. + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `pagesFile` | string | required | File path with one page title per line | + +### `classify` + +Ask a model to classify a single observation boundary. See [CLI reference](cli) for the boundary table and all options. + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `boundary` | enum | required | `revert`, `sentence_similarity`, `heuristic`, `template_signal`, `activity_spike` | +| `input` | object | required | Input data (fields vary by boundary) | +| `apiKey` | string | | Inference provider API key (env: `REFRACT_INFERENCE_API_KEY`) | +| `endpoint` | string | | OpenAI-compatible endpoint (DeepSeek, Ollama, etc.) | +| `model` | string | | Model name | + +## Protocol + +- **Transport:** stdio (stdin for requests, stdout for responses) +- **Format:** JSON-RPC 2.0 (protocol version `2025-06-18`) +- **Capabilities:** `tools`, `sampling` +- **Initialization:** Send `tools/list` to discover tools, `tools/call` to invoke + +## Validation + +To verify your MCP setup is working: + +```bash +# Start the server +echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | refract mcp | head -1 +``` + +You should receive a JSON-RPC response listing the available tools. For interactive testing, use the MCP Inspector: + +```bash +npx @modelcontextprotocol/inspector npx -y @refract-org/cli mcp +``` diff --git a/apps/docs/docs/naming.md b/apps/docs/docs/naming.md new file mode 100644 index 0000000..1e8307f --- /dev/null +++ b/apps/docs/docs/naming.md @@ -0,0 +1,20 @@ +# Naming conventions + +## The split explained + +| Name | What it is | Why | +|---|---|---| +| **Refract** | The project | Sequence of observed changes | +| **refract-org** | GitHub organization | npm scope matching; chosen for consistency with the project name | +| **`@refract-org/*`** | npm package scope | All lowercase by npm convention | +| **`refract`** / **`wikihistory`** | CLI commands | `refract` is the primary name; `wikihistory` works as an alias | + +## Why `refract` and also `wikihistory`? + +The CLI is named after the action it performs, not the project it belongs to. When you type `refract analyze "Bitcoin"`, the meaning is self-evident: analyze the revision history of Bitcoin. `wikihistory` works as an alias with the same self-evident logic. + +## In code + +- Use `Refract` (capital R) as the project name in prose +- Use `@refract-org/<name>` when referencing packages +- Use `refract` as the primary CLI command name; `wikihistory` is an alias diff --git a/apps/docs/docs/nav.json b/apps/docs/docs/nav.json new file mode 100644 index 0000000..a376244 --- /dev/null +++ b/apps/docs/docs/nav.json @@ -0,0 +1,91 @@ +[ + { "title": "Home", "slug": "index" }, + { + "title": "Getting started", + "slug": null, + "children": [ + { "title": "Why Refract", "slug": "why-refract" }, + { "title": "Compare to alternatives", "slug": "compare" }, + { "title": "Demo", "slug": "demo" }, + { "title": "Quick start", "slug": "quickstart" }, + { "title": "Common events", "slug": "quickstart-events" }, + { "title": "Complete workflow", "slug": "complete-workflow" }, + { "title": "Install", "slug": "install" }, + { "title": "Concepts", "slug": "concepts" } + ] + }, + { + "title": "Reference", + "slug": null, + "children": [ + { "title": "CLI", "slug": "cli" }, + { "title": "SDK / packages", "slug": "sdk" }, + { "title": "Event schema", "slug": "schema" }, + { "title": "Event taxonomy", "slug": "events" }, + { "title": "Temporal model", "slug": "temporal-model" }, + { "title": "Depth levels", "slug": "depth" }, + { "title": "Bundles & manifests", "slug": "bundle-manifest" }, + { "title": "Evaluation", "slug": "eval" }, + { "title": "Architecture decisions", "slug": "architecture-decisions" } + ] + }, + { + "title": "Integration", + "slug": null, + "children": [ + { "title": "Integrations overview", "slug": "integrations" }, + { "title": "Downstream integration", "slug": "downstream" }, + { "title": "MCP / AI agents", "slug": "mcp" }, + { "title": "Python SDK", "slug": "python-sdk" }, + { "title": "Refract UI", "slug": "visualizer" }, + { "title": "Analytics with DuckDB", "slug": "analytics" }, + { "title": "Notebooks", "slug": "notebooks" }, + { "title": "Cron monitoring", "slug": "cron" } + ] + }, + { + "title": "Tutorials", + "slug": null, + "children": [ + { "title": "Wikipedia history", "slug": "tutorials/wikipedia-history" }, + { "title": "Fandom canon", "slug": "tutorials/fandom-canon" }, + { "title": "Citation churn", "slug": "tutorials/citation-churn" }, + { "title": "Dispute timeline", "slug": "tutorials/dispute-timeline" }, + { "title": "Cross-wiki diff", "slug": "tutorials/cross-wiki-diff" }, + { "title": "Refract UI", "slug": "tutorials/refract-ui" }, + { + "title": "Combat revisionism", + "slug": "tutorials/combating-revisionism" + }, + { "title": "RAG provenance", "slug": "tutorials/rag-provenance" }, + { "title": "MCP agent", "slug": "tutorials/mcp-agent" }, + { + "title": "Scheduled monitoring", + "slug": "tutorials/scheduled-monitoring" + }, + { "title": "Python SDK notebook", "slug": "tutorials/python-sdk" }, + { "title": "BYO-inference", "slug": "tutorials/byo-inference" }, + { "title": "Custom analyzer", "slug": "tutorials/custom-analyzer" }, + { "title": "Custom eval labels", "slug": "tutorials/custom-eval" }, + { "title": "Private wikis", "slug": "tutorials/private-wiki" }, + { "title": "Non-English wikis", "slug": "tutorials/non-english" }, + { "title": "Summarization", "slug": "tutorials/summarization" }, + { "title": "Custom adapter", "slug": "tutorials/custom-adapter" }, + { "title": "Model evaluation", "slug": "tutorials/model-evaluation" } + ] + }, + { + "title": "Appendix", + "slug": null, + "children": [ + { "title": "Glossary", "slug": "glossary" }, + { "title": "FAQ", "slug": "faq" }, + { "title": "Interpreting output", "slug": "interpretation" }, + { "title": "Security", "slug": "security" }, + { "title": "Boundary", "slug": "boundary" }, + { "title": "Naming", "slug": "naming" }, + { "title": "Frontier use cases", "slug": "frontier-use-cases" }, + { "title": "Contributing to docs", "slug": "contributing-docs" } + ] + } +] diff --git a/apps/docs/docs/notebooks.md b/apps/docs/docs/notebooks.md new file mode 100644 index 0000000..c4eba8e --- /dev/null +++ b/apps/docs/docs/notebooks.md @@ -0,0 +1,244 @@ +# Work with notebooks + +Refract's event output is standard NDJSON — load it into any notebook environment for +interactive analysis. This page shows a complete workflow in Python (Jupyter, Marimo) +and R. + +## Setup + +```bash +pip install pandas altair +refract export "Bitcoin" --format ndjson > events.jsonl +``` + +## Load and explore + +### Python (Jupyter / Marimo) + +```python +import pandas as pd +import json + +events = [] +with open("events.jsonl") as f: + for line in f: + if line.strip(): + events.append(json.loads(line)) + +df = pd.json_normalize(events) +df["timestamp"] = pd.to_datetime(df["timestamp"]) + +print(f"Loaded {len(df)} events") +print(f"Event types: {df['eventType'].nunique()}") +``` + +### R + +```r +library(jsonlite) +library(dplyr) +library(ggplot2) + +events <- stream_in(file("events.jsonl")) %>% + mutate(timestamp = as.POSIXct(timestamp)) +``` + +## Event type distribution + +### Python + +```python +import altair as alt + +dist = df["eventType"].value_counts().reset_index() +dist.columns = ["event_type", "count"] + +alt.Chart(dist).mark_bar().encode( + x=alt.X("event_type:N", sort="-y", title="Event type"), + y=alt.Y("count:Q", title="Count"), + color=alt.Color("event_type:N", legend=None) +).properties(width=600, height=300) +``` + +### R + +```r +events %>% + count(event_type) %>% + ggplot(aes(reorder(event_type, n), n)) + + geom_col() + + coord_flip() + + labs(x = "Event type", y = "Count") +``` + +## Citation churn over time + +### Python + +```python +citation_df = df[df["eventType"].str.startswith("citation")] +citation_df["month"] = citation_df["timestamp"].dt.to_period("M").astype(str) + +churn = citation_df.groupby(["month", "eventType"]).size().reset_index(name="count") + +alt.Chart(churn).mark_line(point=True).encode( + x=alt.X("month:T", title="Month"), + y=alt.Y("count:Q", title="Events"), + color=alt.Color("eventType:N", title="Citation event") +).properties(width=600, height=300) +``` + +### R + +```r +events %>% + filter(grepl("^citation", event_type)) %>% + mutate(month = format(timestamp, "%Y-%m")) %>% + count(month, event_type) %>% + ggplot(aes(as.Date(paste0(month, "-01")), n, color = event_type)) + + geom_line() + + geom_point(size = 2) + + scale_x_date(date_breaks = "3 months", date_labels = "%b %Y") + + labs(x = "Month", y = "Events", color = "Citation event") + + theme_minimal() +``` + +## Claim stability scores + +### Python + +Define contested vs. stable events: + +```python +contested_types = ["revert_detected", "edit_cluster_detected", "sentence_removed"] +df["stability"] = df["eventType"].apply( + lambda t: "Contested" if t in contested_types else "Stable" +) + +stability_counts = df["stability"].value_counts().reset_index() +stability_counts.columns = ["category", "count"] +stability_counts +``` + +### R + +```r +events %>% + mutate(stability = ifelse( + event_type %in% c("revert_detected", "edit_cluster_detected", "sentence_removed"), + "Contested", "Stable" + )) %>% + count(stability) +``` + +A high ratio of contested-to-stable events indicates an actively disputed page. +A page with mostly stable events (citations added, sections reorganized, categories +changed) is undergoing editorial improvement, not dispute. + +## Section-level analysis + +Which sections have the most activity? + +### Python + +```python +section_activity = df["section"].value_counts().head(10) +section_activity +``` + +### R + +```r +events %>% + count(section, sort = TRUE) %>% + top_n(10) +``` + +Sections with high event counts combined with high revert ratios are the most +contested parts of the page. Drill into a specific section by filtering on +its name and re-running the stability analysis. + +## Exporting results + +### Python + +```python +# Save stability analysis as CSV +stability_counts.to_csv("bitcoin-stability.csv", index=False) + +# Save filtered events to new JSONL +contested = df[df["stability"] == "Contested"] +contested.to_json("contested-events.jsonl", orient="records", lines=True) +``` + +## Next steps + +- [Analyze with DuckDB](analytics.md) +- [Monitor citation churn on specific pages](tutorials/citation-churn.md) +- [Build a dispute timeline](tutorials/dispute-timeline.md) + +## Using the Python SDK (`refract-py`) + +The [refract-py](https://github.com/refract-org/refract-py) package provides typed +dataclasses and pandas integration for Python workflows: + +```bash +pip install git+https://github.com/refract-org/refract-py.git # not on PyPI +``` + +### Load events with typed dataclasses + +```python +from refract import RefractClient, EvidenceEvent + +client = RefractClient() +events = client.analyze("Bitcoin", depth="detailed") +# → list[EvidenceEvent] with typed fields: event_type, timestamp, section, etc. +``` + +### Load into pandas DataFrame + +```python +import pandas as pd + +events_dict = [e.model_dump() for e in events] +df = pd.DataFrame(events_dict) +df["timestamp"] = pd.to_datetime(df["timestamp"]) + +# Quick exploration +df["event_type"].value_counts() +df.groupby("section")["event_type"].count().sort_values(ascending=False) +``` + +### Analyze claim stability + +```python +contested_types = ["revert_detected", "edit_cluster_detected", "sentence_removed"] +df["stability"] = df["event_type"].apply( + lambda t: "Contested" if t in contested_types else "Stable" +) + +print(df["stability"].value_counts()) +# → Stable: 285, Contested: 45 +``` + +### Export to Parquet for archival + +```python +df.to_parquet("bitcoin-events.parquet", index=False) +``` + +### LangChain document loader + +```python +from refract_langchain import RefractDocumentLoader + +loader = RefractDocumentLoader(page="Bitcoin") +docs = loader.load() +# → list[Document] with page_content = claim text, metadata = stability + provenance +``` + +The Python SDK wraps the `@refract-org/cli` npm package — install both for full +functionality: `pip install git+https://github.com/refract-org/refract-py.git` plus the Refract CLI. Typed +exceptions like `RefractConfigError`, `RefractFetchError`, and +`RefractInterpretationError` provide structured error handling. diff --git a/apps/docs/docs/notebooks/model-evaluation.ipynb b/apps/docs/docs/notebooks/model-evaluation.ipynb new file mode 100644 index 0000000..e7f5381 --- /dev/null +++ b/apps/docs/docs/notebooks/model-evaluation.ipynb @@ -0,0 +1,257 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Refract: Model Evaluation with Deterministic Ground Truth\n", + "\n", + "This notebook shows how to use Refract to evaluate AI models for temporal leakage,\n", + "retrieval quality, and provenance hallucination.\n", + "\n", + "**No API keys needed.** Refract is deterministic \u2014 same input, same output, every time." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "!pip install git+https://github.com/refract-org/refract-py.git pandas matplotlib -q" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Install the Refract CLI (required by refract-py)\n", + "!npm install -g @refract-org/cli 2>/dev/null || echo \"CLI already installed or using npx fallback\"" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. Analyze a page and load events\n", + "\n", + "**No API key needed.** First run fetches from Wikipedia (requires internet).\n", + "Subsequent runs use the local cache.\n", + "\n", + "If the CLI isn't available, the next cell loads a pre-computed sample." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Try live analysis, fall back to pre-computed sample\n", + "import pandas as pd\n", + "import json\n", + "from urllib.request import urlopen\n", + "\n", + "try:\n", + " from refract import Refract\n", + " r = Refract()\n", + " df = r.analyze(\"COVID-19\", depth=\"forensic\", as_frame=True)\n", + " print(f\"Loaded {len(df)} events from live Wikipedia analysis\")\n", + "except Exception as e:\n", + " print(f\"CLI not available ({e}), loading sample data...\")\n", + " url = \"https://raw.githubusercontent.com/refract-org/refract-demo-data/main/historical-wikipedia/sample-covid-events.jsonl\"\n", + " events = []\n", + " with urlopen(url) as f:\n", + " for line in f:\n", + " line = line.decode().strip()\n", + " if line and not line.startswith(\"#\"):\n", + " events.append(json.loads(line))\n", + " # Build a minimal DataFrame matching Refract's flat format\n", + " rows = []\n", + " for e in events:\n", + " fact = e.get(\"deterministicFacts\", [{}])[0]\n", + " rows.append({\n", + " \"timestamp\": e.get(\"timestamp\"),\n", + " \"event_type\": e.get(\"eventType\"),\n", + " \"from_revision_id\": e.get(\"fromRevisionId\"),\n", + " \"to_revision_id\": e.get(\"toRevisionId\"),\n", + " \"section\": e.get(\"section\"),\n", + " \"fact\": fact.get(\"fact\", \"\"),\n", + " \"fact_detail\": fact.get(\"detail\", \"\"),\n", + " \"layer\": e.get(\"layer\"),\n", + " \"event_id\": e.get(\"eventId\", \"\"),\n", + " })\n", + " df = pd.DataFrame(rows)\n", + " print(f\"Loaded {len(df)} events from sample dataset\")\n", + "\n", + "print(f\"Event types: {df['event_type'].nunique()}\")\n", + "df.head()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Temporal leakage detection\n", + "\n", + "Find claims that first appeared after a model's training cutoff." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from datetime import datetime\n", + "\n", + "CUTOFF = datetime(2024, 6, 1) # GPT-4o cutoff\n", + "\n", + "first_seen = df[df[\"event_type\"] == \"sentence_first_seen\"].copy()\n", + "first_seen[\"timestamp_dt\"] = pd.to_datetime(first_seen[\"timestamp\"])\n", + "\n", + "leaked = first_seen[first_seen[\"timestamp_dt\"] > CUTOFF]\n", + "known = first_seen[first_seen[\"timestamp_dt\"] <= CUTOFF]\n", + "\n", + "print(f\"Claims before cutoff: {len(known)}\")\n", + "print(f\"Claims after cutoff: {len(leaked)}\")\n", + "print(f\"Leakage candidates: {len(leaked)} claims a model should not know\")\n", + "print()\n", + "print(\"Sample claims after cutoff:\")\n", + "for _, row in leaked.head(5).iterrows():\n", + " text = row.get(\"fact_detail\", \"\")[:120]\n", + " print(f\" [{row['timestamp'][:10]}] {text}\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. Retrieval quality scoring\n", + "\n", + "Score claims by stability for use as RAG retrieval weights." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Score each claim by contestation signals\n", + "stability = df.groupby(\"fact_detail\").agg(\n", + " reverts=(\"event_type\", lambda x: (x == \"revert_detected\").sum()),\n", + " citation_churn=(\"event_type\", lambda x: (x.str.startswith(\"citation_\")).sum()),\n", + " talk_activity=(\"event_type\", lambda x: (x.str.startswith(\"talk_\")).sum()),\n", + " first_seen=(\"timestamp\", \"min\"),\n", + ").reset_index()\n", + "\n", + "stability[\"contestation_score\"] = (\n", + " stability[\"reverts\"] * 0.4 +\n", + " stability[\"citation_churn\"] * 0.1 +\n", + " stability[\"talk_activity\"] * 0.05\n", + ")\n", + "stability[\"stability\"] = 1.0 - stability[\"contestation_score\"].clip(0, 1)\n", + "\n", + "print(f\"Mean stability: {stability['stability'].mean():.3f}\")\n", + "print(f\"Contested claims (stability < 0.7): {(stability['stability'] < 0.7).sum()}\")\n", + "\n", + "# Most contested claims\n", + "stability.sort_values(\"contestation_score\", ascending=False).head(10)[\n", + " [\"fact_detail\", \"reverts\", \"citation_churn\", \"talk_activity\", \"stability\"]\n", + "]" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. Provenance hallucination check\n", + "\n", + "Did a source ever exist on this page?" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "def check_source(source_pattern: str) -> dict:\n", + " citations = df[df[\"event_type\"].str.startswith(\"citation_\")]\n", + " matches = citations[\n", + " citations[\"fact_detail\"].str.contains(source_pattern, na=False)\n", + " ]\n", + " \n", + " verified = len(matches[matches[\"event_type\"] == \"citation_added\"])\n", + " removed = len(matches[matches[\"event_type\"] == \"citation_removed\"])\n", + " replaced = len(matches[matches[\"event_type\"] == \"citation_replaced\"])\n", + " \n", + " if verified + removed + replaced == 0:\n", + " return {\"status\": \"hallucinated\", \"message\": f\"No citation matching '{source_pattern}' ever found\"}\n", + " if removed > 0 or replaced > 0:\n", + " return {\"status\": \"outdated\", \"added\": verified, \"removed\": removed, \"replaced\": replaced}\n", + " return {\"status\": \"verified\", \"count\": verified}\n", + "\n", + "# Test a known source\n", + "print(check_source(\"who.int\"))\n", + "\n", + "# Test a likely hallucinated source\n", + "print(check_source(\"nonexistent-source-xyz.gov\"))" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. Export for benchmark submission\n", + "\n", + "Submit results to the community benchmark." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "import json\n", + "\n", + "benchmark = {\n", + " \"model\": \"your-model-name\",\n", + " \"claimed_cutoff\": \"2024-06-01\",\n", + " \"evaluated_at\": pd.Timestamp.now().isoformat(),\n", + " \"refract_version\": \"0.5.3\",\n", + " \"results\": {\n", + " \"total_claims_tested\": len(first_seen),\n", + " \"claims_after_cutoff\": len(leaked),\n", + " \"leakage_rate\": round(len(leaked) / max(len(first_seen), 1), 4),\n", + " \"mean_stability\": round(stability[\"stability\"].mean(), 3),\n", + " }\n", + "}\n", + "\n", + "with open(\"benchmark_results.json\", \"w\") as f:\n", + " json.dump(benchmark, f, indent=2)\n", + "\n", + "print(json.dumps(benchmark, indent=2))\n", + "print(\"\\nSaved to benchmark_results.json\")\n", + "print(\"Submit to: https://github.com/refract-org/refract/blob/main/BENCHMARK.md\")" + ], + "execution_count": null, + "outputs": [] + } + ], + "metadata": { + "colab": { + "name": "Refract Model Evaluation", + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/apps/docs/docs/python-sdk.md b/apps/docs/docs/python-sdk.md new file mode 100644 index 0000000..e09ed0b --- /dev/null +++ b/apps/docs/docs/python-sdk.md @@ -0,0 +1,195 @@ +# Python SDK + +The official Python SDK for Refract — wraps the CLI via subprocess and provides typed dataclasses, pandas integration, and notebook support. + +## Install + +```bash +pip install git+https://github.com/refract-org/refract-py.git # not on PyPI +``` + +Requires the Refract CLI (the SDK calls it via subprocess): + +```bash +npm install -g @refract-org/cli +``` + +`npx @refract-org/cli` is used as a fallback if `refract` is not on PATH. +`Refract(binary=...)` takes the path to a particular CLI instead; a path ending in +`.js` is run with `node`. + +## Quick start + +```python +from refract import Refract + +r = Refract() + +# Analyze — get typed dataclasses +events = r.analyze("Bitcoin", depth="brief") +for event in events: + print(event.eventType, event.timestamp) + +# Export as pandas DataFrame +df = r.analyze("Bitcoin", depth="forensic", as_frame=True) +print(df.groupby("event_type").size()) +``` + +## API reference + +### `Refract` + +The main client. No constructor arguments needed — it auto-detects the CLI. + +```python +r = Refract() +``` + +### `analyze(page, depth, as_frame, flatten)` + +Run a full page analysis. Returns `list[EvidenceEvent]` or `DataFrame` if `as_frame=True`. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `page` | `str` | required | Page title | +| `depth` | `str` | `"detailed"` | `"brief"`, `"detailed"`, or `"forensic"` | +| `as_frame` | `bool` | `False` | Return a pandas DataFrame | +| `flatten` | `bool` | `False` | Flatten nested provenance fields into flat columns | + +```python +events = r.analyze("Climate_change", depth="forensic") +df = r.analyze("Climate_change", depth="forensic", as_frame=True, flatten=True) +``` + +### `claim(page, text, as_frame)` + +Track a specific claim across all revisions. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `page` | `str` | required | Page title | +| `text` | `str` | required | Claim text to track (partial match) | +| `as_frame` | `bool` | `False` | Return a pandas DataFrame | + +```python +lifecycle = r.claim("Bitcoin", "decentralized") +print(lifecycle.status, lifecycle.first_seen) +``` + +### `export(page, format, flatten, as_frame)` + +Export analysis to a file or DataFrame. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `page` | `str` | required | Page title | +| `format` | `str` | `"ndjson"` | `"json"`, `"ndjson"`, `"csv"` | +| `flatten` | `bool` | `False` | Flatten nested fields | +| `as_frame` | `bool` | `False` | Return a pandas DataFrame | + +```python +df = r.export("Bitcoin", format="ndjson", flatten=True, as_frame=True) +``` + +### `EvidenceEvent` dataclass + +```python +@dataclass +class EvidenceEvent: + eventType: str + fromRevisionId: int + toRevisionId: int + section: str + before: str + after: str + timestamp: str + eventId: str = "" + claimId: str = "" + layer: str = "" + deterministicFacts: list[DeterministicFact] = field(default_factory=list) +``` + +## Integrations + +### pandas & polars + +`analyze` and `export` (NDJSON format) accept `as_frame=True` for a pandas DataFrame or `as_polars=True` for a polars DataFrame, one row per event with flattened provenance fields: + +```python +# pandas +df_pandas = r.analyze("Bitcoin", depth="forensic", as_frame=True) + +# polars +df_polars = r.analyze("Bitcoin", depth="forensic", as_polars=True) +print(df_polars.group_by("event_type").len()) +``` + +### Sentence survival (`compute_survival_records`) + +How long each sentence stayed on the page. A span starts at `sentence_first_seen` or `sentence_reintroduced` and ends at `sentence_removed`, matched on section and the first 60 characters of the sentence. A sentence still present at the end is right-censored (`event_observed` 0, no end time): + +```python +from refract import compute_survival_records + +events = r.analyze("Quantum_computing", depth="detailed") +survival_data = compute_survival_records(events) + +# One record per span: +# [ +# { +# "statement_key": "(lead)::Quantum computers use superposition...", +# "section": "(lead)", +# "start_time": "2020-01-01T00:00:00Z", +# "end_time": "2022-06-15T00:00:00Z", +# "duration_days": 896.0, +# "event_observed": 1 +# }, +# ... +# ] +``` + +### NetworkX (`to_networkx`) + +A directed graph of revision transitions: a node per revision (`rev:<id>`) and an edge from each event's `fromRevisionId` to its `toRevisionId`, carrying that event's `eventType` and `timestamp` (a later event on the same pair overwrites them). It also adds a `cites` edge from a revision to any fact detail containing `url=`. No analyzer emits a citation URL as a fact, but at `forensic` depth the `full_wikitext_before`/`full_wikitext_after` facts contain `url=` whenever the page cites a web source, and each becomes a node holding a revision's whole wikitext, so build the graph from `brief` or `detailed` events: + +```python +from refract import to_networkx + +events = r.analyze("Artificial_intelligence", depth="detailed") +G = to_networkx(events) + +import networkx as nx +print(f"Nodes: {G.number_of_nodes()}, Edges: {G.number_of_edges()}") +``` + +### LangChain + +`refract_langchain.py` loads events as `Document` objects with stability metadata for provenance-aware RAG: + +```python +from refract_langchain import RefractLoader + +loader = RefractLoader(page="Bitcoin", depth="forensic") +documents = loader.load() + +for doc in documents: + print(doc.metadata["event_type"], doc.metadata["stability_score"]) +``` + +### Jupyter / Marimo + +Combine with `pandas` and `matplotlib` or `altair` for interactive exploration: + +```python +df = r.analyze("Bitcoin", depth="forensic", as_frame=True, flatten=True) +citations = df[df["event_type"].str.startswith("citation_")] +citations.groupby("event_type").size().plot(kind="bar") +``` + +## Domain boundary + +This SDK wraps the Refract CLI. It does not add model logic, interpretation, or domain-specific judgment. It provides typed Python access to deterministic observation output. + +## Source + +[github.com/refract-org/refract-py](https://github.com/refract-org/refract-py) diff --git a/apps/docs/docs/quickstart-events.md b/apps/docs/docs/quickstart-events.md new file mode 100644 index 0000000..2c8bab3 --- /dev/null +++ b/apps/docs/docs/quickstart-events.md @@ -0,0 +1,107 @@ +# Quickstart: common events + +New to Refract? These are the 5 event types you'll see most often, what they mean, +and how to query them. After 5 minutes, move on to the [full event taxonomy](events.md). + +## The 5 most common events + +### `sentence_first_seen` / `sentence_removed` + +Content lifecycle. A sentence appeared or disappeared. + +```bash +refract analyze "Earth" --depth brief +``` + +```sql +SELECT timestamp, section, after +FROM 'events.jsonl' +WHERE event_type = 'sentence_first_seen' +ORDER BY timestamp; +``` + +### `citation_added` / `citation_removed` / `citation_replaced` + +Source changes. Added, removed, or swapped. `citation_replaced` means the claim +text stayed but the supporting source changed — the strongest revisionism signal. + +```sql +SELECT timestamp, event_type, before, after +FROM 'events.jsonl' +WHERE event_type LIKE 'citation_%' +ORDER BY timestamp; +``` + +### `revert_detected` + +An edit was undone. Refract checks edit comments against revert patterns. Multiple +reverts on the same section signal edit-warring. + +```sql +SELECT timestamp, section +FROM 'events.jsonl' +WHERE event_type = 'revert_detected' +ORDER BY timestamp; +``` + +### `template_added` / `template_removed` + +Policy templates. `NPOV`, `citation needed`, `dispute` — these are editorial signals +that someone flagged the content as problematic. + +```sql +SELECT timestamp, event_type, after +FROM 'events.jsonl' +WHERE event_type LIKE 'template_%' +ORDER BY timestamp; +``` + +### `section_reorganized` + +Section structure changed — sections were added, removed, or reordered. Often +accompanies major content rewrites. + +```sql +SELECT timestamp, section +FROM 'events.jsonl' +WHERE event_type = 'section_reorganized' +ORDER BY timestamp; +``` + +## Your first 3 queries + +```bash +refract analyze "Bitcoin" --depth detailed -c > bitcoin.jsonl +``` + +```sql +duckdb -c " +SELECT event_type, count(*) as n +FROM 'bitcoin.jsonl' +GROUP BY event_type +ORDER BY n DESC; +" + +duckdb -c " +SELECT section, count(*) as n +FROM 'bitcoin.jsonl' +WHERE event_type LIKE 'citation_%' +GROUP BY section +ORDER BY n DESC +LIMIT 5; +" + +duckdb -c " +SELECT timestamp, after +FROM 'bitcoin.jsonl' +WHERE event_type = 'sentence_first_seen' +ORDER BY timestamp +LIMIT 10; +" +``` + +## Next + +- [Full event taxonomy](events.md) — all 26 event types +- [Analytics with DuckDB](analytics.md) — pre-built SQL views +- [Wikipedia history tutorial](tutorials/wikipedia-history.md) — first full walkthrough diff --git a/apps/docs/docs/quickstart.md b/apps/docs/docs/quickstart.md new file mode 100644 index 0000000..da05cc1 --- /dev/null +++ b/apps/docs/docs/quickstart.md @@ -0,0 +1,91 @@ +# Quick start + +## Prerequisites + +- [Node.js](https://nodejs.org) 20+ or [Bun](https://bun.sh) v1.2+ runtime +- Internet connection (for fetching revision data from Wikimedia) + +See [installation](install.md) for all options (npx, npm, bun, Docker, from source). + +## Install the CLI + +**Zero install** (no download needed): + +```bash +npx @refract-org/cli analyze "Earth" --depth brief +``` + +**Or install locally:** + +```bash +bun add -g @refract-org/cli +refract analyze "Earth" --depth brief +``` + +(`wikihistory` also works as an alias.) + +See [installation](install.md) for all options (bun, npm, Docker, from source). + +## Your first analysis + +```bash +refract analyze "Earth" +``` + +By default, this fetches up to 20 recent revisions of the Wikipedia page "Earth" and runs the standard detailed-depth analyzers (section changes, citation changes, revert detection, template changes). Use `--depth forensic` for all 26 event types. Use `--from` and `--to` to scope to a specific revision range. + +Output is printed to stdout as structured JSON. Use `--report` to get a structured `ObservationReport` that groups events by claim with a lifecycle ledger. Use `--similarity <n>` to adjust the sentence matching threshold (default 0.8). + +## View results in the web UI + +```bash +refract explore "Earth" +``` + +Opens a local web server (default port 8899) with a timeline view, evidence table, and diff viewer — the most user-friendly way to browse results. + +## Saving output + +```bash +refract export "Earth" --format ndjson > earth-events.jsonl +``` + +Use `refract export` to format output as json, csv, ndjson, html, parquet, an evidence bundle with a SHA-256 hash (`--bundle`), or a replay manifest (`--manifest`). Add `--flatten` to produce flat column-oriented CSV suitable for analytical tools. + +## Try it: trace a claim + +Now find when a specific word first appeared and track how it changed: + +```bash +refract claim "Earth" --text "climate" -c +``` + +Refract scans every revision for "climate," reports when it was first added, modified, or removed, and summarizes its status (STABLE, REMOVED, CONTESTED). Try a term you expect to be disputed — any word or phrase works. + +## View the results visually + +The standalone [Refract UI](tutorials/refract-ui.md) loads JSONL output and renders timelines, diffs, citation graphs, and event breakdowns. Export your analysis and drop the file into the browser: + +```bash +refract export "Earth" --format ndjson > earth-events.jsonl +# then open http://localhost:5173 in Refract UI and drag the file in +``` + +See the [Refract UI tutorial](tutorials/refract-ui.md) for the full walkthrough. + +## Classify events with a model + +```bash +# Default: mechanical heuristic +refract classify revert --input '{"comment":"revert vandalism"}' + +# With API key: uses configured model +refract classify revert --input '{"comment":"..."}' --model deepseek-chat +``` + +## Next steps + +- [Concepts](concepts.md) +- [CLI reference](cli.md) +- [Analysis depth levels](depth.md) +- [Tutorial: Track Wikipedia changes](tutorials/wikipedia-history.md) diff --git a/apps/docs/docs/schema.md b/apps/docs/docs/schema.md new file mode 100644 index 0000000..40aae57 --- /dev/null +++ b/apps/docs/docs/schema.md @@ -0,0 +1,166 @@ +# Event schema reference + +## `EventType` — all 26 event types + +```typescript +export type EventType = + // Claim lifecycle + | "sentence_first_seen" + | "sentence_removed" + | "sentence_modified" + | "sentence_reintroduced" + + // Citation changes + | "citation_added" + | "citation_removed" + | "citation_replaced" + + // Template changes + | "template_added" + | "template_removed" + | "template_parameter_changed" + + // Section & page structure + | "section_reorganized" + | "lead_promotion" + | "lead_demotion" + | "page_moved" + + // Links & categories + | "wikilink_added" + | "wikilink_removed" + | "category_added" + | "category_removed" + + // Access control + | "protection_changed" + + // Content conflict + | "revert_detected" + | "edit_cluster_detected" + + // Talk page correlation + | "talk_page_correlated" + | "talk_thread_opened" + | "talk_thread_archived" + | "talk_reply_added" + | "talk_activity_spike"; +``` + +## Core interface: `EvidenceEvent` + +All events share a common envelope with `before`/`after` snapshots and +deterministic facts. There are no discriminated subtypes — all fields are +optional at the envelope level: + +```typescript +export interface EvidenceEvent { + eventId?: string; // deterministic content hash (see below) + eventType: EventType; // discriminator (serialized as "eventType" in NDJSON) + claimId?: string; // claim identity hash, when applicable + fromRevisionId: number; // parent revision + toRevisionId: number; // source revision + section: string; // section title where change occurred + before: string; // text / state before the change + after: string; // text / state after the change + deterministicFacts: DeterministicFact[]; // facts backing this event + modelInterpretation?: ModelInterpretation; // set by downstream consumers + layer: EvidenceLayer; // provenance layer + timestamp: string; // ISO 8601 +} +``` + +## Supporting types + +```typescript +export type EvidenceLayer = + | "observed" // directly observed from the diff + | "policy_coded" // coded against a policy rule + | "model_interpretation" // set by downstream consumers + | "speculative" // inferred but without a hard rule + | "unknown"; // layer not yet classified + +export interface DeterministicFact { + fact: string; + detail?: string; + provenance?: FactProvenance; +} + +export interface FactProvenance { + analyzer: string; // analyzer that produced this fact + version: string; // analyzer version + inputHashes: string[]; // hashes of input data used + parameters?: Record<string, string | number | boolean>; // parameters used by the analyzer +} + +export interface ModelInterpretation { + semanticChange: string; + confidence: number; + policyDimension?: PolicyDimension; + discussionType?: + | "notability_challenge" + | "sourcing_dispute" + | "neutrality_concern" + | "content_deletion" + | "content_addition" + | "naming_dispute" + | "procedural" + | "other"; +} + +export type PolicyDimension = + | "verifiability" + | "npov" + | "blp" + | "due_weight" + | "protection" + | "edit_warring" + | "notability" + | "copyright" + | "civility"; +``` + +`ModelInterpretation` and `modelInterpretation` on `EvidenceEvent` are never set by Refract's deterministic pipeline — they exist for downstream consumers to attach semantic analysis without modifying the deterministic event. + +`AnalyzerConfig` is a supporting type exported from `@refract-org/evidence-graph` that defines configurable parameters for analyzers (similarity thresholds, time windows, revert patterns). See the [CLI reference](./cli) for the per-flag equivalents. + +## Deterministic identity + +`eventId` is derived deterministically from the event content so the same +observation always produces the same ID: + +`modelInterpretation` is excluded from the hash so downstream consumers can add it without changing the event's deterministic identity. + +```typescript +import { createHash } from "node:crypto"; + +export function createEventIdentity( + event: Omit<EvidenceEvent, "eventId" | "modelInterpretation">, +): string { + const factsStr = event.deterministicFacts + .map((f) => `${f.fact}:${f.detail ?? ""}`) + .join("|"); + const identityKey = + `${event.eventType}|${event.fromRevisionId}|${event.toRevisionId}|` + + `${event.section}|${event.before}|${event.after}|${event.timestamp}|${factsStr}`; + return createHash("sha256").update(identityKey).digest("hex").slice(0, 16); +} +``` + +The hash is truncated to 16 hex characters (64 bits of collision resistance). + +## Version compatibility + +When consuming Refract events across different package versions, the `schemaVersion` field on each event tells you which schema produced it. + +| refract CLI | `@refract-org/evidence-graph` | `EVENT_SCHEMA_VERSION` | Key changes | +|------------|------------------------------|-----------------------|-------------| +| 0.5.x | 0.4.x | `"0.4.0"` | `sentence_modified` event type, `FactProvenance.parameters`, `AnalyzerConfig`, `EVENT_SCHEMA_VERSION`, `CLAIM_IDENTITY_VERSION` | +| 0.4.x | 0.3.x | `"0.3.0"` | ClaimLedger, ObservationReport, `--report` flag, cron merging | +| 0.3.x | 0.2.x | `"0.2.0"` | 25 event types, initial public release | + +**Negotiation rules:** +- Consumers SHOULD accept events with the same minor schema version (0.4.x) +- Consumers MAY accept events across minor versions if they handle unknown `EventType` members gracefully (skip unrecognized types, log, preserve) +- Consumers MUST NOT silently drop events with unrecognized event types — log them and preserve the raw data +- When `schemaVersion` is missing, assume the most recent compatible schema for the consuming package version diff --git a/apps/docs/docs/sdk.md b/apps/docs/docs/sdk.md new file mode 100644 index 0000000..cd28124 --- /dev/null +++ b/apps/docs/docs/sdk.md @@ -0,0 +1,266 @@ +# SDK / package reference + +## Overview + +Refract's SDK is a set of packages that compose into a pipeline: ingest → analyze → persist. + +![Package dependency graph](packages.svg) + +Packages are published on npm under the `@refract-org` scope, except `@refract-org/persistence`. All packages are ESM-only and written in TypeScript. + +## Basic pipeline + +The events `refract analyze` produces, from revisions you fetch yourself. `buildRevisionEvents` and `annotateEvents` need `@refract-org/analyzers` 0.5.1 or later. + +```typescript +import { MediaWikiClient } from "@refract-org/ingestion"; +import { annotateEvents, buildRevisionEvents } from "@refract-org/analyzers"; +import { createEventIdentity } from "@refract-org/evidence-graph"; + +const client = new MediaWikiClient({ apiUrl: "https://en.wikipedia.org/w/api.php" }); +const revisions = await client.fetchRevisions("Earth", { limit: 50 }); // the 50 latest + +// Section, citation, wikilink, category, template, revert and sentence events +// for each consecutive pair of revisions, ordered by timestamp. +const events = annotateEvents(buildRevisionEvents(revisions)); +for (const event of events) event.eventId = createEventIdentity(event); +``` + +`annotateEvents` adds `schemaVersion` and the semantic fields the CLI's output carries (`certaintyProfile`, `directionSignal`, `quantitativeFindings` and others). `buildRevisionEvents` takes `depth` (`"brief"`, `"detailed"`, `"forensic"`), `similarityThreshold` and the page's `protectionLogs`. It reads no network and no filesystem, so it runs in a Worker too (with `nodejs_compat`). For page moves and talk-page correlation as well, follow the recipe in the [analyzers README](https://github.com/refract-org/refract/tree/main/packages/analyzers#event-pipeline-051). + +## Storage + +`@refract-org/persistence` is not on npm: import it from a source checkout, under Bun. + +```typescript +import { Persistence } from "@refract-org/persistence"; + +const db = new Persistence({ dbPath: "refract.db" }); +await db.insertEvents(events); +const saved = await db.getEvents({ pageTitle: "Earth" }); +``` + +## Package reference + +### `@refract-org/evidence-graph` + +Core types, event schemas, and utilities. Zero runtime dependencies. + +```typescript +import type { EvidenceEvent, EventType, Revision, ClaimLedger, ClaimLedgerEntry, ObservationReport } from "@refract-org/evidence-graph"; +import { createClaimIdentity, createEventIdentity } from "@refract-org/evidence-graph"; +``` + +`EventType` now includes `sentence_modified` (26 event types total). + +Key exports: +- Interfaces: `EvidenceEvent`, `Revision`, `DeterministicFact`, `ModelInterpretation`, `ClaimLedger`, `ClaimLedgerEntry`, `ObservationReport` +- Types: `EventType`, `EvidenceLayer`, `PolicyDimension`, `Depth`, `AnalyzerConfig` +- Utilities: `createClaimIdentity`, `createEventIdentity` +- Merkle tree: `createReplayManifest`, `buildMerkleTree`, `getMerkleProof`, `verifyMerkleProof` +- `AnalyzerConfig`: configurable thresholds and windows for analyzers — similarity threshold for sentence matching, time windows for clusters and talk correlation, revert patterns, spike factors. Consumers pass their own config at each boundary; Refract records the effective parameters in `FactProvenance.parameters`. +- `DEFAULT_ANALYZER_CONFIG`: frozen default values for all configurable parameters +- `FactProvenance.parameters`: optional record of analyzer parameters (strings, numbers, booleans) — set when non-default config is used, enabling transparent provenance + +No prompt engineering, no interpretation schema — consumers define their own taxonomy at each configurable boundary. + +### `@refract-org/ingestion` + +Wikimedia API adapters. Fetches revision history and parses wikitext. + +```typescript +import { MediaWikiClient } from "@refract-org/ingestion"; +import type { RevisionFetcher, AuthConfig } from "@refract-org/ingestion"; + +const client = new MediaWikiClient({ apiUrl: "https://en.wikipedia.org/w/api.php" }); +const revisions = await client.fetchRevisions("Earth"); +``` + +Key exports: `MediaWikiClient` (class), `RevisionFetcher` (interface), `AuthConfig` + +**Wayback Machine, Git and snapshot revision sources**: + +```typescript +import { + WaybackRevisionSource, + GitRevisionSource, + SnapshotDirectorySource, +} from "@refract-org/ingestion"; + +// 1. Ingest snapshots from the Wayback Machine for any public URL +const wayback = new WaybackRevisionSource(); +for await (const rev of wayback.revisions("https://example.gov/public-policy")) { + console.log(rev.timestamp, rev.size); +} + +// 2. Ingest commit history from a local Git repository +const git = new GitRevisionSource({ repoPath: "/path/to/repo" }); +for await (const rev of git.revisions("specs/standard.md")) { + console.log(rev.comment, rev.timestamp); +} + +// 3. Ingest timestamped document files from a directory +const snapshots = new SnapshotDirectorySource({ baseDir: "/path/to/archives" }); +for await (const rev of snapshots.revisions("document-title")) { + console.log(rev.revId, rev.content.length); +} +``` + +Key exports: `MediaWikiClient`, `WaybackRevisionSource`, `GitRevisionSource`, `SnapshotDirectorySource`, `XmlDumpRevisionSource` + +**Wikidata entity mapping**: + +```typescript +import { fetchWikidataId, mapPageToEntity, mapPagesToEntities } from "@refract-org/ingestion"; +import type { PageToEntityMap, WikidataEntity, WikidataClaim } from "@refract-org/ingestion"; + +const qid = await fetchWikidataId("Douglas_Adams"); // "Q42" +const mapping = await mapPageToEntity("Douglas_Adams"); // { pageTitle, qid, entity } +``` + +Key exports: `fetchWikidataId`, `fetchWikidataEntity`, `mapPageToEntity`, `mapPagesToEntities`, `wikidataEntityToEvents` + +### `@refract-org/analyzers` + +Deterministic analyzers for section diffs, citation tracking, text propagation, revert detection, and template analysis. Exported as lowercase singleton instances and pure analysis functions. + +```typescript +import { + sectionDiffer, + citationTracker, + analyzeCitationNetwork, + detectTextPropagation, + revertDetector, + templateTracker, +} from "@refract-org/analyzers"; +``` + +**Text propagation**: the Jaccard similarity of two texts' token shingles, and the verbatim token spans they share: + +```typescript +const result = detectTextPropagation(sourceDocument, targetDocument, { + minSpanTokens: 8, + shingleSize: 5, +}); + +if (result.isSignificantBorrowing) { + console.log("Shared tokens:", result.sharedTokenCount); + console.log("Borrowed spans:", result.borrowedSpans); +} +``` + +**Citation concentration**: citation counts per domain and per source, and a Herfindahl-Hirschman index over the domain shares: + +```typescript +const citations = citationTracker.extractCitations(wikitext); +const network = analyzeCitationNetwork(citations); + +console.log("Unique sources:", network.uniqueSourceCount); +console.log("Concentration index:", network.sourceConcentrationIndex); +console.log("Domain breakdown:", network.domainDistribution); +``` + +All analyzers share a common pattern: extract from wikitext, then diff two extractions. The diffs are change records (`SectionChange`, `CitationChange`, `TemplateChange`), not events; `buildRevisionEvents` (0.5.1+) turns a revision history into `EvidenceEvent`s. Every analyzer accepts an optional `AnalyzerConfig` — thresholds, patterns, and windows that can be tuned per domain. The effective config is recorded in each event's `FactProvenance.parameters` when non-default values are used. + +```typescript +import { sectionDiffer, citationTracker, revertDetector, templateTracker, detectEditClusters } from "@refract-org/analyzers"; +import type { SectionDiffer, CitationTracker, RevertDetector, TemplateTracker } from "@refract-org/analyzers"; +import type { AnalyzerConfig } from "@refract-org/evidence-graph"; + +// Configure per-domain thresholds +const config: AnalyzerConfig = { + section: { similarityThreshold: 0.8 }, + editCluster: { windowMs: 30 * 60 * 1000, minSize: 2 }, +}; + +// Pass alongside standard calls +const changes = sectionDiffer.diffSections(before, after, config.section); +``` + +Key exports: +- **Event pipeline** (0.5.1+): `buildRevisionEvents`, `annotateEvents`, and the per-pair steps `parseContent`, `computeStructuralDiffs`, `detectEditorialSignals` +- Instances: `sectionDiffer`, `citationTracker`, `revertDetector`, `templateTracker`, `protectionTracker` +- Builders: `buildSectionLineage`, `buildSourceLineage`, `buildClaimLineage`, `buildWikilinkEvents`, `buildPageMoveEvents`, `buildTalkThreadEvents`, `buildCategoryEvents`, `buildParamChangeEvents` +- Classifiers: `classifyHeuristic` +- Parsers: `sanitizeWikitext`, `extractHeadingMap`, `extractWikilinks`, `extractCategories`, `countCitations`, `countKeywordMentions`, `deriveSectionHeading`, `findSectionForText` and `buildSectionCharMap` (0.5.1+) +- Cross-revision: `correlateTalkRevisions`, `diffObservations`, `parseTalkThreads`, `diffTalkThreads`, `diffTemplateParams`, `diffCategories`, `diffWikilinks` +- Clusters & activity: `detectEditClusters`, `detectTalkActivitySpikes` +- **Semantic enrichment** (v0.5.0+): `computeCertaintyProfile`, `computeDirectionSignal`, `computeEditMagnitude`, `computeContentChange`, `extractKeyTerms`, `extractQuantitativeFindings` + +### `@refract-org/cli` + +The `refract` / `wikihistory` CLI tool (16 commands: analyze, claim, classify, cron, delegation, diff, eval, explore, export, init, mcp, snapshot, stream, verify, visualize, watch). See [CLI reference](./cli). + +### `@refract-org/persistence` + +SQLite storage adapter (uses `bun:sqlite`). Not published to npm: import it from a +source checkout, under Bun. + +```typescript +import { Persistence } from "@refract-org/persistence"; + +const db = new Persistence({ dbPath: "refract.db" }); +await db.insertEvents(events); +const events = await db.getEvents({ pageTitle: "Earth" }); +``` + +Key exports: `Persistence` (class), `PersistenceAdapter` (interface), `PersistenceConfig` + +### Observable Framework data loader + +Recipe for embedding Refract queries in [Observable Framework](https://observablehq.com/framework/) dashboards. Copy the loader pattern directly — it is not a published npm package. + +```typescript +import { readFileSync } from "node:fs"; + +export interface RefractLoaderOptions { + path: string; + format?: "json" | "sqlite"; +} + +export class RefractLoader { + private path: string; + private format: "json" | "sqlite"; + + constructor(options: RefractLoaderOptions) { + this.path = options.path; + this.format = options.format ?? (options.path.endsWith(".db") ? "sqlite" : "json"); + } + + async load(): Promise<Record<string, unknown>> { + if (this.format === "json") { + const raw = readFileSync(this.path, "utf-8"); + return JSON.parse(raw) as Record<string, unknown>; + } + const { Database } = await import("bun:sqlite"); + const db = new Database(this.path, { readonly: true }); + const events = db.query("SELECT * FROM evidence_events").all(); + const revisions = db.query("SELECT * FROM revisions").all(); + db.close(); + return { events, revisions }; + } +} + +export function refractLoader(options: RefractLoaderOptions): RefractLoader { + return new RefractLoader(options); +} +``` + +Usage in Observable: + +```js +import { refractLoader } from "./data-loader.ts"; +const data = refractLoader({ path: "./bitcoin-analysis.json" }); +``` + +--- + +### `@refract-org/eval` + +Evaluation harness for measuring analyzer accuracy against ground truth labels. + +```typescript +import { createEvalHarness, validateAgainstGroundTruth } from "@refract-org/eval"; +``` + +Key exports: `createEvalHarness`, `validateAgainstGroundTruth`, `EvalHarness`, `GROUND_TRUTH_LABELS`, `getGroundTruthById`, `getGroundTruthForPage` diff --git a/apps/docs/docs/security.md b/apps/docs/docs/security.md new file mode 100644 index 0000000..a533c92 --- /dev/null +++ b/apps/docs/docs/security.md @@ -0,0 +1,45 @@ +# Security + +## Credential exposure + +CLI flags containing credentials (`--api-key`, `--api-password`) are visible in process listings. Use environment variables instead: + +### MediaWiki auth (global `--api-key`) + +Used for authenticated API access to private wikis. Applies globally across `analyze`, `claim`, `export`, and `cron`: + +| CLI flag | Environment variable | +|----------|---------------------| +| `--api-key` | `REFRACT_MEDIAWIKI_API_KEY` | +| `--api-user` | `REFRACT_MEDIAWIKI_API_USER` | +| `--api-password` | `REFRACT_MEDIAWIKI_API_PASSWORD` | + +### Inference provider auth (`refract classify --api-key`) + +Used only for `refract classify` to call an LLM at a BYO-inference boundary: + +| CLI flag | Environment variable | +|----------|---------------------| +| `--api-key` | `REFRACT_INFERENCE_API_KEY` | +| `--endpoint` | `REFRACT_INFERENCE_ENDPOINT` | +| `--model` | `REFRACT_INFERENCE_MODEL` | + +## Local storage + +When using `--cache`, revision content is persisted to `~/.wikihistory/refract.db` (SQLite). This file contains full wikitext from every revision fetched. On shared machines, set `--cache-dir` to an encrypted volume. + +## Network + +Refract makes outbound HTTPS requests to the configured MediaWiki API. Authentication tokens are sent as `Authorization` or `x-api-key` headers. All traffic is encrypted in transit. + +Bundled evidence files (`--bundle`) are neither signed nor encrypted — they contain plaintext event data and a SHA-256 hash of their contents, which anyone who edits the file can recompute. + +## Data retention + +Cached revision data persists indefinitely in `~/.wikihistory/refract.db`. Clear it manually: + +```bash +rm -rf ~/.wikihistory/ +``` + +There is no automatic data retention policy. The cache only contains data you explicitly fetched. diff --git a/apps/docs/docs/temporal-model.md b/apps/docs/docs/temporal-model.md new file mode 100644 index 0000000..b0825b6 --- /dev/null +++ b/apps/docs/docs/temporal-model.md @@ -0,0 +1,57 @@ +# Temporal Model + +Refract is designed around time — not just "what is the latest version?" but +"how did this claim change across revisions?" + +## Why temporal infrastructure + +Most document tools answer from the present corpus. Refract answers across +time: + +- **Rewind**: What did this claim say two years ago? +- **Replay**: How did this claim drift across revisions? +- **Compare**: What changed between the 2024 and 2025 guideline versions? +- **Audit**: What was supportable at the time of a specific decision? +- **Forecast**: What would trigger re-review? + +A vector database can find similar claims. It cannot do any of the above. +Refract fills that gap with deterministic, provenance-backed temporal +infrastructure. + +## Claim-state events + +Refract emits structured events that capture how claims change over time: + +| Event | Meaning | +|-------|---------| +| `sentence_first_seen` | Claim appeared in the source for the first time | +| `sentence_modified` | Claim wording changed (with edit magnitude) | +| `sentence_removed` | Claim removed from the source | +| `citation_added` | New supporting citation attached | +| `citation_removed` | Citation removed or superseded | +| `revert` | Edit was reverted (with cluster information) | + +Each event carries deterministic enrichment fields: +- `editMagnitude` — minor / moderate / major +- `contentChange` — introduction / removal / expansion / compression / rewrite +- `directionSignal` — strengthening / weakening / neutral +- `certaintyProfile` — counts of hedging and certainty markers +- `quantitativeFindings` — extracted p-values, hazard ratios, and sample sizes + +All fields are byte-reproducible. Same source, same events, every time. + +## Downstream use + +Refract provides the timeline. Downstream systems provide the judgment. + +[NextConsensus](https://nextconsensus.com), which maintains Refract, reads +these timelines as one input to its own work on how claims move across +institutions; what it concludes from them is its judgment, made downstream and +described on its own site. Refract itself remains domain-neutral — it works on +any versioned text source. + +## See also + +- [Events](./events.md) — full event type reference +- [Schema](./schema.md) — data model +- [Why Refract](./why-refract.md) — the problem Refract solves diff --git a/apps/docs/docs/tutorials/byo-inference.md b/apps/docs/docs/tutorials/byo-inference.md new file mode 100644 index 0000000..ae65a23 --- /dev/null +++ b/apps/docs/docs/tutorials/byo-inference.md @@ -0,0 +1,188 @@ +# Tutorial: BYO-inference with real models + +## Goal + +Replace Refract's default mechanical heuristics with a model at any BYO-inference +boundary. Compare model output against the default, and audit which path was taken. + +## What BYO-inference means + +Every analyzer threshold encodes an interpretive judgment — what counts as a revert, +what sentence similarity means "modified," what activity level constitutes a spike. +Refract ships with mechanical defaults that work offline. But at any of 5 typed +boundaries, you can plug in a model. + +The event records which path was taken in `FactProvenance.parameters`. The audit +trail is transparent regardless of who supplied the judgment. + +## Step 1: See the default heuristics + +Run a classification without a model: + +```bash +refract classify revert --input '{"comment":"reverted vandalism"}' +``` + +Output: +```json +{ + "boundary": "revert", + "output": { "isRevert": true, "rationale": "comment matches revert pattern" }, + "source": "default" +} +``` + +`source: "default"` means the mechanical heuristic (6 regex patterns) made the +decision. No API call was made. + +## Step 2: Plug in a model + +Set your API key and endpoint: + +```bash +export REFRACT_INFERENCE_API_KEY="sk-..." +export REFRACT_INFERENCE_ENDPOINT="https://api.openai.com/v1/chat/completions" +export REFRACT_INFERENCE_MODEL="gpt-4o-mini" +``` + +Or pass them as flags: + +```bash +refract classify revert \ + --input '{"comment":"reverted vandalism"}' \ + --endpoint https://api.openai.com/v1/chat/completions \ + --model gpt-4o-mini +``` + +Output: +```json +{ + "boundary": "revert", + "output": { "isRevert": true, "rationale": "The edit comment explicitly states 'reverted vandalism'" }, + "source": "model", + "confidence": 0.98 +} +``` + +`source: "model"` + `confidence` — the model classified the boundary, and you know +how confident it was. + +## Step 3: Try different providers + +Refract works with any OpenAI-compatible API: + +### DeepSeek + +```bash +refract classify revert \ + --input '{"comment":"rv unexplained removal"}' \ + --endpoint https://api.deepseek.com/v1/chat/completions \ + --model deepseek-chat +``` + +### Local Ollama + +```bash +# Start Ollama locally +ollama serve + +# Run with a local model +refract classify revert \ + --input '{"comment":"undo previous edit"}' \ + --endpoint http://localhost:11434/v1/chat/completions \ + --model llama3 +``` + +### Anthropic (via proxy) + +```bash +refract classify revert \ + --input '{"comment":"rvv"}' \ + --endpoint https://your-anthropic-proxy/v1/chat/completions \ + --model claude-3-haiku +``` + +No provider lock-in. Any endpoint that speaks `chat/completions` works. + +## Step 4: Compare model vs default + +Run the same classification with and without a model and compare: + +```bash +# Default +refract classify sentence_similarity \ + --input '{"before":"Earth is the third planet from the Sun","after":"Earth orbits the Sun as the third planet"}' + +# Model +refract classify sentence_similarity \ + --input '{"before":"Earth is the third planet from the Sun","after":"Earth orbits the Sun as the third planet"}' \ + --model gpt-4o-mini +``` + +The default uses word-overlap ratio (threshold 0.8). The model considers semantic +meaning. Same input, different reasoning, both recorded. + +## Step 5: Audit which path was taken + +When a model classifies a boundary during a full page analysis, the event records +the path: + +```bash +refract analyze "COVID-19" --depth forensic --report > report.json +``` + +Check `FactProvenance.parameters` in the output: + +```json +{ + "deterministicFacts": [{ + "fact": "revert_detected", + "provenance": { + "analyzer": "revert-detector", + "version": "0.5.1", + "parameters": { + "similaritySource": "model", + "modelName": "gpt-4o-mini" + } + } + }] +} +``` + +`similaritySource: "model"` means the model was used. `similaritySource: "default"` +means the mechanical heuristic was used. Every event carries this audit trail — you +always know who made the judgment. + +## Step 6: Use the MCP server for agent-driven classification + +AI coding agents can call `refract classify` via the MCP server's sampling capability. +The agent's host LLM classifies the boundary, and the result includes `source: "model"` +for auditability: + +```bash +refract mcp +``` + +Connect any MCP client and ask: + +> "Classify the most recent edit to the ChatGPT page — is it a revert, a major addition, +> or a minor edit? Use the model for classification." + +The agent calls `refract classify heuristic` via MCP, the host LLM classifies, and +the result is recorded with provenance. + +## The 5 BYO-inference boundaries + +| Boundary | Default (mechanical) | Model question | +|---|---|---| +| `revert` | 6 regex patterns | "Is this edit comment a revert?" | +| `sentence_similarity` | Word-overlap ratio (0.8) | "Are these two sentences the same claim?" | +| `heuristic` | Size thresholds + comment patterns | "What kind of edit is this?" | +| `template_signal` | Name-to-type lookup | "What policy signal does this template represent?" | +| `activity_spike` | 3x moving average | "Is this a meaningful spike in talk activity?" | + +## Next steps + +- [MCP agent tutorial](mcp-agent.md) — connect AI coding agents to Refract +- [CLI classify reference](../cli.md#refract-classify) — all boundaries and flags +- [Concepts: BYO-inference](../concepts.md#configurable-heuristics--byo-inference-boundaries) — architectural rationale diff --git a/apps/docs/docs/tutorials/citation-churn.md b/apps/docs/docs/tutorials/citation-churn.md new file mode 100644 index 0000000..b38f869 --- /dev/null +++ b/apps/docs/docs/tutorials/citation-churn.md @@ -0,0 +1,170 @@ +# Tutorial: Monitor citation churn on a contested page + +## Goal + +Track citation additions, removals, and replacements on a Wikipedia page to detect +sourcing instability — which sources appear, which disappear, and which sections +have the highest turnover. + +## Why citation churn matters + +Citation churn reveals source instability. When citations appear in one edit and +disappear in the next, sources are contested. When a journal article is replaced +with a news article, the evidence base is shifting. When churn concentrates on one +section, a content dispute is likely underway. + +Refract detects all three patterns mechanically. + +## Steps + +### 1. Analyze a page at forensic depth + +```bash +refract analyze "COVID-19" --depth forensic -c +``` + +Forensic depth enables all event types including citation replacement tracking. + +### 2. Filter to citation events + +```bash +refract export "COVID-19" --format ndjson | grep '"citation_' > citations.jsonl +``` + +This gives you a file with only `citation_added`, `citation_removed`, and +`citation_replaced` events. + +### 3. Parse with a script + +```python +import json + +additions = 0 +removals = 0 +replacements = 0 +sections = {} + +with open("citations.jsonl") as f: + for line in f: + event = json.loads(line) + etype = event["eventType"] + section = event["section"] + if etype == "citation_added": + additions += 1 + elif etype == "citation_removed": + removals += 1 + elif etype == "citation_replaced": + replacements += 1 + sections[section] = sections.get(section, 0) + 1 + +print(f"Additions: {additions}") +print(f"Removals: {removals}") +print(f"Replacements: {replacements}") +print(f"\nChurn by section:") +for sec, count in sorted(sections.items(), key=lambda x: -x[1])[:5]: + print(f" {sec}: {count} events") +``` + +### 4. Query with DuckDB + +```bash +refract export "COVID-19" --format ndjson > covid-events.jsonl +``` + +```sql +SELECT section, + count(*) FILTER (WHERE "eventType" = 'citation_added') as added, + count(*) FILTER (WHERE "eventType" = 'citation_removed') as removed, + count(*) FILTER (WHERE "eventType" = 'citation_replaced') as replaced +FROM 'covid-events.jsonl' +WHERE "eventType" LIKE 'citation_%' +GROUP BY section +ORDER BY (added + removed + replaced) DESC +LIMIT 5; +``` + +### 5. Track a specific source domain + +After export, filter by source domain to see which types of sources churn most +(official sources vs. news media vs. academic journals vs. primary sources): + +```bash +grep -o 'https\?://[^"]*' covid-events.jsonl | cut -d'/' -f3 | sort | uniq -c | sort -rn | head -10 +``` + +## Reading the patterns + +| Pattern | What it means | +|---------|---------------| +| Citation added, never removed | Stable, uncontested source | +| Citation added, removed in next edit | Contested — likely debated on talk page | +| Citation replaced (journal → news) | Evidence base shifting in real time | +| High churn in one section | Active editorial dispute in that section | +| High churn across all sections | Page undergoing major revision or cleanup | + +## Example: vaccine efficacy section + +```json +{ + "eventId": "d1e3f5a7b9c2048a", + "eventType": "citation_added", + "fromRevisionId": 1280090010, + "toRevisionId": 1280090100, + "section": "Vaccine efficacy", + "before": "", + "after": "<ref>{{cite journal |last1=Smith |title=Efficacy of mRNA vaccines...}}</ref>", + "timestamp": "2024-11-20T10:00:00Z", + "layer": "observed", + "deterministicFacts": [ + { + "fact": "Citation added in section Vaccine efficacy", + "provenance": { + "analyzer": "citation-tracker", + "version": "0.5.1", + "inputHashes": [] + } + } + ] +} +``` + +Twenty-four hours later: + +```json +{ + "eventId": "2b4d6f8a0c1e3059", + "eventType": "citation_replaced", + "fromRevisionId": 1280090100, + "toRevisionId": 1280090200, + "section": "Vaccine efficacy", + "before": "<ref>{{cite journal |last1=Smith...}}</ref>", + "after": "<ref>{{cite web |title=CDC Vaccine Report...}}</ref>", + "timestamp": "2024-11-21T14:30:00Z", + "layer": "observed", + "deterministicFacts": [ + { + "fact": "Citation replaced: journal article → web report", + "provenance": { + "analyzer": "citation-tracker", + "version": "0.5.1", + "inputHashes": [] + } + } + ] +} +``` + +The journal article was swapped for a government web report — the evidence base +shifted in under 24 hours. This pattern is what Refract is built to detect at scale. + +## Troubleshooting + +- **No citation events?** The page may use minimal citations. Try a page with + active sourcing (medical, scientific, or controversial topics work best). +- **DuckDB version**: DuckDB 1.0+ required for the `FILTER` clause. For older + versions, use `SUM(CASE WHEN ... THEN 1 ELSE 0 END)`. + +## Next steps + +- [Build a dispute timeline](dispute-timeline.md) +- [Analyze with DuckDB](../analytics.md) diff --git a/apps/docs/docs/tutorials/combating-revisionism.md b/apps/docs/docs/tutorials/combating-revisionism.md new file mode 100644 index 0000000..04179f0 --- /dev/null +++ b/apps/docs/docs/tutorials/combating-revisionism.md @@ -0,0 +1,153 @@ +# Tutorial: Combat revisionism with deterministic provenance + +## Goal + +Use Refract to detect revisionist edits — claim laundering, citation swapping, +coordinated rewrites — and produce an event record, with a Merkle root, that +anyone can reproduce by running the same analysis. + +## What Refract gives historians that Wikipedia's UI doesn't + +Wikipedia's page history shows you diffs. Refract shows you patterns across diffs: +which claims survived, which sources got swapped, whether edits were debated or +silent, whether two wikis tell different stories, and a Merkle root that anyone +re-running the analysis can reproduce. + +Revisionism works by making the old version disappear. Refract makes the +disappearance visible as a structured, queryable event. Every change becomes a +timestamped, provenance-tagged record. The edit may vanish from the page — it +cannot vanish from the event stream. + +## Step 1: Claim provenance — when did this sentence enter the record? + +A contested historical claim appears on Wikipedia. When was it first added? + +```bash +refract claim "Battle of X" --text "civilian casualties were minimal" -c +``` + +Output: a timeline of every revision where that sentence appeared, was rewritten, +removed, or reintroduced. Each event carries the exact revision ID, timestamp, +and a deterministic SHA-256 `eventId`. Run the same command on the same revision +range and you get the same hash — independently verifiable. + +```json +{ + "eventType": "sentence_first_seen", + "toRevisionId": 1280110100, + "timestamp": "2021-04-03T12:00:00Z", + "after": "civilian casualties were minimal according to official sources", + "claimId": "c7d8e9f0a1b23456", + "layer": "observed" +} +``` + +## Step 2: Citation laundering — same claim, different source + +A common revisionist tactic: leave the claim text intact but swap the supporting +source. "Civilian casualties were minimal" stays, but the citation changes from +an independent human rights report to a government press release. + +Refract catches this as a `citation_replaced` event: + +```bash +refract analyze "Battle of X" --depth forensic -c > battle-events.jsonl +``` + +```sql +SELECT timestamp, before, after +FROM 'battle-events.jsonl' +WHERE "eventType" = 'citation_replaced' + AND section LIKE '%Casualties%' +ORDER BY timestamp; +``` + +The `before` field contains the old citation. The `after` field contains the new +one. The claim text didn't change — but what supports it did. Without Refract, +this pattern requires manually comparing every revision diff. + +To find claims that lost their original sources: + +```sql +SELECT + claim_id, + count(*) FILTER (WHERE "eventType" = 'citation_added') as sources_added, + count(*) FILTER (WHERE "eventType" = 'citation_removed') as sources_removed, + count(*) FILTER (WHERE "eventType" = 'citation_replaced') as sources_swapped +FROM 'battle-events.jsonl' +GROUP BY claim_id +HAVING sources_removed > sources_added +ORDER BY sources_swapped DESC; +``` + +## Step 3: Edit clusters — coordinated or organic? + +3+ edits hitting the same section within an hour fire `edit_cluster_detected`: + +```sql +SELECT timestamp, section, deterministicFacts[0].detail as detail +FROM 'battle-events.jsonl' +WHERE "eventType" = 'edit_cluster_detected' +ORDER BY timestamp; +``` + +The `detail` field reports the editor count and time span. +Refract does not record editor identities. + +## Step 4: Talk page correlation — was this debated? + +Refract checks whether talk page discussion occurred within 7 days before or +3 days after each edit. The absence of talk activity alongside content changes +is itself a signal: + +```sql +SELECT + count(*) FILTER (WHERE "eventType" LIKE 'sentence_%') as content_changes, + count(*) FILTER (WHERE "eventType" LIKE 'talk_%') as talk_activity, + count(*) FILTER (WHERE "eventType" = 'revert_detected') as reverts +FROM 'battle-events.jsonl'; +``` + +## Step 5: Cross-wiki temporal precedence — who said it first? + +```bash +refract analyze "Topic" --api https://en.wikipedia.org/w/api.php > wiki-en.jsonl +refract analyze "Topic" --api https://de.wikipedia.org/w/api.php > wiki-de.jsonl +``` + +Or use `refract diff` for structured cross-wiki comparison with z-score outlier detection. + +## Step 6: A Merkle root others can reproduce + +```bash +refract export "Battle of X" --manifest --from 1270000000 --to 1280110100 > battle-manifest.json +``` + +The manifest lists a SHA-256 hash of each input revision, a hash of each event, +the Merkle root over the event hashes, and the Refract version. A skeptic who +runs the same version over the same revision range gets the same Merkle root; a +different root means the events differ. Nothing is signed, so the root shows the +analysis is reproducible, not who ran it or when. + +## What Refract deliberately does NOT tell you + +Refract never says "this edit was revisionist" or "this claim is true." It says: +this sentence appeared at this revision, supported by this source, removed at +this revision, discussed on talk at this date, reinstated without its original +citation at this revision. + +- No sentiment analysis +- No truth claims +- No editor targeting +- No model calls in the deterministic pipeline + +The historian brings the domain knowledge. Refract brings the audit trail. + +## Next steps + +- [Event taxonomy](../events.md) — all 26 event types +- [Schema reference](../schema.md) — full EvidenceEvent structure +- [Citation churn tutorial](citation-churn.md) +- [Dispute timeline tutorial](dispute-timeline.md) +- [Analytics with DuckDB](../analytics.md) +- [Bundle and manifest formats](../bundle-manifest.md) diff --git a/apps/docs/docs/tutorials/cross-wiki-diff.md b/apps/docs/docs/tutorials/cross-wiki-diff.md new file mode 100644 index 0000000..05efcc9 --- /dev/null +++ b/apps/docs/docs/tutorials/cross-wiki-diff.md @@ -0,0 +1,143 @@ +# Tutorial: Cross-wiki comparison with `refract diff` + +## Goal + +Compare how the same topic is covered across two or more MediaWiki instances — different +language Wikipedias, competing Fandom wikis, or independent vs. official wikis — and +detect where they diverge. + +## Why cross-wiki comparison matters + +The same topic framed differently across wikis reveals editorial perspective, regional +bias in sourcing, canon disputes in fictional universes, and institutional differences +in how knowledge is maintained. Refract can detect these divergences mechanically. + +## Steps + +### 1. Compare two Wikipedia language editions + +```bash +refract diff "Douglas_Adams" \ + --wiki-a https://en.wikipedia.org/w/api.php \ + --wiki-b https://de.wikipedia.org/w/api.php \ + --depth detailed +``` + +Refract fetches both pages, runs the full deterministic pipeline on each, and produces +a side-by-side diff of section structure, citations, categories, and sentence changes. + +### 2. Compare competing Fandom wikis + +```bash +refract diff "Darth_Vader" \ + --wiki-a https://starwars.fandom.com/api.php \ + --wiki-b https://clone-wars.fandom.com/api.php \ + --depth forensic +``` + +Forensic depth enables edit cluster and talk page correlation on both wikis — showing +not just what differs, but how contested the differences are. + +### 3. Compare three wikis at once + +```bash +refract diff "climate_change" \ + --wiki-a https://en.wikipedia.org/w/api.php \ + --wiki-b https://de.wikipedia.org/w/api.php \ + --wiki-c https://fr.wikipedia.org/w/api.php \ + --depth detailed +``` + +### 4. Export the comparison + +```bash +refract diff "Bitcoin" \ + --wiki-a https://en.wikipedia.org/w/api.php \ + --wiki-b https://simple.wikipedia.org/w/api.php \ + --depth detailed > diff-output.jsonl +``` + +## Reading the output + +`refract diff` produces events with a `wiki` property tagging which wiki the event +came from: + +```json +{ + "eventType": "citation_added", + "wiki": "b", + "wikiUrl": "https://simple.wikipedia.org/w/api.php", + "fromRevisionId": 100, + "toRevisionId": 101, + "section": "History", + "before": "", + "after": "<ref>{{cite web |title=Bitcoin.org...}}</ref>", + "timestamp": "2024-01-15T10:00:00Z", + "deterministicFacts": [ + { + "fact": "Citation added only on wiki B, absent on wiki A", + "provenance": { "analyzer": "cross-wiki-differ", "version": "0.5.1" } + } + ] +} +``` + +Key signals: +- **Event present on one wiki but not the other** → content divergence +- **Same section, different content** → framing divergence +- **Different categories** → classification divergence (canon vs. non-canon, different rating systems) +- **Different citation sources** → evidence base divergence (English-language sources vs. local sources) + +## Use cases + +### Detecting canon divergence (Fandom) + +When two Fandom wikis cover the same fictional universe with different canon policies, +`refract diff` catches the moment of divergence: + +``` +Wiki A: category_removed "Canon characters" at rev 1234 +Wiki B: no corresponding event + +→ Wiki A reclassified canon. Wiki B didn't. Canon divergence detected. +``` + +### Detecting framing divergence (Wikipedia) + +The same topic across English and German Wikipedia often differs in what's emphasized: + +``` +Wiki A: citation_added (section "Regulation") — SEC filing from 2023 +Wiki B: citation_added (section "Regulierung") — BaFin statement from 2023 + +→ Each wiki uses locally-relevant primary sources. The evidence base is jurisdiction-specific. +``` + +### Detecting editorial depth divergence + +Simple Wikipedia vs. English Wikipedia shows how a topic is explained at different +complexity levels: + +``` +Wiki A (English): 340 events, 26 revisions, forensic depth +Wiki B (Simple): 45 events, 8 revisions, detailed depth + +→ Simple Wikipedia has fewer events, fewer citations, fewer contested claims. +→ The complexity difference is measurable from the event stream alone. +``` + +## Troubleshooting + +- **One wiki returns no results**: Verify the page title is exact — different wikis + may use different naming conventions (e.g., "Douglas_Adams" vs. "Douglas Adams"). + Try the page title on each wiki individually first with `refract analyze`. +- **Rate limits when hitting 3+ wikis**: Use `-c` on each individual analyze call + to cache results, then run `refract diff` with cached data. +- **Custom API endpoints**: Any MediaWiki instance works. For private wikis, pass + auth credentials with `--api-key`, `--api-user`, or `--api-password`. + +## Next steps + +- [Track Wikipedia changes](wikipedia-history.md) +- [Monitor citation churn](citation-churn.md) +- [Build a dispute timeline](dispute-timeline.md) diff --git a/apps/docs/docs/tutorials/custom-adapter.md b/apps/docs/docs/tutorials/custom-adapter.md new file mode 100644 index 0000000..b104053 --- /dev/null +++ b/apps/docs/docs/tutorials/custom-adapter.md @@ -0,0 +1,128 @@ +# Tutorial: Add a non-Wikipedia data source + +## Goal + +Connect Refract to a wiki or knowledge base that isn't Wikipedia — Confluence, +GitHub wikis, Notion, or any revision-tracked content source. The engine doesn't +change. You write an adapter. + +## How the adapter surface works + +Refract's ingestion pipeline consumes `Revision[]` — an array of revision objects +with content, timestamps, and metadata. The `MediaWikiClient` is one implementation +of this interface. Any source that can produce `Revision[]` works. + +```typescript +export interface Revision { + revId: number; + pageId: number; + pageTitle: string; + timestamp: string; + user?: string; + comment: string; + content: string; // ← the wikitext or document content + size: number; + minor: boolean; +} +``` + +Once you have `Revision[]`, every analyzer works: section differ, citation tracker, +revert detector, edit cluster detector, talk page correlator. The analyzers are +pure functions — they don't know or care where the revisions came from. + +They do care what the content is written in. The section, citation, template and +link diffs parse wikitext: `== headings ==`, `<ref>` tags, `{{templates}}`, +`[[links]]`. Content in another markup has to be converted to wikitext first, or +those diffs find nothing to compare. Confluence's storage format, used below, is +XHTML. + +## Pattern: adapter function + +Write a single function that fetches your source and returns `Revision[]`: + +```typescript +import type { Revision } from "@refract-org/evidence-graph"; +import { annotateEvents, buildRevisionEvents } from "@refract-org/analyzers"; + +async function fetchFromConfluence( + pageId: string, + apiUrl: string, + apiToken: string, +): Promise<Revision[]> { + const response = await fetch(`${apiUrl}/rest/api/content/${pageId}/version`, { + headers: { Authorization: `Bearer ${apiToken}` }, + }); + + const data = await response.json(); + return data.results.map((v: any) => ({ + revId: v.number, + pageId: parseInt(pageId), + pageTitle: v.title ?? pageId, + timestamp: v.when, + user: v.by?.displayName, + comment: v.message ?? "", + content: toWikitext(v.body?.storage?.value ?? ""), // your XHTML-to-wikitext converter + size: v.body?.storage?.value?.length ?? 0, + minor: v.minorEdit ?? false, + })); +} + +// Use it exactly like the Wikipedia client +const revisions = await fetchFromConfluence("12345", "https://mycompany.atlassian.net/wiki", "token"); +// The events `refract analyze` derives from each pair of revisions +// (@refract-org/analyzers 0.5.1+). +const events = annotateEvents(buildRevisionEvents(revisions)); + +console.log(`Found ${events.length} events across ${revisions.length} revisions`); +``` + +## Existing adapters + +| Source | Protocol | Auth | Example | +|---|---|---|---| +| **MediaWiki** (Wikipedia, Fandom) | `api.php` | None / Bearer / Basic / OAuth2 | Built-in (`@refract-org/ingestion`) | +| **Private MediaWiki** | `api.php` | Bearer / Basic | [Private wiki tutorial](private-wiki.md) | +| **Confluence** | REST API | Bearer token | Example above | + +## When to build an adapter vs. use the CLI + +| If you need | Use | +|---|---| +| Wikipedia or any MediaWiki wiki | `refract analyze "Page" --api <url>` — no code needed | +| A non-MediaWiki source | Write an adapter function (pattern above) | +| An adapter that others might use | Contribute it to `labs/` in the refract monorepo | +| Private/authenticated sources | [Private wiki tutorial](private-wiki.md) | + +## What the analyzers expect + +The analyzers operate on `content` (plain wikitext). If your source isn't +wikitext (e.g., Markdown, HTML, Notion blocks), preprocess it before passing +to analyzers: + +```typescript +function markdownToWikitext(md: string): string { + return md + .replace(/^### /gm, "=== ") // headings + .replace(/^## /gm, "== ") + .replace(/^# /gm, "= ") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // links → plain text + .replace(/`([^`]+)`/g, "$1"); // inline code → plain text +} +``` + +The better the preprocessing, the better the analysis. Citation tracking, for +example, looks for `<ref>` tags — if your source doesn't use them, citations +won't be detected. Adapt the preprocessing to match what your analyzers expect. + +## Contribute an adapter + +If you've built an adapter for a common source, contribute it to +[refract-labs](https://github.com/refract-org/refract-labs) as an experimental +probe. Follow the [custom analyzer tutorial](custom-analyzer.md) for the full +pipeline: adapter → analyzer integration → tests → eval. + +## Next steps + +- [Private wiki tutorial](private-wiki.md) — authenticated MediaWiki instances +- [Custom analyzer tutorial](custom-analyzer.md) — build a new analyzer +- [Downstream integration](../downstream.md) — production patterns diff --git a/apps/docs/docs/tutorials/custom-analyzer.md b/apps/docs/docs/tutorials/custom-analyzer.md new file mode 100644 index 0000000..0a58085 --- /dev/null +++ b/apps/docs/docs/tutorials/custom-analyzer.md @@ -0,0 +1,213 @@ +# Tutorial: Build a custom analyzer + +## Goal + +Add a new deterministic analyzer to Refract — one that produces a new event type and +integrates into the pipeline. This tutorial walks through the full cycle: define the +event type, implement the analyzer, integrate with the CLI, write tests, add an eval +benchmark, and verify the build gate. + +## Prerequisites + +- Bun 1.2+ +- Clone of the refract monorepo (`git clone https://github.com/refract-org/refract`) +- Passing build gate before you start: `bun run ci` + +## Step 1: Define the event type + +All event types live in `packages/evidence-graph/src/schemas/evidence.ts`. Add your +new type to the `EventType` union. This example adds a "redirect detected" event: + +```typescript +export type EventType = + // ... existing types ... + | "redirect_detected"; // ← add here +``` + +Keep the union alphabetically grouped within its category. Bump the +`EVENT_SCHEMA_VERSION` minor version: + +```typescript +export const EVENT_SCHEMA_VERSION = "0.5.0"; +``` + +Build to verify the type propagates: + +```bash +cd packages/evidence-graph && bun run build +``` + +## Step 2: Implement the analyzer + +Create `packages/analyzers/src/redirect-detector.ts`: + +```typescript +import type { EvidenceEvent, Revision } from "@refract-org/evidence-graph"; + +export function detectRedirects(revisions: Revision[]): EvidenceEvent[] { + const events: EvidenceEvent[] = []; + + for (let i = 1; i < revisions.length; i++) { + const prev = revisions[i - 1]; + const curr = revisions[i]; + + // A redirect is wikitext starting with #REDIRECT + const wasRedirect = prev.content.trimStart().startsWith("#REDIRECT"); + const becameRedirect = curr.content.trimStart().startsWith("#REDIRECT"); + + if (becameRedirect && !wasRedirect) { + events.push({ + eventType: "redirect_detected", + fromRevisionId: prev.revId, + toRevisionId: curr.revId, + section: "", + before: prev.content.slice(0, 200), + after: curr.content.slice(0, 200), + deterministicFacts: [{ + fact: "redirect_detected", + detail: `page converted to redirect in revision ${curr.revId}`, + provenance: { + analyzer: "redirect-detector", + version: "0.5.0", + inputHashes: [], + }, + }], + layer: "observed", + timestamp: curr.timestamp, + }); + } + } + + return events; +} +``` + +Add the export to `packages/analyzers/src/index.ts`: + +```typescript +export { detectRedirects } from "./redirect-detector.js"; +``` + +## Step 3: Integrate with the CLI + +In `packages/cli/src/commands/analyze.ts`, add the analyzer to the forensic depth +pipeline (or create a new depth level if appropriate): + +```typescript +import { detectRedirects } from "@refract-org/analyzers"; + +// In the forensic depth block: +if (depth === "forensic") { + const redirectEvents = detectRedirects(revisions); + events.push(...redirectEvents); +} +``` + +## Step 4: Write tests + +Create `packages/analyzers/src/__tests__/redirect-detector.test.ts`: + +```typescript +import { describe, expect, it } from "vitest"; +import type { Revision } from "@refract-org/evidence-graph"; +import { detectRedirects } from "../redirect-detector.js"; + +function makeRev(revId: number, content: string, timestamp = "2026-01-01T00:00:00Z"): Revision { + return { + revId, content, comment: "", timestamp, + pageId: 1, pageTitle: "Test", user: undefined, + }; +} + +describe("detectRedirects", () => { + it("detects when a page becomes a redirect", () => { + const revs = [ + makeRev(1, "Normal article text"), + makeRev(2, "#REDIRECT [[Target page]]"), + ]; + const events = detectRedirects(revs); + expect(events).toHaveLength(1); + expect(events[0].eventType).toBe("redirect_detected"); + }); + + it("does not fire when page stays a redirect", () => { + const revs = [ + makeRev(1, "#REDIRECT [[Old target]]"), + makeRev(2, "#REDIRECT [[New target]]"), + ]; + const events = detectRedirects(revs); + expect(events).toHaveLength(0); + }); + + it("does not fire when page stays normal", () => { + const revs = [ + makeRev(1, "Normal text"), + makeRev(2, "Updated normal text"), + ]; + expect(detectRedirects(revs)).toHaveLength(0); + }); +}); +``` + +Run the tests: + +```bash +bun run test +``` + +## Step 5: Add an eval benchmark + +In `packages/eval/src/index.ts`, the `benchmarkPages()` method returns test cases. +Add one for your analyzer: + +```typescript +{ + id: "redirect-detection", + description: "Pages that were converted to redirects produce redirect_detected events", + pageTitle: "Wikipedia:Redirect", // a known redirect + pageId: 12345, + revisionRange: { from: 0, to: 0 }, + expectedEvents: [{ eventType: "redirect_detected", section: "" }], + tolerance: { minEventCount: 1, minPrecision: 0.0 }, +}, +``` + +Run the eval: + +```bash +refract eval --page "Wikipedia:Redirect" +``` + +## Step 6: Verify the build gate + +```bash +bun run ci +``` + +This runs: `build && lint && typecheck && check:boundaries && test`. All must pass. +If your analyzer introduces exports that cross package boundaries incorrectly, +`check:boundaries` will catch it. + +## Step 7: Update documentation + +- Add the new event type to `refract-docs/docs/schema.md` +- Add it to `refract-docs/docs/events.md` event taxonomy +- If the CLI flag changed, update `refract-docs/docs/cli.md` + +## Conventions checklist + +- [ ] File: kebab-case (`redirect-detector.ts`) +- [ ] Function: camelCase (`detectRedirects`) +- [ ] Imports: `import type` for types, `.js` extension for intra-package +- [ ] Test file: `src/__tests__/redirect-detector.test.ts` +- [ ] Exports: only the public API from `index.ts` +- [ ] Event types: snake_case (`redirect_detected`) +- [ ] No model calls in the analyzer +- [ ] No comments explaining what code does (only non-obvious constraints) +- [ ] Commit: `feat(analyzers): add redirect detector` + +## Next steps + +- [SDK reference](../sdk.md) — all packages and their APIs +- [Architecture decisions](../architecture-decisions.md) — why deterministic-only +- [Workqueue protocol](https://github.com/refract-org/refract/blob/main/AGENTS.md) — how tasks are structured diff --git a/apps/docs/docs/tutorials/custom-eval.md b/apps/docs/docs/tutorials/custom-eval.md new file mode 100644 index 0000000..4d457fd --- /dev/null +++ b/apps/docs/docs/tutorials/custom-eval.md @@ -0,0 +1,140 @@ +# Tutorial: Custom eval ground truth labels + +## Goal + +Define your own ground truth labels, run Refract's evaluation harness against them, +and interpret precision, recall, and F1 scores per analyzer. Extend the eval beyond +the built-in benchmark pages. + +## How the eval works + +The eval package compares Refract's pipeline output against independently verified +outcome labels — human-labeled ground truth about what actually happened on a page +at a specific time. Labels are stored separately from pipeline output. The pipeline +never modifies them. + +Each label specifies: +- Which event types should appear on the page at a given time +- What section they should appear in (optional) +- The source of the ground truth (talk page consensus, RFC closure, ArbCom decision) + +## Step 1: Create a ground truth labels file + +Create a JSON file with your labels. Each label describes one known outcome: + +```json +[ + { + "id": "covid-origin-consensus", + "source": "talk_page_consensus", + "pageTitle": "COVID-19", + "description": "Talk page consensus (Jan 2023) removed unsourced origin claims from lead", + "observedAt": "2023-01-15T00:00:00Z", + "resolution": "keep", + "referenceUrl": "https://en.wikipedia.org/wiki/Talk:COVID-19/Archive_50#Origin_claims", + "expectedEventTypes": ["sentence_removed", "section_reorganized"], + "expectedSection": "(lead)" + }, + { + "id": "bitcoin-citation-dispute", + "source": "rfc_closure", + "pageTitle": "Bitcoin", + "description": "RFC closed June 2022: Nakamoto whitepaper citation must remain in lead", + "observedAt": "2022-06-20T00:00:00Z", + "resolution": "keep", + "referenceUrl": "https://en.wikipedia.org/wiki/Talk:Bitcoin/RFC_citation", + "expectedEventTypes": ["citation_added"], + "expectedSection": "(lead)" + }, + { + "id": "trump-protection-event", + "source": "page_protection", + "pageTitle": "Donald_Trump", + "description": "Page protected Aug 2024 after edit war. Revert events expected in surrounding revisions.", + "observedAt": "2024-08-10T00:00:00Z", + "resolution": "other", + "referenceUrl": "https://en.wikipedia.org/wiki/Special:Log?page=Donald_Trump", + "expectedEventTypes": ["protection_changed", "revert_detected"] + } +] +``` + +Each label requires: +- `id`: unique identifier +- `source`: one of `talk_page_consensus`, `rfc_closure`, `arbcom_decision`, `page_protection` +- `pageTitle`: exact Wikipedia page title +- `expectedEventTypes`: which event types the pipeline should detect +- `referenceUrl`: public permalink to the ground truth source + +## Step 2: Run the eval with custom labels + +```bash +refract eval --ground-truth my-labels.json +``` + +Refract loads the labels, fetches the relevant pages, runs the pipeline, and compares +output against your ground truth: + +``` +Loaded 3 ground truth labels from my-labels.json + +[1/3] COVID-19 — talk_page_consensus: Talk page consensus (Jan 2023)... + PASS — precision=1.00 recall=1.00 f1=1.00 + 2/2 expected events matched (sentence_removed, section_reorganized) + +[2/3] Bitcoin — rfc_closure: RFC closed June 2022... + PASS — precision=1.00 recall=1.00 f1=1.00 + 1/1 expected events matched (citation_added) + +[3/3] Donald_Trump — page_protection: Page protected Aug 2024... + FAIL — precision=0.50 recall=1.00 f1=0.67 + 1/2 expected events matched (revert_detected) + Missed: protection_changed + +=== Eval Summary === +Total outcomes: 3 +Passed: 2 / Failed: 1 +Overall precision: 83.3% +Overall recall: 100.0% +Overall F1: 88.9% +``` + +## Step 3: Interpret the results + +| Outcome | What it means | +|---|---| +| **Pass (P=1.0, R=1.0)** | Pipeline detected all expected events. The analyzer is working as intended. | +| **Pass (P<1.0, R=1.0)** | Pipeline found the expected events plus extras (false positives). Precision < 1.0 may indicate the analyzer is too sensitive. | +| **Fail (R=0.0)** | Pipeline missed all expected events. The analyzer may not cover this scenario, or the revision range doesn't include the event. | +| **Fail (P=0.5, R=1.0)** | Pipeline found some expected events but missed others. The analyzer is partially working — check if the missed event type is in scope. | + +## Step 4: Diagnose misses + +When an outcome fails, investigate the missed events: + +1. Run the same page manually: `refract analyze "Page" --depth forensic` +2. Check the revision range around the `observedAt` timestamp +3. Verify the `expectedEventTypes` match what the analyzer produces +4. If the `expectedSection` is specified, check that the event fired in that section + +The eval doesn't tell you *why* a miss happened — it tells you *what* was missed. +Use the manual analysis to diagnose. + +## Step 5: Contribute labels back + +If you've curated labels for notable Wikipedia events, consider contributing them to +the built-in `GROUND_TRUTH_LABELS` in `packages/eval/src/ground-truth.ts`. Each label +is a coded observation of a real editorial process outcome. The more labels the eval +has, the better it measures analyzer accuracy across diverse scenarios. + +Format for contribution: +- `id`: descriptive kebab-case (`covid-origin-consensus-2023`) +- `referenceUrl`: must be a public, permanent Wikipedia URL +- `source`: must be one of the 4 supported types +- `expectedEventTypes`: must be members of the current `EventType` union + +## Next steps + +- [Evaluation harness reference](../eval.md) — full eval API and benchmark pages +- [Schema reference](../schema.md) — all event types +- [Build a custom analyzer](custom-analyzer.md) — add new event types and test them diff --git a/apps/docs/docs/tutorials/dispute-timeline.md b/apps/docs/docs/tutorials/dispute-timeline.md new file mode 100644 index 0000000..9b319f4 --- /dev/null +++ b/apps/docs/docs/tutorials/dispute-timeline.md @@ -0,0 +1,179 @@ +# Tutorial: Build a dispute timeline + +## Goal + +Combine article revision history with talk page activity to build a timeline of +an editorial dispute — when it started, how intense it was, whether it was resolved, +and how talk page discussion correlated with content changes. + +## What makes a dispute visible in the data + +Refract detects three mechanical signals that, together, form a dispute signature: + +1. **Reverts** — edits undone by another editor (`revert_detected`) +2. **Edit clusters** — 3+ edits in a tight time window (`edit_cluster_detected`) +3. **Talk page activity** — correlated discussion threads, reply chains, and activity + spikes (`talk_page_correlated`, `talk_activity_spike`) + +A section with revert clusters, no talk page activity, and rapid edit turnover is +likely edit-warring. A section with revert clusters, long talk threads, and slower +turnover is active deliberation. Refract distinguishes these patterns mechanically. + +## Steps + +### 1. Analyze the article at forensic depth + +```bash +refract analyze "Global_warming" --depth forensic -c +``` + +Forensic depth enables edit cluster detection, talk page correlation, and sentence +modification tracking — all necessary for dispute detection. + +### 2. Analyze the talk page + +```bash +refract analyze "Talk:Global_warming" --depth detailed -c +``` + +Talk pages are fetched by prefixing the page title with `Talk:`. Refract detects +thread openings, replies, archival, and activity spikes from talk page revisions. + +### 3. Visualize the combined timeline + +```bash +refract visualize "Global_warming" --format mermaid --all +``` + +Shows all event types (not just claim events) as a timeline diagram. Reverts and +edit clusters will stand out visually. + +### 4. Export for quantitative analysis + +```bash +refract export "Global_warming" --format ndjson > events.jsonl +refract export "Talk:Global_warming" --format ndjson > talk-events.jsonl +``` + +### 5. Correlate reverts with talk page activity + +```python +import json +from collections import defaultdict + +# Load article reverts +article_reverts = [] +with open("events.jsonl") as f: + for line in f: + event = json.loads(line) + if event["eventType"] in ("revert_detected", "edit_cluster_detected"): + article_reverts.append(event) + +# Load talk activity +talk_days = defaultdict(int) +with open("talk-events.jsonl") as f: + for line in f: + event = json.loads(line) + day = event["timestamp"][:10] + talk_days[day] += 1 + +# Correlate: reverts per day vs talk activity per day +revert_days = defaultdict(int) +for event in article_reverts: + day = event["timestamp"][:10] + revert_days[day] += 1 + +# Days with high reverts but low talk → potential edit-warring +# Days with high reverts and high talk → active deliberation +for day in sorted(set(revert_days) | set(talk_days)): + reverts = revert_days.get(day, 0) + talk = talk_days.get(day, 0) + if reverts > 0 or talk > 0: + signal = "edit-warring?" if reverts > 2 and talk < 3 else \ + "deliberation" if reverts > 0 and talk >= 3 else \ + "discussion" if talk > 0 else "" + print(f"{day}: {reverts} reverts, {talk} talk events → {signal}") +``` + +## Reading the patterns + +```json +{ + "eventId": "3c5e7f9a1b2d4068", + "eventType": "revert_detected", + "fromRevisionId": 1280100100, + "toRevisionId": 1280100200, + "section": "Scientific consensus", + "before": "The scientific consensus is settled", + "after": "The scientific consensus is disputed", + "timestamp": "2024-11-18T09:00:00Z", + "layer": "observed", + "deterministicFacts": [ + { + "fact": "Edit reverted in section Scientific consensus", + "detail": "Content replaced with previous version", + "provenance": { + "analyzer": "revert-detector", + "version": "0.5.1", + "inputHashes": [] + } + } + ] +} +``` + +Thirty minutes later, the same change was reverted back: + +```json +{ + "eventId": "4d6f8a0b2c3e5179", + "eventType": "revert_detected", + "fromRevisionId": 1280100200, + "toRevisionId": 1280100300, + "section": "Scientific consensus", + "before": "The scientific consensus is disputed", + "after": "The scientific consensus is settled", + "timestamp": "2024-11-18T09:30:00Z", + "layer": "observed", + "deterministicFacts": [ + { + "fact": "Edit reverted in section Scientific consensus", + "detail": "Content replaced with previous version", + "provenance": { + "analyzer": "revert-detector", + "version": "0.5.1", + "inputHashes": [] + } + } + ] +} +``` + +Two reverts in 30 minutes on the same sentence — `"settled"` → `"disputed"` → +`"settled"` — with the `before`/`after` fields showing the exact text that ping-ponged. + +A correlated `talk_activity_spike` on the same day would indicate editors discussing +the change on the talk page while reverting on the article. A day with reverts but +no talk activity suggests edit-warring without discussion. + +| Signal combination | Interpretation | +|--------------------|----------------| +| Revert cluster + no talk activity | Edit-warring | +| Revert cluster + talk thread opened | Dispute acknowledged and being discussed | +| Revert + talk reply chain | Active deliberation | +| Talk activity spike + no reverts | Pre-revert discussion (editors talking before acting) | +| `edit_cluster_detected` with 5+ editors | Coordinated editing or page under heavy revision | + +## Troubleshooting + +- **No talk page events?** Not all pages have active talk pages. Try pages with + known controversies (climate change, political topics, technology disputes). +- **Talk: prefix not working?** Some wikis use different namespaces. Try + `Discussion:` or check the wiki's namespace configuration. The `Talk:` prefix + works for English Wikipedia and most Fandom wikis. + +## Next steps + +- [Monitor citation churn](citation-churn.md) +- [Export for analytics](../analytics.md) +- [Work with notebooks](../notebooks.md) diff --git a/apps/docs/docs/tutorials/fandom-canon.md b/apps/docs/docs/tutorials/fandom-canon.md new file mode 100644 index 0000000..14bd3e5 --- /dev/null +++ b/apps/docs/docs/tutorials/fandom-canon.md @@ -0,0 +1,120 @@ +# Tutorial: Track canon changes on a Fandom wiki + +## Goal + +Monitor how a Fandom wiki's canon pages change over time: when lore-critical +statements appear, disappear, or are rewritten — and how editorial consensus shifts. + +## Steps + +### 1. Point Refract at a Fandom wiki + +Fandom wikis use the standard MediaWiki API. Pass `--api` with the wiki's API endpoint: + +```bash +refract analyze "Darth_Vader" --api https://starwars.fandom.com/api.php --depth detailed +``` + +### 2. Cache for repeated analysis + +Fandom API rate limits differ from Wikipedia. Cache revisions to avoid re-fetching: + +```bash +refract analyze "Darth_Vader" --api https://starwars.fandom.com/api.php --depth detailed -c +``` + +### 3. Export for comparison + +```bash +refract export "Darth_Vader" --api https://starwars.fandom.com/api.php --format ndjson > canon-events.jsonl +``` + +### 4. Track a specific lore claim + +```bash +refract claim "Darth_Vader" \ + --text "midichlorian count is over 20,000" \ + --api https://starwars.fandom.com/api.php -c +``` + +This produces a timeline of every revision where that claim was added, modified, +removed, or reintroduced. + +### 5. Watch for new edits + +```bash +refract watch "Darth_Vader" --api https://starwars.fandom.com/api.php --interval 60000 +``` + +Polls the wiki every 60 seconds and prints new events as they appear. Press Ctrl+C to stop. + +## Understanding what you see + +Fandom wikis differ from Wikipedia in important ways: + +| Signal | On Wikipedia | On Fandom wikis | +|--------|-------------|-----------------| +| **`sentence_first_seen`** | New factual claim | New character backstory, power level, or timeline entry | +| **`sentence_removed`** | Claim removed (possibly contested) | Retcon — lore being erased or rewritten | +| **`category_added` / `category_removed`** | Content tagging change | Canon classification shift (e.g., "Canon characters" → "Legends characters") | +| **`template_added`** | Policy template | "Citation needed" on lore claims, "Speculation" tags | +| **`revert_detected`** | Editorial dispute | Canon war — competing interpretations of what's official | + +**Retcon detection pattern**: Watch for `sentence_removed` on a long-standing claim +followed by `sentence_first_seen` with different text in the same section. This is +the mechanical signature of a retcon — old lore replaced by new canon. + +**Canon divergence across wikis**: Compare the same topic across two Fandom wikis with +`refract diff`. Divergence in categories or template usage between wikis signals +different interpretations of what's official. + +## Example: 2014 Disney acquisition + +After Disney acquired Lucasfilm in 2014, many characters and ships had their +canon status reclassified. Refract captures this as a pattern of: + +- `category_removed`: `Canon characters` +- `category_added`: `Legends characters` +- `sentence_modified` events as backstories were rewritten to match new canon +- `template_added`: `{{Legends|canon}}` banners + +``` +[2014-04-25] category_removed (rev 123456→123457) + Section: body + target: Category:Canon characters + +[2014-04-25] category_added (rev 123456→123457) + Section: body + target: Category:Legends characters +``` + +## Cross-wiki comparison + +For the same fictional universe across competing wikis, use `refract diff`: + +```bash +refract diff "Darth_Vader" \ + --wiki-a https://starwars.fandom.com/api.php \ + --wiki-b https://star-wars.fandom.com/api.php \ + --depth detailed +``` + +This produces a side-by-side analysis showing where the two wikis diverge on the same +topic — different backstories, different categorizations, different levels of detail. + +## Troubleshooting + +- **Fandom rate limits**: Fandom APIs may throttle more aggressively than Wikipedia. + Use `-c` to cache and add `--interval 120000` for `watch` mode if you hit limits. +- **Private wikis**: Pass auth credentials for private or institutional MediaWiki + instances: `--api-key <token>` or `--api-user <user> --api-password <pass>`. +- **Custom domain wikis**: Fandom wikis hosted on custom domains still expose the + MediaWiki API at `https://<domain>/api.php`. Find the API endpoint by appending + `?action=query&format=json` to the wiki's base URL. + +## Next steps + +- [Track Wikipedia changes](wikipedia-history.md) +- [Monitor citation churn](citation-churn.md) +- [Build a dispute timeline](dispute-timeline.md) +- [Analyze with DuckDB](../analytics.md) diff --git a/apps/docs/docs/tutorials/mcp-agent.md b/apps/docs/docs/tutorials/mcp-agent.md new file mode 100644 index 0000000..d6c9cbc --- /dev/null +++ b/apps/docs/docs/tutorials/mcp-agent.md @@ -0,0 +1,167 @@ +# Tutorial: MCP server for AI coding agents + +## Goal + +Connect an AI coding agent (Claude Code, VS Code/Cursor, Claude Desktop) to Refract +via its built-in MCP server. The agent calls Refract tools to analyze Wikipedia pages, +track claims, and cite provenance in its reasoning — without you running any commands. + +## What the MCP server gives agents + +`refract mcp` starts a JSON-RPC server over stdio that exposes Refract's engine as +typed tools. Any MCP-compatible agent can: + +- Analyze a page and receive structured events with deterministic hashes +- Track a specific claim across revision history +- Export analysis as structured data +- Re-observe pages for scheduled monitoring +- Classify observation boundaries using a model + +The agent reads events, not documentation. It cites provenance, not opinion. + +## Step 1: Connect your agent + +### Claude Code + +Add to your Claude Code MCP config: + +```json +{ + "mcpServers": { + "refract": { + "command": "npx", + "args": ["-y", "@refract-org/cli", "mcp"] + } + } +} +``` + +### VS Code / Cursor / GitHub Copilot + +```json +{ + "mcpServers": { + "refract": { + "command": "npx", + "args": ["-y", "@refract-org/cli", "mcp"] + } + } +} +``` + +### Claude Desktop + +```json +{ + "mcpServers": { + "refract": { + "command": "npx", + "args": ["-y", "@refract-org/cli", "mcp"] + } + } +} +``` + +The server starts and waits for MCP client connections on stdio. No configuration +beyond pointing at the binary. + +## Step 2: Ask the agent to analyze a page + +Once connected, ask your agent: + +> "Use Refract to analyze the Wikipedia page for ChatGPT. Show me a timeline of when +> citations were added and removed. Which citations have survived the longest?" + +The agent calls `refract analyze` via MCP, receives structured events, and synthesizes: + +> ChatGPT's Wikipedia page has 47 citation events across 20 revisions. The citation to +> the OpenAI blog post (added 2022-11-30) is the oldest surviving source — it has never +> been removed or replaced. In contrast, the citation to the "GPT-4 technical report" +> was replaced 3 times between March and June 2023, each time with a different arXiv +> preprint. The current citation has been stable since June 2023. + +Every event the agent references carries a deterministic `eventId` and `FactProvenance` +— verifiable, not hallucinated. + +## Step 3: Track a specific claim + +> "Using Refract, check the claim 'GPT-4 was trained on 45TB of text data' on the +> ChatGPT page. When was it first added? Has it been removed or rewritten?" + +The agent calls `refract claim` via MCP, gets the claim's revision history: + +> The claim first appeared on March 14, 2024 (revision 1213800001). It was removed +> on March 20, 2024 by an edit that replaced the entire "Training" section. The claim +> was reintroduced on March 22, 2024 with softened language: "reports suggest GPT-4 +> was trained on approximately 45TB of text data." The original unsourced version was +> never restored. + +The agent can trace every mutation — addition, removal, reintroduction, softening — +with exact revision IDs and timestamps. + +## Step 4: Classify a boundary with a model + +The MCP server supports sampling — it can request the host's LLM to interpret events: + +> "Classify the most recent edit to the ChatGPT page using a model. Is it a revert, +> a major content addition, or a minor edit?" + +The agent calls `refract classify heuristic` via MCP with the edit data. If an API key +is configured, the model classifies the boundary. The result includes `source: "model"` +for auditability — the agent knows whether the classification came from a model or the +default heuristic. + +## Step 5: Ask follow-up questions + +The agent can chain multiple calls. Structure the conversation around the data: + +> "Now analyze the talk page. Were there discussions about the training data claim +> before it was removed?" + +The agent calls `refract analyze "Talk:ChatGPT"` and correlates talk page events with +the article timeline. It reports whether the removal was preceded by deliberation +(`talk_page_correlated` events) or was silent. + +## Step 6: Verify the results + +The agent can verify its own output. Ask it: + +> "Run the same analysis again and compare the event hashes. Are the results identical?" + +Because Refract's output is deterministic, the agent can compare `eventId` hashes +across runs and confirm they match — proving the observation is reproducible, not +stochastic. + +## Available MCP tools + +| Tool | What it does | Key parameters | +|---|---|---| +| `analyze` | Analyze a page's full edit history | `page`, `depth`, `api`, `from`, `to`, `since` | +| `claim` | Track a specific sentence across revisions | `page`, `text` | +| `export` | Export page analysis as structured JSON | `page`, `depth`, `api`, `since` | +| `cron` | Re-observe pages for scheduled monitoring | `pagesFile` | +| `classify` | Classify a boundary with a model | `boundary`, `input`, `apiKey`, `model` | + +All tools return typed events with `schemaVersion`, `FactProvenance`, and deterministic +hashes — structured data the agent can reason about, not unstructured text. + +## Validation + +Verify your MCP setup is working: + +```bash +echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | refract mcp | head -1 +``` + +You should receive a JSON-RPC response listing the available tools. For interactive +testing: + +```bash +npx @modelcontextprotocol/inspector npx -y @refract-org/cli mcp +``` + +## Next steps + +- [MCP reference](../mcp.md) — full protocol, tool schemas, all parameters +- [RAG provenance tutorial](rag-provenance.md) — using stability signals in retrieval +- [Downstream integration](../downstream.md) — production patterns for consuming events diff --git a/apps/docs/docs/tutorials/model-evaluation.md b/apps/docs/docs/tutorials/model-evaluation.md new file mode 100644 index 0000000..5ad43a4 --- /dev/null +++ b/apps/docs/docs/tutorials/model-evaluation.md @@ -0,0 +1,264 @@ +# Tutorial: Evaluate AI models with Refract + +## Goal + +Use Refract as deterministic ground truth to evaluate frontier models. Test temporal +leakage, compare retrieval quality, detect provenance hallucination, and measure +knowledge recency — all with reproducible evidence, not heuristic scores. + +**Python users**: skip the CLI examples below and use `refract_eval` directly — +`build_leakage_benchmark()`, `check_provenance()`, `score_retrieval_quality()`. +[→ Python SDK tutorial](python-sdk.md) + +Refract doesn't evaluate models. It provides the ground truth that makes evaluation +possible. Every claim is backed by a revision ID, timestamp, and deterministic +SHA-256 hash. Anyone can reproduce your results. + +## 1. Temporal leakage: who knows things they shouldn't? + +Every model has a training cutoff. Some "know" things past it. Refract proves it. + +### Build a leakage benchmark + +Pick 20 Wikipedia pages with known changes near model cutoffs: + +```bash +cat > leakage-pages.txt << EOF +GPT-4 +Gemini_(language_model) +Claude_(language_model) +OpenAI +Anthropic +Google_DeepMind +EOF +``` + +Analyze each at forensic depth: + +```bash +while read page; do + refract analyze "$page" --depth detailed -c > "events-${page// /_}.jsonl" +done < leakage-pages.txt +``` + +### Extract claims that appeared after a model's cutoff + +For each model, find claims that first appeared after its training cutoff: + +```sql +-- GPT-4o cutoff: June 2024 +SELECT after as claim_text, timestamp, toRevisionId +FROM 'events-GPT-4.jsonl' +WHERE "eventType" = 'sentence_first_seen' + AND timestamp > '2024-06-01' +ORDER BY timestamp; +``` + +A claim that first appeared in August 2024 should not be in GPT-4o's knowledge. If +the model cites it, the model leaked. Refract provides the deterministic proof: +revision ID, timestamp, SHA-256 hash. + +### Compare leakage rates across models + +```sql +-- Count claims per model that appeared after that model's cutoff +SELECT 'GPT-4o (cutoff Jun 2024)' as model, + count(*) as claims_after_cutoff +FROM 'events-GPT-4.jsonl' +WHERE "eventType" = 'sentence_first_seen' + AND timestamp > '2024-06-01' +UNION ALL +SELECT 'Claude (cutoff Apr 2024)', + count(*) +FROM 'events-Claude_(language_model).jsonl' +WHERE "eventType" = 'sentence_first_seen' + AND timestamp > '2024-04-01' +UNION ALL +SELECT 'Gemini (cutoff Nov 2023)', + count(*) +FROM 'events-Gemini_(language_model).jsonl' +WHERE "eventType" = 'sentence_first_seen' + AND timestamp > '2023-11-01'; +``` + +Run the benchmark, test each model against the same claims, publish the leakage +rates per model per month past cutoff. The data is reproducible — include the +Refract version and commit hash in your paper. + +## 2. Retrieval quality: who surfaces contested claims as fact? + +Two RAG systems, same query, same corpus. One retrieves stable claims. The other +retrieves edit-warred sentences. Both show the same confidence. Refract makes the +difference visible. + +### Score claims by stability + +```sql +SELECT + after as claim_text, + count(*) FILTER (WHERE "eventType" = 'revert_detected') as reverts, + count(*) FILTER (WHERE "eventType" LIKE 'citation_%') as citation_churn, + count(*) FILTER (WHERE "eventType" LIKE 'talk_%') as talk_activity, + count(*) FILTER (WHERE "eventType" LIKE 'template_%') as template_disputes, + reverts + citation_churn + template_disputes as contestation_score +FROM 'events.jsonl' +WHERE "eventType" LIKE 'sentence_%' +GROUP BY after +ORDER BY contestation_score DESC; +``` + +Claims with high contestation scores are candidates for closer examination. A RAG +system that retrieves these as fact without hedging is measurably worse. + +### Compare two RAG systems + +1. Run both systems on the same 50 queries +2. For each retrieved passage, look up its stability score from Refract +3. Count how many contested claims each system retrieves +4. Publish the results: "System A retrieves contested claims 3x more often than System B" + +```python +from refract import Refract + +r = Refract() +stability = r.analyze("COVID-19", depth="forensic", as_frame=True) + +# Score each claim by contestation signals +stability["score"] = ( + stability["event_type"].str.startswith("sentence_").astype(int) * + (1.0 - 0.3 * (stability["fact"].str.contains("revert", na=False).astype(int))) +) + +# Map retrieved passages to stability scores +for passage in rag_system_a_results: + match = stability[stability["after"].str.contains(passage[:50], na=False)] + passage.stability = match["score"].mean() if len(match) > 0 else None +``` + +## 3. Provenance hallucination: who makes up sources? + +A model says "According to a 2022 WHO report, ..." Refract checks: was there a +citation to a WHO report on that page in 2022? + +### Check if a source ever existed + +```sql +SELECT timestamp, "eventType", before, after +FROM 'events.jsonl' +WHERE "eventType" IN ('citation_added', 'citation_removed', 'citation_replaced') + AND (after LIKE '%who.int%' OR before LIKE '%who.int%') +ORDER BY timestamp; +``` + +If no results: the model hallucinated the source. If results show `citation_removed`: +the source existed but was removed — the model cited outdated evidence. Either way, +Refract provides the ground truth. + +### Build a provenance hallucination benchmark + +1. Generate 100 claims from a model, each with a cited source +2. Run Refract on the relevant Wikipedia pages +3. For each claimed source, query `citation_*` events +4. Classify each claim: verified (source found, still present), outdated (source + found, now removed), hallucinated (no matching source ever existed) +5. Publish the rates per model + +## 4. Interpretation divergence: same events, different stories + +Feed the same Refract event stream into two models and ask each to summarize. +Same deterministic input — different outputs. The divergence is purely in the model. + +### Generate a controlled comparison + +```bash +refract analyze "Bitcoin" --depth detailed --since 2024-01-01 > bitcoin-events.jsonl +``` + +Feed `bitcoin-events.jsonl` to both models with the same prompt: + +> "Summarize what changed on the Bitcoin Wikipedia page in 2024. Group by theme: +> citations, claims, disputes, structure. Be specific." + +Model A says "3 citations removed, origin claim softened." Model B says "article +updated with new sources, minor structural changes." Same data, different narratives. + +Quantify: for 50 pages, how often do models disagree on the same event stream? +On which event types? With what confidence? The events are deterministic — the +disagreement is purely interpretive. + +## 5. Knowledge recency: who knows what's current? + +A page changed in March. A model trained in January "knows" the January version. +A model trained in June "knows" the March version. Ask both the same question. +Refract shows exactly what the page said at each date. + +### Check what a page said at a model's effective knowledge date + +```bash +refract snapshot "COVID-19" --at 2024-01-15 +refract snapshot "COVID-19" --at 2024-06-15 +``` + +Compare the two snapshots. The difference is what changed between January and June. +Any model trained in January that answers with June information is leaking. Any +model trained in June that answers with January information is stale. + +### Build a recency benchmark + +1. Select 50 pages with known significant changes +2. Run `refract snapshot` at 3 knowledge dates (e.g., Jan 2024, Apr 2024, Jul 2024) +3. Test each model against each date +4. Score accuracy against the deterministic snapshot at that date +5. Publish: "Model X's effective knowledge is ~2 months behind its claimed cutoff. + Model Y's knowledge is accurate to within 1 week of its cutoff." + +## Using the Python eval adapter + +All of the above workflows have a Python equivalent. Install `refract-py` and use +`refract_eval` directly — no CLI needed: + +```python +from refract_eval import build_leakage_benchmark, check_provenance, score_retrieval_quality + +# Temporal leakage +records = build_leakage_benchmark("events.jsonl", cutoff="2024-06-01") +leaked = [r for r in records if r.leaked] +print(f"Leakage rate: {len(leaked)}/{len(records)}") + +# Provenance check +result = check_provenance("events.jsonl", "who.int") +print(f"Verified: {result.verified}, Outdated: {result.outdated}, Hallucinated: {result.hallucinated}") + +# Retrieval quality +scores = score_retrieval_quality("events.jsonl", ["passage 1", "passage 2"]) +for s in scores: + print(f"{s['passage'][:50]}... → stability: {s['score']}") +``` + +The adapter source is in `refract-py/src/refract_eval.py`. See the [Python SDK +tutorial](python-sdk.md) for the full notebook workflow. + +## Reproducibility + +Every finding includes: + +```bash +refract --version +# refract 0.5.3 + +git log -1 --format="%H" -- packages/evidence-graph packages/analyzers +# a1b2c3d4e5f6... + +refract analyze "Page" --depth forensic > events.jsonl +# SHA-256 of events.jsonl: e7f8a9b0c1d2... +``` + +A reviewer runs the same commands on the same revisions, gets the same hashes. + +## Next steps + +- [Combat revisionism tutorial](combating-revisionism.md) — deterministic audit trail +- [RAG provenance tutorial](rag-provenance.md) — stability-weighted retrieval +- [BYO-inference tutorial](byo-inference.md) — model-augmented classification +- [MCP agent tutorial](mcp-agent.md) — connect AI agents to Refract +- [Summarization tutorial](summarization.md) — model-generated event summaries diff --git a/apps/docs/docs/tutorials/non-english.md b/apps/docs/docs/tutorials/non-english.md new file mode 100644 index 0000000..9f91aea --- /dev/null +++ b/apps/docs/docs/tutorials/non-english.md @@ -0,0 +1,132 @@ +# Tutorial: Non-English Wikipedia analysis + +## Goal + +Use Refract to analyze pages on any language edition of Wikipedia. Refract works with +any MediaWiki instance — English is just the default. This tutorial uses German +Wikipedia (de.wikipedia.org) as an example. + +## Why non-English wikis matter + +Different language editions tell different versions of the same story. A claim that +appears in English Wikipedia may be absent, contested, or framed differently in German, +French, or Japanese. Refract's `--api` flag lets you run the same deterministic analysis +on any language edition and compare results. + +## Step 1: Analyze a page on German Wikipedia + +```bash +refract analyze "COVID-19" --api https://de.wikipedia.org/w/api.php --depth detailed +``` + +Output is identical in structure to English Wikipedia — same 26 event types, same +`EvidenceEvent` schema, same deterministic guarantees. Only the page content differs: + +``` +Analysis Results + Page: COVID-19 + Events: 247 + +[2020-03-15T12:00:00Z] sentence_first_seen (rev 123456789→123456790) + Section: Symptome + • COVID-19 kann zu Symptomen wie Fieber, Husten und Atemnot führen. +``` + +## Step 2: Compare across editions + +Export both language editions and compare: + +```bash +refract analyze "COVID-19" \ + --api https://en.wikipedia.org/w/api.php \ + --depth detailed -c > covid-en.jsonl + +refract analyze "COVID-19" \ + --api https://de.wikipedia.org/w/api.php \ + --depth detailed -c > covid-de.jsonl +``` + +Query for differences with DuckDB: + +```sql +SELECT 'en' AS wiki, count(*) AS events FROM 'covid-en.jsonl' +UNION ALL +SELECT 'de', count(*) FROM 'covid-de.jsonl'; +``` + +Or use `refract diff` for structured cross-wiki comparison: + +```bash +refract diff "COVID-19" \ + --wiki-a https://en.wikipedia.org/w/api.php \ + --wiki-b https://de.wikipedia.org/w/api.php \ + --depth detailed +``` + +`refract diff` compares the same topic across wikis and surfaces statistical outliers +via z-score detection. + +## Step 3: Track a claim across language editions + +```bash +# English +refract claim "COVID-19" --text "originated in Wuhan" \ + --api https://en.wikipedia.org/w/api.php + +# German +refract claim "COVID-19" --text "erstmals in Wuhan" \ + --api https://de.wikipedia.org/w/api.php +``` + +Compare when each edition first added the claim, whether it was removed, and whether +talk page discussion accompanied the change. + +## Step 4: Forensic depth on non-English pages + +All depth levels work identically: + +```bash +refract analyze "COVID-19" \ + --api https://de.wikipedia.org/w/api.php \ + --depth forensic -c +``` + +Forensic depth enables talk page correlation, edit cluster detection, and sentence +modification tracking — the same structural analysis, regardless of language. + +## Supported language editions + +Refract has been tested against: + +| Language | API endpoint | Example page | +|---|---|---| +| English | `https://en.wikipedia.org/w/api.php` | `Earth` | +| German | `https://de.wikipedia.org/w/api.php` | `Erde` | +| French | `https://fr.wikipedia.org/w/api.php` | `Terre` | +| Japanese | `https://ja.wikipedia.org/w/api.php` | `地球` | + +Any MediaWiki instance that exposes an `api.php` endpoint is supported. This includes: + +- All 300+ Wikipedia language editions +- Fandom wikis (e.g., `https://starwars.fandom.com/api.php`) +- Independent MediaWiki installations +- Private/authenticated wikis (see [private wiki tutorial](private-wiki.md)) + +## Caching across wikis + +The cache stores revisions per API endpoint, so analyzing the same page on different +wikis doesn't interfere: + +```bash +refract analyze "Earth" --api https://en.wikipedia.org/w/api.php -c +refract analyze "Earth" --api https://de.wikipedia.org/w/api.php -c +``` + +Each wiki's data is stored separately. Use `--cache-dir` to change the cache location. + +## Next steps + +- [Cross-wiki comparison tutorial](cross-wiki-diff.md) — structured multi-wiki diff +- [Private wiki tutorial](private-wiki.md) — authenticated MediaWiki instances +- [Combat revisionism tutorial](combating-revisionism.md) — reproducible event records +- [Comparison page](../compare.md) — Refract vs other tools diff --git a/apps/docs/docs/tutorials/private-wiki.md b/apps/docs/docs/tutorials/private-wiki.md new file mode 100644 index 0000000..49e7022 --- /dev/null +++ b/apps/docs/docs/tutorials/private-wiki.md @@ -0,0 +1,156 @@ +# Tutorial: Connect to private and authenticated wikis + +## Goal + +Use Refract to analyze pages on private MediaWiki instances — internal wikis, corporate +knowledge bases, or restricted-access wikis that require authentication. + +## Step 1: Identify the API endpoint + +Every MediaWiki instance exposes an `api.php` endpoint. For private wikis, this is +typically behind authentication: + +```bash +# Public wiki (no auth) +refract analyze "Page" --api https://en.wikipedia.org/w/api.php + +# Private wiki (requires auth) +refract analyze "Page" --api https://internal.company.com/w/api.php +``` + +The API URL must end in `/api.php`. If your wiki uses a different path (e.g., +`/w/api.php` for Wikimedia-hosted wikis), adjust accordingly. + +## Step 2: Authenticate with a bearer token + +Many private MediaWiki instances use OAuth2 or personal access tokens: + +```bash +refract analyze "Internal_Page" \ + --api https://wiki.internal.example.com/w/api.php \ + --api-key "your-bearer-token-here" +``` + +Refract sends the token as an `Authorization: Bearer <token>` header. If your wiki +expects a different header format, use the environment variable approach and a +custom header injection. + +Environment variable alternative (avoids exposing the token in shell history): + +```bash +export REFRACT_API_KEY="your-bearer-token-here" +refract analyze "Internal_Page" --api https://wiki.internal.example.com/w/api.php +``` + +## Step 3: Authenticate with basic auth + +Some wikis use HTTP basic authentication: + +```bash +refract analyze "Restricted_Page" \ + --api https://restricted.wiki.example.com/api.php \ + --api-user "username" \ + --api-password "password" +``` + +Refract sends `Authorization: Basic <base64>` with the credentials. Use environment +variables for production: + +```bash +export REFRACT_API_USER="username" +export REFRACT_API_PASSWORD="password" +refract analyze "Restricted_Page" \ + --api https://restricted.wiki.example.com/api.php +``` + +## Step 4: OAuth2 authentication + +For wikis that use OAuth2 (common with enterprise MediaWiki deployments): + +```bash +refract analyze "Enterprise_Page" \ + --api https://enterprise.wiki.example.com/api.php \ + --api-key "oauth2-access-token" +``` + +The OAuth2 flow itself (obtaining the token) is outside Refract's scope. Once you +have an access token, pass it as `--api-key`. Refract sends it as a bearer token. + +## Step 5: Verify the connection + +Test with a lightweight analysis: + +```bash +refract analyze "Main_Page" --depth brief \ + --api https://private.wiki.example.com/api.php \ + --api-key "$REFRACT_API_KEY" +``` + +`brief` depth runs only the section differ — minimal API calls, fast feedback. +If you get events, the connection works. If you get an authentication error, check +the token scope and format. + +## Step 6: Handle authentication errors + +| Error | Likely cause | Fix | +|---|---|---| +| `401 Unauthorized` | Missing or invalid token | Check `--api-key` is correct and not expired | +| `403 Forbidden` | Token lacks read permission | Verify token scope with wiki admin | +| Connection refused | Wrong API URL | Ensure URL ends in `/api.php` and the wiki is reachable | +| `maxlag` errors | Database replicas lagging behind the `maxlag` you set on `MediaWikiClient` | Retried after the wiki's `Retry-After` (`@refract-org/ingestion` 0.3.2+); if they persist, wait | + +## Step 7: Run regular analysis with auth + +Once authenticated, all Refract commands work identically — just pass the auth flags: + +```bash +# Full forensic analysis on a private wiki +refract analyze "Sensitive_Page" --depth forensic \ + --api https://private.wiki.example.com/api.php \ + --api-key "$REFRACT_API_KEY" \ + -c + +# Track a specific claim +refract claim "Sensitive_Page" --text "specific claim text" \ + --api https://private.wiki.example.com/api.php \ + --api-key "$REFRACT_API_KEY" + +# Export as NDJSON +refract export "Sensitive_Page" --format ndjson \ + --api https://private.wiki.example.com/api.php \ + --api-key "$REFRACT_API_KEY" +``` + +## Step 8: Cron monitoring with auth + +For scheduled monitoring of private wikis, include auth in the cron command: + +```bash +# crontab entry +0 */6 * * * refract cron /path/to/pages.txt --interval 6 \ + --api https://private.wiki.example.com/api.php \ + --api-key "$REFRACT_API_KEY" -c +``` + +Or with GitHub Actions: + +```yaml +- run: refract cron pages.txt --interval 6 -c + env: + REFRACT_API_KEY: ${{ secrets.WIKI_API_KEY }} +``` + +## Security considerations + +- Tokens in CLI flags are visible in `ps` / process listings. Use environment + variables (`REFRACT_API_KEY`) for production. +- Cached revisions (SQLite in `~/.wikihistory/refract.db`) contain full wikitext + content from the private wiki. On shared machines, set `--cache-dir` to an + encrypted volume. +- Refract makes outbound HTTPS requests only. All traffic is encrypted in transit. + +## Next steps + +- [CLI reference](../cli.md) — all global auth flags +- [Scheduled monitoring tutorial](scheduled-monitoring.md) — cron and watch setup +- [Security reference](../security.md) — credential handling and data retention diff --git a/apps/docs/docs/tutorials/python-sdk.md b/apps/docs/docs/tutorials/python-sdk.md new file mode 100644 index 0000000..a156da9 --- /dev/null +++ b/apps/docs/docs/tutorials/python-sdk.md @@ -0,0 +1,177 @@ +# Tutorial: Python SDK notebook workflow + +## Goal + +Use `refract-py` to analyze Wikipedia pages, load results into pandas DataFrames, +plot citation churn and claim stability, and export findings — all from a Jupyter +notebook or Python script. + +## Prerequisites + +```bash +pip install git+https://github.com/refract-org/refract-py.git pandas matplotlib # not on PyPI +``` + +The Python SDK wraps the Refract CLI via subprocess. Install the CLI: + +```bash +npm install -g @refract-org/cli +``` + +Or use `npx` — the SDK falls back to it automatically. + +## Step 1: Analyze a page and get typed objects + +The SDK converts NDJSON `eventType` (camelCase) to pandas `event_type` (snake_case) for Python conventions. DataFrames use `event_type`, Python dataclasses use `eventType`. + +```python +from refract import Refract + +r = Refract() + +# Analyze at forensic depth for maximum signal +events = r.analyze("COVID-19", depth="forensic") + +print(f"Found {len(events)} events") +print(f"First event: {events[0].eventType} at {events[0].timestamp}") +``` + +Each event is a typed `EvidenceEvent` dataclass with `.eventType`, `.timestamp`, +`.section`, `.before`, `.after`, `.fromRevisionId`, `.toRevisionId`, and +`.deterministicFacts`. No manual JSON parsing needed. + +## Step 2: Load into pandas as a DataFrame + +```python +# Direct DataFrame export with flattened fields +df = r.analyze("COVID-19", depth="forensic", as_frame=True) + +print(df.columns) +# Index(['timestamp', 'event_type', 'from_revision_id', 'to_revision_id', +# 'section', 'event_id', 'schema_version', 'layer', 'fact', +# 'fact_detail', 'analyzer_name', 'analyzer_version'], dtype='object') + +print(df.head()) +``` + +The `as_frame=True` flag returns a pandas DataFrame with nested fields (deterministic +facts, provenance) flattened into columns. No manual transformation needed. + +## Step 3: Analyze event type distribution + +```python +# Count events by type +event_counts = df["event_type"].value_counts() +print(event_counts) + +# Plot +import matplotlib.pyplot as plt + +event_counts.head(10).plot(kind="barh", figsize=(10, 6)) +plt.title("Top 10 Event Types — COVID-19 Wikipedia Page") +plt.xlabel("Event Count") +plt.tight_layout() +plt.show() +``` + +## Step 4: Plot citation churn over time + +```python +# Filter to citation events +citations = df[df["event_type"].str.startswith("citation_")].copy() +citations["date"] = pd.to_datetime(citations["timestamp"]).dt.date + +# Count by date and type +churn = citations.groupby(["date", "event_type"]).size().unstack(fill_value=0) + +# Plot +churn.plot(kind="area", figsize=(14, 6), alpha=0.7, stacked=True) +plt.title("Citation Churn — COVID-19 Wikipedia Page") +plt.xlabel("Date") +plt.ylabel("Events per Day") +plt.legend(title="Event Type") +plt.tight_layout() +plt.show() +``` + +An area chart shows the rhythm of citation activity — when sources were being actively +added (expansion) vs removed (contraction) vs replaced (re-evaluation). + +## Step 5: Identify the most contested sections + +```python +# Count events per section +section_activity = df.groupby("section").agg( + total_events=("event_type", "count"), + reverts=("event_type", lambda x: (x == "revert_detected").sum()), + citations=("event_type", lambda x: (x.str.startswith("citation_")).sum()), + talk=("event_type", lambda x: (x.str.startswith("talk_")).sum()), +).sort_values("total_events", ascending=False) + +print(section_activity.head(10)) +``` + +Sections with high revert counts and low talk activity are edit-warred. Sections with +high revert counts and high talk activity are actively deliberated. The DataFrame +makes this distinction visible at a glance. + +## Step 6: Export for further analysis + +```python +# Export as flat CSV +df_flat = r.export("COVID-19", format="ndjson", flatten=True, as_frame=True) +df_flat.to_csv("covid-events.csv", index=False) + +# Or export as raw NDJSON for DuckDB +events_raw = r.export("COVID-19", format="ndjson") +with open("covid-events.jsonl", "w") as f: + for event in events_raw: + f.write(json.dumps(event) + "\n") +``` + +## Step 7: Error handling + +```python +from refract import Refract, RefractError + +r = Refract() + +try: + events = r.analyze("ThisPageDoesNotExist", depth="brief") +except RefractError as e: + print(f"Refract error: {e}") + # Handle gracefully — the page may have been deleted, the API may be down, + # or the CLI may not be installed +``` + +## Step 8: Use the model evaluation adapter + +`refract_eval` maps Refract events to model evaluation records — no CLI needed: + +```python +from refract_eval import build_leakage_benchmark, check_provenance + +# Export events first, then use the adapter +r.export("COVID-19", format="ndjson", flatten=False) +# (events saved to stdout — pipe to file, then load with adapter) + +# Or use pre-computed events: +records = build_leakage_benchmark("covid-events.jsonl", cutoff="2024-06-01") +leaked = [r for r in records if r.leaked] +print(f"Leakage rate: {len(leaked)}/{len(records)}") + +# Check if a source ever existed on the page +result = check_provenance("covid-events.jsonl", "who.int") +print(f"Verified: {result.verified}, Outdated: {result.outdated}") +``` + +See the [model evaluation tutorial](model-evaluation.md) for full benchmark workflows and +the [benchmark specification](https://github.com/refract-org/refract/blob/main/BENCHMARK.md) +for standard pages and submission format. + +## Next steps + +- [RAG provenance tutorial](rag-provenance.md) — using stability signals in retrieval +- [Analytics with DuckDB](../analytics.md) — SQL queries on Refract NDJSON output +- [Notebook analysis](../notebooks.md) — DuckDB, Observable, and Marimo workflows +- [SDK reference](../sdk.md) — all packages and their APIs diff --git a/apps/docs/docs/tutorials/rag-provenance.md b/apps/docs/docs/tutorials/rag-provenance.md new file mode 100644 index 0000000..05b57d9 --- /dev/null +++ b/apps/docs/docs/tutorials/rag-provenance.md @@ -0,0 +1,171 @@ +# Tutorial: AI provenance for RAG pipelines + +## Goal + +Score each document in your retrieval corpus by its Wikipedia claim stability — revert +count, citation churn, talk page activity, template disputes — and use those signals +to filter or weight retrieval results. RAG systems treat every sentence as equally +stable. Refract makes instability visible. + +## Why provenance matters for RAG + +A RAG pipeline retrieves text and presents it as fact. If the retrieved text comes from +a Wikipedia sentence that has been reverted 5 times, lost its citations, and generated +a talk page dispute, the pipeline is surfacing contested content without knowing it. + +Refract attaches stability metadata to every sentence. Your RAG system can: +- Filter out contested claims before they reach the model +- Weight retrieval results by source stability +- Surface provenance alongside generated text ("this claim has been stable since 2019") + +## Step 1: Export claim stability signals + +Run forensic-depth analysis on the pages your RAG pipeline sources from: + +```bash +refract analyze "COVID-19" --depth forensic -c > covid-events.jsonl +``` + +Forensic depth enables talk page correlation, edit cluster detection, and sentence +modification tracking — the signals that distinguish stable from contested claims. + +## Step 2: Score claims by stability + +Query the event stream for stability signals per claim: + +```sql +SELECT + claim_id, + after as claim_text, + min(timestamp) as first_seen, + max(timestamp) as last_seen, + count(*) FILTER (WHERE "eventType" = 'revert_detected') as revert_count, + count(*) FILTER (WHERE "eventType" LIKE 'citation_%') as citation_churn, + count(*) FILTER (WHERE "eventType" LIKE 'talk_%') as talk_activity, + count(*) FILTER (WHERE "eventType" LIKE 'template_%') as template_disputes, + count(*) FILTER (WHERE "eventType" = 'edit_cluster_detected') as edit_clusters +FROM 'covid-events.jsonl' +WHERE "eventType" LIKE 'sentence_%' + AND claim_id IS NOT NULL +GROUP BY claim_id, after +ORDER BY revert_count DESC, citation_churn DESC; +``` + +Interpretation: + +| Signal | Indicates | +|---|---| +| `revert_count > 0` | The claim was undone — contested by another editor | +| `citation_churn > 3` | Sources added/removed/replaced — evidentiary foundation unstable | +| `talk_activity > 0` | The claim was discussed on the talk page — deliberated, not just edited | +| `template_disputes > 0` | The section was tagged with policy templates (NPOV, citation needed, etc.) | +| `edit_clusters > 0` | The claim was in a rapid-edit cluster — possible edit war | + +High revert + high citation churn + low talk activity = contested without deliberation. +High revert + high talk activity = actively deliberated. Knowing which is which matters +for retrieval. + +## Step 3: Filter your training data + +Use the stability scores to filter: + +```python +import json +import duckdb + +con = duckdb.connect() + +# Load Refract output +events = con.execute(""" + SELECT * FROM 'covid-events.jsonl' +""").fetchdf() + +# Score claims +stability = con.execute(""" + SELECT + claim_id, + after as claim_text, + max(CASE WHEN "eventType" = 'revert_detected' THEN 1 ELSE 0 END) as was_reverted, + count(*) FILTER (WHERE "eventType" LIKE 'citation_%') as citation_churn + FROM events + WHERE claim_id IS NOT NULL + GROUP BY claim_id, after +""").fetchdf() + +# Filter to stable, well-sourced claims +stable_claims = stability[ + (stability["was_reverted"] == 0) & + (stability["citation_churn"] <= 2) +] + +print(f"Total claims: {len(stability)}") +print(f"Stable claims: {len(stable_claims)}") +print(f"Filtered out: {len(stability) - len(stable_claims)} contested claims") +``` + +The filtered set contains only claims that have never been reverted and have minimal +citation churn. Feed these into your RAG corpus and the model sees stable content. + +## Step 4: Weight retrieval by provenance + +Instead of binary filtering, use stability as a retrieval weight: + +```python +# Score each claim 0.0–1.0 by stability +stability["stability_score"] = 1.0 - ( + (stability["was_reverted"] * 0.4) + + (min(stability["citation_churn"] / 10, 1.0) * 0.3) + + # ... additional signals +) + +# Boost stable claims, deprecate contested ones +stable_claims = stability.sort_values("stability_score", ascending=False) +``` + +Use the stability score as a reranking feature in your RAG pipeline — claims with higher +stability scores surface above contested ones, even if the embedding similarity is identical. + +## Step 5: Surface provenance to the user + +When your RAG system generates a response, include the provenance: + +```python +def format_provenance(claim_row): + if claim_row["was_reverted"]: + return f"⚠️ Contested — reverted {claim_row['revert_count']} times" + if claim_row["citation_churn"] > 3: + return f"⚠️ Source-unstable — {claim_row['citation_churn']} citation changes" + if claim_row["first_seen"]: + return f"✅ Stable since {claim_row['first_seen'][:10]}" + return "Unknown stability" +``` + +The generated response becomes: "COVID-19 was first identified in December 2019 +(Source: Wikipedia, stable since 2020-03-15, 3 citations)." The user knows what +they're reading and how much to trust it. + +## Step 6: LangChain document loader + +refract-py includes a LangChain document loader that wraps this workflow: + +```python +from refract_langchain import RefractLoader + +loader = RefractLoader( + page="COVID-19", + depth="forensic", + min_stability=0.7, +) +documents = loader.load() +``` + +Each `Document` has `page_content` (the claim text) and `metadata` (stability score, +revert count, citation churn, first seen, last seen). Integrate directly into a +LangChain retrieval chain. + +## Next steps + +- [Python SDK tutorial](python-sdk.md) — pandas integration and Jupyter workflows +- [Downstream integration](../downstream.md) — production DDL and repository pattern +- [Notebook analysis](../notebooks.md) — DuckDB and Observable workflows +- [Schema reference](../schema.md) — the full evidence event structure diff --git a/apps/docs/docs/tutorials/refract-ui.md b/apps/docs/docs/tutorials/refract-ui.md new file mode 100644 index 0000000..6ec11e1 --- /dev/null +++ b/apps/docs/docs/tutorials/refract-ui.md @@ -0,0 +1,116 @@ +# Tutorial: Explore your analysis in Refract UI + +## Goal + +Take Refract's CLI output and explore it visually — timelines, diffs, citation graphs, and +event breakdowns — using the standalone Refract UI. + +## Prerequisites + +- [Node.js](https://nodejs.org) 20+ or [Bun](https://bun.sh) v1.2+ +- [Refract CLI](https://www.npmjs.com/package/@refract-org/cli) (`npx @refract-org/cli` works) +- Clone of [refract-ui](https://github.com/refract-org/refract-ui) + +Refract UI is a zero-dependency web app (vanilla TS + Vite). It runs entirely in your +browser — no server, no API key, no data leaves your machine. + +## Steps + +### 1. Generate analysis data + +Run a Refract analysis and export it as NDJSON: + +```bash +refract analyze "Bitcoin" --depth forensic > bitcoin-events.jsonl +``` + +Use `--depth forensic` to get the full 26 event types (citation changes, reverts, edit +clusters, talk page activity, category shifts — everything the visualizer can display). + +### 2. Start Refract UI + +In a second terminal: + +```bash +cd refract-ui +bun install +bun run dev +``` + +Open `http://localhost:5173`. Sample data loads on first visit — you will see a populated +timeline immediately. + +### 3. Load your data + +Drag `bitcoin-events.jsonl` from your file manager onto the upload zone in the browser +toolbar. Refract UI parses the file, validates event types against the +`@refract-org/evidence-graph` schema, and populates every panel. + +## What you see + +### Timeline (top-left) + +Every event plotted on a horizontal axis by revision timestamp. Hover for event type and +section. Click any event to populate the diff panel. Filter by event type using the +dropdown in the toolbar. + +### Diff (top-right, middle-right) + +When you click an event, `before` and `after` snapshots appear side by side. For +`sentence_modified` events, the changed text is highlighted. + +### Citation churn (top-center-right) + +Bar chart of citation activity over time — additions in green, removals in red. Watch for +spikes that correlate with revert clusters — a page edit war where sources are being +added and stripped in rapid succession. + +### Certainty timeline (top-right) + +When you export events with `--depth forensic` on Refract v0.5.0+, each event carries +enrichment fields (`certaintyProfile`, `directionSignal`, `editMagnitude`). The certainty +timeline plots how confident the language was over time. Drops in certainty often precede +reverts or disputes. + +### Row 2 — Disputes, Sources, Word-level diffs, Schema + +| Panel | What it shows | +|-------|--------------| +| **Revert clusters** | Groups of edits reverted together, with timestamps and revision IDs | +| **Edit velocity** | Edits-per-day bar chart — spikes indicate concentrated editing activity | +| **Talk page timeline** | Thread activity over time, correlated against main-page event density | +| **Source change table** | Every citation added, removed, or replaced, with the URL and revision | +| **Wording diff card** | When a sentence was modified in-place, see the exact text change | +| **Schema viewer** | Raw event count by type — quick glance at which analyzers fired most | + +## Interpretation example + +Say you run `refract diff "Bitcoin" --wiki-a ... --wiki-b ...` and load the output into +Refract UI: + +1. **Timeline** shows the two wikis' events overlaid — one color per wiki. +2. **Citation churn** reveals Wiki B has 3× more citation turnover. +3. **Revert clusters** in Wiki A's timeline align with edit velocity spikes — edit-warring + without resolution. +4. **Certainty timeline** drops sharply on Wiki B after a controversial edit, then slowly + recovers as more sources appear. + +Without the visualizer, you would spot these patterns by reading raw JSON. With it, the +relationships between event types become visible at a glance. + +## Troubleshooting + +- **"No events loaded"**: Make sure your file uses NDJSON format (`--format ndjson`). + Refract UI expects one JSON object per line with `eventType` and `timestamp` fields. +- **Empty panels**: Some panels require specific event types. Citation churn needs + `citation_added` or `citation_removed` events. Run `--depth forensic` to get the full + set. +- **File size**: Very large analyses (1000+ revisions) may load slowly. Use `--depth + detailed` instead and export a narrower revision range with `--from` and `--to`. + +## Next steps + +- [Complete end-to-end workflow](../complete-workflow.md) — analyze, export, query, visualize +- [Track Wikipedia changes](wikipedia-history.md) — build a claim timeline +- [Analytics with DuckDB](../analytics.md) — query the raw data +- [Refract UI source](https://github.com/refract-org/refract-ui) — run it locally diff --git a/apps/docs/docs/tutorials/scheduled-monitoring.md b/apps/docs/docs/tutorials/scheduled-monitoring.md new file mode 100644 index 0000000..82dbd1b --- /dev/null +++ b/apps/docs/docs/tutorials/scheduled-monitoring.md @@ -0,0 +1,168 @@ +# Tutorial: Scheduled monitoring with cron, watch, and notifications + +## Goal + +Set up Refract to re-observe Wikipedia pages on a schedule, detect when claims change, +and notify you via Slack, email, or webhook. Catch citation removal, template disputes, +and section reorganization as they happen — not when someone notices. + +## Two monitoring modes + +| Mode | How it works | Use when | +|---|---|---| +| `refract cron` | One-shot re-observation for cron scheduling | Batch monitoring of many pages on a fixed schedule | +| `refract watch` | Polls one page and prints its new revisions until stopped | Following a specific page as it changes | + +## Step 1: Create your pages file + +List the pages you want to monitor, one per line: + +```bash +cat > watch-pages.txt << EOF +COVID-19 +SARS-CoV-2 +COVID-19_pandemic +COVID-19_vaccine +mRNA_vaccine +EOF +``` + +Pages can be any valid MediaWiki page title. Use underscores for spaces. + +## Step 2: Run a one-shot re-observation + +```bash +refract cron watch-pages.txt --interval 24 --cache-dir ~/.refract-watch +``` + +This re-observes every page in the file over the last 24 hours and prints, per page, +how many events are new since the previous run. The first run for a page records a +baseline and reports none. `--cache-dir` is where `cron` keeps each page's previous +observation; without it, `~/.wikihistory`. The [cron reference](../cron.md) describes +the comparison, the output and the exit code. + +## Step 3: Schedule it + +Add to your crontab, with the interval matching the schedule: + +```bash +# Every 6 hours +0 */6 * * * refract cron /path/to/watch-pages.txt --interval 6 --cache-dir /path/to/state +``` + +On GitHub Actions the runner starts empty, so the state directory has to be carried +between runs — cached or committed — or every run is a first run and reports nothing. +The [cron reference](../cron.md#scheduling-with-github-actions) has a complete +workflow that does this. + +Refract spaces its requests to the MediaWiki API (100 ms apart by default) and retries +a 429 or 503 after the server's `Retry-After`. + +## Step 4: Set up notifications + +Notifications go out only when a page has new or resolved events. + +### Slack + +```bash +export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/..." + +refract cron watch-pages.txt --interval 24 --notify-slack +``` + +The message lists each changed page with its counts: + +``` +Refract observation report +2 page(s) changed since last observation. + +COVID-19 +3 new event(s), 0 resolved +Changes detected + +mRNA_vaccine +5 new event(s), 1 resolved +Changes detected +``` + +### Email + +```bash +export SMTP_TO="researcher@example.com" + +refract cron watch-pages.txt --interval 24 --notify-email +``` + +Mail goes through the local `sendmail` (`/usr/sbin/sendmail`, or the path in +`SMTP_SENDMAIL`). There is no SMTP host, user or password setting. + +### Webhook + +```bash +refract cron watch-pages.txt --interval 24 --notify-webhook https://your-server.com/hooks/refract +``` + +Refract POSTs a JSON summary — per page, the new and resolved counts. The payload is +in the [cron reference](../cron.md#webhook). + +## Step 5: Live polling with watch + +For one page, as it changes: + +```bash +refract watch "COVID-19" --interval 60000 +``` + +Polls every 60 seconds (`--interval` is in milliseconds) and prints each new revision +with the events it produced, one `- <eventType> <detail>` line per event. +`--section "Vaccine safety"` limits section events to that section. The output is text +for people; a pipe can still pick lines out of it: + +```bash +refract watch "COVID-19" | while read -r line; do + case "$line" in + *citation_removed*) + echo "Citation removed from COVID-19: $line" | mail -s "Refract alert" researcher@example.com ;; + esac +done +``` + +`watch` polls until stopped. + +## Step 6: Get the events themselves + +`cron` reports counts. For the events, run `analyze` over the same window with +`--json`, which prints one JSON event per line: + +```bash +SINCE=$(date -u -d '25 hours ago' +%Y-%m-%dT%H:%M:%SZ) + +# Citation removals — often the first sign a claim is about to change +refract analyze "COVID-19" --since "$SINCE" --json | jq -c 'select(.eventType == "citation_removed")' + +# Template changes — dispute, neutrality and citation-needed tags +refract analyze "COVID-19" --since "$SINCE" --json | jq -c 'select(.eventType | startswith("template_"))' +``` + +The window opens an hour before the day it covers because the first revision inside a +`--since` window is not diffed against the one before it. + +## Step 7: Integrate with your own alerts + +```bash +while read -r page; do + refract analyze "$page" --since "$SINCE" --json | while read -r event; do + if [ "$(echo "$event" | jq -r '.eventType')" = "citation_removed" ]; then + echo "ALERT: citation removed from $page / $(echo "$event" | jq -r '.section')" + # Trigger your alerting pipeline + fi + done +done < watch-pages.txt +``` + +## Next steps + +- [CLI cron reference](../cron.md) — all flags and environment variables +- [Citation churn tutorial](citation-churn.md) — interpreting citation patterns +- [Dispute timeline tutorial](dispute-timeline.md) — detecting edit wars +- [Downstream integration](../downstream.md) — production patterns for consuming events diff --git a/apps/docs/docs/tutorials/summarization.md b/apps/docs/docs/tutorials/summarization.md new file mode 100644 index 0000000..b05d3ac --- /dev/null +++ b/apps/docs/docs/tutorials/summarization.md @@ -0,0 +1,103 @@ +# Tutorial: BYO-inference summarization + +## Goal + +Pipe Refract's deterministic event stream through an LLM to produce a human-readable +summary of what changed on a Wikipedia page. The engine stays deterministic. The +model provides the narrative. Each one is auditable independently. + +## Why summarize with a model + +Refract produces 26 event types — raw, structured, queryable. Non-technical users +don't want an event stream. They want: "This week, 3 citations were removed from the +lead section, the origin claims were softened, and a dispute template was added." + +The model doesn't change Refract's output. It reads it and summarizes. The +`FactProvenance.parameters` field records which path was taken. + +## Step 1: Export the event stream + +```bash +refract analyze "COVID-19" --since 2026-01-01 --depth forensic > covid-events.jsonl +``` + +## Step 2: Summarize with a model + +```bash +# Pipe to any OpenAI-compatible endpoint +cat covid-events.jsonl | head -100 | \ + jq -s '{events: .}' | \ + curl -s https://api.openai.com/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $OPENAI_API_KEY" \ + -d '{ + "model": "gpt-4o-mini", + "messages": [{ + "role": "system", + "content": "Summarize these Wikipedia revision events in 3-5 bullet points. Group by theme: citations, claims, disputes, structure. Mention which sections were affected. Be specific about what changed." + }, { + "role": "user", + "content": "." + }], + "temperature": 0 + }' | jq -r '.choices[0].message.content' +``` + +Output: + +``` +• Citations: 2 sources removed from "Origins" section (WHO report dated 2023-01-15, + Lancet study dated 2022-06-03). 1 new citation added to "Transmission" section + (CDC update dated 2026-01-10). +• Claims: Origin language softened — "The virus originated in Wuhan" changed to + "The earliest known cases were identified in Wuhan." 4 sentence modifications + across "Origins" and "Timeline" sections. +• Disputes: NPOV template added to "Origins" section on 2026-01-12. 2 reverts + detected in same section within 24 hours. +• Structure: "Variants" section reorganized — 2 new subsections added. +• Talk page: 1 new thread opened on 2026-01-13 discussing origin language. + 8 replies across 3 threads. +``` + +## Step 3: Use the MCP server for agent-driven summarization + +```bash +refract mcp +``` + +Connect any MCP client and ask: + +> "Analyze the COVID-19 Wikipedia page since January 2026. Give me a summary of +> what changed: citations, claims, disputes, structure. Be specific." + +The agent calls `refract analyze` via MCP, receives structured events, and +synthesizes a summary. Every claim in the summary is backed by a deterministic +event with a hash — the agent can cite provenance. + +## Step 4: Audit which path was taken + +Every event carries `FactProvenance.parameters` recording whether the default +heuristic or a model was used. When a model produces a summary, the original +events are unchanged — the model consumed the stream, it didn't modify it. + +To verify: compare the `eventId` hashes before and after summarization. They +match. The engine is deterministic. The narrative is interpretive. Each is +auditable independently. + +## Available models + +Any OpenAI-compatible endpoint works: + +| Provider | Endpoint | Model | +|---|---|---| +| OpenAI | `https://api.openai.com/v1/chat/completions` | `gpt-4o-mini` | +| DeepSeek | `https://api.deepseek.com/v1/chat/completions` | `deepseek-chat` | +| Ollama (local) | `http://localhost:11434/v1/chat/completions` | `llama3` | +| Anthropic (proxy) | Your proxy endpoint | `claude-3-haiku` | +| Together | `https://api.together.xyz/v1/chat/completions` | `mistral` | + +## Next steps + +- [BYO-inference tutorial](byo-inference.md) — replacing heuristics with models at each boundary +- [MCP agent tutorial](mcp-agent.md) — connect AI coding agents to Refract +- [RAG provenance tutorial](rag-provenance.md) — stability signals for retrieval diff --git a/apps/docs/docs/tutorials/wikipedia-history.md b/apps/docs/docs/tutorials/wikipedia-history.md new file mode 100644 index 0000000..2b83495 --- /dev/null +++ b/apps/docs/docs/tutorials/wikipedia-history.md @@ -0,0 +1,150 @@ +# Track changes to a Wikipedia article over time + +## Goal + +Use Refract to analyze the full revision history of a Wikipedia page and understand what +changed, when, and what kind of change it was. + +The analysis is deterministic, and its output is reproducible byte for byte. + +## Steps + +### 1. Run your first analysis + +Zero install: + +```bash +npx @refract-org/cli analyze "Earth" --depth detailed +``` + +Or with a local install: + +```bash +bun add @refract-org/cli +refract analyze "Earth" --depth detailed -c +``` + +The `-c` flag enables caching so subsequent runs skip already-fetched revisions. This +is essential for large pages or repeated analysis. + +### 2. Read the output + +`refract analyze` prints one JSON event per line to stdout. Each event represents a +single observed change at a revision boundary. For example: + +```json +{ + "eventId": "a3f5c2e1b7d409fa", + "eventType": "section_reorganized", + "fromRevisionId": 1280110001, + "toRevisionId": 1280110100, + "section": "Geology", + "before": "", + "after": "== Geology ==\nEarth's crust consists of tectonic plates...", + "timestamp": "2024-11-25T12:00:00Z", + "layer": "observed", + "deterministicFacts": [ + { + "fact": "Section Geology added with 3 paragraphs", + "provenance": { + "analyzer": "section-differ", + "version": "0.5.1", + "inputHashes": [] + } + } + ] +} +``` + +Key fields to read: +- **`eventType`**: what kind of change (sentence, citation, template, revert, section, etc.) +- **`section`**: where in the page the change happened +- **`before` / `after`**: the text before and after the change +- **`deterministicFacts`**: why the engine produced this event — always mechanical +- **`layer`**: `"observed"` means directly extracted from the diff; other layers + (`"policy_coded"`, `"model_interpretation"`) are set by downstream consumers + +### 3. Scope to a specific revision range + +Large pages can produce thousands of events. Narrow the range with `--from` and `--to`: + +```bash +refract analyze "Earth" --from 1250000000 --to 1260000000 --depth detailed +``` + +Or filter by time with `--since`: + +```bash +refract analyze "Earth" --since 2024-01-01 --depth detailed +``` + +### 4. View results in a browser + +```bash +refract explore "Earth" +``` + +Opens a local web server (default port 8899) with an interactive timeline, evidence +table, and diff viewer. Press Ctrl+C to stop. + +### 5. Export structured output + +```bash +# Line-delimited JSON for tools +refract export "Earth" --format ndjson > earth-events.jsonl + +# CSV for spreadsheets +refract export "Earth" --format csv > earth-events.csv + +# Bundle with a SHA-256 hash of its contents +refract export "Earth" --bundle > earth-bundle.json +``` + +### 6. Track a specific claim + +```bash +refract claim "Earth" --text "Earth is the third planet from the Sun" -c +``` + +This shows every revision where that sentence was added, modified, removed, or +reintroduced — the full lifecycle of a claim. + +## Understanding what you see + +**Citation events** (`citation_added`, `citation_removed`, `citation_replaced`) trace +source churn. A section with frequent citation turnover may indicate an actively +contested topic. A citation that survives many revisions without change is a +high-stability source. + +**Revert events** (`revert_detected`) mark edits that were undone — either by the +same editor ("self-revert") or by another editor. Clusters of revert events in a +short time window often indicate edit-warring. + +**Template events** (`template_added`, `template_removed`) reveal policy tagging. +Templates like `{{citation needed}}` or `{{NPOV}}` are Wikipedia's dispute signals. +Refract detects these mechanically from wikitext. + +**Section events** (`section_reorganized`, `lead_promotion`) track structural +reorganization. When material moves from body to lead, the framing of the page has +changed — Refract flags this as a change in editorial emphasis. + +## Troubleshooting + +- **Rate limits**: Wikipedia's API limits request frequency. Use `-c` to cache + revisions and avoid re-fetching on repeat runs. +- **Large pages**: Pages with thousands of revisions produce many events. Use + `--from` / `--to` or `--since` to scope to a date range. +- **Other wikis**: Point `--api` to any MediaWiki instance: + ```bash + refract analyze "Erde" --api https://de.wikipedia.org/w/api.php + ``` +- **Too much output**: Pipe to a file instead of stdout, or use `refract export` + for structured formats. + +## Next steps + +- [Track canon changes on Fandom](fandom-canon.md) +- [Monitor citation churn on contested pages](citation-churn.md) +- [Build a dispute timeline](dispute-timeline.md) +- [Analyze with DuckDB](../analytics.md) +- [Work with notebooks](../notebooks.md) diff --git a/apps/docs/docs/visualizer.md b/apps/docs/docs/visualizer.md new file mode 100644 index 0000000..2415ce2 --- /dev/null +++ b/apps/docs/docs/visualizer.md @@ -0,0 +1,62 @@ +# Refract UI — Visualizer + +Standalone web app for exploring Refract observation data. Load any JSONL file of Refract events and see timelines, word-level diffs, citation graphs, and event-type breakdowns. No backend, no server, no data leaves your machine. + +![Refract UI: timeline, word-level diff, and citation panels](assets/refract-ui-screenshot.png) + +## Quick start + +```bash +git clone https://github.com/refract-org/refract-ui +cd refract-ui +bun install +bun run dev +``` + +Open `http://localhost:5173`. Sample data loads immediately. Drag your own `.jsonl` file onto the upload zone to inspect it. + +## Generate data to visualize + +```bash +refract analyze "Bitcoin" --depth forensic --format ndjson > bitcoin-events.jsonl +``` + +Use `--depth forensic` for the full 26 event types. + +## Panels + +### Timeline + +Every event in chronological order. Click any event to see the diff. Filter by event type with the tag buttons in the toolbar. + +### Diff Viewer + +Word-level diff of `before` → `after` for the selected event. Removed text is shown struck-through in red, added text in green. + +### Citation Churn + +Bar chart of citation additions, removals, and replacements per revision, plus a sortable table of every citation change. + +### Certainty Timeline + +Confidence scores over time for model-interpreted events. Dots are color-coded: green (≥70%), yellow (40–69%), red (<40%). + +### Disputes + +Revert clusters, edit velocity per day, and talk page activity timeline. + +### Language Changes + +Card grid of wording events — claims reworded, strengthened, softened, or removed. + +### Schema Inspector + +Every JSON key in the loaded data with inferred types and presence percentage. + +## Export + +Download filtered data as JSON, JSONL, or CSV using the toolbar buttons. Filters apply to exports — narrow by event type first, then export. + +## Source + +[github.com/refract-org/refract-ui](https://github.com/refract-org/refract-ui) · [Full tutorial](tutorials/refract-ui) diff --git a/apps/docs/docs/why-refract.md b/apps/docs/docs/why-refract.md new file mode 100644 index 0000000..177da4d --- /dev/null +++ b/apps/docs/docs/why-refract.md @@ -0,0 +1,118 @@ +# Why Refract? + +Refract is a deterministic observation engine for revision histories: it tells you what +changed on a page, when, and in which revision. + +## Who this is for + +### Journalists and researchers + +You need to trace how a claim about a person, company, event, or policy evolved across +Wikipedia's revision history. Refract gives you a deterministic timeline of every +change — when a sentence first appeared, when sources were added or removed, when +edits were reverted, and when talk page discussions accompanied the changes. + +```bash +refract claim "Theranos" --text "revolutionary blood testing" -c +``` + +Result: a verifiable timeline of when the claim was added, who softened it, when +the sources disappeared, and when the article was tagged with dispute templates. + +### Data scientists and OSINT analysts + +You need structured, queryable data about page revision histories — no scraping, no +custom parsers, no fragile heuristics. Refract outputs standard NDJSON that DuckDB, +Python pandas, and any JSON tool can consume directly. + +```bash +refract analyze "Bitcoin" --depth forensic --since 2024-01-01 > bitcoin.jsonl +duckdb -c "SELECT event_type, count(*) FROM 'bitcoin.jsonl' GROUP BY 1 ORDER BY 2 DESC" +``` + +### ML engineers building RAG or training data + +You need provenance signals for retrieved text. Is this claim stable? Has it been +contested? Is the supporting source still in the article? Refract attaches claim +stability metadata to every chunk so your retrieval system can weight results by +provenance quality, not just embedding similarity. + +```python +# Filter training data to stable, well-sourced claims +stable_claims = df[(df["eventType"] == "sentence_first_seen") + & (df["is_contested"] == False)] +``` + +### Regulatory and policy monitors + +You need to track changes to drug safety pages, guideline entries, or policy language +for early signals of institutional shifts. Refract's cron mode re-observes pages on a +schedule and notifies you when new events fire — citation removal, template disputes, +section reorganization. + +```bash +refract cron pages.txt --notify-webhook https://hooks.example.com/refract +``` + +### AI agent developers + +You need an MCP-native tool to give your agent claim-awareness. `refract mcp` starts +a JSON-RPC server that any MCP client can connect to. Your agent can call +`analyze`, `claim`, and `export` directly; no API key is needed. + +```json +{ + "mcpServers": { + "refract": { + "command": "npx", + "args": ["-y", "@refract-org/cli", "mcp"] + } + } +} +``` + +### Fan wiki maintainers + +Your wiki's canon is contested. Headcanon, fan edits, and retcons overwrite +established lore. Refract tracks every change to every page — when a character's +backstory changed, when a category was reclassified, when one wiki's canon diverged +from another's. + +```bash +refract diff "Darth_Vader" \ + --wiki-a https://starwars.fandom.com/api.php \ + --wiki-b https://clone-wars.fandom.com/api.php +``` + +## What makes Refract different + +| Refract | Alternatives | +|---------|-------------| +| **Deterministic** — same input, same output, every run. Byte-for-byte identical. | Most tools depend on model calls, heuristic sampling, or non-reproducible computation | +| **Provenance-tagged** — every event records which analyzer, what version, what parameters | Most tools produce output without an audit trail | +| **26 event types** — sentence lifecycle, citations, templates, reverts, sections, categories, wikilinks, talk pages, edit clusters, protection changes | Most tools track 1–3 signal types | +| **BYO-inference** — every analyzer threshold is a pluggable function. Defaults work offline. Plug a model where you need one. | Most tools are either all-model or no-model, not selectable per boundary | +| **Hash-verifiable** — evidence bundles carry a SHA-256 hash, replay manifests a Merkle root | Most tools export no hash to check a copy against | +| **MCP-native** — AI agents connect via built-in MCP server | Most tools have no AI agent integration | + +## When Refract is not the right tool + +- **You need real-time monitoring**: Refract is polling-based (via cron or watch mode), not push-based. Use a MediaWiki webhook if you need sub-second latency. +- **You need sentiment analysis**: Refract is deterministic — it doesn't score, rank, or classify editor intent. Use a downstream tool for sentiment. +- **You need editor profiling**: Refract never records editor identities. Use a diff analytics tool if you need per-editor attribution. +- **You need a truth-verification system**: Refract reports what changed, not whether the change is correct. Truth assessment lives downstream. + +## Quick evaluation + +```bash +# Brief analysis +npx @refract-org/cli analyze "Earth" --depth brief + +# Detailed analysis +npx @refract-org/cli analyze "Bitcoin" --depth detailed + +# Browse the events in a local web UI +refract explore "Bitcoin" +``` + +The [complete workflow](complete-workflow.md) walks through one use case in 5 steps. diff --git a/apps/docs/e2e/edge-deploy.spec.ts b/apps/docs/e2e/edge-deploy.spec.ts new file mode 100644 index 0000000..aff6592 --- /dev/null +++ b/apps/docs/e2e/edge-deploy.spec.ts @@ -0,0 +1,9 @@ +// E2E Testing on Edge — generated by tech-tree +import { expect, test } from "@playwright/test"; + +const BASE = process.env.DEPLOY_URL || "http://localhost:8787"; + +test("deploy and verify", async ({ page }) => { + await page.goto(BASE); + await expect(page.locator("h1")).toBeVisible(); +}); diff --git a/apps/docs/mise.toml b/apps/docs/mise.toml new file mode 100644 index 0000000..0c607eb --- /dev/null +++ b/apps/docs/mise.toml @@ -0,0 +1,3 @@ +[tools] +bun = "latest" +node = "22" diff --git a/apps/docs/package-lock.json b/apps/docs/package-lock.json new file mode 100644 index 0000000..cc7c7df --- /dev/null +++ b/apps/docs/package-lock.json @@ -0,0 +1,1680 @@ +{ + "name": "refract-docs", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "refract-docs", + "version": "0.1.0", + "license": "CC-BY-4.0", + "devDependencies": { + "@biomejs/biome": "^2.5.15", + "@expo-google-fonts/roboto": "^0.4.3", + "@resvg/resvg-js": "^2.6.2", + "husky": "^9.1.7", + "lint-staged": "^17.6.0", + "marked": "^15.0.12", + "vitest": "^4.1.11" + } + }, + "node_modules/@biomejs/biome": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.15.tgz", + "integrity": "sha512-WZTW4slm/pdkh92K6t/3aEN++44JD1PQ7squ7RCMLI1flHGl43DVOIGXvCjHiZgBMO1V4uYxoFNtqrIA4qGuIQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.5.15", + "@biomejs/cli-darwin-x64": "2.5.15", + "@biomejs/cli-linux-arm64": "2.5.15", + "@biomejs/cli-linux-arm64-musl": "2.5.15", + "@biomejs/cli-linux-x64": "2.5.15", + "@biomejs/cli-linux-x64-musl": "2.5.15", + "@biomejs/cli-win32-arm64": "2.5.15", + "@biomejs/cli-win32-x64": "2.5.15" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.15.tgz", + "integrity": "sha512-BZVzFhJ/mUvTLMYbc9x6el0o2Uv5zP7bACYyhFb6fSc1giroXm7PL0a5KqMJ9F+/BnkYRjagD/ROSDVOZ6eapg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.15.tgz", + "integrity": "sha512-V5Kw63V+fVGNFhFrizDxMbGXAzZCh8kYzJpy3GI6gTapWDg7bZK9oYC9CbVgQSEdTnupz0U5Efz8Ev+5vdBBXw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.15.tgz", + "integrity": "sha512-XaG7P7eeSLYETD3K9grfB0mQmpLQZjygdbsxekWBJAg4am79fGAtRpfRyNnTvXB2fZFNnJZG3nXm7HBat0+VUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.15.tgz", + "integrity": "sha512-tGzZUTcJCV7tj5Adh/Gn6nR4MycqA3iohq2LEcrarAgRBkU0h0OKd2UzCRkOsOYOuUGaUJb4YmqRqyy2feGGSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.15.tgz", + "integrity": "sha512-xE4iEW/3LqlYj9GFgGrFsFSH51dEEpUbYWBfeOv0q87WkUDxK2o/HhreSb7qMJqck70RVM6Lg96hgedcAYcOkA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.15.tgz", + "integrity": "sha512-IlxUcyxilVGPsE008x13pWdkTbU3nQpP2i9b5UrbOYj6goBkKsRX1XB7yJLcV+O1H9LBchIIm4adOaVLZBX0ZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.15.tgz", + "integrity": "sha512-2kPKzhNlm8C+Ru3GcO0Me2ODkCBLrVOUNuyi84RJyWDVKAA4+Kjj3jMjL938lF6BRfvltC4vB2nPY9SvN51Ovg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.15.tgz", + "integrity": "sha512-yAzh4UqEImV6Hcy0zjUqNfJAsUhoD64FBQCdTG8Md/Z2wQeVP/ZmJ3XRYAs1g8J9fjTFyl2fCtc5FB5til47+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@expo-google-fonts/roboto": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@expo-google-fonts/roboto/-/roboto-0.4.3.tgz", + "integrity": "sha512-kX3wmpmu06fN1yOwYWMdjYJWNUYW6xUiAGeHbvPzzSEb3A3j23JelQ3tAgwQNKgEKRq6wtMmzixy2r7y9c8dsA==", + "dev": true, + "license": "MIT AND OFL-1.1" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.5.tgz", + "integrity": "sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.132.0", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@resvg/resvg-js": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz", + "integrity": "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@resvg/resvg-js-android-arm-eabi": "2.6.2", + "@resvg/resvg-js-android-arm64": "2.6.2", + "@resvg/resvg-js-darwin-arm64": "2.6.2", + "@resvg/resvg-js-darwin-x64": "2.6.2", + "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2", + "@resvg/resvg-js-linux-arm64-gnu": "2.6.2", + "@resvg/resvg-js-linux-arm64-musl": "2.6.2", + "@resvg/resvg-js-linux-x64-gnu": "2.6.2", + "@resvg/resvg-js-linux-x64-musl": "2.6.2", + "@resvg/resvg-js-win32-arm64-msvc": "2.6.2", + "@resvg/resvg-js-win32-ia32-msvc": "2.6.2", + "@resvg/resvg-js-win32-x64-msvc": "2.6.2" + } + }, + "node_modules/@resvg/resvg-js-android-arm-eabi": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm-eabi/-/resvg-js-android-arm-eabi-2.6.2.tgz", + "integrity": "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-android-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm64/-/resvg-js-android-arm64-2.6.2.tgz", + "integrity": "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.2.tgz", + "integrity": "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-x64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.2.tgz", + "integrity": "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm-gnueabihf": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.2.tgz", + "integrity": "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.2.tgz", + "integrity": "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz", + "integrity": "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.2.tgz", + "integrity": "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.2.tgz", + "integrity": "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-arm64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.2.tgz", + "integrity": "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-ia32-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.2.tgz", + "integrity": "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-x64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.2.tgz", + "integrity": "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", + "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/husky": { + "version": "9.1.7", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lint-staged": { + "version": "17.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^4.0.7", + "string-argv": "^0.3.2", + "tinyexec": "^1.3.1" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=22.22.1" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + }, + "optionalDependencies": { + "yaml": "^2.9.1" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/marked": { + "version": "15.0.12", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.132.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/string-argv": { + "version": "0.3.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/vite": { + "version": "8.0.14", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.2", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.11", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yaml": { + "version": "2.9.1", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/apps/docs/package.json b/apps/docs/package.json new file mode 100644 index 0000000..7a3f6bd --- /dev/null +++ b/apps/docs/package.json @@ -0,0 +1,32 @@ +{ + "name": "refract-docs", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Public documentation for Refract — the open-source deterministic observation engine for public revision histories", + "license": "CC-BY-4.0", + "repository": { + "type": "git", + "url": "git+https://github.com/refract-org/refract-docs.git" + }, + "homepage": "https://refract-org.github.io/refract-docs/", + "bugs": { + "url": "https://github.com/refract-org/refract-docs/issues" + }, + "scripts": { + "build": "node build.mjs", + "test": "vitest run", + "lint": "biome check .", + "format": "biome format --write .", + "prepare": "husky" + }, + "devDependencies": { + "@biomejs/biome": "^2.5.15", + "@expo-google-fonts/roboto": "^0.4.3", + "@resvg/resvg-js": "^2.6.2", + "husky": "^9.1.7", + "lint-staged": "^17.6.0", + "marked": "^15.0.12", + "vitest": "^4.1.11" + } +} diff --git a/apps/docs/social-card.mjs b/apps/docs/social-card.mjs new file mode 100644 index 0000000..fc30031 --- /dev/null +++ b/apps/docs/social-card.mjs @@ -0,0 +1,119 @@ +// Renders the Open Graph card: the site name, the positioning line and the +// site address, in the colors of assets/style.css. +import { createRequire } from "node:module"; +import { Resvg } from "@resvg/resvg-js"; + +const require = createRequire(import.meta.url); + +export const CARD_WIDTH = 1200; +export const CARD_HEIGHT = 630; + +// The site's font stack starts with system-ui, which resolves to the reader's +// operating-system face (SF Pro, Segoe UI); those cannot be redistributed in an +// image. Roboto is the stack's openly licensed face (OFL-1.1). Only these two +// files are loaded, never system fonts, so every machine renders the same PNG. +const FONT = { + fontFiles: [ + require.resolve( + "@expo-google-fonts/roboto/400Regular/Roboto_400Regular.ttf", + ), + require.resolve("@expo-google-fonts/roboto/700Bold/Roboto_700Bold.ttf"), + ], + loadSystemFonts: false, + defaultFontFamily: "Roboto", +}; + +// Custom properties from assets/style.css. +const COLORS = { + background: "#07090f", // --bg-deep + brand: "#ffffff", // .brand + text: "#e2e4ed", // --text + muted: "#7c82a0", // --text-muted + accent: "#2dd4bf", // --accent + rule: "rgba(255, 255, 255, 0.12)", // --border-strong +}; + +const MARGIN = 80; +const TEXT_WIDTH = CARD_WIDTH - 2 * MARGIN; +const NAME = { size: 104, baseline: 196 }; +const LINE = { size: 46, leading: 62, firstBaseline: 304 }; +const FOOTER = { + size: 28, + baseline: CARD_HEIGHT - 72, + rule: CARD_HEIGHT - 132, +}; +export const MAX_LINES = Math.floor( + (FOOTER.rule - LINE.firstBaseline) / LINE.leading, +); + +function escapeXml(text) { + return text + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function text(content, { x, y, size, weight = 400, fill, spacing = 0 }) { + return `<text x="${x}" y="${y}" font-family="Roboto" font-size="${size}" font-weight="${weight}" letter-spacing="${spacing}" fill="${fill}">${escapeXml(content)}</text>`; +} + +function textWidth(content, size, weight = 400) { + const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${CARD_WIDTH * 2}" height="${size * 2}">${text(content, { x: 0, y: size, size, weight, fill: "#000" })}</svg>`; + return new Resvg(svg, { font: FONT }).getBBox()?.width ?? 0; +} + +// Greedy word wrap, measured in the font the card is drawn in. +export function wrapText(content, size = LINE.size, maxWidth = TEXT_WIDTH) { + const lines = []; + let line = ""; + for (const word of content.trim().split(/\s+/)) { + const candidate = line ? `${line} ${word}` : word; + if (line && textWidth(candidate, size) > maxWidth) { + lines.push(line); + line = word; + } else { + line = candidate; + } + } + if (line) lines.push(line); + return lines; +} + +export function socialCardSvg({ name, description, address }) { + const lines = wrapText(description); + if (lines.length > MAX_LINES) { + throw new Error( + `The social card fits ${MAX_LINES} lines of description; this one wraps to ${lines.length}: ${description}`, + ); + } + // The site icon (the favicon triangle, drawn on a 16-unit grid), sized to + // the cap height of the name. + const capHeight = NAME.size * 0.711; + const scale = capHeight / 12; + const iconWidth = 14 * scale; + const icon = `<path d="M8 2 15 14H1z" fill="${COLORS.accent}" transform="translate(${MARGIN - scale} ${NAME.baseline - 14 * scale}) scale(${scale})"/>`; + const body = lines + .map((line, i) => + text(line, { + x: MARGIN, + y: LINE.firstBaseline + i * LINE.leading, + size: LINE.size, + fill: COLORS.text, + }), + ) + .join("\n "); + return `<svg xmlns="http://www.w3.org/2000/svg" width="${CARD_WIDTH}" height="${CARD_HEIGHT}" viewBox="0 0 ${CARD_WIDTH} ${CARD_HEIGHT}"> + <rect width="${CARD_WIDTH}" height="${CARD_HEIGHT}" fill="${COLORS.background}"/> + ${icon} + ${text(name, { x: MARGIN + iconWidth + 28, y: NAME.baseline, size: NAME.size, weight: 700, fill: COLORS.brand, spacing: -0.02 * NAME.size })} + ${body} + <rect x="${MARGIN}" y="${FOOTER.rule}" width="${TEXT_WIDTH}" height="1" fill="${COLORS.rule}"/> + ${text(address, { x: MARGIN, y: FOOTER.baseline, size: FOOTER.size, fill: COLORS.muted })} +</svg>`; +} + +export function renderSocialCard(content) { + return new Resvg(socialCardSvg(content), { font: FONT }).render().asPng(); +} diff --git a/apps/docs/tests/claims-e2e.test.ts b/apps/docs/tests/claims-e2e.test.ts new file mode 100644 index 0000000..8b0bc19 --- /dev/null +++ b/apps/docs/tests/claims-e2e.test.ts @@ -0,0 +1,160 @@ +import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; +import { readdir, readFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { beforeAll, describe, expect, it } from "vitest"; + +const ROOT_DIR = resolve(__dirname, ".."); +const DOCS_DIR = join(ROOT_DIR, "docs"); +const DIST_DIR = join(ROOT_DIR, "dist"); +const execFileAsync = promisify(execFile); + +async function findMarkdownFiles(dir: string): Promise<string[]> { + const entries = await readdir(dir, { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries) { + const filePath = join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...(await findMarkdownFiles(filePath))); + } else if (entry.name.endsWith(".md")) { + files.push(filePath); + } + } + return files; +} + +function slugForDoc(filePath: string): string { + return filePath + .slice(DOCS_DIR.length + 1) + .replaceAll("\\", "/") + .replace(/\.md$/, ""); +} + +function renderedPathForSlug(slug: string): string { + return slug === "index" + ? join(DIST_DIR, "index.html") + : join(DIST_DIR, slug, "index.html"); +} + +function extractCliCommands(cliMarkdown: string): string[] { + return Array.from(cliMarkdown.matchAll(/^## `refract ([^`\s]+)`$/gm)) + .map((match) => match[1]) + .sort(); +} + +function extractEventTypes(eventsMarkdown: string): string[] { + return Array.from(eventsMarkdown.matchAll(/^\| `([a-z][a-z0-9_]+)` \|/gm)) + .map((match) => match[1]) + .sort(); +} + +describe("docs claim audit e2e", () => { + beforeAll(async () => { + await execFileAsync(process.execPath, ["build.mjs"], { cwd: ROOT_DIR }); + }); + + it("renders every source markdown document into the built site", async () => { + const markdownFiles = await findMarkdownFiles(DOCS_DIR); + const missingPages = markdownFiles + .map((filePath) => slugForDoc(filePath)) + .filter((slug) => !existsSync(renderedPathForSlug(slug))); + + expect(missingPages).toEqual([]); + }); + + it("renders every pipe table as a table, not as pipe text", async () => { + // A delimiter row with more cells than its header row (|---|---|---|---| + // under three headings) is not a GFM table, so the page prints the + // whole table as a paragraph of pipes. + const delimiterRow = /^\s*\|?\s*:?-+:?\s*(?:\|\s*:?-+:?\s*)+\|?\s*$/m; + const pagesWithPipeText: string[] = []; + for (const filePath of await findMarkdownFiles(DOCS_DIR)) { + const slug = slugForDoc(filePath); + const html = await readFile(renderedPathForSlug(slug), "utf-8"); + for (const [, paragraph] of html.matchAll(/<p>([\s\S]*?)<\/p>/g)) { + if (delimiterRow.test(paragraph)) { + pagesWithPipeText.push(`${slug}: ${paragraph.split("\n")[0]}`); + } + } + } + + expect(pagesWithPipeText).toEqual([]); + }); + + it("keeps the search index in sync with rendered documentation pages", async () => { + const markdownSlugs = (await findMarkdownFiles(DOCS_DIR)) + .map((filePath) => slugForDoc(filePath)) + .sort(); + const searchIndex = JSON.parse( + await readFile(join(DIST_DIR, "search-index.json"), "utf-8"), + ) as Array<{ slug: string }>; + const indexedSlugs = searchIndex.map((entry) => entry.slug).sort(); + + expect(indexedSlugs).toEqual(markdownSlugs); + }); + + it("keeps rendered landing-page claims in sync with homepage source", async () => { + const html = await readFile(join(DIST_DIR, "index.html"), "utf-8"); + const css = await readFile(join(DIST_DIR, "style.css"), "utf-8"); + + expect(html.match(/class="feature-card"/g) ?? []).toHaveLength(6); + expect(html.match(/class="usecase-card"/g) ?? []).toHaveLength(6); + expect(html.match(/class="column-card /g) ?? []).toHaveLength(2); + expect(css).toContain(".features-grid"); + expect(css).toContain("grid-template-columns"); + expect(css).toContain(".usecase-grid"); + expect(css).toContain(".split-columns"); + }); + + it("backs numeric CLI command claims with the CLI reference", async () => { + const sdkMarkdown = await readFile(join(DOCS_DIR, "sdk.md"), "utf-8"); + const cliMarkdown = await readFile(join(DOCS_DIR, "cli.md"), "utf-8"); + const cliCommands = extractCliCommands(cliMarkdown); + const commandCountClaim = sdkMarkdown.match(/CLI tool \((\d+) commands:/); + + expect(commandCountClaim?.[1]).toBe(String(cliCommands.length)); + for (const command of cliCommands) { + expect(sdkMarkdown).toContain(command); + } + }); + + it("backs the 26 event-type claim with the event taxonomy", async () => { + const eventsMarkdown = await readFile(join(DOCS_DIR, "events.md"), "utf-8"); + const eventTypes = extractEventTypes(eventsMarkdown); + + expect(new Set(eventTypes).size).toBe(26); + expect(eventTypes).toHaveLength(26); + }); + + it("keeps primary naming claims consistent across docs", async () => { + const checkedFiles = [ + ...(await findMarkdownFiles(DOCS_DIR)), + join(ROOT_DIR, "README.md"), + join(ROOT_DIR, "package.json"), + ]; + const violations: string[] = []; + + for (const filePath of checkedFiles) { + const content = await readFile(filePath, "utf-8"); + if (/\bVaria\b/.test(content)) { + violations.push(`${filePath}: references legacy project name Varia`); + } + for (const block of content.matchAll( + /```(?:bash|sh|shell)?\n([\s\S]*?)```/g, + )) { + for (const line of block[1].split("\n")) { + if (/^\s*(?:\$\s*)?wikihistory\b/.test(line)) { + violations.push( + `${filePath}: uses wikihistory as an executable command`, + ); + } + } + } + } + + expect( + violations.map((violation) => violation.replace(`${ROOT_DIR}/`, "")), + ).toEqual([]); + }); +}); diff --git a/apps/docs/tests/links.test.ts b/apps/docs/tests/links.test.ts new file mode 100644 index 0000000..88cfa7a --- /dev/null +++ b/apps/docs/tests/links.test.ts @@ -0,0 +1,421 @@ +import { execFile } from "node:child_process"; +import { existsSync, statSync } from "node:fs"; +import { readdir, readFile, stat } from "node:fs/promises"; +import { basename, dirname, join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { marked } from "marked"; +import { beforeAll, describe, expect, it } from "vitest"; +import { rewriteLink } from "../build.mjs"; + +const ROOT_DIR = resolve(__dirname, ".."); +const DOCS_DIR = join(ROOT_DIR, "docs"); +const DIST_DIR = join(ROOT_DIR, "dist"); +const SITE_BASE = "/refract-docs"; +const SITE_URL = `https://refract-org.github.io${SITE_BASE}/`; +const execFileAsync = promisify(execFile); + +// Helper to recursively find all .md files +async function findMarkdownFiles(dir: string): Promise<string[]> { + const entries = await readdir(dir, { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries) { + const fp = join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...(await findMarkdownFiles(fp))); + } else if (entry.name.endsWith(".md")) { + files.push(fp); + } + } + return files; +} + +async function findHtmlFiles(dir: string): Promise<string[]> { + const entries = await readdir(dir, { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries) { + const fp = join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...(await findHtmlFiles(fp))); + } else if (entry.name.endsWith(".html")) { + files.push(fp); + } + } + return files; +} + +// Extract all links/images from markdown content using marked +function extractLinks(content: string): string[] { + const links: string[] = []; + const renderer = new marked.Renderer(); + + // Collect markdown links + renderer.link = ({ href }) => { + if (href) links.push(href); + return ""; + }; + + // Collect images + renderer.image = ({ href }) => { + if (href) links.push(href); + return ""; + }; + + marked.parse(content, { renderer }); + + // Extract HTML links (like <a href="...">) since index.md uses them. + const htmlLinkRegex = /href=["']([^"']+)["']/g; + let match = htmlLinkRegex.exec(content); + while (match !== null) { + links.push(match[1]); + match = htmlLinkRegex.exec(content); + } + + // Extract HTML src attributes for images (if any) + const htmlSrcRegex = /src=["']([^"']+)["']/g; + let srcMatch = htmlSrcRegex.exec(content); + while (srcMatch !== null) { + links.push(srcMatch[1]); + srcMatch = htmlSrcRegex.exec(content); + } + + return Array.from(new Set(links)); +} + +function checkLink( + sourceFile: string, + href: string, +): { isValid: boolean; resolvedPath: string } { + // Ignore external links + if ( + href.startsWith("http://") || + href.startsWith("https://") || + href.startsWith("mailto:") || + href.startsWith("data:") || + href.startsWith("javascript:") + ) { + return { isValid: true, resolvedPath: href }; + } + + // Remove anchor hash and query params + const [pathPart] = href.split(/[?#]/); + if (!pathPart) { + return { isValid: true, resolvedPath: href }; // Page-internal anchor + } + + let resolvedPath: string; + if (pathPart.startsWith("/")) { + // Root-relative + // Try relative to docs first, then relative to repository root + const docsPath = join(DOCS_DIR, pathPart.slice(1)); + const rootPath = join(ROOT_DIR, pathPart.slice(1)); + + if (existsSync(docsPath) || existsSync(`${docsPath}.md`)) { + return { isValid: true, resolvedPath: docsPath }; + } + if (existsSync(rootPath)) { + return { isValid: true, resolvedPath: rootPath }; + } + resolvedPath = docsPath; + } else { + // Relative to the source file + resolvedPath = join(dirname(sourceFile), pathPart); + } + + // 1. Exact path matches + if (existsSync(resolvedPath)) { + return { isValid: true, resolvedPath }; + } + + // Clean up trailing slash for further existence checks (e.g. appending .md) + const cleanResolvedPath = resolvedPath.endsWith("/") + ? resolvedPath.slice(0, -1) + : resolvedPath; + + // 1b. If it's an asset file (ends with .svg, .css, etc.), check if it exists in the ROOT_DIR/assets directory + const isAssetExtension = /\.(svg|css|png|jpg|jpeg|gif|webp|ico|ipynb)$/i.test( + pathPart, + ); + if (isAssetExtension) { + const assetName = basename(pathPart); + const assetPath = join(ROOT_DIR, "assets", assetName); + if (existsSync(assetPath)) { + return { isValid: true, resolvedPath: assetPath }; + } + } + + // 1c. Try resolving relative to the compiled output directory structure + if (sourceFile.startsWith(DOCS_DIR)) { + const relToDocs = sourceFile.slice(DOCS_DIR.length + 1); // e.g. "demo.md" or "tutorials/wikipedia-history.md" + const slug = relToDocs.replace(/\.md$/, ""); + const outputDir = slug === "index" ? DOCS_DIR : join(DOCS_DIR, slug); + const resolvedOutputDirPath = join(outputDir, pathPart); + const cleanOutputDirPath = resolvedOutputDirPath.endsWith("/") + ? resolvedOutputDirPath.slice(0, -1) + : resolvedOutputDirPath; + + if ( + existsSync(resolvedOutputDirPath) || + existsSync(`${cleanOutputDirPath}.md`) + ) { + return { isValid: true, resolvedPath: resolvedOutputDirPath }; + } + if (pathPart.endsWith("/")) { + if ( + existsSync(`${cleanOutputDirPath}.md`) || + existsSync(join(resolvedOutputDirPath, "index.md")) + ) { + return { isValid: true, resolvedPath: `${cleanOutputDirPath}.md` }; + } + } + } + + // 2. Markdown file referenced without extension (e.g. quickstart -> quickstart.md) + if (existsSync(`${cleanResolvedPath}.md`)) { + return { isValid: true, resolvedPath: `${cleanResolvedPath}.md` }; + } + + // 3. Trailing slash / folder references + if (pathPart.endsWith("/")) { + if (existsSync(`${cleanResolvedPath}.md`)) { + return { isValid: true, resolvedPath: `${cleanResolvedPath}.md` }; + } + if (existsSync(join(resolvedPath, "index.md"))) { + return { isValid: true, resolvedPath: join(resolvedPath, "index.md") }; + } + } + + // 4. No extension reference to file inside folder or sibling folder + if (existsSync(`${cleanResolvedPath}.md`)) { + return { isValid: true, resolvedPath: `${cleanResolvedPath}.md` }; + } + + return { isValid: false, resolvedPath }; +} + +function isExternalLink(href: string): boolean { + return /^(https?:|mailto:|data:|javascript:)/.test(href); +} + +function normalizeUrlPath(path: string): string { + const parts: string[] = []; + for (const part of path.split("/")) { + if (!part || part === ".") continue; + if (part === "..") { + parts.pop(); + } else { + parts.push(part); + } + } + return `/${parts.join("/")}${path.endsWith("/") ? "/" : ""}`; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +// The URL path a link in a built page points at ("/refract-docs/cli/"), or +// null for an external link or a same-page anchor. Absolute links to the +// published site, such as the canonical URL, count as internal. +function sitePathOf(sourceFile: string, href: string): string | null { + const local = href.startsWith(SITE_URL) + ? href.slice(SITE_URL.length - SITE_BASE.length - 1) + : href; + if (isExternalLink(local) || local.startsWith("#")) return null; + + const [pathPart] = local.split(/[?#]/); + if (!pathPart) return null; + + const relativeDir = dirname(sourceFile) + .replace(DIST_DIR, "") + .replaceAll("\\", "/"); + const pagePath = `${SITE_BASE}${relativeDir === "/" ? "" : relativeDir}/`; + return normalizeUrlPath( + pathPart.startsWith("/") ? pathPart : `${pagePath}${pathPart}`, + ); +} + +async function checkGeneratedLink( + sourceFile: string, + href: string, +): Promise<{ isValid: boolean; resolvedPath: string }> { + const urlPath = sitePathOf(sourceFile, href); + if (urlPath === null) { + return { isValid: true, resolvedPath: href }; + } + const hash = href.split("#")[1]; + + if (!(urlPath === `${SITE_BASE}/` || urlPath.startsWith(`${SITE_BASE}/`))) { + return { isValid: false, resolvedPath: urlPath }; + } + + const diskPath = urlPath.slice(SITE_BASE.length) || "/"; + let resolvedPath = diskPath.endsWith("/") + ? join(DIST_DIR, diskPath, "index.html") + : join(DIST_DIR, diskPath); + + try { + const pathStat = await stat(resolvedPath); + if (pathStat.isDirectory()) { + resolvedPath = join(resolvedPath, "index.html"); + await stat(resolvedPath); + } + if (hash) { + const html = await readFile(resolvedPath, "utf-8"); + const decodedHash = decodeURIComponent(hash); + const idPattern = new RegExp(`id=["']${escapeRegExp(decodedHash)}["']`); + if (!idPattern.test(html)) { + return { + isValid: false, + resolvedPath: `${resolvedPath}#${decodedHash}`, + }; + } + } + return { isValid: true, resolvedPath }; + } catch { + return { isValid: false, resolvedPath }; + } +} + +describe("Documentation Links Validator", async () => { + const mdFiles = await findMarkdownFiles(DOCS_DIR); + // Also include the root README.md + mdFiles.push(join(ROOT_DIR, "README.md")); + + for (const file of mdFiles) { + const relativeFilePath = file.replace(`${ROOT_DIR}/`, ""); + + it(`should have valid links in ${relativeFilePath}`, async () => { + const content = await readFile(file, "utf-8"); + const hrefs = extractLinks(content); + const invalidLinks: string[] = []; + + for (const href of hrefs) { + const { isValid, resolvedPath } = checkLink(file, href); + if (!isValid) { + invalidLinks.push(`${href} (resolved: ${resolvedPath})`); + } + } + + expect( + invalidLinks, + `Found broken links in ${relativeFilePath}:\n${invalidLinks.join("\n")}`, + ).toEqual([]); + }); + } +}); + +describe("Generated site links", async () => { + beforeAll(async () => { + await execFileAsync(process.execPath, ["build.mjs"], { cwd: ROOT_DIR }); + }); + + it("should have valid internal links after build", async () => { + const htmlFiles = await findHtmlFiles(DIST_DIR); + const invalidLinks: string[] = []; + + for (const file of htmlFiles) { + const relativeFilePath = file.replace(`${DIST_DIR}/`, ""); + const html = await readFile(file, "utf-8"); + const hrefs = extractLinks(html); + + for (const href of hrefs) { + const { isValid, resolvedPath } = await checkGeneratedLink(file, href); + if (!isValid) { + invalidLinks.push( + `${relativeFilePath}: ${href} (resolved: ${resolvedPath})`, + ); + } + } + } + + expect( + invalidLinks, + `Found broken generated links:\n${invalidLinks.join("\n")}`, + ).toEqual([]); + }); + + // GitHub Pages serves dist/<slug>/index.html at <slug>/ and answers the + // slash-less <slug> with a 301 redirect. + it("links to pages at their trailing-slash URL, so no internal link redirects", async () => { + const redirects: string[] = []; + let checked = 0; + + for (const file of await findHtmlFiles(DIST_DIR)) { + const html = await readFile(file, "utf-8"); + for (const href of extractLinks(html)) { + const urlPath = sitePathOf(file, href); + if (urlPath === null) continue; + checked++; + if (urlPath.endsWith("/")) continue; + const diskPath = join(DIST_DIR, urlPath.slice(SITE_BASE.length)); + if (existsSync(diskPath) && statSync(diskPath).isDirectory()) { + redirects.push(`${file.replace(`${DIST_DIR}/`, "")}: ${href}`); + } + } + } + + expect(checked).toBeGreaterThan(100); + expect( + redirects, + `These internal links redirect:\n${redirects.join("\n")}`, + ).toEqual([]); + }); +}); + +describe("rewriteLink", () => { + it.each([ + ["cli", "", "/refract-docs/cli/"], + ["./cli", "", "/refract-docs/cli/"], + ["cli/", "", "/refract-docs/cli/"], + ["cli.md", "", "/refract-docs/cli/"], + [ + "schema#version-compatibility", + "", + "/refract-docs/schema/#version-compatibility", + ], + [ + "schema.md#version-compatibility", + "", + "/refract-docs/schema/#version-compatibility", + ], + ["tutorials/refract-ui", "", "/refract-docs/tutorials/refract-ui/"], + ["fandom-canon.md", "tutorials", "/refract-docs/tutorials/fandom-canon/"], + ["../analytics.md", "tutorials", "/refract-docs/analytics/"], + ["../index.md", "tutorials", "/refract-docs/"], + ["index.md", "", "/refract-docs/"], + ["./", "", "/refract-docs/"], + ["/quickstart", "tutorials", "/refract-docs/quickstart/"], + ["cli?x=1", "", "/refract-docs/cli/?x=1"], + ])("links %s (from docs/%s) to the page URL %s", (href, dir, expected) => { + expect(rewriteLink(href, dir)).toBe(expected); + }); + + it.each([ + ["mcp-sequence.svg", "", "/refract-docs/mcp-sequence.svg"], + [ + "assets/refract-ui-screenshot.png", + "", + "/refract-docs/assets/refract-ui-screenshot.png", + ], + [ + "notebooks/model-evaluation.ipynb", + "", + "/refract-docs/notebooks/model-evaluation.ipynb", + ], + ["../boundary.svg", "tutorials", "/refract-docs/boundary.svg"], + ])( + "leaves the asset link %s (from docs/%s) without a slash", + (href, dir, expected) => { + expect(rewriteLink(href, dir)).toBe(expected); + }, + ); + + it.each([ + "https://example.org/page", + "http://example.org", + "mailto:docs@example.org", + "#anchor", + ])("leaves %s unchanged", (href) => { + expect(rewriteLink(href)).toBe(href); + }); +}); diff --git a/apps/docs/tests/seo.test.ts b/apps/docs/tests/seo.test.ts new file mode 100644 index 0000000..f2d627f --- /dev/null +++ b/apps/docs/tests/seo.test.ts @@ -0,0 +1,373 @@ +import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { beforeAll, describe, expect, it } from "vitest"; +import { + CARD_HEIGHT, + CARD_WIDTH, + MAX_LINES, + renderSocialCard, + socialCardSvg, + wrapText, +} from "../social-card.mjs"; + +const ROOT_DIR = resolve(__dirname, ".."); +const DIST_DIR = join(ROOT_DIR, "dist"); +const SITE_URL = "https://refract-org.github.io/refract-docs/"; +const SITE_DESCRIPTION = + "A deterministic observation engine for revision histories. It reads a page's edits and emits a typed event for each change."; +// Pages that use the site description: the home page by design, and pages with +// no prose paragraph to describe them (the build logs these). +const SITE_DESCRIPTION_PAGES = ["glossary", "index"]; +const SOCIAL_CARD_URL = `${SITE_URL}social-card.png`; +const execFileAsync = promisify(execFile); + +type Page = { slug: string; title: string; html: string }; +type JsonLdNode = Record<string, unknown> & { "@type": string }; + +// Width and height from a PNG's IHDR chunk, after checking the signature. +function pngSize(png: Buffer): { width: number; height: number } { + expect(png.subarray(0, 8)).toEqual( + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + ); + expect(png.toString("latin1", 12, 16)).toBe("IHDR"); + return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) }; +} + +function canonicalFor(slug: string): string { + return slug === "index" ? SITE_URL : `${SITE_URL}${slug}/`; +} + +function decodeEntities(text: string): string { + return text + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&/g, "&"); +} + +function metaContents(html: string, attr: string, key: string): string[] { + const pattern = new RegExp(`<meta ${attr}="${key}" content="([^"]*)">`, "g"); + return Array.from(html.matchAll(pattern), (match) => + decodeEntities(match[1]), + ); +} + +function descriptionOf(html: string): string { + return metaContents(html, "name", "description")[0] ?? ""; +} + +// Visible text of an HTML fragment: tags dropped, entities decoded, +// whitespace collapsed. +function textOf(fragment: string): string { + return decodeEntities(fragment.replace(/<[^>]+>/g, "")) + .replace(/\s+/g, " ") + .trim(); +} + +function stringsIn(value: unknown): string[] { + if (typeof value === "string") return [value]; + if (Array.isArray(value)) return value.flatMap(stringsIn); + if (value && typeof value === "object") { + return Object.values(value).flatMap(stringsIn); + } + return []; +} + +function canonicals(html: string): string[] { + return Array.from( + html.matchAll(/<link rel="canonical" href="([^"]*)">/g), + (match) => decodeEntities(match[1]), + ); +} + +function jsonLdGraph(html: string): JsonLdNode[] { + const blocks = Array.from( + html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g), + (match) => JSON.parse(match[1]), + ); + expect(blocks).toHaveLength(1); + expect(blocks[0]["@context"]).toBe("https://schema.org"); + return blocks[0]["@graph"]; +} + +function nodeOfType(graph: JsonLdNode[], type: string): JsonLdNode { + const node = graph.find((entry) => entry["@type"] === type); + expect(node, `missing ${type} node`).toBeDefined(); + return node as JsonLdNode; +} + +describe("technical SEO", () => { + const pages: Page[] = []; + + beforeAll(async () => { + await execFileAsync(process.execPath, ["build.mjs"], { cwd: ROOT_DIR }); + // The search index lists every rendered page; claims-e2e checks that. + const searchIndex = JSON.parse( + await readFile(join(DIST_DIR, "search-index.json"), "utf-8"), + ) as Array<{ slug: string; title: string }>; + for (const { slug, title } of searchIndex) { + const file = + slug === "index" + ? join(DIST_DIR, "index.html") + : join(DIST_DIR, slug, "index.html"); + pages.push({ slug, title, html: await readFile(file, "utf-8") }); + } + expect(pages.length).toBeGreaterThan(1); + }); + + it("gives every page one canonical URL, at the address it is served from", () => { + for (const { slug, html } of pages) { + expect(canonicals(html), slug).toEqual([canonicalFor(slug)]); + } + }); + + it("titles each page with the visible text of its H1", () => { + for (const { slug, title, html } of pages) { + const h1 = html.match(/<h1 [^>]*>([\s\S]*?)<\/h1>/)?.[1] ?? ""; + expect(title, slug).toBe(textOf(h1)); + const documentTitle = title.startsWith("Refract") + ? title + : `${title} — Refract`; + const titleElement = html.match(/<title>([^<]*)<\/title>/)?.[1] ?? ""; + expect(decodeEntities(titleElement), slug).toBe(documentTitle); + } + const crossWiki = pages.find((page) => + page.slug.endsWith("cross-wiki-diff"), + ); + expect(crossWiki?.title).toBe( + "Tutorial: Cross-wiki comparison with refract diff", + ); + }); + + it("keeps the site description on the home page", () => { + const home = pages.find((page) => page.slug === "index"); + expect(descriptionOf(home?.html ?? "")).toBe(SITE_DESCRIPTION); + }); + + it("gives each doc page its own description, apart from listed fallbacks", () => { + const fallbacks = pages + .filter(({ html }) => descriptionOf(html) === SITE_DESCRIPTION) + .map(({ slug }) => slug) + .sort(); + expect(fallbacks).toEqual(SITE_DESCRIPTION_PAGES); + + const descriptions = pages + .filter(({ slug }) => !SITE_DESCRIPTION_PAGES.includes(slug)) + .map(({ html }) => descriptionOf(html)); + expect(new Set(descriptions).size).toBe(descriptions.length); + }); + + it("takes each description from the page's own text, cut at a word boundary", () => { + for (const { slug, html } of pages) { + if (SITE_DESCRIPTION_PAGES.includes(slug)) continue; + const description = descriptionOf(html); + const main = html.match(/<main class="content">([\s\S]*?)<\/main>/); + const body = textOf(main?.[1] ?? ""); + const cut = description.endsWith("…"); + const prefix = cut ? description.slice(0, -1) : description; + const at = body.indexOf(prefix); + + expect(at, `${slug}: ${description}`).toBeGreaterThanOrEqual(0); + if (cut) { + expect(body[at + prefix.length], slug).toMatch(/[^\p{L}\p{N}]/u); + } + } + }); + + it("keeps every description to 160 characters or fewer", () => { + for (const { slug, html } of pages) { + const description = descriptionOf(html); + expect(description.length, `${slug}: ${description}`).toBeLessThanOrEqual( + 160, + ); + expect(description.length, slug).toBeGreaterThan(0); + } + }); + + it("leaves no markdown backticks or asterisks in titles or descriptions", () => { + for (const { slug, html } of pages) { + const texts = [ + textOf(html.match(/<title>([\s\S]*?)<\/title>/)?.[1] ?? ""), + descriptionOf(html), + ...metaContents(html, "property", "og:title"), + ...metaContents(html, "property", "og:description"), + ...stringsIn(jsonLdGraph(html)), + ]; + for (const text of texts) { + expect(text, slug).not.toMatch(/[`*]/); + } + } + }); + + it("emits Open Graph and Twitter card tags that match the page", () => { + for (const { slug, title, html } of pages) { + const description = metaContents(html, "name", "description"); + expect(metaContents(html, "property", "og:title"), slug).toEqual([title]); + expect(metaContents(html, "property", "og:description"), slug).toEqual( + description, + ); + expect(metaContents(html, "property", "og:url"), slug).toEqual([ + canonicalFor(slug), + ]); + expect(metaContents(html, "property", "og:type"), slug).toEqual([ + slug === "index" ? "website" : "article", + ]); + expect(metaContents(html, "property", "og:site_name"), slug).toEqual([ + "Refract", + ]); + expect(metaContents(html, "property", "og:image"), slug).toEqual([ + SOCIAL_CARD_URL, + ]); + expect(metaContents(html, "property", "og:image:width"), slug).toEqual([ + String(CARD_WIDTH), + ]); + expect(metaContents(html, "property", "og:image:height"), slug).toEqual([ + String(CARD_HEIGHT), + ]); + expect(metaContents(html, "property", "og:image:alt"), slug).toEqual([ + `Refract. ${SITE_DESCRIPTION}`, + ]); + expect(metaContents(html, "name", "twitter:card"), slug).toEqual([ + "summary_large_image", + ]); + } + }); + + it("publishes the 1200×630 card that og:image points to", async () => { + const png = await readFile( + join(DIST_DIR, SOCIAL_CARD_URL.slice(SITE_URL.length)), + ); + expect(pngSize(png)).toEqual({ width: 1200, height: 630 }); + // A flat rectangle compresses to a few kilobytes; rendered text does not. + expect(png.length).toBeGreaterThan(20_000); + expect(png.length).toBeLessThan(500_000); + }); + + it("escapes titles in the title element, attributes and JSON-LD", () => { + const mcp = pages.find((page) => page.slug === "mcp"); + expect(mcp?.title).toContain("&"); + expect(mcp?.html).toContain("<title>Wikipedia & MediaWiki"); + expect(mcp?.html).toContain( + '<meta property="og:title" content="Wikipedia & MediaWiki', + ); + for (const { slug, html } of pages) { + const head = html + .slice(0, html.indexOf("</head>")) + .replace(/<script[\s\S]*?<\/script>/g, ""); + // Outside scripts, every "&" in the head starts a character reference. + expect(head, slug).not.toMatch(/&(?![a-z]+;|#\d+;)/); + for (const [, json] of html.matchAll( + /<script type="application\/ld\+json">([\s\S]*?)<\/script>/g, + )) { + expect(json, slug).not.toContain("<"); + } + } + }); + + it("describes the home page as a WebSite for the Refract source code", () => { + const home = pages.find((page) => page.slug === "index"); + const graph = jsonLdGraph(home?.html ?? ""); + const website = nodeOfType(graph, "WebSite"); + const source = nodeOfType(graph, "SoftwareSourceCode"); + + expect(website).toMatchObject({ name: "Refract", url: SITE_URL }); + expect(website.description).toBe( + metaContents(home?.html ?? "", "name", "description")[0], + ); + expect(source.codeRepository).toBe( + "https://github.com/refract-org/refract", + ); + expect(website.about).toEqual({ "@id": source["@id"] }); + }); + + it("describes every doc page as a TechArticle with a breadcrumb trail", () => { + for (const { slug, title, html } of pages) { + if (slug === "index") continue; + const url = canonicalFor(slug); + const graph = jsonLdGraph(html); + const article = nodeOfType(graph, "TechArticle"); + const breadcrumbs = nodeOfType(graph, "BreadcrumbList"); + + expect(article, slug).toMatchObject({ + headline: title, + description: metaContents(html, "name", "description")[0], + url, + isPartOf: { "@type": "WebSite", url: SITE_URL }, + }); + expect(breadcrumbs.itemListElement, slug).toEqual([ + expect.objectContaining({ position: 1, name: "Home", item: SITE_URL }), + expect.objectContaining({ position: 2, item: url }), + ]); + } + }); + + it("lists every page once in sitemap.xml, with no guessed lastmod", async () => { + const sitemap = await readFile(join(DIST_DIR, "sitemap.xml"), "utf-8"); + const locs = Array.from(sitemap.matchAll(/<loc>([^<]*)<\/loc>/g), (m) => + decodeEntities(m[1]), + ); + + expect(sitemap.startsWith('<?xml version="1.0" encoding="UTF-8"?>')).toBe( + true, + ); + expect(sitemap).toContain( + '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">', + ); + expect(sitemap).not.toContain("<lastmod>"); + expect(new Set(locs).size).toBe(locs.length); + expect([...locs].sort()).toEqual( + pages.map((page) => canonicalFor(page.slug)).sort(), + ); + for (const loc of locs) { + const diskPath = join(DIST_DIR, loc.slice(SITE_URL.length), "index.html"); + expect(existsSync(diskPath), loc).toBe(true); + } + }); +}); + +describe("social card", () => { + const description = SITE_DESCRIPTION; + + it("wraps the description into lines that each fit the card", () => { + const lines = wrapText(description); + expect(lines.length).toBeGreaterThan(1); + expect(lines.length).toBeLessThanOrEqual(MAX_LINES); + expect(lines.join(" ")).toBe(description); + for (const line of lines) { + expect(wrapText(line), line).toEqual([line]); + } + }); + + it("refuses a description too long to fit, instead of clipping it", () => { + const long = Array(MAX_LINES + 2) + .fill(description) + .join(" "); + expect(() => + socialCardSvg({ name: "Refract", description: long, address: "x" }), + ).toThrow(/fits \d+ lines/); + }); + + it("escapes text for SVG", () => { + const svg = socialCardSvg({ + name: "A & B", + description: 'Uses <tags> & "quotes".', + address: "example.org/?a=1&b=2", + }); + expect(svg).toContain(">A & B</text>"); + expect(svg).toContain("Uses <tags> & "quotes"."); + expect(svg).toContain("example.org/?a=1&b=2"); + }); + + it("renders a 1200×630 PNG", () => { + const png = renderSocialCard({ + name: "Refract", + description, + address: "refract-org.github.io/refract-docs", + }); + expect(pngSize(Buffer.from(png))).toEqual({ width: 1200, height: 630 }); + }); +}); diff --git a/apps/docs/tests/setup.test.ts b/apps/docs/tests/setup.test.ts new file mode 100644 index 0000000..e25230a --- /dev/null +++ b/apps/docs/tests/setup.test.ts @@ -0,0 +1,7 @@ +import { describe, expect, it } from "vitest"; + +describe("repo", () => { + it("loads", () => { + expect(true).toBe(true); + }); +}); diff --git a/apps/docs/vitest.config.ts b/apps/docs/vitest.config.ts new file mode 100644 index 0000000..fb80269 --- /dev/null +++ b/apps/docs/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; +export default defineConfig({ + test: { + fileParallelism: false, + include: ["tests/**/*.test.ts"], + }, +}); diff --git a/apps/ui/.github/workflows/ci.yml b/apps/ui/.github/workflows/ci.yml new file mode 100644 index 0000000..3a66708 --- /dev/null +++ b/apps/ui/.github/workflows/ci.yml @@ -0,0 +1,15 @@ +name: CI +on: + push: + branches: [main] + pull_request: +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install + - run: bun run lint + - run: bun run test + - run: bun run build diff --git a/apps/ui/.gitignore b/apps/ui/.gitignore new file mode 100644 index 0000000..441a029 --- /dev/null +++ b/apps/ui/.gitignore @@ -0,0 +1,12 @@ +node_modules +dist +.DS_Store +*.local +opencode.json + +# Tooling +.cmux/ +.sisyphus/ +.codex/ +.agent/ +.wrangler/ diff --git a/apps/ui/.husky/pre-commit b/apps/ui/.husky/pre-commit new file mode 100755 index 0000000..2312dc5 --- /dev/null +++ b/apps/ui/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged diff --git a/apps/ui/.lintstagedrc.json b/apps/ui/.lintstagedrc.json new file mode 100644 index 0000000..dcdfaf0 --- /dev/null +++ b/apps/ui/.lintstagedrc.json @@ -0,0 +1,6 @@ +{ + "*.{js,ts,tsx,jsx,mjs,cjs}": [ + "npx biome check --write --no-errors-on-unmatched" + ], + "*.{json,md,yaml,yml}": ["npx biome check --write --no-errors-on-unmatched"] +} diff --git a/apps/ui/.opencode/commands/build.md b/apps/ui/.opencode/commands/build.md new file mode 100644 index 0000000..efe07d4 --- /dev/null +++ b/apps/ui/.opencode/commands/build.md @@ -0,0 +1,5 @@ +--- +description: "Run the full build" +agent: build +--- +npx biome check .||tsc && vite build||vitest run diff --git a/apps/ui/.opencode/commands/check.md b/apps/ui/.opencode/commands/check.md new file mode 100644 index 0000000..0c485c5 --- /dev/null +++ b/apps/ui/.opencode/commands/check.md @@ -0,0 +1,5 @@ +--- +description: "Run typecheck + lint" +agent: build +--- +npx biome check .||tsc && vite build||vitest run diff --git a/apps/ui/.opencode/modes/deploy.md b/apps/ui/.opencode/modes/deploy.md new file mode 100644 index 0000000..1436453 --- /dev/null +++ b/apps/ui/.opencode/modes/deploy.md @@ -0,0 +1,9 @@ +# Deploy Mode + +You are in **deploy mode**. Process: + +1. **Run full QA** — `bun run qa` (typecheck + test + build) +2. **If QA fails** — report failures and stop. Do not deploy. +3. **If QA passes** — run `bun run deploy` +4. **Verify** — confirm the deploy succeeded, note the URL +5. **Report** — what was deployed, commit hash, any notable changes diff --git a/apps/ui/.opencode/modes/refactor.md b/apps/ui/.opencode/modes/refactor.md new file mode 100644 index 0000000..13528aa --- /dev/null +++ b/apps/ui/.opencode/modes/refactor.md @@ -0,0 +1,11 @@ +# Refactor Mode + +You are in **refactoring mode**. Process: + +1. **Read first** — understand the full module before changing anything +2. **Scope tightly** — one concern per refactor, never mix with feature work +3. **Preserve contracts** — public API signatures, exports, and types must not change unless planned +4. **Leverage LSP** — use rename, find references, and diagnostics to validate +5. **Verify after each change** — `bun run typecheck` then `bun run test` + +If the refactor touches 5+ files, stop and explain the plan first. diff --git a/apps/ui/.opencode/modes/review.md b/apps/ui/.opencode/modes/review.md new file mode 100644 index 0000000..0fef67b --- /dev/null +++ b/apps/ui/.opencode/modes/review.md @@ -0,0 +1,11 @@ +# Review Mode + +You are in **code review mode**. Focus on: + +1. **Read the changed files** — understand what the diff achieves +2. **Check for bugs** — edge cases, null safety, error paths, race conditions +3. **Verify patterns** — does the change follow existing codebase conventions? +4. **Security** — no secrets, no injection vectors, proper input validation +5. **Run verification** — `bun run typecheck && bun run test` before approving + +Output a structured review: what's good, what needs changing, and why. Be specific with file paths and line suggestions. diff --git a/apps/ui/AGENTS.md b/apps/ui/AGENTS.md new file mode 100644 index 0000000..08c2f5f --- /dev/null +++ b/apps/ui/AGENTS.md @@ -0,0 +1,45 @@ +# refract-ui — Agent Instructions + +Standalone visualization UI for Refract. Not part of the monorepo — no `@refract-org/*` package imports. + +## Quick Start + +```bash +bun install +bun run dev # vite dev server +bun run build # tsc + vite build +bun run test # typecheck + vitest +bun run preview # vite preview +``` + +## Repository Structure + +- `src/` — Component source code (vanilla TypeScript) +- `src/language/` — Viz-specific UI components +- `tests/` — Vitest test suite +- `public/sample-data.jsonl` — Valid `EvidenceEvent` objects +- `dist/` — Build output + +## Tooling + +- **Package manager:** Bun +- **Linter/Formatter:** Biome +- **Tests:** Vitest +- **Pre-commit:** Husky + lint-staged (Biome check on staged files) +- **CI:** None yet — runs locally + +## Conventions + +- Each component is a class with a `render()` method. +- Components take a container `HTMLElement` and data, manipulate DOM directly. +- No framework. No JSX. No virtual DOM. +- Types mirror `@refract-org/evidence-graph` schemas (defined locally in `src/types.ts`). +- CSS custom properties in `main.css` — no CSS framework. + +## Verification + +```bash +bun run lint # Biome check +bun run test # Typecheck + vitest +bun run build # Production build +``` diff --git a/apps/ui/LICENSE b/apps/ui/LICENSE new file mode 100644 index 0000000..a8c12f1 --- /dev/null +++ b/apps/ui/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 NextConsensus + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/ui/README.md b/apps/ui/README.md new file mode 100644 index 0000000..56a9854 --- /dev/null +++ b/apps/ui/README.md @@ -0,0 +1,49 @@ +# Refract UI + +Standalone visualization for the [Refract](https://github.com/refract-org/refract) observation engine. + +Load a JSONL file of Refract events to see them as a timeline with word-level diffs, citation and revert charts, talk page activity, and a list of the fields the events carry. Everything runs in the browser; there is no backend. + +## Quick start + +```bash +bun install +bun run dev +``` + +Open `http://localhost:5173`. Sample data loads automatically. Drag a JSONL file onto the upload zone to inspect your own data. + +### Using your own Refract data + +```bash +refract export "Bitcoin" --format ndjson > bitcoin-events.jsonl +``` + +Then drag `bitcoin-events.jsonl` onto the Refract UI upload zone. + +## What you'll see + +**Timeline** — Every event, in the order the file lists them. Click an event to see the word-level diff of what changed. Filter by event type to narrow the view. + +**Citations** — Bar chart of citation additions, removals, and replacements per revision, plus a table of every citation change. + +**Model confidence** — The confidence score of each event on the `model_interpretation` layer. + +**Disputes** — Revert clusters, events per day (with reverts), and talk page activity. + +**Wording changes** — One card per sentence event (first seen, modified, removed, reintroduced), with the before and after text. + +**Event schema** — Every top-level field in the loaded events, with its types and the share of events that have it. + +**Export** — Download filtered data as JSON, JSONL, or CSV. + +## Stack + +- Vite + vanilla TypeScript +- No framework dependencies +- DOM-based rendering with CSS custom properties +- Loads nothing from other origins; an uploaded file is read in the browser and never sent anywhere + +--- + +[Refract Engine](https://github.com/refract-org/refract) · [Docs](https://refract-org.github.io/refract-docs/) · [npm](https://www.npmjs.com/org/refract-org) diff --git a/apps/ui/biome.json b/apps/ui/biome.json new file mode 100644 index 0000000..a7749f7 --- /dev/null +++ b/apps/ui/biome.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.8/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": ["**", "!!**/dist"] + }, + "formatter": { + "enabled": true, + "indentStyle": "tab" + }, + "linter": { + "enabled": true, + "rules": { + "preset": "recommended" + } + }, + "overrides": [ + { + "includes": ["src/main.ts"], + "linter": { + "rules": { + "style": { "noNonNullAssertion": "off" } + } + } + }, + { + "includes": ["scripts/generate-scenes.ts"], + "linter": { + "rules": { + "suspicious": { "noExplicitAny": "off" } + } + } + } + ], + "javascript": { + "formatter": { + "quoteStyle": "double" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/apps/ui/bun.lock b/apps/ui/bun.lock new file mode 100644 index 0000000..02be11c --- /dev/null +++ b/apps/ui/bun.lock @@ -0,0 +1,295 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "varia-ui", + "devDependencies": { + "@biomejs/biome": "^2.5.8", + "happy-dom": "^20.14.5", + "husky": "^9.1.7", + "lint-staged": "^17.0.5", + "typescript": "^5.7.0", + "vite": "^6.0.0", + "vitest": "^4.1.10", + }, + }, + }, + "packages": { + "@biomejs/biome": ["@biomejs/biome@2.5.8", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.8", "@biomejs/cli-darwin-x64": "2.5.8", "@biomejs/cli-linux-arm64": "2.5.8", "@biomejs/cli-linux-arm64-musl": "2.5.8", "@biomejs/cli-linux-x64": "2.5.8", "@biomejs/cli-linux-x64-musl": "2.5.8", "@biomejs/cli-win32-arm64": "2.5.8", "@biomejs/cli-win32-x64": "2.5.8" }, "bin": { "biome": "bin/biome" } }, "sha512-aeAeeJB9fSDc7Gq+2GqpQxA0qBj6gj1k2R6L1cYqGePKP/baIq1WX8y6B+D+nRsO5ViQL22K/8IwbqERW0q1nw=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mk1QON9PHllvvLN5gU3f4rMxeh4syK5p9OvKyWH6/W8ueh04uaC8TUXXByhGufWf/y5mQc03ZLM45zU+cmqMjA=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-bsGwFMBNyHPyiLSsQcZJxdoRrg1V4JL+d7wEsvUBczlP9U9lwM+7mzQHxI4o1mhBsTmdOBbAb6fHU3Z3snN45w=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-XmFiA0WPYFC+uiUDC8WRFzAIH9bo7vwQLav38Uoq4ETC+T/+uBi0TsYGJECkugY3r8USl3jc+Ae2/irAF6F2lQ=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-VcJNbstduTHx83NGAdhp78/JOcP45BZHXL7yNsfI1uGzdUgegAz2s+mSoT7wK6PBNzLoqG0zDOXaz/RQYVtSiw=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.8", "", { "os": "linux", "cpu": "x64" }, "sha512-S5wcm9OBDvLHodD4PUaN488hCpco9QD/9ZxuYJiw4euWtr/oQvLR72z2ixItH8Wd5BCm6FZaeb+YNvOoM1xHtQ=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.8", "", { "os": "linux", "cpu": "x64" }, "sha512-kKmiyokeISRGq2FLwvr+TzsgBusfxaZ0FZNLcOYOpCK/78tRrEjeEBLvq3xLZMpqbANgJdRPI7vZX8ZL37u9/w=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-nILH0mzm3Hi3iEdd7o7GpB8kBR/mSQwfQG/tyBqyNrY2GFtcgwfV9nV8xLmbtUpMNY/Oi0Ml1XgfR4flOdq+AA=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.8", "", { "os": "win32", "cpu": "x64" }, "sha512-I2czzXTY61f3nFJxXoMDq80t7MivxDEnCjE+8sDKoFfcKMaoQdkqhIFQ3KyY0XLzeSpUBYeNAXgD+iOV/BU0VA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.4", "", { "os": "android", "cpu": "arm" }, "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.4", "", { "os": "android", "cpu": "arm64" }, "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.4", "", { "os": "none", "cpu": "arm64" }, "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/node": ["@types/node@26.6.4", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg=="], + + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + + "@types/ws": ["@types/ws@8.18.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w=="], + + "@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.10", "", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.10", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q=="], + + "@vitest/runner": ["@vitest/runner@4.1.10", "", { "dependencies": { "@vitest/utils": "4.1.10", "pathe": "^2.0.3" } }, "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw=="], + + "@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="], + + "@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="], + + "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], + + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], + + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + + "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], + + "cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + + "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], + + "es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="], + + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + + "happy-dom": ["happy-dom@20.14.5", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ=="], + + "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + + "lint-staged": ["lint-staged@17.0.5", "", { "dependencies": { "listr2": "^10.2.1", "picomatch": "^4.0.4", "string-argv": "^0.3.2", "tinyexec": "^1.1.2" }, "optionalDependencies": { "yaml": "^2.8.4" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-d12yC+/e8RhBjZtaxZn71FyrgU/P5e+uAPifhCLwdosQZP/zamSdKRWDC30ocVIbzDKiFG1McHc/LUgB92GIPw=="], + + "listr2": ["listr2@10.2.1", "", { "dependencies": { "cli-truncate": "^5.2.0", "eventemitter3": "^5.0.4", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^10.0.0" } }, "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q=="], + + "log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + + "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + + "onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="], + + "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], + + "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + + "rollup": ["rollup@4.60.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.4", "@rollup/rollup-android-arm64": "4.60.4", "@rollup/rollup-darwin-arm64": "4.60.4", "@rollup/rollup-darwin-x64": "4.60.4", "@rollup/rollup-freebsd-arm64": "4.60.4", "@rollup/rollup-freebsd-x64": "4.60.4", "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", "@rollup/rollup-linux-arm-musleabihf": "4.60.4", "@rollup/rollup-linux-arm64-gnu": "4.60.4", "@rollup/rollup-linux-arm64-musl": "4.60.4", "@rollup/rollup-linux-loong64-gnu": "4.60.4", "@rollup/rollup-linux-loong64-musl": "4.60.4", "@rollup/rollup-linux-ppc64-gnu": "4.60.4", "@rollup/rollup-linux-ppc64-musl": "4.60.4", "@rollup/rollup-linux-riscv64-gnu": "4.60.4", "@rollup/rollup-linux-riscv64-musl": "4.60.4", "@rollup/rollup-linux-s390x-gnu": "4.60.4", "@rollup/rollup-linux-x64-gnu": "4.60.4", "@rollup/rollup-linux-x64-musl": "4.60.4", "@rollup/rollup-openbsd-x64": "4.60.4", "@rollup/rollup-openharmony-arm64": "4.60.4", "@rollup/rollup-win32-arm64-msvc": "4.60.4", "@rollup/rollup-win32-ia32-msvc": "4.60.4", "@rollup/rollup-win32-x64-gnu": "4.60.4", "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], + + "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], + + "string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], + + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], + + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + + "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + + "vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], + + "vitest": ["vitest@4.1.10", "", { "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@vitest/pretty-format": "4.1.10", "@vitest/runner": "4.1.10", "@vitest/snapshot": "4.1.10", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.10", "@vitest/browser-preview": "4.1.10", "@vitest/browser-webdriverio": "4.1.10", "@vitest/coverage-istanbul": "4.1.10", "@vitest/coverage-v8": "4.1.10", "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw=="], + + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "wrap-ansi": ["wrap-ansi@10.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="], + + "ws": ["ws@8.22.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg=="], + + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + + "log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], + + "log-update/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + + "log-update/wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + } +} diff --git a/apps/ui/docs/assets/refract-ui-badge.png b/apps/ui/docs/assets/refract-ui-badge.png new file mode 100644 index 0000000..0f30ebb Binary files /dev/null and b/apps/ui/docs/assets/refract-ui-badge.png differ diff --git a/apps/ui/docs/assets/refract-ui-blur.png b/apps/ui/docs/assets/refract-ui-blur.png new file mode 100644 index 0000000..3c90f33 Binary files /dev/null and b/apps/ui/docs/assets/refract-ui-blur.png differ diff --git a/apps/ui/docs/assets/refract-ui-card.png b/apps/ui/docs/assets/refract-ui-card.png new file mode 100644 index 0000000..41b0097 Binary files /dev/null and b/apps/ui/docs/assets/refract-ui-card.png differ diff --git a/apps/ui/docs/assets/refract-ui-circle.png b/apps/ui/docs/assets/refract-ui-circle.png new file mode 100644 index 0000000..f74a1c1 Binary files /dev/null and b/apps/ui/docs/assets/refract-ui-circle.png differ diff --git a/apps/ui/docs/assets/refract-ui-dark-og.png b/apps/ui/docs/assets/refract-ui-dark-og.png new file mode 100644 index 0000000..32eda55 Binary files /dev/null and b/apps/ui/docs/assets/refract-ui-dark-og.png differ diff --git a/apps/ui/docs/assets/refract-ui-dark.png b/apps/ui/docs/assets/refract-ui-dark.png new file mode 100644 index 0000000..f0076a2 Binary files /dev/null and b/apps/ui/docs/assets/refract-ui-dark.png differ diff --git a/apps/ui/docs/assets/refract-ui-demo.gif b/apps/ui/docs/assets/refract-ui-demo.gif new file mode 100644 index 0000000..26374c5 Binary files /dev/null and b/apps/ui/docs/assets/refract-ui-demo.gif differ diff --git a/apps/ui/docs/assets/refract-ui-demo.png b/apps/ui/docs/assets/refract-ui-demo.png new file mode 100644 index 0000000..08ddad4 Binary files /dev/null and b/apps/ui/docs/assets/refract-ui-demo.png differ diff --git a/apps/ui/docs/assets/refract-ui-email.png b/apps/ui/docs/assets/refract-ui-email.png new file mode 100644 index 0000000..1e49187 Binary files /dev/null and b/apps/ui/docs/assets/refract-ui-email.png differ diff --git a/apps/ui/docs/assets/refract-ui-favicon.png b/apps/ui/docs/assets/refract-ui-favicon.png new file mode 100644 index 0000000..17c4f50 Binary files /dev/null and b/apps/ui/docs/assets/refract-ui-favicon.png differ diff --git a/apps/ui/docs/assets/refract-ui-github-preview.png b/apps/ui/docs/assets/refract-ui-github-preview.png new file mode 100644 index 0000000..a080ea2 Binary files /dev/null and b/apps/ui/docs/assets/refract-ui-github-preview.png differ diff --git a/apps/ui/docs/assets/refract-ui-github.png b/apps/ui/docs/assets/refract-ui-github.png new file mode 100644 index 0000000..d0ca87f Binary files /dev/null and b/apps/ui/docs/assets/refract-ui-github.png differ diff --git a/apps/ui/docs/assets/refract-ui-header.png b/apps/ui/docs/assets/refract-ui-header.png new file mode 100644 index 0000000..f7796d5 Binary files /dev/null and b/apps/ui/docs/assets/refract-ui-header.png differ diff --git a/apps/ui/docs/assets/refract-ui-mastodon.png b/apps/ui/docs/assets/refract-ui-mastodon.png new file mode 100644 index 0000000..9b7d393 Binary files /dev/null and b/apps/ui/docs/assets/refract-ui-mastodon.png differ diff --git a/apps/ui/docs/assets/refract-ui-og.png b/apps/ui/docs/assets/refract-ui-og.png new file mode 100644 index 0000000..ea2198a Binary files /dev/null and b/apps/ui/docs/assets/refract-ui-og.png differ diff --git a/apps/ui/docs/assets/refract-ui-square.png b/apps/ui/docs/assets/refract-ui-square.png new file mode 100644 index 0000000..505858a Binary files /dev/null and b/apps/ui/docs/assets/refract-ui-square.png differ diff --git a/apps/ui/docs/assets/refract-ui-touch-icon.png b/apps/ui/docs/assets/refract-ui-touch-icon.png new file mode 100644 index 0000000..f1d0fa0 Binary files /dev/null and b/apps/ui/docs/assets/refract-ui-touch-icon.png differ diff --git a/apps/ui/docs/assets/refract-ui-touch.png b/apps/ui/docs/assets/refract-ui-touch.png new file mode 100644 index 0000000..04f68e7 Binary files /dev/null and b/apps/ui/docs/assets/refract-ui-touch.png differ diff --git a/apps/ui/docs/assets/refract-ui-unfurl.png b/apps/ui/docs/assets/refract-ui-unfurl.png new file mode 100644 index 0000000..a94b6ee Binary files /dev/null and b/apps/ui/docs/assets/refract-ui-unfurl.png differ diff --git a/apps/ui/docs/assets/source.svg b/apps/ui/docs/assets/source.svg new file mode 100644 index 0000000..328056a --- /dev/null +++ b/apps/ui/docs/assets/source.svg @@ -0,0 +1,32 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 200" width="400" height="200"> + <title>refract-ui + + + + + + + + + + + + + + + + + + refract-ui + + + + + + React components for agentic knowledge interfaces + \ No newline at end of file diff --git a/apps/ui/index.html b/apps/ui/index.html new file mode 100644 index 0000000..5940a5f --- /dev/null +++ b/apps/ui/index.html @@ -0,0 +1,37 @@ + + + + + + Refract event viewer + + + +
+
+

Refract

+

Event viewer for Refract JSONL exports

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + diff --git a/apps/ui/mise.toml b/apps/ui/mise.toml new file mode 100644 index 0000000..0c607eb --- /dev/null +++ b/apps/ui/mise.toml @@ -0,0 +1,3 @@ +[tools] +bun = "latest" +node = "22" diff --git a/apps/ui/package.json b/apps/ui/package.json new file mode 100644 index 0000000..ec58303 --- /dev/null +++ b/apps/ui/package.json @@ -0,0 +1,36 @@ +{ + "name": "refract-ui", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Standalone visualization UI for Refract — load JSONL, render timelines, diffs, citations", + "license": "AGPL-3.0", + "repository": { + "type": "git", + "url": "git+https://github.com/refract-org/refract-ui.git" + }, + "homepage": "https://github.com/refract-org/refract-ui#readme", + "bugs": { + "url": "https://github.com/refract-org/refract-ui/issues" + }, + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "test": "vitest run", + "lint": "biome check .", + "format": "biome format --write .", + "prepare": "husky", + "assets:check": "bun run scripts/check-assets.ts", + "assets:generate": "bun run scripts/generate-scenes.ts" + }, + "devDependencies": { + "@biomejs/biome": "^2.5.8", + "happy-dom": "^20.14.5", + "husky": "^9.1.7", + "lint-staged": "^17.0.5", + "typescript": "^5.7.0", + "vite": "^6.0.0", + "vitest": "^4.1.10" + } +} diff --git a/apps/ui/public/sample-data.jsonl b/apps/ui/public/sample-data.jsonl new file mode 100644 index 0000000..27708fd --- /dev/null +++ b/apps/ui/public/sample-data.jsonl @@ -0,0 +1,12 @@ +{"eventId":"evt-001","eventType":"sentence_first_seen","claimId":"clm-001","fromRevisionId":1200001,"toRevisionId":1200002,"section":"Lead","before":"","after":"The Earth is the third planet from the Sun.","deterministicFacts":[{"fact":"Claim first appeared in the lead section","detail":"New claim introduced: 'The Earth is the third planet from the Sun.'"}],"layer":"observed","timestamp":"2024-01-15T10:30:00Z"} +{"eventId":"evt-002","eventType":"citation_added","claimId":"clm-001","fromRevisionId":1200002,"toRevisionId":1200003,"section":"Lead","before":"The Earth is the third planet from the Sun.","after":"The Earth is the third planet from the Sun.[1]","deterministicFacts":[{"fact":"Citation [1] added to support claim","detail":"Source: NASA Solar System Fact Sheet"}],"layer":"observed","timestamp":"2024-01-15T11:00:00Z"} +{"eventId":"evt-003","eventType":"sentence_modified","claimId":"clm-001","fromRevisionId":1200003,"toRevisionId":1200005,"section":"Lead","before":"The Earth is the third planet from the Sun.","after":"The Earth is the third planet from the Sun and the only known planet to harbor life.","deterministicFacts":[{"fact":"Claim scope expanded","detail":"Added 'and the only known planet to harbor life'"}],"modelInterpretation":{"semanticChange":"Claim scope expanded to include life-hosting property","confidence":0.88},"layer":"model_interpretation","timestamp":"2024-01-16T09:15:00Z"} +{"eventId":"evt-004","eventType":"citation_replaced","claimId":"clm-001","fromRevisionId":1200005,"toRevisionId":1200007,"section":"Lead","before":"The Earth is the third planet from the Sun and the only known planet to harbor life.[1]","after":"The Earth is the third planet from the Sun and the only known planet to harbor life.[2]","deterministicFacts":[{"fact":"Citation [1] replaced with [2]","detail":"Old: NASA Fact Sheet → New: IPCC Report 2023"}],"layer":"observed","timestamp":"2024-01-17T14:00:00Z"} +{"eventId":"evt-005","eventType":"revert_detected","claimId":"clm-002","fromRevisionId":1200007,"toRevisionId":1200008,"section":"Climate","before":"Global temperatures have risen 1.5\u00b0C above pre-industrial levels.","after":"Global temperatures have risen 0.8\u00b0C above pre-industrial levels.","deterministicFacts":[{"fact":"Revision r1200007 reverted to earlier content","detail":"Temperature claim rolled back from 1.5\u00b0C to 0.8\u00b0C"}],"modelInterpretation":{"semanticChange":"Contested climate statistic","confidence":0.72},"layer":"model_interpretation","timestamp":"2024-01-18T08:30:00Z"} +{"eventId":"evt-006","eventType":"sentence_modified","claimId":"clm-002","fromRevisionId":1200008,"toRevisionId":1200009,"section":"Climate","before":"Global temperatures have risen 0.8\u00b0C above pre-industrial levels.","after":"Global temperatures have risen approximately 0.8\u00b0C above pre-industrial levels.","deterministicFacts":[{"fact":"Claim wording changed","detail":"Added 'approximately' to temperature claim"}],"modelInterpretation":{"semanticChange":"Certainty reduced via hedging","confidence":0.65},"layer":"model_interpretation","timestamp":"2024-01-18T12:00:00Z"} +{"eventId":"evt-007","eventType":"talk_reply_added","fromRevisionId":1200009,"toRevisionId":1200010,"section":"Talk:Climate","before":"","after":"This temperature figure needs a better source. The IPCC report says 1.1\u00b0C. \u2014User123","deterministicFacts":[{"fact":"Reply added to talk page discussing temperature sourcing","detail":"User challenged the accuracy of the temperature claim"}],"layer":"observed","timestamp":"2024-01-18T15:00:00Z"} +{"eventId":"evt-008","eventType":"sentence_modified","claimId":"clm-003","fromRevisionId":1200010,"toRevisionId":1200012,"section":"History","before":"The war began in 1939 when Germany invaded Poland.","after":"The war began in 1939 following the German invasion of Poland.","deterministicFacts":[{"fact":"Claim reworded for neutrality","detail":"Active voice changed to passive construction"}],"modelInterpretation":{"semanticChange":"Wording adjusted for neutral tone","confidence":0.91},"layer":"model_interpretation","timestamp":"2024-01-19T10:00:00Z"} +{"eventId":"evt-009","eventType":"citation_removed","claimId":"clm-003","fromRevisionId":1200012,"toRevisionId":1200013,"section":"History","before":"The war began in 1939 following the German invasion of Poland.[3]","after":"The war began in 1939 following the German invasion of Poland.","deterministicFacts":[{"fact":"Citation [3] removed from claim","detail":"Source was a deprecated blog"}],"layer":"observed","timestamp":"2024-01-19T11:30:00Z"} +{"eventId":"evt-010","eventType":"sentence_removed","claimId":"clm-004","fromRevisionId":1200013,"toRevisionId":1200014,"section":"Economy","before":"The country's GDP grew 12% in 2023 according to unofficial estimates.","after":"","deterministicFacts":[{"fact":"Claim removed from section","detail":"Removed unsourced GDP growth figure"}],"modelInterpretation":{"semanticChange":"Unverified economic statistic removed","confidence":0.78},"layer":"model_interpretation","timestamp":"2024-01-20T09:00:00Z"} +{"eventId":"evt-011","eventType":"citation_added","claimId":"clm-001","fromRevisionId":1200014,"toRevisionId":1200015,"section":"Lead","before":"The Earth is the third planet from the Sun and the only known planet to harbor life.[2]","after":"The Earth is the third planet from the Sun and the only known planet to harbor life.[2][3]","deterministicFacts":[{"fact":"Additional citation [3] added to support life-harboring claim","detail":"Source: Encyclopedia of Astrobiology"}],"layer":"observed","timestamp":"2024-01-21T16:00:00Z"} +{"eventId":"evt-012","eventType":"talk_thread_opened","fromRevisionId":1200015,"toRevisionId":1200016,"section":"Talk:Earth","before":"","after":"== Citation quality ==\nThe sources in the lead need review. [3] is from a predatory journal. \u2014EditorXYZ","deterministicFacts":[{"fact":"New talk thread about citation quality","detail":"Discussion about potential unreliable source in lead section"}],"layer":"observed","timestamp":"2024-01-22T08:00:00Z"} diff --git a/apps/ui/scripts/check-assets.ts b/apps/ui/scripts/check-assets.ts new file mode 100644 index 0000000..ef68616 --- /dev/null +++ b/apps/ui/scripts/check-assets.ts @@ -0,0 +1,172 @@ +#!/usr/bin/env bun +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { basename, join } from "node:path"; + +const ASSETS_DIR = join( + import.meta.dirname ?? __dirname, + "..", + "docs", + "assets", +); + +const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + +type Spec = { width: number; height: number; minBytes: number }; + +const SPECS: Record = { + badge: { width: 240, height: 96, minBytes: 2_000 }, + blur: { width: 50, height: 28, minBytes: 300 }, + card: { width: 400, height: 300, minBytes: 7_000 }, + circle: { width: 540, height: 540, minBytes: 8_000 }, + dark: { width: 960, height: 540, minBytes: 10_000 }, + demo: { width: 720, height: 405, minBytes: 12_000 }, + email: { width: 600, height: 200, minBytes: 5_000 }, + favicon: { width: 64, height: 64, minBytes: 400 }, + github: { width: 1280, height: 640, minBytes: 15_000 }, + header: { width: 1920, height: 400, minBytes: 12_000 }, + mastodon: { width: 1200, height: 600, minBytes: 15_000 }, + og: { width: 1200, height: 675, minBytes: 15_000 }, + square: { width: 1080, height: 1080, minBytes: 15_000 }, + touch: { width: 360, height: 360, minBytes: 8_000 }, + unfurl: { width: 1200, height: 628, minBytes: 15_000 }, +}; + +const COLOR_TYPE_LABELS: Record = { + 0: "grayscale", + 2: "RGB", + 3: "indexed", + 4: "grayscale+alpha", + 6: "RGBA", +}; + +type PngInfo = { + width: number; + height: number; + colorType: number; + bitDepth: number; + byteLength: number; +}; + +function readPngInfo(filePath: string): PngInfo { + const buf = readFileSync(filePath); + if (buf.length < 33) throw new Error("file too small for valid PNG"); + if (!buf.subarray(0, 8).equals(PNG_SIGNATURE)) + throw new Error("not a valid PNG"); + return { + width: buf.readUInt32BE(16), + height: buf.readUInt32BE(20), + colorType: buf.readUInt8(25), + bitDepth: buf.readUInt8(24), + byteLength: buf.length, + }; +} + +function main() { + const files = readdirSync(ASSETS_DIR) + .filter((f) => f.endsWith(".png")) + .sort(); + if (files.length === 0) { + console.error(`FAIL: no PNGs found in ${ASSETS_DIR}`); + process.exit(1); + } + + const errors: string[] = []; + const found = new Set(); + const filesWithSizes = files.map((f) => ({ + name: f, + size: statSync(join(ASSETS_DIR, f)).size, + })); + + const prefix = basename(process.cwd()); + console.log(`\n🔍 Checking assets for «${prefix}» in ${ASSETS_DIR}\n`); + + for (const { name, size } of filesWithSizes) { + const filePath = join(ASSETS_DIR, name); + let typeKey = name + .replace(/\.png$/, "") + .replace(new RegExp(`^${prefix}-`), ""); + + if (typeKey.startsWith("dark-og")) typeKey = "dark"; + else if (typeKey.startsWith("touch")) typeKey = "touch"; + else if (typeKey.startsWith("github-preview")) typeKey = "github"; + + const spec = SPECS[typeKey]; + if (!spec) { + try { + const info = readPngInfo(filePath); + found.add(typeKey); + console.log( + ` ⚠ ${name} (${info.width}×${info.height}) — no spec, skipped`, + ); + } catch (e: unknown) { + errors.push(`${name}: invalid PNG — ${(e as Error).message}`); + } + continue; + } + + found.add(typeKey); + try { + const info = readPngInfo(filePath); + + if (info.width !== spec.width || info.height !== spec.height) { + errors.push( + `${name}: expected ${spec.width}×${spec.height}, got ${info.width}×${info.height}`, + ); + continue; + } + if (info.colorType === 3) { + errors.push(`${name}: indexed color (type 3) — posterized`); + continue; + } + if (info.bitDepth < 8) { + errors.push(`${name}: low bit depth (${info.bitDepth}-bit)`); + continue; + } + if (size < spec.minBytes) { + errors.push( + `${name}: ${size}B < ${spec.minBytes}B min — possible posterization or truncation`, + ); + continue; + } + + const label = + COLOR_TYPE_LABELS[info.colorType] ?? `type${info.colorType}`; + console.log( + ` ✅ ${name} ${info.width}×${info.height} ${size.toLocaleString()}B ${label}`, + ); + } catch (e: unknown) { + errors.push(`${name}: ${(e as Error).message}`); + } + } + + const svgPath = join(ASSETS_DIR, "source.svg"); + try { + const svg = readFileSync(svgPath, "utf8").trim(); + if (svg.startsWith(" root)"); + } + } catch { + errors.push("source.svg: missing — design source file required"); + } + + const expectedTypes = Object.keys(SPECS); + const missing = expectedTypes.filter((t) => !found.has(t)); + if (missing.length > 0) { + console.log(`\n ⚠ Missing types: ${missing.join(", ")}`); + } + + console.log( + `\n${errors.length === 0 ? "✅ All assets valid" : "❌ FAILURES:"} (${files.length} PNGs, 1 SVG checked)\n`, + ); + + if (errors.length > 0) { + for (const err of errors) console.error(` ❌ ${err}`); + process.exit(1); + } +} + +main(); diff --git a/apps/ui/scripts/generate-scenes.ts b/apps/ui/scripts/generate-scenes.ts new file mode 100644 index 0000000..9c4b83f --- /dev/null +++ b/apps/ui/scripts/generate-scenes.ts @@ -0,0 +1,240 @@ +#!/usr/bin/env bun + +/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, no-empty */ +/** + * generate-scenes.ts — renders source.svg into scene-specific PNGs with variation. + * Usage: bun run scripts/generate-scenes.ts [--dry-run] + */ + +import { execSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + readFileSync, + statSync, + writeFileSync, +} from "node:fs"; +import { basename, join } from "node:path"; + +const ASSETS_DIR = join(process.cwd(), "docs", "assets"); +const SVG_PATH = join(ASSETS_DIR, "source.svg"); +const PROJECT = basename(process.cwd()); + +type SceneSpec = { + width: number; + height: number; + minBytes: number; + bg: string; + gradient?: boolean; + center?: boolean; + scale?: number; +}; + +const SCENES: Record = { + badge: { width: 240, height: 96, minBytes: 2_000, bg: "#1a1a2e", scale: 0.8 }, + blur: { width: 50, height: 28, minBytes: 300, bg: "#1a1a2e", scale: 0.5 }, + card: { + width: 400, + height: 300, + minBytes: 8_000, + bg: "#16213e", + center: true, + scale: 0.7, + }, + circle: { + width: 540, + height: 540, + minBytes: 8_000, + bg: "#0f3460", + center: true, + gradient: true, + }, + dark: { + width: 960, + height: 540, + minBytes: 10_000, + bg: "#0a0a0a", + gradient: true, + }, + demo: { + width: 720, + height: 405, + minBytes: 12_000, + bg: "#1a1a2e", + gradient: true, + }, + email: { width: 600, height: 200, minBytes: 5_000, bg: "#16213e" }, + favicon: { width: 64, height: 64, minBytes: 600, bg: "#e94560", scale: 0.95 }, + github: { + width: 1280, + height: 640, + minBytes: 15_000, + bg: "#1a1a2e", + gradient: true, + }, + header: { + width: 1920, + height: 400, + minBytes: 12_000, + bg: "#1a1a2e", + gradient: true, + }, + mastodon: { + width: 1200, + height: 600, + minBytes: 15_000, + bg: "#16213e", + gradient: true, + }, + og: { + width: 1200, + height: 675, + minBytes: 15_000, + bg: "#1a1a2e", + gradient: true, + }, + square: { + width: 1080, + height: 1080, + minBytes: 15_000, + bg: "#0f3460", + center: true, + gradient: true, + }, + touch: { + width: 360, + height: 360, + minBytes: 8_000, + bg: "#16213e", + center: true, + }, + unfurl: { + width: 1200, + height: 628, + minBytes: 15_000, + bg: "#1a1a2e", + gradient: true, + }, +}; + +function hueFromProject(name: string): number { + let hash = 0; + for (let i = 0; i < name.length; i++) + hash = (hash << 5) - hash + name.charCodeAt(i); + return Math.abs(hash) % 360; +} + +function rotateHex(hex: string, hue: number): string { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + const f = (hue / 360) * 0.25 + 0.875; // 0.875-1.125 + const nr = Math.min(255, Math.round(r * f)); + const ng = Math.min(255, Math.round(g * (1 + ((hue % 40) - 20) / 200))); + const nb = Math.min( + 255, + Math.round(b * (1 + (((hue + 120) % 40) - 20) / 200)), + ); + return `#${nr.toString(16).padStart(2, "0")}${ng.toString(16).padStart(2, "0")}${nb.toString(16).padStart(2, "0")}`; +} + +function buildOverlay(spec: SceneSpec, hue: number): string { + const bg = rotateHex(spec.bg, hue); + const { width, height, gradient } = spec; + let svg = ``; + svg += ``; + + if (gradient) { + svg += ` + + + `; + } + + const fs = Math.max(10, Math.min(width, height) * 0.025); + svg += `${PROJECT}`; + return `${svg}`; +} + +function main() { + const dryRun = process.argv.includes("--dry-run"); + + if (!existsSync(SVG_PATH)) { + console.error(`❌ No source.svg at ${SVG_PATH}`); + process.exit(1); + } + + const svgRaw = readFileSync(SVG_PATH, "utf-8"); + const innerMatch = svgRaw.match(/]*>([\s\S]*?)<\/svg>/); + const innerSvg = innerMatch ? innerMatch[1] : svgRaw; // Inner content only, no wrapper + + mkdirSync(ASSETS_DIR, { recursive: true }); + const hue = hueFromProject(PROJECT); + + let ok = 0, + warn = 0, + err = 0; + + for (const [scene, spec] of Object.entries(SCENES)) { + const outPath = join(ASSETS_DIR, `${PROJECT}-${scene}.png`); + const overlayPath = join(ASSETS_DIR, `_${scene}_overlay.svg`); + const compPath = join(ASSETS_DIR, `_${scene}_comp.svg`); + + if (dryRun) { + console.log(` [dry] ${scene}: ${spec.width}×${spec.height} ${spec.bg}`); + continue; + } + + try { + // 1. Build overlay SVG with per-project hue variation + const overlay = buildOverlay(spec, hue); + writeFileSync(overlayPath, overlay); + + // 2. Build composed SVG: overlay background + scaled source.svg content + const scale = spec.scale ?? 0.8; + const sw = Math.round(spec.width * scale); + const sh = Math.round(spec.height * scale); + const sx = spec.center + ? Math.round((spec.width - sw) / 2) + : Math.round(spec.width * 0.05); + const sy = spec.center + ? Math.round((spec.height - sh) / 2) + : Math.round(spec.height * 0.05); + + // Render source content ON TOP of overlay + const composed = overlay.replace( + "", + `\n ${innerSvg}\n`, + ); + // Also add a subtle pattern for depth + writeFileSync(compPath, composed); + + // 3. Render via rsvg-convert + execSync( + `rsvg-convert -w ${spec.width} -h ${spec.height} -o "${outPath}" "${compPath}"`, + { stdio: "pipe", timeout: 10000 }, + ); + + // 4. Verify + const sz = statSync(outPath).size; + if (sz < spec.minBytes / 2) { + console.log(` ⚠️ ${scene}: ${sz}B (below ${spec.minBytes}B)`); + warn++; + } else { + ok++; + } + } catch (e: any) { + console.error(` ❌ ${scene}: ${e.stderr || e.message}`); + err++; + } finally { + try { + execSync(`rm -f "${overlayPath}" "${compPath}"`); + } catch {} + } + } + + console.log(`\n${dryRun ? "DRY RUN " : ""}✅ ${ok} ⚠️ ${warn} ❌ ${err}`); + if (err > 0) process.exit(1); +} + +main(); diff --git a/apps/ui/src/citations/CitationChurnChart.ts b/apps/ui/src/citations/CitationChurnChart.ts new file mode 100644 index 0000000..b093e25 --- /dev/null +++ b/apps/ui/src/citations/CitationChurnChart.ts @@ -0,0 +1,136 @@ +import type { EvidenceEvent } from "../types.js"; + +interface ChurnData { + revId: number; + added: number; + removed: number; + replaced: number; + label: string; +} + +export class CitationChurnChart { + private container: HTMLElement; + private events: EvidenceEvent[] = []; + + constructor(container: HTMLElement) { + this.container = container; + } + + setData(events: EvidenceEvent[]): void { + this.events = events; + this.render(); + } + + render(): void { + this.container.innerHTML = ""; + + const title = document.createElement("div"); + title.className = "panel-title"; + title.textContent = "Citation churn"; + this.container.appendChild(title); + + const churnData = this.computeChurn(); + + if (churnData.length === 0) { + const empty = document.createElement("div"); + empty.style.cssText = + "color:var(--text-dim);font-size:0.85rem;padding:1rem 0;"; + empty.textContent = "No citation events found."; + this.container.appendChild(empty); + return; + } + + const maxVal = Math.max( + ...churnData.map((d) => Math.max(d.added, d.removed, d.replaced)), + 1, + ); + + const chart = document.createElement("div"); + chart.className = "bar-chart"; + + for (const point of churnData) { + const group = document.createElement("div"); + group.className = "bar-group"; + + const addedBar = createBar(point.added, maxVal, "added"); + const removedBar = createBar(point.removed, maxVal, "removed"); + const replacedBar = createBar(point.replaced, maxVal, "replaced", true); + + group.appendChild(addedBar); + group.appendChild(removedBar); + group.appendChild(replacedBar); + + chart.appendChild(group); + } + + this.container.appendChild(chart); + + const legend = document.createElement("div"); + legend.style.cssText = + "display:flex;gap:1rem;font-size:0.75rem;color:var(--text-dim);margin-top:0.25rem;"; + + const addLegend = createLegendItem("Added", "var(--green)"); + const remLegend = createLegendItem("Removed", "var(--red)"); + const repLegend = createLegendItem("Replaced", "var(--orange)"); + legend.append(addLegend, remLegend, repLegend); + this.container.appendChild(legend); + } + + private computeChurn(): ChurnData[] { + const citationEvents = this.events.filter( + (e) => + e.eventType === "citation_added" || + e.eventType === "citation_removed" || + e.eventType === "citation_replaced", + ); + + const grouped = new Map(); + + for (const event of citationEvents) { + let entry = grouped.get(event.toRevisionId); + if (!entry) { + entry = { + revId: event.toRevisionId, + added: 0, + removed: 0, + replaced: 0, + label: `r${event.toRevisionId}`, + }; + grouped.set(event.toRevisionId, entry); + } + + if (event.eventType === "citation_added") entry.added++; + else if (event.eventType === "citation_removed") entry.removed++; + else if (event.eventType === "citation_replaced") entry.replaced++; + } + + return Array.from(grouped.values()).sort((a, b) => a.revId - b.revId); + } +} + +function createBar( + value: number, + max: number, + cls: string, + isReplaced = false, +): HTMLElement { + const bar = document.createElement("div"); + bar.className = `bar ${cls}`; + const pct = max > 0 ? (value / max) * 100 : 0; + bar.style.height = `${Math.max(pct, value > 0 ? 3 : 0)}%`; + if (isReplaced) { + bar.style.background = "var(--orange)"; + } + bar.title = `${cls}: ${value}`; + return bar; +} + +function createLegendItem(label: string, color: string): HTMLElement { + const item = document.createElement("span"); + item.style.cssText = "display:flex;align-items:center;gap:0.3rem;"; + const dot = document.createElement("span"); + dot.style.cssText = `display:inline-block;width:8px;height:8px;border-radius:2px;background:${color};`; + item.appendChild(dot); + item.appendChild(document.createTextNode(label)); + return item; +} diff --git a/apps/ui/src/citations/SourceChangeTable.ts b/apps/ui/src/citations/SourceChangeTable.ts new file mode 100644 index 0000000..c34acad --- /dev/null +++ b/apps/ui/src/citations/SourceChangeTable.ts @@ -0,0 +1,100 @@ +import type { EvidenceEvent } from "../types.js"; + +interface SourceChange { + revisionId: number; + timestamp: string; + section: string; + changeType: string; + detail: string; +} + +export class SourceChangeTable { + private container: HTMLElement; + private events: EvidenceEvent[] = []; + + constructor(container: HTMLElement) { + this.container = container; + } + + setData(events: EvidenceEvent[]): void { + this.events = events; + this.render(); + } + + render(): void { + this.container.innerHTML = ""; + + const title = document.createElement("div"); + title.className = "panel-title"; + title.textContent = "Source changes"; + this.container.appendChild(title); + + const changes = this.computeChanges(); + + if (changes.length === 0) { + const empty = document.createElement("div"); + empty.style.cssText = + "color:var(--text-dim);font-size:0.85rem;padding:1rem 0;"; + empty.textContent = "No source/citation changes found."; + this.container.appendChild(empty); + return; + } + + const table = document.createElement("table"); + table.className = "data-table source-table"; + + // Detail is last so it takes the width the short columns leave. The + // revision id is on each row's tooltip rather than in a column of its own. + const thead = document.createElement("thead"); + thead.innerHTML = ` + + Date + Type + Section + Detail + + `; + table.appendChild(thead); + + const tbody = document.createElement("tbody"); + for (const ch of changes) { + const tr = document.createElement("tr"); + tr.title = `Revision r${ch.revisionId}`; + const ts = new Date(ch.timestamp); + // textContent, not innerHTML: section and detail come from the loaded file. + for (const text of [ + ts.toLocaleDateString(), + ch.changeType, + ch.section, + ch.detail, + ]) { + const td = document.createElement("td"); + td.textContent = text; + tr.appendChild(td); + } + tbody.appendChild(tr); + } + table.appendChild(tbody); + this.container.appendChild(table); + } + + private computeChanges(): SourceChange[] { + const citationEvents = this.events.filter( + (e) => + e.eventType === "citation_added" || + e.eventType === "citation_removed" || + e.eventType === "citation_replaced", + ); + + return citationEvents.map((e) => ({ + revisionId: e.toRevisionId, + timestamp: e.timestamp, + section: e.section, + changeType: e.eventType.replace("citation_", ""), + detail: + e.deterministicFacts.length > 0 + ? e.deterministicFacts[0].fact + : e.eventType, + })); + } +} diff --git a/apps/ui/src/disputes/EditVelocityChart.ts b/apps/ui/src/disputes/EditVelocityChart.ts new file mode 100644 index 0000000..1f954bb --- /dev/null +++ b/apps/ui/src/disputes/EditVelocityChart.ts @@ -0,0 +1,143 @@ +import type { EvidenceEvent } from "../types.js"; + +interface VelocityBucket { + date: string; + count: number; + reverts: number; + label: string; +} + +export class EditVelocityChart { + private container: HTMLElement; + private events: EvidenceEvent[] = []; + + constructor(container: HTMLElement) { + this.container = container; + } + + setData(events: EvidenceEvent[]): void { + this.events = events; + this.render(); + } + + render(): void { + this.container.innerHTML = ""; + + const title = document.createElement("div"); + title.className = "panel-title"; + title.textContent = "Events per day"; + this.container.appendChild(title); + + const buckets = this.computeBuckets(); + + if (buckets.length === 0) { + const empty = document.createElement("div"); + empty.style.cssText = + "color:var(--text-dim);font-size:0.85rem;padding:1rem 0;"; + empty.textContent = "No events to chart."; + this.container.appendChild(empty); + return; + } + + const maxCount = Math.max( + ...buckets.map((b) => Math.max(b.count, b.reverts)), + 1, + ); + + const chart = document.createElement("div"); + chart.className = "bar-chart"; + + for (const bucket of buckets) { + const group = document.createElement("div"); + group.className = "bar-group"; + + const countBar = document.createElement("div"); + countBar.className = "bar added"; + countBar.style.height = `${(bucket.count / maxCount) * 100}%`; + countBar.title = `Events: ${bucket.count}`; + group.appendChild(countBar); + + const revertBar = document.createElement("div"); + revertBar.className = "bar removed"; + revertBar.style.height = `${(bucket.reverts / maxCount) * 100}%`; + revertBar.title = `Reverts: ${bucket.reverts}`; + group.appendChild(revertBar); + + chart.appendChild(group); + } + + this.container.appendChild(chart); + + const xLabels = document.createElement("div"); + xLabels.style.cssText = + "display:flex;justify-content:space-between;font-size:0.65rem;color:var(--text-dim);margin-top:0.25rem;"; + + const step = Math.max(1, Math.floor(buckets.length / 6)); + for (let i = 0; i < buckets.length; i += step) { + const label = document.createElement("span"); + label.textContent = buckets[i].label; + xLabels.appendChild(label); + } + if (buckets.length > 0) { + const lastLabel = document.createElement("span"); + lastLabel.textContent = buckets[buckets.length - 1].label; + xLabels.appendChild(lastLabel); + } + + this.container.appendChild(xLabels); + + const legend = document.createElement("div"); + legend.style.cssText = + "display:flex;gap:1rem;font-size:0.75rem;color:var(--text-dim);margin-top:0.25rem;"; + const allLegend = createBarLegend("All events", "var(--green)"); + const revertLegend = createBarLegend("Reverts", "var(--red)"); + legend.append(allLegend, revertLegend); + this.container.appendChild(legend); + } + + private computeBuckets(): VelocityBucket[] { + if (this.events.length === 0) return []; + + const sorted = [...this.events].sort( + (a, b) => + new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), + ); + + const buckets = new Map(); + + for (const event of sorted) { + const d = new Date(event.timestamp); + const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + + let bucket = buckets.get(key); + if (!bucket) { + bucket = { + date: key, + count: 0, + reverts: 0, + label: `${d.getMonth() + 1}/${d.getDate()}`, + }; + buckets.set(key, bucket); + } + + bucket.count++; + if (event.eventType === "revert_detected") { + bucket.reverts++; + } + } + + return Array.from(buckets.values()).sort((a, b) => + a.date.localeCompare(b.date), + ); + } +} + +function createBarLegend(label: string, color: string): HTMLElement { + const item = document.createElement("span"); + item.style.cssText = "display:flex;align-items:center;gap:0.3rem;"; + const dot = document.createElement("span"); + dot.style.cssText = `display:inline-block;width:8px;height:8px;border-radius:2px;background:${color};`; + item.appendChild(dot); + item.appendChild(document.createTextNode(label)); + return item; +} diff --git a/apps/ui/src/disputes/RevertClusterDisplay.ts b/apps/ui/src/disputes/RevertClusterDisplay.ts new file mode 100644 index 0000000..1854120 --- /dev/null +++ b/apps/ui/src/disputes/RevertClusterDisplay.ts @@ -0,0 +1,124 @@ +import type { EvidenceEvent } from "../types.js"; + +interface RevertCluster { + revisionId: number; + timestamp: string; + section: string; + facts: string[]; + count: number; +} + +export class RevertClusterDisplay { + private container: HTMLElement; + private events: EvidenceEvent[] = []; + + constructor(container: HTMLElement) { + this.container = container; + } + + setData(events: EvidenceEvent[]): void { + this.events = events; + this.render(); + } + + render(): void { + this.container.innerHTML = ""; + + const title = document.createElement("div"); + title.className = "panel-title"; + title.textContent = "Revert clusters"; + this.container.appendChild(title); + + const clusters = this.computeClusters(); + + if (clusters.length === 0) { + const empty = document.createElement("div"); + empty.style.cssText = + "color:var(--text-dim);font-size:0.85rem;padding:1rem 0;"; + empty.textContent = "No revert events detected."; + this.container.appendChild(empty); + return; + } + + for (const cluster of clusters) { + const card = document.createElement("div"); + card.className = "card"; + card.style.marginBottom = "0.5rem"; + + const header = document.createElement("div"); + header.style.cssText = + "display:flex;justify-content:space-between;align-items:center;margin-bottom:0.3rem;"; + + const label = document.createElement("div"); + label.className = "card-label"; + const ts = new Date(cluster.timestamp); + label.textContent = `r${cluster.revisionId} · ${ts.toLocaleDateString()}`; + header.appendChild(label); + + const badge = document.createElement("span"); + badge.style.cssText = + "background:var(--red);color:#fff;font-size:0.7rem;padding:0.1rem 0.4rem;border-radius:4px;font-weight:600;"; + badge.textContent = `${cluster.count} revert${cluster.count > 1 ? "s" : ""}`; + header.appendChild(badge); + + card.appendChild(header); + + if (cluster.section) { + const section = document.createElement("div"); + section.style.cssText = + "font-size:0.78rem;color:var(--text-dim);margin-bottom:0.3rem;font-family:var(--mono);"; + section.textContent = cluster.section; + card.appendChild(section); + } + + for (const fact of cluster.facts) { + const factEl = document.createElement("div"); + factEl.style.cssText = + "font-size:0.8rem;padding:0.2rem 0;border-left:2px solid var(--red);padding-left:0.5rem;margin-top:0.2rem;"; + factEl.textContent = fact; + card.appendChild(factEl); + } + + this.container.appendChild(card); + } + } + + private computeClusters(): RevertCluster[] { + const revertEvents = this.events.filter( + (e) => e.eventType === "revert_detected", + ); + + const grouped = new Map(); + + for (const event of revertEvents) { + const key = `${event.toRevisionId}-${event.section}`; + let cluster = grouped.get(key); + if (!cluster) { + cluster = { + revisionId: event.toRevisionId, + timestamp: event.timestamp, + section: event.section, + facts: [], + count: 0, + }; + grouped.set(key, cluster); + } + + cluster.count++; + for (const fact of event.deterministicFacts) { + if (!cluster.facts.includes(fact.fact)) { + cluster.facts.push(fact.fact); + } + } + + if (event.timestamp > cluster.timestamp) { + cluster.timestamp = event.timestamp; + } + } + + return Array.from(grouped.values()).sort( + (a, b) => + new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(), + ); + } +} diff --git a/apps/ui/src/disputes/TalkPageTimeline.ts b/apps/ui/src/disputes/TalkPageTimeline.ts new file mode 100644 index 0000000..a6c569c --- /dev/null +++ b/apps/ui/src/disputes/TalkPageTimeline.ts @@ -0,0 +1,91 @@ +import type { EvidenceEvent } from "../types.js"; + +interface TalkActivity { + revisionId: number; + timestamp: string; + eventType: string; + summary: string; +} + +export class TalkPageTimeline { + private container: HTMLElement; + private events: EvidenceEvent[] = []; + + constructor(container: HTMLElement) { + this.container = container; + } + + setData(events: EvidenceEvent[]): void { + this.events = events; + this.render(); + } + + render(): void { + this.container.innerHTML = ""; + + const title = document.createElement("div"); + title.className = "panel-title"; + title.textContent = "Talk page activity"; + this.container.appendChild(title); + + const activities = this.computeActivities(); + + if (activities.length === 0) { + const empty = document.createElement("div"); + empty.style.cssText = + "color:var(--text-dim);font-size:0.85rem;padding:1rem 0;"; + empty.textContent = "No talk page events found."; + this.container.appendChild(empty); + return; + } + + const timeline = document.createElement("div"); + timeline.className = "timeline"; + + for (const act of activities) { + const item = document.createElement("div"); + item.className = "timeline-item"; + + const typeLabel = document.createElement("div"); + typeLabel.className = "event-type"; + typeLabel.textContent = act.eventType.replace(/_/g, " "); + item.appendChild(typeLabel); + + const summary = document.createElement("div"); + summary.className = "event-summary"; + summary.textContent = act.summary; + item.appendChild(summary); + + const meta = document.createElement("div"); + meta.className = "event-meta"; + const ts = new Date(act.timestamp); + meta.textContent = `${ts.toLocaleDateString()} ${ts.toLocaleTimeString()} · r${act.revisionId}`; + item.appendChild(meta); + + timeline.appendChild(item); + } + + this.container.appendChild(timeline); + } + + private computeActivities(): TalkActivity[] { + const talkTypes = new Set([ + "talk_page_correlated", + "talk_thread_opened", + "talk_thread_archived", + "talk_reply_added", + ]); + + return this.events + .filter((e) => talkTypes.has(e.eventType)) + .map((e) => ({ + revisionId: e.toRevisionId, + timestamp: e.timestamp, + eventType: e.eventType, + summary: + e.deterministicFacts.length > 0 + ? e.deterministicFacts[0].fact + : (e.modelInterpretation?.semanticChange ?? e.eventType), + })); + } +} diff --git a/apps/ui/src/export/ImportUpload.ts b/apps/ui/src/export/ImportUpload.ts new file mode 100644 index 0000000..0849039 --- /dev/null +++ b/apps/ui/src/export/ImportUpload.ts @@ -0,0 +1,191 @@ +import type { EvidenceEvent } from "../types.js"; + +const VALID_EVENT_TYPES = new Set([ + "sentence_first_seen", + "sentence_removed", + "sentence_modified", + "sentence_reintroduced", + "citation_added", + "citation_removed", + "citation_replaced", + "template_added", + "template_removed", + "template_parameter_changed", + "revert_detected", + "edit_cluster_detected", + "talk_activity_spike", + "section_reorganized", + "lead_promotion", + "lead_demotion", + "page_moved", + "wikilink_added", + "wikilink_removed", + "category_added", + "category_removed", + "protection_changed", + "talk_page_correlated", + "talk_thread_opened", + "talk_thread_archived", + "talk_reply_added", +]); + +const VALID_LAYERS = new Set([ + "observed", + "policy_coded", + "model_interpretation", + "speculative", + "unknown", +]); + +export class ImportUpload { + private container: HTMLElement; + private onData: (events: EvidenceEvent[]) => void; + + constructor( + container: HTMLElement, + onData: (events: EvidenceEvent[]) => void, + ) { + this.container = container; + this.onData = onData; + this.render(); + } + + private render(): void { + this.container.innerHTML = ""; + + const uploadZone = document.createElement("div"); + uploadZone.className = "upload-zone"; + uploadZone.textContent = "Upload JSONL"; + + const fileInput = document.createElement("input"); + fileInput.type = "file"; + fileInput.accept = ".jsonl,.json"; + fileInput.style.display = "none"; + + uploadZone.addEventListener("click", () => fileInput.click()); + uploadZone.addEventListener("dragover", (e) => { + e.preventDefault(); + uploadZone.style.borderColor = "var(--accent)"; + }); + uploadZone.addEventListener("dragleave", () => { + uploadZone.style.borderColor = ""; + }); + uploadZone.addEventListener("drop", (e) => { + e.preventDefault(); + uploadZone.style.borderColor = ""; + const files = e.dataTransfer?.files; + if (files && files.length > 0) { + this.handleFile(files[0]); + } + }); + + fileInput.addEventListener("change", () => { + const file = fileInput.files?.[0]; + if (file) { + this.handleFile(file); + } + }); + + this.container.appendChild(uploadZone); + this.container.appendChild(fileInput); + + const status = document.createElement("div"); + status.id = "import-status"; + status.style.cssText = "font-size:0.75rem;color:var(--text-dim);"; + this.container.appendChild(status); + } + + private async handleFile(file: File): Promise { + const status = document.getElementById("import-status"); + if (!status) return; + + try { + const text = await file.text(); + const lines = text.split("\n").filter((l) => l.trim().length > 0); + const events: EvidenceEvent[] = []; + const errors: string[] = []; + + for (let i = 0; i < lines.length; i++) { + try { + const parsed = JSON.parse(lines[i]); + const validation = this.validateEvent(parsed); + if (validation.valid) { + events.push(parsed as EvidenceEvent); + } else { + errors.push(`Line ${i + 1}: ${validation.error}`); + } + } catch { + errors.push(`Line ${i + 1}: Invalid JSON`); + } + } + + if (events.length > 0) { + status.textContent = `Imported ${events.length} events${errors.length > 0 ? ` (${errors.length} errors)` : ""}`; + status.style.color = "var(--green)"; + this.onData(events); + } else { + status.textContent = "No valid events found."; + status.style.color = "var(--red)"; + } + + if (errors.length > 0) { + const errorList = document.createElement("div"); + errorList.style.cssText = `margin-top:0.5rem;padding:0.75rem;background:var(--red-soft);border-radius:var(--radius-sm);font-size:0.72rem;color:var(--red);max-height:8rem;overflow-y:auto;white-space:pre-wrap;line-height:1.5;`; + errorList.textContent = errors.slice(0, 10).join("\n"); + if (errors.length > 10) { + errorList.textContent += `\n\u2026 and ${errors.length - 10} more errors`; + } + status.appendChild(errorList); + } + } catch (err) { + status.textContent = `Error reading file: ${err}`; + status.style.color = "var(--red)"; + } + } + + private validateEvent(obj: unknown): { valid: boolean; error?: string } { + if (!obj || typeof obj !== "object") { + return { valid: false, error: "Not an object" }; + } + + const e = obj as Record; + + if ( + typeof e.eventType !== "string" || + !VALID_EVENT_TYPES.has(e.eventType) + ) { + return { + valid: false, + error: `Invalid or missing eventType: ${e.eventType}`, + }; + } + + if ( + typeof e.fromRevisionId !== "number" || + typeof e.toRevisionId !== "number" + ) { + return { valid: false, error: "Missing fromRevisionId or toRevisionId" }; + } + + if ( + typeof e.timestamp !== "string" || + Number.isNaN(new Date(e.timestamp).getTime()) + ) { + return { valid: false, error: "Invalid or missing timestamp" }; + } + + if (typeof e.section !== "string") { + return { valid: false, error: "Missing section" }; + } + + if (typeof e.before !== "string" || typeof e.after !== "string") { + return { valid: false, error: "Missing before or after text" }; + } + + if (e.layer && typeof e.layer === "string" && !VALID_LAYERS.has(e.layer)) { + return { valid: false, error: `Invalid layer: ${e.layer}` }; + } + + return { valid: true }; + } +} diff --git a/apps/ui/src/export/SchemaViewer.ts b/apps/ui/src/export/SchemaViewer.ts new file mode 100644 index 0000000..942540f --- /dev/null +++ b/apps/ui/src/export/SchemaViewer.ts @@ -0,0 +1,115 @@ +export class SchemaViewer { + private container: HTMLElement; + private events: Record[] = []; + + constructor(container: HTMLElement) { + this.container = container; + this.render(); + } + + setData(events: Record[]): void { + this.events = events; + this.render(); + } + + render(): void { + this.container.innerHTML = ""; + + const title = document.createElement("div"); + title.className = "panel-title"; + title.textContent = "Event schema"; + this.container.appendChild(title); + + if (this.events.length === 0) { + const empty = document.createElement("div"); + empty.style.cssText = + "color:var(--text-dim);font-size:0.85rem;padding:1rem 0;"; + empty.textContent = "Upload data to inspect schema."; + this.container.appendChild(empty); + return; + } + + const keys = this.collectKeys(); + const infos = new Map(keys.map((key) => [key, this.getTypeInfo(key)])); + const allPresent = [...infos.values()].every((i) => i.inEveryEvent); + + const schema = document.createElement("div"); + schema.className = "schema-viewer"; + + // Presence is stated once for the fields every event carries; only the + // fields some events lack show their share. + const headerEl = document.createElement("div"); + headerEl.style.cssText = + "color:var(--text-dim);margin-bottom:0.5rem;font-weight:600;"; + headerEl.textContent = `${this.events.length} events, ${keys.length} fields. ${ + allPresent + ? "Every field is present in every event." + : "Fields without a percentage are present in every event." + }`; + schema.appendChild(headerEl); + + // One grid for every field, so name, type and presence line up across + // rows whether or not a row has a presence cell. + const fields = document.createElement("div"); + fields.className = "schema-fields"; + + for (const [key, typeInfo] of infos) { + const keyName = document.createElement("span"); + keyName.className = "schema-key"; + keyName.textContent = key; + fields.appendChild(keyName); + + const typeName = document.createElement("span"); + typeName.textContent = typeInfo.type; + fields.appendChild(typeName); + + if (!typeInfo.inEveryEvent) { + const presence = document.createElement("span"); + presence.textContent = `${typeInfo.presence}% present`; + fields.appendChild(presence); + } + } + + schema.appendChild(fields); + this.container.appendChild(schema); + } + + private collectKeys(): string[] { + const keySet = new Set(); + for (const event of this.events) { + for (const key of Object.keys(event)) { + keySet.add(key); + } + } + const keys = Array.from(keySet); + keys.sort(); + return keys; + } + + private getTypeInfo(key: string): { + type: string; + presence: number; + inEveryEvent: boolean; + } { + let present = 0; + const types = new Set(); + + for (const event of this.events) { + if (key in event) { + present++; + const val = event[key]; + if (val === null) types.add("null"); + else if (Array.isArray(val)) types.add("array"); + else types.add(typeof val); + } + } + + const typeStr = Array.from(types).join(" | "); + const inEveryEvent = present === this.events.length; + // A field missing from some events never rounds up to 100%. + const pct = inEveryEvent + ? 100 + : Math.min(99, Math.round((present / this.events.length) * 100)); + return { type: typeStr, presence: pct, inEveryEvent }; + } +} diff --git a/apps/ui/src/export/TimelineExport.ts b/apps/ui/src/export/TimelineExport.ts new file mode 100644 index 0000000..8fd8936 --- /dev/null +++ b/apps/ui/src/export/TimelineExport.ts @@ -0,0 +1,127 @@ +import type { EvidenceEvent } from "../types.js"; + +export type ExportFormat = "json" | "jsonl" | "csv"; + +const FORMATS: [ExportFormat, string][] = [ + ["json", "JSON"], + ["jsonl", "JSONL"], + ["csv", "CSV"], +]; + +export class TimelineExport { + private container: HTMLElement; + private events: EvidenceEvent[] = []; + + constructor(container: HTMLElement) { + this.container = container; + this.render(); + } + + setData(events: EvidenceEvent[]): void { + this.events = events; + } + + private render(): void { + this.container.innerHTML = ""; + + // One export action with a format choice, instead of a button per format. + const format = document.createElement("select"); + format.setAttribute("aria-label", "Export format"); + for (const [value, label] of FORMATS) { + const option = document.createElement("option"); + option.value = value; + option.textContent = label; + format.appendChild(option); + } + + const exportBtn = document.createElement("button"); + exportBtn.type = "button"; + exportBtn.className = "export-btn"; + exportBtn.textContent = "Export"; + exportBtn.addEventListener("click", () => + this.export(format.value as ExportFormat), + ); + + this.container.appendChild(format); + this.container.appendChild(exportBtn); + + const exportStatus = document.createElement("span"); + exportStatus.id = "export-status"; + exportStatus.style.cssText = + "font-size:0.72rem;color:var(--text-dim);margin-left:0.5rem;display:none;"; + this.container.appendChild(exportStatus); + } + + private export(format: ExportFormat): void { + if (this.events.length === 0) { + const status = document.getElementById("export-status"); + if (status) { + status.textContent = "No data to export."; + status.style.color = "var(--red)"; + status.style.display = "inline"; + setTimeout(() => { + status.style.display = "none"; + }, 3000); + } + return; + } + + let content: string; + let mimeType: string; + let extension: string; + + switch (format) { + case "json": + content = JSON.stringify(this.events, null, 2); + mimeType = "application/json"; + extension = "json"; + break; + case "jsonl": + content = this.events.map((e) => JSON.stringify(e)).join("\n"); + mimeType = "application/jsonl"; + extension = "jsonl"; + break; + case "csv": { + const headers = [ + "eventType", + "timestamp", + "section", + "fromRevisionId", + "toRevisionId", + "claimId", + "layer", + "before", + "after", + ]; + const rows = this.events.map((e) => + headers + .map((h) => { + const val = (e as unknown as Record)[h]; + if (val === undefined || val === null) return ""; + const str = String(val); + return str.includes(",") || + str.includes('"') || + str.includes("\n") + ? `"${str.replace(/"/g, '""')}"` + : str; + }) + .join(","), + ); + content = [headers.join(","), ...rows].join("\n"); + mimeType = "text/csv"; + extension = "csv"; + break; + } + } + + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `refract-timeline.${extension}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } +} diff --git a/apps/ui/src/language/CertaintyTimeline.ts b/apps/ui/src/language/CertaintyTimeline.ts new file mode 100644 index 0000000..a7e8c51 --- /dev/null +++ b/apps/ui/src/language/CertaintyTimeline.ts @@ -0,0 +1,139 @@ +import type { EvidenceEvent } from "../types.js"; + +interface CertaintyPoint { + revisionId: number; + timestamp: string; + confidence: number; + label: string; +} + +export class CertaintyTimeline { + private container: HTMLElement; + private events: EvidenceEvent[] = []; + + constructor(container: HTMLElement) { + this.container = container; + } + + setData(events: EvidenceEvent[]): void { + this.events = events; + this.render(); + } + + render(): void { + this.container.innerHTML = ""; + + const title = document.createElement("div"); + title.className = "panel-title"; + title.textContent = "Model confidence"; + this.container.appendChild(title); + + const points = this.computePoints(); + + if (points.length === 0) { + const empty = document.createElement("div"); + empty.style.cssText = + "color:var(--text-dim);font-size:0.85rem;padding:1rem 0;"; + empty.textContent = + "No model interpretations with confidence scores found."; + this.container.appendChild(empty); + return; + } + + const chart = document.createElement("div"); + chart.className = "certainty-chart"; + chart.style.position = "relative"; + + const minConf = Math.min(...points.map((p) => p.confidence)); + const maxConf = Math.max(...points.map((p) => p.confidence)); + const range = maxConf - minConf || 1; + const totalWidth = points.length * 60; + + for (let i = 0; i < points.length; i++) { + const p = points[i]; + const y = 100 - ((p.confidence - minConf) / range) * 80 - 10; + const x = i * 60 + 30; + + const dot = document.createElement("div"); + dot.className = "certainty-dot"; + const confidenceLevel = + p.confidence >= 0.7 ? "high" : p.confidence >= 0.4 ? "medium" : "low"; + dot.classList.add(confidenceLevel); + dot.style.left = `${x}px`; + dot.style.top = `${y}%`; + dot.title = `r${p.revisionId}: ${(p.confidence * 100).toFixed(0)}%`; + + dot.addEventListener("click", () => { + const info = document.getElementById("certainty-info"); + if (info) { + info.textContent = `r${p.revisionId}: ${p.label} (${(p.confidence * 100).toFixed(0)}% confidence)`; + } + }); + + chart.appendChild(dot); + + if (i < points.length - 1) { + const next = points[i + 1]; + const nextY = 100 - ((next.confidence - minConf) / range) * 80 - 10; + const nextX = (i + 1) * 60 + 30; + + const line = document.createElement("div"); + line.className = "certainty-line"; + const angle = Math.atan2(nextY - y, nextX - x) * (180 / Math.PI); + const lineLength = Math.sqrt( + (nextX - x) ** 2 + + (((nextY - y) / 100) * (chart.offsetHeight || 100)) ** 2, + ); + line.style.width = `${lineLength}px`; + line.style.left = `${x}px`; + line.style.top = `${y}%`; + line.style.transformOrigin = "0 50%"; + line.style.transform = `rotate(${angle}deg)`; + chart.appendChild(line); + } + } + + chart.style.minHeight = "100px"; + chart.style.minWidth = `${totalWidth}px`; + this.container.appendChild(chart); + + const info = document.createElement("div"); + info.id = "certainty-info"; + info.style.cssText = + "font-size:0.78rem;color:var(--text-dim);margin-top:0.25rem;font-family:var(--mono);"; + info.textContent = "Click a dot for details."; + this.container.appendChild(info); + + const legend = document.createElement("div"); + legend.style.cssText = + "display:flex;gap:1rem;font-size:0.75rem;color:var(--text-dim);margin-top:0.25rem;"; + const high = createCertaintyLegend("High (≥70%)", "var(--green)"); + const med = createCertaintyLegend("Medium (40-69%)", "var(--yellow)"); + const low = createCertaintyLegend("Low (<40%)", "var(--red)"); + legend.append(high, med, low); + this.container.appendChild(legend); + } + + private computePoints(): CertaintyPoint[] { + const modelEvents = this.events.filter( + (e) => e.modelInterpretation && e.layer === "model_interpretation", + ); + + return modelEvents.map((e) => ({ + revisionId: e.toRevisionId, + timestamp: e.timestamp, + confidence: e.modelInterpretation?.confidence ?? 0, + label: e.modelInterpretation?.semanticChange ?? "", + })); + } +} + +function createCertaintyLegend(label: string, color: string): HTMLElement { + const item = document.createElement("span"); + item.style.cssText = "display:flex;align-items:center;gap:0.3rem;"; + const dot = document.createElement("span"); + dot.style.cssText = `display:inline-block;width:8px;height:8px;border-radius:50%;background:${color};`; + item.appendChild(dot); + item.appendChild(document.createTextNode(label)); + return item; +} diff --git a/apps/ui/src/language/WordingDiffCard.ts b/apps/ui/src/language/WordingDiffCard.ts new file mode 100644 index 0000000..69b6077 --- /dev/null +++ b/apps/ui/src/language/WordingDiffCard.ts @@ -0,0 +1,115 @@ +import type { EvidenceEvent } from "../types.js"; + +interface WordingChange { + revisionId: number; + timestamp: string; + section: string; + eventType: string; + before: string; + after: string; + summary: string; +} + +export class WordingDiffCard { + private container: HTMLElement; + private events: EvidenceEvent[] = []; + + constructor(container: HTMLElement) { + this.container = container; + } + + setData(events: EvidenceEvent[]): void { + this.events = events; + this.render(); + } + + render(): void { + this.container.innerHTML = ""; + + const title = document.createElement("div"); + title.className = "panel-title"; + title.textContent = "Wording changes"; + this.container.appendChild(title); + + const changes = this.computeChanges(); + + if (changes.length === 0) { + const empty = document.createElement("div"); + empty.style.cssText = + "color:var(--text-dim);font-size:0.85rem;padding:1rem 0;"; + empty.textContent = "No wording-related events found."; + this.container.appendChild(empty); + return; + } + + const grid = document.createElement("div"); + grid.className = "card-grid"; + + for (const ch of changes) { + const card = document.createElement("div"); + card.className = "card"; + + const label = document.createElement("div"); + label.className = "card-label"; + const ts = new Date(ch.timestamp); + label.textContent = `r${ch.revisionId} · ${ts.toLocaleDateString()} · ${ch.section}`; + card.appendChild(label); + + const body = document.createElement("div"); + body.className = "card-body"; + body.textContent = ch.summary; + card.appendChild(body); + + // A sentence seen for the first time has no "before" and a removed + // one has no "after"; each printed an empty − or + line. + if (ch.before !== ch.after) { + if (ch.before.trim()) { + const beforeEl = document.createElement("div"); + beforeEl.className = "card-diff"; + beforeEl.style.cssText += + ";border-left:3px solid var(--red);margin-bottom:2px;"; + beforeEl.textContent = `− ${ch.before}`; + card.appendChild(beforeEl); + } + + if (ch.after.trim()) { + const afterEl = document.createElement("div"); + afterEl.className = "card-diff"; + afterEl.style.cssText += ";border-left:3px solid var(--green);"; + afterEl.textContent = `+ ${ch.after}`; + card.appendChild(afterEl); + } + } + + grid.appendChild(card); + } + + this.container.appendChild(grid); + } + + private computeChanges(): WordingChange[] { + const wordingTypes = new Set([ + "sentence_modified", + "sentence_first_seen", + "sentence_removed", + "sentence_reintroduced", + ]); + + const wordingEvents = this.events.filter((e) => + wordingTypes.has(e.eventType), + ); + + return wordingEvents.map((e) => ({ + revisionId: e.toRevisionId, + timestamp: e.timestamp, + section: e.section, + eventType: e.eventType, + before: e.before, + after: e.after, + summary: + e.deterministicFacts.length > 0 + ? e.deterministicFacts[0].fact + : e.eventType.replace(/_/g, " "), + })); + } +} diff --git a/apps/ui/src/main.ts b/apps/ui/src/main.ts new file mode 100644 index 0000000..68adb63 --- /dev/null +++ b/apps/ui/src/main.ts @@ -0,0 +1,123 @@ +import { CitationChurnChart } from "./citations/CitationChurnChart.js"; +import { SourceChangeTable } from "./citations/SourceChangeTable.js"; +import { EditVelocityChart } from "./disputes/EditVelocityChart.js"; +import { RevertClusterDisplay } from "./disputes/RevertClusterDisplay.js"; +import { TalkPageTimeline } from "./disputes/TalkPageTimeline.js"; +import { ImportUpload } from "./export/ImportUpload.js"; +import { SchemaViewer } from "./export/SchemaViewer.js"; +import { TimelineExport } from "./export/TimelineExport.js"; +import { CertaintyTimeline } from "./language/CertaintyTimeline.js"; +import { WordingDiffCard } from "./language/WordingDiffCard.js"; +import { DiffView } from "./timeline/DiffView.js"; +import { EventFilter } from "./timeline/EventFilter.js"; +import { TimelineView } from "./timeline/TimelineView.js"; +import type { EventType, EvidenceEvent } from "./types.js"; +import "./styles/main.css"; + +let allEvents: EvidenceEvent[] = []; + +const timelineView = new TimelineView({ + container: document.getElementById("panel-timeline")!, + onSelectEvent: (event) => { + if (event) diffView.showDiff(event); + else diffView.clear(); + }, +}); + +const diffView = new DiffView(document.getElementById("panel-diff")!); + +const citationChart = new CitationChurnChart( + document.getElementById("panel-citations")!, +); +const certaintyTimeline = new CertaintyTimeline( + document.getElementById("panel-certainty")!, +); + +const schemaViewer = new SchemaViewer(document.getElementById("panel-schema")!); +const sourceChangeTable = new SourceChangeTable( + document.getElementById("panel-source")!, +); +const wordingDiffCard = new WordingDiffCard( + document.getElementById("panel-wording")!, +); + +const disputesContainer = document.getElementById("panel-disputes")!; +const revertSection = document.createElement("div"); +const velocitySection = document.createElement("div"); +const talkSection = document.createElement("div"); +velocitySection.style.marginTop = "1rem"; +talkSection.style.marginTop = "1rem"; +disputesContainer.appendChild(revertSection); +disputesContainer.appendChild(velocitySection); +disputesContainer.appendChild(talkSection); + +const revertClusterDisplay = new RevertClusterDisplay(revertSection); +const editVelocityChart = new EditVelocityChart(velocitySection); +const talkPageTimeline = new TalkPageTimeline(talkSection); + +const filter = new EventFilter({ + container: document.getElementById("filter-area")!, + allTypes: [], + onChange: (activeTypes) => { + applyFilters(activeTypes); + }, +}); + +const exportCtrl = new TimelineExport(document.getElementById("export-area")!); + +new ImportUpload(document.getElementById("import-area")!, (events) => { + allEvents = events; + exportCtrl.setData(events); + const allTypes = collectEventTypes(events); + filter.setTypes(allTypes); + timelineView.setData(events); + updateAll(events); +}); + +async function loadSampleData(): Promise { + try { + const resp = await fetch("/sample-data.jsonl"); + const text = await resp.text(); + const lines = text.split("\n").filter((l) => l.trim().length > 0); + const events: EvidenceEvent[] = lines.map((l) => JSON.parse(l)); + allEvents = events; + exportCtrl.setData(events); + const allTypes = collectEventTypes(events); + filter.setTypes(allTypes); + timelineView.setData(events); + updateAll(events); + } catch (err) { + console.warn("Could not load sample data:", err); + } +} + +function collectEventTypes(events: EvidenceEvent[]): EventType[] { + const set = new Set(); + for (const e of events) { + set.add(e.eventType); + } + return Array.from(set).sort(); +} + +// Every panel but the timeline. The timeline takes the full set once, on load, +// and a filter through applyFilter, so its count reads "3 of 12". Setting its +// data from here made the filtered set its total, and the count read "3 of 3". +function updateAll(events: EvidenceEvent[]): void { + citationChart.setData(events); + certaintyTimeline.setData(events); + schemaViewer.setData(events as unknown as Record[]); + sourceChangeTable.setData(events); + wordingDiffCard.setData(events); + revertClusterDisplay.setData(events); + editVelocityChart.setData(events); + talkPageTimeline.setData(events); +} + +function applyFilters(activeTypes: Set): void { + const filtered = allEvents.filter((e) => activeTypes.has(e.eventType)); + timelineView.applyFilter(filtered); + exportCtrl.setData(filtered); + updateAll(filtered); +} + +loadSampleData(); diff --git a/apps/ui/src/styles/main.css b/apps/ui/src/styles/main.css new file mode 100644 index 0000000..a495de4 --- /dev/null +++ b/apps/ui/src/styles/main.css @@ -0,0 +1,625 @@ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --bg-deep: #080a12; + --bg-surface: #0c0f1a; + --bg-panel: rgba(14, 18, 30, 0.85); + --bg-hover: rgba(255, 255, 255, 0.05); + --border: rgba(255, 255, 255, 0.07); + --border-strong: rgba(255, 255, 255, 0.14); + --text: #e2e4ed; + --text-dim: #6b7194; + --accent: #7c8aff; + --accent-dim: #5b68e0; + --accent-soft: rgba(124, 138, 255, 0.1); + --green: #4ade80; + --green-soft: rgba(74, 222, 128, 0.12); + --red: #f87171; + --red-soft: rgba(248, 113, 113, 0.12); + --orange: #fb923c; + --orange-soft: rgba(251, 146, 60, 0.12); + --yellow: #facc15; + --purple: #a78bfa; + --cyan: #22d3ee; + --radius: 10px; + --radius-sm: 6px; + --font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --font-heading: var(--font); + --mono: + ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; +} + +html { + font-size: 14px; +} + +body { + font-family: var(--font); + background: var(--bg-deep); + color: var(--text); + line-height: 1.5; + -webkit-font-smoothing: antialiased; + min-height: 100vh; +} + +#app { + max-width: 1440px; + margin: 0 auto; + padding: 1.25rem 1.5rem; +} + +/* ── Header ── */ +#app-header { + margin-bottom: 1.25rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--border); + display: flex; + align-items: baseline; + gap: 12px; +} + +#app-header h1 { + font-size: 1.4rem; + font-weight: 600; + color: var(--accent); + letter-spacing: -0.02em; +} + +.subtitle { + color: var(--text-dim); + font-size: 0.8rem; +} + +#app-header .subtitle { + margin-left: auto; +} + +/* ── Toolbar ── */ +#toolbar { + display: flex; + gap: 0.75rem; + margin-bottom: 1.25rem; + flex-wrap: wrap; + align-items: center; +} + +#toolbar > div { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +/* ── Panels ── */ +.panel { + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem 1.25rem; + overflow: auto; + transition: border-color 0.2s ease; +} + +.panel:hover { + border-color: var(--border-strong); +} + +.panel-title { + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-dim); + margin-bottom: 0.75rem; +} + +/* ── Dashboard Grid ── */ +/* Panels take the height of what they hold; none is stretched to match a + taller neighbour. */ +#dashboard { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + align-items: start; +} + +#panel-timeline { + max-height: 80vh; + overflow-y: auto; +} + +/* Diff, citation churn and model confidence stack beside the timeline. */ +#dashboard-side { + display: flex; + flex-direction: column; + gap: 1rem; + min-width: 0; +} + +/* Three equal columns: the disputes charts fit one column, and the source + changes table needs the width a quarter of the row could not give it. */ +#dashboard-row2 { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; + margin-top: 1rem; + align-items: start; +} + +#panel-schema { + grid-column: 1 / -1; +} + +/* ── Buttons & Inputs ── */ +button { + background: var(--bg-hover); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.4rem 0.85rem; + cursor: pointer; + font-family: var(--font); + font-size: 0.8rem; + font-weight: 500; + transition: + background 0.15s ease, + border-color 0.15s ease, + color 0.15s ease; +} + +button:hover { + background: rgba(255, 255, 255, 0.08); + border-color: var(--border-strong); +} + +button:disabled { + opacity: 0.4; + cursor: default; + pointer-events: none; +} + +select { + background: var(--bg-hover); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.4rem 0.75rem; + font-family: var(--font); + font-size: 0.8rem; +} + +/* ── Timeline ── */ +.timeline { + position: relative; + padding-left: 2rem; +} + +.timeline::before { + content: ""; + position: absolute; + left: 0.5rem; + top: 0; + bottom: 0; + width: 1px; + background: var(--border); +} + +.timeline-item { + position: relative; + padding: 0.5rem 0.75rem; + margin-bottom: 0.25rem; + border-radius: var(--radius-sm); + cursor: pointer; + transition: background 0.15s ease; +} + +.timeline-item:hover { + background: var(--bg-hover); +} + +.timeline-item.selected { + background: var(--accent-soft); + border-left: 2px solid var(--accent); +} + +.timeline-item::before { + content: ""; + position: absolute; + left: -1.5rem; + top: 0.8rem; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--border-strong); + transition: + background 0.2s ease, + transform 0.2s ease; +} + +.timeline-item:hover::before { + transform: scale(1.3); +} + +.timeline-item.event-citation_added::before, +.timeline-item.event-citation_replaced::before, +.timeline-item.event-citation_removed::before { + background: var(--green); +} +.timeline-item.event-claim_removed::before { + background: var(--red); +} +.timeline-item.event-claim_softened::before { + background: var(--orange); +} +.timeline-item.event-claim_strengthened::before { + background: var(--green); +} +.timeline-item.event-claim_reworded::before { + background: var(--yellow); +} +.timeline-item.event-revert_detected::before { + background: var(--red); +} +.timeline-item.event-talk_reply_added::before { + background: var(--purple); +} + +.timeline-item .event-type { + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-dim); + font-weight: 600; +} + +.timeline-item .event-summary { + font-size: 0.9rem; + margin-top: 0.15rem; +} + +.timeline-item .event-meta { + font-size: 0.72rem; + color: var(--text-dim); + margin-top: 0.15rem; + font-family: var(--mono); +} + +/* ── Diff Viewer ── */ +/* One run of text: unchanged, removed and added words flow inline. */ +.diff-container { + font-size: 0.85rem; + line-height: 1.7; + white-space: pre-wrap; + word-break: break-word; +} + +.diff-word-added { + background: var(--green-soft); + color: var(--green); + border-radius: 2px; +} + +.diff-word-removed { + background: var(--red-soft); + color: var(--red); + text-decoration: line-through; + border-radius: 2px; +} + +.diff-legend { + display: flex; + gap: 1rem; + margin-top: 0.75rem; + font-size: 0.75rem; + color: var(--text-dim); +} + +.diff-pane { + border: 1px solid var(--border); + border-radius: var(--radius-sm); + overflow: hidden; +} + +.diff-pane-header { + background: var(--bg-hover); + padding: 0.35rem 0.6rem; + font-size: 0.72rem; + color: var(--text-dim); + font-weight: 600; + border-bottom: 1px solid var(--border); +} + +.diff-lines { + font-family: var(--mono); + font-size: 0.78rem; + line-height: 1.6; + padding: 0.5rem 0.6rem; + white-space: pre-wrap; + word-break: break-word; +} + +.diff-line-added { + background: var(--green-soft); + color: var(--green); +} +.diff-line-removed { + background: var(--red-soft); + color: var(--red); +} +.diff-line-unchanged { + color: var(--text-dim); +} + +/* ── Bar Chart ── */ +.bar-chart { + display: flex; + align-items: flex-end; + gap: 4px; + height: 120px; + padding: 0.5rem 0; +} + +.bar-group { + display: flex; + gap: 2px; + flex: 1; + align-items: flex-end; + height: 100%; +} + +.bar { + flex: 1; + min-width: 3px; + border-radius: 2px 2px 0 0; + transition: + height 0.25s ease, + opacity 0.2s ease; + position: relative; +} + +.bar.added { + background: var(--green); +} +.bar.removed { + background: var(--red); +} + +.bar:hover { + opacity: 0.7; +} + +.bar-chart-label { + text-align: center; + font-size: 0.62rem; + color: var(--text-dim); + padding-top: 0.25rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── Certainty Chart ── */ +.certainty-chart { + position: relative; + height: 100px; + margin: 0.5rem 0; +} + +.certainty-line { + position: absolute; + left: 0; + right: 0; + height: 1px; + background: var(--border); + border-radius: 1px; +} + +.certainty-dot { + position: absolute; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--accent); + transform: translate(-50%, -50%); + cursor: pointer; + transition: transform 0.2s ease; +} + +.certainty-dot:hover { + transform: translate(-50%, -50%) scale(1.5); +} + +.certainty-dot.high { + background: var(--green); +} +.certainty-dot.medium { + background: var(--yellow); +} +.certainty-dot.low { + background: var(--red); +} + +/* ── Table ── */ +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; +} + +.data-table th { + text-align: left; + padding: 0.5rem 0.6rem; + color: var(--text-dim); + font-weight: 600; + border-bottom: 1px solid var(--border-strong); + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.data-table td { + padding: 0.5rem 0.6rem; + border-bottom: 1px solid var(--border); + font-family: var(--mono); + font-size: 0.76rem; +} + +.data-table tr:hover td { + background: var(--bg-hover); +} + +/* Source changes: the short columns shrink to their content so the Detail + column, the table's only prose, gets the rest of the row. */ +.source-table th:not(:last-child), +.source-table td:not(:last-child) { + width: 1%; + white-space: nowrap; +} + +.source-table td:last-child { + font-family: var(--font); + font-size: 0.8rem; + overflow-wrap: anywhere; +} + +/* ── Cards ── */ +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 0.75rem; +} + +.card { + background: var(--bg-deep); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.75rem; + transition: border-color 0.15s ease; +} + +.card:hover { + border-color: var(--border-strong); +} + +.card-label { + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-dim); + margin-bottom: 0.25rem; +} + +.card-body { + font-size: 0.85rem; +} + +.card-diff { + font-family: var(--mono); + font-size: 0.78rem; + margin-top: 0.4rem; + padding: 0.4rem; + background: var(--bg-panel); + border-radius: 4px; +} + +/* ── Export ── */ +.export-btn { + background: var(--accent-soft); + color: var(--accent); + border-color: rgba(124, 138, 255, 0.3); +} + +.export-btn:hover { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} + +/* ── Upload Zone ── */ +.upload-zone { + border: 1px dashed var(--border); + border-radius: var(--radius-sm); + padding: 0.45rem 0.85rem; + cursor: pointer; + font-size: 0.8rem; + color: var(--text-dim); + transition: + border-color 0.15s ease, + color 0.15s ease; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.upload-zone:hover { + border-color: var(--accent); + color: var(--accent); + background: var(--accent-soft); +} + +/* ── Filter Tags ── */ +.filter-tag { + display: inline-flex; + align-items: center; + gap: 0.3rem; + background: var(--bg-hover); + border: 1px solid var(--border); + border-radius: 4px; + padding: 0.2rem 0.5rem; + font-size: 0.72rem; + font-weight: 400; + cursor: pointer; + transition: + background 0.15s ease, + color 0.15s ease, + border-color 0.15s ease; +} + +.filter-tag.active { + background: var(--accent-soft); + color: var(--accent); + border-color: rgba(124, 138, 255, 0.3); +} + +/* ── Schema Viewer ── */ +.schema-viewer { + font-family: var(--mono); + font-size: 0.76rem; + line-height: 1.6; + white-space: pre-wrap; + color: var(--text-dim); +} + +/* Name, type, and a presence cell only on fields some events lack. Each + name starts a new row. */ +.schema-fields { + display: grid; + grid-template-columns: + minmax(min-content, 200px) minmax(min-content, 100px) + auto; + justify-content: start; + column-gap: 0.5rem; + row-gap: 0.3rem; +} + +.schema-key { + grid-column: 1; + color: var(--accent); +} + +/* ── Responsive ── */ +@media (max-width: 900px) { + #dashboard { + grid-template-columns: 1fr; + } + + #dashboard-row2 { + grid-template-columns: 1fr; + } +} diff --git a/apps/ui/src/timeline/DiffView.ts b/apps/ui/src/timeline/DiffView.ts new file mode 100644 index 0000000..20b12f5 --- /dev/null +++ b/apps/ui/src/timeline/DiffView.ts @@ -0,0 +1,159 @@ +import type { DiffLine, EvidenceEvent } from "../types.js"; + +function computeWordDiff(before: string, after: string): DiffLine[] { + const beforeWords = before.split(/(\s+)/); + const afterWords = after.split(/(\s+)/); + const result: DiffLine[] = []; + + let bi = 0; + let ai = 0; + + while (bi < beforeWords.length || ai < afterWords.length) { + if (bi >= beforeWords.length) { + result.push({ + type: "added", + content: afterWords.slice(ai).join(""), + lineNumber: 0, + }); + break; + } + if (ai >= afterWords.length) { + result.push({ + type: "removed", + content: beforeWords.slice(bi).join(""), + lineNumber: 0, + }); + break; + } + + if (beforeWords[bi] === afterWords[ai]) { + let chunk = ""; + while ( + bi < beforeWords.length && + ai < afterWords.length && + beforeWords[bi] === afterWords[ai] + ) { + chunk += beforeWords[bi]; + bi++; + ai++; + } + if (chunk) + result.push({ type: "unchanged", content: chunk, lineNumber: 0 }); + } else { + const bStart = bi; + const aStart = ai; + + while ( + bi < beforeWords.length && + (ai >= afterWords.length || beforeWords[bi] !== afterWords[ai]) + ) { + bi++; + } + + if ( + ai < afterWords.length && + bi < beforeWords.length && + beforeWords[bi] === afterWords[ai] + ) { + const removed = beforeWords.slice(bStart, bi).join(""); + const added = afterWords.slice(aStart, ai).join(""); + if (removed) + result.push({ type: "removed", content: removed, lineNumber: 0 }); + if (added) + result.push({ type: "added", content: added, lineNumber: 0 }); + } else { + const removed = beforeWords.slice(bStart).join(""); + const added = afterWords.slice(aStart).join(""); + if (removed) + result.push({ type: "removed", content: removed, lineNumber: 0 }); + if (added) + result.push({ type: "added", content: added, lineNumber: 0 }); + break; + } + } + } + + return result.length > 0 + ? result + : [{ type: "unchanged", content: after || before, lineNumber: 0 }]; +} + +export class DiffView { + private container: HTMLElement; + private event: EvidenceEvent | null = null; + + constructor(container: HTMLElement) { + this.container = container; + this.renderEmpty(); + } + + showDiff(event: EvidenceEvent): void { + this.event = event; + this.render(); + } + + clear(): void { + this.event = null; + this.renderEmpty(); + } + + private renderEmpty(): void { + this.container.innerHTML = ""; + const title = document.createElement("div"); + title.className = "panel-title"; + title.textContent = "Diff"; + this.container.appendChild(title); + + // The timeline selects its first listed event, so this shows only when + // no event is loaded or none matches the filter. + const hint = document.createElement("div"); + hint.style.cssText = "color:var(--text-dim);font-size:0.85rem;"; + hint.textContent = "No event selected."; + this.container.appendChild(hint); + } + + private render(): void { + if (!this.event) { + this.renderEmpty(); + return; + } + + const event = this.event; + this.container.innerHTML = ""; + + const title = document.createElement("div"); + title.className = "panel-title"; + title.textContent = `Diff · r${event.fromRevisionId} → r${event.toRevisionId}`; + this.container.appendChild(title); + + const sectionInfo = document.createElement("div"); + sectionInfo.style.cssText = + "font-size:0.78rem;color:var(--text-dim);margin-bottom:0.5rem;font-family:var(--mono);"; + sectionInfo.textContent = event.section || "(no section)"; + this.container.appendChild(sectionInfo); + + const diffLines = computeWordDiff(event.before, event.after); + + const diffContainer = document.createElement("div"); + diffContainer.className = "diff-container"; + + for (const line of diffLines) { + const el = document.createElement("span"); + if (line.type === "added") { + el.className = "diff-word-added"; + } else if (line.type === "removed") { + el.className = "diff-word-removed"; + } + el.textContent = line.content; + diffContainer.appendChild(el); + } + + this.container.appendChild(diffContainer); + + const labels = document.createElement("div"); + labels.className = "diff-legend"; + labels.innerHTML = + '⬤ Removed⬤ Added'; + this.container.appendChild(labels); + } +} diff --git a/apps/ui/src/timeline/EventFilter.ts b/apps/ui/src/timeline/EventFilter.ts new file mode 100644 index 0000000..a885e79 --- /dev/null +++ b/apps/ui/src/timeline/EventFilter.ts @@ -0,0 +1,95 @@ +import type { EventType } from "../types.js"; + +export interface EventFilterOptions { + container: HTMLElement; + allTypes: EventType[]; + onChange: (activeTypes: Set) => void; +} + +export class EventFilter { + private container: HTMLElement; + private allTypes: EventType[]; + private activeTypes: Set; + private onChange: (activeTypes: Set) => void; + + constructor(opts: EventFilterOptions) { + this.container = opts.container; + this.allTypes = opts.allTypes; + this.activeTypes = new Set(opts.allTypes); + this.onChange = opts.onChange; + this.render(); + } + + setTypes(types: EventType[]): void { + this.allTypes = types; + this.activeTypes = new Set(types); + this.render(); + } + + getActiveTypes(): Set { + return this.activeTypes; + } + + toggleType(type: EventType): void { + if (this.activeTypes.has(type)) { + if (this.activeTypes.size > 1) { + this.activeTypes.delete(type); + } + } else { + this.activeTypes.add(type); + } + this.render(); + this.onChange(this.activeTypes); + } + + selectAll(): void { + this.activeTypes = new Set(this.allTypes); + this.render(); + this.onChange(this.activeTypes); + } + + clearAll(): void { + this.activeTypes = new Set(); + this.render(); + this.onChange(this.activeTypes); + } + + render(): void { + this.container.innerHTML = ""; + + // All and None are actions, not states: the chips already show which + // types are on, so neither button is highlighted. Each is disabled when + // it would change nothing. + const selectAll = document.createElement("button"); + selectAll.type = "button"; + selectAll.textContent = "All"; + selectAll.disabled = this.activeTypes.size === this.allTypes.length; + selectAll.addEventListener("click", () => this.selectAll()); + this.container.appendChild(selectAll); + + const clearBtn = document.createElement("button"); + clearBtn.type = "button"; + clearBtn.textContent = "None"; + clearBtn.disabled = this.activeTypes.size === 0; + clearBtn.addEventListener("click", () => this.clearAll()); + this.container.appendChild(clearBtn); + + for (const type of this.allTypes) { + const tag = document.createElement("button"); + tag.type = "button"; + tag.className = "filter-tag"; + const active = this.activeTypes.has(type); + if (active) { + tag.classList.add("active"); + } + tag.setAttribute("aria-pressed", String(active)); + tag.textContent = formatEventTypeShort(type); + tag.addEventListener("click", () => this.toggleType(type)); + this.container.appendChild(tag); + } + } +} + +function formatEventTypeShort(type: EventType): string { + return type.replace(/_/g, " "); +} diff --git a/apps/ui/src/timeline/TimelineView.ts b/apps/ui/src/timeline/TimelineView.ts new file mode 100644 index 0000000..c3a4003 --- /dev/null +++ b/apps/ui/src/timeline/TimelineView.ts @@ -0,0 +1,135 @@ +import type { EventType, EvidenceEvent } from "../types.js"; + +export interface TimelineViewOptions { + container: HTMLElement; + /** Called with the selected event, or null when no event is listed. */ + onSelectEvent?: (event: EvidenceEvent | null) => void; +} + +export class TimelineView { + private container: HTMLElement; + private events: EvidenceEvent[] = []; + private filtered: EvidenceEvent[] = []; + // Held by reference: eventId is optional in the schema, and filtering keeps + // the same objects. + private selected: EvidenceEvent | null = null; + private onSelectEvent?: (event: EvidenceEvent | null) => void; + + constructor(opts: TimelineViewOptions) { + this.container = opts.container; + this.onSelectEvent = opts.onSelectEvent; + } + + setData(events: EvidenceEvent[]): void { + this.events = events; + this.filtered = [...events]; + this.syncSelection(); + this.render(); + } + + applyFilter(filtered: EvidenceEvent[]): void { + this.filtered = filtered; + this.syncSelection(); + this.render(); + } + + selectEvent(eventId: string | null): void { + this.selected = + this.filtered.find( + (e) => e.eventId !== undefined && e.eventId === eventId, + ) ?? null; + this.render(); + this.onSelectEvent?.(this.selected); + } + + /** + * Keep the selected event while it is still listed; otherwise select the + * first listed one, so the diff panel shows a change instead of opening + * empty. The first listed event is the one at the top of this panel, next + * to the diff it fills. + */ + private syncSelection(): void { + if (this.selected && this.filtered.includes(this.selected)) return; + this.selected = this.filtered[0] ?? null; + this.onSelectEvent?.(this.selected); + } + + render(): void { + this.container.innerHTML = ""; + + const title = document.createElement("div"); + title.className = "panel-title"; + title.textContent = "Revision timeline"; + this.container.appendChild(title); + + const count = document.createElement("div"); + count.style.cssText = + "font-size:0.75rem;color:var(--text-dim);margin-bottom:0.5rem;"; + count.textContent = `${this.filtered.length} of ${this.events.length} events`; + this.container.appendChild(count); + + if (this.filtered.length === 0) { + const empty = document.createElement("div"); + empty.style.cssText = + "padding:1rem 0;color:var(--text-dim);font-size:0.85rem;"; + empty.textContent = "No events match the current filter."; + this.container.appendChild(empty); + return; + } + + const timeline = document.createElement("div"); + timeline.className = "timeline"; + + for (const event of this.filtered) { + const item = document.createElement("div"); + item.className = `timeline-item event-${event.eventType}`; + if (event === this.selected) { + item.classList.add("selected"); + } + + const typeLabel = document.createElement("div"); + typeLabel.className = "event-type"; + typeLabel.textContent = formatEventType(event.eventType); + item.appendChild(typeLabel); + + const summary = document.createElement("div"); + summary.className = "event-summary"; + summary.textContent = summarizeEvent(event); + item.appendChild(summary); + + const meta = document.createElement("div"); + meta.className = "event-meta"; + const ts = new Date(event.timestamp); + meta.textContent = `${ts.toLocaleDateString()} ${ts.toLocaleTimeString()} · r${event.fromRevisionId} → r${event.toRevisionId}`; + if (event.section) { + meta.textContent += ` · ${event.section}`; + } + item.appendChild(meta); + + item.addEventListener("click", () => { + this.selected = event; + this.render(); + this.onSelectEvent?.(event); + }); + + timeline.appendChild(item); + } + + this.container.appendChild(timeline); + } +} + +function formatEventType(type: EventType): string { + const words = type.replace(/_/g, " "); + return words.charAt(0).toUpperCase() + words.slice(1); +} + +function summarizeEvent(event: EvidenceEvent): string { + if (event.deterministicFacts.length > 0) { + return event.deterministicFacts[0].fact; + } + if (event.modelInterpretation) { + return event.modelInterpretation.semanticChange; + } + return `${formatEventType(event.eventType)} in ${event.section}`; +} diff --git a/apps/ui/src/types.ts b/apps/ui/src/types.ts new file mode 100644 index 0000000..1b8d69c --- /dev/null +++ b/apps/ui/src/types.ts @@ -0,0 +1,101 @@ +export type EvidenceLayer = + | "observed" + | "policy_coded" + | "model_interpretation" + | "speculative" + | "unknown"; + +export type EventType = + | "claim_first_seen" + | "claim_removed" + | "claim_softened" + | "claim_strengthened" + | "claim_reworded" + | "claim_moved" + | "claim_reintroduced" + | "citation_added" + | "citation_removed" + | "citation_replaced" + | "template_added" + | "template_removed" + | "revert_detected" + | "section_reorganized" + | "lead_promotion" + | "lead_demotion" + | "page_moved" + | "wikilink_added" + | "wikilink_removed" + | "category_added" + | "category_removed" + | "protection_changed" + | "talk_page_correlated" + | "talk_thread_opened" + | "talk_thread_archived" + | "talk_reply_added" + | "template_parameter_changed"; + +export interface FactProvenance { + analyzer: string; + version: string; + inputHashes: string[]; +} + +export interface DeterministicFact { + fact: string; + detail?: string; + provenance?: FactProvenance; +} + +export type PolicyDimension = + | "verifiability" + | "npov" + | "blp" + | "due_weight" + | "protection" + | "edit_warring" + | "notability" + | "copyright" + | "civility"; + +export type DiscussionType = + | "notability_challenge" + | "sourcing_dispute" + | "neutrality_concern" + | "content_deletion" + | "content_addition" + | "naming_dispute" + | "procedural" + | "other"; + +export interface ModelInterpretation { + semanticChange: string; + confidence: number; + policyDimension?: PolicyDimension; + discussionType?: DiscussionType; +} + +export interface EvidenceEvent { + eventId?: string; + eventType: EventType; + claimId?: string; + fromRevisionId: number; + toRevisionId: number; + section: string; + before: string; + after: string; + deterministicFacts: DeterministicFact[]; + modelInterpretation?: ModelInterpretation; + layer: EvidenceLayer; + timestamp: string; +} + +export interface DiffLine { + type: "added" | "removed" | "unchanged"; + content: string; + lineNumber: number; +} + +export interface TimelineFilter { + eventTypes: Set; + dateRange: { start: string; end: string } | null; +} diff --git a/apps/ui/tests/citations.test.ts b/apps/ui/tests/citations.test.ts new file mode 100644 index 0000000..af1a3a8 --- /dev/null +++ b/apps/ui/tests/citations.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { CitationChurnChart } from "../src/citations/CitationChurnChart.js"; +import { SourceChangeTable } from "../src/citations/SourceChangeTable.js"; +import type { EvidenceEvent } from "../src/types.js"; + +describe("CitationChurnChart", () => { + let container: HTMLElement; + + beforeEach(() => { + container = document.createElement("div"); + }); + + it("renders empty state when no citation events exist", () => { + const chart = new CitationChurnChart(container); + chart.setData([]); + + expect(container.textContent).toContain("No citation events found"); + }); + + it("renders added/removed metrics and chart bars when citation events exist", () => { + const chart = new CitationChurnChart(container); + const events: EvidenceEvent[] = [ + { + eventId: "c1", + eventType: "citation_added", + fromRevisionId: 10, + toRevisionId: 11, + timestamp: "2026-02-01T00:00:00Z", + section: "References", + before: "", + after: "Source A", + deterministicFacts: [], + layer: "observed", + }, + { + eventId: "c2", + eventType: "citation_removed", + fromRevisionId: 11, + toRevisionId: 12, + timestamp: "2026-02-02T00:00:00Z", + section: "References", + before: "Source B", + after: "", + deterministicFacts: [], + layer: "observed", + }, + ]; + + chart.setData(events); + expect(container.textContent).toContain("Citation churn"); + expect(container.textContent).toContain("Added"); + expect(container.textContent).toContain("Removed"); + expect(container.querySelectorAll(".bar-group").length).toBe(2); + expect(container.querySelectorAll(".bar").length).toBe(6); + }); +}); + +describe("SourceChangeTable", () => { + let container: HTMLElement; + + beforeEach(() => { + container = document.createElement("div"); + }); + + it("renders empty state when no source events exist", () => { + const table = new SourceChangeTable(container); + table.setData([]); + + expect(container.textContent).toContain("No source/citation changes found"); + }); + + it("renders table rows for citation modifications", () => { + const table = new SourceChangeTable(container); + const events: EvidenceEvent[] = [ + { + eventId: "s1", + eventType: "citation_replaced", + fromRevisionId: 30, + toRevisionId: 31, + timestamp: "2026-04-10T10:00:00Z", + section: "Outcomes", + before: "doi:10.1001/old", + after: "doi:10.1001/new", + deterministicFacts: [ + { + fact: "Updated DOI reference", + }, + ], + layer: "observed", + }, + ]; + + table.setData(events); + const rows = container.querySelectorAll("tbody tr"); + expect(rows.length).toBe(1); + expect(rows[0].textContent).toContain("Outcomes"); + expect(rows[0].textContent).toContain("Updated DOI reference"); + }); +}); diff --git a/apps/ui/tests/disputes.test.ts b/apps/ui/tests/disputes.test.ts new file mode 100644 index 0000000..d14144d --- /dev/null +++ b/apps/ui/tests/disputes.test.ts @@ -0,0 +1,143 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { EditVelocityChart } from "../src/disputes/EditVelocityChart.js"; +import { RevertClusterDisplay } from "../src/disputes/RevertClusterDisplay.js"; +import { TalkPageTimeline } from "../src/disputes/TalkPageTimeline.js"; +import type { EvidenceEvent } from "../src/types.js"; + +describe("EditVelocityChart", () => { + let container: HTMLElement; + + beforeEach(() => { + container = document.createElement("div"); + }); + + it("renders empty state when no events exist", () => { + const chart = new EditVelocityChart(container); + chart.setData([]); + + expect(container.textContent).toContain("No events to chart"); + }); + + it("groups events into daily velocity buckets and renders bars", () => { + const chart = new EditVelocityChart(container); + const events: EvidenceEvent[] = [ + { + eventId: "e1", + eventType: "claim_first_seen", + fromRevisionId: 1, + toRevisionId: 2, + timestamp: "2026-05-10T10:00:00Z", + section: "Lead", + before: "", + after: "First statement", + deterministicFacts: [], + layer: "observed", + }, + { + eventId: "e2", + eventType: "revert_detected", + fromRevisionId: 2, + toRevisionId: 3, + timestamp: "2026-05-10T11:00:00Z", + section: "Lead", + before: "First statement", + after: "", + deterministicFacts: [], + layer: "observed", + }, + ]; + + chart.setData(events); + expect(container.querySelectorAll(".bar-group").length).toBe(1); + expect(container.querySelectorAll(".bar").length).toBe(2); + }); +}); + +describe("RevertClusterDisplay", () => { + let container: HTMLElement; + + beforeEach(() => { + container = document.createElement("div"); + }); + + it("renders empty state when no revert events exist", () => { + const display = new RevertClusterDisplay(container); + display.setData([]); + + expect(container.textContent).toContain("No revert events detected"); + }); + + it("renders revert cluster cards with count badge", () => { + const display = new RevertClusterDisplay(container); + const events: EvidenceEvent[] = [ + { + eventId: "rev-1", + eventType: "revert_detected", + fromRevisionId: 100, + toRevisionId: 101, + timestamp: "2026-07-01T15:00:00Z", + section: "Safety", + before: "Claim with caveat", + after: "Claim without caveat", + deterministicFacts: [ + { + fact: "Reverted 1 edit by user A", + }, + ], + layer: "observed", + }, + ]; + + display.setData(events); + const cards = container.querySelectorAll(".card"); + expect(cards.length).toBe(1); + expect(cards[0].textContent).toContain("r101"); + expect(cards[0].textContent).toContain("Safety"); + expect(cards[0].textContent).toContain("Reverted 1 edit by user A"); + }); +}); + +describe("TalkPageTimeline", () => { + let container: HTMLElement; + + beforeEach(() => { + container = document.createElement("div"); + }); + + it("renders empty state when no talk page events exist", () => { + const timeline = new TalkPageTimeline(container); + timeline.setData([]); + + expect(container.textContent).toContain("No talk page events found"); + }); + + it("renders timeline items for talk page discussions", () => { + const timeline = new TalkPageTimeline(container); + const events: EvidenceEvent[] = [ + { + eventId: "talk-1", + eventType: "talk_page_correlated", + fromRevisionId: 5, + toRevisionId: 6, + timestamp: "2026-08-01T12:00:00Z", + section: "Talk:Article", + before: "", + after: "Discussion started regarding trial validity", + deterministicFacts: [ + { + fact: "Thread opened: 'Trial Validity Analysis'", + }, + ], + layer: "observed", + }, + ]; + + timeline.setData(events); + const items = container.querySelectorAll(".timeline-item"); + expect(items.length).toBe(1); + expect(items[0].textContent).toContain("talk page correlated"); + expect(items[0].textContent).toContain( + "Thread opened: 'Trial Validity Analysis'", + ); + }); +}); diff --git a/apps/ui/tests/export.test.ts b/apps/ui/tests/export.test.ts new file mode 100644 index 0000000..69f0607 --- /dev/null +++ b/apps/ui/tests/export.test.ts @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { SchemaViewer } from "../src/export/SchemaViewer.js"; +import { TimelineExport } from "../src/export/TimelineExport.js"; +import type { EvidenceEvent } from "../src/types.js"; + +describe("TimelineExport", () => { + let container: HTMLElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + it("renders format selector and export button", () => { + new TimelineExport(container); + + const select = container.querySelector("select"); + expect(select).not.toBeNull(); + expect(select?.children.length).toBe(3); + + const button = container.querySelector(".export-btn"); + expect(button).not.toBeNull(); + expect(button?.textContent).toBe("Export"); + }); + + it("displays message when exporting with empty events", () => { + new TimelineExport(container); + const button = container.querySelector(".export-btn") as HTMLButtonElement; + button.click(); + + const status = container.querySelector("#export-status"); + expect(status?.textContent).toBe("No data to export."); + }); +}); + +describe("SchemaViewer", () => { + let container: HTMLElement; + + beforeEach(() => { + container = document.createElement("div"); + }); + + it("renders empty state when no events are provided", () => { + const viewer = new SchemaViewer(container); + viewer.setData([]); + + expect(container.textContent).toContain("Upload data to inspect schema"); + }); + + it("analyzes fields and types across events", () => { + const viewer = new SchemaViewer(container); + const events: EvidenceEvent[] = [ + { + eventId: "e1", + eventType: "claim_first_seen", + fromRevisionId: 10, + toRevisionId: 11, + timestamp: "2026-01-01T00:00:00Z", + section: "Lead", + before: "", + after: "Text", + deterministicFacts: [], + layer: "observed", + }, + ]; + + viewer.setData(events as unknown as Record[]); + + const keys = container.querySelectorAll(".schema-key"); + expect(keys.length).toBeGreaterThan(0); + expect(container.textContent).toContain("1 events"); + }); +}); diff --git a/apps/ui/tests/language.test.ts b/apps/ui/tests/language.test.ts new file mode 100644 index 0000000..830b8b2 --- /dev/null +++ b/apps/ui/tests/language.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { CertaintyTimeline } from "../src/language/CertaintyTimeline.js"; +import { WordingDiffCard } from "../src/language/WordingDiffCard.js"; +import type { EvidenceEvent } from "../src/types.js"; + +describe("CertaintyTimeline", () => { + let container: HTMLElement; + + beforeEach(() => { + container = document.createElement("div"); + }); + + it("renders empty state when no events with confidence exist", () => { + const timeline = new CertaintyTimeline(container); + timeline.setData([]); + + expect(container.querySelector(".panel-title")?.textContent).toBe( + "Model confidence", + ); + expect(container.textContent).toContain( + "No model interpretations with confidence scores found", + ); + }); + + it("renders dots and chart when events have modelInterpretation confidence", () => { + const events: EvidenceEvent[] = [ + { + eventId: "ev-1", + eventType: "claim_strengthened", + fromRevisionId: 100, + toRevisionId: 101, + timestamp: "2026-01-01T00:00:00Z", + section: "Lead", + before: "alleged to work", + after: "proven to work", + deterministicFacts: [], + layer: "model_interpretation", + modelInterpretation: { + confidence: 0.85, + summary: "Stronger language", + }, + }, + { + eventId: "ev-2", + eventType: "claim_softened", + fromRevisionId: 101, + toRevisionId: 102, + timestamp: "2026-01-02T00:00:00Z", + section: "Lead", + before: "proven to work", + after: "suggested to work", + deterministicFacts: [], + layer: "model_interpretation", + modelInterpretation: { + confidence: 0.35, + summary: "Hedged language", + }, + }, + ]; + + const timeline = new CertaintyTimeline(container); + timeline.setData(events); + + const chart = container.querySelector(".certainty-chart"); + expect(chart).not.toBeNull(); + + const dots = container.querySelectorAll(".certainty-dot"); + expect(dots.length).toBe(2); + expect(dots[0].className).toContain("high"); + expect(dots[1].className).toContain("low"); + }); +}); + +describe("WordingDiffCard", () => { + let container: HTMLElement; + + beforeEach(() => { + container = document.createElement("div"); + }); + + it("renders empty state when no wording events exist", () => { + const card = new WordingDiffCard(container); + card.setData([]); + + expect(container.querySelector(".panel-title")?.textContent).toBe( + "Wording changes", + ); + expect(container.textContent).toContain("No wording-related events found"); + }); + + it("renders cards with before and after diffs for sentence modifications", () => { + const events: EvidenceEvent[] = [ + { + eventId: "w-1", + eventType: "sentence_modified", + fromRevisionId: 200, + toRevisionId: 201, + timestamp: "2026-03-01T12:00:00Z", + section: "Background", + before: "The protocol was disputed.", + after: "The protocol was established in 2024.", + deterministicFacts: [ + { + fact: "Clarified historical timing", + }, + ], + layer: "observed", + }, + ]; + + const card = new WordingDiffCard(container); + card.setData(events); + + const cards = container.querySelectorAll(".card"); + expect(cards.length).toBe(1); + + const diffs = container.querySelectorAll(".card-diff"); + expect(diffs.length).toBe(2); + expect(diffs[0].textContent).toContain("− The protocol was disputed."); + expect(diffs[1].textContent).toContain( + "+ The protocol was established in 2024.", + ); + }); +}); diff --git a/apps/ui/tests/setup.test.ts b/apps/ui/tests/setup.test.ts new file mode 100644 index 0000000..e25230a --- /dev/null +++ b/apps/ui/tests/setup.test.ts @@ -0,0 +1,7 @@ +import { describe, expect, it } from "vitest"; + +describe("repo", () => { + it("loads", () => { + expect(true).toBe(true); + }); +}); diff --git a/apps/ui/tests/timeline.test.ts b/apps/ui/tests/timeline.test.ts new file mode 100644 index 0000000..fa926d5 --- /dev/null +++ b/apps/ui/tests/timeline.test.ts @@ -0,0 +1,133 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { DiffView } from "../src/timeline/DiffView.js"; +import { EventFilter } from "../src/timeline/EventFilter.js"; +import { TimelineView } from "../src/timeline/TimelineView.js"; +import type { EventType, EvidenceEvent } from "../src/types.js"; + +describe("EventFilter", () => { + let container: HTMLElement; + + beforeEach(() => { + container = document.createElement("div"); + }); + + it("initializes with all types active and renders filter chips", () => { + const allTypes: EventType[] = [ + "citation_added", + "claim_first_seen", + "revert_detected", + ]; + const onChange = vi.fn(); + const filter = new EventFilter({ container, allTypes, onChange }); + + expect(filter.getActiveTypes().size).toBe(3); + const chips = container.querySelectorAll(".filter-tag"); + expect(chips.length).toBe(3); + }); + + it("toggles a filter type and triggers onChange callback", () => { + const allTypes: EventType[] = ["citation_added", "claim_first_seen"]; + const onChange = vi.fn(); + const filter = new EventFilter({ container, allTypes, onChange }); + + filter.toggleType("citation_added"); + expect(filter.getActiveTypes().has("citation_added")).toBe(false); + expect(onChange).toHaveBeenCalledWith(filter.getActiveTypes()); + }); + + it("clears and selects all types", () => { + const allTypes: EventType[] = ["citation_added", "claim_first_seen"]; + const onChange = vi.fn(); + const filter = new EventFilter({ container, allTypes, onChange }); + + filter.clearAll(); + expect(filter.getActiveTypes().size).toBe(0); + + filter.selectAll(); + expect(filter.getActiveTypes().size).toBe(2); + }); +}); + +describe("TimelineView", () => { + let container: HTMLElement; + + beforeEach(() => { + container = document.createElement("div"); + }); + + it("renders empty state when no events exist", () => { + const view = new TimelineView({ container }); + view.setData([]); + + expect(container.textContent).toContain( + "No events match the current filter.", + ); + }); + + it("syncs selection and notifies onSelectEvent callback", () => { + const onSelect = vi.fn(); + const view = new TimelineView({ container, onSelectEvent: onSelect }); + + const events: EvidenceEvent[] = [ + { + eventId: "evt-01", + eventType: "claim_first_seen", + fromRevisionId: 10, + toRevisionId: 11, + timestamp: "2026-05-01T00:00:00Z", + section: "Lead", + before: "", + after: "Initial statement", + deterministicFacts: [], + layer: "observed", + }, + ]; + + view.setData(events); + expect(onSelect).toHaveBeenCalledWith(events[0]); + expect(container.querySelectorAll(".timeline-item").length).toBe(1); + }); +}); + +describe("DiffView", () => { + let container: HTMLElement; + + beforeEach(() => { + container = document.createElement("div"); + }); + + it("renders empty prompt when no event is selected", () => { + const diff = new DiffView(container); + diff.clear(); + + expect(container.textContent).toContain("No event selected."); + }); + + it("renders word-level diff and metadata for a selected event", () => { + const diff = new DiffView(container); + const event: EvidenceEvent = { + eventId: "evt-diff", + eventType: "sentence_modified", + fromRevisionId: 50, + toRevisionId: 51, + timestamp: "2026-06-01T08:00:00Z", + section: "Clinical efficacy", + before: "Drug X was ineffective in trials.", + after: "Drug X showed efficacy in phase 3 trials.", + deterministicFacts: [ + { + fact: "Trial outcome updated", + detail: "primary endpoint met", + }, + ], + layer: "observed", + }; + + diff.showDiff(event); + expect(container.textContent).toContain("r50 → r51"); + expect(container.textContent).toContain("Clinical efficacy"); + expect( + container.querySelectorAll(".diff-word-added, .diff-word-removed").length, + ).toBeGreaterThan(0); + }); +}); diff --git a/apps/ui/tsconfig.json b/apps/ui/tsconfig.json new file mode 100644 index 0000000..1b9bda2 --- /dev/null +++ b/apps/ui/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "verbatimModuleSyntax": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/apps/ui/vite.config.ts b/apps/ui/vite.config.ts new file mode 100644 index 0000000..622b078 --- /dev/null +++ b/apps/ui/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + root: ".", + build: { + outDir: "dist", + }, +}); diff --git a/apps/ui/vitest.config.ts b/apps/ui/vitest.config.ts new file mode 100644 index 0000000..221f952 --- /dev/null +++ b/apps/ui/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "happy-dom", + }, +}); diff --git a/bun.lock b/bun.lock index 90849ce..6be953d 100644 --- a/bun.lock +++ b/bun.lock @@ -13,16 +13,42 @@ "vitest": "^4.1.10", }, }, + "apps/docs": { + "name": "refract-docs", + "version": "0.1.0", + "devDependencies": { + "@biomejs/biome": "^2.5.15", + "@expo-google-fonts/roboto": "^0.4.3", + "@resvg/resvg-js": "^2.6.2", + "husky": "^9.1.7", + "lint-staged": "^17.6.0", + "marked": "^15.0.12", + "vitest": "^4.1.11", + }, + }, + "apps/ui": { + "name": "refract-ui", + "version": "0.1.0", + "devDependencies": { + "@biomejs/biome": "^2.5.8", + "happy-dom": "^20.14.5", + "husky": "^9.1.7", + "lint-staged": "^17.0.5", + "typescript": "^5.7.0", + "vite": "^6.0.0", + "vitest": "^4.1.10", + }, + }, "packages/analyzers": { "name": "@refract-org/analyzers", - "version": "0.5.1", + "version": "0.5.2", "dependencies": { "@refract-org/evidence-graph": "^0.5.0", }, }, "packages/cli": { "name": "@refract-org/cli", - "version": "0.5.15", + "version": "0.5.17", "bin": { "refract": "dist/src/cli.js", "wikihistory": "dist/src/cli.js", @@ -41,18 +67,18 @@ }, "packages/eval": { "name": "@refract-org/eval", - "version": "0.2.1", + "version": "0.2.2", "dependencies": { "@refract-org/evidence-graph": "^0.5.0", }, }, "packages/evidence-graph": { "name": "@refract-org/evidence-graph", - "version": "0.5.0", + "version": "0.5.3", }, "packages/ingestion": { "name": "@refract-org/ingestion", - "version": "0.3.1", + "version": "0.3.3", "dependencies": { "@refract-org/evidence-graph": "^0.5.0", }, @@ -93,8 +119,64 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.14", "", { "os": "win32", "cpu": "x64" }, "sha512-oJWmBhoHsnhUKIke+0gXDX0mltJrWHA1UyHsrTlXwX0TL64ilVZAo+TYZm97baecV0esBdpzy3k96q+09JaZUQ=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "@expo-google-fonts/roboto": ["@expo-google-fonts/roboto@0.4.3", "", {}, "sha512-kX3wmpmu06fN1yOwYWMdjYJWNUYW6xUiAGeHbvPzzSEb3A3j23JelQ3tAgwQNKgEKRq6wtMmzixy2r7y9c8dsA=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="], + "@oxc-project/types": ["@oxc-project/types@0.151.0", "", {}, "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA=="], "@refract-org/analyzers": ["@refract-org/analyzers@workspace:packages/analyzers"], @@ -111,6 +193,32 @@ "@refract-org/persistence": ["@refract-org/persistence@workspace:packages/persistence"], + "@resvg/resvg-js": ["@resvg/resvg-js@2.6.2", "", { "optionalDependencies": { "@resvg/resvg-js-android-arm-eabi": "2.6.2", "@resvg/resvg-js-android-arm64": "2.6.2", "@resvg/resvg-js-darwin-arm64": "2.6.2", "@resvg/resvg-js-darwin-x64": "2.6.2", "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2", "@resvg/resvg-js-linux-arm64-gnu": "2.6.2", "@resvg/resvg-js-linux-arm64-musl": "2.6.2", "@resvg/resvg-js-linux-x64-gnu": "2.6.2", "@resvg/resvg-js-linux-x64-musl": "2.6.2", "@resvg/resvg-js-win32-arm64-msvc": "2.6.2", "@resvg/resvg-js-win32-ia32-msvc": "2.6.2", "@resvg/resvg-js-win32-x64-msvc": "2.6.2" } }, "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q=="], + + "@resvg/resvg-js-android-arm-eabi": ["@resvg/resvg-js-android-arm-eabi@2.6.2", "", { "os": "android", "cpu": "arm" }, "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA=="], + + "@resvg/resvg-js-android-arm64": ["@resvg/resvg-js-android-arm64@2.6.2", "", { "os": "android", "cpu": "arm64" }, "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ=="], + + "@resvg/resvg-js-darwin-arm64": ["@resvg/resvg-js-darwin-arm64@2.6.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A=="], + + "@resvg/resvg-js-darwin-x64": ["@resvg/resvg-js-darwin-x64@2.6.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw=="], + + "@resvg/resvg-js-linux-arm-gnueabihf": ["@resvg/resvg-js-linux-arm-gnueabihf@2.6.2", "", { "os": "linux", "cpu": "arm" }, "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw=="], + + "@resvg/resvg-js-linux-arm64-gnu": ["@resvg/resvg-js-linux-arm64-gnu@2.6.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg=="], + + "@resvg/resvg-js-linux-arm64-musl": ["@resvg/resvg-js-linux-arm64-musl@2.6.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg=="], + + "@resvg/resvg-js-linux-x64-gnu": ["@resvg/resvg-js-linux-x64-gnu@2.6.2", "", { "os": "linux", "cpu": "x64" }, "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw=="], + + "@resvg/resvg-js-linux-x64-musl": ["@resvg/resvg-js-linux-x64-musl@2.6.2", "", { "os": "linux", "cpu": "x64" }, "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ=="], + + "@resvg/resvg-js-win32-arm64-msvc": ["@resvg/resvg-js-win32-arm64-msvc@2.6.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ=="], + + "@resvg/resvg-js-win32-ia32-msvc": ["@resvg/resvg-js-win32-ia32-msvc@2.6.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w=="], + + "@resvg/resvg-js-win32-x64-msvc": ["@resvg/resvg-js-win32-x64-msvc@2.6.2", "", { "os": "win32", "cpu": "x64" }, "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ=="], + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.11", "", { "os": "android", "cpu": "arm" }, "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw=="], "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.11", "", { "os": "android", "cpu": "arm64" }, "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA=="], @@ -143,6 +251,56 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.64.5", "", { "os": "android", "cpu": "arm" }, "sha512-MrfgRQSpD5hkkRg6XI16B6EDLb+6+xp8xje6bpdSwkYw+qxrBKbUVrRAQu9h82UT+QumSIVQ8HXsXjPd2dlhqw=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.64.5", "", { "os": "android", "cpu": "arm64" }, "sha512-+uu2p/R9Tzn8GpoQyX1pAo5dmKT1DhXevfaCzqzmPFhnLjHLWw7uWkaVGlxmhwYoRiYYhsGfRcD5cTBpL7rgSw=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.64.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-17dys+YEmzwzzFVHQAVDONg5FALXY9kd99HFEpfk4gJVHYxoh5bGea3D4t1fXXnwkTcOe25wPyd6nWS0I8IdLQ=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.64.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-wg0zQEKkEvFZf1UaaX1UELOoPs//OSKUG7D+yITuqOsAvza7LvHTv5Wu4nmMfzNG7EYI2jp2PJC7Id6OETmsYQ=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.64.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-CoJmWcpEMuwin+TZ/ev/H9uDyF/uxUKQSWXdmIPn/UBx/uGV9+4ebMp0FwnlhWR9PE1AdzYsKf3bZevhif38+A=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.64.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-PxZnLAaTUAL5xG0W2BXLeYPKrK2b3LIASV2/e/Kf7PZvSLI1iAQIm9B9aJD+YyFIT7hc6qaMV3+OOO35R28LfA=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.64.5", "", { "os": "linux", "cpu": "arm" }, "sha512-uPo3IvnONjhbFXwWgroBeFwOicuhT7ZwQrnzkzmOtBJzeQbkUJUE1SJDfDI31ijQ5+ZUIA6sPrdUMxTSnxUWAg=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.64.5", "", { "os": "linux", "cpu": "arm" }, "sha512-ywI8Jyuc4LyHYTjC0mw4yA1ulvZBkHusdpWnoiEBsmBuHvSnt90btTBGCZ+s/2y+YNNfVAEKy33nPo5UI9zTmg=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.64.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-tmuXJoQ/3ovSr637aykZMdQteU/CB2APAz2CrvsbibuoHZi1BS1Db13cVViBdEARGwXCLn1DSJ8wsjoThLJPGA=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.64.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-5FMjSYNcUOHx7oW8Qu9KB6jGPpvF/8sG7+BDHwU/Ihh1/6FMJ9ySmsFG4tJ1qbNRuP8OLzCBDso4sF0mQaLuFQ=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.64.5", "", { "os": "linux", "cpu": "none" }, "sha512-K8ZJ+neLDbUkIrL8dqF/5LtuVCrKwo27kREgOlxjBEjlOXwHvDnHLU8Z/UKv/B49qi7e8hsSDOyoJwhwqhvX4w=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.64.5", "", { "os": "linux", "cpu": "none" }, "sha512-NlVxsq7PFPv0DBKGuNWRDKixQYQCjM53zl04mxEcKMBmJbCBIUm1JdPfIWDRHyOOFzzYPmLQzfeEwh4716ayQg=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.64.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ARhRHJYs5eCFet5azfrHD97vOkD8OzNW2RSMlvo5CcBqXaTOLfp0OHDtUQh7/K5AL5fUYoFp2fgC9KDg4vVYMQ=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.64.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-atSgreM5C7jpxd9aEXN+6vJ5wTd05iYw+S3FiQpw7VsoEl8B90llbq35ARON+GOHj5QlVQEq2vOKWCHTob/BSQ=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.64.5", "", { "os": "linux", "cpu": "none" }, "sha512-vmlmwBFGnjVWxSRoVabNkyYuscSmg52Pd0fM8jEbNT3Jmka4A6HcB84x4uAmuumznWfBVaxKOqU6jbEgomWKCA=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.64.5", "", { "os": "linux", "cpu": "none" }, "sha512-hhJrP6S9WkbsbcHgUkvCBS5Cn8IdsG8XjlLZyxjnv9+NdoYfbbZE4TWJYr8apdaj1M5t5Y1id2StnEslxpNFyA=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.64.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-NNVCyN9pGrwFmQa4bf+Cnf5aVH3g6ZpnlOg1Cs/H9swIHT9bRixijfzUqyEIWm1WtiAuR1LJ+rJH6zpBm8kgQA=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.64.5", "", { "os": "linux", "cpu": "x64" }, "sha512-dR9nd6OKnvSS60Ifz/L7Mj5zzYyoqyRn2NwTuaOONKaa3xsVGXyp8YusMbvFyEOR94DxnwgcbgsFqkHd29ZSzQ=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.64.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1tzsMjaqhsh3bAO6SJiJqh8hIr9sSPRd+i2vPPxJHod8/XYnf9TEnK0z/u3r1z1AkI347X0KbRSg9dWcxeGahw=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.64.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-01mWMQblKoXXsmk28KXtpbA96w7g/aOWUoV3AC16fdHdqkykGalqyyM+5u8M7uP5/MEMiiPv69Hv7iv/hEH/dA=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.64.5", "", { "os": "none", "cpu": "arm64" }, "sha512-trdnMPb/DNm4V/CgsaoGgr90HfQQ071wfK0KoIs4YpLvjBgVQGXhGabg2Z6nK4EcYNilrb9ZZPcsP6+zpJIdxQ=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.64.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-x2LxZlfdfsw99T6/qb9EqE/Qi84JWzibjyOJyhJsgFJy6CafaTiqIXCCnslCGrV5XWNh7fwrS3FbRENcuKnooA=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.64.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-/Qd/9aDQIo0+jmItgBmPIs8195YVbDxRRApn47GyufgbutCDOh7eP5vSKKlI4QNFeaI+wKQAkSMsPnF3EuMpaA=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.64.5", "", { "os": "win32", "cpu": "x64" }, "sha512-ngUJ/XvOWrqbd2IMsfXfh2xXFLTP3sY7sCKvxbGvYdBZ1KmXFd3ewDZ9n3/GNzAUgzRuhLT/UvJRivbNoAQRBw=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.64.5", "", { "os": "win32", "cpu": "x64" }, "sha512-IhV6ew/DuzUWwHaYmlh7MrPCV3lKFUq/amTEwysi4r/mwNPAEfAMh5dYWeTSPTeMy2pFHGMEWYtPkHzfX3tG5A=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], @@ -153,6 +311,10 @@ "@types/node": ["@types/node@26.6.3", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + + "@types/ws": ["@types/ws@8.18.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w=="], + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], @@ -209,6 +371,8 @@ "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], @@ -217,8 +381,12 @@ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], @@ -227,6 +395,8 @@ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "happy-dom": ["happy-dom@20.14.6", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-5DDsf4QmEIlRL6NkCv/fxSjYHHkZceL/IaYQdJc2h3G7DRTEai++VTjbfL0LB9geW2VrA3+vrjF3Efx+OTtX0Q=="], + "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], "int53": ["int53@0.2.4", "", {}, "sha512-a5jlKftS7HUOhkUyYD7j2sJ/ZnvWiNlZS1ldR+g1ifQ+/UuZXIE+YTc/lK1qGj/GwAU5F8Z0e1eVq2t1J5Ob2g=="], @@ -259,6 +429,8 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + "nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="], "node-int64": ["node-int64@0.3.3", "", {}, "sha512-bLdNOp5SYyqfDz/ssGHt2OTg8u+jEkCx4EoZIzprqeonFIUhlSBrKu40e/x6hIFYJx4ZEt64/9IZJyafvhGZrw=="], @@ -275,8 +447,14 @@ "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="], + "refract-docs": ["refract-docs@workspace:apps/docs"], + + "refract-ui": ["refract-ui@workspace:apps/ui"], + "rolldown": ["rolldown@1.2.11", "", { "dependencies": { "@oxc-project/types": "=0.151.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.11", "@rolldown/binding-android-arm64": "1.2.11", "@rolldown/binding-darwin-arm64": "1.2.11", "@rolldown/binding-darwin-x64": "1.2.11", "@rolldown/binding-freebsd-x64": "1.2.11", "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", "@rolldown/binding-linux-arm64-gnu": "1.2.11", "@rolldown/binding-linux-arm64-musl": "1.2.11", "@rolldown/binding-linux-ppc64-gnu": "1.2.11", "@rolldown/binding-linux-s390x-gnu": "1.2.11", "@rolldown/binding-linux-x64-gnu": "1.2.11", "@rolldown/binding-linux-x64-musl": "1.2.11", "@rolldown/binding-openharmony-arm64": "1.2.11", "@rolldown/binding-win32-arm64-msvc": "1.2.11", "@rolldown/binding-win32-x64-msvc": "1.2.11" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw=="], + "rollup": ["rollup@4.64.5", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.64.5", "@rollup/rollup-android-arm64": "4.64.5", "@rollup/rollup-darwin-arm64": "4.64.5", "@rollup/rollup-darwin-x64": "4.64.5", "@rollup/rollup-freebsd-arm64": "4.64.5", "@rollup/rollup-freebsd-x64": "4.64.5", "@rollup/rollup-linux-arm-gnueabihf": "4.64.5", "@rollup/rollup-linux-arm-musleabihf": "4.64.5", "@rollup/rollup-linux-arm64-gnu": "4.64.5", "@rollup/rollup-linux-arm64-musl": "4.64.5", "@rollup/rollup-linux-loong64-gnu": "4.64.5", "@rollup/rollup-linux-loong64-musl": "4.64.5", "@rollup/rollup-linux-ppc64-gnu": "4.64.5", "@rollup/rollup-linux-ppc64-musl": "4.64.5", "@rollup/rollup-linux-riscv64-gnu": "4.64.5", "@rollup/rollup-linux-riscv64-musl": "4.64.5", "@rollup/rollup-linux-s390x-gnu": "4.64.5", "@rollup/rollup-linux-x64-gnu": "4.64.5", "@rollup/rollup-linux-x64-musl": "4.64.5", "@rollup/rollup-openbsd-x64": "4.64.5", "@rollup/rollup-openharmony-arm64": "4.64.5", "@rollup/rollup-win32-arm64-msvc": "4.64.5", "@rollup/rollup-win32-ia32-msvc": "4.64.5", "@rollup/rollup-win32-x64-gnu": "4.64.5", "@rollup/rollup-win32-x64-msvc": "4.64.5", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-EAP5ZHcz2ijbgCWwPkpI7zh6bfsri2Ez5FtCy2dCp1k62duXzfG7DLGGh7RU2QQXWwavT+u4qpvDF/TWOiiGTw=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], "snappyjs": ["snappyjs@0.6.1", "", {}, "sha512-YIK6I2lsH072UE0aOFxxY1dPDCS43I5ktqHpeAsuLNYWkE5pGxRGWfDM4/vSUfNzXjC1Ivzt3qx31PCLmc9yqg=="], @@ -303,12 +481,60 @@ "varint": ["varint@5.0.2", "", {}, "sha512-lKxKYG6H03yCZUpAGOPOsMcGxd1RHCu1iKvEHYDPmTyq2HueGhD73ssNBqqQWfvYs04G9iUFRvmAVLW20Jw6ow=="], - "vite": ["vite@8.3.1", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.9", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA=="], + "vite": ["vite@6.4.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q=="], "vitest": ["vitest@4.1.11", "", { "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", "@vitest/pretty-format": "4.1.11", "@vitest/runner": "4.1.11", "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.11", "@vitest/browser-preview": "4.1.11", "@vitest/browser-webdriverio": "4.1.11", "@vitest/coverage-istanbul": "4.1.11", "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "ws": ["ws@8.22.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg=="], + "yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="], + + "refract-docs/@biomejs/biome": ["@biomejs/biome@2.5.15", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.15", "@biomejs/cli-darwin-x64": "2.5.15", "@biomejs/cli-linux-arm64": "2.5.15", "@biomejs/cli-linux-arm64-musl": "2.5.15", "@biomejs/cli-linux-x64": "2.5.15", "@biomejs/cli-linux-x64-musl": "2.5.15", "@biomejs/cli-win32-arm64": "2.5.15", "@biomejs/cli-win32-x64": "2.5.15" }, "bin": { "biome": "bin/biome" } }, "sha512-WZTW4slm/pdkh92K6t/3aEN++44JD1PQ7squ7RCMLI1flHGl43DVOIGXvCjHiZgBMO1V4uYxoFNtqrIA4qGuIQ=="], + + "refract-docs/lint-staged": ["lint-staged@17.6.0", "", { "dependencies": { "picomatch": "^4.0.7", "string-argv": "^0.3.2", "tinyexec": "^1.3.1" }, "optionalDependencies": { "yaml": "^2.9.1" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-nOhBfYkZEwkFtNzD5pK9s9LkcWoxJ+whLzEnhqi9nl7zTB5n7tg8+9iYgMGx2Wm3VlLu8cbNj0RACzY+nWi5iQ=="], + + "refract-ui/@biomejs/biome": ["@biomejs/biome@2.5.15", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.15", "@biomejs/cli-darwin-x64": "2.5.15", "@biomejs/cli-linux-arm64": "2.5.15", "@biomejs/cli-linux-arm64-musl": "2.5.15", "@biomejs/cli-linux-x64": "2.5.15", "@biomejs/cli-linux-x64-musl": "2.5.15", "@biomejs/cli-win32-arm64": "2.5.15", "@biomejs/cli-win32-x64": "2.5.15" }, "bin": { "biome": "bin/biome" } }, "sha512-WZTW4slm/pdkh92K6t/3aEN++44JD1PQ7squ7RCMLI1flHGl43DVOIGXvCjHiZgBMO1V4uYxoFNtqrIA4qGuIQ=="], + + "refract-ui/lint-staged": ["lint-staged@17.6.0", "", { "dependencies": { "picomatch": "^4.0.7", "string-argv": "^0.3.2", "tinyexec": "^1.3.1" }, "optionalDependencies": { "yaml": "^2.9.1" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-nOhBfYkZEwkFtNzD5pK9s9LkcWoxJ+whLzEnhqi9nl7zTB5n7tg8+9iYgMGx2Wm3VlLu8cbNj0RACzY+nWi5iQ=="], + + "refract-ui/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "vitest/vite": ["vite@8.3.1", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.9", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA=="], + + "refract-docs/@biomejs/biome/@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BZVzFhJ/mUvTLMYbc9x6el0o2Uv5zP7bACYyhFb6fSc1giroXm7PL0a5KqMJ9F+/BnkYRjagD/ROSDVOZ6eapg=="], + + "refract-docs/@biomejs/biome/@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-V5Kw63V+fVGNFhFrizDxMbGXAzZCh8kYzJpy3GI6gTapWDg7bZK9oYC9CbVgQSEdTnupz0U5Efz8Ev+5vdBBXw=="], + + "refract-docs/@biomejs/biome/@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-XaG7P7eeSLYETD3K9grfB0mQmpLQZjygdbsxekWBJAg4am79fGAtRpfRyNnTvXB2fZFNnJZG3nXm7HBat0+VUw=="], + + "refract-docs/@biomejs/biome/@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-tGzZUTcJCV7tj5Adh/Gn6nR4MycqA3iohq2LEcrarAgRBkU0h0OKd2UzCRkOsOYOuUGaUJb4YmqRqyy2feGGSw=="], + + "refract-docs/@biomejs/biome/@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.15", "", { "os": "linux", "cpu": "x64" }, "sha512-xE4iEW/3LqlYj9GFgGrFsFSH51dEEpUbYWBfeOv0q87WkUDxK2o/HhreSb7qMJqck70RVM6Lg96hgedcAYcOkA=="], + + "refract-docs/@biomejs/biome/@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.15", "", { "os": "linux", "cpu": "x64" }, "sha512-IlxUcyxilVGPsE008x13pWdkTbU3nQpP2i9b5UrbOYj6goBkKsRX1XB7yJLcV+O1H9LBchIIm4adOaVLZBX0ZQ=="], + + "refract-docs/@biomejs/biome/@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-2kPKzhNlm8C+Ru3GcO0Me2ODkCBLrVOUNuyi84RJyWDVKAA4+Kjj3jMjL938lF6BRfvltC4vB2nPY9SvN51Ovg=="], + + "refract-docs/@biomejs/biome/@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.15", "", { "os": "win32", "cpu": "x64" }, "sha512-yAzh4UqEImV6Hcy0zjUqNfJAsUhoD64FBQCdTG8Md/Z2wQeVP/ZmJ3XRYAs1g8J9fjTFyl2fCtc5FB5til47+g=="], + + "refract-ui/@biomejs/biome/@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BZVzFhJ/mUvTLMYbc9x6el0o2Uv5zP7bACYyhFb6fSc1giroXm7PL0a5KqMJ9F+/BnkYRjagD/ROSDVOZ6eapg=="], + + "refract-ui/@biomejs/biome/@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-V5Kw63V+fVGNFhFrizDxMbGXAzZCh8kYzJpy3GI6gTapWDg7bZK9oYC9CbVgQSEdTnupz0U5Efz8Ev+5vdBBXw=="], + + "refract-ui/@biomejs/biome/@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-XaG7P7eeSLYETD3K9grfB0mQmpLQZjygdbsxekWBJAg4am79fGAtRpfRyNnTvXB2fZFNnJZG3nXm7HBat0+VUw=="], + + "refract-ui/@biomejs/biome/@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-tGzZUTcJCV7tj5Adh/Gn6nR4MycqA3iohq2LEcrarAgRBkU0h0OKd2UzCRkOsOYOuUGaUJb4YmqRqyy2feGGSw=="], + + "refract-ui/@biomejs/biome/@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.15", "", { "os": "linux", "cpu": "x64" }, "sha512-xE4iEW/3LqlYj9GFgGrFsFSH51dEEpUbYWBfeOv0q87WkUDxK2o/HhreSb7qMJqck70RVM6Lg96hgedcAYcOkA=="], + + "refract-ui/@biomejs/biome/@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.15", "", { "os": "linux", "cpu": "x64" }, "sha512-IlxUcyxilVGPsE008x13pWdkTbU3nQpP2i9b5UrbOYj6goBkKsRX1XB7yJLcV+O1H9LBchIIm4adOaVLZBX0ZQ=="], + + "refract-ui/@biomejs/biome/@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-2kPKzhNlm8C+Ru3GcO0Me2ODkCBLrVOUNuyi84RJyWDVKAA4+Kjj3jMjL938lF6BRfvltC4vB2nPY9SvN51Ovg=="], + + "refract-ui/@biomejs/biome/@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.15", "", { "os": "win32", "cpu": "x64" }, "sha512-yAzh4UqEImV6Hcy0zjUqNfJAsUhoD64FBQCdTG8Md/Z2wQeVP/ZmJ3XRYAs1g8J9fjTFyl2fCtc5FB5til47+g=="], } } diff --git a/package.json b/package.json index 812bc5d..86bd983 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,9 @@ "packages/cli", "packages/mcp", "packages/persistence", - "packages/eval" + "packages/eval", + "apps/docs", + "apps/ui" ], "scripts": { "build": "tsc -b", diff --git a/tests/monorepo-contract.test.ts b/tests/monorepo-contract.test.ts new file mode 100644 index 0000000..ee6a2a8 --- /dev/null +++ b/tests/monorepo-contract.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +const ROOT = join(import.meta.dirname, ".."); + +describe("Refract Monorepo App Consolidation Contract (TDD Harness)", () => { + it("contains apps/docs and apps/ui in the monorepo root", () => { + expect(existsSync(join(ROOT, "apps", "docs"))).toBe(true); + expect(existsSync(join(ROOT, "apps", "ui"))).toBe(true); + }); + + it("verifies apps/docs has a valid package manifest and build script", () => { + const pkgPath = join(ROOT, "apps", "docs", "package.json"); + expect(existsSync(pkgPath)).toBe(true); + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + expect(pkg.name).toBe("refract-docs"); + expect(pkg.scripts?.build).toBeDefined(); + }); + + it("verifies apps/ui has a valid package manifest and build script", () => { + const pkgPath = join(ROOT, "apps", "ui", "package.json"); + expect(existsSync(pkgPath)).toBe(true); + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + expect(pkg.name).toBe("refract-ui"); + expect(pkg.scripts?.build).toBeDefined(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 3afac05..e24db8a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,7 +11,7 @@ export default defineConfig({ // suites again through every dependent package: performance.test.ts ran // four times, and one failure was reported as four. Vitest's own default // is **/node_modules/**; naming `exclude` at all replaced it. - exclude: ["**/node_modules/**", "**/.opencode/**", "**/dist/**"], + exclude: ["**/node_modules/**", "**/.opencode/**", "**/dist/**", "apps/**"], // Prints once per run which tests the live gate skipped. It has to be a // global hook: vitest discards console output from a file whose tests are // all skipped, which is every file the gate closes.