A continual-learning harness for Claude Code and Codex. Lessons are learned once in verbose form, then recursively compressed each time they are actually used, producing a graph of progressively more abstract memories. At retrieval time you load the most compressed node that still works, and only pay for detail when the abstraction fails.
You do not drive it. You work normally, and it runs in hooks.
| The thesis | why compression is driven by usage |
| The loop | what happens, when, and what it costs |
| The governing rule | harness structures, model judges |
| 1. Recall | choosing what to inject |
| 2. Reflection | noticing there was something to learn |
| 3. Attribution | which lessons actually mattered |
| 4. Consolidation | where a new lesson goes |
| 5. Compression | earning a smaller form |
| 6. Descent | recovering detail when the short form fails |
| 7. Selection lessons | teaching retrieval where to look |
| Data model | nodes, episodes, the DAG, the store |
| Install | plugin or clone |
| Inspecting it | status, tree, recall, trace |
| Not built | honest gaps |
The standard learning cycle produces one artifact and stops:
task + steering -> correct output -> reflection -> lesson
ROSE keeps going. Every time a lesson is used and the work succeeds, that is evidence the lesson contained slack, so it earns a compression attempt:
task + lesson(L0) -> used, worked -> reflect -> lesson(L1) ~75% tokens
task + lesson(L1) -> used, worked -> reflect -> lesson(L2) ~55%
task + lesson(L2) -> used, worked -> reflect -> lesson(L3) ~40%
And lessons that are repeatedly useful together earn a shared parent, which is a different kind of abstraction — generalisation across lessons rather than compression within one.
The more a memory is used, the cheaper and more general it becomes. That is the whole idea: usage drives abstraction.
| When | What happens | Model calls |
|---|---|---|
| you submit a prompt | matching lessons are injected as context | 0–1, cached |
| context is about to compact | the record of what you were shown is cleared | 0 |
| a substantial turn ends | a reflector runs off-thread: did anything teach us something, and which lessons actually mattered | 1–2, detached |
| you teach it something | rose add records it immediately, reconciled against what is known |
1–2 |
| the session ends | the transcript is judged, lessons minted, compression attempted, selection rules written | 2–4, detached |
Everything expensive is detached — spawned as a separate process that
outlives the hook. Spawned agents get ROSE_CHILD=1, which makes ROSE's own hooks
no-op; without it a reflector would trigger reflectors forever.
The harness owns structure. The model owns meaning.
The harness owns the graph, the traversal, budgets, caches, the schemas answers must fit, and whether to ask at all. The model owns every question about meaning, each behind a JSON schema, cached so nothing is judged twice.
There are exactly five semantic calls in the system (rose/judge.py):
| Call | Question |
|---|---|
relevance |
which remembered lessons bear on this prompt? |
related |
which existing lessons cover the same subject as this new one? |
scope |
does this lesson belong to this repo, or everywhere? |
assess |
how did this session go, and which lessons actually bore on it? |
rank_repairs |
which dropped detail explains this failure? |
Plus four more: select (which lessons this work needs), compress, replay-probe, and the replay judge.
What stays in code, and why it is not a violation: counting an observed outcome is evidence — a success rate, a rescue count, how often two lessons were used together on work that succeeded. Computing how alike two texts are is a proxy standing in for a judgement, and must be a model call. The test is what question the number answers, not where it came from.
This distinction was learned the hard way. Retrieval was originally Jaccard
similarity, contradictions were found with a KEY=value regex, and session
outcomes came from phrase banks with hand-tuned weights. All of it looked
structural and all of it was a lookup table wearing a judgement's clothes.
Runs on UserPromptSubmit. Produces the block injected above your prompt.
Neither is a judgement, so neither costs a call:
- Empty store — nothing to recall, nothing to ask.
- Everything fits — if the whole store is under
recall.max_pack_tokens, there is no choice to make, so all of it is served unfiltered. Relevance filtering switches on exactly when the store outgrows the budget.
The second gate matters more than it sounds. Without it, recall spent a model call deciding which two of two lessons to load, and blew the hook's deadline doing it. With it, recall returns in ~0.1s until the store is genuinely large.
Once filtering is needed, the model decides — walking abstract to concrete:
- Start from the apexes (nodes with no parent). They are the most compressed things in the store, so the whole top level fits in one question.
- Each candidate is rendered as one line: id, title, and a gist — never the body. Sending 700 characters per lesson to choose which lessons to send is the scaling bug that eats the context it was meant to protect.
- The model returns
relevant/maybe/unrelatedper candidate, plusdescendwhen a summary is too abstract to judge from. - Only
descendbranches are opened. A branch judged unrelated is never walked. - Bounded by
recall.judge_callsandrecall.max_depth. Anything unresolved when the budget runs out is served rather than dropped.
A lesson already in context should not be paid for twice — but present and still attended to are different things, since attention over long context decays and an early injection ends up in the middle where models attend least.
| State | Action |
|---|---|
| never served | full body |
served < stays_fresh_turns ago (8) |
skipped — still fresh |
| served longer ago | refreshed with its one-line gist, ~20 tokens |
PreCompact clears the whole record, because compaction may have deleted the
text being tracked.
- Sticky patches — dropped claims that previously rescued this node get re-attached, rather than waiting for the same failure to recur.
- Unresolved conflicts — surfaced here, at the moment you are thinking about the topic.
⋯ Recalling lessons… (while the hook runs)
⋯ ROSE · 2 lessons · 512 tok — Retry policy, Cache TTLs (after injection)
⋯ ROSE · 1 lesson · 118 tok — Retry policy · 1 refreshed, 2 already in context
The counts distinguish three different things: a lesson injected in full, a one-line refresher for one that is present but stale, and one skipped entirely because it is still fresh in context. Only the first is a lesson arriving.
An injection is never silent. A memory system that edits your prompts without saying so is one you cannot notice going wrong.
Runs on Stop, per turn. Its job is to schedule attention, not to judge.
The occasion is structural — "did this turn have enough substance to be worth a
thought" — measured by nudge_after_tool_calls (12), nudge_after_turns (3), or
min_surprises (2 failed tool calls), subject to nudge_cooldown_s.
The occasion is deliberately not "something failed." An earlier version triggered on failed tool calls, which sounds structural but is a mechanical proxy for a semantic question, and it biases hard toward the cheapest mistakes. Conceptual errors — believing a system works one way when it does not — exit zero and pass their tests. During ROSE's own development the worst mistake ran green the whole way.
| Mode | Reflector sees | Cost | Interrupts you |
|---|---|---|---|
background (default) |
a transcript digest, ~3k tokens | one small call | no |
fork |
the whole session, inherited | ~0.1× its tokens | no |
block |
the agent's own live context | none extra | yes, one turn |
off |
— | none until session end | no |
fork spawns claude --resume <id> --fork-session detached. --fork-session
allocates a new id, so the live session is never written to. It is affordable
because prompt-cache reads bill at 0.1× and the cache keys on prefix
content, not session identity — the fork hits what the live session just
wrote.
If nudges keep producing nothing, the cooldown backs off automatically
(nudge_backoff_after).
The question that drives everything downstream: which served lessons actually bore on the work?
Serving is a retrieval decision; using is an outcome. Conflating them breaks two things at once — an irrelevant lesson that happened to be injected accrues a record of usefulness it never earned, and serving ten lessons manufactures the selector is never told that what it chose went unread.
Two sources, best first:
- The in-session reflector. The fork holds the real conversation, so it can
see a principle being applied and not merely a command being run. It reports
with
rose used --session <id> --used <ids> --unused <ids>. - The digest judge.
assessis shown the served lessons and the session digest and answerslessons_usedper lesson. Weaker: influence on reasoning is nearly invisible in a digest of commands, so it under-credits principles. The digest now includes the agent's own reasoning for exactly this reason.
Either way the prompt is strict and defaults to false: being on-topic is not being used; being read and found irrelevant is not being used; having been served a lesson and then done the opposite means it was not used.
A lesson served but unused is scored neither success nor failure. It has three possible causes and only the first is a retrieval problem:
| Cause | Meaning |
|---|---|
| irrelevant | recall over-served |
| redundant | the agent knew it anyway |
| relevant and ignored | the lesson isn't landing — a salience failure |
rose status reports precision (used ÷ served).
New knowledge is reconciled with old before it is stored, never appended blindly.
Where a lesson lands decides whether it can ever be found again. A lesson about a vendor API filed under one repo is invisible from every other one, so nothing downstream can rescue it — not recall, not compression, not descent.
So scope asks: does this depend on this repository, or would it be true for
anyone using these tools anywhere? Global lessons go to ~/.rose, project
lessons stay local. The error is asymmetric: a global lesson filed locally is
lost, a project lesson filed globally is merely noise.
related walks the graph for lessons on the same subject, and reconcile
classifies:
| Relation | Action |
|---|---|
duplicate |
nothing stored; the hit is recorded |
refines |
fold into the matched lesson's own lineage, then patch its ancestors |
contradicts |
keep both, mark both disputed, attach a question |
specialises |
attach as a sibling under the more general lesson |
orthogonal |
new family |
Refinement must reach the apex. Folding detail into a level-0 node leaves
every ancestor missing it. Rather than invalidating working compressions, the
detail is registered as a rescue on each ancestor, so recall re-attaches it
immediately and repair folds it in permanently once it proves necessary.
Contradictions are never resolved silently. Last-write-wins is how a memory rots. Both lessons stay servable, and the question is raised at recall time:
> Unresolved: Is 5434 the new permanent host port mapping, or was 5433
> only temporarily unavailable at the time?
rose conflicts lists them, rose resolve <id> [--drop] settles them.
A node becomes eligible when it is an active apex, below max_level (6), has at
least min_successes (2) recalls that were attributed as used, is past its
cooldown, and has recorded episodes to validate against. A node with no
episodes is left alone: compressing with no way to check the result is worse
than not compressing.
Every compression must declare what it removed, as discrete claims:
dropped:
- claim: "Backoff constants are 100ms / 400ms / 1.6s, jitter ±25%"
kind: parameter
holder: n_7f2aA compression that does not record its losses cannot be descended, so the lost detail is simply gone. A candidate that shrank materially while declaring nothing is rejected as under-reported.
Deltas are inherited — an apex knows about detail several levels below it without holding the text, which is what makes delta-jumping possible.
The check from the original sketch, hardened:
R = sample(regression episodes over the node's whole subtree, k=5)
for each: fresh agent process, candidate lesson + the original prompt
accept iff pass_rate >= 1.0 and tokens <= 0.75 × original
- Fresh process — otherwise the main agent's memory of the verbose lesson leaks in and every compression looks successful.
- Subtree-wide — validating only on the triggering episode is how you get a beautifully compressed, useless tree.
- Probe form — replay asks how would you approach this, not do the work. Asking for the work meant judging scaffolding completeness and truncation artefacts rather than the lesson.
- An unreadable judge is a failure, never a pass, so an infrastructure blip cannot promote a bad compression.
- Rejections are informative — failing episodes become
preserve:hints for the next attempt, so the compressor converges instead of thrashing.
It is given the spans a reflection pass observed doing work — the sentences that changed what an agent did. Where that evidence exists the reduction is taken from everything else; where it is absent the compressor is told so and compresses conservatively, because a span with no record may simply not have come up yet.
Before this, the compressor chose what to cut from the text alone and found out afterwards whether it had been wrong.
A delta that repeatedly rescues the same node is proof the compression cut too deep, so it is folded permanently back into the body and dropped from the manifest. The graph heals where it was over-cut.
When the compressed lesson fails, which child do you go to?
Compression normally destroys the information you would need to invert it, which is why the manifest is mandatory. Descent is then a ranking problem over dropped claims, not a search over the graph:
score = w_judge · model_usefulness + w_prior · rescue_rate − w_cost · tokens
(0.60) (0.28) (0.12)
model_usefulness— one call ranks every candidate 0–1: how likely is it that this detail being absent caused this failure. A judgement, so the model makes it. "Parse the body, not the status code" is the fix for "treated HTTP 200 as success" while sharing none of its words.prior— Laplace-smoothed rate at which this node has actually rescued failures. Observed outcome, so it stays in code. Makes descent a contextual bandit over the graph.cost— token count. A measurement.
With no judge available the first term is simply absent and ranking falls back to "try what has worked, cheapest first" — degrading honestly rather than substituting a metric that looks like judgement.
Strategies (recall.strategy): delta-patch (apex + matched claims, default),
delta-jump (swap in the holder node), stepwise (walk children, ignore the
manifest — the ablation baseline).
Escalation: exhaust candidates → load the level-0 node, which is never deleted. If that fails too, it is a genuine knowledge gap, not a compression bug.
ROSE applied to its own retrieval. Every other stage learns from outcomes; selection learned from nothing, and it is the stage measured worst — filtering lifts precision from 28% to 48%, which means over half of what recall serves is never used.
A selection lesson is what the reflection pass writes after watching a session: not knowledge about the work, but knowledge about where the knowledge was.
- When the task runs the integration tests: read nodes/testing/ before running pytest
- When the task is a docs change: nothing in nodes/deploy/ applies, skip it
The next selector reads those and reaches the right set in fewer searches, or skips one it now knows is fruitless.
Not under nodes/. If they were lessons they would be retrieved by the
mechanism they exist to fix, and would compete with real lessons for the same
budget. They live in .rose/routing/, are always injected, and are capped by
routing.max_tokens.
They are also the only thing retrieval still costs per prompt — the index is grepped, the rules are sent — so the cap is real. When it binds, the rules with the worst record of improving a selection drop out first.
Every rule must be conditioned on a kind of task. One that is not is refused rather than stored.
| Form | Verdict |
|---|---|
"when the task touches the integration tests, read nodes/testing/" |
stored |
"n_abc is rarely useful" |
refused |
This is not tidiness. The second form was measured: annotating candidates with their usage record dropped precision from 48% to 41% and recall from 100% to 81% — worse on both. How often a lesson gets used is a statement about the distribution of work, not about the lesson. It is also one rule per lesson, which would make this layer a second copy of the store.
The bet is that selection lessons track kinds of work, not lessons, so the injected layer stays small while the store does not. That is a bet, so it is printed rather than assumed:
routing 4 selection rules over 210 lessons (ratio 0.02, should fall as the store grows)
If that ratio climbs, the approach to the long tail is wrong.
rose route # the rules, their record, the growth ratio
rose route --when "..." --then "..." # teach one by hand
rose route --forget r_e5fea0 # delete oneid: n_7f2a91
family: retry
title: Retrying flaky services
gist: Retry idempotent remote calls; S3 needs body parsing. # the routing view
level: 3
status: active # active | superseded | demoted | disputed | archived
origin: compression # reflection | compression | merge | manual
conflict: "" # an unresolved contradiction, surfaced at recall
derived_from: [n_c41b, n_9de1] # points DOWN, toward detail
parents: [n_aa01] # points UP, toward abstraction
covers_tasks: [e_4f1a, e_9c22] # the regression set
dropped: [...] # the delta manifest
preserve: [...] # hints from rejected compressions
stats: {attempts, successes, failures, expansions, rescues, last_used}Both edges are lists, so this is a DAG, not a tree. A lesson can be abstracted in more than one direction: compressed vertically into a terser form of itself, and merged sideways into a shared generalisation. While the parent link was a single field, the second silently destroyed the first.
id: e_4f1a
prompt: "add retry to the http client"
outcome: success
served: [n_7f2a, n_c41b] # what was injected
used: [n_7f2a] # what actually bore on the work
accepted_summary: "..." # what the agent ended up doingThis is the ambient oracle. Nobody writes YAML oracles for their own repo, so instead ROSE records what happened when work was accepted, and later asks whether a compressed lesson still reproduces it.
.rose/
config.yaml settings
nodes/<family>/*.md the graph. Worth committing.
episodes/*.json the replay corpus. Worth committing.
sessions/*.json per-session scratch. Machine-local.
events.jsonl telemetry. Machine-local.
judge-cache.json cached judgements. Machine-local.
Two scopes. If ~/.rose exists it is layered under the project store: both
are recalled, new lessons are written to the project one, and editing a global
lesson writes back to it rather than forking a local copy that drifts.
Privacy. Everything passes through redact.py before touching disk — API
keys, tokens, private keys, card numbers, secret=… assignments. Biased toward
over-redaction: a mangled lesson is recoverable, a leaked key is not. ROSE never
sends anything anywhere; model calls go through whichever CLI you already have.
Python 3.10+ and at least one of claude / codex. No third-party
dependencies.
uv tool install rose-memory
rose install --scope userThat is the whole thing. rose install wires the hooks and ROSE runs in every
repo you open — you do not drive it, you just work. Drop --scope user to
limit it to the project you run it from.
Try it without installing anything: uvx rose-memory doctor. Without uv:
pipx install rose-memory, or pip install --user rose-memory.
The distribution is
rose-memorybecauserosewas taken on PyPI. The command it installs, and the package you import, are bothrose.
As a Claude Code plugin, if you would rather not install anything yourself:
/plugin marketplace add yiheinchai/rose
/plugin install rose@rose
Codex gets an AGENTS.md block instructing the agent to call rose recall
itself, since Codex's hook schema is less settled. Codex also works as an
execution backend for any ROSE install (rose config agent codex).
git clone https://github.com/yiheinchai/rose && cd rose
python3 -m unittest discover -s tests
./bin/rose install --scope user./bin/rose runs from a clone with no virtualenv, since the package is
stdlib-only. Installing from a clone also symlinks the command into
~/.local/bin; pass --no-link to skip that. Note the scope — without
--scope user the hooks land in the clone, and every repo you actually work in
gets nothing.
rose uninstall removes only what ROSE added, and rose doctor reports what is
wired where.
rose status # families, levels, precision, capture stats
rose tree --family retry # the graph, with delta manifests
rose recall --prompt "..." # what would be injected, and the model's reason
rose trace --prompt "..." # every stage, ending in the verbatim block
rose index # is the lesson the selector searches actually indexed
rose route # the selection rules, their record, and the growth ratio
rose conflicts # unresolved contradictions
rose doctor # backends, store, hook wiring
rose report --about "..." # a redacted defect report; sends nothing
rose events --kind inject # raw telemetryrose trace is the agent's-eye view: what was offered to the model, its verdict
and reason for each candidate, the exact injected text, and what you see in
Claude Code. Use it when you want to know precisely what ROSE is doing to your
prompts.
Full command reference: docs/cli.md. Wiring details: docs/integration.md. Design rationale and known failure modes: DESIGN.md.
Stated plainly, because a harness that hides its gaps is useless:
- Progressive disclosure. Recall is one pass. A lesson only recognisable as relevant after reading another one cannot currently be found — you ask about a stuck deploy, retrieve "deploys use Argo Rollouts", and the lesson about Argo's dedupe key stays invisible because your prompt never said "Argo". The fix is a second pass with the first results in hand.
- Agent-driven search. Lessons are plain markdown on disk, so
grep -r "argo" .rose/nodes/already works — the agent is just never told it can. - Held-out evaluation. Compressions are validated against the episodes they were tested on. In-sample. There is no measurement of whether a compressed lesson generalises to unseen work.
- Codex hooks. Instruction block only, not true ambient operation.
- Attribution is a counterfactual the model cannot run. "Would this have gone differently without the lesson?" is inferred, not measured. Reliable for concrete lessons, softer for dispositional ones.
- Recall costs a call per prompt once the store outgrows the budget.
Cached, and
recall.enabled: falseopts out. - Reconciliation only compares against apexes, so a contradiction with detail deep in a subtree is missed until descent surfaces it.
- Merges can over-generalise. The regression set is a mitigation, not a proof.
- Compression is not monotone. A level-4 node is only known to be better on the episodes it was validated against.
python3 -m unittest discover -s tests97 tests, no dependencies. Two kinds: structural tests stub the judgements and assert what the harness does with an answer; control-flow tests run against a simulated knowledge world where a task is solved iff the required facts are in the lesson text, so compress → fail → descend → rescue genuinely executes.
The site under docs/ is generated. Content lives in docs/_sections/*.html;
docs/build.py wraps it in the shared navigation, search index and chrome, and
verifies every internal link.
python3 docs/build.py
Edit a fragment, run the build, commit the output — GitHub Pages serves it
directly. The configuration reference is read out of rose/config.py at build
time rather than retyped, because the hand-kept version drifted from the code
and nobody noticed for weeks.