salc-cli
v0.3.0
Published
Symbolic Context Virtualization Protocol — local-first MCP server + CLI that gives AI coding agents (Claude Code, Cursor) a compressed AST symbol graph instead of full source files, cutting context window usage via lazy expansion and structured AST patchi
Maintainers
Readme
SALC
Symbolic Context Virtualization Protocol
Give your AI coding agent a map of your codebase, not a photocopy of it.
Quickstart · Features · CLI Reference · Architecture · Full Design (PRD)
Every AI coding agent — Claude Code, Cursor, Windsurf, your own custom agent — solves the same problem the same expensive way: dump the whole file into context, ask the model to rewrite the whole file back. It works. It also burns context window and token budget on code the model never needed to see or regenerate in the first place.
SALC replaces that with a symbol graph. It parses your repo locally with web-tree-sitter, folds every class and function down to a one-line signature behind a stable hash, and exposes it over the Model Context Protocol (MCP) — or a plain CLI, no MCP required. Your agent orients over the folded graph, expands only the function it's actually about to touch, and edits it with a structured AST patch instead of rewriting the file top to bottom.
STRUCTURED_AST_SCHEMA:
CLASS OrderProcessor:
METHOD process_payment(order_id: str, amount: float) -> bool [#DOC:0x01]
[#BODY:0xA1F0A2B3] ← folded. not sent until the agent asks for it.No cloud dependency, no local model, no embeddings — just deterministic parsing and a SQLite index sitting in .salc/. Runs on a laptop, a CI runner, or a locked-down container the same way.
Supports Python, TypeScript, TSX, JavaScript, and Go out of the box — the language grammar layer is a pluggable registry (src/parser/language_registry.ts), so adding another language is a matter of wiring up a new tree-sitter WASM grammar, not a rewrite.
Why SALC
| | Reading full files (the default) | With SALC |
| --- | --- | --- |
| Context sent per turn | Entire file(s), every time | Folded signatures — full source only for nodes the agent explicitly expands |
| Edit output | The whole function/file, regenerated | A small structured patch — UPDATE_FIELD changes one field, not the file |
| Repeat queries in one session | Full re-send every time | since_graph_version returns only what changed |
| Editing protected code | Whatever the model infers from a comment | @ai:freeze is enforced locally — the patch is rejected before it touches disk |
| Sensitive code (secrets, PII logic) | Sent to the model's context like everything else | @ai:restrict blocks it from being read at all, not just edited |
| Grounding | None — the model can propose edits to content it never actually read | Every patch is checked against the file's live content; stale or unread edits are rejected |
| "How much is this actually saving?" | A guess | salc stats — every call is measured against a full-file baseline and persisted, not estimated |
| Re-indexing after an edit | N/A (no local index to begin with) | Delta indexing — only the touched node (+ ancestor) is re-parsed, not the whole file, git-backed when available |
| A misbehaving/looping agent | Nothing stops it from writing indefinitely | Session patch limit (maxPatchesPerSession, default 500) caps total writes per MCP session |
The mechanism — folding, lazy expansion, and field-level AST patching instead of full-file diffs — is what drives the token/context reduction. It's architectural, not a tuned benchmark number: the less of your codebase the agent has to read and regenerate per edit, the less it costs, and that scales with how targeted the edit is. Don't take that on faith — run salc stats after a session to see the real input/output byte savings for your own codebase.
Built for:
- 🤖 AI/LLM engineers wiring up Claude Code, Cursor, Windsurf, or a custom MCP-based agent
- 💸 Teams optimizing token spend on AI coding tools, or hitting context window limits on large repos
- 🛡️ Codebases with protected regions — compliance-critical code, generated files, anything that shouldn't be silently rewritten
📦 Install
npm install -g salc-cliRequires Node ≥22.16.0. Run salc doctor to check your environment (Node version, node:sqlite, WASM grammar loading, git).
Quickstart
1. Register salc once, globally
Not per project — salc serve defaults to the directory it's launched from, so one registration correctly serves whichever project your editor has open.
claude mcp add --scope user salc -- npx salc serveFor Cursor (or any other MCP-compatible editor), add the same server config to its MCP settings — for Cursor, ~/.cursor/mcp.json:
{ "mcpServers": { "salc": { "command": "npx", "args": ["salc", "serve"] } } }2. In each project, run salc init once
cd your-project
salc init[!IMPORTANT] This is the step that actually matters.
salc initscaffolds.salc/(local index, git-ignored), runs the initial full index, and — this is the part people skip — writesCLAUDE.mdand.cursor/rules/salc.mdcsteering instructions telling the agent to prefer SALC's tools over its built-in file reader/editor. Registering the MCP server only makes the tools available; it doesn't make anything use them. Skip this step and SALC sits there unused while Claude Code/Cursor keeps editing files directly.
salc init does not write a project-scoped .mcp.json by default (that's what step 1 replaces) — pass --project-mcp if you'd rather pin one project to an absolute path instead of registering globally.
You never run salc serve yourself — the editor spawns it as a subprocess. Ask for an edit, and the agent calls salc_get_project_graph to orient, salc_expand_node_hash to read a specific function's current source, and salc_apply_ast_patch to make the change.
Or skip MCP entirely: the CLI
Every MCP tool has a direct CLI equivalent, callable via an agent's shell/Bash tool — no registration, no approval prompt, works with any agent that can run shell commands, not just MCP-aware ones:
salc graph --level 2 # orient — same as salc_get_project_graph
salc expand 0xabc123 # read current source, marks it grounded
salc patch '{"target_node":"0xabc123","based_on_content_hash":"0x...","action":"UPDATE_FIELD","field":"return_type","value":"int"}'Grounding (the "did you actually look before patching" check) is persisted in .salc/index.db, not held in memory by a long-running process — salc expand in one shell call and salc patch in the next, run as two completely separate processes, still see each other correctly. salc init writes CLI usage into CLAUDE.md/.cursor/rules/salc.mdc right alongside the MCP option, so the agent knows about whichever interface is actually available in a given session.
Measuring the savings
Every graph/expand/patch/expand-and-patch call — CLI or MCP — is measured against a full-file baseline and persisted to .salc/index.db. See real numbers for your own codebase instead of taking the pitch on faith:
salc statsSESSION 59cf0898-... (1 call(s), started ...)
input (expand): 349 bytes sent vs 4,910 bytes baseline (92.9% saved)
ALL-TIME (6 session(s), 6 call(s))
input (expand, graph): 3,757 bytes sent vs 11,295 bytes baseline (66.7% saved)Pass --session <id> to look at an earlier session instead of the most recently active one.
Making it mandatory, not just preferred (Claude Code only)
Soft steering via instructions is usually enough, but it's still a preference the model can ignore. To make it a hard rule:
salc init --lockdownWrites .claude/settings.json denying Claude Code's built-in Edit tool for the file types SALC manages, while explicitly allowing SALC's own MCP tools — no working fallback path. Cursor has no equivalent permission mechanism (verified against its docs — .cursorignore hides files entirely rather than redirecting edits), so for Cursor, the instructions file is the only lever available.
CLI Reference
| Command | What it does |
| --- | --- |
| salc init | Scaffold .salc/, run the initial index, write steering instructions. --force, --project-mcp, --lockdown |
| salc serve | Start the MCP server over stdio, scoped to --project (default: cwd). Normally launched by the editor. |
| salc graph | Print the folded project graph. --scope, --level 0-4, --include-dependencies, --include-fingerprints, --since, --target-node-hash |
| salc expand <hash...> | Print current source behind one or more #BODY: hashes; marks them grounded |
| salc patch '<json>' | Apply one patch. Exits 1 on rejection |
| salc patch-batch '<json array>' | Apply several patches in one call |
| salc expand-and-patch '<json>' | Ground and patch in one call, no prior expand needed |
| salc parse <file> | Index a single file (debugging) |
| salc status | Print index summary without re-indexing |
| salc stats | Measured token savings — actual bytes sent/written vs. a full-file baseline. --session <id> for an earlier session |
| salc doctor | Environment check: Node version, node:sqlite, WASM grammars, git |
All graph/expand/patch* commands accept --project <path> and re-verify the index is current before running — no separate watch step needed for CLI usage.
🧊 The @ai:freeze directive
Add a comment directly above a class or function:
# @ai:freeze Critical legal compliance requirement.
class OrderProcessor:
...Any patch targeting that node — or anything nested inside it — is rejected locally, before it ever reaches disk. The rejection is a structured error (FROZEN_NODE_REJECTED), not a silent no-op, so the calling agent can tell the user rather than retrying blindly. // @ai:read-only works the same way in TypeScript/JavaScript.
🔒 The @ai:restrict directive
@ai:freeze stops an agent from editing a node — it can still read it to understand the surrounding code. @ai:restrict (aliases: @ai:no-read, @ai:secret) is stricter: the node's content is never surfaced to an agent at all, read or write. Use it for credential handling, PII logic, or anything else that shouldn't end up in a model's context in the first place.
# @ai:restrict Contains credential handling logic — must never reach an LLM context.
def check_password(password, stored_hash):
...salc_expand_node_hash (and salc expand) reject it with ACCESS_RESTRICTED before touching disk; the graph view still shows the function's signature (so dependency edges stay coherent) but never its body, even under the foldThresholdLines inline shortcut. Any patch targeting it — grounded or not — is rejected the same way. The two directives are independent and can be combined on the same node; both cascade to nested code the same tightening-only way (a child can narrow scope further, never widen an inherited freeze or restriction).
⚙️ Configuration
.salc/config.json, written by salc init:
{
"languages": ["python", "typescript", "tsx", "javascript", "go"],
"ignorePatterns": ["node_modules/**", ".git/**", "..."],
"foldThresholdLines": 4,
"maxBatchSize": 50,
"maxPatchesPerSession": 500
}languages— which of the supported languages (Python, TypeScript, TSX, JavaScript, Go) to index; defaults to all of them. Drop one to skip parsing it entirely, e.g. a repo with avendor/tree in a language you don't want indexed.foldThresholdLinescontrols when a function/method is inlined directly in the graph view instead of folded into a#BODY:0x...reference — below this many lines, folding costs more (a round trip) than just showing the content.maxBatchSizecaps how many nodes a singlesalc_expand_node_hash/salc_apply_ast_patch_batchcall may carry, bounding the blast radius of one tool call.maxPatchesPerSessioncaps total patches one MCP session may apply before further patches are rejected withSESSION_PATCH_LIMIT_EXCEEDED— a guard against a misbehaving/looping agent, not enforced for the CLI (a human invoking it directly already controls their own input).
Architecture
Local-first, deterministic, zero cloud dependency:
- Parsing —
web-tree-sitter(WASM) — no native build toolchain, so it installs the same way on a dev laptop, a fresh CI runner, or an Alpine container. - Hashing —
@noble/hashes, pure JS, no native binary. - Storage —
node:sqlite, Node's own built-in module — the symbol graph, comments, dependency edges, and grounding state all live in.salc/index.db. - Protocol — a standard MCP stdio server (works with any MCP-compatible client) plus a parallel CLI backed by the exact same engine.
- Re-indexing — node-level delta indexing: on a file change, only the touched node (and its enclosing ancestor) is re-parsed and re-hashed, not the whole file. Diffs against git when available for fast, precise change detection; falls back to mtime comparison (still correct, just coarser) when git isn't present.
- Prefetching — expanding a node warms its direct
DEPENDS_ONcallees into a session-scoped cache, so a follow-up expand of an already-warmed dependency resolves without a disk read. Cache-only: nothing is sent to the model until it's actually requested, so this never changes what crosses the wire.
Full technical design — schema, hashing scheme, the AST patch format, and the reasoning behind every trade-off — is in PRD.md.
Different projects at once — no special handling needed, and this is exactly what global registration in Quickstart is for. Each editor window spawns its own salc serve subprocess with its own cwd, so each project gets its own .salc/index.db and nothing is shared between them.
The same project open in two sessions at once (two editor windows on one repo, or MCP and the CLI both touching the same project) spawns two separate processes against the same .salc/index.db. SQLite's WAL mode plus a busy-timeout (node:sqlite's timeout option) means one process's write waits for the other's to finish rather than failing outright — this is what actually needs Node ≥22.16.0.
Grounding is shared across sessions/interfaces on the same project, persisted with a 24-hour TTL — deliberate, not an oversight: it's what makes salc expand in one CLI call and salc patch in the next, run as separate processes, work at all. The correctness backstop doesn't depend on this sharing, though: every patch is independently re-verified against the file's live content on disk regardless of what any grounding record says.
Moved or renamed a project and used --project-mcp? The absolute path baked into .mcp.json goes stale — re-run salc init --force --project-mcp from the new location. (Global registration doesn't have this problem.)
salc doctorreports a Node version failure — install Node ≥22.16.0. Earlier versions either don't havenode:sqliteat all, need the--experimental-sqliteflag, or lack thebusy_timeoutoption needed for safe concurrent access.- A grammar fails to load — re-run
npm install(or reinstall globally); WASM grammar files ship alongside the package and can go missing ifnode_moduleswas pruned aggressively. - Claude Code doesn't pick up the server — confirm it's registered (
claude mcp list, or check.mcp.jsonif you used--project-mcp) and that the MCP approval prompt wasn't dismissed. - The agent isn't using SALC at all, just editing files directly — expected default without the steering step. Confirm
CLAUDE.md/.cursor/rules/salc.mdcexist and haven't been edited out. For a hard guarantee in Claude Code, usesalc init --lockdown. - An edit gets rejected as
NODE_NOT_GROUNDED— by design, not a bug: the node's content changed since it was last read. The rejection response includes current content — just retry with it. - An edit gets rejected as
FROZEN_NODE_REJECTED— the target (or an ancestor) has an@ai:freezedirective. - An expand or edit gets rejected as
ACCESS_RESTRICTED— the target (or an ancestor) has an@ai:restrictdirective; its content is never returned, so don't retry. - A patch gets rejected as
SESSION_PATCH_LIMIT_EXCEEDED— the current MCP session already appliedmaxPatchesPerSession(default 500) patches; raise it in.salc/config.jsonif that's genuinely expected, or start a new session.
🤝 Contributing
Issues and PRs welcome — see PRD.md for the full design before diving in. Run npm test and npm run typecheck before opening a PR.
