How it works
The signature algorithm, why matching is exact, and what the ledger actually records.
Signatures
Every decision is normalized into a stable signature before it’s used as a cache key:
export function signature(decision: Decision): string {
const normalized = {
intent: decision.intent,
params: sortKeysDeep(decision.params),
};
return createHash("sha256").update(JSON.stringify(normalized)).digest("hex");
}
Normalization is deliberately shallow — object keys are sorted recursively so { a: 1, b: 2 } and { b: 2, a: 1 } hash identically, but nothing about intent or the param values is touched. There’s no stemming, no case-folding, no semantic canonicalization.
{ intent: "summarize_pr", params: { repo: "acme/widgets", pr: 482 } }
{ intent: "summarize_pr", params: { pr: 482, repo: "acme/widgets" } }
│ │
└──────────────── same signature ──────┘
{ intent: "summarize_pr", params: { repo: "acme/widgets", pr: 483 } }
│
└── different signature (different pr) ──┘
Why exact match, not semantic match
A semantic/fuzzy cache would catch more repeats — “summarize this PR” and “give me a summary of this pull request” would both hit. It would also mean Runic has to judge whether two differently-phrased requests are close enough to share a cached answer, which is exactly the kind of non-deterministic judgment call Runic is designed to avoid making.
Exact match means a cache hit is provably the same decision, not probably the same decision. The cost is that callers have to be deliberate about what goes into params — see Quickstart.
What the ledger records
The ledger is an append-only log, not a running total that can drift:
interface LedgerEvent {
signature: string;
kind: "hit" | "miss";
tokensSpent: number;
timestamp: number;
}
- On a miss, Runic logs the
tokensSpentyour code reported for producing the artifact. - On a hit, Runic looks up the original miss for that signature and logs the same
tokensSpentvalue as what was saved — never an estimate, never a guess. If somehow no prior miss exists for a signature that’s being hit, it logs0rather than inventing a number.
summary.totalSpent = sum of all miss events
summary.totalSaved = sum of all hit events
tokensWithoutRunic = totalSpent + totalSaved (what it would've cost with no cache at all)
savingsPercent = totalSaved / tokensWithoutRunic
Runic never measures token cost itself. It only ever repeats back what the calling code told it via storeResult(decision, artifact, tokensSpent) — see Benchmarks for how openrouter-savings gets that number from a real API response instead of an estimate.
Storage backends
Both @runic-labs/cache and @runic-labs/ledger are built against a small storage interface, with two implementations shipped in v1:
| Backend | Lifetime | Use case |
|---|---|---|
MemoryCacheStore / MemoryLedgerStore |
Process lifetime only | Tests, ephemeral scripts, benchmarks |
FileCacheStore / FileLedgerStore |
Persisted to disk (.runic/ by default) |
A CLI process and an agent process reading the same state without a running server |
Both are per-session scope in v1 — see Scope for why cross-session sharing is deliberately a later phase rather than something bolted on now.
Staleness is advisory only
Cache entries carry a stale flag, computed from a configurable window (24h by default). Runic never evicts or refuses a stale entry automatically — it’s information for the caller to act on if it wants to, not an enforcement mechanism. If your use case needs hard expiry, check entry.stale yourself and decide what to do with it.