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 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 four age/activity conditions, all optional, plus the same eight dimension filters the Lore Explorer's filter bar offers:
| Condition | Meaning |
|---|---|
min_age_days | Only lessons at least this many days old |
unseen_days | Only 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_count | Only lessons that have recurred at most this many times. Counts writes, not reads |
max_read_count | Only lessons read back 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. Use it to find lore nothing has ever actually used |
tags / tags_mode | Labels a lesson must carry — any (default), all, or none |
source_agent / source_agent_mode | The writing agent — in (default) or nin |
trigger / trigger_mode | The write trigger — in (default) or nin |
kind / kind_mode | lesson / bus / signal — in (default) or nin |
host / host_mode | The owning skill or agent — in (default) or nin |
origin_repo / origin_repo_mode | owner/repo — in (default) or nin |
origin_branch / origin_branch_mode | The branch name — in (default) or nin |
origin_pr / origin_pr_mode | Pull-request numbers, as digit strings — in (default) or nin |
max_read_count counts a DIFFERENT direction from max_seen_count: the first
counts how many times a lesson was read back, the second how many times it was
written. A lesson written once and read fifty times matches
max_seen_count: 1 but not max_read_count: 1. Neither is called "seen" in
the dashboard for that reason — the Explorer labels them "Recurrence" and
"Consumption", the same two words the lesson detail sheet uses.
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.
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), "Consumption" (read_count / last_read_at — how
many times it has been read back, 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), 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.
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 90or 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: its
"Age & activity" filter (min_age_days / unseen_days / max_seen_count /
max_read_count)
combines with its own filter bar (the same eight dimension filters above) to
narrow the list to the actual lesson ROWS a policy with those conditions
would catch, not just a count — so you can scroll through them before saving
anything. Once at least one condition or filter is set, a Create retention
policy action 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.
Save it as a policy
lorekit policy create --scope repo::acme/app --name "stale repo lore" \
--unseen-days 90 --mode reviewEach condition has its own flag — --min-age-days, --unseen-days,
--max-seen-count, --max-read-count — and lorekit policy update pairs each
with a --clear-* form for removing one from a saved policy:
# never actually read back, and old
lorekit policy create --scope repo::acme/app --name "unused lore" \
--min-age-days 90 --max-read-count 0 --mode review
lorekit policy update <id> --clear-max-read-countmode: 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 --enabledlorekit 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 today through the dashboard, POST /policies/PATCH /policies/:id,
and the MCP policy.create/policy.update/groom.preview/groom.run tools
— lorekit policy/lorekit groom have no CLI flags for them yet.
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-lessonlorekit 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> --runprompts 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.
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 dedupeis 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