Agents · 2026-05-22 · By RedDB team · 5 min read
Slash commands with memory — commands that learn between sessions
A Claude Code slash command is a markdown file with a shell expansion. Wire that shell call to RedDB and the command stops being a stateless macro — /remember, /forget, /recall become a tiny CRUD app the agent uses for itself.
Stateless by default
A Claude Code slash command is, mechanically, a markdown file in ~/.claude/commands/. When the user types /foo, the harness loads foo.md, expands any !shell lines, substitutes $ARGUMENTS, and feeds the result to the model as the next user turn. That is the whole feature.
Useful, but the surface is intentionally thin: a command is a function call, not an object with state. Two consequences:
- Anything the command “knows” has to be encoded in its prompt or computed by its shell expansion every time it runs.
- Two invocations of the same command, ten minutes apart, share nothing. The harness has no slot for command-local state.
For commands that do one thing — /init, /test, /review — the statelessness is fine. For commands that should accumulate context across a project’s lifetime — anything in the “memory” family — it is the wrong default. A /remember that forgets between sessions is just a comment.
The previous post in this pillar introduced a memory table and a SessionStart hook that reads top-K rows into context on boot. This post finishes the picture: three slash commands — /remember, /recall, /forget — that turn that table into something the agent can write, query, and prune from inside a turn.
The schema, recapped
Same table as before. Repeated here so this post stands alone:
CREATE TABLE memory (
id TEXT PRIMARY KEY, -- ulid
kind TEXT NOT NULL, -- user | feedback | project | reference
scope TEXT NOT NULL, -- global | <project-path> | <session-id>
body TEXT NOT NULL,
embedding VECTOR(1024) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ -- soft delete; /forget never DROPs
);
CREATE INDEX memory_scope_idx ON memory (scope, created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX memory_embedding_idx ON memory USING hnsw (embedding vector_cosine_ops) WHERE deleted_at IS NULL; Two changes from the D1 post:
deleted_atis nullable and exclusive in the indexes./forgetflips the column; it does notDELETE. Memory is auditable; deletions are recoverable.- The indexes are partial. A pruned row stops costing query time the moment it is forgotten.
/remember — the write
The D1 post showed a minimal version. The production-shape one is below. Drop at ~/.claude/commands/remember.md:
---
description: Persist a fact, preference, or correction into long-term memory
argument-hint: [--kind=feedback] [--scope=project|global] <body...>
---
Save the following to memory. Parse flags from `$ARGUMENTS`; default `kind=user`, `scope=project` (the current working directory).
After the write succeeds, echo back the id and kind so the user has a handle for `/forget`.
!set -euo pipefail; \
body="$ARGUMENTS"; \
kind="$(printf '%s' "$body" | grep -oP -- '--kind=\K\S+' || echo user)"; \
scope="$(printf '%s' "$body" | grep -oP -- '--scope=\K\S+' || echo "$PWD")"; \
clean="$(printf '%s' "$body" | sed -E 's/--(kind|scope)=\S+ ?//g')"; \
curl -sS --fail --max-time 3 \
-H "Authorization: Bearer $REDDB_TOKEN" \
-H "Content-Type: application/json" \
"$REDDB_URL/memory/insert" \
-d "$(jq -nc \
--arg kind "$kind" \
--arg scope "$scope" \
--arg body "$clean" \
'{kind:$kind, scope:$scope, body:$body}')" The expansion shells out, the API computes the embedding server-side, the row commits, and the JSON comes back as part of the user turn the model sees. The model then narrates the success (“Saved as mem_01H7… (kind=feedback)”) because the prompt told it to.
The reason /remember is one curl call and not three is the same reason MEMORY.md lost: a database is transactional. Insert + embed + index either all commit or none of them do. There is no failure mode where the row is present but unembeddable.
/recall — the read
/recall <query> is the symmetric command: turn a natural-language question into a vector search, return the top-K body strings, and let the model integrate them.
~/.claude/commands/recall.md:
---
description: Search long-term memory for facts relevant to the current task
argument-hint: <natural-language query>
---
The user wants you to consult prior memory. The block below is the top 5 results by semantic similarity, ordered most relevant first. Treat them as established context for the rest of this turn; if any contradicts the user's current message, mention the conflict before acting.
!curl -sS --fail --max-time 2 \
-H "Authorization: Bearer $REDDB_TOKEN" \
-H "Content-Type: application/json" \
"$REDDB_URL/memory/search" \
-d "$(jq -nc --arg q "$ARGUMENTS" --arg scope "$PWD" \
'{query:$q, scope:[$scope,"global"], top_k:5}')" The server-side SQL is the obvious thing:
SELECT id, kind, body, 1 - (embedding <=> embed($1)) AS score
FROM memory
WHERE deleted_at IS NULL
AND scope = ANY($2)
ORDER BY embedding <=> embed($1)
LIMIT $3; Two design notes worth lingering on:
scope = ANY($2)makes the query naturally federated. The command passes[$PWD, "global"], so project-local memories and user-global memories are pooled into one ranked list. Cross-project memory works without extra commands.- The prompt tells the model to surface contradictions.
/recallis not just retrieval; it is retrieval the model is expected to reason about. A memory that says “we never use ORM migrations on this repo” should make the model push back when the user asks for a migration script, not silently obey.
/forget — the prune
Two shapes, depending on how the user invokes it:
/forget <id>— soft-delete a specific row by id, used after/rememberreturnsmem_01H7…./forget <query>— semantic forget. Find the top match, show it to the user for confirmation, only then flipdeleted_at.
~/.claude/commands/forget.md:
---
description: Remove a memory by id, or find one by description and confirm before pruning
argument-hint: <id | natural-language description>
---
If `$ARGUMENTS` looks like a ulid (matches `^mem_[0-9A-Z]{26}$`), soft-delete it directly and report the id back.
Otherwise, search for the closest matching memory and show the user its body and id; do not delete until the user confirms with "yes" or "forget it".
!arg="$ARGUMENTS"; \
if [[ "$arg" =~ ^mem_[0-9A-Z]{26}$ ]]; then \
curl -sS --fail --max-time 2 \
-H "Authorization: Bearer $REDDB_TOKEN" \
-X POST "$REDDB_URL/memory/$arg/forget"; \
else \
curl -sS --fail --max-time 2 \
-H "Authorization: Bearer $REDDB_TOKEN" \
-H "Content-Type: application/json" \
"$REDDB_URL/memory/search" \
-d "$(jq -nc --arg q "$arg" --arg scope "$PWD" \
'{query:$q, scope:[$scope,"global"], top_k:1}')"; \
fi The two-step “find, confirm, delete” flow is the part that matters. A vector search is fuzzy by design; deleting on the first match would let /forget vue preferences quietly remove a feedback row about React preferences whose embedding sat slightly closer to the query than the actual Vue one. Putting the model in the loop costs one round-trip and prevents the failure entirely.
The soft-delete itself is a one-line update on the API side:
UPDATE memory
SET deleted_at = now()
WHERE id = $1 AND deleted_at IS NULL
RETURNING id, kind, body; RETURNING is there so the command can echo what it pruned. If you ever want an /unforget, the same row is still there — just flip deleted_at back to NULL.
State across sessions
The three commands share nothing in the harness. They share the memory table. That is what gives them coherence: writing /remember … 'use pnpm, never npm' in one session means a SessionStart hook in the next session pulls the row into the system reminder, and the model will reach for pnpm without being told twice.
The lifecycle, as a sequence:
- Session A: user runs
/remember --kind=feedback 'use pnpm, never npm'. Row commits. - Session A ends.
- Session B boots. The
SessionStarthook from D1 queriesmemoryfor top-K by scope, gets the pnpm row, injects it as context. - User in session B asks the model to install a dependency. Model uses
pnpm. - Session C, six weeks later: the rule has stopped applying. User runs
/forget pnpm preference./forgetfinds the row, the model shows it, the user confirms. Row’sdeleted_atflips. - Session D: the row is no longer in the partial indexes; the model has no record of it.
Each step is independent, each step is durable, and the only thing that crosses the boundary between sessions is the table.
What this buys, in one table
| Behaviour | Stateless command | Command + memory table |
|---|---|---|
| Persist a fact from a turn | impossible | /remember |
| Read prior facts in a future turn | re-read MEMORY.md blindly | /recall ranks by similarity |
| Prune a stale fact | edit a markdown file by hand | /forget with confirmation |
| Cross-project recall | symlink markdown files | scope=ANY([project, "global"]) |
| Audit who/when/why | git blame if committed | created_at + deleted_at are columns |
The three command files together are about 40 lines of markdown. The SQL is four statements. The state is one table. Compared to the workaround of “edit a markdown file and hope the model re-reads it,” it is hard to overstate how much smaller the surface gets when the slash command is a UI over a database instead of a macro.
What is next in this pillar
- An MCP server that exposes
memory.write/memory.search/memory.forgetas MCP tools, so every MCP-compatible CLI (Cursor, Codex, Gemini CLI) writes into the same table Claude Code’s slash commands do. - A tutorial on building a full
remember-thisskill that captures interesting conversation snippets automatically — same table, different write path. - The context-window-economics post: how much does swapping
MEMORY.mdfor a/recallactually save, per turn, in tokens and dollars?
If you want to be told when those land, grab the Atom feed.
RedDB Cloud
Join the private beta.
One-click managed deploy. Free during beta. Founding pricing locked at GA.
Request access →