# 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

**Structure-aware, reversible context optimization for coding agents.**
A library, not a proxy.
[](https://github.com/smeltjs/smelt/actions/workflows/ci.yml)
[](#the-four-laws)
[](#requirements)
[](./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.