LoreKitLoreKit docs

Retention policies

Save scoped rules that automatically archive lessons you no longer need — reviewed manually or swept nightly — without ever hard-deleting anything. Plus the Insights grid that shows which lessons are earning their place, citations so an agent can say which lessons it actually applied, and the read-only Duplicate Clusters sidebar for spotting the same lesson written twice.

Retention policies automate the manual archiving you would otherwise do by hand: a saved rule ("lessons in repo::acme/app unseen for 90 days") that you either run yourself whenever you like, or that runs automatically every night. Either way it only ever archives — the same reversible soft-delete memory.archive and lorekit archive already use. A policy can never hard-delete; that stays a separate, manual, confirm-or---yes step (memory.purge).

Preview before you save anything

Every rule is the AND of five age/activity conditions, all optional, plus the same eight dimension filters the Lore Explorer's filter bar offers:

ConditionMeaning
min_age_daysOnly lessons at least this many days old
unseen_daysOnly lessons no agent has individually opened (over MCP or the lorekit CLI) in at least this many days. A lesson never opened this way counts from its creation date, so it matches only once it is itself that old. A bulk memory.list/memory.search appearance or a human viewing the dashboard does NOT count as "opened"; see the note below
max_seen_countOnly lessons that have recurred at most this many times. Counts writes, not reads
max_read_countOnly lessons delivered at most this many times. Counts every read — a bulk memory.list/memory.search result page and a human opening the dashboard's detail sheet both count, unlike unseen_days. This is a COST threshold, and it runs to the hundreds; see the note below before typing a small number
max_opened_countOnly lessons an agent deliberately fetched at most this many times. A bulk list/search ride-along does NOT count, so 0 means "nothing has ever chosen this". This is the UPTAKE threshold — the one that answers "was this lesson ever worth going and getting?"
tags / tags_modeLabels a lesson must carry — any (default), all, or none
source_agent / source_agent_modeThe writing agent — in (default) or nin
trigger / trigger_modeThe write trigger — in (default) or nin
kind / kind_modelesson / bus / signal — in (default) or nin
host / host_modeThe owning skill or agent — in (default) or nin
origin_repo / origin_repo_modeowner/repo — in (default) or nin
origin_branch / origin_branch_modeThe branch name — in (default) or nin
origin_pr / origin_pr_modePull-request numbers, as digit strings — in (default) or nin

The three counters measure three different things, and none of them is called "seen". max_seen_count counts WRITES. max_read_count counts every read, deliberate or not. max_opened_count counts only the deliberate ones. A lesson written once and delivered fifty times matches max_seen_count: 1 but not max_read_count: 1; a lesson delivered 1,400 times that no agent ever opened matches max_opened_count: 0 but no max_read_count value you would ever think to type. The dashboard labels them "Recurrence", "Delivered" and "Chosen" — the same three words the lesson detail sheet uses.

max_read_count: 0 matches nothing, and that is not a bug. A lesson picks up a read every time it rides along in a memory.list or memory.search page, so anything that has been in your store for a while carries a count in the hundreds. Measured against a real store: max_read_count <= 5 matched no lessons at all, the first non-empty result appeared around 26, and 400 matched essentially everything. Treat it as a "delivered at most this often" cost filter and start in the hundreds. If what you actually want is "nothing has ever used this", that is max_opened_count: 0.

One caveat on max_read_count: reads have only been counted since the read_count column shipped, so a long-lived lesson can show a low count it never earned. Pair it with min_age_days only once your store has been counting for a while. max_opened_count has no such caveat — it was backfilled over the whole recorded read history when it shipped.

unseen_days is deliberately the NARROWEST of the three "has this been touched" signals a lesson carries, and the Lore Explorer's detail sheet shows all three side by side so they're never confused for one another: "Recurrence" (seen_count — how many times the lesson has been written, unrelated to reading), "Delivered" (read_count / last_read_at — how many times it has been handed to a reader, by ANY surface: a bulk memory.list/.search result page, GET /memories/:id, or a human opening the dashboard's own detail sheet all bump it), "Chosen" (opened_count — the subset of those that were deliberate agent fetches) and "Last agent open" (last_opened_at). unseen_days reads only the last of those — it only moves when an agent deliberately retrieves this ONE lesson over MCP (memory.read) or the CLI (lorekit read/show), never from riding along in a list page or from a person browsing the dashboard. That is what keeps "unseen for 90 days" honest: reloading the Explorer, or a memory.list call that happens to include the lesson, does not reset the clock.

"Last updated" is a fourth signal, and reading never moves it. It is the last time the lesson's content was written. A read bumps the three counters above and nothing else, so a lesson can show a recent Delivered count and a months-old Last updated at the same time — that combination means "still in use, unchanged", not stale data. (Before this was fixed, a single memory.list restamped every lesson on the page, which is why an older store may show a cluster of lessons all "updated" within minutes of each other. The counters are unaffected; only those updated_at values are lost.)

A lesson no agent has ever opened has no last_opened_at to measure from, so the clock starts at its creation date. A lesson written a week ago therefore does not match unseen_days: 90 — it has not yet had 90 days in which to go unread. This matters most right after last_opened_at was introduced, when every pre-existing lesson has no recorded open: without the fallback, unseen_days would match the entire store regardless of threshold.

Preview what a rule would catch before saving it as a policy, with the CLI:

lorekit groom --scope repo::acme/app --unseen-days 90

or the REST API:

curl -X POST https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/memories/groom/preview \
  -H "Authorization: Bearer $LOREKIT_TOKEN" -H "Content-Type: application/json" \
  -d '{"scope": "repo::acme/app", "unseen_days": 90}'

Both answer { "count": <n>, "keys": [{ "scope", "key" }] } — the exact set a run would archive, because a saved run and a preview resolve through the IDENTICAL candidate query. Nothing is changed by a preview.

The Lore Explorer previews the same conditions a different way, and it does it through the same Filter menu you use for everything else. Open Filter and the five conditions sit under an Age & activity heading, below the nine dimensions:

Menu entryConditionWhat it tests
Createdmin_age_daysWritten more than N days ago
Last agent openunseen_daysLast deliberately fetched more than N days ago (never opened counts from Created)
Recurrencemax_seen_countWritten N times or fewer
Deliveredmax_read_countHanded to an agent N times or fewer — bulk list/search reads count, so this runs to the hundreds
Chosenmax_opened_countDeliberately fetched N times or fewer — bulk reads do NOT count, so 0 means never chosen

Each offers a short preset list, and the menu's search box doubles as the custom-value input: type 45 under Created and "More than 45 days ago" is offered straight away. A custom value you have applied keeps a row of its own at the top of that list, labelled custom, so reopening the condition shows what is in force rather than five presets with none selected. Picking a value applies it and returns you to the list, so conditions chain without reopening the menu. Each committed condition becomes a pill in the same row as the dimension filters, and everything lives in the URL (?retention=), so a filtered view is shareable and survives a reload.

Conditions AND across the filter bar's dimensions, so the list narrows to the actual lesson ROWS a policy with those conditions would catch, not just a count — you can scroll through them before saving anything.

The numbers AROUND the list follow the conditions too: the filter menu's per-value counts, the stat cards above it and the matrix all narrow to the same set, so a count never describes a population the list below it is not showing. The one exception is the Read card, which stays scope-level — a read is recorded against a scope but not against the age or counters of the memories it returned, so a per-lesson threshold is unanswerable for it.

Once at least one condition or filter is set, a Create retention policy action appears at the end of the pill row and hands the current scope, conditions AND filter bar to Settings → Retention Policies, which opens the New policy dialog pre-filled with all of it, using the identical filter control to edit it further.

Reading the other way: the lore that IS used

Four of the entries also answer the opposite question. Their value list shows both directions at once, as two groups of presets — At most / At least for the three counters, More than / Within for Last agent open. Typing works the same way: a bare number such as 40 offers both 40 times or fewer and 40 times or more, while >40 or <40 picks one side (>= and <= work too). On a count a strict comparison means the next whole number, so >40 reads — and applies — as 41 times or more; on days it does not, so >30 under Last agent open is More than 30 days ago.

Menu entryReverse conditionWhat it tests
Last agent open → Withinopened_within_daysLast deliberately fetched within N days. A lesson never opened does not match, however new
Recurrence → At leastmin_seen_countWritten N times or more
Delivered → At leastmin_read_countHanded to an agent N times or more
Chosen → At leastmin_opened_countDeliberately fetched N times or more — the lore agents actually reach for

A row holds one direction at a time: picking Chosen → 10 times or more replaces Chosen: 5 times or fewer rather than combining into an empty range. The pill carries the direction in its wording (Chosen: 10 times or more), and the counts, stat cards and matrix narrow exactly as they do for the retention side.

These reverse directions are for exploring, not for policies — a retention policy cannot carry them, so Create retention policy is hidden while one is set rather than saving a policy that would archive a different set from the one on screen.

To see the most-used lore first without picking a threshold at all, set the Sort control next to the date picker to Most chosen: the list orders by deliberate fetches, highest first. The order is kept in the page URL like the filters, so a sorted view is shareable; Newest is the default. Over the REST API the same order is sort=opened_count on GET /memories and POST /memories/list.

Save it as a policy

lorekit policy create --scope repo::acme/app --name "stale repo lore" \
  --unseen-days 90 --mode review

Each condition has its own flag — --min-age-days, --unseen-days, --max-seen-count, --max-read-count, --max-opened-count, and the eight dimension filters (--tags, --kind, --host, --trigger, --source-agent, --origin-repo, --origin-branch, --origin-pr — comma-separated, each paired with its own --<dim>-mode) — and lorekit policy update pairs every one of them with a --clear-* form for removing it from a saved policy:

# old, and no agent has ever deliberately fetched it — the "noise tax" rule
lorekit policy create --scope repo::acme/app --name "unused lore" \
  --min-age-days 90 --max-opened-count 0 --mode review

lorekit policy update <id> --clear-max-opened-count

# precise, dimension-scoped: only bus-kind CI review-state events, tagged
# with the exact label
lorekit policy create --scope repo::acme/app --name "stale ci review events" \
  --kind bus --kind-mode in --tags ci::pr-review-state --tags-mode all \
  --min-age-days 30

lorekit policy update <id> --clear-kind --host reviewer,aw

mode: review is the default — the policy is saved and shows up in the dashboard's Retention Policies settings for you to run by hand whenever you like (lorekit groom --policy-id <id> --run). mode: auto gets swept nightly, but only once you also pass --enabled — a saved auto policy always starts disabled, so creating one never archives anything unattended by itself:

lorekit policy create --scope repo::acme/app --name "stale repo lore" \
  --unseen-days 90 --mode auto --enabled

lorekit policy list / update <id> / delete <id> round out the set. Deleting a policy deletes the RULE only — it never touches the lessons it matched.

The eight dimension filters (tags, source_agent, trigger, kind, host, origin_repo, origin_branch, origin_pr and their *_mode pairs) are reachable everywhere: the dashboard, POST /policies/PATCH /policies/:id, the MCP policy.create/policy.update/groom.preview/groom.run tools, and lorekit policy/lorekit groom's own flags (--tags, --kind, --host, --trigger, --source-agent, --origin-repo, --origin-branch, --origin-pr, each with a --<dim>-mode).

A policy does not have to name one scope. --scope global matches every lesson in the account, in every scope — that is what the dashboard's dialog offers as the All scopes row pinned to the top of the scope picker. A named scope always matches itself, and a repo::owner/repo policy additionally covers every branch::owner/repo::… under it, so a repo rule does not need a branch rule beside it.

The name is optional in the dashboard. Leave the field blank and the policy is named after what it does — chosen ≤ 0× in all scopes, created >90d ago · unopened >90d in repo::acme/app — which is exactly the text the field shows as its placeholder while you build the rule, so you can see the name before you save it. Typing anything overrides it. The API and the CLI still require name explicitly.

Protect a lesson from every policy

Some lessons should never be swept, no matter what rule matches them — a load-bearing convention, a hard-won gotcha you want permanent. Mark it protected and every policy (and every manual groom --run) skips it:

lorekit pin repo::acme/app::the-load-bearing-lesson
# …later, if it's no longer special:
lorekit unpin repo::acme/app::the-load-bearing-lesson

lorekit protect <scope::key> [--off] is the longer spelling of the same call. Protection is per-lesson and persists until you explicitly clear it — it survives every policy change, and a disabled policy or one in review mode obviously can't reach it either.

Run it, or let the sweep run it

lorekit groom --policy-id <id> --run

prompts for confirmation (or pass --yes non-interactively) and then archives every match — recoverable via lorekit restore or memory.restore, exactly like any other archive. An auto + enabled policy runs on its own every night; nothing else needs to happen for it to take effect once you flip --enabled on.

All server-side, no local-store equivalent. groom, policy, protect and pin/unpin are remote-only commands — retention policies are account-wide state, the same posture as lorekit purge.

Which lessons are earning their place?

Retention rules answer "is this old?". The Insights page answers the harder question — "was this ever used?" — and hands you the rows.

It leads with the bill: how many tokens of lore were delivered to your agents over the last 30 days, and how little of it anything ever deliberately fetched. Both halves come with a qualifier printed next to them, because the number is only honest with them. Tokens are estimated at four characters each, never tokenized. And the share measures selection, not influence — a lesson injected at session start is already in your agent's context and never needs a second fetch to be acted on, so a low share means "rarely looked up on purpose", not "wasted".

Below it, every lesson is placed on a grid of delivered × chosen:

Chosen oftenRarely chosen
Delivered widelyLoad-bearing — promote it to a ruleNoise tax — prune this first
Delivered narrowlySpecialist — broaden its scopeDormant — archive it

Plus a fifth state the other four depend on: Too new to judge. A lesson only gets a verdict once it is at least a week old and has been delivered at least ten times. Without that floor a lesson you wrote yesterday and one that has been dead for a year would look identical, which is exactly the confusion the grid exists to remove.

Pick a quadrant and its lessons replace the list underneath — each row links into the Explorer, and Copy prompt puts a ready-to-paste instruction on your clipboard: what the quadrant measured, the one action to take, a closed menu of choices, the rule that nothing is archived before you approve it, and the lessons themselves with their delivered/chosen counts. Paste it into your agent and it has everything it needs; you do not have to explain the grid again.

The agent-side spelling of the same selection is max_opened_count: 0, which selects the Noise tax and Dormant lessons over your whole store rather than a page of them:

lorekit groom --scope repo::acme/app --max-opened-count 0

Two limits worth knowing. The grid reads lifetime counters while the cost line reads a 30-day window, so the two describe different periods on purpose — each states its own. And a 0 is always captioned with the date the counting began, never called "never": opens have only been recorded since the counter shipped, so a lesson older than the counter cannot support that claim.

Letting your agent say which lessons it used

Everything above is measured from the outside: LoreKit can see which lessons were delivered and which were deliberately fetched, but not which ones actually changed what the agent did. It cannot — a lesson injected at session start is already in your agent's context and gets applied without ever being fetched again. Only the agent knows.

So memory.write takes an optional cited array, and the agent fills it in when it writes up what it learned:

{
  "scope": "repo::acme/app",
  "key": "backfill-before-index",
  "value": "Run the backfill before adding the unique index …",
  "cited": ["global::pnpm-first", "repo::acme/app::migration-order"]
}

Each entry is a scope::key reference to a lesson the write applied. The lesson detail sheet then shows a Cited line beside Delivered and Chosen, and that line is the only evidence on the page that says a lesson was used rather than merely shown.

The same field is on the CLI, remote store only — the offline store keeps no citation ledger, so a local write with --cited is refused rather than quietly recording nothing:

lorekit write repo::acme/app::backfill-before-index "Run the backfill first" \
  --cited "global::pnpm-first,repo::acme/app::migration-order"

Three things to know before you read the number:

  • It is voluntary, so a 0 means nothing said so — not "never used". That is the opposite reading of a zero in the Chosen column, which at least covers every targeted read since the counter shipped. Never treat Cited as a denominator.
  • Wrong guesses cost nothing. A reference that does not resolve to lore you own is dropped, and a lesson cannot cite itself. Naming a lesson you are unsure about is cheaper than omitting one you relied on.
  • Once per run. Citations are de-duplicated per (lesson, retrospective, run), so a retried write credits a lesson once rather than inflating it.

If you use the Claude Code plugin, the end-of-turn hook already does this for you: the retrospective nudge lists the scope::key refs that were injected this session and asks the agent to keep the ones it actually applied.

Spotting the same lesson written twice

Retention rules catch lore that has gone stale. A different kind of clutter is the same lesson learned two or three times under slightly different keys — which no age condition can see, because every copy keeps recurring.

The Lore Explorer has a Duplicate clusters trigger for that. It is rolling out behind a feature flag, so you may not see it yet — the CLI's lorekit dedupe (below) answers the same question in the meantime, and answers it over your whole store. Once it's on, click the trigger and a sidebar opens beside the list — not a popup, so you can keep clicking lessons while it's open. It groups the current scope's near-duplicate lessons by how much vocabulary they share (the same Jaccard heuristic lorekit dedupe uses), ranks the groups by how often their members have recurred, and — where the keys match a known recurrence class — names it. Pick a cluster and its members replace the list, so opening one is the same click you'd use on any lesson.

It is read-only, by design: nothing in it merges, edits or deletes lore. Deciding that three near-duplicate lessons are really one entry is a judgment call about meaning, so the sidebar gets you to the evidence and stops. Rewriting them into one lesson is an ordinary memory.write (the surviving key) plus an archive of the others.

Two things worth knowing before you read it as a clean bill of health:

  • It is a recency window, not your whole store. The sidebar clusters the most recently updated lessons in the scope and says so in its footnote when that window was full. A duplicate pair whose members were both last touched long ago will not appear.
  • lorekit dedupe is the whole-store answer. It streams every lesson in the scope through the identical clustering, so it has no window at all — use it when you want the complete list rather than "what have I written lately that duplicates something else lately".
# The whole scope, not just the recent window
npx @lorekit/cli dedupe --scope repo::acme/app