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 demo -

- -

- 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 -

- -

- Built with tsx/Node.js - Powered by Claude - Works with lingo.dev -

- ---- - -## 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`); -}