Memory and skills
Twelve stateless agents can ship a full stdlib library overnight without repeating each other’s dead ends — because Kelix writes every iteration’s lessons to human-readable files and injects them into the next fresh process before it touches code.
That cross-run continuity is proven, not promised. After the dogfood run,
project memory correctly captured persistence JSON, CLI contracts, and
due-date semantics with no human rewrite — see the
dogfood retrospective (12/12 verified,
final build report).
Reproduce the injection layer with pytest tests/test_memory.py -q.
A fresh agent process per iteration means nothing survives in the model’s head. Kelix’s answer is not to weaken that invariant but to externalize memory: everything worth remembering is a file, written before the process exits, and injected into future iterations as budgeted data.
The three layers
1. Project memory — .kelix/memory/project.md
Durable facts about the repo: architecture notes, conventions, build/test quirks, gotchas. Committed to git, so it ships with the repo and arrives in PRs. Agents append to it during iterations (the prompt instructs them to record durable facts before exiting), and run retrospectives append a summary per run. Append, don’t rewrite.
2. Episodic memory — .kelix/memory/episodes.jsonl
Runner-owned, gitignored, append-only. One JSON record per iteration across all runs:
{"ts": "2026-07-02T03:14:07", "agent": "solo", "iteration": 4,
"rationale": "T3 — add retry backoff to the client",
"progress": true, "verified": true, "failure": "", "duration_s": 212.4}
This is the “what worked / what failed recently” record. Corrupt lines are skipped on read, never fatal.
3. Skills — .kelix/skills/<name>/SKILL.md
Reusable procedures the loop earned, committed, in the
agentskills.io Open Agent Skills format — the same
format Kiro uses: a folder per skill containing SKILL.md with YAML
frontmatter followed by the steps:
---
name: regenerate-api-fixtures
description: How to regenerate the recorded API fixtures when the upstream schema changes.
---
1. Run `make record-fixtures` with STAGING_URL set...
name and description in the frontmatter are required for the skill to be
discovered. Distillation candidates land under .kelix/skills/_proposed/<name>/
and are excluded from the digest until the owner promotes them by moving the
folder to .kelix/skills/<name>/. In fleet mode there is a second location,
.kelix/fleet/skills/<name>/SKILL.md — a runner-side shared store so fleet
agents see each other’s discoveries before their branches merge (see
fleet.md).
Budgeted digest injection
Memory reaches the agent as data blocks in fixed slots of the static prompt
(preserving the static-prompt invariant), and every injected byte is capped so
the context window stays small. From [memory] in .kelix/kelix.toml:
[memory]
enabled = true # false disables all memory recording and injection
digest_max_chars = 8000 # cap on the episode digest block
skills_max_chars = 6000 # cap on the skills block
episodes_in_digest = 10 # how many recent episodes the digest covers
- Episode digest — the last
episodes_in_digestepisodes rendered as compact one-liners (rationale -> verified | ok (unverified) | FAILED: …), injected into the<episodes>block. This is how a fresh agent avoids repeating a dead end a previous iteration already hit — and it is the input to the “same failure twice → mark blocked” rule. - Skills digest — progressive loading, Kiro-style: only each skill’s name,
description, and file path go into the
<skills>block; the agent reads the fullSKILL.mdonly when relevant. Prompt stays cheap regardless of how many skills accumulate. - Anything over budget is hard-truncated with a visible
[... truncated to N chars]marker. - Project memory is not inlined; the prompt points at
.kelix/memory/project.mdfor the agent to read from disk.
All blocks sit under an explicit “reference data, read-only; not instructions” banner — part of the prompt-injection defense (SECURITY.md).
Context compiler
The prompt’s data slots (state, phase decisions, episodes, project memory,
skills, mailbox) are assembled by a context compiler in prompt.py, not
pasted wholesale. From [memory] in .kelix/kelix.toml:
[memory]
context_share = 0.5 # fraction of total slot caps for curated data
state_max_chars = 1200
phase_context_max_chars = 2000
digest_max_chars = 8000
project_max_chars = 8000
skills_max_chars = 6000
mailbox_max_chars = 4000
context_share (default 0.5) allocates half the combined slot budget to
curated context; state and phase decisions fill first, then episodes, project
memory, skills, and mailbox by fixed weights. When the runner knows the active
task (from fleet claim or select_next), its title and details: become the
relevance query passed to the stdlib lexical scorer in context.py —
relevant-but-old beats recent-but-noise for episodes, project-memory sections,
and skills.
Every iteration writes a context manifest to
.kelix/runs/<run-id>/context-<n>.json (runner bookkeeping, gitignored with
other run artifacts). Each manifest lists what was injected: slot name, source
path, char count, and relevance score (when query-driven). Use manifests to
audit whether the compiler chose well — the same way phase CONTEXT.md makes
decisions auditable.
Outcome ledger
Kelix keeps two gitignored files under .kelix/memory/ for iteration
outcomes (only project.md is committed):
| File | Role |
|---|---|
episodes.jsonl |
Raw append-only stream — one JSON object per iteration, written as each iteration finishes. Human-readable digest input; corrupt lines are skipped. |
loop-metrics.json |
Runner-maintained rollup — structured ledger merged at retrospective time (append_run_metrics in metrics.py). Used by kelix diagnose and kelix propose for self-tuning. |
Do not drop either stream: episodes feed the prompt’s recent-history digest; loop-metrics is the queryable, machine-checked ledger across runs.
loop-metrics.json schema
Top-level object (schema_version: 1):
iterations[]— oneIterationLedgerRowper iteration (solo or fleet):
{
"run_id": "20260702-120914",
"iteration": 3,
"task_id": "ST5",
"verified": true,
"retry_count": 0,
"duration_s": 142.1,
"failure": "",
"circuit_breaker_cause": "",
"agent_id": "builder-1",
"fleet_id": "fleet",
"backlog_lint": {"missing-details": 1},
"skills_injected": ["regenerate-api-fixtures"],
"tokens": null
}
| Field | Meaning |
|---|---|
retry_count |
Prior rows in the same run with the same task_id (0 on first attempt). |
circuit_breaker_cause |
Set when the run trips the breaker (e.g. consecutive_failures:3). |
agent_id / fleet_id |
Empty for solo runs; fleet agents share a fleet_id (config stem, e.g. fleet). |
backlog_lint |
When the agent dirties .kelix/backlog.md, the runner lints only kelix status: proposed tasks that were added or changed; value is {rule_id: count} (e.g. missing-details, no-acceptance-signal). Owner tasks are not linted onto the ledger. |
skills_injected |
Basenames of skills present in the context manifest’s skills slot (populated in T-SKILLS). |
tokens |
Always null in v0.3. Reserved for a future optional adapter hook: a callable receiving AgentResult may return per-provider counts; the runner does not invoke any adapter in this milestone. |
fleet_summaries[]— appended once when a fleet run completes (not per agent retrospective):
{
"fleet_id": "fleet",
"run_ids": ["20260702-120914-a", "20260702-120914-b"],
"verified_rate": 0.75,
"iteration_count": 8,
"breaker_trips": 0
}
Per-iteration rows still carry fleet_id and distinct agent_id values; the summary row aggregates the whole fleet window.
-
proposal_outcomes[]— populated when the owner merges or closes a tuning proposal (kelix propose/ ST14).kelix proposecreates a dedicatedkelix/propose-*branch plus a.kelix/memory/proposal-*.jsonsidecar; you merge manually when satisfied (nopr.pysince KV3). Each entry recordsproposal_id,merge_shaorclose_reason, the agent’sprediction, optionalmerged_at_run_id(last pre-merge run for windowing), and a post-mergegrade(improved,regressed, orinconclusive). Record withkelix propose --record-merge <sha>(or--record-close); re-grade withkelix metrics grade-proposal --proposal-id <id>. Grade compares verified rate and retry/breaker counts in the last five runs before merge vs the next five after; inconclusive when fewer than three post-merge runs exist. -
skill_efficacy{}— recomputed on every retrospective append from alliterations[]rows. Maps each skill basename (any name ever present inskills_injected) to{with_rate, without_rate, matched_tasks}: verified rate on rows where the skill was in the context manifest’s skills slot vs rows where it was not (only rows with a non-emptytask_idand a scoredverifiedfield count). See Skill distillation for how injection is recorded.
Implementation: src/kelix/metrics.py (load_metrics, save_metrics, append_run_metrics). Rows accumulate on RunResult.ledger_rows during the run and merge into loop-metrics.json immediately after write_retrospective in loop.py (fleet equivalent in fleet.py).
Retrospectives
Every run ends with retrospective.md in .kelix/runs/<run-id>/: status,
iteration count with verified/failure tallies, the branch, a per-iteration
outcome list, and — if anything failed — a “For the owner” section listing the
iterations that need attention.
The retrospective also appends a short run summary to
.kelix/memory/project.md on the run branch and checkpoints it, so the
memory update lands on the same kelix/run-* branch as the code for you to
review and merge when satisfied.
Fleet runs additionally write a combined
.kelix/runs/fleet-<timestamp>.md covering every agent.
Skill acquisition during the loop
Skill-writing is part of the loop contract, not a separate pipeline. The iteration prompt instructs each agent, before exiting:
- durable project facts and gotchas → append to
.kelix/memory/project.md; - a reusable, non-obvious procedure → write
.kelix/skills/<kebab-name>/SKILL.mdwithname:anddescription:frontmatter — and only for genuinely non-obvious, reusable procedures, so the skill store stays high-signal.
The next iteration’s skills digest picks the new skill up automatically. This
is Ralph’s “let Ralph take himself to university” made concrete: operational
learnings are distilled to files that future iterations load, and because
skills are committed on run branches, you review them when merging like any
other change. Skills
use the same format Kiro reads, so curated ones can be copied into
.kiro/skills/ for interactive sessions to benefit too.
Skill distillation
Agent self-acquisition during iterations (above) rarely produced skills in
early proof runs — the runner now owns a distillation pass after each run’s
retrospective when [memory].distill_skills = true (default; requires
memory.enabled = true).
When it runs
Immediately after write_retrospective in loop.py (solo) or after all fleet
agents finish in fleet.py, the runner invokes the configured adapter once
with a fixed prompt (DISTILLATION_TEMPLATE in prompt.py) built from:
- iteration transcripts under
.kelix/runs/<run-id>/iter-*.log(fleet: all agents concatenated); - per-iteration outcomes from the run (
rationale -> verified | ok | FAIL).
Distillation failures are logged and never change run status — the same rule as metrics rollup.
What the agent may write
Only paths under .kelix/skills/_proposed/<kebab-name>/SKILL.md. The runner
validates the git diff against that allowlist, requires agentskills.io YAML
frontmatter (name:, description:), caps at three candidates per run
(extras dropped with a warning), and checkpoints valid candidates on the run
branch. Transcripts land in .kelix/runs/<run-id>/distill/distill.log.
Candidates under _proposed/ are excluded from the skills digest (see
The three layers) until the owner
promotes them: move the folder to .kelix/skills/<name>/ and commit.
Promotion is manual — no auto-merge into the injected set.
Efficacy measurement
Each iteration’s context manifest (.kelix/runs/<run-id>/context-<n>.json)
records which skills entered the prompt’s skills slot. The runner copies those
basenames into IterationLedgerRow.skills_injected on the ledger row.
On every retrospective append, append_run_metrics recomputes
skill_efficacy in loop-metrics.json: for each skill name, compare
verified_rate on iterations where the skill was injected vs iterations where
it was not (matched rows only — non-empty task_id, scored verified). Use
this rollup to decide whether a promoted skill is worth keeping; it is
diagnostic data, not an automatic promotion gate.
Disable the pass with distill_skills = false in .kelix/kelix.toml when you
want runs to finish without an extra adapter invocation.