Docs / Memory · docs/MEMORY.md
Memory
relay's memory is what lets a decision made in a Codex session show up in the next Claude or Grok step without anyone re-explaining it. It is small on purpose: decisions, preferences, vendor guidance, and a generated rules file. No transcripts are stored and none are ever replayed into a model.
What is remembered
Decisions, with validity
A decision is one sentence of the form "we do X because Y", tied to a project (or global). Each row in decisions
has valid_from and valid_to: a decision is current while valid_to is null. When a later decision contradicts
it, the old row gets valid_to = now() and superseded_by = <new id>; nothing is deleted, so you can always ask
"what did we believe on the 3rd". Recall only returns current decisions unless asked for history.
Sources (decisions.source): claude, codex, grok (captured by the session-end hook), pr (extracted from a
merged PR's description and review thread), human (typed through the CLI, the MCP tools or the web app), nightly
(produced by consolidation). source_ref keeps the session id or PR URL so a decision can be traced.
Preferences, with scopes
A preference is a key/value such as test_runner = vitest or commit_style = imperative, one topic. Scopes, from
widest to narrowest: global, project (scope_id = project slug), lane (scope_id = claude|codex|grok),
model (scope_id = model id). Narrower scopes override wider ones when a brief is assembled
(mergedPreferences in packages/core/src/brief/), so you can say "globally, prefer small PRs; in the grok lane,
never touch migrations". Lane preferences are merged only when the caller names a lane (hooks pass the CLI they run
in; relay brief --lane).
Vendor guidance, behind a gate
For models you prompt rather than code with (image, video, speech), relay ingests the vendor's official docs and
published skills into guidance and skills, chunked and embedded. The guidance gate (assertOnboarded) means
a lane cannot use a model until pnpm relay onboard <provider/model> has run and the sources are fresher than the
model's stale_days. Only domains allowlisted in packages/core/src/onboard/registry.ts are accepted; community
prompt guides are excluded unless you allowlist a domain yourself. onboard.refreshed events record every ingest.
Projects and digests
projects holds the repo, its stack note and check command, and a 2K-token summary. file_digests keeps one
summary per file, keyed by content hash, re-summarized only when the hash changes. Digests make briefs cheap:
a brief names the files that matter instead of pasting them.
The brief compiler
compileBrief(db, {project, issue?, intent, model?, lane?, maxTokens}) in packages/core/src/brief/index.ts is the
one place an envelope is assembled. It returns {text, sections: [{name, tokens}], tokens, omitted} and holds a hard
cap:
| Section | Content | Atomicity |
|---|---|---|
summary |
the project's ≤2K-token summary | whole or omitted |
preferences |
merged preferences for the project (and lane / model when given), rendered as hard rules | whole or omitted |
decisions |
current decisions matching the issue text and intent (searchDecisions, vector when AI_GATEWAY_API_KEY is set, else full text), topped up with the most recent and a few global ones |
whole items until the budget; omitted when none fits |
guidance |
vendor guidance for model matching the query (vectorSearchGuidance or textSearchGuidance) |
whole chunks until the budget; omitted when none fits |
Sections are allocated in the order preferences, decisions, summary, guidance (binding and cheap first, the big
summary after, guidance last) and rendered in the order summary, preferences, decisions, guidance so every brief
reads the same way. Nothing is ever cut mid-sentence; a section that does not fit is reported in omitted. Tokens
are the ≈4-chars/token estimate from approxTokens; the ledger records real counts.
How recall reaches a model
Nothing reads memory inside the model loop. Memory is injected at two edges and captured at a third:
Brief time (runners).
scripts/runner/run-lane.shcallsrelay brief <issue>when the relay CLI is installed on the runner and appends its output to the brief under a## Memoryheading.relay brieffetches the issue throughghwhen it can, compiles the envelope under a 3K-token cap (--max-tokens), and prints it. Without the CLI the section is skipped and the lane still runs.Session start and every prompt (hooks).
pnpm relay setupinstalls hooks into each CLI it finds:CLI File Events installed Injects Claude Code ~/.claude/settings.json(hooks),~/.claude.json(user-scopemcpServers.relay)SessionStart,UserPromptSubmit,SessionEndbrief ≤2.5K tokens; recall ≤1K tokens; capture Codex ~/.codex/config.toml, one block between# relay:beginand# relay:end:[mcp_servers.relay],[[hooks.SessionStart]],[[hooks.SessionEnd]]SessionStart,SessionEnd(Codex has noUserPromptSubmit; therelay_recalltool covers it)brief ≤2.5K tokens; capture Grok Build ~/.grok/hooks/relay.json(relay owns the whole file),~/.grok/settings.json(mcpServers.relay)SessionStart,UserPromptSubmit,Stopbrief ≤2.5K tokens; recall ≤1K tokens; capture The hooks are
packages/cli/hooks/{session-start,user-prompt,session-end}.mjs --cli <name>: dependency-free Node scripts that read the hook payload on stdin, look for.relay/rules.jsonabove the cwd (so an unlinked repo gets nothing and costs nothing), and call the relay CLI through the repo's owntsx.session-startkeeps the brief per project and lane for ten minutes under~/.harness/cache/brief/and answers from the cache without starting the CLI (≈30 ms); a cold start is ≈0.5 s.user-promptskips prompts shorter than 20 characters and slash commands.relay decideandrelay preferdrop the project's cache so a new decision reaches the next session. The budgets are fixed in the scripts (2.5K / 1K), not preferences.Session end (hooks).
session-endstartsrelay capture --transcript <path>detached, so the session never waits on a model call, and logs to~/.harness/cache/capture.log. Capture parses the transcript (Claude Code and Grok JSONL, Codex rollouts, or plain text), splits it into ≈2.5K-token segments on turn boundaries, gates each segment with a cheap rule (decision-flavoured language) or with the command incapture.gateCommandof~/.harness/config.json/RELAY_CAPTURE_GATE(Jev plugs in here: the segment arrives on stdin, exit 0 plus anything butnopasses), and asks the Sonnet lane (runClaudewithtools: [], structured output) for the durable decisions in the segments that passed, with the project's current decisions in the prompt so it can returnsupersedes_id. New rows getsource = <cli>andsource_ref = <session id>; each one emits adecision.capturedevent and the Sonnet call is astep.usagerow. A byte offset per transcript in~/.harness/cache/capture-state.jsonmakes per-turnStophooks and re-runs incremental. The transcript is never stored and never sent whole.
Cloud agents and the web app reach the same data through the memory MCP server (packages/mcp): the relay-mcp
stdio binary for local CLIs (dist/relay-mcp.js in the published @kyleroche/relay; packages/mcp/bin/relay-mcp.mjs
run through the repo's tsx in a checkout; either way with the node that ran relay setup), and createMcpHandler(db, {token, publicRead?}) for a Next.js route at /api/memory
(Streamable HTTP, stateless; RELAY_MEMORY_TOKEN as the bearer enables relay_decide and relay_prefer, reads
are open only when publicRead is set). Tools: relay_recall(query, project?), relay_decide(text, project, source?, supersedes_id?), relay_prefer(key, value, scope, scope_id?, reason?), relay_guidance(model, query),
relay_brief(project, intent), relay_feed(project?, limit?), relay_project(slug). Every result is text capped at
3K tokens.
The rules file and the owned section
relay link is the one per-repo step and runs the whole pipeline, printing a line per stage:
- owned sections in
AGENTS.md/CLAUDE.md(below), so a createdAGENTS.mdis hashed in the same run; - register the project and hash every text file (
scanFiles;relay.mditself is skipped); - digest the files whose hash changed (
digestProject: Sonnet lane, no tools, 8 files per call, at most--max-files= 400 per run); an entry under 10 words is dropped and re-requested once with a stricter prompt; - write the project summary (
summarizeProject) when it is missing, when files were digested, or on--refresh. The summary comes back through--json-schemaas{summary}, must be 150-700 words and carry the headings Purpose, Stack and layout, Commands, Conventions and Gotchas; a rejected answer is re-requested once, a second rejection fails the stage (the old summary is kept) and link exits non-zero; - render
relay.mdfrom the real summary (rewritten whenever its content changed); - caller workflows and GitHub labels (
--no-workflows,--no-labels); - one cost line read back from the ledger: calls, tokens in/out, cost equivalent.
--no-digest skips steps 3 and 4. A second run with nothing changed makes zero model calls. The utility calls run
claude -p with no tools, no skills, no instruction files or auto-memory, in a scratch directory
(lanes/claude.ts), so the model only sees the prompt. RELAY_FAKE_LANES=1 makes the lane return canned JSON
for tests.
Each linked repo gets a generated relay.md (renderRulesFile, ≤2K tokens): a header that says how relay works in
this repo (labels, relay/<lane>-<issue> branches, cross-model review) and tells the model to use the memory
tools, the hard rules from preferences, the top current decisions, and as many whole paragraphs of the project
summary as still fit. run-lane.sh puts relay.md at the top of every brief.
So that every CLI reads it without being told, relay link adds a short owned section to AGENTS.md (and
CLAUDE.md when it exists and is more than an @AGENTS.md include) between two markers:
<!-- relay:begin -->
This repository is managed with relay (project `demo`). Read `relay.md` before making changes; it holds the current
decisions and preferences for this project.
Issues labelled `agent:claude`, `agent:codex` or `agent:grok` are implemented by lanes on self-hosted runners that
open pull requests from `relay/<lane>-<issue>` branches.
Memory tools (MCP server `relay`): `relay_recall`, `relay_decide`, `relay_prefer`, `relay_guidance`, `relay_brief`,
`relay_feed`, `relay_project`. Call `relay_recall` before choosing an approach and `relay_decide` when you settle one.
<!-- relay:end -->
Rules for the owned section (packages/core/src/rules/owned-section.ts):
- relay rewrites only the text between
<!-- relay:begin -->and<!-- relay:end -->. Everything else in the file is yours and is never touched. - If
AGENTS.mdis missing it is created; if the markers are missing, the section is appended at the end. .relay/rules.jsonin the repo records the project slug, the repo, and a hash of what relay last wrote per file. If the text inside the markers no longer matches that hash (you edited it), or markers exist that relay never wrote,relay linkrefuses to overwrite and says so;relay link --forcetakes the section over.- If you delete the markers, relay stops writing to that file (
checkOwnedSectionreports it). relay linkre-rendersrelay.mdwhenever memory changed;--refreshalso rewrites the project summary and the caller workflows. The post-merge job that extracts decisions and opens arelay/rules-<date>PR is not built yet; until it is, runrelay linkafter memory changes.relay briefbudgets the project summary first, so a linked project's brief always carries it when one exists; when the project has no summary it says so and points atrelay link --refresh. In--hookmode errors are never printed; they go to~/.harness/cache/hooks.log.
Consolidation
A nightly job (memory.consolidated events) merges duplicate decisions, closes the validity of decisions
contradicted by newer ones, and drops preferences whose scope target no longer exists. It runs on the web app's
schedule, not on your machines, and it uses the Gateway only if AI_GATEWAY_API_KEY is set; otherwise it does the
exact-match merges only. (Not built yet; capture already dedupes exact matches and records supersessions.)
Site mode
A machine does not have to hold the database. relay setup --site https://relay.example.com stores the site URL in
~/.harness/config.json (site) and the credential in ~/.harness/credentials.json (mode 0600; RELAY_SITE and
RELAY_TOKEN override both). Without --token it runs the device flow: it prints a code like RLAY-4F7Q, you approve
it at <site>/activate (or a bot approves it over the API), and the CLI stores the 24-hour access token with its
90-day refresh token and renews it before it lapses. --invite <code> redeems an invite from relay invite create
with no approval; --token <t> pastes a token from /tokens. relay setup checks the site answers and accepts the
credential before saving (--yes saves anyway). docs/INTEGRATIONS.md has the flows.
With a site configured, openHarness() hands every command a RelayClient over HTTPS (packages/core/src/client/,
httpClient) instead of one over the database (localClient): relay feed, relay usage, relay recall,
relay brief, relay decide, relay prefer, relay memory history, relay token, relay invite, relay task,
relay webhook and the session hooks (which call these commands) talk to /api/v1 and the memory tools on /mcp. relay link still
hashes and digests files on the machine, with its own Claude login, and uploads the project, digests, summary and the
step.usage rows the calls produced (PUT /api/v1/projects/<slug>, /digests, /summary; scope projects:write).
relay doctor reports mode, auth (how the machine got its token, its name and expiry), site and whoami.
Without a site nothing changes: the CLI opens PGlite or DATABASE_URL.
Not available in site mode (they need the database on the machine): relay capture (the session-end hook exits
quietly; use relay decide), relay decide --global, relay sql, relay init, relay onboard, relay models,
relay project *, relay asset *, and the stdio MCP server, which always serves the local database. They fail with
one line saying so. relay setup --no-site returns to local mode.
Working with memory by hand
pnpm relay setup [--yes] [--only claude|codex|grok] [--remove] # hooks + MCP server per CLI; prints the plan, writes with --yes
pnpm relay setup --site <url> [--invite <code> | --token <t>] | --no-site # site mode: device flow (approve the code at /activate), invite, or a pasted token
pnpm relay invite create --for <name> --scopes a,b [--expires 24h] [--uses 1] # one-time code another machine redeems with --invite
pnpm relay link [--repo owner/name] [--refresh] [--force] [--no-digest] [--max-files 400] # the per-repo step: digests, summary, relay.md, owned sections, workflows, labels
pnpm relay brief <issue-number | intent words> [--max-tokens 3000] [--model <id>] [--lane <name>]
pnpm relay recall [text] [--project <slug>] # what a brief would contain right now
pnpm relay decide "<text>" [--project <slug> | --global] [--supersedes <id>]
pnpm relay prefer <key> <value> --scope lane --id grok
pnpm relay capture --transcript <path> [--source claude|codex|grok] [--dry-run] [--all]
pnpm relay memory history [--project <slug>] [--current] # decisions including superseded ones
pnpm relay task create --title <t> --lane claude|codex|grok [--priority <p>] [--key <k>] # open a task (GitHub issue with the lane label)
pnpm relay task get <owner/repo#n> | relay task ls [--state review] # derived state from the feed
pnpm relay webhook add --url <url> --events pr.merged,run.failed [--bearer <k>] | ls | rm <id> | test <id>
relay decide writes a decision.captured event, relay prefer a preference.set event, relay link a
project.linked event, so the web app shows who changed memory and when. Commands resolve the project from the cwd:
--project wins, then .relay/rules.json, then the origin remote's repo name. RELAY_HOME relocates ~/.harness
(database, cache, config); RELAY_USER_HOME relocates where relay setup looks for ~/.claude, ~/.codex and
~/.grok (tests use both).
Data model and query modules
Data model
Schema lives in packages/core/src/db/migrations/NNN_name.sql, applied in order by relay init and tracked in
meta.schema_version. Shipped migrations are never edited; add a new file.
| Table | Holds | Key |
|---|---|---|
projects |
a registered repo: path, stack, check command, the ≤2K-token summary every brief starts with | slug |
file_digests |
per-file summary keyed by content hash; stale while summary_sha <> sha256 |
(project, path) |
decisions |
durable decisions with a validity window (valid_to null = current, superseded_by links the replacement), embedding + full-text |
id |
preferences |
key/value settings by scope (global, project, lane, model) |
(key, scope, scope_id) |
providers, models |
the onboarding registry: allowlisted doc domains, lanes, onboarded_at, counts |
slug, id |
sources |
every official page or skill archive fetched, with sha256/etag so unchanged pages are skipped | url |
guidance |
chunked vendor guidance per model with kind, tokens, embedding (1536) and a generated tsv |
(model, id) — one source may guide several models |
skills |
provider-published skills installed under ~/.harness/skills |
(name, model) |
events |
the append-only feed: who did what, with tokens and cost; dedupe_key makes webhook writes idempotent |
id |
tokens |
bearer tokens for the site: name, scopes, SHA-256 of the plaintext, revocation | id |
tasks |
a GitHub issue relay opened or tracks as a task; state is derived from events (docs/INTEGRATIONS.md) |
id = owner/repo#n |
webhooks, webhook_deliveries |
outbound hooks (url, events, project, secret, bearer) and one row per delivery attempt set (docs/FEED.md) |
id |
ledger (view) |
events WHERE kind = 'step.usage'; relay usage groups it by subscription |
— |
Full-text search uses websearch_to_tsquery('english', …) against the tsv columns; vector search uses the
pgvector cosine operator <=> on embedding. Embeddings are optional (AI_GATEWAY_API_KEY); without them
retrieval falls back to full text.
Query modules
packages/core/src/db/ exposes typed functions, one module per area, all taking the Db first:
projects.ts:upsertProject,getProject,listProjects,setProjectSummary,upsertFileDigests,removeMissingFileDigests,listFileDigests,setFileSummariesguidance.ts: providers, models, sources, guidance chunks (replaceGuidance,pruneGuidance,setEmbeddings,vectorSearchGuidance,textSearchGuidance), skillsmemory.ts:addDecision,supersedeDecision,listDecisions,searchDecisions,upsertPreference,listPreferencesevents.ts:writeEvent,listEvents,usageSummarytokens.ts:createToken,findToken,listTokens,revokeToken,TOKEN_SCOPEStasks.ts:createTask,getTask,listTasks,taskState/deriveTaskStatus,listTaskViewswebhooks.ts:createWebhook,listWebhooks,matchingWebhooks,deleteWebhook, the delivery log
repo.ts re-exports all of them as one namespace (repo.getModel(h.db, id)). The Ledger class in
ledger/ledger.ts is a thin adapter that writes step.usage events and holds the Gateway list-price table.
Above the query layer: brief/ (compileBrief, renderRulesFile, mergedPreferences), rules/owned-section.ts,
hooks/ (per-CLI installers, planSetup/applyPlan, the brief cache), memory/capture.ts (captureDecisions,
parseTranscript, the gates), tasks/create.ts (openTask), github/client.ts (issues over an env token or gh),
webhooks/dispatch.ts (signing, delivery, fan-out), client/ (RelayClient: localClient, httpClient, site config).
Configuration
DATABASE_URL(env, optional): use Postgres over TCP instead of the embedded database.AI_GATEWAY_API_KEY(env, optional): enables embeddings for guidance and decisions.RELAY_REPO,RELAY_REF(env, optional) orrelay.repo/relay.refin~/.harness/config.json: whatrelay linksubstitutes for__RELAY_REPO__/__RELAY_REF__in the caller workflows (default<owner>/relay@main).RELAY_CAPTURE_GATE(env, optional) orcapture.gateCommandin~/.harness/config.json: the segment gate command.RELAY_SITE,RELAY_TOKEN(env, optional) orsitein~/.harness/config.jsonplustokenin~/.harness/credentials.json(0600): site mode, written byrelay setup --site.~/.harness/config.json:staleDays,gateway.embeddingModel,site. No secrets are ever written there; the site token lives incredentials.jsonnext to it.
relay doctor reports database reachability and schema version alongside the CLI login checks.