diff --git a/.changeset/orange-lines-thank.md b/.changeset/orange-lines-thank.md
new file mode 100644
index 000000000..a845151cc
--- /dev/null
+++ b/.changeset/orange-lines-thank.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/demo/ctx/.gitignore b/demo/ctx/.gitignore
deleted file mode 100644
index 1170717c1..000000000
--- a/demo/ctx/.gitignore
+++ /dev/null
@@ -1,136 +0,0 @@
-# Logs
-logs
-*.log
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-lerna-debug.log*
-.pnpm-debug.log*
-
-# Diagnostic reports (https://nodejs.org/api/report.html)
-report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
-
-# Runtime data
-pids
-*.pid
-*.seed
-*.pid.lock
-
-# Directory for instrumented libs generated by jscoverage/JSCover
-lib-cov
-
-# Coverage directory used by tools like istanbul
-coverage
-*.lcov
-
-# nyc test coverage
-.nyc_output
-
-# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
-.grunt
-
-# Bower dependency directory (https://bower.io/)
-bower_components
-
-# node-waf configuration
-.lock-wscript
-
-# Compiled binary addons (https://nodejs.org/api/addons.html)
-build/Release
-
-# Dependency directories
-node_modules/
-jspm_packages/
-
-# Snowpack dependency directory (https://snowpack.dev/)
-web_modules/
-
-# TypeScript cache
-*.tsbuildinfo
-
-# Optional npm cache directory
-.npm
-
-# Optional eslint cache
-.eslintcache
-
-# Optional stylelint cache
-.stylelintcache
-
-# Microbundle cache
-.rpt2_cache/
-.rts2_cache_cjs/
-.rts2_cache_es/
-.rts2_cache_umd/
-
-# Optional REPL history
-.node_repl_history
-
-# Output of 'npm pack'
-*.tgz
-
-# Yarn Integrity file
-.yarn-integrity
-
-# dotenv environment variable files
-.env
-.env.development.local
-.env.test.local
-.env.production.local
-.env.local
-
-# parcel-bundler cache (https://parceljs.org/)
-.cache
-.parcel-cache
-
-# Next.js build output
-.next
-out
-
-# Nuxt.js build / generate output
-.nuxt
-dist
-
-# Gatsby files
-.cache/
-# Comment in the public line in if your project uses Gatsby and not Next.js
-# https://nextjs.org/blog/next-9-1#public-directory-support
-# public
-
-# vuepress build output
-.vuepress/dist
-
-# vuepress v2.x temp and cache directory
-.temp
-.cache
-
-# vitepress build output
-**/.vitepress/dist
-
-# vitepress cache directory
-**/.vitepress/cache
-
-# Docusaurus cache and generated files
-.docusaurus
-
-# Serverless directories
-.serverless/
-
-# FuseBox cache
-.fusebox/
-
-# DynamoDB Local files
-.dynamodb/
-
-# TernJS port file
-.tern-port
-
-# Stores VSCode versions used for testing VSCode extensions
-.vscode-test
-
-# yarn v2
-.yarn/cache
-.yarn/unplugged
-.yarn/build-state.yml
-.yarn/install-state.gz
-.pnp.*
diff --git a/demo/ctx/README.md b/demo/ctx/README.md
deleted file mode 100644
index a384c465a..000000000
--- a/demo/ctx/README.md
+++ /dev/null
@@ -1,362 +0,0 @@
-
-
-
-
-
- ctx — AI context engine for lingo.dev
-
-
-
- Your AI translator knows grammar. ctx teaches it your product.
-
-
-
-
-
- Problem •
- What It Fixes •
- How It Works •
- Brand Voices •
- Agentic Pipeline •
- Install •
- Usage •
- JSONC Notes
-
-
-
-
-
-
-
-
----
-
-## The Problem
-
-lingo.dev is genuinely great — fast, cheap, AI-powered translation that plugs straight into your codebase. But out of the box, it has no idea who *you* are. It'll translate "ship it" differently every time. It'll switch between formal and informal mid-product. It'll call your core feature something different in every locale. The translations are correct — they're just not *yours*.
-
-> "ship" → translated as "enviar" (to mail/send) instead of "lanzar" (to launch/deploy)
-> "fly solo" → translated literally instead of "trabajar solo"
-> tú vs vos inconsistency across files because no one wrote down the register rule
-
-lingo.dev solves this with [`lingo-context.md`](https://lingo.dev/en/translator-context) — a global context file it injects into every translation prompt. But writing it by hand takes hours, and keeping it current as your codebase grows is easy to forget.
-
-**ctx automates that entirely.** It reads your project, understands your product, and generates a precise, structured `lingo-context.md`. Then it keeps it in sync as your source files change — file by file, cheaply, only processing what actually changed.
-
----
-
-## What ctx Actually Fixes
-
-lingo handles the translation. ctx makes sure every translation sounds like it came from the same company, in the same voice, on the same product. It's the difference between *translated* and *localized*.
-
-- **Pronoun consistency** — picks `tú` or `usted` once, enforces it everywhere. No more mixed register in the same product.
-- **Grammar conventions** — locale-specific rules baked in. German compound nouns, French gender agreements, Japanese politeness levels — defined once, applied always.
-- **Repeated terms** — your product's vocabulary is locked. "Workspace" is always "Workspace", not "Space", "Area", or "Room" depending on which string Claude saw first.
-- **On-brand tone** — not just "be professional" (useless), but "use informal du, keep CTAs under 4 words, never use exclamation marks".
-
----
-
-## How It Works
-
-```
-your lingo.dev project
-├── i18n.json ← ctx reads this: locales, bucket paths, provider config
-├── lingo-context.md ← ctx generates and maintains this
-└── app/locales/
- ├── en.tsx ← source locale files ctx reads and analyses
- ├── en.jsonc ← ctx injects per-key translator notes here
- └── en/
- └── getting-started.md
-```
-
-ctx reads `i18n.json` to discover your bucket files, analyses only the source locale, and writes a context file that covers:
-
-- **App** — what the product does, factual, no marketing copy
-- **Tone & Voice** — explicit dos and don'ts the translator must follow
-- **Audience** — who reads these strings and in what context
-- **Languages** — per-language pitfalls: pronoun register, dialect, length warnings
-- **Tricky Terms** — every ambiguous, idiomatic, or domain-specific term with exact guidance
-- **Files** — per-file rules for files that need them
-
-Once written, ctx injects the full context into `i18n.json` as the provider prompt so lingo.dev carries it into every translation automatically.
-
----
-
-## UI
-
-ctx has a minimal terminal UI designed to stay out of your way. Every stage is clearly labelled, tool calls are shown inline, and you always know where you are.
-
-**Fresh scan — first run:**
-```
- ctx /your-project
- lingo-context.md · claude-sonnet-4-6 · en → es fr de
-
- ◆ Research
- scanning project + searching web
-
- ↳ web_search lingo crypto exchange localization
- ↳ read_file en.jsonc
- ↳ list_files app/locales
- ↳ read_file package.json
-
- ◆ Context Generation
- writing lingo-context.md
-
- ↳ read_file en.jsonc
- ↳ read_file en/getting-started.md
- ↳ write_file lingo-context.md
-
- ◆ JSONC Injection
- ↳ annotate en.jsonc
-
- ┌─ Review: en.jsonc ──────────────────────────────────────┐
-
- {
- // Buy/sell action — use financial verb, not generic "send"
- "trade.submit": "Place order",
-
- // Shown on empty portfolio — encouraging, not alarming
- "portfolio.empty": "No assets yet"
- }
-
- └──────────────────────────────────────────────────────────┘
-
- ❯ Accept
- Request changes
- Skip
-
- ◆ Provider Sync
-
- ✓ Done
-```
-
-**Update run — after changing a file:**
-```
- ctx /your-project
- lingo-context.md · claude-sonnet-4-6 · en → es fr de
-
- [1/2] en.jsonc
- ↳ write_file lingo-context.md
-
- [2/2] en/getting-started.md
- ↳ write_file lingo-context.md
-
- ~ Tricky Terms (+2 terms)
- ~ Files (getting-started.md updated)
-
- ◆ JSONC Injection
- ↳ annotate en.jsonc
-
- ◆ Provider Sync
-
- ✓ Done
-```
-
-**No changes:**
-```
- ✓ No new changes (uncommitted) — context is up to date.
-
- ❯ No, exit
- Yes, regenerate
-```
-
-**Brand voices (`--voices`):**
-```
- ◆ Brand Voices
- generating voice for es
-
- ┌─ Review: voice · es ────────────────────────────────────┐
-
- Write in Spanish using informal tú throughout — never usted.
- Tone is direct and confident, like a senior dev talking to
- a peer. Avoid exclamation marks. Keep CTAs under 4 words.
- Financial terms use standard Latin American conventions.
-
- └──────────────────────────────────────────────────────────┘
-
- ❯ Accept
- Request changes
- Skip
-```
-
----
-
-## Brand Voices
-
-Beyond the global context, ctx can generate a **brand voice** per locale — a concise prose brief that tells the translator exactly how your product sounds in that language.
-
-```bash
-ctx ./my-app --voices
-```
-
-A brand voice covers pronoun register (tú/usted, du/Sie, tu/vous), tone, audience context, and locale-specific conventions — all derived from your existing `lingo-context.md`. Voices are written into `i18n.json` under `provider.voices` and picked up by lingo.dev automatically.
-
-Each voice goes through a review loop — accept, skip, or give feedback and the agent revises.
-
----
-
-## Agentic Pipeline
-
-ctx runs as a multi-step agentic pipeline. Each step is a separate Claude call with a focused job — not one big prompt trying to do everything.
-
-```
-ctx run
- │
- ├── Step 1: Research (first run only, optional)
- │ Claude searches the web + reads your project files
- │ Produces a product brief: market, audience, tone conventions
- │ Or: answer 4 quick questions yourself
- │
- ├── Step 2: Fresh scan (first run only)
- │ Claude agent explores the project using tools:
- │ list_files → read_file → write_file
- │ Reads: i18n.json + bucket files + package.json + README
- │ Writes: lingo-context.md
- │
- ├── Step 3: Per-file update (subsequent runs)
- │ For each changed source file — one Claude call per file:
- │ Reads: current lingo-context.md + one changed file
- │ Updates: only the sections affected by that file
- │ Records: file hash so it won't re-process unless content changes
- │
- ├── Step 4: JSONC comment injection (for .jsonc buckets)
- │ One Claude call per .jsonc source file:
- │ Reads: lingo-context.md + source file
- │ Writes: per-key // translator notes inline in the file
- │ lingo.dev reads these natively during translation
- │
- └── Step 5: Provider sync
- Writes the full lingo-context.md into i18n.json provider.prompt
- so lingo.dev uses it automatically — no manual step needed
-```
-
-**Why per-file?** Sending all changed files in one prompt crushes context and produces shallow analysis. Processing one file at a time keeps the window focused — Claude can deeply scan every string for tricky terms rather than skimming.
-
-**Human in the loop:** Every write shows a preview and waits for approval. You can request changes and the agent revises with full context, or skip a step entirely.
-
----
-
-## Install
-
-**Requirements:** [Node.js](https://nodejs.org) (with `tsx`) and an Anthropic API key.
-
-```bash
-git clone https://github.com/lingodotdev/lingo.dev
-cd lingo.dev/demo/ctx
-bun install
-bun link
-```
-
-```bash
-export ANTHROPIC_API_KEY=your_key_here
-```
-
----
-
-## Usage
-
-```bash
-# Run in your lingo.dev project
-ctx ./my-app
-
-# With a focus prompt
-ctx ./my-app -p "B2B SaaS, formal tone, legal/compliance domain"
-
-# Preview what would run without writing anything
-ctx ./my-app --dry-run
-
-# Use files changed in last 3 commits
-ctx ./my-app --commits 3
-
-# Generate brand voices for all target locales
-ctx ./my-app --voices
-```
-
-**Options:**
-
-| Flag | Short | Default | Description |
-|------|-------|---------|-------------|
-| `--prompt` | `-p` | interactive | What the agent should focus on |
-| `--out` | `-o` | `lingo-context.md` | Output file path |
-| `--model` | `-m` | `claude-sonnet-4-6` | Claude model to use |
-| `--commits` | `-c` | — | Use files changed in last N commits |
-| `--dry-run` | `-d` | `false` | Preview what would run, write nothing |
-| `--voices` | `-V` | `false` | Generate brand voices only |
-| `--help` | `-h` | — | Show help |
-
----
-
-## Modes
-
-| Mode | Trigger | What runs |
-|------|---------|-----------|
-| **Fresh** | No `lingo-context.md` yet | Research → full agent scan → writes context from scratch |
-| **Update** | Context exists, files changed | Per-file update — one agent call per changed bucket file |
-| **Commits** | `--commits ` | Same as update but diffs against last N commits instead of uncommitted |
-
-State is tracked via content hashes in `~/.ctx/state/` — only genuinely new or changed files are processed. Hashes are saved only after the full pipeline completes, so cancelling mid-run leaves state untouched and the same changes are detected next run.
-
----
-
-## JSONC Translator Notes
-
-For `.jsonc` bucket files, ctx injects per-key translator notes directly into the source:
-
-```jsonc
-{
- // Navigation item in the top header — keep under 12 characters
- "nav.dashboard": "Dashboard",
-
- // Button that triggers payment — not just "submit", implies money changing hands
- "checkout.pay": "Pay now",
-
- // Shown when session expires — urgent but not alarming, avoid exclamation marks
- "auth.session_expired": "Your session has ended"
-}
-```
-
-lingo.dev reads these `//` comments natively and passes them to the LLM alongside the string. Notes are generated from `lingo-context.md` so they stay consistent with your global rules. Only changed `.jsonc` files get re-annotated on update runs.
-
----
-
-## Review Before Writing
-
-ctx never writes silently. Every write — context file, JSONC comments, or brand voices — shows a preview first:
-
-```
- ┌─ Review: lingo-context.md ──────────────────────────────┐
-
- ## App
- A B2B SaaS tool for managing compliance workflows...
-
- ## Tone & Voice
- Formal, precise. Use "you" not "we"...
- … 42 more lines
-
- └──────────────────────────────────────────────────────────┘
-
- ❯ Accept
- Request changes
- Skip
-```
-
-Choose **Request changes**, describe what's wrong, and the agent revises with full context and shows you the result again.
-
----
-
-## Tested On
-
-- [lingo-crypto](https://github.com/Bhavya031/lingo-crypto) — crypto exchange UI
-- [others](https://github.com/Bhavya031/others) — mixed project types
-
----
-
-## Requirements
-
-- [Node.js](https://nodejs.org) v18+ with [tsx](https://github.com/privatenumber/tsx)
-- `ANTHROPIC_API_KEY`
-- A lingo.dev project with `i18n.json`
-
----
-
-*Built for the lingo.dev hackathon.*
diff --git a/demo/ctx/agent.ts b/demo/ctx/agent.ts
deleted file mode 100755
index cb236da9f..000000000
--- a/demo/ctx/agent.ts
+++ /dev/null
@@ -1,292 +0,0 @@
-#!/usr/bin/env tsx
-import Anthropic from "@anthropic-ai/sdk";
-import fs from "fs";
-import path from "path";
-import { values, positionals, selectMenu, textPrompt, die } from "./src/cli.ts";
-import { loadState, clearState, fileHash, filterNewFiles, recordFiles, type FileEntry } from "./src/state.ts";
-import { readFile, listFiles, getChangedFiles, formatFileBlock } from "./src/files.ts";
-import { allTools, writeOnlyTools, runAgent } from "./src/agent-loop.ts";
-import { runJsoncInjection } from "./src/jsonc.ts";
-import { printUpdateSummary, updateI18nProvider } from "./src/i18n.ts";
-import { runResearch } from "./src/research.ts";
-import { runVoices } from "./src/voices.ts";
-import { printHeader, phase, progress, fileItem, ok, warn, fail, info } from "./src/ui.ts";
-
-const targetDir = path.resolve(positionals[0] ?? process.cwd());
-const outPath = path.resolve(targetDir, values.out!);
-const model = values.model!;
-const commitCount = values.commits ? parseInt(values.commits, 10) : null;
-const dryRun = values["dry-run"]!;
-const voicesOnly = values["voices"]!;
-const debug = values["debug"]!;
-
-const dbg = (...args: any[]) => { if (debug) console.log("\x1B[2m[debug]", ...args, "\x1B[0m"); };
-const SKIPPED_MSG = `lingo-context.md was not written — skipping JSONC injection and provider update.`;
-
-async function run() {
- if (!fs.existsSync(targetDir)) {
- die(` ✗ Target folder not found: ${targetDir}`);
- }
-
- const i18nPath = path.join(targetDir, "i18n.json");
- if (!fs.existsSync(i18nPath)) {
- die(` ! No i18n.json found — is this a lingo project?`, ` Run: npx lingo.dev@latest init`);
- }
-
- const client = new Anthropic();
- const hasContext = fs.existsSync(outPath);
- const isUpdateMode = hasContext && commitCount === null;
- const isCommitMode = hasContext && commitCount !== null;
- const isFreshMode = !hasContext;
-
- const i18nContent = readFile(i18nPath);
- const i18nBlock = `\n--- i18n.json ---\n${i18nContent}\n`;
-
- let sourceLocale = "en";
- let targetLocales: string[] = [];
- let bucketIncludes: string[] = [];
- let jsoncSourceFiles: string[] = [];
- try {
- const i18n = JSON.parse(i18nContent);
- sourceLocale = i18n.locale?.source ?? i18n.locale ?? "en";
- targetLocales = (i18n.locale?.targets ?? i18n.locales ?? []).filter((l: string) => l !== sourceLocale);
- bucketIncludes = Object.values(i18n.buckets ?? {})
- .flatMap((b: any) => b.include ?? [])
- .map((p: string) => p.replace("[locale]", sourceLocale));
- jsoncSourceFiles = Object.values(i18n.buckets ?? {})
- .flatMap((b: any) => b.include ?? [])
- .filter((p: string) => p.endsWith(".jsonc"))
- .map((p: string) => path.resolve(targetDir, p.replace("[locale]", sourceLocale)))
- .filter((f: string) => fs.existsSync(f));
- } catch {}
-
- function matchesBucket(filePath: string): boolean {
- return bucketIncludes.some((pattern) => {
- const abs = path.resolve(targetDir, pattern);
- return filePath === abs || filePath.endsWith(pattern);
- });
- }
-
- function resolveBucketFiles(): string[] {
- const results: string[] = [];
- for (const p of bucketIncludes) {
- if (p.includes("*")) {
- const dir = path.resolve(targetDir, path.dirname(p));
- const ext = path.extname(p);
- try {
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
- if (entry.isFile() && (!ext || entry.name.endsWith(ext))) {
- results.push(path.join(dir, entry.name));
- }
- }
- } catch {}
- } else {
- const abs = path.resolve(targetDir, p);
- try { if (fs.statSync(abs).isFile()) results.push(abs); } catch {}
- }
- }
- return results;
- }
-
- const agent = (system: string, message: string, tools: Anthropic.Tool[], review = false) =>
- runAgent(client, model, system, message, tools, listFiles, targetDir, review);
-
- const printDone = () => fs.existsSync(outPath) ? ok(`Done → ${outPath}`) : warn(`Output file was not created`);
- const modeLabel = isCommitMode ? `last ${commitCount} commit(s)` : "uncommitted";
-
- printHeader({ targetDir, outPath, model, source: sourceLocale, targets: targetLocales });
-
- if (voicesOnly) {
- await runVoices(client, model, outPath, i18nPath, targetLocales);
- return;
- }
-
- dbg(`hasContext=${hasContext} isFreshMode=${isFreshMode} isUpdateMode=${isUpdateMode} isCommitMode=${isCommitMode}`);
- dbg(`bucketIncludes:`, bucketIncludes);
- dbg(`jsoncSourceFiles:`, jsoncSourceFiles);
- dbg(`outPath exists:`, hasContext);
-
- const freshSystem = `You are a localization context agent. Generate lingo-context.md so an AI translator produces accurate, consistent translations.
-
-Read: i18n.json (provided) → source bucket files → package.json + README. Stop there unless something is still unclear.
-
-Rules:
-- Every rule must be actionable. Bad: "be careful with tone". Good: "use tú not usted — never vos".
-- App section: what it does and who uses it. No marketing language.
-- Language sections: named pitfalls only, no generic advice. Include pronoun register (tú/usted/vos), script/dialect notes, and length warnings.
-- Tricky Terms: flag every ambiguous, idiomatic, or domain-specific term. For tech terms, name the wrong translation risk explicitly (e.g. "ship" — mistranslated as mail/send, means deploy/launch).
-
-Structure (use exactly):
-
-## App
-## Tone & Voice
-## Audience
-## Languages
-Source: ${sourceLocale}
-Targets: ${targetLocales.join(", ") || "none specified"}
-###
--
-
-## Tricky Terms
-| Term | Risk | Guidance |
-|------|------|----------|
-
-## Files
-###
-What / Tone / Priority
-
-You MUST call write_file to write lingo-context.md. Do NOT output the file content as text — call write_file.`;
-
- const freshMessage = (prompt: string, brief?: string | null) => [
- `Instructions:\n${prompt}`,
- brief ? `\n${brief}` : "",
- i18nBlock,
- `Target folder: ${targetDir}`,
- `Output file: ${outPath}`,
- `\nExplore the project and write lingo-context.md.`,
- ].join("\n");
-
- // --- Update / Commit mode: detect changes BEFORE asking for instructions ---
- let earlyChangedFiles: ReturnType | null = null;
- if (isUpdateMode || isCommitMode) {
- const state = loadState(outPath);
- const gitChanged = getChangedFiles(targetDir, commitCount);
- dbg(`gitChanged:`, gitChanged);
- const allBucket = resolveBucketFiles();
- dbg(`resolveBucketFiles:`, allBucket);
- const candidates = [...new Set([...gitChanged.filter(f => matchesBucket(f) || path.basename(f) === 'i18n.json'), ...allBucket])];
- dbg(`candidates:`, candidates);
- earlyChangedFiles = filterNewFiles(candidates, state);
- dbg(`earlyChangedFiles:`, earlyChangedFiles.map(([f]) => f));
-
- if (earlyChangedFiles.length === 0) {
- ok(`No new changes (${modeLabel}) — context is up to date.`);
- const choice = await selectMenu("Regenerate anyway?", ["No, exit", "Yes, regenerate"], 0);
- if (choice === 0) return;
-
- const override = values.prompt ?? await textPrompt("What should the full regeneration cover?", "blank for default");
- const regen = override || "Generate a comprehensive lingo-context.md for this project.";
- const prevMtime = fs.existsSync(outPath) ? fs.statSync(outPath).mtimeMs : null;
- await agent(freshSystem, freshMessage(regen), allTools, true);
- const newMtime = fs.existsSync(outPath) ? fs.statSync(outPath).mtimeMs : null;
- if (newMtime === null || newMtime === prevMtime) { warn(SKIPPED_MSG); return; }
- clearState(outPath);
- phase("JSONC Injection");
- const jsoncEntries1 = await runJsoncInjection(client, model, jsoncSourceFiles, outPath, true);
- phase("Provider Sync");
- await updateI18nProvider(i18nPath, outPath);
- recordFiles([...allBucket.map((f) => [f, fileHash(f)] as FileEntry), ...jsoncEntries1, [i18nPath, fileHash(i18nPath)]], outPath);
- return printDone();
- }
- }
-
- // --- Dry run ---
- if (dryRun) {
- if (isFreshMode) {
- phase("Fresh Scan", "would generate lingo-context.md");
- if (jsoncSourceFiles.length) {
- info(`JSONC inject ${jsoncSourceFiles.length} file(s)`);
- jsoncSourceFiles.forEach((f) => fileItem(path.relative(targetDir, f)));
- }
- } else if (earlyChangedFiles && earlyChangedFiles.length > 0) {
- phase("Update", `${earlyChangedFiles.length} file(s) from ${modeLabel}`);
- earlyChangedFiles.forEach(([f]) => fileItem(path.relative(targetDir, f)));
- const jsonc = earlyChangedFiles.map(([f]) => f).filter((f) => jsoncSourceFiles.includes(f));
- if (jsonc.length) {
- info(`JSONC inject ${jsonc.length} file(s)`);
- jsonc.forEach((f) => fileItem(path.relative(targetDir, f)));
- }
- } else {
- ok(`Up to date — nothing to do`);
- }
- warn(`dry-run — no files written`);
- return;
- }
-
- // Get instructions
- let instructions = values.prompt;
- if (!instructions) {
- const question = hasContext ? "What changed or what should the update cover?" : "What should the context summary include?";
- const defaultInstr = hasContext ? "Update lingo-context.md to reflect any recent changes." : "Generate a comprehensive lingo-context.md for this project.";
- instructions = await textPrompt(question, "blank for default");
- if (!instructions) instructions = defaultInstr;
- }
-
- // --- Fresh mode ---
- if (isFreshMode) {
- const brief = await runResearch(client, targetDir, i18nBlock);
- clearState(outPath);
- dbg(`ensuring output dir:`, path.dirname(outPath));
- fs.mkdirSync(path.dirname(outPath), { recursive: true });
- phase("Context Generation", `writing ${path.basename(outPath)}`);
- await agent(freshSystem, freshMessage(instructions, brief), allTools, true);
- dbg(`after agent — outPath exists:`, fs.existsSync(outPath));
- if (!fs.existsSync(outPath)) { warn(SKIPPED_MSG); return; }
- const bucketFiles = resolveBucketFiles();
- phase("JSONC Injection");
- const jsoncEntries2 = await runJsoncInjection(client, model, jsoncSourceFiles, outPath, true);
- phase("Provider Sync");
- await updateI18nProvider(i18nPath, outPath);
- // Record all hashes last — after everything completes, so a cancel leaves state unchanged
- recordFiles([...bucketFiles.map((f) => [f, fileHash(f)] as FileEntry), ...jsoncEntries2, [i18nPath, fileHash(i18nPath)]], outPath);
- return printDone();
- }
-
- // --- Update / Commit mode ---
- if ((isUpdateMode || isCommitMode) && earlyChangedFiles && earlyChangedFiles.length > 0) {
- const changedFiles = earlyChangedFiles;
- phase("Context Update", `${changedFiles.length} changed file(s) from ${modeLabel}`);
-
- const updateSystem = `You are a localization context updater. One file at a time.
-
-Given: existing lingo-context.md + one changed source file. Update the context to reflect it.
-
-Rules:
-- Touch only what this file changes. Leave all other sections as-is.
-- Tricky Terms: scan every string. Add any term that is ambiguous, idiomatic, or has a wrong-translation risk:
- - Tech verbs with non-obvious meaning (ship = deploy not mail, run = execute not jog, push = git push not shove)
- - Idioms that fail literally ("off to the races", "bang your head against the wall")
- - Pronoun/register traps — if the file uses a pronoun register, note it and enforce consistency (e.g. tú throughout — never vos)
- - Cultural references that don't map across regions
-- Language section: if a new consistency rule emerges from this file, add it.
-
-You MUST call write_file with the full updated lingo-context.md. Do NOT output the content as text.`;
-
- const beforeContext = readFile(outPath);
-
- for (let i = 0; i < changedFiles.length; i++) {
- const [filePath] = changedFiles[i];
- const fileName = path.relative(targetDir, filePath);
- progress(i + 1, changedFiles.length, fileName);
-
- const currentContext = readFile(outPath);
- const updateMessage = [
- `Instructions:\n${instructions}`,
- `\n--- Existing context ---\n${currentContext}`,
- `\n--- File to process ---${formatFileBlock(filePath)}`,
- `\nUpdate the context file at: ${outPath}`,
- ].join("\n");
-
- await agent(updateSystem, updateMessage, writeOnlyTools, true);
- }
-
- printUpdateSummary(beforeContext, readFile(outPath));
-
- const changedJsonc = changedFiles.map(([f]) => f).filter((f) => jsoncSourceFiles.includes(f));
- phase("JSONC Injection");
- const jsoncEntries3 = await runJsoncInjection(client, model, changedJsonc, outPath, true);
- phase("Provider Sync");
- await updateI18nProvider(i18nPath, outPath);
-
- // Record all hashes last — after everything completes, so a cancel leaves state unchanged
- recordFiles([
- ...changedFiles.map(([f]) => [f, fileHash(f)] as FileEntry),
- ...jsoncEntries3,
- [i18nPath, fileHash(i18nPath)],
- ], outPath);
- }
-
- printDone();
-}
-
-run().catch((e) => die(` ✗ ${e.message}`));
diff --git a/demo/ctx/package.json b/demo/ctx/package.json
deleted file mode 100644
index 1d5f33cc7..000000000
--- a/demo/ctx/package.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
- "name": "ctx",
- "version": "1.0.0",
- "type": "module",
- "bin": {
- "ctx": "./agent.ts"
- },
- "scripts": {
- "agent": "tsx agent.ts"
- },
- "dependencies": {
- "@anthropic-ai/sdk": "^0.78.0",
- "tsx": "^4.19.3"
- }
-}
diff --git a/demo/ctx/src/agent-loop.ts b/demo/ctx/src/agent-loop.ts
deleted file mode 100644
index 2dde00f6a..000000000
--- a/demo/ctx/src/agent-loop.ts
+++ /dev/null
@@ -1,137 +0,0 @@
-import Anthropic from "@anthropic-ai/sdk";
-import path from "path";
-import { selectMenu, textPrompt } from "./cli.ts";
-import { readFile, writeFile } from "./files.ts";
-import { toolCall, reviewBox } from "./ui.ts";
-
-export const allTools: Anthropic.Tool[] = [
- {
- name: "list_files",
- description: "List all files in a directory (ignores node_modules, .next, .git, dist)",
- input_schema: {
- type: "object",
- properties: { directory: { type: "string" } },
- required: ["directory"],
- additionalProperties: false,
- },
- },
- {
- name: "read_file",
- description: "Read the contents of a file",
- input_schema: {
- type: "object",
- properties: { file_path: { type: "string" } },
- required: ["file_path"],
- additionalProperties: false,
- },
- },
- {
- name: "write_file",
- description: "Write content to a file",
- input_schema: {
- type: "object",
- properties: {
- file_path: { type: "string" },
- content: { type: "string" },
- },
- required: ["file_path", "content"],
- additionalProperties: false,
- },
- },
-];
-
-export const writeOnlyTools: Anthropic.Tool[] = [allTools[2]];
-
-function resolveSafe(allowedRoot: string, rawPath: string): string | null {
- const root = path.resolve(allowedRoot);
- const resolved = path.resolve(root, rawPath);
- if (resolved !== root && !resolved.startsWith(root + path.sep)) return null;
- return resolved;
-}
-
-function executeTool(name: string, input: Record, listFilesFn: (dir: string) => string[], allowedRoot: string): string {
- switch (name) {
- case "list_files": {
- const dir = resolveSafe(allowedRoot, input.directory);
- if (!dir) return "Error: Path outside project root";
- return JSON.stringify(listFilesFn(dir));
- }
- case "read_file": {
- const file = resolveSafe(allowedRoot, input.file_path);
- if (!file) return "Error: Path outside project root";
- return readFile(file);
- }
- case "write_file": {
- const file = resolveSafe(allowedRoot, input.file_path);
- if (!file) return "Error: Path outside project root";
- return writeFile(file, input.content);
- }
- default: return `Unknown tool: ${name}`;
- }
-}
-
-export async function reviewContent(label: string, content: string): Promise<"accept" | "skip" | string> {
- reviewBox(label, content);
- const choice = await selectMenu("", ["Accept", "Request changes", "Skip"], 0);
- if (choice === 0) return "accept";
- if (choice === 2) return "skip";
- const feedback = await textPrompt("What should be changed?");
- return feedback || "skip";
-}
-
-export async function runAgent(
- client: Anthropic,
- model: string,
- system: string,
- userMessage: string,
- tools: Anthropic.Tool[],
- listFilesFn: (dir: string) => string[],
- allowedRoot: string,
- review = false,
-) {
- const messages: Anthropic.MessageParam[] = [{ role: "user", content: userMessage }];
-
- while (true) {
- const response = await client.messages.create({ model, max_tokens: 16000, system, tools, messages });
-
- for (const block of response.content) {
- if (block.type === "text" && block.text.trim()) {
- // dim agent reasoning — it's secondary to the tool calls
- process.stdout.write(`\x1B[2m ${block.text.trim()}\x1B[0m\n`);
- }
- }
-
- if (response.stop_reason !== "tool_use") break;
-
- const toolUses = response.content.filter((b): b is Anthropic.ToolUseBlock => b.type === "tool_use");
- messages.push({ role: "assistant", content: response.content });
-
- const toolResults: Anthropic.ToolResultBlockParam[] = [];
- for (const tool of toolUses) {
- const input = tool.input as Record;
-
- if (review && tool.name === "write_file") {
- const resolvedPath = resolveSafe(allowedRoot, input.file_path);
- if (!resolvedPath) {
- toolResults.push({ type: "tool_result", tool_use_id: tool.id, content: "Error: Path outside project root" });
- } else {
- const label = path.basename(resolvedPath);
- const result = await reviewContent(label, input.content);
- if (result === "accept") {
- toolCall("write_file", input);
- toolResults.push({ type: "tool_result", tool_use_id: tool.id, content: writeFile(resolvedPath, input.content) });
- } else if (result === "skip") {
- toolResults.push({ type: "tool_result", tool_use_id: tool.id, content: "User skipped this write — do not write this file." });
- } else {
- toolResults.push({ type: "tool_result", tool_use_id: tool.id, content: `User requested changes: ${result}\nPlease revise and call write_file again with the updated content.` });
- }
- }
- } else {
- toolCall(tool.name, input);
- toolResults.push({ type: "tool_result", tool_use_id: tool.id, content: executeTool(tool.name, input, listFilesFn, allowedRoot) });
- }
- }
-
- messages.push({ role: "user", content: toolResults });
- }
-}
diff --git a/demo/ctx/src/cli.ts b/demo/ctx/src/cli.ts
deleted file mode 100644
index c5716d83c..000000000
--- a/demo/ctx/src/cli.ts
+++ /dev/null
@@ -1,113 +0,0 @@
-import { parseArgs } from "util";
-
-export const { values, positionals } = parseArgs({
- args: process.argv.slice(2),
- options: {
- prompt: { type: "string", short: "p" },
- out: { type: "string", short: "o", default: "lingo-context.md" },
- model: { type: "string", short: "m", default: "claude-haiku-4-5" },
- commits: { type: "string", short: "c" },
- "dry-run": { type: "boolean", short: "d", default: false },
- voices: { type: "boolean", short: "V", default: false },
- debug: { type: "boolean", short: "D", default: false },
- help: { type: "boolean", short: "h", default: false },
- },
- allowPositionals: true,
-});
-
-if (values.help) {
- console.log(`
-Usage: ctx [folder] [options]
-
-Arguments:
- folder Folder to analyse (default: current directory)
-
-Options:
- -p, --prompt What the agent should focus on
- -o, --out Output file (default: lingo-context.md)
- -m, --model Claude model (default: claude-haiku-4-5)
- -c, --commits Use files changed in last N commits instead of uncommitted
- -d, --dry-run Show what would run without writing anything
- -V, --voices Generate per-locale brand voices into i18n.json (requires lingo-context.md)
- -D, --debug Verbose logging — show all state, tool calls, and file paths
- -h, --help Show this help
-
-Modes:
- Fresh lingo-context.md absent → full project scan via agent tools
- Update lingo-context.md exists → only changed files sent to LLM (uncommitted)
- Commits --commits → only files changed in last N commits sent to LLM
-
-Examples:
- ctx ./lingo-app -p "B2B SaaS, formal tone"
- ctx ./lingo-app -p "consumer app, friendly and casual"
- ctx ./lingo-app --out lingo-context.md
- ctx --commits 3
-`);
- process.exit(0);
-}
-
-export async function selectMenu(question: string, options: string[], defaultIndex = 0): Promise {
- let selected = defaultIndex;
- const render = () => {
- process.stdout.write("\x1B[?25l");
- process.stdout.write(`\n${question}\n`);
- for (let i = 0; i < options.length; i++) {
- process.stdout.write(i === selected
- ? `\x1B[36m❯ ${options[i]}\x1B[0m\n`
- : ` ${options[i]}\n`
- );
- }
- };
- const clear = () => process.stdout.write(`\x1B[${options.length + 2}A\x1B[0J`);
-
- render();
-
- return new Promise((resolve) => {
- if (!process.stdin.isTTY) {
- resolve(defaultIndex);
- return;
- }
-
- process.stdin.setRawMode(true);
- process.stdin.resume();
- process.stdin.setEncoding("utf-8");
-
- const onKey = (key: string) => {
- if (key === "\x1B[A" && selected > 0) { clear(); selected--; render(); }
- else if (key === "\x1B[B" && selected < options.length - 1) { clear(); selected++; render(); }
- else if (key === "\r" || key === "\n") {
- process.stdout.write("\x1B[?25h");
- process.stdin.setRawMode(false);
- process.stdin.pause();
- process.stdin.off("data", onKey);
- process.stdout.write("\n");
- resolve(selected);
- } else if (key === "\x03") {
- process.stdout.write("\x1B[?25h");
- process.exit(0);
- }
- };
-
- process.stdin.on("data", onKey);
- });
-}
-
-export async function textPrompt(question: string, placeholder = ""): Promise {
- process.stdout.write(question);
- if (placeholder) process.stdout.write(` \x1B[2m(${placeholder})\x1B[0m`);
- process.stdout.write("\n\x1B[36m❯ \x1B[0m");
-
- return new Promise((resolve) => {
- process.stdin.resume();
- process.stdin.setEncoding("utf-8");
- process.stdin.once("data", (data: string) => {
- process.stdin.pause();
- resolve(data.trim());
- });
- });
-}
-
-export function die(...lines: string[]): never {
- for (const line of lines) console.error(line);
- process.exit(1);
-}
diff --git a/demo/ctx/src/files.ts b/demo/ctx/src/files.ts
deleted file mode 100644
index 0322babbd..000000000
--- a/demo/ctx/src/files.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import fs from "fs";
-import path from "path";
-import { execSync } from "child_process";
-
-const IGNORE = new Set(["node_modules", ".next", ".git", "dist", ".turbo"]);
-
-export function readFile(filePath: string): string {
- try {
- const buf = fs.readFileSync(filePath);
- if (buf.byteLength > 50_000) return `[File too large: ${buf.byteLength} bytes]`;
- return buf.toString("utf-8");
- } catch (e) {
- return `[Error: ${e}]`;
- }
-}
-
-export function writeFile(filePath: string, content: string): string {
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
- fs.writeFileSync(filePath, content, "utf-8");
- return `Written to ${filePath}`;
-}
-
-export function listFiles(dir: string): string[] {
- const results: string[] = [];
- function walk(current: string) {
- for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
- if (IGNORE.has(entry.name)) continue;
- const full = path.join(current, entry.name);
- entry.isDirectory() ? walk(full) : results.push(full);
- }
- }
- walk(dir);
- return results;
-}
-
-export function git(cmd: string, cwd: string): string {
- try { return execSync(cmd, { cwd, encoding: "utf-8" }).trim(); }
- catch { return ""; }
-}
-
-export function getChangedFiles(cwd: string, commits: number | null): string[] {
- let output: string;
- if (commits !== null) {
- output = git(`git diff HEAD~${commits} --name-only`, cwd);
- } else {
- output = git("git status --porcelain", cwd)
- .split("\n").filter(Boolean).map((l) => {
- const entry = l.slice(3).trim();
- return entry.includes(" -> ") ? entry.split(" -> ")[1].trim() : entry;
- }).join("\n");
- }
- const paths = output.split("\n").map((f) => f.trim()).filter(Boolean)
- .map((f) => path.join(cwd, f));
-
- const files: string[] = [];
- for (const p of paths) {
- try {
- const stat = fs.statSync(p);
- if (stat.isFile()) files.push(p);
- else if (stat.isDirectory()) files.push(...listFiles(p));
- } catch {}
- }
- return files;
-}
-
-export function formatFileBlock(filePath: string): string {
- return `\n--- ${filePath} ---\n${readFile(filePath)}\n`;
-}
diff --git a/demo/ctx/src/i18n.ts b/demo/ctx/src/i18n.ts
deleted file mode 100644
index 7b88a9da6..000000000
--- a/demo/ctx/src/i18n.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import fs from "fs";
-import { readFile } from "./files.ts";
-import { selectMenu } from "./cli.ts";
-import { summaryLine, info } from "./ui.ts";
-
-export function parseSections(content: string): Record {
- const sections: Record = {};
- const parts = content.split(/^(## .+)$/m);
- for (let i = 1; i < parts.length; i += 2) {
- sections[parts[i].trim()] = parts[i + 1]?.trim() ?? "";
- }
- return sections;
-}
-
-export function printUpdateSummary(before: string, after: string): void {
- const prev = parseSections(before);
- const next = parseSections(after);
- const allKeys = new Set([...Object.keys(prev), ...Object.keys(next)]);
- const lines: string[] = [];
-
- for (const key of allKeys) {
- const label = key.replace("## ", "");
- if (!prev[key]) {
- lines.push(` + ${label} (new section)`);
- } else if (!next[key]) {
- lines.push(` - ${label} (removed)`);
- } else if (prev[key] !== next[key]) {
- const pluralize = (n: number, word: string) => `${n} ${word}${n !== 1 ? "s" : ""}`;
- if (label === "Tricky Terms") {
- const countRows = (s: string) => s.split("\n").filter(l => l.startsWith("| ") && !l.includes("---") && !l.includes("Term |")).length;
- const added = countRows(next[key]) - countRows(prev[key]);
- const suffix = added > 0 ? ` (+${pluralize(added, "term")})` : "";
- lines.push(` ~ ${label}${suffix}`);
- } else if (label === "Files") {
- const countFiles = (s: string) => (s.match(/^### /gm) ?? []).length;
- const added = countFiles(next[key]) - countFiles(prev[key]);
- const suffix = added > 0 ? ` (+${pluralize(added, "file")})` : "";
- lines.push(` ~ ${label}${suffix}`);
- } else {
- lines.push(` ~ ${label}`);
- }
- }
- }
-
- if (lines.length) {
- console.log();
- for (const l of lines) {
- const prefix = l.trimStart()[0] as "+" | "-" | "~";
- const rest = l.replace(/^\s*[+\-~]\s*/, "");
- const [label, detail] = rest.split(/\s*\((.+)\)$/);
- summaryLine(prefix, label.trim(), detail);
- }
- }
-}
-
-export async function updateI18nProvider(i18nPath: string, contextPath: string): Promise {
- const context = readFile(contextPath);
- const i18nRaw = fs.readFileSync(i18nPath, "utf-8");
- const i18n = JSON.parse(i18nRaw);
-
- if (i18n.provider) {
- info(`provider: ${i18n.provider.id} · ${i18n.provider.model}`);
- const choice = await selectMenu("Overwrite provider with updated context?", ["Update", "Keep existing"], 1);
- if (choice === 1) return;
- }
-
- const mergedProvider = {
- ...(i18n.provider ?? {}),
- id: "anthropic",
- model: "claude-haiku-4-5",
- prompt: `Translate from {source} to {target}.\n\n${context}`,
- };
-
- i18n.provider = mergedProvider;
- fs.writeFileSync(i18nPath, JSON.stringify(i18n, null, 2), "utf-8");
- info(`updated provider in i18n.json`);
-}
diff --git a/demo/ctx/src/jsonc.ts b/demo/ctx/src/jsonc.ts
deleted file mode 100644
index 810fc7541..000000000
--- a/demo/ctx/src/jsonc.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-import Anthropic from "@anthropic-ai/sdk";
-import fs from "fs";
-import path from "path";
-import { readFile } from "./files.ts";
-import { reviewContent } from "./agent-loop.ts";
-import { fileHash, type FileEntry } from "./state.ts";
-import { toolCall } from "./ui.ts";
-
-export async function generateJsoncComments(
- client: Anthropic,
- model: string,
- sourceFile: string,
- lingoContext: string,
- feedback = "",
-): Promise> {
- const content = readFile(sourceFile);
- const feedbackBlock = feedback ? `\nUser feedback on previous attempt:\n${feedback}\nPlease revise accordingly.\n` : "";
- const response = await client.messages.create({
- model,
- max_tokens: 4096,
- messages: [{
- role: "user",
- content: `You are generating translator notes for a JSONC localization file.
-
-Localization context:
-${lingoContext}
-
-Source file (${path.basename(sourceFile)}):
-${content}
-${feedbackBlock}
-For each key, write a short one-line translator note that tells the translator:
-- What UI element or context the string appears in
-- Any ambiguity, idiom, or special meaning to watch out for
-- Length or tone constraints if relevant
-
-Return ONLY a flat JSON object mapping each key to its note. No nesting, no explanation.
-Example: {"nav.home": "Navigation item in top header bar", "checkout.submit": "Button — triggers payment, keep short"}`,
- }],
- });
-
- const text = response.content.find((b): b is Anthropic.TextBlock => b.type === "text")?.text ?? "{}";
- const match = text.match(/\{[\s\S]*\}/);
- if (!match) return {};
- try { return JSON.parse(match[0]); } catch { return {}; }
-}
-
-export function injectJsoncComments(filePath: string, comments: Record): void {
- const lines = fs.readFileSync(filePath, "utf-8").split("\n");
- const result: string[] = [];
-
- for (const line of lines) {
- const keyMatch = line.match(/^(\s*)"([^"]+)"\s*:/);
- if (keyMatch) {
- const indent = keyMatch[1];
- const key = keyMatch[2];
- if (result.length > 0 && result[result.length - 1].trimStart().startsWith("// CTX:")) {
- result.pop();
- }
- if (comments[key]) result.push(`${indent}// CTX: ${comments[key]}`);
- }
- result.push(line);
- }
-
- fs.writeFileSync(filePath, result.join("\n"), "utf-8");
-}
-
-export async function runJsoncInjection(
- client: Anthropic,
- model: string,
- files: string[],
- contextPath: string,
- review = false,
-): Promise {
- if (files.length === 0) return [];
- const injected: FileEntry[] = [];
- const lingoContext = readFile(contextPath);
-
- for (const file of files) {
- let comments: Record = {};
- let extraContext = "";
-
- while (true) {
- toolCall("annotate", { file_path: path.basename(file) + (extraContext ? " (revised)" : "") });
- comments = await generateJsoncComments(client, model, file, lingoContext, extraContext);
- if (Object.keys(comments).length === 0) break;
-
- if (!review) break;
-
- const preview = Object.entries(comments).map(([k, v]) => ` "${k}": "${v}"`).join("\n");
- const result = await reviewContent(`comments for ${path.basename(file)}`, preview);
- if (result === "accept") break;
- if (result === "skip") { comments = {}; break; }
- extraContext = result;
- }
-
- if (Object.keys(comments).length > 0) {
- injectJsoncComments(file, comments);
- injected.push([file, fileHash(file)]);
- }
- }
-
- return injected;
-}
diff --git a/demo/ctx/src/research.ts b/demo/ctx/src/research.ts
deleted file mode 100644
index 7c2042fc1..000000000
--- a/demo/ctx/src/research.ts
+++ /dev/null
@@ -1,192 +0,0 @@
-import Anthropic from "@anthropic-ai/sdk";
-import path from "path";
-import { selectMenu, textPrompt } from "./cli.ts";
-import { readFile, listFiles } from "./files.ts";
-import { phase, toolCall, dim } from "./ui.ts";
-
-// Research agent uses Sonnet — needs web search + stronger reasoning
-// web_search_20250305 requires Sonnet — falls back gracefully if unavailable
-const RESEARCH_MODEL = "claude-sonnet-4-6";
-
-const researchSystem = `You are a product research analyst. Research a software product and produce a concise brief that will help an AI translation engine understand the product's market, audience, and tone.
-
-Steps:
-1. Read the project files — README, package.json, landing page copy, app strings
-2. Search the web for the product/company name to understand market position, competitors, and industry tone conventions
-3. Search for "[product category] localization best practices" or "[industry] translation tone" if useful
-
-Produce a brief covering:
-- What the product does and what problem it solves
-- Target customers (role, industry, technical level)
-- Market segment (B2B SaaS, consumer, devtools, etc.)
-- Tone conventions in this space — what competitors use, what the market expects
-- Domain-specific terms with known translation risks in this market
-- Recommended tone register and pronoun form per language
-
-Rules:
-- Be specific and factual. No marketing language.
-- Under 300 words.
-- End with "Translation implications:" — concrete rules derived from market and audience research.
-
-Respond with the brief as plain text. Do not use write_file.`;
-
-// --- Research agent with file + web access ---
-
-export async function runResearchAgent(
- client: Anthropic,
- targetDir: string,
- i18nBlock: string,
-): Promise {
- phase("Research", "scanning project + searching web");
-
- const messages: Anthropic.MessageParam[] = [{
- role: "user",
- content: [
- `Research this project and produce a product brief.`,
- i18nBlock,
- `Project folder: ${targetDir}`,
- `\nExplore the project files and search the web as needed.`,
- ].join("\n"),
- }];
-
- const tools = [
- {
- type: "web_search_20250305" as const,
- name: "web_search" as const,
- },
- {
- name: "list_files",
- description: "List all files in a directory",
- input_schema: {
- type: "object" as const,
- properties: { directory: { type: "string" } },
- required: ["directory"],
- additionalProperties: false,
- },
- },
- {
- name: "read_file",
- description: "Read the contents of a file",
- input_schema: {
- type: "object" as const,
- properties: { file_path: { type: "string" } },
- required: ["file_path"],
- additionalProperties: false,
- },
- },
- ];
-
- let brief = "";
-
- while (true) {
- const response = await client.messages.create({
- model: RESEARCH_MODEL,
- max_tokens: 2048,
- system: researchSystem,
- tools,
- messages,
- } as any);
-
- for (const block of response.content) {
- if (block.type === "text" && block.text.trim()) {
- brief = block.text.trim();
- process.stdout.write(`\x1B[2m ${brief}\x1B[0m\n`);
- }
- }
-
- if (response.stop_reason === "pause_turn") {
- messages.push({ role: "assistant", content: response.content });
- continue;
- }
-
- if (response.stop_reason !== "tool_use") break;
-
- const toolUses = response.content.filter((b): b is Anthropic.ToolUseBlock => b.type === "tool_use");
- messages.push({ role: "assistant", content: response.content });
-
- const toolResults: Anthropic.ToolResultBlockParam[] = [];
- for (const tool of toolUses) {
- const input = tool.input as Record;
- if (tool.name === "web_search") {
- toolCall("web_search", { query: input.query });
- toolResults.push({ type: "tool_result", tool_use_id: tool.id, content: "" });
- } else if (tool.name === "list_files") {
- const resolvedDir = path.resolve(targetDir, input.directory);
- const relDir = path.relative(targetDir, resolvedDir);
- if (relDir.startsWith("..") || path.isAbsolute(relDir)) {
- toolCall("list_files", { directory: input.directory });
- toolResults.push({ type: "tool_result", tool_use_id: tool.id, content: "Error: Path outside project root" });
- } else {
- toolCall("list_files", { directory: resolvedDir });
- toolResults.push({ type: "tool_result", tool_use_id: tool.id, content: JSON.stringify(listFiles(resolvedDir)) });
- }
- } else if (tool.name === "read_file") {
- const resolvedFile = path.resolve(targetDir, input.file_path);
- const relFile = path.relative(targetDir, resolvedFile);
- if (relFile.startsWith("..") || path.isAbsolute(relFile)) {
- toolCall("read_file", { file_path: input.file_path });
- toolResults.push({ type: "tool_result", tool_use_id: tool.id, content: "Error: Path outside project root" });
- } else {
- toolCall("read_file", { file_path: resolvedFile });
- toolResults.push({ type: "tool_result", tool_use_id: tool.id, content: readFile(resolvedFile) });
- }
- }
- }
-
- messages.push({ role: "user", content: toolResults });
- }
-
- if (!brief) return null;
- return `--- Product Research Brief ---\n${brief}\n--- End Brief ---`;
-}
-
-// --- Quick questionnaire ---
-
-const TONE_OPTIONS = [
- "Formal & professional",
- "Friendly & conversational",
- "Technical & precise",
- "Playful & energetic",
- "Neutral — let the code speak",
-];
-
-async function runQuestionnaire(): Promise {
- console.log("\n Answer a few questions — blank to skip any.\n");
-
- const product = await textPrompt("What does your product do?", "e.g. task manager for remote teams");
- const users = await textPrompt("Who are your target users?", "e.g. developers, small business owners");
- const market = await textPrompt("What industry or market?", "e.g. B2B SaaS, consumer, fintech");
- const toneIdx = await selectMenu("What tone should translations use?", TONE_OPTIONS, 0);
- const extra = await textPrompt("Anything else translators should know?", "e.g. never translate brand name");
-
- const lines = ["Product brief from user interview:"];
- if (product) lines.push(`- Product: ${product}`);
- if (users) lines.push(`- Target users: ${users}`);
- if (market) lines.push(`- Market: ${market}`);
- lines.push(`- Tone: ${TONE_OPTIONS[toneIdx]}`);
- if (extra) lines.push(`- Notes: ${extra}`);
-
- return lines.join("\n");
-}
-
-// --- Entry point: let user pick ---
-
-export async function runResearch(
- client: Anthropic,
- targetDir: string,
- i18nBlock: string,
-): Promise {
- const choice = await selectMenu(
- "No lingo-context.md found. How should we gather product context?",
- [
- "Research agent — Claude searches the web + reads your project",
- "Quick interview — answer 4 questions yourself",
- "Skip — let the agent figure it out from code",
- ],
- 0,
- );
-
- if (choice === 0) return runResearchAgent(client, targetDir, i18nBlock);
- if (choice === 1) return runQuestionnaire();
- return null;
-}
diff --git a/demo/ctx/src/state.ts b/demo/ctx/src/state.ts
deleted file mode 100644
index 6edc40bbf..000000000
--- a/demo/ctx/src/state.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-import fs from "fs";
-import os from "os";
-import path from "path";
-import { createHash } from "crypto";
-
-export type State = { processedFiles: Record };
-export type FileEntry = [path: string, hash: string];
-
-let _stateDir: string | undefined;
-function getStateDir(): string {
- if (!_stateDir) {
- _stateDir = path.join(os.homedir(), ".ctx", "state");
- fs.mkdirSync(_stateDir, { recursive: true });
- }
- return _stateDir;
-}
-
-export function md5(data: Buffer | string): string {
- return createHash("md5").update(data).digest("hex");
-}
-
-function stateFile(p: string) {
- return path.join(getStateDir(), `${md5(p)}.json`);
-}
-
-export function loadState(p: string): State {
- try { return JSON.parse(fs.readFileSync(stateFile(p), "utf-8")); }
- catch { return { processedFiles: {} }; }
-}
-
-export function saveState(p: string, state: State) {
- fs.writeFileSync(stateFile(p), JSON.stringify(state, null, 2));
-}
-
-export function fileHash(f: string): string {
- try { return md5(fs.readFileSync(f)); }
- catch { return ""; }
-}
-
-export function filterNewFiles(files: string[], state: State): FileEntry[] {
- return files.flatMap((f) => {
- const hash = fileHash(f);
- if (!hash) return []; // skip unreadable files
- return hash !== state.processedFiles[f] ? [[f, hash]] : [];
- });
-}
-
-export function recordFiles(entries: FileEntry[], p: string) {
- const state = loadState(p);
- for (const [f, hash] of entries) {
- if (hash) state.processedFiles[f] = hash; // never store empty hash
- }
- saveState(p, state);
-}
-
-export function clearState(p: string) {
- try { fs.unlinkSync(stateFile(p)); } catch {}
-}
diff --git a/demo/ctx/src/ui.ts b/demo/ctx/src/ui.ts
deleted file mode 100644
index 8d9be9a45..000000000
--- a/demo/ctx/src/ui.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-// ─── ANSI ────────────────────────────────────────────────────────────────────
-const R = "\x1B[0m";
-const B = "\x1B[1m"; // bold
-const D = "\x1B[2m"; // dim
-const CY = "\x1B[36m"; // cyan
-const GR = "\x1B[32m"; // green
-const YL = "\x1B[33m"; // yellow
-const RD = "\x1B[31m"; // red
-const MG = "\x1B[35m"; // magenta
-
-// ─── Building blocks ─────────────────────────────────────────────────────────
-export const dim = (s: string) => `${D}${s}${R}`;
-export const bold = (s: string) => `${B}${s}${R}`;
-export const cyan = (s: string) => `${CY}${s}${R}`;
-export const green = (s: string) => `${GR}${s}${R}`;
-
-// ─── Header ──────────────────────────────────────────────────────────────────
-export function printHeader(opts: {
- targetDir: string;
- outPath: string;
- model: string;
- source: string;
- targets: string[];
-}) {
- const rel = opts.outPath.replace(opts.targetDir + "/", "");
- const arrow = opts.targets.length ? `${opts.source} → ${opts.targets.join(" ")}` : opts.source;
- console.log();
- console.log(` ${B}${CY}ctx${R} ${B}${opts.targetDir}${R}`);
- console.log(` ${D}${rel} · ${opts.model} · ${arrow}${R}`);
-}
-
-// ─── Phase header — big transition between stages ────────────────────────────
-export function phase(label: string, sub?: string) {
- console.log();
- console.log(` ${B}${CY}◆${R} ${B}${label}${R}`);
- if (sub) console.log(` ${D}${sub}${R}`);
-}
-
-// ─── Tool call — compact, no full paths ──────────────────────────────────────
-export function toolCall(name: string, input: Record) {
- const arg = input.file_path ?? input.directory ?? Object.values(input)[0] ?? "";
- const isPath = arg.startsWith("/") || arg.startsWith("./") || arg.startsWith("../") || /^[a-zA-Z]:[/\\]/.test(arg) || (arg.includes("/") && !/\s/.test(arg));
- const display = isPath
- ? arg.split("/").slice(-2).join("/")
- : arg.length > 60 ? `${arg.slice(0, 57)}…` : arg;
- const color = name === "write_file" ? `${GR}` : `${D}`;
- console.log(` ${color}↳ ${name.padEnd(12)}${display}${R}`);
-}
-
-// ─── File item (dry-run / update list) ───────────────────────────────────────
-export function fileItem(name: string) {
- console.log(` ${D}· ${name}${R}`);
-}
-
-// ─── Progress counter (update loop) ──────────────────────────────────────────
-export function progress(i: number, total: number, label: string) {
- console.log();
- console.log(` ${D}[${i}/${total}]${R} ${B}${label}${R}`);
-}
-
-// ─── Status lines ─────────────────────────────────────────────────────────────
-export function ok(msg: string) { console.log(`\n ${GR}✓${R} ${msg}`); }
-export function warn(msg: string) { console.log(`\n ${YL}!${R} ${msg}`); }
-export function fail(msg: string) { console.log(`\n ${RD}✗${R} ${msg}`); }
-export function info(msg: string) { console.log(` ${D}${msg}${R}`); }
-
-// ─── Summary line (section changed/added/removed) ─────────────────────────────
-export function summaryLine(prefix: "+" | "-" | "~", label: string, detail = "") {
- const color = prefix === "+" ? GR : prefix === "-" ? RD : YL;
- console.log(` ${color}${prefix}${R} ${label}${detail ? ` ${D}${detail}${R}` : ""}`);
-}
-
-// ─── Review box ───────────────────────────────────────────────────────────────
-const PREVIEW_LINES = 50;
-const WIDTH = 62;
-
-export function reviewBox(label: string, content: string) {
- const lines = content.split("\n");
- const preview = lines.slice(0, PREVIEW_LINES).join("\n");
- const truncated = lines.length > PREVIEW_LINES;
- const title = ` ${label} `;
- const pad = WIDTH - title.length - 2;
- const hr = `${D}${"─".repeat(WIDTH)}${R}`;
-
- console.log();
- console.log(` ${D}┌─${R}${B}${title}${R}${D}${"─".repeat(Math.max(0, pad))}┐${R}`);
- console.log();
- // indent content
- for (const line of preview.split("\n")) console.log(` ${line}`);
- if (truncated) console.log(`\n ${D}… ${lines.length - PREVIEW_LINES} more lines${R}`);
- console.log();
- console.log(` ${D}└${"─".repeat(WIDTH)}┘${R}`);
-}
diff --git a/demo/ctx/src/voices.ts b/demo/ctx/src/voices.ts
deleted file mode 100644
index 464b0556d..000000000
--- a/demo/ctx/src/voices.ts
+++ /dev/null
@@ -1,110 +0,0 @@
-import Anthropic from "@anthropic-ai/sdk";
-import fs from "fs";
-import { readFile } from "./files.ts";
-import { reviewContent } from "./agent-loop.ts";
-import { phase, info, warn, fail } from "./ui.ts";
-
-const voiceSystem = `You are a brand voice writer for software localization.
-
-Given: lingo-context.md describing the product, tone, audience, and language-specific rules.
-Task: Write a brand voice for one target locale — concise natural language instructions for the LLM translator.
-
-A brand voice must cover:
-- Pronoun register: formal vs informal (du/Sie, tu/vous, tú/usted, etc.)
-- Tone: professional, conversational, technical, playful — be specific
-- Audience context if it changes word choice
-- Any critical conventions from the lingo-context.md for this locale (length, script, idioms)
-
-Rules:
-- 3–6 sentences. No bullet points. Plain prose.
-- Actionable only — no generic advice like "be natural". Every sentence must constrain a decision.
-- Pull from the lingo-context.md language section for this locale. Do not invent rules not in the file.
-- Write in English.`;
-
-async function generateVoice(
- client: Anthropic,
- model: string,
- locale: string,
- context: string,
- feedback?: string,
- previous?: string,
-): Promise {
- const messages: Anthropic.MessageParam[] = [
- { role: "user", content: `Target locale: ${locale}\n\n${context}` },
- ];
- if (previous && feedback) {
- messages.push({ role: "assistant", content: previous });
- messages.push({ role: "user", content: `Please revise: ${feedback}` });
- }
-
- const response = await client.messages.create({
- model,
- max_tokens: 512,
- system: voiceSystem,
- messages,
- });
-
- return response.content.find((b): b is Anthropic.TextBlock => b.type === "text")?.text.trim() ?? "";
-}
-
-export async function runVoices(
- client: Anthropic,
- model: string,
- contextPath: string,
- i18nPath: string,
- targetLocales: string[],
-): Promise {
- if (!fs.existsSync(contextPath)) {
- fail(`lingo-context.md not found — run ctx first, then re-run with --voices.`);
- return;
- }
-
- if (targetLocales.length === 0) {
- warn(`No target locales in i18n.json — nothing to generate.`);
- return;
- }
-
- const context = readFile(contextPath);
- if (context.startsWith("[Error:")) {
- fail(`Cannot read context file: ${contextPath}`);
- return;
- }
-
- let i18nRaw: string;
- try {
- i18nRaw = fs.readFileSync(i18nPath, "utf-8");
- } catch (e) {
- fail(`Cannot read i18n file: ${i18nPath}\n${e}`);
- return;
- }
-
- let i18n: Record;
- try {
- i18n = JSON.parse(i18nRaw);
- } catch (e) {
- fail(`Malformed JSON in ${i18nPath}: ${e}`);
- return;
- }
- const voices: Record = { ...(i18n.provider?.voices ?? {}) };
-
- phase("Brand Voices", targetLocales.join(" "));
-
- for (const locale of targetLocales) {
- info(`[${locale}] generating...`);
- let text = await generateVoice(client, model, locale, context);
- if (!text) { warn(`[${locale}] no output — skipped`); continue; }
-
- while (true) {
- const result = await reviewContent(`Brand voice · ${locale}`, text);
- if (result === "accept") { voices[locale] = text; break; }
- if (result === "skip") { info(`[${locale}] skipped`); break; }
- info(`[${locale}] revising...`);
- text = await generateVoice(client, model, locale, context, result, text) || text;
- }
- }
-
- if (!i18n.provider) i18n.provider = { id: "anthropic", model };
- i18n.provider.voices = voices;
- fs.writeFileSync(i18nPath, JSON.stringify(i18n, null, 2), "utf-8");
- info(`wrote ${Object.keys(voices).length} brand voice(s) to i18n.json`);
-}