Your agent's memory should scale from 6 lessons to 60,000
The obvious answer to agent memory — inject the lessons — breaks the moment your store gets interesting. LoreKit spends a fixed slice of your context window on the highest-signal, de-duplicated lessons, so the read costs the same at 6 lessons or 60,000 — and always tells you what it left out. Here's how it scales, and the honest limits.
Two stores, one read
New to LoreKit? Start here → Give your coding agent a memory in one command.
Picture two memory stores. One holds six lessons your agent picked up this week. The other holds sixty thousand — a year of every teammate's mistakes, gotchas, and hard-won workarounds. Your agent has to read both well.
The obvious approach — inject what you've got — handles the first store and falls apart on the second. Sixty thousand lessons don't fit in a context window, and even the fraction that would fit buries the one line that matters under five hundred that don't. A memory that gets worse the more it learns isn't memory. It's a leak.
So LoreKit reads both stores the same way: it spends a fixed slice of your context window — not a fixed number of lessons — and fills that slice with the highest-signal, de-duplicated ones. Six lessons or sixty thousand, the block costs your prompt the same, and it tells you exactly what it left behind. That's the whole design, and it's what lets one read path serve a fresh repo and a year-old monorepo without you tuning a thing.
A budget, not a count
Start with the thing that scales: what bounds the block. It's a character budget — hooks.sessionStart.maxChars, default 3000 (about 25 index lines) — because that's what actually costs you. "15 lessons" is meaningless across scale: fifteen terse keys and fifteen paragraph-long ones differ by an order of magnitude in what they take from the window, and a fixed count fails at both ends of the range — it pads a six-lesson store up to a number it doesn't have, and truncates a sixty-thousand-lesson store down to one, silently, with no way to tell which happened.
A budget does neither. Six lessons render six lines and stop. Sixty thousand render however many fit in 3000 characters, ranked best-first. The window cost is flat; the count adapts to the store.
// packages/cli/src/shared/control.mjs — two dials, both defaulting small.
const DEFAULT_SESSION_START_MAX_CHARS = 3000; // characters you SEE — a slice of the window
const DEFAULT_SESSION_START_MAX_LESSONS = 100; // read DEPTH — candidates the ranker chooses from
Characters rather than tokens on purpose — a real tokenizer is a dependency this package won't take, and ~4-chars-per-token is accurate enough to bound an order of magnitude. That's the number you turn up or down if you want a bigger or smaller slice; nothing else changes.
Ranked by signal, not recency
A budget decides how much. Ranking decides which, and it's the half that matters as the store grows. Fill the budget by recency and a busy repo hands you one task's iteration log — the newest cluster, not the most useful. So the lessons that survive precedence are scored, and the score is a normalized blend of four factors:
// packages/cli/src/shared/lessons-pure.mjs (shape of scoreWithTerms) — every factor in [0,1], weights normalized (default: all equal)
const score =
(w.recency * recencyFactor(entry.updatedAt, now, 14 /* -day half-life */) +
w.salience * salienceFactor(seenCount, maxSeenCount) + // how often it recurred
w.relevance * relevanceFromTerms(entry, branchTerms) + // does it match what you're on?
w.outcome * normalizeOutcome(entry.outcome)) // did applying it help? (0.5 when unknown)
/ total;
Recency is one factor, decaying on a two-week half-life — not the sort order. Salience is recurrence: a lesson you've re-learned five times outranks a fresh one-off, which is exactly the signal you want a big store to surface. Outcome leans toward lessons that helped, with a cold-start prior of 0.5 so a brand-new lesson isn't buried for lacking a track record. The budget goes to what's been earned — and the more your store holds, the more that ranking is doing for you.
There's a relevance factor in there too, and it's the part a first read can't fake: at session start there's no task yet. So LoreKit distills a query from your branch name — a session on feat/embedding-pipeline lifts embedding lessons up the ranking. It only ever lifts an on-topic lesson (a non-match scores zero), so a bare main or a detached HEAD simply falls back to recency, salience, and outcome. Cheap, deterministic, and it means even a sixty-thousand-lesson store opens leaning toward what you're actually about to do.
It stays legible as it grows
Ranking answers "which score highest." At scale, two things still go wrong, and both get fixed before the block is rendered:
// packages/cli/src/core/lessons.mjs — the two guards that keep a large read readable.
const ranked = rankLessons(winners, rankOpts);
const capped = capPerBucket(ranked, { cap: loopCap, bucketOf: loopBucketOf }); // loopCap=1
const lessons = diversifyRankedLessons(capped, { ...rankOpts, k: ceiling }); // MMR, λ 0.7 — ceiling = maxLessons
A per-bucket cap keeps any single self-improvement loop — a bot writing its own loop:: bookkeeping, which recurs constantly and scores high — from taking the read. One per bucket, and general, human-written lessons are never capped; they're what the room is for. As a store accumulates more automated writers, this is what stops them from crowding out the knowledge a coding session actually needs.
Then MMR — Maximal Marginal Relevance, Carbonell & Goldstein 1998, at λ=0.7 — removes near-duplicates. A dense store has clusters that score alike and read alike (pr395-it3, it4, it5 — the same lesson three times); MMR keeps the best and pushes the echoes down, so the budget buys variety instead of repetition. The bigger the store, the more this earns its keep.
It always tells you what it didn't show
This is the part that makes large stores safe. The header reports both numbers — 8 of 6000 — and in the default hybrid shape it appends a scope map of where the rest live:
LoreKit: 8 of 6000 memories loaded · repo::mthines/lorekit — considerations, not rules; read any in full with memory.read.
- (repo::mthines/lorekit) supabase-rls::empty-array-is-not-no-rows — RLS returns 200 + [], not a 4xx…
- (global) always-typecheck-before-done — run the typecheck before you claim it's done
- … 6 more, ranked and de-duplicated, inside the budget …
More lore: branch::…/feat/x 12 · repo::mthines/lorekit 4180 · global 1808 — memory.search or memory.read to drill in.
(That block is illustrative — the shape formatLessons produces, not a single capture. The real output prints raw counts, no thousands separators.) The counts are exact at any size: they come from the store's own inventory — a Postgres aggregate on the hosted side — not from the bounded read, so a scope holding four thousand lessons reports 4180, not a hand-wavy 100+ — and when the store can't enumerate (an unreachable remote, no inventory), it falls back to the approximate +-suffixed count rather than inventing a total. A truncated read is never mistaken for a complete one: the agent can see there's more and reach for memory.search to pull the exact lesson it needs. The working set stays small; the store behind it can be enormous.
The second gear: relevance when you fail
The ranked block is the always-on read. There's a sharper one that only engages when your agent hits a wall — a tool call fails — because that's the one moment it knows exactly what to search for. LoreKit distills terms from the error, runs one query across your scopes, and drops in up to three prior lessons before the retry. This one's real, unedited — a git clone failed while I was writing this post:
LoreKit: 3 related memories — you've hit something like this before (considerations, not rules; read in full with memory.read):
- (global) implement-suggestion-lessons::negative-source-scan-assertions-must-be-mutation-verified — A negative source-scan assertion is unverified until you make it fail
- (global) reviewer-lessons::skill-dispatch-unavailable-is-not-skill-not-installed-read-the-symlinked-skill-md — A `Skill()` call the sub-agent cannot dispatch does NOT mean the skill is missing — the skill is on disk and must be READ instead, through…
- (global) reviewer-lessons::rebased-branch-makes-prior-sha-compare-useless-as-an-incremental-delta — A rebased PR branch makes `compare/PRIOR_SHA...HEAD_SHA` meaningless, and the harvest heuristic reads the resulting garbage as if it were…
I'll be honest about why those three: the matcher is lexical, not semantic. That error said "could not read Username" — and two of those lessons literally contain "read." It matched on the shared word, not the meaning. No bullseye, no magic — a bounded ranked set up front, a targeted search the moment you trip. Both are bounded, so both scale.
Built for the loops that never stop
The heaviest writers to a real store aren't people — they're loops. The aw agent (plan → build → test → PR) and pr-reviewer we build LoreKit with run this loop on every task: they read their ranked loop::aw-lessons and loop::reviewer-lessons before they act, and write a new lesson the moment something bites. Over a busy month that's thousands of entries — most of them one bot's private bookkeeping.
Those thousands of entries are exactly what the ranking and the per-bucket cap are for. When a loop reads its own bucket by tag it gets the whole bucket — uncapped by the loop limit, paged so it can walk all of it — that's its working memory. When you open a coding session, the same store's read ranks across all of it and caps each loop:: bucket at one — its single best lesson still surfaces — so your window fills with the general, human-written lessons a task needs, not a review bot's iteration log. One store, two very different reads — and neither starves the other.
And it isn't only LoreKit's loops. The same wiring is a skill: lorekit-setup scaffolds the read-fail-write loop into your own skill, agent, or workflow — the bucket, the scopes, the read-before-you-act and write-on-friction — so your automation gets better across runs the way the aw agent does, and the ranked read keeps its growing memory out of everyone else's way.
Small and large, one read path
Nothing above is a mode you switch on. The same code runs against every store:
- Six lessons: you get six lines, ranked, and no scope map — there's nothing left to point at. No padding to a magic number.
- Sixty thousand: you get a 3000-character slice of the highest-signal, de-duplicated, branch-warmed lessons — chosen from up to 100 candidates per scope — plus a map of the four scopes holding the rest and a one-call path (
memory.search) to any of them.
Start local with a handful and it's useful on day one; point it at a shared, org-wide store a year later and the read that worked at six still works at sixty thousand — and when you want a fatter or leaner read, it's a dial, not a rewrite.
Turn it up, or off
A few dials govern the read, and they're the same whether your store holds six lessons or sixty thousand. They live in your repo config (.lorekit.json, which wins) or your user config (~/.lorekit/config.json):
{
"hooks.sessionStart.maxChars": 5000,
"hooks.sessionStart.loopCap": 0
}
maxChars is the character budget (200–20,000) — how many lines you actually see, about 25 at the default 3000. Turn it up to see more, down to spend less of your window. maxLessons is the subtler one, and it really only moves in one direction. It's the read depth — how many candidate memories per scope the ranker picks those lines from — and its default of 100 already sits at the API's 100-per-page ceiling. So raising it above 100 buys nothing; lowering it toward the floor of 25 shrinks the candidate pool for a cheaper, shallower read. That depth is where relevance comes from: at 100 the ranker picks its ~25 lines from up to 400 candidates across a four-scope hierarchy; drop it to 25 and it's choosing from the newest handful, so a lesson you re-learned five times last quarter might never enter the running. loopCap: 0 drops the self-improvement loop:: buckets entirely and reads only human-written memories.
And past whatever slice you pick, the rest of the store is one memory.search away. You tune the working set, not the ceiling on what your agent can reach.
What it doesn't do
Let me be straight about the edges, because scale has them.
- Ranking sees each scope's newest window, not all of it. A scope is read to its newest
maxLessonsmemories (100 by default, and never more than the API's 100 per page) and ranked within that window — so on a huge scope, an old-but-high-salience lesson past that depth won't surface in the session-start block. It's still there, andmemory.searchreaches it by content; the read is depth-bounded on purpose so a big store never stalls your session start. - The ranking is lexical, not semantic — no embeddings on the read path. Recurrence, recency, outcome, word-overlap with your branch. There is an embedding pipeline in the repo now, but it's dormant, off by default, and semantic search isn't built — so a genuine paraphrase can still miss. The read stays deterministic: no vector index, no re-embedding bill, no drift.
- "Relevant" means your branch name, not your task. A coarse hint — a well-named branch helps, a branch called
wipdoes nothing. - The exact scope map isn't free on a local store. Counting every scope means walking the local files; the hosted store aggregates in Postgres instead. On a large local-only store that walk is the one place size shows up — the fix is the hosted store, not a smaller count.
None of these are things I'd rather you found at 2am.
See it for yourself
Wire up the loop and start a session. The header tells you how much of your lore fit the budget — 8 of 6000 — and the scope map points at the rest. That one line is the whole idea: a bounded, ranked slice, and an honest pointer to everything behind it. Tune it up or down with the dials above; the store behind it scales either way. 🙂
Enjoyed this? Show some love.