# smelt — llms-full.txt > Every document `llms.txt` names, concatenated in that order, each under a rule and > its path in the smeltjs/smelt repository. The index itself — the summary, the four > laws, the commands and the MCP tool names — is `llms.txt` beside this file. --- # README.md
smelt **Structure-aware, reversible context optimization for coding agents.** A library, not a proxy. [![CI](https://img.shields.io/github/actions/workflow/status/smeltjs/smelt/ci.yml?style=for-the-badge&logo=githubactions&logoColor=EFEBE5&label=CI&labelColor=131417&color=E4602F)](https://github.com/smeltjs/smelt/actions/workflows/ci.yml) [![network calls](https://img.shields.io/badge/network_calls-0-E4602F?style=for-the-badge&labelColor=131417)](#the-four-laws) [![node](https://img.shields.io/badge/node-%5E20.19_%7C%7C_%3E%3D22.12-6E7783?style=for-the-badge&logo=nodedotjs&logoColor=EFEBE5&labelColor=131417)](#requirements) [![License](https://img.shields.io/badge/license-Apache_2.0-6E7783?style=for-the-badge&labelColor=131417)](./LICENSE) [Docs](docs/ARCHITECTURE.md) · [Vocabulary](CONTEXT.md) · [Changelog](CHANGELOG.md) · [Skill](skills/smelt/SKILL.md) · [llms.txt](llms.txt)
**Measured, in three numbers** — every row in [`bench/RESULTS.md`](packages/core/bench/RESULTS.md), logs committed: | tokens sent, nine-case corpus | expansion rate, whole-file tasks | answer quality, A/B against raw | | :------------------------------------------------------------------------------------------------ | :--------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **−80%** · 109,348 → 21,696 | **0.94** · 17 of 18 blobs asked back | **6 ties** · 2 raw better · 1 smelted better\* | | Counted on the model's own tokenizer. _tier 2 · claude-opus-5 · 2026-09-07 · corpus 10462aa46b8e_ | The over-pruning alarm ringing where it should: 8 of 9 cases retrieved everything. _tier 3 · claude-opus-5 · 2026-09-07 · corpus 10462aa46b8e_ | Judged blind, one run, a model's opinion. \*The one "smelted better" is an artifact — [why](#tier-4--answer-quality--ab-one-judged-run-verdicts-are-a-models-opinion). _tier 4 · claude-opus-5 · 2026-09-07 · corpus 10462aa46b8e_ | And the same honesty at your own keyboard — `smelt stats` after a session, per rule ([below](#sixty-seconds-from-a-shell)). ## What it does **smelt shrinks what your coding agent sends to a model, without lying about what it removed.** Hand it a blob of text — a file, a grep result, a stack trace, a build log — and a byte budget. You get back a smaller blob in which the parts the task needs survive, and everything else has been replaced by a single line saying what went, how big it was, and a hash to get it back: ``` <> ``` The removed bytes are kept locally, content-addressed. The model gets a `smelt_retrieve` tool. **Every retrieval is counted**, so cutting too much shows up as a rising number rather than as a model that is quietly wrong about your code. This is also the shape the vendors have arrived at from their side: Anthropic's context-engineering guidance is to "maintain lightweight identifiers (file paths, stored queries, web links, etc.) and use these references to dynamically load data into context at runtime using tools" ([essay](https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents)). The marker plus `smelt_retrieve` is that pattern — with every reference explained, reversible, and counted. | What your agent does today | What smelt does instead | | -------------------------------------------------- | --------------------------------------------------------------------------------- | | Sends the whole 40 kB file, or its first 200 lines | Keeps the declarations your focus matched, with their signatures and doc comments | | `[...output truncated...]` | `<>` | | Truncated content is gone | Stored locally, keyed by hash, one tool call away | | No idea whether the cut hurt | An expansion rate you can watch move | | Asks a hosted model which lines matter | Never leaves the machine | ## Install ```sh npm install @smeltjs/core # or: pnpm add @smeltjs/core · bun add @smeltjs/core · yarn add @smeltjs/core ``` Or run the CLI without installing anything: ```sh npx @smeltjs/core src/server.ts --budget 4000 --focus handleRequest # also: pnpm dlx @smeltjs/core … · bunx @smeltjs/core … ``` One runtime dependency (`web-tree-sitter`); the parsers ship inside the tarball. No native build step, no post-install download, no Docker, no service, no API key. ## For agents **AI agents / LLMs:** read [`llms.txt`](llms.txt) here — the summary, the four laws, the three commands and the five MCP tool names, with a link list of everything else — or fetch [`llms-full.txt`](https://smeltjs.github.io/smelt/llms-full.txt) for every document in that list concatenated into one blob. The companion is served, not vendored: it lives in the repository only under `site/public/`, and `pnpm generate:llms-txt` renders both locally whenever you want them. Neither is hand-written — a hand edit fails `pnpm verify`. There are exactly **two instruction channels**, and they teach the same commands from the same [SetupRecipe](#one-command-smelt-setup) ([ADR-0002](docs/adr/0002-skill-pack-complements-marker-blocks.md)): the **marker block** `smelt setup` writes beside the enforcement hooks, and the **[SkillPack](skills/smelt/SKILL.md)**, which an agent's owner installs with `npx skills add smeltjs/smelt`. Neither is written uninvited. What an agent on 0.8.0 should actually do: - **On Homebrew and upgrading from 0.6.0 or earlier, re-run `smelt setup`** — hooks written by those releases point at a Cellar path `brew upgrade` deletes. - **`smelt setup --yes --scope user`** installs one config and one store for the whole machine instead of one per project ([below](#one-project-or-the-whole-machine)). - **`smelt hooks install --yes`** is the non-interactive wiring, for CI and for any run with no terminal to answer a prompt. - **`smelt doctor`** reports each wired artifact as `wired (verified)`, `wired but inert` or `wired but missing`, and exits 3 when something is behind — with the repair command named. Re-run `smelt setup`; never hand-edit what doctor names. - **`smelt store prune --older-than 30d --dry-run`**, then the same line without `--dry-run`, is the only thing that deletes an elision. Nothing evicts on its own. The age can be written down — `"store": { ..., "retention": { "olderThan": "30d" } }` — instead of retyped; the verb still has to be typed, and `--older-than` still wins. - **Reranking is opt-in and you write it down** — there is no default reranker, nothing is loaded unless a `rerank` key in your `smelt.config.json` says so, and the environment variable read is the one that config names ([below](#reranking-a-seam-and-an-opt-in-you-write-down)). ## Sixty seconds, from a shell ```sh smelt src/server.ts --budget 4000 --focus handleRequest # smelted text → stdout, report → stderr smelt --budget 4000 --focus TypeError < build.log # stdin works too smelt big.log --budget 4000 > small.log # the two pipe apart ``` ``` smelt packages/core/src/plan/lexical.ts --budget 4000 --focus planLexical ``` ``` smelt packages/core/src/plan/lexical.ts typescript lexical/v1 in 8,205 B → out 987 B (-88.0%, 3 elisions) focus planLexical rule lines bytes hash explanation focus-window 49 2,077 8ce2e5af28e6d6f0 collapsed 49 lines with no match for the focu… focus-window 11 756 c35d231379780e11 collapsed 11 lines with no match for the focu… focus-window 141 4,715 9d211d0922e7bb2f collapsed 141 lines with no match for the foc… ``` At the end of a session the store reports on itself — what it holds, the expansion rate, the counters, then the ledger, one rule at a time. Real output of `smelt stats` after `smelt packages/core/src/plan/lexical.ts --budget 4000 --focus planLexical --strategy auto` and retrieving one of the two markers — the block below is regenerated from the binary by `test/guards/readme-numbers.test.ts` on every `pnpm verify`, so it is this build's output rather than a past release's: ``` smelt stats /your/project/.smelt/store 2 blobs, 4.8 KB on disk expansion ████████████░░░░░░░░░░░░ 50.0% 1 of 2 elisions asked for back elisionsStored 2 bytesStored 4,865 retrieveCalls 1 uniqueRetrieved 1 misses 0 expansionRate 0.5 allElisionsRetrieved false rule stored retrieved rate sibling-collapse 2 1 50.0% ``` In a terminal that is lava-coloured; in a pipe, in CI, under `NO_COLOR` or with `--no-color`, it is exactly these bytes. `--json` is the surface to parse, and it never carries a colour byte. `expansionRate` is the fraction of what smelt hid that the model asked for back — the honest signal of over-pruning, measured and never thresholded. The ledger is the same signal per elision rule, so a rule whose every cut keeps getting asked back shows up as a fact you can act on. Reading stats never moves them. - `--strategy structural` parses the file and collapses whole sibling declarations, keeping every signature and doc comment. `--strategy lexical` (the default) uses focus windows — right for logs, traces, and anything that is not code. `--strategy json` cuts a JSON document by members and elements, `--strategy diff` cuts a unified diff by files and hunks, and each refuses any other content. `--strategy auto` picks by content kind first (json, diff), then by language (structural where a grammar is bundled, lexical otherwise), and labels what it ran — for a stream that is sometimes code, sometimes a build log, sometimes a diff. - Every structural cut's report row carries an **outline** — the names of the declarations behind the marker — so you (or a model) can decide what to retrieve without retrieving it. `--producer ''` names the command whose output you are piping, and derives the focus from it exactly as the hooks guard does. - `--json` prints a versioned envelope; `--reconstruct` reads it back and prints the original, byte for byte. Reversibility you can run from a shell. - `smelt map --budget 4000` prints a ranked symbol map of a whole repository — tree-sitter tags, deterministic PageRank, every included symbol stating why it ranked. Modelled on Aider's repo-map, credited as such. The map fits itself to the budget by construction. - `smelt agents lint` measures the other blob an agent loads on every request: your `AGENTS.md`. See [`smelt agents`](#smelt-agents--the-file-that-loads-on-every-request). - The exit code is non-zero when the plan came back over budget, and the report says so. `1` over budget, `2` usage, `3` refused, `4` unexpected. ## `smelt init` — the setup wizard ```sh npx @smeltjs/core init ``` Walks you through your defaults one question at a time — budget, store, strategy, an optional tokenizer hook, an optional reranker adapter — and writes a `smelt.config.json` the CLI reads for defaults from then on. Every step accepts `back`; re-running it loads your current answers and edits one choice at a time; **nothing is written until a final confirm, and no existing file is ever overwritten without an explicit per-file yes.** The reranker question has three answers: `none` (the default — nothing is loaded and nothing is called), a `module` of your own (the wizard writes a typed stub **into your project** and points the config at it), or `voyage` (the separately-installed `@smeltjs/rerank-voyage` adapter, keyed from an environment variable the wizard names and never reads). Either non-default answer is written down as a `rerank` block in your own config, because a reranker nobody opted into would ship your source to a third party. See [Reranking](#reranking-a-seam-not-a-feature). ## The library ```ts import { createSmelter } from '@smeltjs/core'; const smelter = createSmelter({ defaultBudgetBytes: 8_000 }); // 1. Shrink tool output on its way to the model. const result = await smelter.smelt(toolOutput, { path: 'src/server.ts', // language detection focus: ['handleRequest'], // what you were actually looking for budgetBytes: 4_000, strategy: 'structural', // parse-tree collapse; 'lexical' for non-code, 'auto' to pick }); result.text; // send this result.elisions; // what was cut: rule, explanation, bytes, hash — per elision result.outputBytes; // check it: the budget is a target, never a silent guarantee // 2. Give the model the way back. const { name, description, inputSchema, invoke } = smelter.tool; // name === 'smelt_retrieve' → invoke({ hash }) returns the exact original bytes // 3. Watch whether you cut too much. smelter.stats().expansionRate; // 0 = the model never needed anything back ``` Long-lived sessions outlive processes, so elisions can too: ```ts import { DirectoryElisionStore } from '@smeltjs/core'; const smelter = createSmelter({ defaultBudgetBytes: 8_000, store: new DirectoryElisionStore('.smelt/store'), // content-addressed, crash-safe, prune-only }); // A smelt_retrieve in a later turn — or a later process — still gets its bytes back. // Retrieval counters survive restarts, so expansionRate stays meaningful across a session. ``` ## Wiring it into an agent harness Three steps, SDK-agnostic: ```ts import { createSmelter, DirectoryElisionStore } from '@smeltjs/core'; // once, at session start — the persistent store keeps bytes AND counters // across turns and processes, so the honest signal spans the whole session const smelter = createSmelter({ defaultBudgetBytes: 8_000, store: new DirectoryElisionStore('.smelt/store'), }); // 1 — every tool result passes through smelt on its way into the context const result = await smelter.smelt(rawToolOutput, { path: 'src/server.ts', // structural planning for supported languages focus: [whatTheModelAskedFor], // the grep pattern, the symbol, the error budgetBytes: 4_000, }); pushToolResult(result.text); // 2 — register the way back as a normal tool const { name, description, inputSchema, invoke } = smelter.tool; // 'smelt_retrieve' tools.push({ name, description, input_schema: inputSchema }); // Anthropic shape shown // in your dispatcher: // if (call.name === 'smelt_retrieve') return invoke(call.input); // exact bytes back // 3 — report the stats wherever you surface metrics const s = smelter.stats(); // s.expansionRate the number to watch: fraction of hidden blobs asked back for // s.retrieveCalls round trips you paid for // s.elisionsStored how much smelt hid // s.allElisionsRetrieved true means the cutting saved nothing — loosen budgets ``` `expansionRate` is the whole feedback loop: 0 means every cut was right; a rising rate means the budget is too aggressive for this task shape. Surface it next to your token counts — it is the honest signal this library exists to provide, and the persistent store is what makes it a session-level fact rather than a per-turn one. Prefer your own planner or a hosted reranker? `createSmelter({ planner })` accepts any `Planner` implementation, and `RerankStage` is the seam for relevance — both are yours to wire, in your source, with your key. ### One command: `smelt setup` Install the CLI, then run one command: ```sh npm install -g @smeltjs/core smelt setup ``` `smelt setup` applies the whole recipe: `smelt.config.json`, the hooks preset for the harnesses it detects, the MCP registration for Claude Code, opencode, Codex and Grok (JSON or TOML, whichever the harness reads), and a real smelt → retrieve round trip to prove the loop. Interactive from a terminal — Enter accepts every default. An existing file is **merged**, never overwritten: every entry that is not smelt's is preserved, and every byte outside the region smelt edits is unchanged. The one file it will not write is one it would have to write whole (the opencode plugin, Cline's hook wrapper) when the file there is somebody else's — that is reported skipped, with the reason. Every wizard — `setup`, `hooks install`, `hooks remove`, `init` — ends the same way: a rule, a verdict counted off what was actually applied (`wrote 2, left 1 unchanged, skipped 1 — 4 files in all`), and the two or three commands that follow from it. Under `--json` the receipt is the whole output, as it always was. For an agent, the whole interface is flags, and the receipt is the output: ```sh npx @smeltjs/core setup --yes --harness claude-code --json ``` The four preset toggles are flags too, each `on|off`, on `setup` and on `hooks install` alike. A toggle you do not name keeps whatever is already installed: ```sh smelt setup --yes --harness claude-code --map on --lint on --json ``` Or hand the agent the skill, which teaches all of it in the agent's own vocabulary: ```sh npx skills add smeltjs/smelt ``` Homebrew, from smelt's own tap: ```sh brew install smeltjs/tap/smelt ``` The formula pulls Homebrew's own `node` by default. To use the Node already on your PATH instead, `brew install --without-node smeltjs/tap/smelt` — that Node must clear smelt's engines floor, `^20.19.0 || >=22.12.0`, and must live where Homebrew's build environment can see it (e.g. `/usr/local/bin` or the Homebrew prefix), since a version-manager shim (nvm, volta, fnm) that only your shell's `PATH` knows about is invisible to the build. Upgrading from 0.6.0 or earlier on Homebrew: **re-run `smelt setup`**. Hooks written by those releases point at the versioned Cellar path `brew upgrade` deletes, and the guard was inert through the `opt` symlink besides — it exited 0 with empty stdout, which every harness reads as _allow_. A re-run rewrites both. To see for yourself whether your guard fires, feed a shim the payload the harness would send it. It reads stdin to EOF, so it needs one — running it bare just hangs: ```sh # a file over the 8 KB threshold, and the PreToolUse payload for reading it head -c 60000 /dev/zero | tr '\0' x > /tmp/smelt-probe.log printf '%s' '{"hook_event_name":"PreToolUse","tool_name":"Read","tool_input":{"file_path":"/tmp/smelt-probe.log"},"cwd":"/tmp"}' \ | node "$(realpath /opt/homebrew/opt/smelt/libexec/lib/node_modules/@smeltjs/core/dist/hooks/shims/claude-code.js)" ``` A `"permissionDecision":"deny"` document on stdout means the guard is live. **Empty stdout means it is inert** — that is the 0.6.0 bug, and `smelt setup` is the fix. (`realpath` ships with macOS 13+ and every Linux; on Linux `readlink -f` does the same, and on older macOS drop the substitution — on any release carrying the fix above, the `opt` path works directly.) ### One project, or the whole machine Setup, `hooks install`/`remove` and `doctor` all take `--scope project|user`, and it defaults to `user` when you run them from your home directory and `project` everywhere else. The interactive wizards state what was detected and let you flip it. A **machine** install is the one to reach for when you want one `smelt.config.json` and one store behind every project: the config goes to `~/smelt.config.json`, which every project below it finds because discovery walks up, and the store to `~/.smelt/store`. Each harness file goes to that harness's **own documented user-level location** — `~/.claude/settings.json` and `~/.claude/CLAUDE.md`, `~/.codex/hooks.json` and `~/.codex/AGENTS.md`, `~/.gemini/settings.json` and `~/.gemini/GEMINI.md`, `~/.cursor/hooks.json`, `~/.config/opencode/`, `~/.cline/` — not the project spelling one directory up, which is a file nothing reads. A harness that documents no user-level home for a file is listed as skipped, with the reason; it is never guessed. ```sh cd ~ && smelt setup --yes --scope user --harness claude-code smelt doctor --scope user ``` One step stays yours at machine scope: Claude Code's user-scope MCP registration lives in `~/.claude.json`, a file Claude Code owns and rewrites, so setup prints the command instead of editing it — `claude mcp add --scope user smelt -- npx @smeltjs/mcp` — and doctor checks the key read-only and names the command when it is missing. ### Updating — and the other machine An update is the same loop on every machine, forever: ```sh smelt doctor ``` Doctor reads installed state and **never writes**: which release wrote the instruction blocks, whether the config parses and its store directory exists, whether the MCP registration is intact, and which pieces are orphans. It also **runs** every hook it finds, for every harness, against an oversized file in a temporary directory, and says what happened: `wired (verified)`, `wired but inert` (the command ran and allowed the read, which is exactly what a shim reached through a symlink does) or `wired but missing` (the script is gone, which is what `brew upgrade` leaves behind). That includes the three harnesses whose hook is a file smelt owns whole rather than an entry in somebody's JSON: Cline's wrapper and Hermes's YAML are run like any other shim, and opencode's plugin is loaded — import graph and all — to prove it still exports its hook. Exit 0 means current. When anything is behind or not firing, the report ends with the exact repair command, which is always: ```sh smelt setup ``` Setup is idempotent — a re-run on a current machine writes nothing and exits 0 — so _upgrade, doctor, setup_ is the whole recovery story, whether "the other machine" is a laptop or a teammate's. Then tell your agent about it, in whatever standing-instructions file it reads (`CLAUDE.md`, `AGENTS.md`, a system prompt — or their user-level siblings, `~/.claude/CLAUDE.md` and `~/.codex/AGENTS.md`, if you want it everywhere): ```md Reading a big file or a long tool output? Pipe it through `smelt --budget 4000 --focus ` instead of reading it raw. For orientation in an unfamiliar repo, `smelt map --budget 4000`. Every elided region leaves a marker ending in `retrieve("hash")` — when you need those exact bytes back, run `smelt retrieve `. ``` (The block `smelt setup` writes opens with "This project uses smelt" — or "This machine uses smelt" at `--scope user`, since a block in `~/.claude/CLAUDE.md` is loaded in every project on the machine.) The marker's `retrieve("hash")` **is** that command, and it is counted like any other retrieval — so at the end of a session, `smelt stats` prints the same honest numbers (`expansionRate` with a bar, `allElisionsRetrieved`, the counters, then the ledger as a table — stored, retrieved and rate per elision rule, so you can see which rule's cuts keep getting asked for back; `--json` for the envelope) that `smelter.stats()` and `smelter.store.ledger()` give a harness. The instruction pattern above works with any agent that can run a command; the hooks preset below wires it in with real enforcement. ### The hooks preset: `smelt hooks install` ```sh smelt hooks install # detects installed harnesses and offers them smelt hooks install --harness claude-code smelt hooks remove # takes it all back out ``` Or without a terminal at all — the same install, answered up front: ```sh smelt hooks install --yes --harness claude-code --map on --lint off smelt hooks remove --yes --harness claude-code ``` Three hooks, individually toggleable, written into the harness's own config with the same discipline as `smelt init` — every file listed before a final confirm, nothing overwritten without a per-file yes in the wizard, re-runs edit toggles. A merge into an existing settings file preserves **every entry that is not smelt's**, and leaves every byte outside the `hooks` key unchanged — your other top-level keys, their indentation, their escapes and their number spellings ride through verbatim. (Inside `hooks`, the value is re-serialised: a foreign entry keeps its content and may come back formatted differently.) Under `--yes` there is nobody to ask, so the plan's own shape answers instead: a file with a merge behind it is written, because no entry of yours can be lost, and a file smelt would write whole is left alone unless it is already smelt's — reported skipped, with the reason, and the run still exits 0. The install also points `smelt.config.json` at a directory store (unless the config already chose one), so the `smelt retrieve` the guard teaches actually works across processes: - **PreToolUse size-guard** (default on): a zero-dependency node script stats the target and refuses raw reads above a threshold (default 8192 bytes, `hooks.thresholdBytes` in `smelt.config.json`) with a reason naming the **exact** replacement — `smelt --budget ` — and the `smelt retrieve` way back. Windowed reads (offset/limit) always pass; so does anything the guard cannot judge whole. Malformed input fails open with a warning: a guard must never brick a session. - **stats on Stop** (default on): `smelt stats` at session end — the expansion rate where the turn ends. Observation only. - **repo map on SessionStart** (opt-in): a budgeted `smelt map` as opening context. - **instruction-file lint on SessionStart** (opt-in): `smelt agents lint .` — a report on the AGENTS.md/CLAUDE.md/GEMINI.md that session is about to load on every request. Advisory; never blocks. See [`smelt agents`](#smelt-agents--the-file-that-loads-on-every-request). Enforcement defaults to **deny-with-reason**: the transcript stays truthful and the model learns to run the replacement itself. `"hooks": {"enforcement": "rewrite"}` opts into in-flight substitution on harnesses whose hooks can modify tool input (cat of an oversized file replaced by the smelt run; grep piped through smelt, no `--focus` on the searched pattern — that would protect every matching line and elide nothing). A substitution is never silent: it is announced in the decision reason where the harness's rewrite schema carries one (Claude Code, Codex), on stderr where it does not (Gemini, Cursor, Hermes, opencode), and falls back to deny where rewrite is impossible. The preset is **cache-safe by construction**. smelt transforms a tool result before that result first reaches the model and never rewrites a prefix a provider has already cached — the geometry both Anthropic and OpenAI document as the trap (retroactive clearing invalidates a warm prefix, and must save enough to pay for the re-write). And caching discounts a re-read; it never frees what those bytes still occupy — the context window, the rate limit, the plan quota. The economics worked on list prices: [`docs/research/2026-09-06-platform-context-landscape.md`](docs/research/2026-09-06-platform-context-landscape.md). One guard core, thin per-harness shims, three honesty tiers (survey: [`docs/research/2026-09-02-harness-capability-matrix.md`](docs/research/2026-09-02-harness-capability-matrix.md)): | Tier | Harnesses | What the tier means | | ------------ | --------------------------------------------- | ------------------------------------------------------------------------------------------ | | verified | Claude Code, Codex | hook schema verified against primary docs and pinned by recorded fixtures | | experimental | Gemini, Grok, Hermes, Cursor, opencode, Cline | schema mapped from the capability matrix, **not yet smoke-tested against the real binary** | | advisory | KiloCode, Aider | no usable hook API — instructions only, and nothing enforces them | This table is written by hand, and deliberately: `--help`, the install wizard and the site all render the tier grouping from `HarnessProfile.tier`, so a mis-tiered profile would move every one of them together and they would go on agreeing with each other. This is the outside voice — `test/guards/harness-registry.test.ts` reads it and fails when it and the registry disagree, and `pnpm mutate` promotes a harness to watch that happen. A generated copy of the registry could not catch the registry being wrong. Every install also writes the harness's instruction file (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`, `CONVENTIONS.md`) with the pattern above — belt and braces, and the part that teaches `smelt retrieve` after a deny. ### As an MCP server [`@smeltjs/mcp`](packages/mcp/) serves the same library as a stdio MCP server — five tools (`smelt_file`, `smelt_retrieve`, `smelt_retrieve_batch`, `repo_map`, `smelt_stats`) over the same `smelt.config.json`-discovered store the CLI uses, so `smelt retrieve ` from a shell and the model's `smelt_retrieve` hit one store and move one set of counters: ```sh claude mcp add smelt -- npx @smeltjs/mcp ``` Codex and Grok TOML snippets, the tool contract, and the stdio-local guarantee (the SDK's HTTP transports never enter the import graph — guard-enforced): [`packages/mcp/README.md`](packages/mcp/README.md). ## `smelt agents` — the file that loads on every request Your `AGENTS.md` is the one blob a coding agent pays for on **every single request**, relevant or not. That is a context-budget problem, which is smelt's whole subject — so smelt measures it: ```sh smelt agents lint # measure and explain; exit 0 smelt agents lint . --strict # any finding exits 1, for CI smelt agents lint . --json # the versioned envelope smelt agents split # the mechanical half of the guide's refactor ``` It lints the **merged set** — every `AGENTS.md`, `CLAUDE.md` and `GEMINI.md` in the tree, because a nested one merges with the root. A merge runs _up_ the tree and never across it, so two numbers come back and each says which question it answers: **per request (worst case)**, the heaviest level plus its ancestors, which is what one agent actually loads; and **whole tree**, every level summed, which is the repository's instruction surface and a cost nobody pays in one request. Plus bytes per level and an imperative count labelled a heuristic. Then eight advisory rules, each with a stable id and an explanation citing the guide it applies ([aihero.dev/a-complete-guide-to-agents-md](https://www.aihero.dev/a-complete-guide-to-agents-md)): | Rule | What it notices | | ----------------------- | ------------------------------------------------------------------------- | | `dead-path` | a path-like token that resolves to nothing in the real tree | | `dead-link` | a Markdown link whose relative target has moved or gone | | `forcing-language` | "always", "never", ALL-CAPS shouting | | `structure-dump` | a directory tree, or a run of bare path lines | | `generated-boilerplate` | init-script fingerprints (**the softest rule, and its own text says so**) | | `language-rule` | a const/let, interface-vs-type or quote-style rule loaded every request | | `mirror-drift` | a `CLAUDE.md`/`GEMINI.md` that has diverged from its `AGENTS.md` | | `restated-at-level` | the same line written at a level and at one of its ancestors | `dead-path` and `dead-link` are the point. Everyone else is linting Markdown; the thing that has rotted is the repository the Markdown describes, and a renamed `src/auth/handlers.ts` is not an invalid file — it is a lie the agent believes on every request. A path with a separator is checked wherever it appears; a bare dotted word is checked only inside backticks, because `Node.js` and `aihero.dev/…` are shaped exactly like paths and one confident false accusation costs more trust than a dozen real findings earn. **No built-in size limit.** The guide's cited "~150-200 instructions" is printed as a citation and compared to nothing. Set `{"agents": {"budgetBytes": 2000}}` in `smelt.config.json` and exceeding **your** number exits 1, exactly as every other smelt budget does — measured against the whole tree, the stricter of the two figures, so it cannot be met by moving bytes into another package. Findings alone exit 0 unless you pass `--strict`. **There is no `smelt agents init`, and there will not be one.** The guide says in as many words never to auto-generate an AGENTS.md, and smelt will not build the thing its own source warns against. `smelt agents split` does the _mechanical_ half of the guide's refactor — partition by `##` heading into `docs/`, rewrite the relative links that moved a directory deeper, leave a link list behind, under `smelt init`'s consent discipline — and then prints the guide's own refactor prompt with your real section headings filled in, for you to hand to your own agent. Deciding which sections are essential is a reading of your project; that needs a model, and smelt has none by law. smelt's own [`AGENTS.md`](AGENTS.md) is written by hand to the guide's minimum checklist and linted by this command, with [`CLAUDE.md`](CLAUDE.md) as the symlink the guide recommends. ## Fine print on the API Three things that look like bugs and are not: - **`budgetBytes` is required** (unless `smelt.config.json` sets a default). A budget smelt invented would be smelt deciding how much of your context to throw away. - **An unsupported language under `strategy: 'structural'` is refused, never approximated.** No silent downgrade to line windows wearing a `structural/v1` label. `strategy: 'auto'` is the way to ask for the choice to be made for you, and its results say which planner ran — a selector, not a fallback: a grammar that fails to load still raises, under `auto` exactly as under `structural`. - **There is no expansion-rate warning threshold.** smelt measures the rate; policy is yours. The one computed fact is `stats().allElisionsRetrieved` — true when every blob smelt hid was asked for again, i.e. the elision saved nothing and cost a round trip. ## What is in the box - **Structural planner** — parses with bundled tree-sitter grammars for **fifteen languages** (`typescript`, `tsx`, `javascript`, `rust`, `python`, `go`, `java`, `c`, `cpp`, `c_sharp`, `ruby`, `php`, `kotlin`, `swift`, `bash`), keeps focus-matched declarations whole — signature, doc comment, body — and collapses sibling runs into markers that name the kind and count from the parse tree. The Python survivor still parses; shebangs, Go build tags, Rust attributes and `#pragma once` stay pinned; a marker is only planned when it costs fewer bytes than it removes. Over budget, a pressure rung re-prices each refused run as its own best profitable sub-run — the structural sibling of the lexical ladder below — and the escalation is stated on the elision itself (`sibling-collapse-pressure` on `reason.rule`), never inferred. - **Lexical planner** — focus windows, head-tail, a context ladder under budget pressure. For logs, traces, diffs, and every other blob that is not code. - **Persistent store** — `DirectoryElisionStore`: one file per content hash, atomic no-clobber writes, bytes re-verified against their hash on every read, counters in an append-only journal. No _automatic_ eviction, ever — no cap, no LRU, no TTL, nothing that deletes because a store was opened: a store that can forget by itself turns "reversible" into "reversible, usually". The one deletion is `smelt store prune --older-than 30d`, which you type: it journals every eviction before it unlinks, so a later `smelt retrieve` of a pruned hash says `EvictedHashError` with the date rather than "it was never elided", and the counters do not move — `elisionsStored` keeps counting what went, so a prune cannot flatter the expansion rate. `--dry-run` first. The age may be written down — `store.retention.olderThan`, with an optional `keepRetrieved`, inside the `store` block of `smelt.config.json`. The deletion may not: a retention schedules nothing, `--older-than` overrides it, the prune report says which of the two chose the number, and with neither present the verb refuses. - **Cache-prefix hygiene** — `findPrefixDivergence` and `detectCacheBreakers` report the byte offset where two prompt prefixes diverge and the silent cache-breakers worth fixing (timestamps/UUIDs in system prompts, unsorted JSON keys, varying tool sets). **Detect and warn only — smelt never rewrites your prompt.** - **Repo-map planner** — a ranked, budgeted symbol map of a whole repository: tree-sitter tags, deterministic PageRank over the reference graph, a caller-owned disk cache. Modelled on [Aider's repo-map](https://aider.chat/2023/10/22/repomap.html) and credited as such. Every included symbol can say why it ranked. - **The setup surface** — `smelt setup` applies the whole recipe in one command (config, hooks preset, MCP registration, a proven round trip), `smelt doctor` reads installed state back — running every hook it can read as an entry, so `wired` is a fact about behaviour and not about text — and names exactly what is behind, and the version-stamped instruction blocks make "is this machine current?" answerable from pure shell. The recipe's facts live as data; the skill pack (`npx skills add smeltjs/smelt`) and this README render from it or are guard-pinned to it. - **The hooks preset** — `smelt hooks install`: a zero-dependency guard core plus thin shims that wire the size-guard, stats-on-stop and map-on-start into agent harnesses, tiered honestly (verified / experimental / advisory — see the harness guide above). Deny-with-reason by default; rewrite opt-in and always announced — in the decision reason where the harness has one, on stderr where it does not. - **The honesty machinery** — a guard suite per law and per guarantee, in the core and around the MCP server's stdio-local surface and the shared operations seam, that walk the real import graph, assert byte-exact reversibility, pin the wire format, and re-derive the attribution file; plus a mutation runner (`pnpm mutate`) that breaks the source on purpose — every mutation watched going red — and fails if a guard does not notice. The tally it counted last is committed in [`guards.json`](guards.json), guard by guard: the runner writes that file and refuses to run when it is stale, so the number is measured wherever it is read and stated nowhere else. Every guarantee in this README has a guard. ## Measured numbers From the committed measurement harness (`pnpm bench`). Each tier's rows come from the last run that measured it, and say so: tier 1 from run 2026-09-07 on corpus `19b11585126f` (eleven cases — this repo's own planner source, real tool outputs, byte-exact files from django, scikit-learn and sympy at pinned upstream commits, and two content-kind probes); tiers 2–4 from run 2026-09-07 on corpus `10462aa46b8e` (the nine cases before the probes were added), tiers 3–4 run once on `claude-opus-5`, their logs committed beside the rows ([`bench/RESULTS.md`](packages/core/bench/RESULTS.md), append-only; [`tier3-log/`](packages/core/bench/tier3-log/), [`ab-log/`](packages/core/bench/ab-log/)). ### Tier 1 — bytes · deterministic, offline · corpus `19b11585126f` | case | planner | in (B) | out (B) | reduction | | ------------------------------ | ------------- | ----------: | ---------: | ------------------- | | large TS file | structural/v1 | 35,458 | 11,324 | −68.1%, over budget | | TSX component | structural/v1 | 1,090 | 861 | −21.0%, over budget | | java classes | structural/v1 | 689 | 366 | −46.9% | | multi-file grep | lexical/v1 | 6,451 | 986 | −84.7% | | stack trace | lexical/v1 | 452 | 344 | −23.9% | | build log (labelled synthetic) | lexical/v1 | 16,354 | 109 | −99.3% | | django query_utils | structural/v1 | 13,389 | 1,697 | −87.3% | | sklearn _ridge | structural/v1 | 91,082 | 31,951 | −64.9% | | sympy boolalg | structural/v1 | 114,180 | 8,151 | −92.9% | | git diff (content-kind probe) | diff/v1 | 4,132 | 2,706 | −34.5%, over budget | | JSON log (content-kind probe) | json/v1 | 11,447 | 3,280 | −71.3%, over budget | | **corpus total** | | **294,724** | **61,775** | **−79.0%** | The two probe rows are the honest trade the kind planners make: on the same bytes the lexical planner left 1,516 B and 2,996 B (earlier rows, same corpus), and the kind planners keep more — every file and hunk header of the diff, the JSON skeleton with an outline of every hidden key. Both were over budget under either planner. ### Tier 2 — tokens · `count_tokens` on `claude-opus-5` | case | in (tok) | out (tok) | reduction | | ------------------ | ----------: | ---------: | ---------: | | large TS file | 11,768 | 4,036 | −65.7% | | TSX component | 429 | 353 | −17.7% | | java classes | 256 | 172 | −32.8% | | multi-file grep | 2,835 | 426 | −85.0% | | stack trace | 196 | 148 | −24.5% | | build log | 9,090 | 58 | −99.4% | | django query_utils | 4,534 | 577 | −87.3% | | sklearn _ridge | 34,962 | 12,365 | −64.6% | | sympy boolalg | 45,278 | 3,561 | −92.1% | | **corpus total** | **109,348** | **21,696** | **−80.2%** | ### Tier 3 — the expansion rate · the honest signal, and it rang Aggregate **0.94**: asked to _"read this file to understand X before editing it"_, the model retrieved **17 of 18** elided blobs back — a LOSS on 8 of 9 cases (the stack trace retrieved none). That is the alarm working, not the product failing: whole-file comprehension is the one task shape that genuinely needs everything, and smelt exists to make that visible instead of silent. On the question-shaped reads of tier 4, the same model retrieved 0–2. ### Tier 4 — answer quality · A/B, one judged run, verdicts are a model's opinion | case | raw in (tok) | smelted in (tok) | retrieves | verdict | | ------------------ | -----------: | ---------------: | --------: | ---------------- | | large TS file | 11,820 | 4,671 | 0 | tie | | TSX component | 472 | 2,300 | 1 | tie | | java classes | 296 | 2,050 | 2 | smelted better\* | | multi-file grep | 2,886 | 5,091 | 2 | raw better | | stack trace | 232 | 1,748 | 1 | tie | | build log | 9,138 | 10,597 | 1 | raw better | | django query_utils | 4,584 | 1,210 | 0 | tie | | sklearn _ridge | 35,024 | 49,082 | 2 | tie | | sympy boolalg | 45,322 | 15,024 | 2 | tie | Six ties, two raw-better, one smelted-better. Three honest readings: - **Quality held.** On answerable questions, the smelted blob tied the raw one in 6 of 9 cases at a fraction of the input — and on the zero-retrieve cases the raw arm paid 2.5–3.8× the smelted arm's tokens. - **Round trips re-bill.** Where retrieves happened, each tool round re-sent the transcript, and on 5 of 9 cases the smelted arm's summed input exceeded the raw arm's. Retrieval is the cost lever — which is exactly why smelt counts it, surfaces it as `expansionRate`, and refuses to threshold it for you. - \* The one "smelted better" is an artifact: that raw arm returned an empty answer (0 output tokens; the judge's reasons in the committed log say so outright). Reported as measured, with the caveat here. What these are: measured bytes, measured tokens on a named model's tokenizer, counted `smelt_retrieve` calls, and one judged A/B run — every row reproducible or committed. What they are **not**: dollar savings (no price table is committed; tokens are the measured unit), rates from real agent traffic (tier 3's framing is a lab task, chosen to ring the alarm on purpose), or an aggregate claim beyond this corpus. The nearest real-traffic comparable remains **Headroom's stated 21–57% across its four proof scenarios** (their README, 2026-09) — their numbers, on their corpus, cited as exactly that. ## On units: bytes, and why that is the strength **Budgets are UTF-8 bytes, permanently.** Bytes are the only unit computable **locally, for every model** — the same property that makes the zero-network guarantee possible. There is no local tokenizer for Claude (only a counting endpoint), and token budgets silently redefine themselves between model generations (Anthropic: _"the same input text produces approximately 30 percent more tokens"_ on newer tokenizers). A byte budget means the same thing in five years. Want the number in your own unit? Bring the counter you already have: ```ts import { encode } from 'gpt-tokenizer'; // any local tokenizer you already ship const smelter = createSmelter({ defaultBudgetBytes: 8_000, measure: { id: 'gpt-tokenizer/o200k_base', unit: 'tokens', count: (t) => encode(t).length }, }); // result.measured = { measure, unit, input, output } — labelled, because a token count // without its tokenizer named is not a measurement. ``` ## Reranking: a seam, and an opt-in you write down There is **no default reranker and never will be** — a default would ship every consumer's source to a third party, including the consumers who never read the changelog. With no `rerank` key in your `smelt.config.json`, nothing is loaded, nothing is imported and nothing is called. That is what a default install does, and the zero-network guard still walks the real import graph to prove it. What you can do is opt in, in a file you own ([ADR-0004](docs/adr/0004-rerank-config-seam.md)): ```json { "rerank": { "kind": "module", "path": "./smelt.rerank.ts" } } ``` ```json { "rerank": { "kind": "voyage", "model": "rerank-2.5", "apiKeyEnv": "VOYAGE_API_KEY", "topK": 8 } } ``` `module` loads a `RerankStage` of your own; `voyage` loads [`@smeltjs/rerank-voyage`](packages/rerank-voyage/), a **separate package you install yourself** and the only one in this repository that reaches the network. There is no `SMELT_RERANK_API_KEY` and no environment variable smelt reads that your config did not name. Every failure is a refusal that names what is missing — the path, the `topK` this kind needs, the environment variable, the uninstalled package — never a quiet fall back to an unranked run. **Install the adapter beside the config that asks for it.** smelt looks in the directory holding your `smelt.config.json` first and in its own install second, so a `~/smelt.config.json` works with a `smelt` from Homebrew or `npm -g` — install into the directory that owns the config: ```sh npm install --prefix ~ @smeltjs/rerank-voyage # for ~/smelt.config.json npm install @smeltjs/rerank-voyage # for a config at your project root ``` If it is in neither place, the refusal names both of them and the exact command for yours. Reading the config's directory is the same trust you already gave that file: a `smelt.config.json` chooses code smelt imports the moment it uses the `module` kind, and nothing is loaded from either directory unless the config carries a `rerank` block — nor does anything leave your machine until the environment variable it names is set. An adapter's `exports` map must reach its entry under `default` or `require` (smelt asks through `createRequire`). One that answers only `import` is reported as _installed and unreachable_ rather than missing, because installing it again would change nothing. **What a stage is asked, and what it may do.** When the planner has decided which regions to remove, the stage is handed _those regions_ and your focus terms, and **whatever it returns is spared** from the cut — a selection, not a ranking of everything, so apply your own cut-off (`topK`, a `.slice`). It can only spare, never cut, so the worst a bad answer can do is cost you bytes. **And it spares only as far as your budget reaches.** The list you return is also an _order_: smelt walks it best score first and spares while the output still fits the budget you asked for, stopping at the first region that would not. So the head of your list is what survives a tight run, and `topK` is a **cap** rather than a quantity — smelt never fills it, and if the best-ranked region alone would break the budget, nothing is spared at all. A plan that fits beats a plan that does not, and a reranker cannot cut, so the only lever left is not sparing. The rule in one line: a K smelt invents is refused; a budget you typed is honoured. Stopping is deliberate rather than packing: a lower-ranked region might have fitted in the headroom left behind, and taking it would re-rank your answer by size instead of by relevance. What you get back is a **prefix of your own ranking**, which is the version you can reconstruct from the report. And if the planner could not meet your budget in the first place, your reranker is **not called at all** — nothing could have been spared, so nothing of your source is sent anywhere to find that out; the report line says so. A stage that throws — a timeout, a 401, a stub you have not filled in — is reported as the refusal it is (`RerankStageError`, the CLI's refused exit code, an `isError` result from `smelt_file`), never as a crash in smelt. Every run that reranks says so, on a line of its own beneath the focus line — this is its shape, not a measurement; every count is tallied per run and never estimated: ``` rerank voyage/rerank-2.5 ( candidates, kept, B back) rerank voyage/rerank-2.5 ( candidates, kept, B back) stopped at the budget: the stage offered ``` The second shape is the one to read closely: your `topK` came back with more regions than the budget could afford, and the clause says so rather than leaving a number smaller than the one you configured with no explanation beside it. The same facts ride in the `--json` envelope (`result.rerank`, which also names `stopped` as `budget`, `cap` or `exhausted`) and in `smelt_file`'s report block, and `smelt doctor` says whether your key variable is set and where the adapter resolved from (`adapter from config dir`, `adapter from smelt's own install`, `adapter not installed:` with the command, or `adapter installed beside smelt.config.json but not loadable`, which also says smelt's own install was not tried and why) — presence only, never the value. It reads the `module` kind by the same rule the loader uses, so a config naming a package rather than a file is not reported as a missing file. Writing your own stage is unchanged: ```ts import type { RerankStage } from '@smeltjs/core'; const myReranker: RerankStage = { id: 'my-hosted-reranker', async rerank(candidates, query) { // Your call, your key, your process. Visible here, in your source. const scored = await myClient.rerank({ query, documents: candidates.map((c) => c.text) }); return scored.map(({ index, score }) => ({ ...candidates[index]!, score })); }, }; ``` ## Two stability promises, not one - **The wire surface a model sees is stable from 0.1 and treated as 1.0** — the marker format `<>` and the `smelt_retrieve` tool contract. The marker carries its version in band; a future format arrives as `smelt/v2`, never as a quiet substitution. The marker goes into prompts: changing its shape would change model behaviour in every consumer as _worse output with no error anywhere_, which is the one thing smelt must never do. - **The TypeScript API is `0.x` and may move.** Expect renames between minors. Snapshot the properties (round-trips, under budget, focus preserved), not the exact elisions. ## The four laws The reasoning — _why_ breaking each produces a library that still looks like it works — is in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md#the-four-laws-and-why-each-one-is-load-bearing): 1. **Zero network.** No external calls, in any code path, enforced by a guard that walks the real import graph from every entrypoint the manifest advertises — and that names the one opt-in adapter package **forbidden** as an import, so it can only ever arrive the way you chose it. 2. **Every elision is explainable.** A named rule and a sentence a human can read in a diff. Never a model's opinion. 3. **Every elision is reversible, and expansions are counted.** Reversibility without counting is how "90% reduction" gets claimed while the model quietly asks for all of it back. 4. **Claim no number that has not been measured.** Absolute — which is why the numbers section above is a table with a date and a corpus commit, not a headline. ## Requirements - **Node** `^20.19 || >=22.12` - **pnpm** 10.15, for development only - Nothing else. No database, no Docker, no compiler, no API key. `@smeltjs/core` is an **ESM package**. From ESM, `import` it; from CommonJS, plain `require('@smeltjs/core')` works too — the supported Node range above is exactly the range where Node loads ES modules through `require()` without a flag, which is why the engines floor sits where it does. ## Prior art, credited honestly smelt's architecture is **close to Headroom's**, and it would be dishonest to imply otherwise. - **[Headroom](https://github.com/headroomlabs-ai/headroom)** — the closest peer, and it has grown: a Rust core behind Python and TS SDKs, a proxy wrapping sixteen-odd agents, JSON statistical crushing, image shaping — and a trained model in the prose cut path, retrieval that expires with a TTL, and telemetry beacons on by default. smelt's shape (a local store plus a retrieve tool) started from its early Python form, and its CacheAligner's detect-don't-rewrite decision is copied here outright. If you want a proxy today, use Headroom. Surveyed against its live docs, 2026-09: [`docs/research/2026-09-06-peer-tools-survey.md`](docs/research/2026-09-06-peer-tools-survey.md). - **[Aider's repo-map](https://aider.chat/2023/10/22/repomap.html)** — the proven prior art the repo-map planner is modelled on: tree-sitter tags + PageRank + a budget + a cache. - **[LLMLingua](https://github.com/microsoft/LLMLingua)** — the prompt-compression research line; its numbers are on non-code benchmarks. - **[SweRank](https://arxiv.org/abs/2505.07849)**, **[LocAgent](https://arxiv.org/abs/2503.09089)**, **[Agentless](https://github.com/OpenAutoCoder/Agentless)** — learned code localization; a v2 conversation, because each puts a model in the retrieval path. - **[Tree-sitter](https://tree-sitter.github.io/)** — the parsers under all of it. **What smelt actually adds**, re-checked against the live field 2026-09 ([survey](docs/research/2026-09-06-peer-tools-survey.md)): the **zero-network guarantee**, guard-enforced and claimed by no peer; the requirement that **every elision explains itself in named-rule terms**; retrieval that is **reversible without eviction and counted** — the expansion rate, which no peer and no platform reports at all; and the **mutation-tested honesty machinery** that makes these claims checkable instead of aspirational. The nearest peers match the honesty _culture_ (llmtrim's disclosed regressions, Headroom's no-artifact-no-number rule) — not the machinery, and not the counting. ## Documentation | Doc | What is in it | | -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | | [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | The deep dive: the four laws and their reasoning, the architecture file by file, the consumer contract, decisions | | [`CONTRIBUTING.md`](CONTRIBUTING.md) | Dev setup, the guard/mutation convention, the recorded transcript of the zero-network guard going red | | [`packages/core/bench/`](packages/core/bench/) | The measurement harness: corpus, tiers, and the append-only results table | | [`docs/research/`](docs/research/) | Dated primary-source surveys: harness capability, peer tools, platform context economics, positioning | | [`packages/core/THIRD-PARTY.md`](packages/core/THIRD-PARTY.md) | Generated attribution for the bundled grammars. Never hand-edited; a stale copy fails `pnpm test`. | | [`assets/PALETTE.md`](assets/PALETTE.md) | The palette, the marks, and how to regenerate the rasters | ## Contributing Contributions welcome — planners, languages, docs, and especially benchmark corpus cases. Read [`CONTRIBUTING.md`](CONTRIBUTING.md) first: dev setup is two commands (`pnpm install && pnpm verify`), but the convention around _guards that can fail_ is the part that matters. `pnpm verify` is the gate; Conventional Commits. ## License [Apache-2.0](./LICENSE). The consumer contract — the stable surface and the guarantees any consumer can rely on — is in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md#the-consumer-contract).


Cut hard. Explain everything. Keep the ore.
--- # skills/smelt/SKILL.md --- name: smelt description: Shrink oversized files and tool output before they hit your context window — structure-aware, reversible, offline. Use when a file, log, grep result, diff or stack trace is too big to read raw — smelt keeps the parts the task needs and replaces the rest with one-line markers you can retrieve by hash. --- # smelt smelt keeps large tool output out of your context window, reversibly: the parts the task needs survive, everything else becomes one line saying what was removed, how big it was, and a hash to get it back. It makes zero network calls. ## Reading big files Instead of reading a large file raw: smelt --budget 4000 --focus Repeat `--focus` once per term. Focused regions survive verbatim; the rest collapses into one-line markers. The budget is a soft ceiling in bytes. ## Retrieving what was cut Every marker ends in `retrieve("hash")`. The exact original bytes come back from: smelt retrieve Retrievals are counted, and `smelt stats` reports the expansion rate — the honest signal of over-pruning. Retrieve what you actually need. ## Orienting in an unfamiliar tree smelt map --budget 4000 prints a ranked symbol map of the repository. The budget is met by construction. ## If a guard denies a raw read This project may run a smelt guard hook: raw reads over a size threshold are denied, and the denial names the exact `smelt` replacement command. Run that command, then `smelt retrieve` any marker you need expanded. The deny teaches the replacement — that pairing is the design, not an obstacle. ## Step by step (when `setup` is unavailable on an older install) - `npm install -g @smeltjs/core` — install the CLI - `smelt init` — write smelt.config.json - `smelt hooks install` — wire the hooks preset - `npx @smeltjs/mcp` — register the MCP server with your harness - `smelt --budget 4000 --focus ` — prove the round trip on a real file ## Setting up npm install -g @smeltjs/core smelt setup --yes [--harness ]... [--scope user] [--guard on|off] [--stats on|off] [--map on|off] [--lint on|off] [--no-mcp] [--json] Nothing installed at all? `npx @smeltjs/core setup --yes [--harness ]... [--no-mcp] [--json]` runs the same recipe. `smelt setup` applies the whole recipe idempotently — the config, the hooks preset for the harnesses you name, the MCP registration step, and a real smelt → retrieve round trip to prove the loop. A re-run on a current machine writes nothing and exits 0, so re-running is always safe; `smelt hooks remove` takes the wiring back out. - `--yes` answers every question up front. Without a terminal it is what makes the command runnable at all, so from CI or a hook use `smelt hooks install --yes`. - `--harness ` is repeatable. The ids are: claude-code, codex, gemini, grok, hermes, cursor, opencode, cline, kilocode, aider. - `--scope user` installs once for the machine — one config and one store for every project — instead of once per project, which is the default. - The four toggles each take `on` or `off`; one you do not name keeps whatever is already installed. - `--json` prints a receipt: every file, every check, and what the exit meant. If you upgraded smelt (`brew upgrade smelt`, `npm update -g`), run `smelt setup` again. The loop is: upgrade → `smelt doctor` → `smelt setup`. ## Checking the install smelt doctor [--scope user] [--json] Doctor reads installed state and reports it; it writes nothing, ever, so it is always safe to run. Each wired artifact comes back as one of three verdicts: - **wired (verified)** — smelt ran the thing and it behaved as installed. - **wired but inert** — it is on disk, but nothing loads or runs it. - **wired but missing** — the wiring names a script that is not there. Exit 0 means current, or nothing is installed. Exit 3 means something is behind or broken, and the report names the exact repair command — `smelt setup`, per harness. Run that; do not hand-edit the files doctor names. ## Keeping the store small Nothing is ever evicted on its own: no timer, no size cap, nothing on opening a store. Deleting elided bytes is one explicit command, and it refuses unless an age was named — on the command line or, since 0.8.0, in the config. Plan it first, then run it: smelt store prune --older-than 30d --dry-run smelt store prune --older-than 30d Read the dry run before the real one. A pruned hash is gone, and a later `smelt retrieve` on it refuses and says when it was pruned rather than pretending the bytes were never there. The age can be written down instead of retyped, inside the store block of `smelt.config.json`: "store": { "kind": "directory", "path": ".smelt/store", "retention": { "olderThan": "30d", "keepRetrieved": true } } That is a number, not a schedule: nothing prunes because it is there. It supplies the default age; `--older-than` on the command line overrides it, and the prune report names which of the two chose the age. A configured `keepRetrieved: true` is added to `--keep-retrieved`, never overridden by its absence — a flag with no negative spelling cannot delete more than the config asked to spare. With no age on either the flag or in the config, the command still refuses. ## Reranking There is no default reranker and never will be. Nothing is loaded, imported or called unless a `rerank` key in `smelt.config.json` says so: { "rerank": { "kind": "module", "path": "./smelt.rerank.ts" } } { "rerank": { "kind": "voyage", "apiKeyEnv": "", "topK": 8 } } `module` loads a stage of your own; `voyage` loads `@smeltjs/rerank-voyage`, a separate package installed by hand. The environment variable read is the one your config names — there is no key smelt reads that you did not write down. A stage may only spare regions from the cut, never cut more, and a stage that throws is reported as the refusal it is, never as a quiet unranked run. The adapter is looked for beside `smelt.config.json` first, smelt's own install second — so at machine scope (`~/smelt.config.json`), install it there, not into whatever project you happen to be standing in: npm install --prefix "$HOME" @smeltjs/rerank-voyage `topK` is a cap under the budget, not a quantity: smelt walks what the stage returns best score first and spares while the output still fits the budget, so a `topK` of 8 can come back as 3 kept. The report line names the wall the walk hit, and the `--json` receipt carries it as `result.rerank.stopped` (`budget`, `cap` or `exhausted`). ## MCP If the project registers smelt over MCP, five tools exist: `smelt_file` (shrink a file under a byte budget with a focus), `repo_map` (a ranked whole-tree symbol map), `smelt_retrieve` (elided bytes back by hash), `smelt_retrieve_batch` (several hashes back in one call — prefer it when more than one marker matters, because every call re-bills the conversation) and `smelt_stats` (retrieval counters). The config's store is shared with the CLI, so a hash a marker gave you is the same hash either surface retrieves. ## Notes - Zero network calls, ever — a test in smelt's own suite fails if that could change. - The wire surface (the marker format, the tool contracts) is stable from 0.1. - This skill complements the marker-block instructions that `smelt hooks install` writes beside the enforcement hooks. If both are present, they teach the same commands from the same recipe; if only this skill is present, nothing is enforced — the discipline above is yours to follow. --- # CONTEXT.md # smelt — domain vocabulary The names the code uses, with their exact meanings. Architecture reviews and refactors use these terms; drift is a bug. Module/interface/seam/adapter vocabulary follows the codebase-design glossary. ## Core domain - **Blob**: the text a caller hands to `smelt()` — a file, grep result, trace, log. smelt never fetches one itself. - **Budget**: a soft output ceiling in UTF-8 bytes. A target planners aim under, never a silent guarantee; overrunning it is reported, not hidden. - **Elision**: a planned removal of a byte range, carrying an `ElisionReason` (stable `rule` id + human `explanation`). Applied elisions are reversible by construction. - **Marker**: the one-line stand-in `<>` that replaces elided bytes. Part of the frozen wire surface; it goes into prompts. - **Marker leader**: the language-specific line-comment prefix (`// `, `# `) a marker needs so the survivor still parses. - **Survivor**: what remains of a blob after `applyPlan`. For structural plans the survivor must still parse in its language. - **Planner**: a module that turns a blob + budget + focus into an `ElisionPlan` without removing any bytes itself. `applyPlan` is the only byte-remover. - **Focus**: the caller's statement of what the task is actually about; focus-matched regions survive planning. - **Store**: content-addressed home of elided bytes (`ElisionStore`). No _automatic_ eviction: a store that can forget by itself turns "reversible" into "reversible, usually". The one deletion is **Prune**, below, and it is a verb the user types. - **Expansion rate**: retrieved-back fraction of what smelt hid — the honest signal of over-pruning. Measured, never thresholded. The marker's `retrieve("hash")` is a real command — `smelt retrieve ` — so the rate moves (and is measurable, via `smelt stats`) from pure shell, not only through the `smelt_retrieve` tool. - **Prune** (`smelt store prune`): the only eviction in smelt, and the reason a store that deletes can still satisfy Law 3. Explicit (a user typed the verb; nothing prunes on a timer, a size cap, or when a store is opened), bounded by a **Cut-off** that user named — `--older-than d|h|w`, or `store.retention.olderThan` in `smelt.config.json`, the flag winning and the receipt naming which — journalled **before** the bytes go (`evict "" ""`, `fsync`ed, then the unlink), and counted: `elisionsStored` keeps counting what was evicted, so pruning cannot raise the **Expansion rate** by shrinking its own denominator, and the **Ledger** is untouched — the rule did make that cut. Only `bytesStored` falls, because only `bytesStored` measures the disk. A later `retrieve` of an evicted hash raises **`EvictedHashError`**, never `UnknownHashError`: "you pruned it on " and "it was never elided" are different answers, and the second one would be false. That lookup **still counts as a miss** — `retrieveCalls` and `misses` move exactly as they would for a hash nobody ever stored, because the model asked for material back and did not get it; only the error text differs, because only the error is read by a person deciding what went wrong. `has()` answers `false` — a boolean has no room for a reason. _Avoid_: eviction policy, GC, LRU, TTL. - **Cut-off**: the age a Prune deletes at, as a user spells it — `30d`, `12h`, `2w` (`readCutoff` in `src/store-cutoff.ts`, one grammar for both places it can be written). There is no default cut-off and never will be: one smelt invented would decide which of somebody's elisions stop being reversible, at an age nobody chose. What a user may do is **write theirs down** — `store.retention: { olderThan, keepRetrieved? }` inside a directory store — which is a number in a file, not a schedule: it makes no deletion happen, and the verb is still typed. The two spellings are a merge (`resolveRetention`), the flag wins, `--keep-retrieved` is OR-ed rather than overridden because a flag with no negative spelling must not delete more than the file asked for, and the receipt carries the provenance (`olderThanSource`). _Avoid_: retention policy, expiry, TTL. - **Survey** (`DirectoryElisionStore.survey()`): the whole reading of a store directory from one blob scan and one journal fold — the counters, the **Ledger** and the size on disk. Not a cache and not a new fact: `stats()` and `rawCounters()` are views over it, and a store still remembers nothing between calls, which is what makes two processes over one directory always agree. It exists because `smelt stats` (the Stop hook's, at every session end) walked the same two files three times to print one report. `ledger()` deliberately is **not** a view over it: every fact in the Ledger comes out of the journal, and it is the one of the three that `smelter.ts` asks for on every smelt run, so it goes through the journal half alone and never scans `blobs/`. - **Ledger**: the per-rule half of the same honesty — for each `ElisionReason.rule`, how many distinct cuts it made in a store and how many of them were retrieved (`RuleLedgerEntry { rule, stored, retrieved }`). The rule is persisted at put time by the one byte-remover, derived once by `ruleLedger()` in `stats.ts`, read by `store.ledger()`, `smelt stats` and `smelt_stats`, and handed to planners as opt-in `PlanInput.ruleHistory`. `retrieved === stored` for a rule is a fact, never a threshold: the rule's every cut was asked for back. _Avoid_: score, penalty. - **Outline**: the names of the declarations one structural elision collapsed (`PlannedElision.names`, carried onto `AppliedElision.names`), rendered beneath the elision's report row and in the `--json` envelope — never in the marker, whose bytes and price do not move. What is behind a marker, by name, so a model can decide whether to retrieve without retrieving. - **Producer**: the command whose output a blob is (`grep -C 3 foo src`). `focusTermsFor` in `src/hooks/focus-terms.ts` derives from it the terms that distinguish output lines the task is about — nothing for a plain grep, whose every line matches — and the guard's rewrite wrap, `smelt --producer` and `smelt_file`'s `producer` all resolve through it. A caller's own focus always wins; the report attributes whose focus cut. - **Batched retrieve**: `smelt_retrieve_batch` / `retrieveMany` — N hashes, one round trip, one `RetrievedBlock` per hash. Changes what an expansion _costs_ (every tool call re-bills the transcript), never what the expansion rate _means_: each hit inside a batch journals exactly as a single call would. Additive beside the frozen `smelt_retrieve`. - **Guard**: a test that pins a law or guarantee, proven non-vacuous by mutations. - **Mutation**: a deliberate minimal break that its guard must catch (`pnpm mutate`). - **Guard tally**: `guards.json` at the repository root — how many guards, how many mutations, guard by guard. Written by the runner (`pnpm generate:guards`), refused by it when stale, and read by everything that wants the number. It exists because the number was prose in four documents, one of them worded past the drift regex meant to catch exactly that, and reconciling it took five commits in a day. Law 4 turned on the repository's own numbers: state no figure that has not been measured, including this one. - **The four laws**: zero network · every elision explainable · every elision reversible (and counted) · no unmeasured numbers. Reasoning in `docs/ARCHITECTURE.md`. ## Deepened modules - **LanguageProfile**: the single adapter carrying every per-language fact — extensions, grammar wasm, marker leader, pinned comments, structural node kinds, repo-map tag kinds, licence provenance. One file per language in `src/lang/`; the registry (`LANGUAGE_PROFILES` in `src/lang/registry.ts`) is `Record`, so totality is a compile error. The seam is `profileFor(id)`, `profileForPath(path)` and `structuralLanguages()`; every rendered list and every exported set (`SUPPORTED_LANGUAGES`, `WASM_BY_LANGUAGE`, `STRUCTURAL_LANGUAGES`, `MARKER_LINE_COMMENT_LEADERS`) is a derived view. Consumers read it, never own a slice of it. "Structural language" = a profile with a `structure` section; `grammar-provenance.json` holds the licence facts, its key set guard-pinned to the registry's wasm set. - **HarnessProfile**: the single adapter carrying every per-harness fact — tier and caveats, the paths that detect it, its instruction file, its native hook schema as data (`HarnessHookSchema`: read/bash tool names, payload keys, the deny and rewrite documents), and its install steps, each step's kind being also how `remove` takes it back out. One file per harness in `src/harness/`; the registry (`HARNESS_PROFILES` in `src/harness/registry.ts`) is `Record`, so totality is a compile error. It imports nothing from `cli/` — that cycle is why the `--harness` help list used to be hand-typed — and every rendered list and derived set (`HARNESS_IDS`, `MANAGED_EVENTS`, `GUARD_EVENTS`, `JSON_HOOK_FILES`, `GUARD_ONLY_FILES`) is a view over it. `planInstall`/`planRemove` (**InstallPlan**, `src/harness/plan.ts`) fold over `profile.install`; they hold no per-harness case. `profile.mcp` is the same discipline for the MCP server: a profile that registers smelt carries the registration _as a person performs it_ (`{manual, manualUser?}`) beside the step that writes it — Claude Code's CLI verb, Codex's and Grok's `[mcp_servers.smelt]` table, opencode's `mcp` key — each the snippet a person pastes, composed from `MCP_RUN_ARGS` so it and the bytes smelt writes cannot drift. The guard reads `packages/mcp/README.md` **one section at a time**: every file a manual names and every line of its snippet must be in that harness's own section, because the TOML table is identical in two of them and a whole-file search stays green while a profile points at somebody else's config. `smelt setup` printed the recipe's Claude Code command for _every_ harness before it, which is a command about a file Codex does not read; and its receipt now carries `mcp.commands`, every registration a run is about, because `mcp.command` can only name one and a run wiring two harnesses had the second read as "not registered". `shimFromSchema(schema)` builds the **ShimAdapter** a shim script runs and owns what every shim shares — the rewrite-input splice, the deny fallback, and the one rewrite announcement (also spliced into the generated opencode plugin). ShimAdapter stays public as the escape hatch for a harness a table cannot express. The **tier grouping** is a view too: `harnessesByTier()` folds `profile.tier` into `{tier, honesty, harnesses}` rows in `TIER_HONESTY`'s key order, and the `hooks` help body, the wizard and the site's table and prompt badges render it — it was hand-typed in five places, so a promoted harness stayed under its old tier in four of them. `wiresLifecycle(profile)` is its sibling and the correction of a confusion: the stats/map toggles wire wherever a JSON hook step declares `lifecycle`, which the wizard called "verified-tier harnesses" because the two sets happen to coincide. Because every _rendered_ grouping now derives from the same field, `README.md`'s tier table is deliberately **not** generated: it is the outside witness the guard reads, and a mis-tiered profile is caught there or nowhere. - **Invocation**: the one answer to "how is smelt re-invoked on this machine" (`src/hooks/invocation.ts`). Everything smelt writes into somebody else's config file is ultimately a command that has to still work tomorrow — a hook entry, the opencode plugin's absolute import, the deny reason's replacement — and three files used to derive it three ways from `import.meta.url`. The seam is `smeltInvocation(options)`, returning `{ kind: 'path' | 'node', command, script?, bin, stable, why }`, ranked: a `smelt` on PATH (a name no upgrade moves) · this package's own `dist/cli/bin.js` in its stable spelling · the versioned path, with `stable: false` and a `why` a receipt prints. It owns three facts nobody else may re-derive: `isSameFile` (realpath both sides — node realpaths the ESM main entry, so `isMainModule`'s old string compare said "not main" through any symlink and the guard exited 0 with empty stdout, which every harness schema reads as _allow_); `pathStability` (below); and `smeltOnPath` (a stat per PATH directory, never a spawn). Rung 1 also carries a `caveat` when the `smelt` it found does not realpath to this install's own bin — the ranking does not move, but a machine with two smelts must not look like a machine with one. It imports **node builtins only**, like its sibling `guard-core.ts`, which reads it — and reads it _lazily_, when a deny reason is rendered rather than at module load (memoised per process, the memo bypassed by any injected call), so the command reflects the environment the hook actually runs in and a test can inject `env` and `fs` instead of the real machine. `harness/paths.ts` keeps its exported names and delegates. - **PathStability** (`pathStability(path)` in `src/hooks/invocation.ts`): the verdict on one path smelt is about to write into somebody else's config file — `{ path, stable, why }`, where `path` is the spelling to write. It is asked **per script actually named** — the guard shim, the guard core the opencode plugin imports, `cli/bin.js` — never of the invocation value, which is stable whenever `smelt` is on PATH: judging the value reported the lifecycle hooks fine while writing the guard hook, the security-relevant one, as a bare Cellar path with nothing said. Unstable means a recognised **version-bearing segment**: a Homebrew keg with no `opt` alias resolving to it, a `/.pnpm/@/` store entry, a `/versions/node//` tree. Stable is deliberately the weaker claim — nothing here can know a packaging manager's policy, so the `why` says "nothing here proves an upgrade moves it — nor that it keeps it" and never that anything is replaced in place. `smelt hooks install` and `smelt setup` print the unstable ones (`smelt.setup.v1`'s optional `notes`). - **HookCommand**: what one entry in a harness's hook config _says_, as a value, and both directions over it (`src/harness/hook-command.ts`). A guard command is `{ kind: 'guard', script }`; the three lifecycle commands are `{ kind: 'stats' | 'map' | 'lint', invocation: 'path' | 'node', script?, args }` — the Invocation's two spellings, carried rather than re-derived. `renderHookCommand` is the only writer and `parseHookCommand` the only reader, and `parseHookCommand(renderHookCommand(c, cwd))` equalling `c` is a guard, because the string used to have one writer and _three_ substring readers (the ownership check the merge runs, the toggle reader that tells the opening map from the instruction lint, and `cli/installed.ts`'s per-file "is this ours"), each carrying its own needle. `undefined` is load-bearing: it means **foreign**, and a re-run may only ever replace entries it can prove are its own — which is why the parser accepts three quotings and the `$(readlink -f …)` workaround people have on disk today, and refuses `node other.js`. **The probe** is the module's second half and the reason `smelt doctor` can now say _verified_: `probeHookCommand` runs the command — for a guard, against a payload built from the harness's own `HarnessHookSchema` naming an oversized file in a fresh temp directory, beside a `smelt.config.json` pinning the threshold so the walk up to the filesystem root cannot change the premise — and answers `fires` / `inert` / `missing`. `probeOwnFile` is its sibling for the three harnesses whose wiring is a file smelt owns **whole** (Cline's wrapper, Hermes's YAML, opencode's plugin): those carry no event-to-entry table, so what each file runs and how to ask it is declared on the profile as data (`HarnessOwnFileProbe` — a command behind the renderer's own prefix, or an ES module to load), and this module folds over that declaration without ever asking which harness it is looking at. Every harness is probed; nothing reports a bare `wired` for want of a reading. `wired` used to be a text fact, and the two defects Invocation fixed (an inert shim through a symlink, a keg path `brew upgrade` deleted) both leave that text exactly as it was; `inert` is the dangerous verdict, because empty stdout is how every harness schema spells _allow_. Probing is a read, so ADR-0003 holds — doctor still writes no byte of the project, and the one thing it spawns is `process.execPath` (the narrower ruling under which `node:child_process` is on the Law 1 allowlist at all). - **InstallScope** (`src/harness/scope.ts`): where an install goes — `'project'` or `'user'`. Every artefact the installer writes used to be a bare relative path joined to `cwd`, at write time and, separately, at read time. That is right for a project and wrong for the only way to get one config and one store for every project on a machine, which is to install from `$HOME`: config discovery walks up, so a config at `~` is the one every project below it finds. Run from there, the installer wrote `~/CLAUDE.md`, `~/.mcp.json`, `~/AGENTS.md`, `~/GEMINI.md` and `~/opencode.json` — files no harness reads at that level (Claude Code reads `~/.claude/CLAUDE.md`, Codex `~/.codex/AGENTS.md`, Gemini `~/.gemini/GEMINI.md`, opencode `~/.config/opencode/opencode.json`) — and doctor read from the same wrong places, so the writer and the reader agreed the install was healthy while nothing was wired. The user-level location is therefore a **per-harness fact**, `HarnessUserLocation` on the profile beside the project path, and the seam is one resolver: `locateStep(step, scope, {cwd, home})` → `{ path?, name?, skipped?, manual? }`. Project scope returns exactly `join(cwd, step.file)`, so a project install is unchanged; user scope returns the location that harness's own documentation names. `path` is absent **exactly when** `skipped` is set, which is what makes the old defect unreachable rather than merely unwritten: there is no path to fall back to, and the compiler says so. `planInstall`, `planRemove`, `readInstalledState`, `presetToggles`, doctor and the snippet all go through it. `manual` is the third answer — a location that exists but is not smelt's to write, because the harness owns and rewrites the file: Claude Code's user-scope MCP registration lives under the top-level `mcpServers` key of `~/.claude.json`, so setup prints `claude mcp add --scope user …` and doctor checks the key read-only. At user scope the config is `~/smelt.config.json` (decided, not discovered) with the store at `~/.smelt/store`, and the marker block says "This machine uses smelt" rather than "This project". Selection is `--scope` on `setup`, `hooks install/remove` and `doctor`, defaulting to `user` when `cwd` realpaths to the home directory; both receipts carry it. A harness that documents no user-level home for an artefact is **project-only** and reported skipped with the reason — today Hermes, KiloCode and Aider entirely, plus Grok's and Cursor's instruction layers and Grok's hook file. `locateFormer` is the resolver's read-only sibling: where a harness has renamed the directory it loads from (opencode's `.opencode/plugin/` → `.opencode/plugins/`), the step declares the old spelling and it is still _read_ and still _removed_ — never written. One artefact, two names: without it every existing install becomes a file nobody owns, `remove` leaves it behind and a re-run reads the toggles back as though nothing were installed. With both names on disk the reading carries the old one as **superseded**, and doctor reports it as an orphan with the command that takes it out. - **InstallPlan** (`src/harness/plan.ts`): every file an install would write, and every one `remove` would take back out, computed against the disk and writing nothing — `planInstall(cwd, choices)` → `{files, skipped, notes, manual}` and its mirror `planRemove`. Both are folds over `HarnessProfile.install` with no per-harness case: what to write is the profile's, where it goes is `locateStep`'s, what a hook entry says is `harness/hook-command.ts`'s, and the byte-faithful edit is `text/json-edit.ts` or `text/toml-edit.ts`. It sits in `harness/` because **planning is not a verb**: both install verbs plan identically and differ only in who consents to the write (**MergePolicy**). While the fold sat inside the hooks wizard's module, `smelt setup` imported that wizard to plan, and the file was ~1200 lines of two unrelated jobs. It imports nothing from `cli/`: the config schema it goes through is `src/config.ts` at the root, because `smelt.config.json` is what the install is _for_, and a key added to the schema must reach the installer and `init` together or not at all. `test/guards/module-seams.test.ts` pins both halves: the import edges, and the count of the declarations, because an import edge that is merely absent is satisfied by a copy. - **MarkerPricing**: the seam through which planners ask what a marker will cost in bytes — `costBytes(reason, elidedBytes)`, required on every `PlanInput`. Owned and built by `apply.ts`: `markerPricing(language, marker)` is the one adapter, built from the exact builder `applyPlan` will use (a caller's custom `MarkerBuilder` prices with its own rendering, so a longer marker makes small cuts unprofitable and the planner sees it). Planners never estimate independently; `createSmelter` and the CLI construct the pricing centrally, and a JS caller who omits it gets `MissingMarkerPricingError`, never a guessed cost. - **Subcommand**: the single adapter carrying every per-verb fact — the flags it owns (`readonly FlagName[]`), its `parse`, its `resolve`, its `run`, its `usage` block and the one sentence a refusal ends with. One file per verb in `src/cli/subcommands/`; the registry (`SUBCOMMANDS` in `src/cli/subcommands/registry.ts`) is `Record`, so totality is a compile error. The seam is `subcommandFor(positionals)` — `parseSmeltArgs` looks a verb up and lets it validate itself, `runCli` is a lookup and a dispatch, and every rendered view (the USAGE block, the help's sections, the `map only.` prefix on an OPTIONS entry) is derived. **Flag ownership is the property it exists for**: a flag outside the chosen verb's list is refused by ONE generated message naming the flag, the verb, and — when exactly one verb owns it — where it does belong, replacing the five hand-written refusals in which every verb refused every other verb's flags. `CLI_FLAGS` in `subcommands/flags.ts` is the companion table: it types `FlagName`, tells `parseArgs` how to read each flag, and carries its OPTIONS entry. `test/guards/subcommand-registry.test.ts` crosses every verb with every flag it does not own. - **ResolvedRun**: the default verb's single merge of flags + config + built-ins (`resolveRun` in `src/cli/subcommands/smelt.ts`); the only place that verb's precedence lives, each value carrying its provenance (`flag`/`config`/`builtin`). It owns the budget-required refusal, and the verb's `run` executes it straight-line with no `??` of its own. - **retrieveStats**: the one exported derivation within `src/` of the honesty arithmetic (`expansionRate`, `allElisionsRetrieved`) from a store's **RawRetrieveCounters** — a free function in `src/stats.ts`, not a base class. A store implements `rawCounters()` and delegates `stats()` to it; adapters supply counters, never derive the metric. Consumers see only `stats()`; the seam is for adapter authors. (`bench/lib.mjs`, deliberately import-free, re-derives the same formula; `test/bench.test.ts` pins the two copies to each other.) - **PLANNERS**: the one registry of planner strategies (`src/plan/planners.ts`), string → factory over the lexical/structural option bags. `createSmelter`, `--strategy` and config validation, the help text, the `init` wizard's menu and the `smelt_file` tool schema all serve its keys; a constructed `planner` on `SmelterConfig` wins over any strategy name. **`DEFAULT_STRATEGY`** lives beside them: the strategy a caller who names none gets, read by `createSmelter`, the `smelt` verb's merge, the `init` wizard and the MCP server's `smelt_file` — the names were derived while the default stayed hand-typed in four places across two packages. - **Selector** (`auto`): the third strategy, and not a planner — it picks one (`src/plan/auto.ts`). Structural where the language carries a bundled grammar (`isStructuralLanguage`, the one membership test, shared with the structural refusal), lexical everywhere else, and it returns the delegate's plan **untouched**, so `result.planner` reads `lexical/v1` or `structural/v1` and never `auto/v1`. It decides on a fact (the language), never on an accident: a grammar that fails to load still raises `GrammarUnavailableError`, and an explicit `strategy: 'structural'` still refuses an unsupported language exactly as before. `DEFAULT_STRATEGY` stays `lexical` — `auto` is opt-in, because a changed default is a behaviour change delivered to callers who asked for nothing. - **Budget rung** (structural): the second pass in `planStructural`, and the reason it reads `input.budgetBytes` at all. When the first pass — every maximal sibling run, collapsed where that pays for its marker — comes back over budget, each run it _refused_ is re-asked as that run's best profitable sub-run, earliest first, stopping the moment the plan fits. Every candidate is minted by the same `collapse`, so a focus-matched or pinned unit is unreachable (it is in no run), the output cannot grow (nothing is minted whose marker is not strictly cheaper than the cut), and the enumeration is start-ascending, length-descending, so the plan stays deterministic. It fires only when the plan is still over budget after the first pass — never merely because a profitable sub-cut exists — and every cut it mints says so: `sibling-collapse-pressure` on the `ElisionReason.rule`, `sibling-collapse` for everything the first pass alone produced. The escalation is stated where every consumer of a plan already reads a rule from — the CLI report's rule column, the `--json` envelope, the per-rule ledger — never inferred from which pass happened to run. The lexical planner's context ladder is the sibling of this idea, the **Rerank budget rung** is the third reader of the same question, and `src/plan/budget.ts` is the arithmetic all three share. - **Unit** (structural, `unitsOf` in `src/plan/structural.ts`): one root-level sibling the structural planner can match or collapse — a top-level declaration plus its attached comment/attribute prefix. **Root children only, one level, a stated non-goal**: a class or object body one level down is never re-grouped into units of its own, so a class is one opaque unit — kept whole the moment anything inside it matches the focus, collapsed whole otherwise, and never split method by method. The honest minimum this planner claims for one very large class with one matching method and nothing else nearby to trade: no elision at all. `--strategy lexical` covers that case by lines, without a per-method name. `test/structural.test.ts` pins the behaviour (`'does not descend into a class body — a stated non-goal'`) as a fixture, not a bug. - **RepoMap**: the ranked whole-tree symbol map `buildRepoMap` returns — deliberately **not** an `ElisionPlan` and its builder deliberately not a Planner: nothing is elided, stored, or reversible, so the Planner interface would claim laws the map cannot honour. Its CLI front door is the `smelt map` subcommand, never a `--strategy` name; the map fits itself to its byte budget by construction, so `map` has no over-budget exit. **Path-only** (`map.pathOnly`, `REPO_MAP_PATH_ONLY_RULE`) is what an unmapped-language file gets, and — by the same mechanism, no special case — what php, kotlin and bash get too: their `LanguageProfile.repomap.defKinds` is `{}` (their definitions are not the identifier-shaped nodes the walk reads, so they are omitted rather than guessed at), which means zero defs, which is the one condition `buildRepoMap` checks. The file still appears in the map, honestly labelled, never as a name-less, rank-less regular entry. - **ResolvedMapRun**: `smelt map`'s single merge of flags + config + built-ins (`resolveMapRun` in `src/cli/subcommands/map.ts`) — ResolvedRun's sibling, sharing the seam that owns precedence (`Subcommand.resolve`) and the budget-required refusal, not the struct. - **Focus promotion** (repo map): a focus term moves matching symbols to the front of the map's fill order with a `focus-match` receipt naming the term; the measured rank and reference counts are never altered. - **RepoReader**: the repo map's whole door to the filesystem — `list(dir)`, `read(path)`, `stat(path)` in `src/repomap/reader.ts`, optional on `RepoMapOptions` and defaulting to `nodeFsReader()` (the `readdirSync` / `lstatSync` / `readFileSync` calls the map used to make in-line). `decide`'s `statFile` is the sibling seam and the precedent. Read-only by construction: **there is no writer on it**, so the only bytes `buildRepoMap` can put on disk are the tags cache a caller named with `cacheDir`. Because it is injectable, the walk's claims are asserted by _counting calls_ — a symlink is statted once and never read (refused on `isSymlink`, not on the accident that an `lstat` of a link is neither file nor directory), an ignored path is never statted at all, and a file's `stat` and `read` are adjacent calls for that one path — never a second whole-tree pass over paths a first pass already vetted, which is what closes the stat-then-read TOCTOU window this module actually controls (KOT-205 §6). - **CacheDiscard**: what `TagsCache.read()` in `src/repomap/cache.ts` returns for an entry it could not hand back as tags — named honestly by _why_, the same "damaged, never unknown" discipline the elision store applies to its own corruption. `'corrupt'` is an entry fully read and found unparseable or wrongly shaped; `'unreadable'` is one `readFileSync` itself refused (`EISDIR`, `EACCES`, anything but the plain `ENOENT` a miss already answers as `undefined`) — a case that used to escape as a raw `RepoMapIoError` and crash the whole map over one damaged cache entry the map never needed (KOT-205 §5). Both discard the same way and both report `deleted` honestly: `false` when the delete itself failed (an undeletable entry, e.g. a cache directory that turned read-only mid-build), never claimed as gone when it is still there. - **Ops**: the operations seam under both front doors (`src/ops/`) — `smeltBlob`, `mapTree`, `retrieveBytes`, `readCounters` (`ops/verbs.ts`) as library functions over **already-resolved** inputs, returning data (text, the values a report needs, a `RepoMap`, bytes, counters); plus the laws an input must satisfy to be resolved (`ops/inputs.ts`). An op never touches argv, stdout, exit codes or MCP result shapes. Both front doors are adapters over it: the CLI's subcommand `run` bodies parse/resolve → call an op → render; the MCP tools validate their JSON Schema → call an op → wrap a `CallToolResult`. Five laws live in `ops/inputs.ts` because both packages held a copy of each: the budget (positive integer, no default), strategy precedence and the `lexical` built-in (`BUILT_IN_STRATEGY`, `resolveStrategy`), the not-a-directory refusal (`readTree`), read-a-path-or-name-it (`readBlob`), and opening a store decision (`openStore`, over `configuredStore`'s `ConfiguredStore`). A law states its **rule and reasoning** once and takes the caller's **naming** as an argument — `--budget` versus `"budgetBytes"`, `map` versus `repo_map` — so the two surfaces stay byte-identical to what each printed before. Nothing in `ops/` throws for a refusable law: it returns a **Ruling** (`{ok, value}` or `{ok: false, refusal}`), because the doors refuse in different currencies (`CliUsageError`/exit 2 versus `isError: true`) and a shared exception would make one of them wrong. Deliberate divergences stay in the adapters — `smelt retrieve`/`stats` refuse a memory store, the MCP server accepts one and hints — which is why `resolveStoreRun` stays unexported: it is the CLI's policy, not a shared law. - **Rerank slot**: where a `RerankStage` actually bites — `src/rerank/protect.ts`, between the planner's decision and the cut. The **candidates** are the planner's own proposed elisions (the regions actually at stake), the **query** is the run's focus terms joined, and **what the stage returns is what smelt spares** — as far as the budget reaches: those entries are dropped from the plan, so they survive into the output as if a focus term had matched them. The returned list is a _selection_ and a _ranking_: not everything the stage was given, and ordered, because the slot walks it score-descending (ties in the order the candidates were sent) and stops at the first region that would push the predicted output past `budgetBytes`. A stage can only spare, never add, and it can no longer spend past the ceiling — a plan that fitted still fits. See the **Rerank budget rung** below for the ruling. No candidates, no query, or a plan whose own predicted output is already over budget, and the stage is not called at all — the last of those because a plan that does not fit affords no spare, so asking would send the caller's source to a third party for an answer refused before it arrived. Every way a stage can fail — including throwing, which a hosted one ordinarily does, and scoring an entry with something that is not a finite number — comes back as a `RerankStageError`, never as an unhandled crash. What it did comes back as a **RerankAttribution** (`{adapter, model?, candidates, returned?, kept?, sparedBytes?, stopped?, skipped?}`) on `SmeltResult`, which the stderr report, the `--json` envelope and `smelt_file`'s report block all render from — one value, three surfaces, no front door counting anything itself. `candidates` is always the measured size of the candidate set and `skipped` names the missing precondition when the stage was not called, so a receipt never carries a count nobody took. _Avoid_: "rerank filters", "rerank cuts" — it only ever keeps. - **Rerank budget rung**: the walk inside the rerank slot that decides how many of a stage's answers a run can afford — `spareWithinBudget` in `src/rerank/protect.ts`, the same shape as the planners' rungs and priced by the same `src/plan/budget.ts` (`predictOutputBytes`, `savingBytes`), so the slot and the planners cannot disagree about what a marker costs. The doctrine, in one line: **a K smelt invents is refused; a budget the user typed is honoured.** `topK` stays the **cap the caller wrote** — smelt never fills it, raises it, or adds a ceiling of its own — and the budget is a ceiling the caller also wrote, on the one number the library exists to control. If the best-ranked region alone breaks the budget, **nothing is spared**: a plan that fits beats a plan that does not, and the stage cannot cut, so the only lever left is not sparing. The walk stops at the first region that does not fit rather than skipping on to a smaller one — accepting that this can leave headroom a lower-ranked region would have used. Packing it would re-rank the stage's answer by size (a relevance decision the slot has no standing to make) and would cost the property that makes the outcome readable: what smelt spares is a **prefix of the stage's own order**, so a reader with the ranking and the budget can re-derive exactly which regions survived and why the next one did not. When the plan is over budget before the stage is even asked, the run is a **skip** (`skipped: 'plan-over-budget'`) rather than a stop: nothing ran, so nothing only a run can measure is reported. Three fields report the runs that did happen, present exactly when the stage ran: `returned` (how many it asked for), `sparedBytes` (what the spares put back — the regions restored, less the markers that no longer land) and `stopped` — `'budget'` (the next region would not fit; the only outcome where `kept` is below `returned`), `'cap'` (every returned region was spared and the stage returned fewer than it was offered — its own cut-off bound the run) or `'exhausted'` (the walk ran off the end of the list with the budget still holding and no cut-off to blame: either every candidate came back, or none did). Guarded by `test/guards/rerank-budget.test.ts`. _Avoid_: calling `topK` a quantity — it is a cap; calling the budget stop a cap — it is smelt's ruling, not the user's; and calling the over-budget skip a stop — the stage never ran. - **Rerank opt-in**: the `rerank` block in `smelt.config.json` (ADR-0004), and the only smelt setting that can send a caller's source to a third party. Two kinds: `module` (an ESM file of the consumer's own, resolved against the config file, default-exporting a `RerankStage`) and `voyage` (`@smeltjs/rerank-voyage`, which the consumer installs). **Absent means nothing happens** — no import, no call — and that is what every default config says. `loadRerankStage` (`src/rerank/load.ts`) is the one loader for both front doors; every failure is a usage error naming the missing thing (the path, the `topK` this kind requires, the environment **variable**, the uninstalled package) and never a silent fall back to an unranked run. Where an adapter package is looked for belongs to the **AdapterResolver** below, not to the loader. There is no `SMELT_RERANK_API_KEY` and no environment variable smelt reads that a config did not name. _Avoid_: "the rerank flag" (there is none), "enable reranking". - **Opt-in rerank bucket**: `OPT_IN_RERANK_PACKAGES` in `src/net/policy.ts` — adapter packages a config block may **load** at runtime and no smelt module may **import**. The name is data here and nowhere else in `src`; `resolve.ts` turns it into a `file:` URL and `load.ts` hands _that_ to `import()`, so the Law 1 walk finds no edge, and both packages' `classify()` rule an import of it **forbidden** rather than unclassified. The rule in one line: _smelt may know this package's name; smelt may not depend on it._ - **AdapterResolver**: `resolveAdapter` in `src/rerank/resolve.ts` — the one module that decides **where** an opt-in adapter package is looked for. The seam is `resolveAdapter(name, configPath, {ownRequire?})`, and it answers with a value rather than an exception: a found adapter carries its `url` and the `from` that says which of the two places answered; an unfound one carries the `configDir`, the `ownDir`, the `install` command and the `why` that names all three. It owns three things. **The order**: the directory holding `smelt.config.json` first (`createRequire(configPath)`, so `~/node_modules` beside a user-scope config and a project's own `node_modules` are one rule), smelt's own install second. **The refusal**: one message naming both places tried and `npm install --prefix `, the command that puts the package in the directory asked first. **The shape of the answer**: a `file:` URL, so the specifier at every `import()` stays a value and the Law 1 walk still finds no edge to an adapter — this is the seam that could have quietly undone the arrangement `net/policy.ts` writes down, so it is guarded beside it. Why it exists: smelt resolved the adapter from its own location, which for a Homebrew keg or an `npm -g` prefix is a directory nobody installs into, and then named `npm install `, which installs into neither place it had searched. Both kinds use it — `voyage` with the package name from `net/policy.ts`, `module` for a **bare** specifier that names no file beside the config (a relative or absolute path keeps the path rule the schema promises). It is asked by `smelt doctor` too, which is why it refuses without throwing: a report line, not an exception. **The condition set is part of the adapter contract**: the question goes through `createRequire`, so an adapter's `exports` map must answer under `default` or `require`; one that answers only `import` is _installed and unreachable_ — a second, distinct refusal, and it offers no install command because installing it again changes nothing. An unreachable copy **beside the config stops the search**, since a copy there is the answer about the adapter this config points at — and because that rule is invisible from a machine that also holds a good copy in smelt's own install, the refusal says smelt's own install was not tried and that removing the broken copy lets the search go on. **On trust**: reading the config directory's `node_modules` is not a new trust. A `smelt.config.json` already chooses code smelt imports — that is the whole of the `module` kind — so a file that can name a path to import can name a package beside itself; and the reach this widens (a global `smelt` finding an adapter in a repository somebody cloned) is bounded by the gate it always had: nothing is loaded unless that config carries a `rerank` block, and nothing leaves the machine unless the environment variable that config names is set. _Avoid_: "where smelt is installed" as a synonym for where an adapter is — the whole point is that they are two directories. - **guard-kit**: the guards' shared machine — `packages/guard-kit`, test-only, `private: true`, never published and never more than a devDependency. It owns the import-graph **walker** (`walkImportGraph`, `assertNoNetwork`) that both packages' Law 1 guards run on, carrying the four vacuity defences and the reasoning for each, plus the source helpers (`guardSrcRoot`, `guardRoot`, `allSourceFiles`, `readSource`, `stripStringsAndComments`). The seam is `classify(edge): Classification` — one small function per package holding only that package's **ruling** (the core partitions against `net/policy.ts`; the mcp package adds a stdio-only SDK subpath allowlist). Each package's `test/guards/_source.ts` stays as its anchor, and the anchor is one call: `guardAnchor(import.meta.url)` derives `packageRoot()`/`repoRoot()` from the anchor's own location and returns the bound helpers, so nothing in the anchor is package-local but the `import.meta.url` it passes (the two anchors used to carry a byte-identical `packageRoot()` each). `GuardMutation` lives in the kit too; each package's `_mutations.ts` re-exports it, so a guard's import and the runner's textual anchor are unchanged. `SMELT_GUARD_SRC` / `SMELT_GUARD_ROOT` keep exactly the semantics `scripts/mutate.mjs` sets them with. The kit also owns the one registry invariant no `Record` can type-check — `assertKeyedById( registry, idField)`: the key **is** the id, and the entry's id field agrees, so a by-key lookup (`profileFor`, `subcommandFor`) and a by-field one (`harnessById`, `HARNESS_IDS`) name one profile per id. It is an assertion applied to each registry, not a shared Registry module (ruling: a module fails the deletion test; the registries stay plain objects). It also owns the **packaging** machine — `packPackage`, which runs the real `npm pack` and extracts it, plus the three rules that read the result: no shipped declaration names an ambient global namespace, no sourcemap points outside the tarball, and a tool schema satisfies strict-mode structured outputs. Those are properties of _published bytes_, which no repo-level check can see. - **SmeltConfig**: the parsed shape of `smelt.config.json`, and the module that owns the schema (`src/config.ts`) owns **both** directions — `parseConfig` reads, `renderConfig` writes, one key order. It sits at the package root, not under `cli/`: the file is a CLI concern (the programmatic API never reads it), but the schema is read by `harness/`, `ops/` and `rerank/` too, and a module three layers depend on cannot live inside one of them — that is how `harness/` came to have a `cli/` import at all. What goes into a config stays each verb's **policy**: the `init` wizard always writes the strategy and store it asked about, `hooks install` injects a directory store when a config carries none (the deny reasons promise `smelt retrieve `, which a memory store cannot honour across processes). The round trip — `parseConfig(renderConfig(c))` equals `c` field for field — is the property that could not be expressed while two modules hand-built the file, and the guard reads the key set out of the reader's own refusal, so a field the writer forgets goes red rather than becoming a setting the user believed was in force. - **Byte-faithful editor**: `src/text/json-edit.ts`. `editTopLevelProperty` replaces, inserts or removes (`value === undefined`) **one top-level property** of a JSON object in its source text, and `upsertMarkerBlock` / `stripMarkerBlock` do the same for a block between two marker lines. The contract is the whole interface: change what you were asked to and leave every other byte alone — indentation, key order, escapes, number spellings, unknown keys. It knows nothing about harnesses or hooks; `harness/plan.ts` decides _what_ the merged `hooks` value is and hands it over. Under `src/text/`, not `cli/`, because it is strings in, strings out — no argv, no stdout, no CLI import. `test/guards/json-edit.test.ts` pins the round trip. Its sibling, `src/text/toml-edit.ts` (KOT-258), carries the same contract for TOML — `editTomlTable` replaces, inserts or removes one `[a.b]` table, header form or dotted form, for Codex's and Grok's `mcp_servers.` registration — pinned by `test/guards/toml-edit.test.ts`. - **Instruction set** (`smelt agents`): the `AGENTS.md` / `CLAUDE.md` / `GEMINI.md` an agent loads on **every request**, as `src/agents/instructions.ts` finds them — through `RepoReader`, so the walk is injectable and its claims are asserted by counting calls. The guide's rule is that a nested file _merges with_ the root — a merge that runs **up** the tree and never across it, which makes two different numbers: the **per-request** cost (`perRequestBytes`, the heaviest level plus its ancestors — what one agent actually loads) and the **whole-tree surface** (`totalBytes`, every level summed — what a team maintains). Siblings never merge, so summing them and calling the result a per-request cost states a cost nobody pays. Each directory contributes one **primary** (its `AGENTS.md`, or whichever file stands alone there) and any number of **mirrors** — the other two names beside it. A mirror is counted for **drift** and never for bytes: one agent loads one of them, so summing all three would triple a cost nobody pays, and a symlinked mirror (the arrangement the guide recommends) cannot drift at all. - **Finding** (`smelt agents lint`): what the lint noticed at one place, carrying an `ElisionReason` — the same stable `rule` id plus explanation an elision carries, for the same reason. Eight rules, in `src/agents/lint.ts`; the explanation always ends with an attributed fragment of the guide, so smelt's measurement and the guide's opinion are never mistaken for each other. Findings are **advisory** — exit 0 — until `--strict`. - **Imperatives (heuristic)**: the count of instruction-looking lines, reported beside the byte total and never as a precise figure. It is a companion measurement, not a finding: an instruction file is _made_ of imperatives, so counting them as defects would make `--strict` red on every real file. The guide's cited "~150–200 instructions" is printed as a citation and compared to nothing — expansion rate's ruling, applied to prose. - **Split seam** (`smelt agents split`): the line between the guide's refactor's mechanical half — partition by `##` heading, rewrite the links that moved a directory deeper, write nothing without a per-file `yes` — and its judgment half, _which sections are essential_. The second needs a reading of the project, which needs a model, which Law 1 forbids; so smelt does the first and prints the guide's own refactor prompt, filled in with the file's real headings, for the user's own agent. The unconfigured rerank stage, applied to prose. - **Palette** (`src/cli/lava.ts`): every byte of colour smelt writes, and the primitives that lay text out under it, behind one interface. The seam is `palette(options)` — plus `stdoutPalette(io)` and `stderrPalette(io)`, which answer the two streams separately, because `smelt big.log --budget 4000 > small.log` leaves the report on a terminal while stdout is a file. It owns **roles** (`heading`, `rule`, `hash`, `number`, `path`, `good`, `bad`, `warn`, `dim`, `strong` — what a span _is_, never what colour it should be), the **primitives** (`kv`, `table`, `bar`, `glyph`, `percent`, `divider`, `logo`) and — one composition above them — the **done block** every wizard ends on (`doneBlock`, with `countedFiles` for its verdict: a rule, what the run did counted off what it _applied_, and the commands that follow). Nothing else: no verb builds an ANSI sequence inline, so the day the brand changes it changes in one file. Three rules make it safe, and `test/guards/palette.test.ts` holds all three. **Off is the identity** — colour off is byte-for-byte the plain rendering, which is what every `--json` envelope, every `--yes` receipt, every pipe, `NO_COLOR`, `--no-color` and every guard's assertion gets. **Padding is measured before painting** — an escape sequence has zero width on screen and a dozen bytes in a string, so a cell padded after painting is a column that does not line up. **A rendering may not round a non-zero to zero** — `percent` prints `<0.1%` and `bar` keeps one filled cell for a rate that is not zero, which is Law 4 at the last inch before a person reads it. The **glyph set** (`✓ ✗ ⚠ · •`), the closing block's rule, the bar's block cells and the prose's **em dash** (`dash()`, the primitive; `EM_DASH` is what it returns and what folds a sentence composed by a module with no palette in hand) fall back to ASCII where the locale never said it could render more (`supportsUnicode`) — so the four closing blocks, `smelt doctor` and `smelt stats` carry nothing above ASCII there, punctuation included, while smelt's voice keeps its em dash everywhere a terminal can render one. The **wordmark** is a committed constant in the ANSI Shadow letterforms with a plain-ASCII twin — smelt runs no figlet. **How much** colour is a capability, not a preference: `colorDepth(env, isTty)` → `'none' | 16 | 256 | 'truecolor'`, in one precedence — `NO_COLOR` (any non-empty value) beats everything, then `FORCE_COLOR` (`0` off, `1` sixteen, `2` 256, `3` truecolor, anything else sixteen), then `COLORTERM` ∈ {`truecolor`, `24bit`}, then `TERM` containing `256color`, then `TERM=dumb` → none, and otherwise sixteen at a terminal and none anywhere else. `colorAllowed` is that same answer as a boolean, so the two can never disagree. The lava ramp resolves against the depth (`38;2` truecolor, `38;5` on the 6×6×6 cube, nearest of the sixteen below that); every other role was already one of the sixteen every ANSI terminal has had since 1979. `bin.ts` asks once, about the terminal, while the per-stream switches stay per-stream. _Avoid_: "theme", "styling helper"; and never a colour name at a call site. ## Setup and distribution Decided in the Sep 2026 architecture review; ADRs 0001–0004 carry the reasoning. - **SetupRecipe** (`src/setup/recipe.ts`): the one true way to put smelt on a machine — install, init choices, hooks, the MCP server's own command, verification — held as data, from which every rendering (README fragments, site prompts, the `setup` verb) derives, or is guard-pinned against it. Prose is never the source. It names **no harness**: registration is a **HarnessProfile** fact (`profile.mcp`), because a `claude` CLI verb is not how Codex or opencode register anything. The recipe held Claude Code's spelling as though it were everyone's, and five renderings read it from there; what is left is `mcp.run`, the plain stdio command true of every MCP client. - **Setup** (`smelt setup`): the one-command, idempotent application of the recipe for chosen harnesses, at an **InstallScope** — interactive when a TTY is present, fully scriptable when an agent runs it, and the only repair path for installed state. Scriptable is load-bearing, not a convenience: the repair path an agent cannot drive is a repair path that does not happen. `--yes` answers every question, and the four toggles (`--guard`, `--stats`, `--map`, `--lint`, each `on|off`) answer the preset's; `smelt hooks install` takes the same four and the same `--yes`. From the home directory it detects a machine-wide install, says so, and lets you flip it; everywhere else it is the project's. The `init` wizard remains the deliberate sibling, not the repair path. _Avoid_: installer, `smelt init` (that is the careful wizard). - **MergePolicy** (`Consent` in `cli/merge-policy.ts`): the one answer to "may this run write over a file that already exists", behind both install verbs. There are two ways to consent and one apply loop, because two loops drift and the one that drifts is the non-interactive path nobody watches. A **wizard** consent asks per file and takes nothing but a literal `yes`. A **policy** consent — what `--yes` and `smelt setup` use — reads the plan's own shape: a file whose planned bytes were computed _from_ the existing bytes (a JSON hooks merge, a marker-block upsert, a registration edit) is written, because **every entry that is not smelt's is already in it**; a file smelt writes _whole_ is refused unless it is already smelt's, and the refusal names it. The claim a merge makes is about entries, not bytes: outside the edited region — the `hooks` key, our marker block, our server entry — the file is byte-identical, but the edited region is re-serialised, so a foreign entry inside `hooks` keeps its content and can come back formatted differently. Recorded on `PlannedFile.ownership` (`'merged' | 'whole'`), so the question is answered by data the planner produced rather than by a list of filenames. It is its own module because it is one idea with two consenters: while it sat inside the hooks wizard, `setup` imported a wizard to apply. _Avoid_: "overwrite" for the merged case — nothing of anybody else's is overwritten. - **InstalledState**: what smelt has written for one **InstallScope** — hook entries (found by their ownership marker), the config, the MCP registration, the binary version. Every path it reads is resolved by `locateStep`, the same resolver the installer wrote through, so a machine install is read back from `~/.claude/settings.json` and a project install from `.claude/settings.json`; a reader with its own list of names is how doctor came to agree with a writer that had moved. `smelt doctor` reads it and never writes it — including the registrations that are the harness's own file to rewrite, which it checks and names but never edits; orphaned pieces are reported facts, never silently cleaned. `presetToggles` lives with it (`cli/installed.ts`), for the same reason: what a re-run's four toggles start from is a reading of what is installed, not a wizard's memory. - **SkillPack**: the opt-in, published teaching artifact an agent's owner installs by consent (`npx skills add smeltjs/smelt`) — the second adapter over the instruction content, beside the marker block. Distinct from R1's refused act (ADR-0002): smelt still never writes an agent's files uninvited. - **AgentIndex** (`llms.txt`, with `llms-full.txt` beside it): the llmstxt.org index an agent fetches _before_ it has installed anything — an H1, a blockquote summary, the four laws as the notes, the three commands, the MCP tool names, and H2 link lists of every document, ending in `## Optional`. It is **not** a third instruction channel: there are still two (the marker block and the SkillPack, ADR-0002), and the index only points at them. `llms-full.txt` inlines every document the index names, for a reader that can spend the tokens on one fetch instead of twelve. Both are rendered by `scripts/generate-llms-txt.mjs` from one document list and the built packages' own facts. The index is written twice — the repository root and `site/public/`, byte-identically, because an agent reading the repo should not have to fetch it, and because two hand-kept copies of a link list is precisely how a link list goes stale. The companion is written **once**, under `site/public/`: it is the whole documentation set inlined, the index already links its served URL, and a second committed copy would put a large regenerated blob in the diff of every docs change for nobody's benefit. _Avoid_: "docs index" (the README's Documentation table is that) and "manifest". --- # docs/ARCHITECTURE.md
# smelt — architecture The deep reference. What smelt is, why each of its four laws exists, the architecture file by file, the contract any consumer can rely on, and the design decisions with the reasoning behind them. Read it before the code — the laws explain the shape of everything else. --- ## What smelt is smelt is a Node/TypeScript library that shrinks what a coding agent sends to a model, without lying about what it removed. Given a blob of text — a file, a grep result, a stack trace, a diff — and a byte budget, it returns a smaller blob in which the parts the task needs survive and everything else has been replaced by a one-line marker that says what went, how big it was, and a hash to get it back. The removed bytes are stored locally, and the model is given a `smelt_retrieve` tool. Every retrieval is counted, so over-pruning shows up as a number instead of as a model that is quietly wrong. It makes no network calls, and a test fails if it could. It is a **library**, not a proxy: it transforms content its caller hands it and never intercepts anyone's traffic. --- ## The four laws, and why each one is load-bearing These are not preferences. Each one exists because breaking it produces a library that _looks_ like it works, and a contributor acting in good faith will break them helpfully unless they understand why they are there. ### Law 1 — zero network **smelt makes no external calls. Code never leaves the machine.** Scoring is structural (tree-sitter WASM) and lexical. Reranking exists as a _pluggable stage interface_ a consumer opts into explicitly, in a config file they wrote — never a default, never bundled, never an environment variable smelt picks up on its own (ADR-0004). _Why it is load-bearing:_ the natural way to make a context optimizer better is to ask a model which parts matter. The moment that becomes a default, every consumer of smelt is shipping their users' source code to a third party — and they find out from a changelog, or a proxy log, or not at all. There is no way to opt out of a default you did not know existed. The zero-network property is also the only reason smelt is usable inside companies that will never approve an outbound call from a dev tool, which is a large fraction of the people who need it most. The subtle failure is not someone adding `fetch()` on purpose. It is a grammar cache: `web-tree-sitter`'s `Language.load()` accepts `string | URL`, and "download the grammar on first use" is a perfectly reasonable-looking optimisation that works flawlessly on the machine that wrote it. That is why `src/plan/grammar.ts` reads the `.wasm` bytes itself and hands tree-sitter a `Uint8Array` — removing the capability rather than documenting it — and why `assertLocalResource()` rejects any non-`file:` scheme before that. Enforced by `test/guards/no-network.test.ts`, which walks the real import graph and classifies _every_ edge. The walk is one machine (`packages/guard-kit`, test-only and never published); the ruling on what an edge may be is one small `classify()` per package, so both packages defend Law 1 with the same defences and their own verdict. See "How to prove a guard can fail" below. The one adapter that _does_ reach the network — `@smeltjs/rerank-voyage` — is a separate package a consumer installs themselves, and the rulings name it as **forbidden** rather than merely unvetted. Its name lives in `net/policy.ts` as data (`OPT_IN_RERANK_PACKAGES`), `rerank/resolve.ts` turns that value into a `file:` URL and `rerank/load.ts` hands _that_ to `import()` when a consumer's own `rerank` config block asks for it, and three mutations prove the distinction is real: a static import of the adapter in either package goes red, and so does respelling the loader's dynamic import with a string literal. That last one is the important one — it changes nothing about the running code, and everything about whether the adapter is in the graph. ### Law 2 — every elision is explainable **Every removal can state what it removed, in words, from a named rule.** "collapsed 3 sibling functions, retrievable" — never a model's opinion, never "compressed by 62%". _Why it is load-bearing:_ explainability is what makes the output debuggable and the library trustworthy at the same time. When an agent gets an answer wrong, the first question is "what did it not see?", and a marker that says `collapsed 3 sibling functions` answers it while `[...truncated...]` does not. It also disciplines the implementation: a rule you cannot describe in a sentence is a rule you do not understand, and it will do something surprising. This is why `ElisionReason` has two fields — a stable `rule` id for counters and an `explanation` a human reads — and why every planner must fill in both. The consequence people find surprising: **no learned distillation in v1.** A model-written summary cannot satisfy this law. "The model condensed this" does not say what was removed, and once the text has been rewritten there is nothing left to store under a hash. The interface exists (`DistillStage`); the implementation does not. ### Law 3 — every elision is reversible **What is elided is stored locally, keyed by content hash; the model gets a stub plus a `retrieve(hash)` tool. Expansions are counted.** _Why it is load-bearing:_ reversibility is what makes cutting safe enough to do aggressively. But reversibility alone is trivially gameable — a library that hides 90% of every file is "reversible" and useless — so the second sentence carries as much weight as the first. **The expansion rate is the honest signal of over-pruning.** If the model keeps calling `smelt_retrieve`, smelt cut material the task needed, and each round trip cost more tokens than the elision saved. A retrieve counter that is not wired up leaves that rate pinned at a flattering zero forever, which is precisely the shape of failure this project exists to refuse — hence `test/guards/expansion-counter.test.ts` guarding an increment. Two design consequences worth understanding before you change them: - `AppliedElision.outputRange` records where the marker landed in the _output_. Without it, "reversible" would mean parsing markers back out of text, which is a guess. Reversibility is a fact recorded at the moment of the cut. - `MemoryElisionStore` has no eviction and no `clear()`. A store that can forget turns this law into "reversible, usually", and a `retrieve()` that fails after an eviction is indistinguishable to the model from a hallucinated hash. - **Reversible until the user prunes — and the prune is itself counted.** One global store shared by every session accumulates blobs forever, so there has to be a way to reclaim the disk; every way that does it quietly (a size cap, an LRU, a TTL applied on open) buys the space by having smelt decide which of someone else's elisions stopped mattering, at a moment they did not choose, with no record of what went. So the only eviction in smelt is `smelt store prune`: a verb a user types, against a cut-off that user names, which journals `evict "" ""` **before** it unlinks anything. The cut-off may be **written down** — `store.retention.olderThan` in `smelt.config.json`, which `--older-than` overrides and the receipt attributes — and that is not a hole in the ruling: the doctrine is about the deletion, not the number. A key in the user's own file schedules nothing, is read by one verb at the moment that user types it, and with neither spelling present the verb still refuses. Two consequences make it compatible with this law rather than an exception to it. A later `retrieve` of an evicted hash throws `EvictedHashError` — "you pruned it on ", never `UnknownHashError`'s "it was never elided" — so the model can still tell a lost blob from a hallucinated hash. And the counters do not move: `elisionsStored` keeps counting what was evicted, because a prune that shrank the denominator would raise the expansion rate for free, and the per-rule ledger is untouched, because the rule did make that cut and nobody asked for it back. Only `bytesStored` falls, because only `bytesStored` measures the disk. `test/guards/store-prune.test.ts` pins all four. ### Law 4 — claim no number that has not been measured **Absolute.** Not in the README, not in a doc comment, not in a commit message, not in a tweet. _Why it is load-bearing:_ this is the entire differentiator. The pitch this project began from claimed "80–94% token reduction" and a "90%+ cache hit rate". Both are unsupported: the second conflates Anthropic's 0.1× _price_ for a cached read with a _hit rate_, which are unrelated quantities, and no benchmark producing either figure exists. Publishing them would have been the first thing a knowledgeable reader checked and the last thing they believed. What is honest to say instead: state the **mechanism** and the **class** of expected saving _with its source_. The nearest real comparable is Headroom's own stated **21–57% across its four proof scenarios** (their README, 2026-09). LLMLingua's 20× results are on non-code benchmarks. Until smelt has run its own harness on its own traffic, the README states mechanisms and cites other people's numbers as other people's. The measurement harness (below) is what changes that. --- ## The architecture, file by file Everything below is typechecked, linted, and covered. `pnpm verify` is the gate. ### The library | File | What it does | | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `packages/core/src/types.ts` | The vocabulary: `Planner`, `ElisionPlan`, `AppliedElision`, `ElisionStore`, `RetrieveStats`, `Measure`, `RerankStage`, `RerankAttribution`, `DistillStage`. Read this first — the doc comments carry the reasoning. | | `packages/core/src/rerank/protect.ts` | The rerank **slot**, and its budget rung: between the planner's decision and the cut. The candidates are the planner's own proposed elisions; what a stage returns is what is spared, never added — best-first, and only while the predicted output still fits `budgetBytes` (`plan/budget.ts`, the arithmetic the planners' own rungs use). A K smelt invents is refused; a budget the user typed is honoured. Every way a stage can fail, throwing included, becomes a `RerankStageError`. | | `packages/core/src/rerank/load.ts` | A `rerank` config block to a live stage. `undefined` in, `undefined` out; the opt-in adapter package is loaded by computed specifier and imported by nothing. | | `packages/core/src/rerank/resolve.ts` | The **adapter resolver**: where an opt-in adapter package is looked for. The config file's own directory first, smelt's own install second, and otherwise one refusal — `missing`, with `npm install --prefix "" `, or `unreachable` for a package that is installed and whose `exports` map answers under neither `default` nor `require`, which no install would fix — and an unreachable copy beside the config **stops the search**, a precedence rule the refusal states along with the fix it opens. Resolves and never imports — what comes back is a `file:` URL, so the specifier at every `import()` stays a value. | | `packages/core/src/errors.ts` | Every error is a `SmeltError`, so callers can tell "the library said no" from "something broke". | | `packages/core/src/hash.ts` | 16 hex chars of sha256. Short because the hash goes in every marker and the model pays for it. | | `packages/core/src/detect.ts` | Extension → language. `'unknown'` is a first-class answer, not a failure. | | `packages/core/src/lang/` | One `LanguageProfile` per language — extensions, grammar wasm, marker leader, pins, structural node kinds, repo-map tag kinds. The registry is `Record`, so totality is a compile error; every exported set is a derived view. | | `packages/core/src/store.ts` | `MemoryElisionStore`: content-addressed, dedupes, refuses hash collisions, counts retrievals. | | `packages/core/src/store-dir.ts` | `DirectoryElisionStore`: the persistent store — one file per content hash, atomic no-clobber writes, verify-on-read, counters in an append-only journal. `survey()` answers the counters, the ledger and the size from one blob scan plus one journal fold, and `stats()`/`rawCounters()` are views over it; `ledger()` takes the journal half alone, because `smelter.ts` asks for it on every run and no fact in it lives in `blobs/`. See "The persistent store" below. | | `packages/core/src/store-cutoff.ts` | The age cut-off grammar (`d`/`h`/`w`) and its one reader — shared by `--older-than` and `store.retention.olderThan`, so a flag and a config key cannot disagree about what `30d` is worth. Reads a spelling; words no refusal, because the two callers do not refuse alike. | | `packages/core/src/stats.ts` | `retrieveStats()` — the one derivation of the honesty arithmetic (`expansionRate`, `allElisionsRetrieved`) from a store's raw counters. Stores supply counts; they never derive the metric. | | `packages/core/src/retrieve.ts` | `createRetrieveTool()` — the `smelt_retrieve` tool a consumer hands its model. Not MCP- or SDK-specific on purpose. | | `packages/core/src/apply.ts` | `applyPlan()` (the only function that removes anything), `reconstruct()` (Law 3 as an equation), `MARKER_FORMAT_VERSION` — the wire surface, frozen — and `markerPricing()`, the one place a marker's byte cost is computed. No judgement at all. | | `packages/core/src/plan/lexical.ts` | The lexical planner: focus-window and head-tail rules, a context ladder under budget pressure, profitability check so a marker never costs more than the lines it replaces. Deterministic. | | `packages/core/src/plan/structural.ts` | The structural planner for all fifteen supported languages. Refuses rather than falls back: an unmapped language or a failed grammar load throws `GrammarUnavailableError`, because output labelled `structural/v1` that is really line windows is undetectable from outside. | | `packages/core/src/plan/planners.ts` | The `PLANNERS` registry, string → factory, and `DEFAULT_STRATEGY` beside it. Five entries now: `lexical`, `structural`, `auto`, `json` and `diff`. `createSmelter`, `--strategy`/config validation, the `--help` text, the `init` wizard's menu and the `smelt_file` tool schema all serve its keys. | | `packages/core/src/plan/auto.ts` | The `auto` strategy: content kind first (`json`, `diff`), then structural where a grammar is bundled, lexical everywhere else — and the delegate's plan returned untouched so `result.planner` names what ran. A selector, not a fallback — a grammar-load failure travels out of it. See "Picking a planner" below. | | `packages/core/src/plan/kind.ts` | `probeKind()` — the BlobKind probe: `'json'` when the text parses, `'diff'` when it carries a unified-diff header shape, `undefined` otherwise. A fact about the bytes, never a language sniff. See "Picking a planner" below. | | `packages/core/src/plan/json.ts` | The JSON planner: members and elements as units, scanned with positions; runs of unmatched siblings collapse, matched containers are descended into, and with no focus the root is kept as a skeleton. Refuses non-JSON with `ContentKindError`. | | `packages/core/src/plan/diff.ts` | The diff planner: files and hunks as units. Whole files the focus never touches collapse as a run; inside a matching file the non-matching hunks do. Refuses text without a diff header with `ContentKindError`. | | `packages/core/src/plan/offsets.ts` | `utf8OffsetIndex()` — the one JS-index → UTF-8-byte conversion the structural and JSON planners share; every converted index is a unit boundary, so a range never splits a character. | | `packages/core/src/plan/budget.ts` | What a plan costs once its markers land — `markerBytes`, `savingBytes`, `predictOutputBytes`, over the `MarkerPricing` seam. Every planner reads `budgetBytes` through it, and this is the one place any of them answers "how big is the output?". | | `packages/core/src/plan/grammar.ts` | Loads a prebuilt grammar `.wasm` off disk, through `assertLocalResource`. Bundled copy first, `tree-sitter-wasms` as the source-checkout fallback. Cached. | | `packages/core/src/repomap/` | `buildRepoMap()` — the ranked, budgeted whole-tree symbol map, read through the `RepoReader` seam in `repomap/reader.ts`. See "The repo map" below. | | `packages/core/src/agents/` | The `smelt agents` engine: the merged instruction set read through `RepoReader` (`instructions.ts`), the eight advisory rules and the measured report (`lint.ts`), the mechanical root/`docs` partition and the guide's refactor prompt (`split.ts`), and the guide's own phrasing quoted once (`guide.ts`). See "The `agents` verb" below. | | `packages/core/src/cache/prefix.ts` | Cache-prefix hygiene: `findPrefixDivergence` and `detectCacheBreakers`. Pure functions; detect and warn, never rewrite. | | `packages/core/src/net/policy.ts` | Law 1, written once: forbidden transports, forbidden globals, **and** the permitted sets — so the guard is a partition, not an allowlist. | | `packages/core/src/config.ts` | `smelt.config.json`: versioned, found by walking up, defaults only, malformed is a loud usage error. Owns **both** directions — `parseConfig` and `renderConfig`, one key order — so the verbs that write the file cannot disagree about its shape. At the package root, not under `cli/`: the _file_ is a CLI concern, but the schema is read by `harness/`, `ops/` and `rerank/` too, and a module three layers depend on cannot live inside one of them. | | `packages/core/src/cli/args.ts` | `node:util.parseArgs`, zero new dependencies. Splits argv, answers `--help`/`--version`, looks the verb up in `SUBCOMMANDS`, and refuses every flag that verb does not own with one generated message — no per-verb branching left. | | `packages/core/src/cli/shell.ts` | The CLI's edge, in one module: `CLI_NAME`, `CliIo` (the two sinks, their two colour switches and the colour depth the terminal reported), `AnswerStream` / `answerReader`, `EXIT`, and the closed-sink refusal — `closedSinkCode` / `refusingSink`, which turns `smelt hooks install | head`into one line and exit 2 rather than an EPIPE stack trace. **It imports nothing**, so every module under`cli/`can read it without making`args.ts → subcommands/* → args.ts`a cycle, and`AnswerStream`is stated structurally rather than as`NodeJS.ReadableStream`so the shipped declarations compile for a consumer with no`@types/node` in global scope. | | `packages/core/src/cli/subcommands/` | One `Subcommand` per verb — the flags it owns, its parse, its `Resolved*Run` merge, its run, its help block. `Record`, so totality is a compile error; the USAGE block, the help sections and the flag refusals are derived views. `store.ts` is the eviction verb (`smelt store prune`) and the only thing in smelt that deletes an elision. | | `packages/core/src/cli/usage.ts` | The help page and the front door, rendered from the registries rather than hand-arranged: each verb's USAGE line, its section, and the `map only.` / `hooks only.` prefix on the flags it owns come from `SUBCOMMANDS` and `CLI_FLAGS`, so a seventh verb or an eleventh flag reaches the help by existing. `test/__snapshots__/cli-usage.help.txt` pins the plain rendering, so a help change is a reviewable diff. The front door — bare `smelt` at a terminal — is the one list here deliberately _not_ derived: it is an opinion about the three commands a newcomer needs, not the ten the registry knows. | | `packages/core/src/cli/report.ts` | Every rendering the CLI has — the stderr report, the map and prune reports, `smelt stats`. Every total is read off the `SmeltResult` or the store: two pieces of code counting the same bytes is how a report ends up disagreeing with its own library. | | `packages/core/src/cli/lava.ts` | The **Palette**: every byte of colour smelt writes, and the primitives under it (`kv`, `table`, `bar`, `glyph`, `percent`, `logo`, and the `doneBlock` every wizard ends on). Roles, never colours, at a call site; off is the identity, padding is measured before painting, a non-zero never rounds to zero, and a closing block counts what was applied rather than what was planned. `colorDepth` asks how much colour the terminal has (`none`/16/256/truecolor) and the lava ramp resolves against it, so `38;2` reaches only a terminal that said it could render one. | | `packages/core/src/cli/run.ts` | The CLI as a function returning an exit code, so it runs in-process in tests. A lookup and a dispatch: the verb that parsed an invocation is the verb that resolves and runs it. | | `packages/core/src/cli/init.ts` | The `smelt init` wizard as a pure function over an input/output pair. See "`smelt init` and `smelt.config.json`" below. | | `packages/core/src/cli/hooks.ts` | `smelt hooks install` / `remove` — the wizard, and only the wizard: the steps, the confirm, the prose. What both install verbs share moved out from under it — the plan (`harness/plan.ts`), the merge policy (`cli/merge-policy.ts`) and the installed toggles (`cli/installed.ts`) — so `setup` no longer imports a wizard to plan. See "The hooks preset" below. | | `packages/core/src/harness/plan.ts` | What an install would write, and what `remove` takes back out: `planInstall` / `planRemove`, folds over `HarnessProfile.install` with no per-harness case, plus the hooks merge (which entries are ours, what a re-run replaces) and `renderConfigWithHooks`. In `harness/` because planning is not a verb; it imports nothing from `cli/` — the config schema it writes through is `src/config.ts`, at the root. | | `packages/core/src/cli/merge-policy.ts` | The MergePolicy: one apply loop with a `Consent` adapter over it — `wizard` asks per file and takes a literal `yes`, `policy` reads `PlannedFile.ownership` (merged bytes are written, a whole-owned file that is not smelt's is refused, with a reason naming it). Both install verbs apply through it, because the loop that drifted would be the non-interactive one. | | `packages/core/src/setup/recipe.ts` | The **SetupRecipe**: the one true way to put smelt on a machine — the install commands, the recommended budget, the store default, the MCP run command, and the ordered setup steps — held as data, because prose is never the source. It names no harness: a _registration_ is `HarnessProfile.mcp`'s, so `mcp.run` (the stdio command any client registers) is the whole of its MCP block. It imports nothing and does nothing: it is the fact layer every setup surface reads, and the seam the `setup` verb, the skill pack and the site's fact generator hang off. `test/guards/setup-recipe.test.ts` pins everything that used to retype it — which is how the store default came to exist under three doc spellings, one of them wrong, and the MCP registration command in four places at once. | | `packages/core/src/cli/setup.ts` | `smelt setup` — the SetupRecipe applied end-to-end: config, the hooks preset over `planInstall` (which also carries the MCP registration, JSON or TOML, for every profile that declares one), the MCP step in the words that harness's own docs use (`HarnessProfile.mcp`) — Codex, Grok and opencode register through their own step, so the handover is _manual_ only where no selected harness carries a registration at all, and then it names none but `npx @smeltjs/mcp`, the plain stdio server any MCP client registers — with `mcp.commands` naming every registration a run is about, and a real smelt → retrieve round trip. One apply path under `--yes` and the wizard, and the merge policy is `cli/merge-policy.ts`'s, applied by policy consent: an existing file is merged, a whole-owned file that is not smelt's is refused. `smelt.config.json` is written once per run. A re-run is a byte-neutral no-op. | | `packages/core/src/cli/wizard.ts` | The wizard kit — the ask adapter, the step machine with real back, the confirms, the plan listing and the one write mechanic — shared by `init`, `hooks` and `setup`. Extracted (review II) because the third copy of the stream machinery was the one that raced. | | `packages/core/src/cli/installed.ts` | The one reader of InstalledState — blocks with stamps, hook wiring, MCP registrations, the config — behind doctor's verdicts and setup's repair policy, and the home of `presetToggles`, the reading a re-run edits its four toggles from. | | `packages/core/src/cli/doctor.ts` | `smelt doctor` — the verdicts over InstalledState. The reading is `cli/installed.ts`'s; this module decides and reports: which blocks are behind the running binary, which pieces are orphans, whether each hook it can read actually fires (`probeHookCommand` → `wired (verified)` / `wired but inert` / `wired but missing`), what the store holds, and what the repair is. It writes no byte of your project (ADR-0003) — the oversized file a hook probe needs lives in a temp directory that is removed again — and the exit code carries the verdict, so the upgrade → doctor → setup loop needs no prose parsing. | | `packages/core/src/cli/agents.ts` | The `smelt agents split` wizard — `init`'s consent discipline over the partition `agents/split.ts` computed. The only thing here that touches disk. | | `packages/core/src/text/json-edit.ts` | The byte-faithful editors: `editTopLevelProperty` replaces, inserts or removes one top-level JSON property in the file's own bytes — every byte outside it rides through verbatim — and `upsertMarkerBlock` / `stripMarkerBlock` do the same for a delimited text block. Strings in, strings out; knows nothing about harnesses. | | `packages/core/src/text/toml-edit.ts` | `json-edit.ts`'s TOML sibling (KOT-258): `editTomlTable` replaces, inserts or removes one `[a.b]` table — header form or dotted-key form — leaving every other byte, comment and dotted key verbatim. Codex's and Grok's `mcp_servers.` registration, byte-faithfully. | | `packages/core/src/harness/` | The `HarnessProfile` registry: one file per harness carrying its hook schema, detection paths, caveats, and what `install`/`remove` do — `Record`, so totality is a compile error. Imports nothing from `cli/`, which is what lets `args.ts` derive the `--harness` list. | | `packages/core/src/hooks/` | The zero-dependency guard core, the shim runtime (`shimFromSchema` turns a profile's schema into an adapter), and the runnable shim front doors it feeds. | | `packages/core/src/hooks/focus-terms.ts` | `focusTermsFor(command)` — the one zero-import derivation of focus terms from a producer command, shared by the guard's rewrite wrap and the ops seam's `producer` hint. A search pattern only when the search prints context; nothing for a plain grep, a listing search, `cat` or a diff. | | `packages/core/src/harness/scope.ts` | Where an install goes — project or machine — as one resolver: `locateStep(step, scope, {cwd, home})` returns the path, or a reason this harness documents none, or the command to run where the file is the harness's own to rewrite. Every writer and every reader goes through it, which is what stops doctor and setup agreeing on an install nothing is wired to. `locateFormer` is its read-only sibling: where a harness has renamed the directory it loads from, the step declares the old spelling and it is still read and still removed, never written. | | `packages/core/src/harness/hook-command.ts` | What one hook entry says, as a value, and both directions over it: `renderHookCommand` is the only writer, `parseHookCommand` the only reader (three quotings, the `$(readlink -f …)` workaround, `undefined` for a foreign entry), and `probeHookCommand` runs the command against a payload built from the harness's own hook schema, in a scratch directory with the threshold pinned, so `smelt doctor` can say `wired (verified)` rather than `wired`. `probeOwnFile` is the same answer for a file smelt owns whole (Cline, Hermes, opencode), folding over the `HarnessOwnFileProbe` its profile declares: the command behind the renderer's own prefix, or an ES module loaded to prove its import graph resolves and it still exports the hook. | | `packages/core/src/hooks/invocation.ts` | How smelt is re-invoked on this machine, answered once: `isSameFile` (realpath both sides, so a shim reached through a symlink is still the main module), `pathStability` (the verdict on one path plus the spelling to write — a keg rewritten to the `opt` alias `brew upgrade` re-points, and a recognised version-bearing segment reported as unstable), `smeltOnPath`, and `smeltInvocation()` — the ranked value every writer asks for instead of deriving a command of its own. Node builtins only, like its sibling guard core, which reads it. | | `packages/core/src/ops/` | The operations seam, below both front doors: `smeltBlob`, `mapTree`, `retrieveBytes`, `readCounters` over already-resolved inputs (`ops/verbs.ts`), and the five laws an input must satisfy to be resolved (`ops/inputs.ts`). Exported from the barrel, so `@smeltjs/mcp` consumes it as a dependency instead of re-deriving it. See "The operations seam" below. | | `packages/core/src/cli/bin.ts` | The `smelt` binary. Owns only what cannot be tested without a real process: the shebang, stdin on fd 0, the exit code. | | `packages/core/src/smelter.ts` | `createSmelter()` — the smelter, its store and its stats, in one place. Outside `index.ts` so nothing under `src/` has to import the package barrel to build one. | | `packages/core/src/index.ts` | The public surface, as a barrel. `createSmelter()` itself lives in `src/smelter.ts`, so nothing under `src/` imports the barrel to build a smelter. | ### Stubs that throw (by design — read `CONTRIBUTING.md` § "A stub throws") | File | Why it throws | | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `packages/core/src/stages.ts` | `unconfiguredRerankStage` and `unconfiguredDistillStage`. Both name the interface you were meant to implement. Out of v1 — see below. | ### The honesty machinery One row per guard file in the repository, in the order `guards.json` lists them. How many mutations each of them exports — and the totals over all of them — live in that file, which the runner writes (`pnpm generate:guards`) and `test/guards/guards-manifest.test.ts` keeps current. No count is retyped here, and no digit for the tally appears anywhere in this document. | Guard | What it guards | | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `packages/core/test/guards/adapter-resolver.test.ts` | Where an opt-in rerank adapter is looked for: the config file's own directory first, smelt's own install second, and one refusal naming both places plus a quoted `npm install --prefix`. A user-scope config with a global `smelt` used to search a Homebrew keg and then name a command that installs somewhere neither — the resolver asking its own location twice, dropping the fallback, naming one place, losing the `--prefix`, handing back the bare name instead of a `file:` URL, or swallowing "installed but unreachable under `require` conditions" into "not installed", or dropping the clause that says the search stopped at the config's own copy and never tried smelt's install, all go red. | | `packages/core/test/guards/agents-lint.test.ts` | Every claim `smelt agents` makes about somebody's instruction files. Each rule fires on its own fixture and no other rule is quietly doing the work; `dead-path` resolves against the real tree, counted call by call at the reader; the budget is the user's and there is no default; a mirror is not a level in the merged sum; every finding carries a published rule id, an explanation and an attribution; `--strict` is the only thing that turns a finding into a failure; and `split` neither overwrites without a per-file yes nor mints the finding it then reports. | | `packages/core/test/guards/auto-strategy.test.ts` | The `auto` strategy's two promises: every plan it returns carries the id of the planner that actually ran (never `auto/v1`), and a grammar that will not load comes back as `GrammarUnavailableError` rather than as line windows. The selector relabelling the plan it delegated, and the selector degrading into a `catch`-and-fall-back, both go red. | | `packages/core/test/guards/bench-results.test.ts` | The harness's honesty: `RESULTS.md` rows carry date + corpus commit + tier (and model where required), stay append-only, never say "up to"; network shapes confined to `tier2.mjs`/`tier3.mjs`; `bench/` never enters the published `files` list. | | `packages/core/test/guards/cache-hygiene.test.ts` | Cache-prefix hygiene's promise: detect and warn, never rewrite — inputs stay unmutated, no export returns a "fixed" prompt, and no cache-hit-rate figure exists anywhere in `src`. | | `packages/core/test/guards/config-writer.test.ts` | The config file's two directions, and the one built-in strategy. `parseConfig(renderConfig(c))` equals `c` field for field for every shape a config can take, and the totality leg reads the key set out of the **reader's own refusal** — a key the reader learns and the writer forgets goes red without anyone remembering this file. `DEFAULT_STRATEGY` is pinned to the registry, to what a smelter given no strategy actually uses, and to the CLI's `builtin` provenance, with a scan refusing a second copy of the default anywhere in `src`. | | `packages/core/test/guards/doctor.test.ts` | The update story's reader, held to its reading. A fresh install reports current at exit 0; an older binary over newer state reports behind at exit 3, naming `smelt setup --harness ` in both prose and receipt; a block that predates stamping is unversioned _behind_, never "not installed"; orphans are named; a clean tree is nothing-installed; and doctor never writes — every scenario asserts the tree is byte-identical afterwards (ADR-0003). The promise added when `wired` stopped being a fact about text: a wired hook is **run**, and reported `wired (verified)`, `wired but inert` or `wired but missing`. | | `packages/core/test/guards/expansion-counter.test.ts` | The retrieve counter and `allElisionsRetrieved`, i.e. the observability half of Law 3. | | `packages/core/test/guards/guards-manifest.test.ts` | The tally itself, as an artefact. Reruns the real mutation runner in `--print-guards` mode and fails when the committed `guards.json` differs; also refuses a document that has typed the digits back into its prose. An `artifact` mutation stales the committed copy and watches it go red. | | `packages/core/test/guards/harness-registry.test.ts` | Harness totality: every profile the registry claims reaches the help text, every profile that ships a shim has a cited fixture (so the schema suite, which loops over the registry, tests it), and the one rewrite announcement stays one — including the copy spliced into the generated opencode plugin. | | `packages/core/test/guards/hook-command.test.ts` | The three promises a hook command makes to everything that has to read it again: `parseHookCommand(renderHookCommand(c, cwd))` is `c`, for every kind and both spellings; a command naming somebody else's script is **foreign** — the parser answers `undefined` and the merge that decides what a re-run may replace answers with it; and the one sanctioned spawn is `process.execPath` and nothing else, asserted over the source because that ruling is narrower than the `node:child_process` import Law 1 allows. | | `packages/core/test/guards/hooks-preset.test.ts` | The hooks preset's promises: the size threshold wired to the config rather than a constant, and an installer that never overwrites an existing file — another tool's config included — without an explicit per-file yes. | | `packages/core/test/guards/hooks-yes.test.ts` | One merge policy behind both install verbs, driven through `runCli` because the flags are the interface being guarded. A merge keeps every foreign entry, indentation, key order and number spelling included; `--yes` merges rather than skipping, because a skip is how the one command an agent can drive fails to finish the install it started, silently, at exit 0; and `smelt.config.json` is written once per run, so no receipt names one file twice. | | `packages/core/test/guards/init-wizard.test.ts` | `smelt init`'s one hard rule: an existing file is never overwritten without an explicit per-file yes. | | `packages/core/test/guards/install-scope.test.ts` | A machine install lands where the harnesses actually read, or is not written at all. A user-scope step goes to the harness's documented user-level location or is reported skipped — with no path to fall back to, so the old defect is unreachable rather than merely unwritten; the marker block says which _thing_ uses smelt; and doctor reads through the same resolver the writer wrote through. `home` is an injected temp directory in every case, because a user-scope plan writes into it. | | `packages/core/test/guards/invocation.test.ts` | How smelt is re-invoked, in three promises. A shim reached through a symlink still runs — the realpath compare, against a guard that otherwise exits 0 with empty stdout, which every harness schema reads as _allow_; a written command survives `brew upgrade`, so no hook entry holds the versioned keg an upgrade deletes; and where neither can be promised the warning is taken **per script written**, not from the invocation value, so the guard hook cannot go in bare while the lifecycle hooks report fine. | | `packages/core/test/guards/json-edit.test.ts` | The byte-faithful JSON editor's one promise: the edit changes the property it was asked to change and no other byte. Insert-then-remove gives back the original bytes exactly, and with the property inserted the original text is the result with the inserted span cut out — rendered in the file's own indentation, which the round trip alone cannot see. | | `packages/core/test/guards/lava.test.ts` | The wizards' renderer, held to the two properties that make the presentation seam safe: colour off is the identity — byte for byte what every other guard's assertions lean on — and styled text stays greppable, ANSI wrapping whole lines rather than splitting the substrings other guards assert. `--yes` and `--json` never style, however pretty the terminal. | | `packages/core/test/guards/marker-format.test.ts` | The wire surface. The rendered marker is pinned per version: the format cannot change without the version changing, and an unknown version fails. | | `packages/core/test/guards/mcp-registration.test.ts` | The MCP registration as a profile fact, applied and removed byte-faithfully in whichever format the harness reads: an apply → remove round trip over a file that never carried the key lands byte-identical, a user's own servers ride through both directions, the written entry is the recipe's command split rather than a second spelling, a container that is not a JSON object is skipped loudly, and Codex's and Grok's TOML sibling composes with the `[features]` marker block on the same file instead of clobbering it. | | `packages/core/test/guards/module-seams.test.ts` | The install seam, in both directions. Nothing imports the wizard to plan — `cli/setup.ts`, `cli/installed.ts` and `harness/plan.ts` name `cli/hooks.ts` in no import; `harness/` stays free of `cli/` with no exception, the config schema having moved to `src/config.ts`; and each shared symbol is **declared once** in the whole of `src`, because an import edge that is merely absent is satisfied by a copy, and a copy is how the two verbs would come to disagree about whose file `CLAUDE.md` is. | | `packages/core/test/guards/no-network.test.ts` | Law 1. Walks the import graph from **every entrypoint the manifest advertises** (`exports` + `bin`, so the CLI is in the walk); classifies every edge; closes the vacuous-walk, unwalked-file, unvetted-dependency and unwalked-entrypoint holes; and rules on the opt-in `@smeltjs/rerank-voyage` adapter by name — an import of it is forbidden, not merely unclassified. | | `packages/core/test/guards/ops-seam.test.ts` | The operations seam, and the property it exists for: a law is stated once, and every front door goes through it. The law's own words appear in exactly one file under `src` — `ops/inputs.ts` — so a sentence copied back into a verb goes red even when the copy is correct; and the CLI's rendered refusal is compared against the ops law's own output, so a law changed in `ops/` and a front door that stopped calling it fail the same assertion from opposite directions. `packages/mcp/test/guards/ops-seam.test.ts` is the other end. | | `packages/core/test/guards/packaging.test.ts` | The tarball, audited as a consumer receives it: this guard packs the package and reads _that_, because each defect it exists for was true of the published bytes and of nothing under `src`. A declaration that only compiles in somebody else's configuration — checked by a compiler rather than a name list, building a scratch consumer around the real tarball with `strict`, `skipLibCheck: false` and `types: []`; source maps that resolve to files the tarball never carried; and a schema strict structured outputs will not register. | | `packages/core/test/guards/palette.test.ts` | The Palette, held to three properties, because a CLI that paints can lie in a new way. Off is the identity — every role, every primitive and the whole help page are the plain bytes, and `--json` is off whatever the terminal says, on every verb with an envelope. The switches are obeyed: `NO_COLOR` beats a terminal, `FORCE_COLOR` beats a pipe and names the depth, `--no-color` beats both, a non-TTY is plain, and the depth a terminal reports decides whether a ramp is truecolor, 256 or the sixteen. And a rendering may not round a non-zero to zero: `percent` prints `<0.1%` and the bar keeps one filled cell. The fourth, quieter one — padding is computed on unpainted text. | | `packages/core/test/guards/persistent-store.test.ts` | Law 3 across a process boundary. A damaged blob is refused as `StoreCorruptionError`, never returned; the retrieval counters survive a restart; "we hold damaged bytes" stays distinct from "never existed". | | `packages/core/test/guards/planner-registry.test.ts` | The strategy seam. The `PLANNERS` registry carries exactly the shipped strategies, and the factory, `--strategy`/config validation and the help text all serve its keys — a dropped entry goes red on every face at once. | | `packages/core/test/guards/readme-numbers.test.ts` | Law 4's first surface, cross-checked mechanically. Every Tier 1–4 figure the README quotes is looked up in `bench/RESULTS.md` by the corpus commit the README itself cites — never one hardcoded here — and the top-of-file summary must restate the same three numbers as the sections below it. The two "Sixty seconds" captures are not pinned prose either: the smelt transcript and the `smelt stats` block are both regenerated from the real binary, in a scratch project whose store is thrown away. | | `packages/core/test/guards/repo-map.test.ts` | The repo map's claims: the byte budget respected by construction, deterministic ranked output, content-hash cache invalidation, corrupt cache entries discarded loudly rather than trusted, and the walk counted call by call at the `RepoReader` seam — a symlink statted once and never read, an ignored path never statted. | | `packages/core/test/guards/reversibility.test.ts` | Law 3. `reconstruct(smelt(x)) === x` over multi-byte, CRLF, no-trailing-newline and one-20 kB-line inputs, plus every refusal. | | `packages/core/test/guards/setup-recipe.test.ts` | The setup recipe is data, and it is owned once. Everything that used to retype the store default or the MCP registration command either imports `setup/recipe.ts` or is pinned to it — including each harness's own registration sentence, pinned against the section `packages/mcp/README.md` gives it. The recipe's MCP block is pinned to `run` alone, and Claude Code's CLI verb to the one profile that owns it. | | `packages/core/test/guards/setup-verb.test.ts` | The one-command recipe, branch by branch: `--yes --json` applies the whole recipe with no prompts and names every file and every check; a re-run on a current machine is a byte-neutral no-op; the hard rule survives `--yes` — a file smelt writes whole and does not own is skipped, with the receipt saying so; a config that already carries choices keeps them; the refusals are the agent-facing interface (no stream without `--yes`, `--json` without `--yes`, an unknown harness answered with the known list); and the interactive wizard completes on Enter alone but for the final confirm. | | `packages/core/test/guards/site-facts.test.ts` | The site may only say what the packages say. The versions in the generated `site/src/generated/facts.json` are the two manifests' character for character, the generator refuses a missing or unparseable source rather than emitting a hole, and the tour's recorded-at version — the one string deliberately not generated, because it is provenance — never names a release the packages have not reached. | | `packages/core/test/guards/skill-pack.test.ts` | The published skill teaches only what the recipe and the MCP server say, and is the generator's output byte for byte: a hand edit to the committed `SKILL.md` is a red verify, the four MCP tool names are pinned to the server's own source, every command the recipe carries reaches the text, and Law 4 holds the prose — the only number in the skill is the recipe's budget. | | `packages/core/test/guards/store-prune.test.ts` | The one eviction Law 3 allows. `smelt store prune` evicts only what the user's cut-off reaches, a dry run deletes nothing, an evicted lookup throws `EvictedHashError` rather than claiming the hash was never elided, and `elisionsStored` keeps counting what went — so a prune cannot raise the expansion rate by shrinking its own denominator. | | `packages/core/test/guards/structural-totality.test.ts` | Tests for every claimed language: each id in `STRUCTURAL_LANGUAGES` must have a fixture, a committed snapshot and a doc-comment case — claiming a language without tests goes red. | | `packages/core/test/guards/structural.test.ts` | The structural planner's claims: honest kinds and counts in every marker, no silent lexical fallback, doc comments attached, pins respected, a survivor that still parses in its own grammar, and the budget rung's over-budget escalation labelled by its own rule id, never silently. | | `packages/core/test/guards/subcommand-registry.test.ts` | The subcommand seam, and flag ownership. The `SUBCOMMANDS` registry carries exactly the shipped verbs with exactly the flags each documents, and **every verb is crossed with every flag it does not own** — each pair refused, with the usage exit code and a message naming the flag and the verb. A flag list widened to smuggle a foreign flag through, a verb dropped from the registry and the generated refusal removed from the parse all go red. | | `packages/core/test/guards/third-party.test.ts` | Attribution. Reruns the real generator and fails if the committed `THIRD-PARTY.md` differs; also proves the generator refuses an unattributed grammar. | | `packages/core/test/guards/toml-edit.test.ts` | `text/toml-edit.ts`'s half of the byte-faithful contract, over a corpus of hand-shaped TOML the installer itself never writes: insert-then-remove gives the file back, every foreign byte rides through (comments, a sibling table, a sibling registered by dotted keys, an inline table, a multi-line array with a trailing comma, CRLF, no trailing newline), and our own entry is found in either representation a hand edit could have left it in, then canonicalized to one table. | | `packages/mcp/test/guards/no-network.test.ts` | The MCP server's stdio-local surface: the SDK's HTTP/SSE transports never enter the package's import graph, an SDK subpath off the explicit allowlist is forbidden rather than unclassified, and the forbidden lists are imported from `@smeltjs/core`'s `net/policy.ts` so the two packages cannot drift on what counts as a transport. | | `packages/mcp/test/guards/ops-seam.test.ts` | The operations seam from the far side, inside the package that once forked it: this server states no law of its own. The laws' words are absent, the machinery is absent — no stat, no blob read, no smelter, no store construction, no strategy fallback — and every ops function the package depends on is **named**, so a law dropped is as red as a law re-forked. | | `packages/mcp/test/guards/packaging.test.ts` | This package's half of the tarball audit, plus a re-fork check where the core's guard makes an assertion: `smelt_retrieve`'s schema is the core's, and this guard watches it stay the core's — one contract, never two documents a library caller and a model could be told apart by. | | `packages/rerank-voyage/test/guards/packaging.test.ts` | The adapter's tarball, where the declarations defect bites hardest: an HTTP adapter's public surface is made of exactly the names a types package supplies — `AbortSignal`, `Response`, `Headers`, `Request` — and none of them announces itself with a dot, so the namespace rule is blind to them. The compiler check is what caught `AbortSignal` in an exported type in this package's first draft. | And the machinery the guards run inside, which the tally does not count because none of it exports mutations of its own: | File | What it does | | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `packages/core/test/guards/_source.ts` | The package's guard anchor: one `guardAnchor(import.meta.url)` call into `packages/guard-kit`, which owns the helpers (`guardSrcRoot()`, `guardRoot()`, the string/comment stripper that stops `net/policy.ts` reporting its own word list), the `GuardMutation` shape, and `assertKeyedById` — the registry-key-is-the-id invariant applied to `LANGUAGE_PROFILES`, `HARNESS_PROFILES` and `SUBCOMMANDS`. | | `scripts/mutate.mjs` | **The meta-guard, as a thin runner.** Discovers the guard files — in every workspace package with a `test/guards/` directory — and applies each one's own `MUTATIONS` export, each of which must go red. A survivor is reported as a hole in the guard, not the mutation. It counts the tally rather than stating one: `guards.json` at the repository root holds the totals guard by guard, `pnpm generate:guards` writes it, and the runner refuses to start when the committed copy disagrees with the guard files — so no document here carries a digit that somebody has to reconcile. | | `site/scripts/facts-data.mjs` | Generates `site/src/generated/facts.json` — versions from both manifests, the tier table from `harnessesByTier()`, the structural language list and grammar set from the language registry, the tally from `guards.json`. A missing or unparseable source fails the build; nothing on the page is a package fact typed twice. | | `scripts/bundle-grammars.mjs` | Copies the grammars `WASM_BY_LANGUAGE` names into the package, so they ship. Reads the built map rather than keeping a second list. | | `scripts/generate-third-party.mjs` | Generates `THIRD-PARTY.md`. The grammar ↔ provenance mapping is a partition: an unattributed grammar throws. | | `scripts/generate-skill.mjs` | Renders the published `SKILL.md` from the built SetupRecipe, so the teaching channel for agents that never ran the installer cannot drift from the commands the installer runs. | | `scripts/generate-llms-txt.mjs` | Renders `llms.txt` (the llmstxt.org index an agent fetches first) and `llms-full.txt` (every document that index names, inlined) from one document list and the built packages' own facts. The index is written twice, at the repository root and under `site/public/`, byte-identically; the companion is written once, under `site/public/`, because a second committed copy of the whole documentation set is a large regenerated blob in every docs diff. The ADRs are discovered rather than listed. `test/guards/llms-txt.test.ts` regenerates every committed copy, compares the index's two directly, refuses a companion at the root, and resolves every link in the index back to a file that exists. | | `scripts/check-fresh-clone.sh` | Installs and verifies from `git archive` output — tracked files only. | | `.github/workflows/ci.yml` | `pnpm verify` on Node 20.19/22.12/24, plus the fresh-clone job. | ### What no number claims yet - No dollar figure, and no rate from real agent traffic. The four tiers ran once (2026-09-07, corpus `10462aa46b8e`, `claude-opus-5`, logs committed): tokens measured on the model's own tokenizer, an expansion rate of 0.94 under deliberate read-the-whole-file framing — the alarm ringing as designed — and one judged A/B run (six ties, two raw-better, one artifact-tinged smelted-better). What is still deliberately unclaimed: cost in currency (no price table is committed; tokens are the measured unit), expansion on real agent sessions, and any aggregate beyond the committed corpus. - Cross-file reasoning inside `smelt()` itself. The repo map covers the whole-tree shape as its own surface, but `smelt()` still sees one blob at a time. --- ## The subsystems Each surface below is independently useful; together they are the library, its CLI, and the measurement equipment that keeps the claims honest. ### The CLI The smallest thing that makes the library visible: a `bin` on `@smeltjs/core` (Decision 2), on `node:util.parseArgs`, with no new dependencies: ```sh smelt src/server.ts --budget 4000 --focus handleRequest smelt --budget 4000 --focus TypeError < build.log ``` Prints the smelted text to stdout, and a report to stderr so the two can be piped apart. Below is a recorded run of the built binary on this repository's own `plan/lexical.ts`. It is that session's capture, not a fresh one: `plan/lexical.ts` has grown since, so these are the byte counts that run measured and not the ones the same command prints today. It is kept as text rather than as a screenshot for exactly that reason — a stale transcript is a diff a reviewer can read, and a stale image is a picture nobody can. The README's transcript of this command is the one that cannot go stale at all: `test/guards/readme-numbers.test.ts` regenerates it from the binary on every `pnpm verify`. ``` smelt packages/core/src/plan/lexical.ts typescript lexical/v1 in 7,297 B → out 985 B (-86.5%, 3 elisions) rule lines bytes hash explanation focus-window 53 2,224 84998967370f38bc collapsed 53 lines with no match for the focu… focus-window 4 253 cb63542ad561a25d collapsed 4 lines with no match for the focus… focus-window 128 4,155 786640c78c602123 collapsed 128 lines with no match for the foc… ``` The properties the CLI holds, each pinned by a test: - `smelt ` and stdin both work; `--budget` is required and its absence is an error, not a default. - `--json` emits the `SmeltResult` verbatim, so it can be diffed in tests. It is nested in a versioned envelope alongside the elided bytes, because a result without its store is not reconstructible; `test/cli.test.ts` asserts the nested result equals the library's own, field for field. - The report totals equal `inputBytes`/`outputBytes` from the result — no separate accounting. - `--reconstruct` reads a `--json` result back and prints the original, proving the round trip from the command line. It verifies every hash against the bytes it keys and the reconstructed length against the recorded `inputBytes`, so an almost-right round trip fails. - Exit code is non-zero when the plan came back over budget, and says so. Never silently over budget. Codes are distinct: 1 over budget, 2 usage, 3 refused, 4 unexpected. **The seam: `Subcommand`.** A verb is one file under `src/cli/subcommands/`, and the registry (`SUBCOMMANDS`) is `Record` — the same shape as `LANGUAGE_PROFILES` and `HARNESS_PROFILES`, so a verb without a file does not compile. Each entry carries the flags that verb owns, its `parse`, its own `Resolved*Run` merge, its `run`, and its help block; `parseSmeltArgs` looks the verb up by `positionals[0]` and `runCli` is a lookup and a dispatch, with no per-verb branch in either. The property this bought is **flag ownership**. There used to be no seam, only a subcommand _shape_ restated in four modules, and its compounding cost was the refusals: because no verb owned its flags, every verb refused every other verb's flags in prose — `--harness` was refused in two places with two different sentences, `--ignore`/`--cache` in a third, `--reconstruct` in a fourth, and `retrieve`/`stats`/`hooks` each re-derived "which flags are mine" from `Object.entries(values)` with a bespoke exclusion list. An eleventh flag edited five messages. Now ownership is declared once per verb and the refusal is generated: it names the offending flag, the verb that refused it, where the flag _does_ belong when exactly one verb owns it, and the one sentence that verb writes about itself — at the same exit code (2) each hand-written refusal used. `test/guards/subcommand-registry.test.ts` crosses every verb with every flag it does not own, and three mutations prove the cross product can go red. The help follows the same rule as `--strategy` and `--harness` already did: USAGE, the sections, and the `map only.` prefix on an OPTIONS entry are rendered from the registries, and `test/__snapshots__/cli-usage.help.txt` pins the bytes. **Not in the CLI, deliberately:** no way to pass a `Measure`. A CLI flag cannot name a function, and a plugin loader would be a dependency and an eval surface. The report prints a measured line when the _library_ was given one. ### The operations seam smelt has two front doors — the `smelt` binary and the `@smeltjs/mcp` server — and they are not two products. They are two conventions for saying the same four things: cut this blob, map this tree, give those bytes back, read the counters. The difference between them is entirely at the edges: one reads argv and writes to two streams and returns an exit code, the other validates a JSON Schema and returns a `CallToolResult`. The middle was duplicated anyway. Some of it was shared correctly (`formatReport`, the `PLANNERS` registry, `DirectoryElisionStore`, `loadNearestConfig`), but the law-carrying half was not. **Five laws, two implementations each:** 1. a budget is a positive integer with no default; 2. an explicit strategy beats a configured one, and `lexical` fills last; 3. a tree reader refuses a file, and names the verb that wanted one; 4. a path is read, or the refusal names it; 5. a config decides a store. The mechanical cause is one line of history: `src/index.ts` exported `resolveRun` and `resolveMapRun` but **not** `resolveStoreRun`, so the MCP package could not import the store law it needed and re-derived it — and once one law was being re-derived in that package, the rest followed. That is the restatement the `ResolvedRun` seam had already abolished inside the CLI, leaking across a package boundary because the seam it needed sat on the wrong side of a barrel. `src/ops/` is the seam, below both doors: - **`ops/verbs.ts`** — `smeltBlob`, `mapTree`, `retrieveBytes`, `readCounters`, over **already-resolved** inputs, returning **data**: the smelted text and the values a report needs (`formatReport(outcome)` typechecks as written), a `RepoMap`, bytes, counters. An op never sees argv, never writes to a stream, never returns an exit code and knows nothing about `CallToolResult`. - **`ops/inputs.ts`** — the five laws, each stated once. A law has two halves and only one can be shared honestly: the **rule and its reasoning** live here, the **naming** is the front door's and is passed in. `--budget` and `"budgetBytes"`, `map` and `repo_map`, `` `smelt ` `` and `smelt_file` — one sentence skeleton, two vocabularies, byte for byte what each surface printed before. Nothing in `ops/inputs.ts` throws. The two doors refuse in different currencies — a `CliUsageError` that exits 2, a tool error carrying `isError: true` — and a shared law that threw would force one of them to catch and re-wrap the other's error type, which is how an exit code changes by accident. A law that can refuse returns a `Ruling`: the value, or the sentence, for the caller to throw in its own currency. Nothing there _finds_ a config either: `openStore` takes a decision `configuredStore()` already made, so no library call's behaviour depends on the directory it was invoked from. **`resolveStoreRun` is still not exported, on purpose.** The review named its absence as the cause of the fork, and that is right about the cause and wrong about the fix: what the MCP package needed was the store _decision_, not the CLI's _refusal_. The decision (`configuredStore`) and its construction (`openStore`) are both exported now and both packages use them. `resolveStoreRun` is the CLI's policy on top — `retrieve` and `stats` refuse a memory store, because a fresh process cannot honestly read one — and the MCP server deliberately rules the other way: it accepts a memory store, serves the whole session from it (a resident process legitimately can) and appends its persistence hint at the moment an unknown hash makes the difference bite. Exporting that refusal would have offered the server the CLI's policy wearing the name of a shared law: the same fork, running the other way. **The guards are a pair**, because the mutation runner runs each mutation against exactly one guard, in its own package. `packages/core/test/guards/ops-seam.test.ts` pins _stated once_ over `src` and crosses it with the CLI's rendered refusals; `packages/mcp/test/guards/ops-seam.test.ts` pins _states no law of its own_ over `packages/mcp/src` and names every ops function the server must call. Break a law inside `ops/` and the first goes red through the CLI; re-fork it in the server and the second goes red through the tools. One law, two front doors, a guard watching from each. ### The structural planner The reason smelt exists. `src/plan/structural.ts` parses with the language's grammar, finds nodes matching `focus`, keeps each match's enclosing declaration — signature, doc comment, body — and collapses its _siblings_ into one marker naming them. Fifteen languages: TypeScript, TSX, JavaScript, Rust, Python, Go, Java, C, C++, C#, Ruby, PHP, Kotlin, Swift and Bash — all from grammars `tree-sitter-wasms` prebuilds, zero new dependencies, each grammar's licence verified (all MIT) and recorded in `grammar-provenance.json`. The properties it holds, each pinned by a fixture or guard: - `collapsed 3 sibling functions` — the explanation names the _kind_ and the _count_, from the parse tree, not a line count. - A kept declaration keeps its signature line and attached doc comment, always — in each language's own doc idiom (`///`, docstring, javadoc, PHPDoc, KDoc, `#`). A fixture asserts this on a file where the doc comment is 40 lines long. - Ranges never split a multi-byte character and never cross a node boundary. - Grammar load failure throws `GrammarUnavailableError`. It does **not** fall back to lexical. A caller who wants the fallback asks for it. - Deterministic: same file, same focus, byte-identical plan. Asserted, not assumed. - A snapshot test per fixture, so a plan change shows up as a reviewable diff, and a totality guard (`test/guards/structural-totality.test.ts`): every id in `STRUCTURAL_LANGUAGES` must have a fixture, a committed snapshot and a doc-comment case — claiming a language without tests goes red. - **Non-goal, stated rather than hidden: units are root children only, one level.** `unitsOf` groups the parse tree's _root_ children and nothing deeper, so a class or object body is one opaque unit — kept whole the moment anything inside it matches the focus, collapsed whole otherwise, never split method by method. One very large class with one matching method and nothing else nearby to trade gets no elision at all under this planner; `--strategy lexical` covers that shape by lines, without a per-method name in the marker. Recursing into class bodies was considered and set aside — it would double the shapes the outline, the budget rung and the per-rule ledger all have to reason about, for a case the lexical planner already handles reasonably. `test/structural.test.ts` pins the behaviour with a fixture (one class the focus matches, one it does not) as a decision, not a bug to fix later. Several rules were set by measuring a claim rather than trusting it, and each is guarded by a mutation: - **Every structural language lands its marker behind its own line-comment leader** (`# ` or `// `, `MARKER_LINE_COMMENT_LEADERS`). The plausible alternative — "brace-delimited languages keep their structure around an unparsable bare marker line" — was measured and is false: reparsing every language's fixture survivor with its own bundled grammar showed ERROR nodes spanning the kept declarations. Python's significant indentation makes the damage non-local; Ruby and Bash read the marker's own leading `<<` as a heredoc operator that swallows every kept declaration after it; PHP re-types the kept function into an expression operand. Only `'unknown'` — lexical text with no syntax to break — keeps the bare marker. The wire surface does not move: the leader wraps the frozen `<>` core, `outputRange` covers it, and reconstruction stays byte-exact. The guard reparses the post-`applyPlan` survivor for **every** structural fixture and asserts no ERROR or missing nodes the original parse did not have. Bare `applyPlan` follows the plan's language when picking its marker (`markerForLanguage`), so the documented `planStructural → applyPlan` composition keeps a survivor parsing without the caller wiring anything. - **Rust outer attributes ride forward.** tree-sitter-rust parses `#[…]` as a top-level _sibling_ of the item it decorates, so a unit boundary between them would let a collapse strip `#[derive(…)]` — and the doc comment above it — off a kept declaration. Attributes (and their attached comments) attach unconditionally to the following item, the way the language means them. - **A line-comment marker must own its whole line.** Python emits semicolon-separated top-level statements as separate nodes, the second starting mid-line; a `# `-led marker replacing the first would comment out the kept one. The planner refuses a collapse whose range does not end at end-of-line in such languages. - **What governs a file never collapses.** The Go spec's mandatory blank line after `//go:build` means it can never attach to a declaration, and collapsing it silently changes which builds see the file — so it is pinned. So are shebang lines in every grammar shape (`#!…` as a comment in Python, a `hash_bang_line` in JavaScript and TypeScript, a `shebang_line` in Kotlin and Swift), Ruby's `# frozen_string_literal:` magic comment, PHP's `" ""` beside the counter lines, and `ledger()` folds puts against hits into `{ rule, stored, retrieved }` rows through the one shared `ruleLedger()` derivation in `stats.ts` (both stores; neither derives it privately). The counter fold matches only `hit`/`miss`/`corrupt` lines and skips the rest, exactly as a reader that predates the ledger skips a line it does not know — so a directory written by this version reads as the same counters under the previous one, and `test/ledger.test.ts` pins that. The loop this closes was open by data absence, not wiring: retrievals were journalled per hash but the `ElisionReason` was never persisted, so "which rule's cuts get asked for back" was derivable from no artefact. It reaches the planner as opt-in `PlanInput.ruleHistory`, filled by `createSmelter` like `MarkerPricing` — data a caller's planner may weigh, never a threshold smelt applies (Decision 4) — and reaches a person through `smelt stats` (the ledger table: rule, stored, retrieved, rate) and `smelt_stats`'s second block. - Concurrent writers do not corrupt the store. Tested with two processes, not two promises — `test/store-dir.test.ts` spawns two real `node` subprocesses against one directory. Writes are write-temp → fsync → `link(2)` (atomic, no-clobber), and `pnpm mutate` proves the verify-on-read and counter-persistence guards can go red. ### Cache-prefix hygiene Provider prompt caches invalidate on any prefix byte change, so a context optimizer that reorders or rewrites a prompt prefix can cost more than it saves. Headroom's CacheAligner detects and warns about this volatility; **it never rewrites**, and neither does this. `src/cache/prefix.ts` — pure functions, zero new dependencies, exported from the package entrypoint. Provider cache facts (byte-matched prefix over tools → system → messages, ≈1024-token minimum, 4 breakpoints, 5 min/1 h TTL, 1.25×/2× write and ≈0.1× read pricing) are encoded as cited constants naming Anthropic's docs and the date they were verified. Guarded by `test/guards/cache-hygiene.test.ts`, with two mutations proving it goes red. The properties it holds: - `findPrefixDivergence()` reports the byte offset of first divergence between two successive prompt prefixes and what changed — UTF-8 byte offsets, excerpts that never split a multi-byte character. - Warnings only. No automatic rewriting of anybody's prompt — an optimizer that silently edits a prefix to help a cache is exactly the class of magic this library refuses. `detectCacheBreakers()` names each silent breaker (system-prompt timestamps and UUIDs, unsorted JSON keys, a tool set that varies between calls) with the `ElisionReason`-style rule id + explanation pair; the guard asserts on frozen inputs that nothing is ever mutated or "fixed". - No claim about cache hit rates anywhere. See Law 4; this is the specific claim that was wrong in the pitch this project began from. The guard scans every source file for the phrase and a mutation proves the scan can go red. ### The repo map smelt sees one blob. Aider's repo-map is the proven prior art for the other shape: whole repository, tree-sitter tags, PageRank over the reference graph, a token budget, and a cache. `src/repomap/` (`buildRepoMap()`, exported from the entrypoint) is **modelled on Aider's repo-map, credited as such** — the design is Paul Gauthier's ([aider.chat/docs/repomap.html](https://aider.chat/docs/repomap.html), `aider/repomap.py` in [Aider-AI/aider](https://github.com/Aider-AI/aider)), not this project's; the module doc comment says so. What smelt adds is its own house rules: local files only (the walk never follows a symlink, skips binary files, honors a caller-supplied ignore list), deterministic ranking (fixed damping and iteration count, sorted walks, a total tie-break by rank → path → name → line — no `Math.random`, no `Date`), and Law 2 applied to _inclusion_: every symbol in the map carries a rule id and a sentence naming its definition site and the measured reference counts that ranked it. The tags cache is plain JSON keyed by content hash — Aider persists through SQLite, but this repo ships zero new runtime dependencies — and it lives **only** in a directory the caller explicitly hands in; a damaged entry is deleted (best effort) and reported as a warning in the result, never trusted and never fatal — named honestly as **corrupt** (unparseable JSON, or the wrong shape) or **unreadable** (`readFileSync` itself refused: `EISDIR`, the entry path is now a directory; `EACCES`, permission lost; anything that is not a plain `ENOENT` miss), the same "damaged, not unknown" discipline the elision store already applies. Both a discard that cannot delete its own entry and a write that cannot land cost the next build a re-parse, never this one a crash — `read()` used to let a non-`ENOENT` failure escape through `fsCall` as a `RepoMapIoError` that crashed the whole map over one damaged cache entry the map never needed. Guarded by `test/guards/repo-map.test.ts`, whose mutations prove that the budget, the tie-break, cache invalidation, the corrupt-entry discard, the unreadable-entry discard, the symlink refusal, the default ignore list, the error wrap, the cache bound and the two statements of the resolution limit can each go red. **php, kotlin and bash are path-only, and the map says so.** All three carry `defKinds: {}` in their `LanguageProfile` — the extraction walk reads only node kinds whose `name` field is itself an identifier node, and none of php's `name` nodes, kotlin's field-less declarations or bash's `word` function names fit that shape, so they are omitted rather than guessed at (`RepoMapFacts`'s doc comment in `lang/profile.ts`). This is not a special case anywhere in `repomap/map.ts`: any file whose extracted tags come back with zero definitions — an unmapped language, or one of these three — falls into the same `pathOnly` list, under `REPO_MAP_PATH_ONLY_RULE`, that a file smelt cannot detect the language of already uses. The file still appears in the map, honestly labelled path-only, rather than looking like a structurally-supported language that simply had nothing to report. `test/guards/repo-map.test.ts` fixtures a php, a kotlin and a bash file and asserts both ends: `extractTags` itself returns no definitions, and `buildRepoMap` renders the file into `pathOnly`, never into a name-less, rank-less regular entry. **What the ranking resolves, and what it does not.** A reference binds to a definition **by bare identifier**. The tags carry names, not resolved symbols, so every definition of a name receives every reference to that name wherever either lives: two files that both define `run` share one another's inbound references and rank alike, two overloads of a name each count the whole traffic to it, and a common identifier (`get`, `main`) collects references that in truth belong to something else. This is Aider's design, inherited on purpose — resolving properly means per-language import and scope resolution, which is a type checker per language, and the map is a _ranking heuristic_ for what to read first, not a symbol resolver. What matters under Law 4 is that nothing claims otherwise: `refsIn` and `refsInFiles` are honestly the references to, and the files mentioning, the **name**, and each receipt says so. Anything that needs true binding — rename, call graph, dead-code detection — needs a different tool. The statement lives in the doc comments on `repomap/map.ts` and `repomap/rank.ts`, pinned by mutation `repomap-ranking-limit-undocumented`. It is a decision, not an oversight left unexamined: splitting rank shares per definer would mean per-language import/scope resolution — exactly the type-checker-per-language cost the paragraph above rules out — so the design stays Aider's, on Aider's own terms, and both halves of it are behaviour, not only prose. `test/guards/repo-map.test.ts` pins the cross-file half (two files that each define `shared`: identical `rank`, `refsIn`, `refsInFiles`) and, separately, the same-file half — two definitions of one name in a single file, matching how a real TypeScript overload set or a duplicated declaration parses (tree-sitter has no duplicate-declaration check; it emits one `defs` entry per declaration it sees): both definitions carry the same measured `refsIn`, so a caller reading the map meets the "each counts the whole traffic to it" sentence as a fact about the numbers, not only a warning in a doc comment. A change that split the shares — the alternative this section rejected — would turn both assertions red. **The default ignore list is `.git`, `node_modules`, `dist`, `build`, `out`, `coverage`.** The first two are object storage and other people's code; the rest are build outputs, and they are the sharper case. On a built TypeScript repo `dist/x.js` and `dist/x.d.ts` sit beside `src/x.ts`, so a default list without them ranked and rendered every symbol three times over, the copies referencing each other — the default map of the commonest repo shape in this ecosystem was two-thirds its own compiler output. It is still deliberately tiny and deliberately not a `.gitignore` parser, and a caller-supplied list still **replaces** it wholesale rather than adding to it: a merge leaves no way to say "map my `dist`, I meant it", and a default that cannot be turned off is not a default. `--ignore`'s help text reads the list off `DEFAULT_REPO_IGNORE` rather than restating it. **Every failure is a `SmeltError`.** The consumer contract makes exactly one promise about errors, and the repo map is the module most able to break it, because it walks a whole tree the caller named: `buildRepoMap({ root: '/nonexistent' })` threw the raw `ENOENT` from `readdirSync`, straight past a consumer catching `SmeltError` exactly as documented. Every `node:fs` call under `src/repomap/` — the walk's `list`/`stat`/`read`, and the tags cache's own reads and writes — now goes through `fsCall` in `repomap/io.ts`, which raises `RepoMapIoError` naming the path and keeping the original as `cause`. It adds no behaviour: the call fails at the same moment for the same reason, inside the contract instead of beside it. A `SmeltError` from a caller's own `RepoReader` passes through untouched. **The tags cache is bounded, and the bound is a sweep.** Because the key is a content hash, an edit does not replace an entry — it mints a new one and orphans the old, which is invisible and permanent, so a long session accumulated the pre-edit version of every file it ever mapped. Each build now deletes every entry it did not use, leaving exactly the tags of the tree as it stands: an entry survives a build only if that build used it, so the cache is at most one entry per mappable file. The safety rule any policy here has to meet is that **a miss can only make a map slower, never wrong** — a missing entry is re-extracted from the file's own bytes and a present one is only ever served for the content that hashed to its key — so sweeping too much costs a re-parse and sweeping too little costs disk, and neither can change a symbol in the map. The cost falls on a caller pointing one cache directory at several trees, which is why the sweep's count rides back in `RepoMap.cache.pruned` and is printed in `smelt map`'s report rather than being hidden: one cache directory per tree is the shape this is tuned for. **The seam: `RepoReader`.** The map reads its tree through one small, optional, read-only interface — `list(dir)`, `read(path)`, `stat(path)` in `src/repomap/reader.ts` — defaulting to `nodeFsReader()`, exactly the `readdirSync` / `lstatSync` / `readFileSync` calls `buildRepoMap` used to make in-line. It is the same move `decide(request, settings, cwd, statFile?)` makes for the hooks guard, and for the same reason: with the filesystem injectable, the cases that used to need a temp directory (an empty repo, a single file, a binary file, an unreadable file, a stale cache key) are a table of literals, and the two claims that are really about _calls_ can be asserted by counting them. The symlink refusal is the sharp one: on a real filesystem an `lstat` of a link reports neither file nor directory, so a walk with no refusal at all still skips it — the guarantee was true by accident. A stub reader whose `stat` _resolves_ the link removes the accident, and mutation `repomap-symlink-refusal-dropped` proves the refusal can now be watched failing. Read-only by construction: the interface has no writer, so the only bytes the map can put on disk are the tags cache the caller named. **The stat-then-read gap, and how far this closes it.** Refusing a symlink on `isSymlink` at scan time stops the walk from ever following one it has seen — but the scan used to run as two whole-tree phases: collect every vetted path first, then loop back over the finished list to read each one's bytes. A path cleared by its `stat` early in a large tree could sit unread for as long as the rest of the scan took, and nothing stops the filesystem from putting something else at that path in the meantime — a file swapped for a symlink escaping `root` would have its target's bytes read straight into the map, past a refusal that had already run and already said no (a genuine, if low-severity, TOCTOU: the audit's finding, KOT-205 §6). `scanFiles` now reads a file in the same walk step that just proved it is not a symlink — `stat` and `read` are adjacent calls for that one path, never separated by every other path's `stat` in the tree — which closes the gap this module actually controlled. What is left is the single `stat`-then-`read` pair itself: no injectable `RepoReader` can make that pair atomic without an `O_NOFOLLOW` open the interface does not expose, which is the same residual gap any program accepts when it opens a path by name on a POSIX filesystem, and is accepted here rather than closed, deliberately, because the root is a path the map's caller named and trusts — closing it fully would mean growing `RepoReader` to carry file descriptors, a change with no test that could tell "closed" from "still open" without a real concurrent writer racing the test process. `test/guards/repo-map.test.ts` proves the adjacency itself: a stub tree's full call log shows every file's `stat` immediately followed by its own `read`, with no other path's `stat` or `list` between them, and mutation `repomap-read-not-fused-with-stat` reverts to the two-phase scan and watches the adjacency assertion go red. **The front door: `smelt map`.** ```sh smelt map src --budget 4000 --focus handleRequest --ignore vendor --cache .smelt-tags ``` The ranked map goes to stdout, a short report (files scanned, symbols ranked, bytes used against the budget, cache counts) to stderr, and `--json` emits the `RepoMap` verbatim in its own versioned envelope (`smelt-map-cli/v1`). `--budget` follows the same philosophy as everywhere else — required, no built-in default, `defaultBudgetBytes` from `smelt.config.json` accepted, the refusal owned by `resolveMapRun` in `src/cli/subcommands/map.ts` (`ResolvedRun`'s sibling, not a contortion of it — the two commands share only the budget leg, so they share the seam that owns precedence rather than a struct). `--focus` promotes matching symbols to the front of the fill order with a `focus-match` receipt naming the term; ranks and counts are never altered. One exit-code difference, documented in `--help`: **`map` never exits 1** — a smelt plan can come back over budget because smelt refuses to cut regions the caller asked to keep, but the map fits itself to the budget by construction, so no over-budget outcome exists to report. The report's "bytes used" figure is read off `RepoMap.outputBytes` and guard-pinned to the actual stdout byte count, with mutation `repomap-map-report-bytes-invented` proving the pin can go red. **Deliberately NOT a planner strategy.** `buildRepoMap` returns a `RepoMap`, not an `ElisionPlan` — nothing is elided, nothing is stored under a hash, nothing is reversible — so it does not implement `Planner` and does not appear in the `PLANNERS` registry. Forcing that interface would claim Law 3 about output that has no bytes to give back; the module doc comment on `src/repomap/map.ts` states the same decision. The properties it holds: - Reads a repo, emits a ranked symbol map inside a byte budget. The budget is respected by construction — symbols are appended in rank order until the next line would not fit — and `outputBytes` is measured off the rendered text. - Ranking is deterministic and explainable — every included symbol can say why it ranked. Two runs are byte-identical (asserted, with and without a warm cache), and each entry's `reason` states the definition site, references in (and from how many files), and its file's references out. - Cached on disk, invalidated by content hash, bounded by a sweep, no network. The key hashes format version + language + file content, so an edit is a miss by construction and its superseded entry is deleted by the build that noticed; the module is reachable from the entrypoint and classified by the zero-network guard. - Credits Aider's repo-map explicitly, in the code and in this document; the README's prior-art section carries the same credit. ### The hooks preset `smelt hooks install` wires smelt into agent harnesses: one zero-dependency guard core (`src/hooks/guard-core.ts`), thin per-harness shims mapping each harness's native hook schema onto it, and an installer that writes the harness config — plus an instruction-file snippet as belt and braces, because the snippet is also what teaches the model to run `smelt retrieve` after a deny. Harnesses are tiered honestly — verified / experimental / advisory — against the primary-source survey in [`docs/research/2026-09-02-harness-capability-matrix.md`](research/2026-09-02-harness-capability-matrix.md). Enforcement defaults to deny-with-reason; rewrite is opt-in and never silent. The README's harness section is the user-facing walkthrough. The guard is the **producer expert** — to decide anything about a `grep` it has already parsed the pattern — and that knowledge used to die inside it: the deny reason printed `--focus ` and the model reinvented what the guard knew. `src/hooks/focus-terms.ts` is the one derivation, a zero-import sibling of the guard core (the guard's no-library-import rule is a latency budget, so the derivation had to be a sibling rather than an exception): `focusTermsFor(command)` answers _which terms distinguish the output lines the task is about_ — a search pattern only when the search also prints non-matching lines (`-C`, `-A`, `-B`), nothing for a plain grep whose every line already matches, nothing for a listing search, nothing for `cat` or a diff. The rewrite wrap carries the literal `--focus ` it derives, and `smeltBlob` applies the same function to a `producer` hint (`smelt --producer `, `smelt_file`'s `producer`), filling only what the caller's own `--focus` left unsaid — so the guard and both front doors cannot disagree about which terms a command names. The report attributes the focus it planned with (`focus handleRequest (from --producer)`). A harness is **one file**, `src/harness/.ts`: its tier and caveats, the paths that detect it, its instruction file, its hook schema as data (tool names, payload keys, the deny and rewrite documents), and its install steps — each step's kind being also how `remove` takes it back out (a JSON hook file is merged and strip-merged, a marker block upserted and stripped, a file that is entirely ours written and deleted). The registry is `Record`, so a new id without a profile does not compile, and it imports nothing from `cli/` — which is what lets `cli/args.ts` derive the `--harness` id list instead of hand-typing it under two lists that were already derived. `shimFromSchema` turns a schema into the adapter the shim script runs, and owns the parts that used to be copied per shim: the rewrite-input splice, the deny fallback, and the one announcement a rewrite makes on stderr — the constant the generated opencode plugin splices in too, rather than carrying a fourth hand-typed copy where nothing could see it drift. `ShimAdapter` stays public as the escape hatch for a harness a table cannot express. Its MCP registration is on the profile too, twice over: the step that writes it — `mcp-registration` in JSON, `toml-mcp-registration` in TOML — and `HarnessProfile.mcp`, the same registration in the words a person would use to add it by hand. `smelt setup` used to print the recipe's Claude Code command whatever harness you named, which is a command about a file Codex and Grok do not read; it now prints the fact of the harness that carries it, and where no selected harness carries one, the plain stdio command any MCP client registers. The recipe no longer holds that command at all — `claude mcp add smelt -- …` is composed in `harness/claude-code.ts` from `SETUP_RECIPE.mcp.run`, so the verb a person is told to run and the server smelt wires name one package by construction. **Two install verbs, one plan and one policy.** `smelt hooks install` and `smelt setup` answer the same three questions — what would be written, may an existing file be written over, and what is installed already — so none of the three lives in either verb. `harness/plan.ts` holds the fold over `profile.install`; `cli/merge-policy.ts` holds the one apply loop and the `Consent` adapter over it; `cli/installed.ts` holds the reading a re-run edits its toggles from. `cli/hooks.ts` is the wizard, and the only module that imports it is its own verb. That seam is pinned in both directions by `test/guards/module-seams.test.ts`: the import edges, and — because an absent import is satisfied by a copy — the count of the declarations themselves. ### The `agents` verb An `AGENTS.md` is the one blob a coding agent loads on **every single request**, relevant or not. That is a context-budget fact, and a context-budget fact is what this whole repository is about — so `smelt agents lint` measures it, using exactly the moves the rest of smelt uses, and the source it lints against is one article: [aihero.dev/a-complete-guide-to-agents-md](https://www.aihero.dev/a-complete-guide-to-agents-md). Its phrasing is quoted once, in `src/agents/guide.ts`, and every explanation ends with an attributed fragment of it, so a reader can always tell smelt's measurement from the guide's opinion. **It lints the merged set, and reports two numbers rather than one.** The guide's rule is that a nested instruction file _merges with_ the root one — but a merge runs **up** the tree and never across it, and a monorepo makes that difference visible. An agent working in `pkg/a` loads the root file and `pkg/a`'s; it never loads `pkg/b`'s. So `src/agents/instructions.ts` computes both, and each is printed under the question it answers: `perRequestBytes`, the heaviest ancestor chain, which is the per-request cost the guide's whole argument is about; and `totalBytes`, every level summed, which is the repository's instruction surface. Summing siblings and calling the result a per-request cost would be the same over-count this module already refuses for mirrors, and it would be the one number the whole verb exists to state. The walk goes through `RepoReader` — the repo map's seam, so every claim about the walk is asserted by counting calls against a stub — and arranges what it finds by level. At each level the **primary** is the file that level costs (`AGENTS.md`, or whichever mirror stands alone); a `CLAUDE.md` or `GEMINI.md` beside one is a **mirror**, counted for drift and never for bytes, because one agent loads one of them and summing all three would triple a cost nobody pays. **Three rulings shape it, and each one is a rule this codebase already lives under:** - **Measure, never threshold.** Bytes per level, the per-request worst case, the whole-tree surface, and an imperative count reported as `imperatives (heuristic)` — labelled, because "Run `pnpm verify`" counts and "The gate is `pnpm verify`" does not, and both are one instruction. The guide's cited "~150-200 instructions" is printed as a citation and compared to nothing, read from `guide.ts` rather than retyped in the renderer. The only number that can fail a run is `agents.budgetBytes` in `smelt.config.json`, which is the user's; exceeding it exits 1, the same over-budget code a `smelt` run uses. There is no default, for the reason `--budget` has none. - **Explain every finding.** A finding is an `ElisionReason` — a stable `rule` id and a sentence — exactly like an elision. Eight rules: `dead-path`, `dead-link`, `forcing-language`, `structure-dump`, `generated-boilerplate` (the softest, and its own explanation says so), `language-rule`, `mirror-drift`, `restated-at-level`. Findings exit 0; `--strict` makes any of them exit 1 for CI, because rules about somebody's house style are advisory until that somebody opts in. - **Resolve against the real tree.** `dead-path` and `dead-link` are the flagship and the reason to run this at all. Everyone else lints Markdown; the thing that rotted is the repository the Markdown describes. A renamed `src/auth/handlers.ts` does not make the file invalid — it makes it a lie the agent believes on every request. Resolution goes through the same reader as the walk, so a guard can prove the difference between a dead token and a live one is made by a `stat` and not by a string. The filters are half the rule: a scheme-less domain (`aihero.dev/…` — the guide smelt itself cites) and a product name (`Node.js`, `Bun.sh`) are shaped exactly like paths, so a token with no separator is a candidate only inside backticks, where the author has said "this is a thing in my repository". A false accusation on the flagship rule costs more than the finding it replaces is worth. **`smelt agents split` states a seam rather than straddling it.** The guide's refactor has a mechanical half — find the `##` sections, name their files, move the bytes, fix the relative links that just moved a directory deeper, leave a link list behind — and a judgment half: _which sections are essential?_ The first has a right answer and lives in `src/agents/split.ts`, under `smelt init`'s consent discipline in `src/cli/agents.ts` (every file listed, one confirm, an existing file never overwritten without its own `yes`). The second is a reading of a specific project by someone who knows it, which means a model, which Law 1 forbids — so smelt prints the guide's own refactor prompt with the file's real section headings filled in and hands it over. That is the unconfigured-rerank-stage pattern, applied to prose. **There is no `smelt agents init`.** The guide says never to auto-generate an AGENTS.md, and a tool that built the thing its own source warns against would be worth less than no tool. smelt's own [`AGENTS.md`](../AGENTS.md) is therefore written by hand to the guide's minimum checklist, with `CLAUDE.md` as the symlink the guide recommends, and linted by the command it tests. ### The MCP server [`@smeltjs/mcp`](../packages/mcp/) serves the same library as a stdio MCP server — five tools (`smelt_file`, `smelt_retrieve`, `smelt_retrieve_batch`, `repo_map`, `smelt_stats`) over the same `smelt.config.json`-discovered store the CLI uses, so a marker minted anywhere can be cashed in anywhere and one set of counters moves. Its stdio-local guarantee — the SDK's HTTP transports never enter the import graph — is guard-enforced in its own package. `smelt_file` honours the same `rerank` opt-in the CLI does, by handing the config block to the core's loader; the server imports no adapter of its own, and its guard says so. ### The opt-in reranker adapter [`@smeltjs/rerank-voyage`](../packages/rerank-voyage/) is the **one package in this workspace that reaches the network**, which is why it is a package at all rather than a module. It is not a dependency of `@smeltjs/core` — it peer-depends on it — and both zero-network guards name it forbidden as an import, so it can only ever arrive the way a consumer chose: `npm install @smeltjs/rerank-voyage` plus a `rerank` block in their own config. It sends the query and the text of the regions the planner had already decided to remove, and nothing else. See ADR-0004. --- ## How to run, test, lint ```sh pnpm install pnpm verify # format:check → lint → build → typecheck → test → mutate ``` (Build precedes typecheck because `@smeltjs/mcp` typechecks against the core's built declarations — on a fresh clone, typecheck-first would fail before the types exist. The same order is what lets `typecheck` reach `site/`: its generator imports the built core, and the site's components consume the core's shape through the JSON that generator writes, so a renamed field is red here rather than on the deployed page.) Individual gates: `pnpm build`, `pnpm test`, `pnpm typecheck`, `pnpm lint`, `pnpm format`, `pnpm mutate`. Generated files: `pnpm generate:third-party` rewrites `packages/core/THIRD-PARTY.md`, and `pnpm build` refills `packages/core/grammars/` — neither is hand-edited, and a stale `THIRD-PARTY.md` fails `pnpm test`. Fresh-clone check: `bash scripts/check-fresh-clone.sh` (installs from `git archive` output — tracked files only). CI runs `pnpm verify` on Node 20.19/22.12/24 plus the fresh-clone job. ### How to prove a guard can fail This is the part not to skip, and it is why `pnpm mutate` exists: ```sh pnpm mutate ``` It copies `packages/core/src` to a scratch tree, applies one deliberate break, points the guard at the copy via `SMELT_GUARD_SRC`, and asserts the guard goes **red**. Every guard file exports its own `MUTATIONS`, beside the assertions that must catch them, and the runner discovers and counts them — the totals it prints are the measurement; a survivor is reported as a hole in the guard, not in the mutation. Not every guard guards source code, so there is a second mutation kind: an `artifact` mutation stales a _committed artefact_ — `THIRD-PARTY.md`, for instance — in a scratch root the guard reads through `SMELT_GUARD_ROOT`. Nothing in the working tree is touched either way, which is the point: a mutation runner that edits tracked files and then crashes leaves the repository broken, and a failure here has to be safe. The hand-run transcript of the zero-network guard failing — a real `node:https` import and a real `fetch()` added to `plan/lexical.ts`, the exact output, then reverted — is in [`CONTRIBUTING.md` § "The recorded failure"](../CONTRIBUTING.md#the-recorded-failure-watching-the-zero-network-guard-go-red). Read it before you add a guard of your own; the convention for new guards is in the same file. --- ## The consumer contract smelt is a library, and every consumer — an agent harness, an app, a shell user — gets the same surface. Nothing in this repository depends on, references, or requires access to any particular consumer. This section is the contract. **What a consumer depends on — the stable surface:** ```ts import { createSmelter } from '@smeltjs/core'; const smelter = createSmelter({ defaultBudgetBytes: 8_000, // store: myPersistentStore, // optional; see "The persistent store" }); // 1. Shrink tool output before it reaches the model. const result = await smelter.smelt(toolOutput, { path: 'src/server.ts', // language detection focus: ['handleRequest'], // what the caller was actually looking for budgetBytes: 4_000, // per-call override }); // → result.text what you send // → result.elisions what was cut, each with rule, explanation, bytes, hash // → result.inputBytes / result.outputBytes // 2. Expose the retrieval tool to the model, in your own SDK's shape. const { name, description, inputSchema, invoke } = smelter.tool; // name === 'smelt_retrieve'; invoke({ hash }) → the exact original bytes // inputSchema is strict-mode shaped (additionalProperties: false, every property // required), so it registers as-is under OpenAI structured outputs // throws UnknownHashError on an unknown hash, and StoreCorruptionError when a // DirectoryElisionStore holds bytes that no longer hash to their own name — // surface either to the model as a tool error, never as empty text // 3. Watch the honest signal. const { expansionRate, retrieveCalls, elisionsStored, allElisionsRetrieved } = smelter.stats(); // allElisionsRetrieved === true means every blob smelt hid was asked for again: the // elision saved nothing and cost a round trip. There is no threshold below that, // deliberately — see Decision 4. ``` **Optional: count in your own unit.** Budgets stay bytes (Decision 1). If you want tokens in the result as well, hand smelt the counter you already have: ```ts const smelter = createSmelter({ defaultBudgetBytes: 8_000, measure: { id: 'tiktoken/o200k_base', unit: 'tokens', count: (text) => encode(text).length }, }); // → result.measured = { measure, unit, input, output } ``` **Also stable to consume: cache-prefix hygiene.** `src/cache/prefix.ts` is a consumer-facing surface in its own right — pure functions, exported from the entrypoint, composed with nothing else in smelt on purpose: cache hygiene is a property of the request _you_ assemble, which smelt never sees or intercepts. Use it from your own send path: ```ts import { detectCacheBreakers, findPrefixDivergence } from '@smeltjs/core'; const warnings = detectCacheBreakers({ tools, system }, { tools: previousTools }); // → CacheWarning[]: a rule id + sentence per silent cache-breaker (a system-prompt // timestamp or UUID, unsorted tool JSON keys, a tool set that varies between // calls). Warnings only — your prompt is never rewritten, and no hit rate is // ever claimed. const divergence = findPrefixDivergence(previousPrefix, nextPrefix); // → undefined when the cached prefix survived (identical, or a pure append), else // { byteOffset, invalidatedBytes, description } — where it broke and what it cost. ``` **Also available:** the `smelt` binary, for seeing all of the above from a shell without writing a script. `smelt --budget --focus ` prints the text on stdout and the report on stderr; `--json` and `--reconstruct` round-trip through a file; and `smelt map --budget ` renders the whole-tree ranked symbol map with the same stdout/stderr split. **Guarantees a consumer may rely on:** 1. `smelt()` makes no network calls of its own, ever, in any version. If that changes, the package name changes. (A `measure` or `RerankStage` you supply is your code running in your process; smelt's guard covers smelt's modules, not yours.) 2. `smelt()` does not mutate its input and is deterministic for a given input, options and version. 3. The tool name is `smelt_retrieve` and will not be renamed. 4. Every `AppliedElision` has a non-empty `reason.rule` and `reason.explanation`. 5. `reconstruct(result)` returns the original text byte for byte, as long as the store still holds the bytes. 6. Every thrown error is an `instanceof SmeltError`. That covers the whole exported surface, `buildRepoMap()` included: a filesystem failure under the repo map arrives as `RepoMapIoError` naming the path, never as a raw Node `ENOENT`, and a grammar that resolves but will not load — unreadable, truncated, half-extracted — arrives as `GrammarUnavailableError` naming the `.wasm`, never as a raw `EACCES` or a V8 `CompileError`, because a promise with one undocumented exception is no promise at all. 7. **The marker format is stable from 0.1 and treated as 1.0.** `<>` will not change shape. A future format arrives as `smelt/v2`, identifiable in band, never as a quiet substitution — see Decision 3, and the guard that enforces it. 8. Budgets are UTF-8 bytes, permanently. A `Measure` you supply adds a labelled second number to the result; it never changes what the budget means. **Two promises, not one.** The **wire surface a model sees** — the marker format and the `smelt_retrieve` tool contract — is stable now. The **TypeScript API** is `0.x` and may move: expect renames and signature changes in the type surface between minors. **What is explicitly _not_ stable pre-1.0:** the TypeScript API, the rule ids, the lexical planner's tuning constants, and the exact set of elisions for a given input. A consumer that snapshot-tests smelt's output will break on a planner improvement — snapshot the _properties_ (round-trips, under budget, focus preserved) instead. Note what is **no longer** on this list: the marker string. It moved to the guarantees, because consumers put it in prompts. **What smelt will never do to a consumer:** intercept its traffic, read its config, write outside a store it was handed, or require a key. --- ## Explicitly out of scope **A _default_ reranker.** Still out, and permanently. A default reranker breaks Law 1 for every consumer at once, including the ones who never read the changelog — there is no way to opt out of a default you did not know existed. There is no `SMELT_RERANK_API_KEY` and no environment variable smelt reads on its own: an env-var switch turns a zero-network library into a library that is zero-network unless configured, which is not the same claim, and it is a switch nobody writes down. **The reranker as an explicit config opt-in** is _in_, and ADR-0004 records the reopening. The difference from the ruling above is the whole of it: a consumer writes a `rerank` block into a `smelt.config.json` they own, installs the adapter package themselves, and names the environment variable their own key lives in. With no `rerank` key — every default install — nothing is loaded, nothing is imported and nothing is called. `smelt doctor` prints the opt-in and whether that variable is set (presence only, never the value), and every run that reranks says so on its own report line, in the `--json` envelope and in the `smelt_file` report block. The stage may only **spare** regions the planner had already decided to cut, and only as far as the caller's own `budgetBytes` reaches: the slot walks the stage's ranking best-first and stops at the first region that would not fit, so a bad answer costs at most the bytes the budget already allowed. `topK` is the cap the caller wrote and smelt still invents none; the budget is the ceiling the caller also wrote, and a stage's opinion does not outrank it. The gap that leaves — a `topK` of 8 that yielded 3 — is reported rather than left to be guessed at: the attribution carries what the stage asked for, what the spares put back in bytes, and which of the three walls the walk hit. Every way a stage can fail, including the throw a hosted reranker performs on a timeout or a 401, arrives as a `RerankStageError`: a refusal, rendered as one, rather than a plain `Error` the CLI would call an internal bug and the MCP handler would crash past. **An example reranker in the repository.** Still out, and Decision 5 is unchanged — but `packages/rerank-voyage` is not that. An `examples/` file importing an HTTP client either breaks the zero-network guard or gets excluded from it, and **excluding a file from an honesty guard to accommodate an example is how a guard erodes.** A published package with its own manifest, its own README, its own tests and a `peerDependency` on the core is _not_ excluded from anything: it is outside the walk because it is outside the package, and the core's ruling names it forbidden by name so it can never quietly come inside. The vendor-dating objection stands and is answered the same way — the name is in one package and one config key, so a second adapter is a second package rather than an edit to smelt's graph. **The learned distillation stage.** Out for a reason beyond the network: a model-written summary cannot satisfy Law 2. "The model condensed this" is not a statement of what was removed, and a rewritten paragraph leaves nothing to store under a hash, so Law 3 goes too. If this ever ships, it stores the original, explains itself in the same rule-named terms every other elision uses, and is reversible — or it does not ship. `DistillStage` exists so that shape is written down, not so it can be filled in quietly. **Learned localization** (SweRank, LocAgent, Agentless). Genuinely better at finding the right code than lexical scoring, and genuinely a v2 conversation: it means a model in the retrieval path, which is both laws again. Named in the README as prior art precisely so nobody thinks smelt invented structural retrieval. **Being a proxy.** A proxy can be built _on top of_ this library. The library never intercepts requests it was not handed, because "we rewrote your agent's traffic" and "we transformed the string you gave us" have very different failure modes and only one of them is debuggable. --- ## Design decisions Each decision below is recorded with its reasoning, and each has a home in the code or the docs. Where a decision is enforced by a check, the check is named. ### Decision 1 — budgets are UTF-8 bytes, permanently, in the core Not a caveat. **Bytes are the only unit computable locally for every model**, which is exactly why they are the core's unit — the same property that makes Law 1 possible. Three facts settled it, verified against Anthropic's documentation on 2026-09-01: - **There is no local tokenizer for Claude.** Anthropic ships only the `/v1/messages/count_tokens` **endpoint** — no downloadable tokenizer, no BPE vocabulary. A token budget inside `smelt()` would require a network call, which is Law 1 gone. - **A token budget silently redefines itself across model generations.** Verbatim from Anthropic's docs: _"Claude 4.7 and later models and Claude Mythos Preview use a newer tokenizer. The same input text produces approximately 30 percent more tokens than on earlier models."_ A byte budget means the same thing in five years. A token budget quietly got 30% tighter with nothing erroring anywhere — this project's own failure class, arriving as someone else's model release. - Per-provider tokenizers multiply the dependency cost on every consumer, and a coding agent talks to more than one provider. **In the code:** a `measure` hook on the public API (`Measure` in `src/types.ts`, `SmelterConfig.measure`). A consumer supplies its own counter — anyone calling a model already has one — and the result carries `measured: { measure, unit, input, output }` alongside the byte counts. `id` and `unit` are **required**, because a token count without the tokenizer named is not a measurement; the 30% shift above is precisely why. The hook **does not relax Law 1.** smelt imports no transport, and the guard proves that about smelt's modules; it cannot prove it about a function you hand in. A `count()` that calls an API makes _your_ process call an API, from a line in _your_ source — the same arrangement `RerankStage` already describes. `count` is synchronous on purpose: local tokenizers are synchronous and network clients are not. ### Decision 2 — the CLI is a `bin` on `@smeltjs/core`, with zero new dependencies `node:util.parseArgs`, stable in Node 20, which `engines` already requires. The case for a second package was dependency-tree size, and it dissolves when the CLI adds nothing. One package, one version, one install. ### Decision 3 — two promises, not one, and this constrains everything - **The wire surface a model sees** — the marker format and the `smelt_retrieve` tool contract — is **stable from 0.1 and treated as 1.0.** - **The TypeScript API** is `0.x` and may move. Why, spelled out in `CONTRIBUTING.md` § "Two promises, not one" so a contributor does not "clean up" the marker format: **the marker goes into prompts.** Changing it changes model behaviour downstream and manifests as _worse output with no error anywhere_. That is not a normal API break; it is this project's signature failure mode shipped as a version bump. **In the code:** the marker carries its own version in band — `<>` — so a future format is additive and identifiable rather than a silent substitution. `MARKER_FORMAT_VERSION` lives in `src/apply.ts`, and `test/guards/marker-format.test.ts` pins the exact rendering per version: the format cannot move without the version moving, and an unknown version fails rather than passing, so a new format is a new row and never an edit. Two mutations (`marker-format-silent-change`, `marker-version-not-frozen`) prove both halves go red. ### Decision 4 — measure the expansion rate; never threshold it No default threshold. It would be a policy claim smelt has no basis for, the right rate depends on how aggressive a budget the consumer chose, and a library printing warnings into someone else's process is bad manners. **In the code:** `RetrieveStats.allElisionsRetrieved` — the one non-arbitrary case, exposed as a computed fact rather than a preference. When every distinct blob smelt hid has been asked for again, the elision achieved nothing and cost a round trip. That is arithmetic, not an opinion, and what to do about it is the caller's call. Guarded in `test/guards/expansion-counter.test.ts`; mutation `degenerate-outcome-never-fires` wires the flag to a constant and the guard goes red. ### Decision 5 — no example reranker in the repo, and no adapter inside the core A README snippet and the stage interface, and **nothing under `examples/`**. The zero-network guard requires every discovered `.ts` file to be reachable from a manifest entrypoint or explicitly justified. A file importing an HTTP client either breaks that guard or gets excluded from it — and **excluding a file from an honesty guard to accommodate an example is how a guard erodes.** ADR-0004 did not weaken this; it took the other route out. The Voyage adapter is a **separate published package** (`@smeltjs/rerank-voyage`), so no `.ts` file inside `packages/core/src` imports a transport, no file is excluded from any walk, and the core's `classify()` names the adapter package **forbidden** rather than unvetted. The vendor-dating objection is answered by the same shape: the vendor's name appears in one package name and one config `kind`, and a second vendor is a second package rather than a second import. ### Decision 6 — the grammars are bundled, and `THIRD-PARTY.md` is generated The WASM grammars **ship inside the npm tarball** — that is what makes "zero native compilation, works offline" true. Before this, `tree-sitter-wasms` was an _optional peer dependency_, so a consumer installing `@smeltjs/core` got no parsers at all and found out from a `GrammarUnavailableError` on someone else's machine. `pnpm build` copies them into `packages/core/grammars/` and `files` packs them. Bundling is redistribution, so attribution is required rather than polite. `scripts/generate-third-party.mjs` produces `THIRD-PARTY.md` from installed package metadata, the bundled files themselves, and `grammar-provenance.json` — which holds only the facts with no machine-readable source here. It is **never hand-written**, because a hand-written notices file is a promise that decays: a grammar gets added, the file does not, and nothing fails. `tree-sitter-wasms` is Unlicense (the packaging); each grammar inside carries its own licence, and all fifteen are MIT, verified against the npm registry and each repository's `LICENSE` on the date `grammar-provenance.json` records (2026-09-02). Even the MIT body is quoted from an installed `LICENSE` rather than typed into the generator. `test/guards/third-party.test.ts` reruns the real generator and fails if the committed copy differs, so staleness is loud rather than silent. Downstream reason to get this right: an app that bundles smelt takes its licence-screen text from here. ### Decision 7 — publishing is a maintainer action Publishing `@smeltjs/*` is deliberate and manual — never a side effect of a contribution, and never an agent's action. `CONTRIBUTING.md` carries the publish checklist, and the ordering rule in it matters: **npm unpublish is restricted after 72 hours**, after which only deprecate remains, so a mistaken publish is effectively permanent. Publish only what has been run against a real file, never to reserve a version number. ### Decision 8 — benchmark tiers `count_tokens` is **free** — _"Token counting is free to use but subject to requests per minute rate limits"_, 5,000 RPM at the Start tier, with limits independent of message creation — which is what makes a four-tier split affordable: | Tier | What it reports | Cost | Key needed | Who can reproduce it | | ---- | ---------------------------------------------------------------- | ---- | ---------- | ------------------------ | | 1 | Bytes and elision counts | none | none | any contributor, offline | | 2 | Token counts, via `count_tokens` | free | any key | anyone with a key | | 3 | Expansion rate — real model calls | paid | any key | anyone, from the log | | 4 | Answer-quality A/B — raw vs smelted, judged against the raw blob | paid | any key | anyone, from the log | Tier 1 is deterministic and needs no key, so a stranger can reproduce the table's structural half exactly. Tiers 3 and 4 are the paid parts: run each once and **commit the log as an artifact** (the retrieval log, the A/B log), so every reading is verifiable from a committed file rather than from trust. The harness implements all four tiers; tiers 2–4 have not yet been run — see "The measurement harness". Tier 4's verdict deserves its own sentence, because it is the one reading in the file that is a model's opinion: the judge is the instrument, its reasons live in the committed log, a verdict that does not parse is reported UNJUDGED rather than guessed, and a run cut off at the round cap claims no verdict at all. An instrument reading, labelled as one, is Law 4; a number dressed as a measurement is not. **The trap, written down:** tokenizers differ by model, so every table row names its model, and re-running on a newer model is a **new row, not an edit**. See Decision 1 — the 30% shift between Claude tokenizer generations would otherwise silently rewrite history. --- ## `smelt init` and `smelt.config.json` The CLI has a setup wizard and a defaults file. Both are **CLI-only surfaces**: the programmatic API never reads a config file — `createSmelter()` takes explicit arguments, and a library whose behaviour depends on where it was invoked from would be an invisible input. **`smelt init`** walks through five choices — default byte budget, store (memory or a persistent directory plus path), default planner strategy, a measure-hook stub, and a reranker (`none`, a `module` of your own, or `voyage`) — one question at a time. Every step accepts `back`. A re-run over an existing config shows the current values and edits one choice at a time. **Nothing is written until a final confirm** that lists exactly what will be written, and an existing file is **never overwritten without an explicit per-file yes** — enforced by `test/guards/init-wizard.test.ts`, with mutation `init-overwrite-without-consent` proving the guard goes red. The wizard is a pure function over an input/output pair (`runInit` in `src/cli/init.ts`), driven in-process by `test/init.test.ts`; `bin.ts` only wires the real stdio. **`smelt.config.json`** is versioned (`{"smeltConfig": 1, …}`) and found by walking up from the working directory, like `package.json`. It supplies **defaults only** — `defaultBudgetBytes`, `strategy`, `store` — and an explicit flag always wins. A malformed config is a usage error even when every flag was given: a config silently skipped would be a setting the user believed was in force. `test/cli-config.test.ts` pins the precedence and the strict parse. Its one non-default key is `rerank` (ADR-0004): the explicit opt-in to a relevance stage, absent from every default config, and refused loudly when it names a module that is not there, a `topK` it needs, an environment variable that is unset, or an adapter package that is not installed — never a quiet fallback to an unranked run. It is **additive and does not bump `smeltConfig`**: the schema version exists so a _mismatch_ is visible rather than half-understood, and an older build reading this key refuses it loudly as unknown, which is exactly the behaviour that makes the strict parse worth having. Bumping would break every config in the field to announce a key nobody set. **The generated stubs** (`smelt.measure.ts`, `smelt.rerank.ts`) implement `Measure` and `RerankStage` against the real exported types — `test/init-stub-typecheck.test.ts` compiles the wizard's actual output with the real `tsc`. The reranker stub sketches the outbound HTTP call as a marked TODO **in the consumer's file**, reading the consumer's own env var, and the wizard now also writes the `rerank` config block that loads it — a stub nothing points at was a file the user watched themselves ask for and never got used. smelt's own import graph gains no HTTP client, and the templates are string literals the zero-network guard's string-stripper ignores. This does not reopen Decision 5: nothing under `examples/`, nothing in smelt's graph — the sketch only ever exists in a file the consumer asked the wizard to write, outside this repository. --- # docs/adr/0001-no-go-tui-for-the-setup-surface.md # No Go TUI for the setup surface The setup experience will not be built with charm.land's Go libraries (Bubble Tea, Huh, Lip Gloss), tempting as the lava aesthetic made it: smelt stays one Node/TypeScript process, because the wizards are pure functions over an injected answer stream — guard-tested and mutation-tested in-process — and a Go TUI would place a second language and a process seam exactly there, while adding a third artifact to install and update (which is the very problem this effort exists to remove). The delight is delivered Node-natively: an ANSI renderer behind the same stream seam, lava palette ported, and the full lava treatment stays on the site. ## Considered Options - **Go TUI** (rejected): second toolchain, third install artifact, wizards lose their in-process test interface. - **Node-native renderer** (chosen): one more adapter behind the existing seam. - **Site-only styling** (fallback): zero CLI change, least delight. --- # docs/adr/0002-skill-pack-complements-marker-blocks.md # The skill pack complements the marker block; it does not replace it Ruling R1 refused `smelt agents init`: smelt never writes an agent's instruction files uninvited. Publishing an opt-in SkillPack (installed by the agent's owner via `npx skills add smeltjs/smelt`) is a different act — consent given at install time — so both channels exist: the marker block, written only when the user runs install, sitting beside the enforcement hooks that need it; and the skill pack, for agents and evaluators running without hooks. Both are adapters over one instruction-content seam, guard-pinned together so they cannot drift. --- # docs/adr/0003-doctor-reports-never-migrates.md # Doctor reports; setup repairs; nothing migrates silently `smelt doctor` only reads InstalledState — binary version, the smelt version stamped into each hook marker block, the config version, the MCP registration, orphans — and prints the exact repair command when something is behind. It never edits a file. Repair is always the idempotent `smelt setup`, and a config version mismatch stays a loud refusal: no silent migration, one writer, and "is this machine current?" stays answerable from pure shell. --- # docs/adr/0004-rerank-config-seam.md # A reranker is an explicit config opt-in, never a default This reopens one line of "Explicitly out of scope" and no more. `RerankStage` stays a seam and smelt still bundles no reranker: with no `rerank` key in `smelt.config.json`, nothing is loaded, nothing is imported and nothing is called — which is what every default install does. What changes is that a consumer can now say _yes, this adapter, under this key_ in a file they wrote, instead of only in code they wrote. The original ruling refused a default, an env-var switch and a bundled adapter; all three stay refused. There is no `SMELT_RERANK_API_KEY`, the adapter (`@smeltjs/rerank-voyage`) is a separate package the consumer installs, and every refusal names the thing that is missing rather than falling back to an unranked run. Law 1 is not reopened. The adapter reaches the network, so it is named in `net/policy.ts` as **data** and loaded through a computed specifier — and both zero-network guards classify any _import_ of that name as forbidden, with mutations proving each half goes red. `smelt doctor` reports the opt-in and whether its named environment variable is set (presence only, never the value), so "does this machine talk to anyone?" stays answerable from pure shell. ## Considered Options - **Nothing (the pre-existing ruling)** (rejected): the seam existed and nothing could reach it. `smelt init` wrote a `smelt.rerank.ts` stub that no code path loaded, which is worse than refusing outright — a feature the user watched themselves configure. - **A bundled adapter with an env-var switch** (rejected, again): "zero network unless an environment variable is set" is not the claim, and an env var is a switch nobody writes down. - **A config key plus a separately-installed adapter package** (chosen): the opt-in is a line in the consumer's own repository, the network client is a package they chose to install, and the guards can still prove the default graph is clean. --- # packages/core/README.md # @smeltjs/core Structure-aware, reversible context optimization for coding agents. Zero network calls. This is the library package. The project README, the four laws and their reasoning, the architecture and the consumer contract all live in the repository root: **https://github.com/smeltjs/smelt** ```ts import { createSmelter } from '@smeltjs/core'; const smelter = createSmelter({ defaultBudgetBytes: 8_000 }); const result = await smelter.smelt(toolOutput, { path: 'src/server.ts', focus: ['handleRequest'], budgetBytes: 4_000, }); result.text; // send this to the model smelter.tool; // the `smelt_retrieve` tool that gives it the rest back smelter.stats().expansionRate; // whether you cut too much ``` There is also a CLI, installed as `smelt` (or run via `npx @smeltjs/core`): ```sh smelt src/server.ts --budget 4000 --focus handleRequest # text on stdout, report on stderr smelt --budget 4000 --focus TypeError < build.log smelt --strategy structural src/api.ts --budget 4000 # parse-tree collapse for code smelt --reconstruct result.json # the round trip, from a shell smelt init # the setup wizard: defaults, back-navigation, smelt.config.json ``` **Budgets are UTF-8 bytes, permanently** — the only unit computable locally for every model, which is what makes the zero-network guarantee possible. Pass a `measure` if you want a token count in the result as well; the budget stays bytes. **Two stability promises.** The marker format (`<>`) and the `smelt_retrieve` tool name are stable from 0.1 and treated as 1.0, because markers go into prompts and a silent change to one shows up as worse model output with no error anywhere. The TypeScript API is `0.x` and may move. The parsers ship inside this tarball — no native build step, no post-install download. That makes smelt a redistributor, so [`THIRD-PARTY.md`](./THIRD-PARTY.md) carries the licences, generated from package metadata rather than written by hand. **0.x.** In the box: structural planning (tree-sitter, with signatures and doc comments always kept), the lexical planner, a persistent content-addressed store, cache-prefix hygiene (detect, never rewrite), a repo-map planner modelled on Aider's, and a committed measurement harness. An unsupported language under `strategy: 'structural'` is refused, never approximated. The CLI that goes with it: `smelt setup` wires the whole recipe into the harnesses you use in one command (`--yes` for an agent, no terminal needed); `smelt doctor` reads that install back and **runs** the hooks it finds, so `wired` is a fact about behaviour rather than about text; both take `--scope project|user`, and a machine install goes to each harness's own documented user-level location; `smelt store prune` is the only eviction there is — explicit, journalled before it deletes, reported blob by blob; `smelt init` writes the config; and a `rerank` block in that config is the one opt-in that can send context off the machine, so nothing loads without it and `smelt doctor` says whether it is configured and whether its key variable is set. See [`docs/ARCHITECTURE.md`](https://github.com/smeltjs/smelt/blob/main/docs/ARCHITECTURE.md). Apache-2.0. --- # packages/mcp/README.md
# @smeltjs/mcp **The [smelt](https://github.com/smeltjs/smelt) MCP server** — structure-aware, reversible, offline context optimization as five tools over stdio. A resident process wrapping [`@smeltjs/core`](https://www.npmjs.com/package/@smeltjs/core), so the tree-sitter grammar cache is paid once per session instead of once per command. **stdio-local, zero network.** The one dependency beyond the core is the official `@modelcontextprotocol/sdk`, and only its stdio transport: the SDK's HTTP/SSE transports never enter this package's import graph, and a guard (`test/guards/no-network.test.ts`) pins the exact SDK subpaths the source may touch — mutation-tested like every other guarantee in this repository. ## The five tools | Tool | In | Out | | ---------------------- | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `smelt_file` | `path` _or_ `text`, `budgetBytes`, `focus?`, `strategy?` | The smelted text, then a report of every elision (rule, lines, bytes, hash, explanation, and for structural cuts the names of the declarations behind the marker) | | `smelt_retrieve` | `hash` (from a marker's `retrieve("hash")`) | The exact original bytes, verbatim. **Counted** — this is the expansion rate moving | | `smelt_retrieve_batch` | `hashes` (several, in one call) | One text block per hash, in order: a first line naming the hash and its size, then the exact bytes. Each hit **counted** exactly as a single call would count it | | `repo_map` | `dir`, `budgetBytes`, `focus?` | A ranked symbol map of the tree, fitted to the budget by construction (modelled on Aider's repo map) | | `smelt_stats` | — | The store's `RetrieveStats`, verbatim JSON. An **uncounted** read: watching the counters never moves them | `smelt_retrieve_batch` exists because of a measured cost, not a convenience: every tool call is a new request and input tokens are billed per request, so a model expanding eighteen markers one call at a time re-bills its whole transcript eighteen times (tier 4 of the bench saw the smelted arm's summed input exceed the raw arm's on five of nine cases for exactly this reason). One request for eighteen blocks changes what that costs and nothing about what the expansion rate means — `smelt_retrieve` is the frozen wire surface and stays byte-identical beside it. Every elided region leaves a one-line marker in band: ``` <> ``` The server's `instructions` field tells the model the one thing it could not infer: that a marker's `retrieve("hash")` maps to the `smelt_retrieve` tool. The marker format and the `smelt_retrieve` contract are the frozen wire surface — stable from 0.1, treated as 1.0. ## Wiring it into a harness `smelt setup` and `smelt hooks install` write this registration automatically for Claude Code, opencode, Codex and Grok — JSON or TOML, whichever the harness reads, byte-faithfully beside any servers you already registered. The commands below are the manual reference: what gets written, and how to wire it by hand into any other client. Per-harness mechanisms surveyed against primary sources in [`docs/research/2026-09-02-harness-capability-matrix.md`](../../docs/research/2026-09-02-harness-capability-matrix.md); each snippet below is labelled with the doc that owns it. ### Claude Code ```sh claude mcp add smelt -- npx @smeltjs/mcp ``` ### Codex CLI `[mcp_servers.]` TOML in `~/.codex/config.toml` — per Codex's config reference (, cross-checked against the `McpServerTransportConfig::Stdio` struct in [`codex-rs/config/src/mcp_types.rs`](https://github.com/openai/codex/blob/main/codex-rs/config/src/mcp_types.rs), verified 2026-09-08): ```toml [mcp_servers.smelt] command = "npx" args = ["@smeltjs/mcp"] ``` ### Grok CLI Same TOML dialect, in Grok's settings — `~/.grok/config.toml`, per xAI's settings reference (, verified 2026-09-08; official CLI = `xai-org/grok-build`): ```toml [mcp_servers.smelt] command = "npx" args = ["@smeltjs/mcp"] ``` ### opencode The `mcp` key in `opencode.json` — per opencode's MCP docs (, carried in the capability matrix's opencode row), project config beside the file or `~/.config/opencode/opencode.json` for the machine: ```json { "mcp": { "smelt": { "type": "local", "command": ["npx", "@smeltjs/mcp"] } } } ``` Note opencode [#2319](https://github.com/sst/opencode/issues/2319): MCP tools can bypass opencode's plugin hooks, so the guard sees built-in tools only. Any other MCP client: the server is a plain stdio server — `npx @smeltjs/mcp`, run from the project directory. ## One store with the CLI The server discovers `smelt.config.json` exactly as the `smelt` CLI does — walking up from the directory it was launched in, using the core's own exported config machinery — so a directory store configured once serves both: ```sh npx @smeltjs/core init # choose a directory store ``` ```json { "smeltConfig": 1, "store": { "kind": "directory", "path": ".smelt/store" } } ``` With that in place, a marker minted by `smelt_file` can be cashed in by `smelt retrieve ` from a shell — and vice versa — and both move the same counters, so `smelt_stats` and `smelt stats` report one honest expansion rate for the whole session. **No config?** The server runs on an in-memory store: `smelt_file` → `smelt_retrieve` works for the lifetime of the server process, but nothing survives a restart. An unknown-hash error on a memory store says exactly that, and how to fix it. A malformed `smelt.config.json` refuses startup loudly — a config silently skipped would be a setting you believed was in force. The config's `strategy` is honored as the default planner; an explicit `strategy` argument wins, same precedence as the CLI's flags. ## Requirements - **Node** `^20.19 || >=22.12` — the same floor as `@smeltjs/core`. - Nothing else. No key, no service, no network: everything runs on your machine, and a mutation-tested guard fails the build if any module in this package could reach the wire. ## License [Apache-2.0](./LICENSE). --- # packages/rerank-voyage/README.md
# @smeltjs/rerank-voyage **The opt-in [Voyage AI](https://docs.voyageai.com/docs/reranker) reranker adapter for [smelt](https://github.com/smeltjs/smelt).** > ⚠️ **This package makes network calls.** It is the only package in the smelt workspace > that does, and that is the whole reason it is a separate package rather than a module > in `@smeltjs/core`. `@smeltjs/core` and `@smeltjs/mcp` reach nothing outside your machine, and their Law 1 guards walk the real import graph on every run of `pnpm verify` to prove it — including a ruling that classifies **this package's name as a forbidden import**, so neither of them can ever come to depend on it. The only way your source reaches Voyage is that you installed this package yourself and wrote a `rerank` block into your own `smelt.config.json`. There is no default reranker, no `SMELT_RERANK_API_KEY`, and no environment variable this package reads on its own. ## Install and configure Install it **beside the `smelt.config.json` that asks for it**: smelt looks in that file's own directory first and in its own install second, so a `~/smelt.config.json` works with a `smelt` from Homebrew or `npm -g`. ```sh npm install @smeltjs/rerank-voyage # config at your project root npm install --prefix ~ @smeltjs/rerank-voyage # config at ~/smelt.config.json export VOYAGE_API_KEY=... ``` If it is in neither place, smelt refuses and names both directories and the exact command for yours. ```json { "smeltConfig": 1, "defaultBudgetBytes": 4000, "rerank": { "kind": "voyage", "model": "rerank-2.5", "apiKeyEnv": "VOYAGE_API_KEY", "topK": 8 } } ``` ### The adapter contract: `default` or `require` smelt asks where an adapter is with `createRequire(...).resolve()`, so **an adapter's `exports` map must reach its entry under a `default` or a `require` condition** — this package states `default`, and any adapter written against the same seam should. A package that exports only an `import` condition is _installed and unreachable_, which smelt reports as exactly that rather than telling you to install it again — and if that copy is the one beside your config, smelt stops there rather than falling through to its own install, because a copy beside the config takes precedence. The refusal says so, and says that removing it lets the search go on. A dual package resolves to its `require` entry, so an adapter whose two builds differ in behaviour has to say so here. `smelt init` writes that block for you if you answer `voyage` at the reranker step, and `smelt doctor` tells you whether `VOYAGE_API_KEY` is set (presence only — never the value). `topK` has no default: it decides how much of your context survives, and a number smelt invented would decide that for you. ## What it sends, exactly When a planner has decided which regions to remove, smelt asks the stage which of them the task actually needs. So each request carries: - **the query** — your `--focus` terms, joined; - **the documents** — the text of the regions **the planner already decided to cut**. Never the whole file, and never the regions that survive. What comes back is a relevance score per region. **What this stage returns is what smelt spares** — not a ranking of everything it was given — so `topK` is the whole of the cut-off, and it is required for that reason: returning every candidate would spare every candidate, leaving the output equal to the input on a run that exits 0. smelt reports what happened on its own report line: ``` rerank voyage/rerank-2.5 (23 candidates, 8 kept) ``` A reranker here can only **spare** a region, never cause one to be cut — so the worst a bad answer can do is cost you bytes, and bytes are already reported (`OVER BUDGET`, in so many words). ## Wire contract `POST https://api.voyageai.com/v1/rerank`, `Authorization: Bearer $VOYAGE_API_KEY`, `{"query", "documents", "model", "top_k"}` in, `{"data": [{"index", "relevance_score"}], "model", "usage"}` out. **Transcribed from [Voyage's published reference](https://docs.voyageai.com/reference/reranker-api) (read 2026-09-08), and not exercised against the live API from this repository.** No request has left a machine in this package's history, and the test fixture is a hand-written transcription of the documented response shape rather than a recording of a real one. The strict validation below is the consequence: if the documented shape and the real one have diverged, you will get an error naming the offending entry rather than a plan that quietly kept the wrong regions. Requests are batched at Voyage's documented **maximum of 1,000 documents** per request, scored candidates are sorted by score descending with **ties broken by original order** (so one input gives one output), and a response is refused rather than half-read when an `index` is out of range or repeated, or a `relevance_score` is not finite. **The 30s timeout is per request, not per call.** A candidate set larger than 1,000 is split into batches and each batch gets its own budget, so N batches can take up to N × `timeoutMs`. Cap `topK` and your candidate set, or wrap the call in your own deadline, if you need a ceiling on the whole thing. The API key rides in a header and is never echoed into an error, a report or a receipt. ## Using it directly ```ts import { createVoyageRerankStage } from '@smeltjs/rerank-voyage'; import { createSmelter } from '@smeltjs/core'; const smelter = createSmelter({ defaultBudgetBytes: 4_000, rerank: createVoyageRerankStage({ apiKey: process.env.VOYAGE_API_KEY!, model: 'rerank-2.5', topK: 8, }), }); ``` `fetch` is injectable (`createVoyageRerankStage({ ..., fetch })`), which is how this package's own tests run against the transcribed fixture and touch the network exactly as often as every other suite in the repository: never. ## Licence Apache-2.0. See [LICENSE](./LICENSE). --- # CHANGELOG.md (newest release section) ## 0.8.0 — 2026-09-10 `@smeltjs/core@0.8.0` · `@smeltjs/mcp@0.7.0` (its own source is unchanged; the rerank attribution `smelt_file`'s report block renders comes from the core, so the three new fields reach the tool through the dependency) · `@smeltjs/rerank-voyage@0.1.1` (README only — the adapter contract is written down there now. Its peer range stays `>=0.7.0 <1`: the adapter names nothing this release adds) The wire surface a model sees — the `<>` marker and the `smelt_retrieve` contract — is unchanged. The TypeScript API is not, in one place: `applyRerank`'s `RerankRequest` gains two required fields, `budgetBytes` and `pricing`, so a caller driving the rerank slot directly has a compile error to fix and a budget to hand it. **If you run a reranker, its output changes.** The slot now spares only as far as the run's byte budget reaches, so a `topK` that used to decide how large the output got is a cap and no longer a quantity — and a plan already over budget before the stage is asked skips the stage entirely rather than sending your source out for an answer that would be refused on arrival. Both are deliberate, both are reported (`stopped`, `skipped`), and the reasoning is under Changed below. ### Added - **An opt-in rerank adapter is looked for beside the config that asks for it.** `rerank.kind: "voyage"` resolved `@smeltjs/rerank-voyage` from `@smeltjs/core`'s own location and, when it was not there, said `npm install @smeltjs/rerank-voyage`. With a `~/smelt.config.json` (`--scope user`) and a `smelt` from Homebrew or `npm -g`, that searched a keg nobody installs into and then named a command that installs into the shell's cwd — a third directory, which smelt never looks in. `src/rerank/resolve.ts` is the new **AdapterResolver**: the directory holding `smelt.config.json` first (so `~/node_modules` beside a user-scope config and a project's own `node_modules` are one rule), smelt's own install second, and otherwise **one** refusal naming both places and `npm install --prefix ` — the command that puts the package in the directory searched first. Both kinds go through it: `voyage`, and `module` for a bare specifier that names no file beside the config (a relative or absolute path keeps the path rule the schema promises). It resolves and never imports — what comes back is a `file:` URL, so the specifier at every `import()` is still a value and Law 1's walk still finds no edge to an adapter; the zero-network guard's literal-specifier mutation is re-anchored to the new call shape and still goes red, and `rerank/resolve.ts` joined that guard's `mustVisit`. The question is asked under Node's `require` conditions, so the adapter contract is now written down: an adapter's `exports` map must answer under `default` or `require`, and one that answers only `import` is refused as **installed and unreachable** — a distinct refusal with no install command, because installing it again would change nothing. An unreachable copy beside the config **stops the search**, since a copy there is the answer about the adapter the config points at; that precedence rule is invisible on a machine that also holds a good copy in smelt's own install, so the refusal says smelt's own install was not tried, why, and that removing the broken copy lets the search go on. `test/guards/adapter-resolver.test.ts` holds the order, the fallback, the one-message refusal, the `file:` URL, that distinction and the stated precedence rule, with seven mutations. The install command quotes its directory, so it survives a path with a space in it. - **`smelt doctor` says where the adapter is, and reads the `module` kind by the loader's rule.** The rerank line now carries `adapter from config dir`, `adapter from smelt's own install`, `adapter not installed:` with the install command for your config's directory, or `adapter installed beside smelt.config.json but not loadable` (which names the precedence rule that stopped the search) — asked through the same resolver a run uses, and resolving only: nothing is imported to answer it. `{"kind":"module","path":"my-reranker"}` is a config every run loads and doctor reported as a missing file, orphan and exit 3, because it asked `existsSync` where the loader asks for a file **or** a package; it now asks the loader's question, absolute paths included. `smelt.doctor.v1` gains `rerank.adapterFrom`, `rerank.adapterProblem`, `rerank.adapterAt` and `rerank.install`, all optional; no existing field changed spelling or meaning, and the key's _value_ still never appears. A configured opt-in whose adapter is in neither place is an orphan with that command as its repair — the same treatment an unset key already had, for the same reason: every run that would rerank refuses instead. An installed but unreachable one is an orphan with no repair command, because there is no command that would repair it. - **`smelt init`'s voyage answer prints a command that can work**: `npm install --prefix "" @smeltjs/rerank-voyage` for the directory it is writing the config into, rather than a bare `npm install` that lands wherever the reader's shell happens to be. - **`store.retention` — the prune cut-off, written down.** `smelt store prune` refused without `--older-than` by doctrine: a cut-off smelt invented would decide which of somebody's elisions stop being reversible, at an age nobody chose. That ruling is about the deletion, not about the number, and it made a user retype the same age at every prune. A `retention` block inside a directory store — `{ "olderThan": "30d", "keepRetrieved": true }`, the same `d|h|w` grammar the flag takes, refused as strictly as every other key — supplies the age when no flag did. Nothing else moves: the key schedules nothing, is read by one verb at the moment a user types it, `--older-than` still wins, and with neither spelling present the verb still refuses — now naming both places an age can be written. The receipt says which won (`olderThanSource` on the report line and on the `smelt-store-prune-cli/v1` envelope, which gains fields rather than changing one) — and `keepRetrievedSource` beside it, because sparing is a union and "the config kept blobs the flag never mentioned" is the surprising case. `smelt doctor` prints the configured cut-off as the age a prune _would_ use. `--keep-retrieved` is OR-ed with the configured one rather than overriding it: the flag has no negative spelling, so letting an absent boolean overrule a written-down `true` would make typing an age silently delete more than the file asked for. `smelt init` does not ask about it. The grammar itself now has one reader (`src/store-cutoff.ts`), so the flag and the key cannot disagree about what `30d` is worth. ### Changed - **The rerank slot now spares only as far as the budget reaches, and `topK` is a cap rather than a quantity.** The slot spared every region a stage returned and never saw `budgetBytes`, so a `topK` written in a config file decided how large the output got — a run that fitted before the reranker could stop fitting after it. `applyRerank` now takes the run's budget and its `MarkerPricing` across the seam, walks the stage's selection best score first, and stops at the first region that would push the predicted output past the ceiling. The prediction is `plan/budget.ts`'s, the same arithmetic the lexical planner picks a ladder rung with and the structural planner runs its own budget rung on, so the slot and the planners cannot disagree about what a marker costs. If the best-ranked region alone breaks the budget, nothing is spared at all: a plan that fits beats a plan that does not, and a stage cannot cut, so the only lever left is not sparing. The doctrine in one line, and it is written at the seam: a K smelt invents is refused; a budget the user typed is honoured. `test/guards/rerank-budget.test.ts` holds both halves, with mutations that remove the budget check and that fabricate the stop reason. A run whose plan is over budget before the stage is asked does not reach the stage at all — it could spare nothing whatever came back, and asking would send the caller's source to a third party for an answer refused before it arrived. That is a skip, not a stop: `skipped: 'plan-over-budget'` beside the existing two reasons, with the counts only a real run can take left absent. - **`RerankedCandidate.score` is load-bearing as an order.** The slot sorts a stage's selection score-descending, breaking ties by the order the candidates were sent, so which regions survive a tight budget no longer depends on how an adapter happened to serialise its response. The interface documents it; a stage that returns its selection unsorted now has a defined outcome rather than an incidental one. A score that is not a finite number is refused with a `RerankStageError`: `NaN` compares false against everything, so it would not disorder the ranking loudly but silently, and differently per engine. - **The rerank attribution says what was asked for and where the sparing stopped.** `RerankAttribution` gains three optional fields, present exactly when the stage ran: `returned` (how many regions it asked to spare), `sparedBytes` (what those put back — the regions restored, less the markers that no longer land, priced through the same seam the plan was made with) and `stopped` — `budget`, `cap` or `exhausted`. A `topK` of 8 that reports 3 kept now carries the reason beside it instead of leaving the reader to guess whether their ranker or their budget made the decision. The stderr report line gains a `B back` clause and, on a budget stop, a clause naming the budget as the reason and how many regions the stage had offered. The `--json` envelope carries `result` verbatim, so `result.rerank` gains the three fields additively — nothing renamed, nothing dropped. - **`smelt stats` reads the store once instead of three times.** The counters, the per-rule ledger and the store's own size on disk were three separate walks of the same two files — a `readdir` plus a `stat` per blob and a whole-journal parse, twice over — paid at the end of every session, because the Stop hook runs `smelt stats`. `DirectoryElisionStore.survey()` answers all three from one blob scan plus one journal fold, and `stats()` and `rawCounters()` are views over it; the interfaces, the numbers and the arithmetic are unchanged, with the pre-fold implementation kept as the test's oracle and compared field for field over a fixture carrying every shape the journal holds. Measured on a scratch store of 5,500 puts and 500 retrievals (a 226,500-byte journal over 21 MB of blobs; Node 26, macOS 15, APFS SSD, 2026-09-09): the traversal work falls from 46–55 ms to 23–27 ms, inside a `smelt stats` that runs end to end in 0.12–0.13 s against 0.16–0.17 s before. `ledger()` alone — the one of the three on `smelt`'s own per-run path, since `smelter.ts` hands planners `ruleHistory` every run — stays on the journal half and costs 2.6–2.9 ms, exactly what it cost before. **No cache** — at that size nothing is paying a cost worth a stale-detection scheme, and a cached tail is a second copy of numbers whose whole value is being read off the disk every time. One deliberate behaviour change comes with it: a blob that vanishes between the listing and the `stat` is now skipped rather than thrown on, which is `readStoreSize`'s existing rule and the right one for a directory a concurrent prune may be emptying. ### Docs - **`llms.txt` and `llms-full.txt`, for the agent that arrives before the install.** The llmstxt.org index now sits at the repository root and is served by the site: the summary, the four laws as its notes, the three commands a newcomer needs, the five MCP tool names, and link lists of every document — with `llms-full.txt` beside it inlining each of those documents for a reader that would rather spend the tokens than the round trips. Neither is hand-written. `scripts/generate-llms-txt.mjs` renders both from one document list and the built packages' own facts (the ADRs discovered rather than listed). The index is committed twice — the repository root and `site/public/`, byte-identically — so an agent reading the repo needs no fetch; the companion is committed once, under `site/public/`, because a second copy of the whole documentation set would be a large regenerated blob in the diff of every docs change, and the index already links its served URL. `test/guards/llms-txt.test.ts` regenerates every committed copy, compares the index's two directly, refuses a companion at the repository root, and resolves every link in the index back to a file that exists — because regenerating an index reproduces a dead link exactly. `pnpm generate:llms-txt` writes them; a hand edit is a red `pnpm verify`. Law 4 holds in the index as it does everywhere else: it states no measured figure and links `packages/core/bench/RESULTS.md` instead. - **The SkillPack now teaches the surface 0.7.0 introduced.** Four sections joined it, still rendered from the package rather than retyped: setting up (`smelt setup --yes` with `--scope user`, the repeatable `--harness` over the ids the registry carries, and the four toggles), checking the install (doctor's `wired (verified)` / `wired but inert` / `wired but missing`, and the refused exit meaning "re-run setup"), keeping the store small (`smelt store prune --older-than 30d --dry-run`, then the same line without it), and reranking (the config block, `module` or `voyage`, the environment variable your config names, and never a default). - **A "For agents" section near the top of the README**, naming the two instruction channels and the one-line version of each 0.7.0 action; `AGENTS.md` points at `llms.txt`; the site footer links it.