Skip to content

Cross-session memory

OpenSwitchboard can carry distilled knowledge across threads: before a model turn it retrieves records scoped to the org and to the requesting user and injects them as a dedicated advisory context block, so a run benefits from what earlier runs learned without re-reading their transcripts. The whole path is flag-gated and OFF by default — with memory off the model input is byte-identical to a build without this feature.

This is delivered in stages. PR1 shipped the seam + the READ path; PR2 the WRITE path (a post-reply reflection pass that distills a finished run into records); PR3 the DURABLE store — a Memory Worker (SQLite Durable Object per scope) behind an HTTPS client, so memory survives bot restarts; PR B the USER scope — each person accumulates a personal knowledge base from their own runs that only they ever see. Then the REPO and CHANNEL scopes — knowledge specific to a repository or a channel lives with it and is shared by everyone who works there. Human controls (memory list / memory forget) and the per-scope cap (§27–29) complete it.

  • Code: src/core/memory/ (types.ts, scorer.ts, engine.ts, scope.ts, stores.ts, workerStore.ts, buildStore.ts, reflection.ts, index.ts), wired in src/core/dispatch/provision.ts (startMemoryRead: retrieve → inject before the model turn) and src/core/dispatch/reply.ts (afterReply: the reflection pass after the reply); scheduleReflection after the reply), store selection + drain in src/index.ts, config in src/config.ts; the Memory Worker in deploy/cloudflare-memory/ (worker.ts, wrangler.jsonc)
  • Docs: AGENTS.md invariants 1, 2, 4, 6, 7, 0017 (why memory is off by default), Deploy
  • Tests: src/core/memory/scorer.test.ts, engine.test.ts, stores.test.ts, workerStore.test.ts, buildStore.test.ts, scope.test.ts, memory.test.ts, reflection.test.ts, src/core/dispatcher.test.ts; deploy/cloudflare-memory/worker.test.ts (runs inside workerd)

Behavior (PR1 — seam + read path)

  1. Flag-gated, off by default. memory.enabled defaults false. When off (or the memory section is absent) the dispatcher selects a NullMemoryStore, whose retrieve returns [] and write is a no-op. No block is built, so the request sent to the provider — system, messages, tools, budgets — is byte-identical to memory-off. This is the load-bearing guarantee.
  2. The seam. The core depends only on the MemoryStore interface (retrieve({scopeKey, query, limit}), write(scopeKey, candidates)), never a concrete store (AGENTS.md invariant 1). Three implementations ship (invariant 2): NullMemoryStore (the disabled default), InMemoryMemoryStore (a Map<scopeKey, MemoryRecord[]>, for tests/dev), and the durable WorkerMemoryStore (an HTTPS client to the Memory Worker, §15). The ranking and write rules are not per-store: they live in the pure engine.ts (rankRecords, planWrite, mintRecord), which the in-process store calls directly and the Worker's Durable Object bundles by relative import — one algorithm, two persistence layers.
  3. Record shape. Distilled records only (never raw transcripts), each keeping provenance (sourceThreadKey/sourceRunId) so a fact can be re-grounded in the run that produced it: id, scopeKey, kind (fact|summary), text, keywords, provenance, createdAt, lastUsedAt, useCount, confidence?, supersedes?, status (active|superseded).
  4. Scope. Pure derivers map request identity → opaque, platform-namespaced keys (AGENTS.md invariant 4): orgorg:<organization> (the config's organization — the installation's one shared resource; the code names no org), useruser:<userId> (e.g. user:slack:U0123), reporepo:<owner/name> (the run's bound repo), channelchannel:<channelId> (e.g. channel:slack:C0123) — §21. The store partitions rows by key and is otherwise scope-agnostic — adding the user, repo, and channel scopes were deriver changes, not schema changes (the Memory Worker was untouched).
  5. Retrieval scoring. Pure function: score = α·keywordMatch + β·recency (α=0.7, β=0.3), where keywordMatch is the fraction of the query's word-tokens present in the record (keywords ∪ text tokens; whole-token, not substring — a substring match lets a one-letter token pollute retrieval) and recency is exponential decay over lastUsedAt ?? createdAt (τ ≈ 1 week). Decay lives in the score, so stale records sink without a sweeper. retrieve returns only records the query actually matches (relevance gate), ranked, and bumps lastUsedAt/useCount on the ones it returns.
  6. Hard budget. At most memory.limit records (default 8) and memory.maxTokens (default ~800, estimated at ~4 chars/token) are injected, regardless of store size — context never bloats. Each record is costed at its rendered bullet — the sanitized/escaped text plus the - … (source: …) provenance boilerplate, not the raw text — so the <&lt; escape overhead is counted and the estimate is a real upper bound on the injected records' size. The first ranked record is always kept so a single large record is never silently dropped.
  7. Injection. In dispatch(), before buildMessages, the dispatcher retrieves and renders the block, then folds it onto the front of the system prompt — a dedicated context segment ahead of the agent's own instructions, never mixed into history. The block leads with the exact line Background memory for <resource> (may be outdated — verify before acting):, then a <background_memory></background_memory> fence around one provenance-tagged bullet per record. The advisory "may be outdated — verify" framing is a deliberate anti-poisoning guard: memory is context, never instructions. Containment is enforced structurally too: both record fields (text, sourceThreadKey) pass through sanitizeMemoryField, which strips control characters and newlines/line-separators, collapses whitespace, and escapes &&amp; (first, so the escaping is complete and unambiguous — a literal &lt; becomes &amp;lt; rather than rendering identically to an escaped <), then <&lt; / >&gt; — so a poisoned record can never inject extra lines, forge a fake SYSTEM:/Human: turn, or emit the <background_memory>/</background_memory> fence delimiter (or any other tag), even inline within a bullet. The fence lines themselves are written by the renderer directly, not through the sanitizer, so they stay real. When no block is produced (memory off, or nothing matched) system is left exactly as it was.
  8. Write. MemoryStore.write(scopeKey, candidates) is implemented by both stores and driven by the reflection pass below. NullMemoryStore no-ops. InMemoryMemoryStore: dedup — a candidate whose normalized text (trim, lowercase, collapse whitespace) equals an active record's bumps that record's useCount instead of inserting (a superseded record is never a dedup target, so re-asserting an old fact after supersession creates a fresh active record); supersede — a candidate carrying supersedes: <id> flips that record to status: "superseded" if it is an active record in the same scope (soft delete: provenance preserved, the cheap version of a bi-temporal invalidation), then inserts the new record with the pointer. An unknown or foreign-scope id supersedes nothing; the new record still lands. A candidate carrying supersedes dedups only against its own target (and against nothing when the id doesn't resolve — it is inserted unconditionally): restating the superseded record's text is a no-op (target stays active, useCount bumped), but a text collision with some unrelated record never swallows the correction — the extractor sees existing record text verbatim, so such a collision could be induced by a poisoned transcript to keep a stale record alive.

Behavior (PR2 — write path: post-run reflection)

  1. When. In dispatch(), after sendAnswer (the reply has landed) the dispatcher calls scheduleReflection, which returns synchronously. The actual reflection is fire-and-forget: its promise is tracked (trackReflection) for the shutdown drain and never awaited by the dispatch, so reflection latency and failures cannot delay or alter the user reply — a reflection that throws produces a [memory] warning on stderr and nothing else. src/index.ts's drain waits on activeRunCount() + pendingReflectionCount() (same 15-minute deadline) so a restart doesn't drop a distillation mid-flight. A dispatch holds its activeRuns slot until its post-run steps (reply, review post, reflection scheduling) have run — released in dispatch()'s outer finally — so the drain can never observe "0 runs, 0 reflections" in the window between the run loop ending and the reflection being scheduled.
  2. Gate: only runs that did real work — and never a review. shouldReflect({toolCalls, historyTurns, agentName}) is true when the run made ≥1 tool call (counted from the run-event stream) or the thread already had ≥REFLECT_MIN_TURNS (4) prior turns. A short toolless Q&A is not distilled. A run of an agent in NO_REFLECT_AGENTS (review) is never distilled, however much work it did: its findings already land on the PR and describe one PR at one moment, so distilling them floods the shared org scope with per-PR ephemera and crowds durable knowledge out of the budgeted block. agentName absent → the work-based gate alone. Config commands, repo commands, the deterministic-ops fast path, refusals, and the ask-once ref prompt all return before this point and never reflect. memory.enabled false (default) → scheduleReflection returns immediately: zero behavior change, nothing written, no extra model call.
  3. Model. memory.model is a <provider>/<model> ref parsed by parseModelRef and looked up in the provider registry — the same mechanics as agent models (invariant 7: never a literal in code). Absent → the run's own resolved model ref (which came through the config layers). An unparseable ref logs a warning and skips reflection; it never fails the run.
  4. One call, no retry. Reflection makes exactly one provider.complete call (REFLECTION_SYSTEM — which also names PR-specific state (PR numbers, SHAs, test counts, CI results, verdicts, "approved at …") as ephemeral by definition, never a fact — no tools, 1024 max tokens) whose single user message is built by buildReflectionInput: each of the run's scopes' up-to-8 relevant existing records (org + the user's own, §22) with their ids (so the extractor can emit supersedes), then the thread transcript (history + request + answer; images dropped; tail-capped at 24k chars because the decision usually lives at the end). The whole input is passed through redactSecrets before it leaves the process — the extractor is a third-party model too. A failed or rejected pass writes nothing; the thread history still holds the raw material.
  5. Output validation (parseReflection). Strict on the envelope (non-JSON / non-object / facts not an array → rejected, nothing written; a ```json fence is tolerated), lenient inside: each fact needs a non-empty string text and a numeric confidence in [MIN_REFLECTION_CONFIDENCE (0.6), 1] — anything else is dropped, not fatal. At most MAX_REFLECTION_FACTS (5) facts survive, plus one summary (non-empty string) → MemoryCandidates of kind fact/summary, all stamped with provenance (sourceThreadKey = the thread, sourceRunId = the live-view run id). keywords are kept only as a string array (lowercased, trimmed, deduped, ≤10), else omitted so the store tokenizes the text. Every text field and keyword is redactSecrets-ed. supersedes survives only when it names one of the existing record ids the extractor was shown; anything else is dropped.
  6. Write. Surviving candidates go to store.write per target scope (dedup/supersede per §8; routing per §22). Nothing durable (no facts, empty summary) → no write at all.

Behavior (PR3 — durable store)

  1. The Memory Worker (deploy/cloudflare-memory/) is a plain Worker + one SQLite-backed Durable Object per scopeKey — the DO name is the scope key, so a scope's records live in one database that survives every bot restart (AGENTS.md invariant 6) and cross-scope reads are impossible by construction. Rows: the record fields plus norm (the dedup key) and an FTS5 shadow table over text + keywords that holds exactly the active rows — every soft delete (forget, supersede, evict) removes the row's FTS entry, so dead rows never match a query or burn FTS scan time. Indexes: records_status_norm (status, norm) (write-time dedup), records_active_seq (status, seq DESC) (/list's newest-first page), records_active_used (status, last_used_at DESC, created_at DESC) (status-prefixed scans — the active count and the eviction fetch). The constructor re-applies the idempotent schema (CREATE TABLE/INDEX IF NOT EXISTS — the DO's one migration path, safe over live data) on every start and then reconciles records_fts down to the active rows (DELETE … WHERE id NOT IN (SELECT id FROM records WHERE status = 'active')) — the one-time cleanup of dead rows left by earlier deploys, kept as a self-healing invariant (O(active rows) once clean, so it stays cheap forever). Routes, JSON in/out: POST /retrieve {scopeKey, query, limit}{records}; POST /write {scopeKey, records}{ok, inserted, deduped, superseded}; unauthenticated GET /healthz. Everything else is 404; non-POST is 405.
  2. Auth + validation. Every data route requires Authorization: Bearer <MEMORY_TOKEN>; comparison is constant-time and an unset/empty secret grants nothing (fail closed) → 401. Bodies are validated with size caps before touching storage: scopeKey non-empty ≤200 chars with no whitespace/control chars; query string ≤4000; limit integer 1–50; ≤50 candidates per batch, each with kind ∈ {fact, summary}, non-empty text ≤4000, sourceThreadKey, optional keywords (≤20 short strings), sourceRunId, confidence ∈ [0,1], supersedes → 400 with a reason otherwise. Before any parsing, a size fence: the Content-Length header must be a plain digit string (RFC 9110) — absent (a chunked/streamed body), blank, or any non-digit form Number() would accept (0x1000, 5e2, 12.5) → 411 Length Required; a well-formed length over 512 KiB → 413. The 411 path is unreachable from the public edge (Cloudflare stamps Content-Length: 0 on a bodiless POST → 400) and is covered by the workerd test. So an authenticated caller can't make the Worker JSON-parse an oversized or unsized body only to be refused by the field caps. (The digit-string test is what closes the Number(null)/Number("") → 0 hole.)
  3. Retrieve = FTS5 prefilter + shared engine. The query is tokenized with the engine's tokenize ([a-z0-9]+), each token quoted and OR-joined into the FTS5 MATCH — user text can never reach the FTS query parser as syntax. The MATCH uses at most the 24 longest distinct tokens (MAX_MATCH_TOKENS, ties broken by first appearance; /list's query filter shares the same builder): a 4000-char query would otherwise become a several-hundred-term OR the FTS index must union per retrieval, and longer tokens are the selective ones — the [a-z0-9]+ tokenizer's 1–3-char tokens are mostly stopwords ("a", "the", "to"). Realistic queries have far fewer distinct tokens and are untouched; on a degenerate >24-token query, a record matched ONLY by a dropped shorter token is not a candidate (accepted trade). Candidates are ordered by bm25(records_fts) (best match first — NOT recency, which starved relevant-but-old records) and capped at max(50, 5×limit) — the floor means a scope with ≤50 matching rows hands the engine every match, so small scopes rank exactly as the engine alone decides; bm25 only chooses which candidates reach the engine, never the final order. Candidates then go through the shared rankRecords (whole-token relevance gate over the FULL query, keyword+recency score, limit — ONE algorithm with the in-process store); the returned rows get lastUsedAt/useCount bumped in one parameterized UPDATE … WHERE id IN (…), not one statement per row. No query tokens → []. Optional fields absent in storage are omitted on the wire, never null.
  4. Write = shared engine plan, applied in SQL. The DO runs planWrite per candidate (§8 rules exactly: dedup bump, supersede soft-delete, insert) over targeted lookups, never a full-active scan: planWrite only ever inspects (a) the active rows whose norm equals the candidate's — an indexed hit on records_status_norm, ordered by seq so with duplicate-norm actives (the §8 collision case) the earliest still takes the dedup bump, exactly as the full-set call did — and (b) the active row supersedes names (WHERE id = ?) — the store's branch matches planWrite's truthiness test, so a validated-but-empty supersedes: "" is a no-supersede candidate that dedups against the norm pool, never an id lookup with an empty pool — so those rows are all the store feeds it; the plan and counts are identical by planWrite's own contract (it reads nothing else). seq is assigned from MAX(seq)+1; a batch sees its own earlier inserts because reads inside the transaction see the batch's writes. A supersede also deletes the superseded row's records_fts entry (the record row itself stays — soft delete). The whole batch runs inside ctx.storage.transactionSync — the lookups, the seq base, and every UPDATE/INSERT/DELETE commit together or not at all (no half-applied supersede if the isolate is evicted mid-batch), and because a DO runs one JS turn at a time with no await inside the method, concurrent writers to one scope cannot interleave between the seq read and the inserts (the mechanism is single-threading + no await, not input gates). The concurrent-writers test proves the non-interleaving half; the all-or-nothing half is by construction (transactionSync) and has no unit proof — the suite cannot evict an isolate mid-batch.
  5. WorkerMemoryStore (bot side) mirrors ResidentExecutor's remote plane: fetch with a bearer, 5 s AbortSignal.timeout per request. Retrieval is advisory — a non-2xx, non-JSON body, malformed record, transport error or timeout degrades to [] with one [memory] warning and never fails or stalls the run beyond the timeout; malformed records in a reply are dropped individually. Writes throw on failure (the reflection pass catches and warns) and skip the round trip for an empty batch.
  6. Store selection at startup (buildMemoryStore, called once in src/index.ts; the one instance is shared by every channel): memory disabled → no store (dispatcher uses NullMemoryStore); memory.worker.baseUrl set and its bearer (memory.worker.tokenEnv, default MEMORY_TOKEN) present → WorkerMemoryStore (the Cloudflare shim forwards MEMORY_TOKEN into the container — without that the bot silently runs on the in-process store); otherwise → InMemoryMemoryStore with a startup warning naming the restart loss and the missing config/env — a dev/test configuration, never prod.

Behavior (PR B — user scope)

  1. Up to four scopes per request, derived not configured. requestScopeKeys(userId, {repo, channelId}) yields the org key always; user:<userId> when the request carries a user identity (IncomingMessage.userId, already platform-namespaced by the adapter); channel:<channelId> when it came from a channel (IncomingMessage.channelId, likewise namespaced); and repo:<owner/name> when the run resolved a repo (the same resolution the run gates on — an agent that declares no repo never binds one, so a toolless general request has no repo scope). A scope without its input is refused (deriveScopeKey throws), so there is never a shared anonymous bucket. listScopeKeys orders them org, repo, channel, user — widest shared scope first, the person's own last. Isolation is by construction for the user scope: the only user scope a request can derive is the requester's own, so another person's records cannot be read or written — no filter to get wrong, and the Worker's per-scope Durable Object keeps them in separate databases anyway (§15). Repo and channel scopes are shared by everyone who runs in that repo / that channel; a request can still only ever reach its own channel's and its own bound repo's scope, never a third one.
  2. Read = one pool. memoryContextBlock retrieves each scope (limit each), re-scores the union with the same pure scorer, sorts (stable, org first on ties), then applies the hard budget (§6). Org, channel, and user are fetched immediately and in parallel; the repo scope is fetched once the run's repo resolution settles — the dispatcher starts the memory read before the GitHub round trip finishes, so the read takes the repo as a promise. A resolution that yields no repo, or fails, simply means no repo scope; the read itself never fails for it. Since each retrieve has just bumped lastUsedAt on what it returned, the recency terms are ≈1 across the union and the cross-scope order is effectively keyword-match order — recency has already shaped each scope's own top-limit to the merged list — the limit/token caps are per request, not per scope. The block prefix names every scope read, in listScopeKeys order: Background memory for org:acme + repo:owner/name + channel:slack:C… + user:slack:U… (may be outdated — verify before acting): (absent scopes are simply omitted; an org-only request keeps the single-scope prefix).
  3. Write = audience routing, still one extractor call. REFLECTION_SYSTEM asks for an audience per fact: "user" when the fact is about the requesting person specifically (preferences, habits, personal conventions, their own setup; phrased "this user …"), "repo" when it is specific to the repository the run worked in (its code, conventions, commands, layout), "channel" when it is about what this channel is for or how it works, "org" (the default — any other or missing value) for shared knowledge. parseReflection tags each candidate; the summary inherits the narrowest audience any fact carried — user > repo > channel > org (a thread that yielded personal knowledge is a personal thread and its summary restates that knowledge — routing it to org would leak a user's preference to every other user; the same holds one level down for repo- and channel-specific threads). The extractor is also told to keep the summary impersonal (personal details belong in user facts) — the prompt is the primary control, the inheritance rule the structural backstop. reflect then writes each candidate to its audience's scope when the run has it — user → the requester's own, repo → the bound repo's, channel → the message's — and org otherwise, one store.write per target scope; the existing records shown to the extractor come from every scope the run has. A supersedes follows the superseded record's scope regardless of the tag (the id was validated against the shown records, whose scopes are known) — a mislabeled correction cannot leak a personal record into the org scope or strand a stale one. A fact whose audience scope this run lacks (a user fact on an identity-less request, a repo fact on a run with no bound repo) falls back to the org scope rather than being dropped. The audience tag is reflection-internal: stripped before store.write, so the MemoryStore contract and the Worker's wire format are unchanged. The audience is a hint the authorization policy may narrow, never widen (authorization.md item 8): once routed, every candidate's write is authorize(runActor, "memory:write", memory-scope { key, kind, originChannelVisibility }), where the origin is the run's stamped channelVisibility and the actor is the run's principal holding the run's own channel and repo as memberships (reflectionActor). An org write from a private or dm origin — or from an unstamped / unknown run — has no row and is narrowed: dm → the requesting user's own scope (a DM is one person's conversation; a group DM counts as a DM), private/unknown → the channel's scope (the origin's own audience), each falling to the other when the run lacks the first, never to repo (a repo's readers span every channel it is used from — wider than the origin), and never dropped silently — one [memory] line per reflection with counts and reason tokens, never a fact's text. A narrowed correction loses its supersedes (the record it would retire lives in the scope the origin may not write; it stands). user/channel/repo facts are never moved up; a public or machine origin keeps the routing above. Reads (§22) are unchanged and never consult the policy.

Behavior (human controls: memory list / memory forget)

  1. Two registry commands, never a model turn (src/core/commands/memory.tsmemory.list / memory.forget, reachable on every surface with the derived grammar; command-registry.md item 20; chat reaches them through the registry's one fast path). memory list [<words>…] [--scope <me|org|repo|channel|all>] [--limit <n>] [--repo owner/name] renders the caller's own scope ("your records", user:<caller id>user:slack:U… in Slack, user:cli:local / user:mcp:<subject> on machine surfaces, where record text leaves wrapped as untrusted), this repo's (--repo, or the thread's bound repo resolved lazily through Caller.origin only when the scope is asked for) and this channel's scopes when the request has them, and the shared org scope, newest first, at most MEMORY_LIST_LIMIT (20) per scope — or --limit <n> up to MEMORY_LIST_MAX_LIMIT (50, the Worker's cap; anything else is a usage error) — one line per record with its id (mem:<scope>:<n>), kind, date, text, and source thread in a code span (italics collide with mrkdwn's _ handling); an empty scope says "no active records", and a full page says how to narrow. Any other words form a text filter: only records that some filter token hits (whole-token, text or keywords — the same test as retrieval's relevance gate) are listed, still newest first, the limit applied after the filter; a filter with no tokens lists nothing. The scope is named either with --scope <me|org|repo|channel|all> or as a bare leading scope word (memory list org deploy) — the first query word is consumed as the scope only when it names one and --scope is absent; with --scope given, every query word stays filter text, and a first word that is not a scope name is never consumed. --limit may sit anywhere (--limit 5 or --limit=5). Listing never bumps usage, filtered or not (it is a human view, not a retrieval). memory forget <id> soft-deletes one record: status: "forgotten" (a third status beside active/superseded; the row and its provenance stay), after which the record is invisible to retrieval, list, and dedup — restating its text later creates a fresh active record. Memory disabled → both commands say so and touch no store. A store failure comes back as a ⚠️ line, never a throw. Because forget mutates durable state it runs as an inline run (registry record + receipt, ok:false on refusal/miss/failure); list is a plain inline reply like repo list.
  2. Scope gate — derived from the id, then enforced. The target scope is parsed out of the id (mem:<scopeKey>:<n>); a string that is not a memory id is refused with the format (invalid_input). Own scope (user:<caller>): always allowed. Shared scopes (org, any repo:…, any channel:…): behind the same fail-closed admin gate as repo management (canManageRepos — admins only when nothing is configured), refused with a reason that points at memory list me. Any other scope (another user's): refused for everyone, admins included — the only scopes a request can ever name are its own, its repo's, its channel's, and the org's (invariant 4), and the Worker enforces the same thing structurally: /forget runs inside the named scope's Durable Object, so a foreign id matches nothing there. An id that names no active record in its scope → "Nothing to forget", nothing changes.
  3. Seam + Worker. MemoryStore gains list(scopeKey, limit, query?) and forget(scopeKey, id); all three stores implement them (Null: []/false; InMemory; WorkerMemoryStorePOST /list {scopeKey, limit, query?}{records}query sent only when a filter was given; on the Worker an optional query (string, ≤4000 chars, else 400) becomes the same quoted-OR FTS5 MATCH prefilter /retrieve uses, so user text never reaches the FTS parser as syntax, followed by status='active' ORDER BY seq DESC LIMIT ? with no usage bump — and POST /forget {scopeKey, id}{ok, forgotten}, both throwing on a non-2xx — these are human commands, so a silent empty list would be a lie). The Worker validates like /retrieve (scopeKey caps, limit 1–50, id non-empty ≤200 chars no whitespace/control chars → 400), requires the bearer (401), and logs [list] <scope> -> n / [forget] <scope> <id> -> bool (ids only, never text). /list = SELECT … WHERE status='active' ORDER BY seq DESC LIMIT ?; /forget = one UPDATE … SET status='forgotten' WHERE id=? AND status='active', forgotten = rowsWritten > 0 — plus, when a row flipped, a DELETE FROM records_fts WHERE id = ? in the same sync transaction, so a forgotten record stops matching FTS at the source instead of surviving as a dead row (§15).

Behavior (per-scope cap)

  1. A scope holds at most memory.maxRecordsPerScope ACTIVE records (default DEFAULT_SCOPE_CAP = 500). The cap is enforced on write, in both stores (invariant 2): after a batch's inserts, the pure planEviction(active, cap) (engine.ts) names exactly active − cap records — the least recently used first (lastUsedAt ?? createdAt ascending; ties broken by lower createdAt, so the older record goes first) — and the store flips them to status: "evicted". Nothing is evicted at or under the cap; only active rows count and only active rows are candidates (superseded/forgotten rows are free). Eviction is a soft delete like supersede/forget: the row and its provenance stay, and an evicted record is invisible to retrieval, memory list, and dedup (restating its text inserts a fresh active record). Decay already keeps stale records out of the ranking; the cap keeps them out of storage growth and memory list.
  2. Atomic with the batch. The in-process store evicts right after the batch loop; the Worker's Durable Object evicts inside the same transactionSync as the inserts, so a batch never commits with the scope over the cap and a mid-batch eviction can never be observed half-applied. The Worker fetches the full active set only when the active count (an indexed COUNT(*)) exceeds the cap — the common under-cap batch does no full scan — and each evicted row's records_fts entry is deleted with the status flip (§15). write returns an evicted count; the Worker logs [write] <scope> <- n candidates (evicted m) only when m > 0.
  3. One value, every store. buildMemoryStore reads memory.maxRecordsPerScope once and hands it to whichever store it builds; WorkerMemoryStore sends it as cap on every POST /write (omitted when unset → the Worker's own default 500). The Worker validates cap as an integer 1–10000 (else 400), and buildMemoryStore validates the config value the same way up front — an out-of-range or non-integer maxRecordsPerScope logs a startup warning and the default applies, so a typo can neither evict everything (0) nor 400 every write (> 10000). selectMemoryStore's dev fallback is capped the same way. Within one batch, records inserted together share createdAt, so their relative eviction order is unspecified — irrelevant in practice since a batch (≤50) is far below any sane cap.

Roadmap (gaps)

  • repo/channel derivers → §21–23; memory list / memory forget → §24–26; the per-scope cap → §27–29. No open [gap] in this file.
  • Deferred (later): vector/embedding retrieval behind the unchanged seam (Vectorize); temporal knowledge graph; background consolidation/decay-sweeper jobs.

Validation criteria

CriterionProof
Scorer = α·keyword + β·recency; keyword dominates, recency breaks ties; recency uses lastUsedAt and decays to 1/e at τ[unit] src/core/memory/scorer.test.ts::scoreRecord, ::recencyScore
keywordMatch is the fraction of query word-tokens hitting the record; whole-token, not substring; 0 for empty query[unit] src/core/memory/scorer.test.ts::keywordMatch
Hard budget caps by record count and token estimate (costed on the rendered/escaped bullet, so the rendered block stays within maxTokens); first record always kept; order preserved[unit] src/core/memory/scorer.test.ts::applyBudget
Injected block leads with the exact advisory prefix, one provenance-tagged bullet per record, fenced by <background_memory> delimiters[unit] src/core/memory/scorer.test.ts::renderMemoryBlock, src/core/memory/memory.test.ts::memoryContextBlock
Containment: record fields are sanitized (control chars/newlines stripped, whitespace collapsed, &/</> escaped — & first) so a poisoned record contributes exactly one line and can forge no fence delimiter or tag — even a no-newline delimiter echo[unit] src/core/memory/scorer.test.ts::sanitizeMemoryField, ::renderMemoryBlock > contains an adversarial record to exactly one sanitized line…, ::renderMemoryBlock > escapes a no-newline delimiter echo…
Scope deriver → org:<organization> from the config, platform-namespaced; no organization → refused, never a shared bucket[unit] src/core/memory/scope.test.ts::deriveScopeKey, ::requestScopeKeys
User scope deriver → user:slack:U… from the request's namespaced user id; refuses a missing/empty id; requestScopeKeys = org + own user, org-only without identity[unit] src/core/memory/scope.test.ts::deriveScopeKey > derives the user scope key…, > refuses a user scope without a user id, ::requestScopeKeys
Read path returns org records + the requesting user's own; never another user's; both scopes named in the prefix; org-only prefix without identity; limit/budget apply to the merged pool, ranked across scopes[unit] src/core/memory/memory.test.ts::memoryContextBlock — user scope …
Reflection routes user-audience facts to the user's scope and org facts to org; the summary follows the user scope when any fact is user, else stays in org; the extractor is told to keep summaries impersonal — still ONE extractor call; without a user scope user facts fall back to org; the extractor is shown the user's existing records; a supersede lands in the superseded record's scope even when mislabeled; a scope the request did not name is never written[unit] src/core/memory/reflection.test.ts::reflect — user scope routing …, ::parseReflection — audience …
Dispatcher end to end: user A's run writes a user fact into A's own scope (not B's); A's next request carries it in the Background memory block; B's request carries the org fact but not A's[unit] src/core/dispatcher.test.ts::cross-session memory WRITE path … > user-scoped memory: a user's own records surface for them and never for another user
Repo/channel derivers → repo:owner/name, channel:slack:C…; missing input refused; requestScopeKeys adds them when present; listScopeKeys orders org, repo, channel, user[unit] src/core/memory/scope.test.ts::deriveScopeKey — repo / channel …, ::requestScopeKeys > adds repo and channel keys …
Read path merges org + repo + channel + user into the one pool, names all four in the prefix, never reads another channel's scope; the repo may arrive as a promise (late resolution) — resolved-to-nothing or rejected → no repo scope, read still succeeds[unit] src/core/memory/memory.test.ts::memoryContextBlock — repo + channel scopes …
Reflection routes repo/channel facts to those scopes when the run has them, else org; summary inherits user > repo > channel > org; existing records from every scope shown; supersede follows the record's scope[unit] src/core/memory/reflection.test.ts::reflect — repo / channel routing …, ::parseReflection — repo / channel audiences …
The write gate: an org fact from a dm origin is narrowed to the user's scope (summary included), from a private or unstamped/unknown origin to the channel's (else the user's), never to repo, never to org; public/machine origins keep today's routing; user/channel/repo facts are never widened; a narrowed correction drops supersedes; denials are one [memory] line of reason tokens, never fact text; reads never call authorize[unit] src/core/memory/reflection.test.ts::reflect — write gate …::*, src/core/memory/memory.test.ts::memoryContextBlock — reads are not policy-gated …::*, src/core/dispatcher.test.ts::cross-session memory WRITE path …::dm-origin memory: an \org` fact from a DM is narrowed to the user's scope, never org — and the same fact from a public channel reaches org …`
`memory list repochannelscope words (repo needs a bound repo — says so without one; channel from the message);memory list` shows repo/channel sections only when the request has them; forgetting a repo/channel record is admin-gated like org
Dispatcher end to end: a repo-bound coding run writes a repo fact into repo:owner/name and a channel fact into the message's channel scope; a same-channel request sees the channel fact; another channel's request does not[unit] src/core/dispatcher.test.ts::… > repo/channel-scoped memory: a repo-bound run writes into repo + channel scopes …
parseMemoryCommand: memory list [me|org] [--limit <n>] [<words>] / memory forget <id> parsed (scope word first, --limit anywhere incl. =, other words → filter); prose is not a command; bad --limit (0, 51, non-integer, missing value) / missing id / unknown verb or option → usage error[unit] src/core/commands/memory.test.ts::memory.list::--scope narrows to one scope…, ::a query filters every listed scope and --limit is passed through and capped…
memory list <words> passes the filter and --limit to every listed scope, shows only matching records, labels the section matching …, renders the source as a code span[unit] src/core/commands/memory.test.ts::memory.list::a query filters every listed scope and --limit is passed through and capped…
Stores: list(scope, limit, query) keeps only whole-token hits (text or keywords), newest first, limit after filter, no usage bump; substring / no-token queries match nothing; Null[][unit] src/core/memory/stores.test.ts::InMemoryMemoryStore.list / forget … > list with a query …
WorkerMemoryStore.list sends query only when a filter is given[unit] src/core/memory/workerStore.test.ts::… > list sends \query` only when a filter is given…`
Worker /list with query: FTS whole-token prefilter (text or keywords), newest first, limit after filter, no usage bump, forgotten rows excluded, FTS syntax inert; non-string / over-long query → 400[unit] deploy/cloudflare-memory/worker.test.ts::list / forget … > /list with \query` …`
memory list shows own + org scopes with ids and never another user's; me/org narrow; empty scope says so; memory off → says so[unit] src/core/commands/memory.test.ts::memory.list::*, > \memory list me` / `memory list org` …, > memory disabled …`
memory forget: own scope allowed; org scope admin-gated (refused with reason, then allowed for admin); another user's scope refused for everyone incl. admin; non-id and no-active-record ids reported with nothing changed; store failure → ⚠️[unit] src/core/commands/memory.test.ts::memory.forget::*, > forgetting an ORG record is admin-gated …, > another user's scope is unreachable …, > an id that is not a memory id …, > a store failure …
Stores: Null list []/forget false; InMemory list = active, newest first, limited, per scope, no usage bump; forget flips to forgotten (kept), false for unknown/foreign/non-active, second call false; forgotten records are neither retrieved nor dedup targets[unit] src/core/memory/stores.test.ts::NullMemoryStore > list returns [] …, ::InMemoryMemoryStore.list / forget …
WorkerMemoryStore.list/forget: POST /list /forget with bearer + body; malformed records dropped; non-2xx THROWS (never a silent empty list)[unit] src/core/memory/workerStore.test.ts::WorkerMemoryStore.list / forget …
Worker: /list newest first, limited, no usage bump; /forget soft-deletes (hidden from list/retrieve/dedup, row kept, second call false); foreign-scope id via another DO → false; body validation 400s; bearer required[unit] deploy/cloudflare-memory/worker.test.ts::list / forget … (inside workerd)
planEviction: nothing at/under cap; exactly active − cap, least recently used first (lastUsedAt ?? createdAt), ties by lower createdAt; non-active rows neither count nor get evicted[unit] src/core/memory/engine.test.ts::planEviction …
In-process store: an over-cap write evicts LRU down to the cap within the batch; evicted rows hidden from retrieve/list/dedup with status evicted; superseded/forgotten rows don't consume the cap; no cap → no eviction[unit] src/core/memory/stores.test.ts::InMemoryMemoryStore per-scope cap …
WorkerMemoryStore sends cap on /write when configured and omits it otherwise; accepts evicted records on the wire[unit] src/core/memory/workerStore.test.ts::WorkerMemoryStore cap on the wire …
Worker: over-cap /write evicts LRU inside the transaction, returns evicted, hides evicted rows from /list + /retrieve and dedup; absent cap → server default; bad cap → 400[unit] deploy/cloudflare-memory/worker.test.ts::per-scope cap … (inside workerd)
Startup wiring: memory.maxRecordsPerScope reaches the in-process fallback store (a write past it evicts); the Worker path is covered by the wire test above[unit] src/core/memory/buildStore.test.ts::… > threads memory.maxRecordsPerScope into the in-process store…
An out-of-range / non-integer maxRecordsPerScope (0, negative, fractional, >10000, NaN) warns naming the key and the default, and the default cap governs[unit] src/core/memory/buildStore.test.ts::… > an out-of-range or non-integer memory.maxRecordsPerScope warns …
Dispatcher: memory list --scope org answered inline through the registry — no model turn, no repo resolution; memory list (all) resolves the thread repo lazily once; memory forget is an inline run with a receipt, memory list is not[unit] src/core/dispatcher.test.ts::… > \memory list` is answered inline from the memory store through the registry…, ::… > `memory forget` is an inline run…`
Live: an admin runs memory list --scope org in Slack and picks a record id; memory forget <id> replies 🧹 Forgot …; wrangler tail switchboard-memory shows [forget] org:<organization> <id> -> true; a new-thread question whose answer previously quoted that record no longer does, and memory list --scope org no longer shows it; a non-admin running the same memory forget on an org id gets the 🚫 refusal and the record stays[agent]
Live: two Slack users. User A runs a tool-using request that states a personal preference ("I always want X"); wrangler tail switchboard-memory shows a /write to user:slack:<A> (and org); user A asks about the topic in a new thread → A's /retrieve hits user:slack:<A> and the reply reflects the preference; user B asks the same in a new thread → B's /retrieve hits user:slack:<B> only, never <A>, and the reply shows no trace of A's preference[agent] User B may be any second real identity (e.g. an http: ingress caller via POST /ingress with a SWITCHBOARD_INGRESS_TOKENS bearer). Also check that B's reply does not restate A's preference via the thread summary (§23 routes it to A's scope).
InMemoryMemoryStore.retrieve: scope-partitioned, relevance-gated, ranked, limited; bumps lastUsedAt/useCount[unit] src/core/memory/stores.test.ts::InMemoryMemoryStore.retrieve
InMemoryMemoryStore.write: inserts active namespaced records; dedups identical text (case/whitespace-insensitive, bumps useCount)[unit] src/core/memory/stores.test.ts::InMemoryMemoryStore.write
InMemoryMemoryStore.write supersede: named active same-scope record → superseded (kept), new record inserted with the pointer; unknown/foreign id supersedes nothing; restating the target is a no-op dedup; a collision with an unrelated record does not swallow the supersede (even when the id is unresolvable); superseded records neither match retrieval nor act as dedup targets[unit] src/core/memory/stores.test.ts::InMemoryMemoryStore.write > supersede…, > a targeted supersede is NOT swallowed…, > superseded records never match retrieval…
Run stays counted in flight through reply + reflection scheduling (drain cannot see 0 runs / 0 reflections in between)[unit] src/core/dispatcher.test.ts::cross-session memory WRITE path … > the run stays counted in flight…
Reflection gate: ≥1 tool call OR ≥REFLECT_MIN_TURNS prior turns; a short toolless chat does not qualify; a review run never qualifies while other/unnamed agents keep the work-based gate[unit] src/core/memory/reflection.test.ts::shouldReflect
Extractor prompt names PR-specific state (PR numbers, SHAs, test counts) as ephemeral, never a fact[unit] src/core/memory/reflection.test.ts::REFLECTION_SYSTEM — ephemera …
Dispatcher: a review run (tools used, long thread) makes no reflection call and writes nothing; a coding run that used tools still reflects[unit] src/core/dispatcher.test.ts::cross-session memory WRITE path … > a \review` run … does NOT reflect…, > a `coding` run that used tools still reflects`
Extractor input: existing records with ids + request + answer + thread; secrets redacted before leaving the process; images dropped; tail-capped[unit] src/core/memory/reflection.test.ts::buildReflectionInput
Extractor output validated: envelope errors rejected without throwing; ≤5 facts; confidence gate (missing/invalid/low/>1 dropped); empty text dropped; keywords normalized; supersedes kept only for shown ids[unit] src/core/memory/reflection.test.ts::parseReflection
Extractor output secret-redacted (fact text, summary, keywords)[unit] src/core/memory/reflection.test.ts::parseReflection > redacts secrets…
reflect: exactly one model call on the given model with REFLECTION_SYSTEM and no tools; shows existing records; writes with supersede applied and provenance stamped; unparseable reply / provider throw / nothing-durable → no write, resolves, onWarn[unit] src/core/memory/reflection.test.ts::reflect (one extractor call → store.write)
Drain tracking: pendingReflectionCount counts until settle (resolve or reject)[unit] src/core/memory/reflection.test.ts::trackReflection…
Dispatcher wiring: a tool-using run (or a ≥4-turn thread) reflects once on memory.model after the reply and writes to the injected store with the thread key + run id; a short toolless chat makes no extra call and writes nothing[unit] src/core/dispatcher.test.ts::cross-session memory WRITE path …
Disabled → no reflection even on a qualifying run (no extra model call, nothing written)[unit] src/core/dispatcher.test.ts::… > memory disabled → no reflection…
memory.model absent → the run's own resolved model, never a hardcoded ref[unit] src/core/dispatcher.test.ts::… > memory.model absent…
Reflection failure never touches the user reply (no ⚠️, answer intact)[unit] src/core/dispatcher.test.ts::… > a reflection failure never touches the user reply
Fire-and-forget: dispatch returns with the reflection still pending; drainReflections settles it[unit] src/core/dispatcher.test.ts::… > dispatch returns without awaiting the reflection…
Config-command fast path never reflects[unit] src/core/dispatcher.test.ts::… > config-command fast path never reflects
Live end-to-end: with memory.enabled: true and memory.model set, ask the general agent a question in a thread ≥4 turns deep (or run any tool-using agent), then in a new thread ask about the same topic — the second run's system prompt (visible in the live-view page / logs) carries a Background memory block quoting a fact from the first, and stderr shows no [memory] warning[agent] superseded by the PR3 live criterion below.
NullMemoryStore: retrieve[], write no-op; is the store when disabled[unit] src/core/memory/stores.test.ts::NullMemoryStore, ::selectMemoryStore
Disabled path is byte-identical to memory-off (with NullMemoryStore the provider request is unchanged); no block off-path; block kept out of history[unit] src/core/dispatcher.test.ts::cross-session memory READ path…
Enabled + seeded store prepends the advisory block while preserving the agent's own prompt[unit] src/core/dispatcher.test.ts::cross-session memory READ path… > enabled with a seeded store prepends the advisory block…
Engine: rankRecords gates/ranks/limits without mutating usage; planWrite dedup/supersede rules incl. the collision guard; mintRecord shape[unit] src/core/memory/engine.test.ts
In-process store applies the engine identically (pre-existing store tests unchanged through the extraction)[unit] src/core/memory/stores.test.ts
Worker: /healthz open; missing/wrong/non-Bearer token → 401; unknown route 404; non-POST 405; oversized body → 413 / undeclared length → 411, both before parsing; malformed bodies → 400 with reason (scopeKey, query, limit, records, kind, text, sourceThreadKey, keywords, size caps)[unit] deploy/cloudflare-memory/worker.test.ts::auth + routing (inside workerd)
Worker: non-ASCII record text stored and retrievable by its ASCII tokens; accented queries inert (miss-only), never errors[unit] worker.test.ts::… > non-ASCII record text…
Worker: 5 concurrent write batches to one scope → all succeed, 10 unique ids (no seq collision). Proves the single-JS-turn non-interleaving property only; the transactionSync all-or-nothing property (no half-applied batch on mid-batch isolate eviction) is by construction and not exercisable in this suite[unit] worker.test.ts::… > concurrent writers to one scope…
Worker: a streamed or bodiless request with no Content-Length is 411 — never parsed (regression guard: Number(null) is 0); blank/non-digit headers take the same path in code (unconstructible from fetch)[unit] worker.test.ts::auth + routing > a streamed body with NO Content-Length is 411…, > fences body size before parsing
Worker: write → retrieve round trip mints mem:<scope>:<seq> records with provenance, ranked by the shared engine; whole-token matching; limit + usage bump only on returned rows; scope isolation; FTS syntax in queries is inert; empty batch no-op[unit] deploy/cloudflare-memory/worker.test.ts::write → retrieve round trip
Worker: dedup bumps (case/whitespace-insensitive); supersede soft-deletes and hides the old row; unknown id supersedes nothing; collision guard; superseded rows are not dedup targets[unit] deploy/cloudflare-memory/worker.test.ts::dedup / supersede… (red-verified: disabling the status flip fails 3 tests)
Worker retrieve orders candidates by bm25, capped at max(50, 5×limit): a relevant-but-old record beyond the 500 most recently used still reaches the engine and returns first[unit] deploy/cloudflare-memory/worker.test.ts::retrieval + write efficiency … > bm25 candidate ordering rescues a relevant-but-old record… (red-verified: fails on recency-ordered candidates)
Candidate floor: when 5×limit < 50, every matching row (≤50) reaches the engine — the engine's keyword+recency ranking, not bm25, decides a small scope's result[unit] worker.test.ts::retrieval + write efficiency … > the 50-candidate floor hands the engine every match in a small scope…
MATCH term cap: a >24-distinct-token query keeps the 24 longest tokens — a record matched by a kept token is found; one matched only by the dropped shortest is not (documented trade); ≤24 tokens unaffected[unit] worker.test.ts::retrieval + write efficiency … > the FTS MATCH is capped at the 24 longest distinct tokens…
Retrieve's usage bump is ONE parameterized UPDATE … WHERE id IN (…) for the whole returned set, and works at MAX_LIMIT (50 ids in one statement)[unit] worker.test.ts::retrieval + write efficiency … > the usage bump is one batched UPDATE…, > the batched bump works at MAX_LIMIT
Write feeds planWrite targeted lookups (norm match + supersede id), never the full-active scan while under the cap; a later candidate dedups against the same batch's earlier insert; duplicate-norm dedup bumps the earliest seq; an empty supersedes: "" (falsy to planWrite) still dedups against the whole active set[unit] worker.test.ts::retrieval + write efficiency … > write plans from targeted lookups…, > a batch's later candidate dedups against its own earlier insert, > dedup against duplicate-norm actives bumps the earliest, > a candidate with an EMPTY supersedes… (red-verified against the !== undefined branch)
forget, supersede, and evict each delete the row's records_fts entry (record rows stay — soft delete); the FTS table holds exactly the active rows[unit] worker.test.ts::FTS hygiene … > forget deletes…, > supersede deletes…, > eviction deletes… (red-verified: dead FTS rows survive without the deletes)
Constructor reconciliation deletes FTS rows for non-active or missing records (dead rows from before the hygiene rule), idempotent, and leaves active rows matched[unit] worker.test.ts::FTS hygiene … > reconciliation removes dead FTS rows…
Migration path: records_active_seq and records_active_used exist after DO construction (idempotent CREATE INDEX IF NOT EXISTS over live data)[unit] worker.test.ts::retrieval + write efficiency … > the schema migration adds the status-prefixed indexes…
WorkerMemoryStore.retrieve: POSTs /retrieve with bearer + body + timeout; degrades to [] + warning on non-2xx / non-JSON / transport failure; drops malformed records[unit] src/core/memory/workerStore.test.ts::WorkerMemoryStore.retrieve (red-verified: removing the record filter fails the malformed-records test)
WorkerMemoryStore.retrieve under a span is an http.client child with route /retrieve and the trace context for the configured host; memoryContextBlock hands its span to every scope's retrieve (tracing.md item 24)[unit] src/core/memory/workerStore.test.ts::WorkerMemoryStore trace context::*, src/core/memory/memory.test.ts::memoryContextBlock — the caller's span (docs/reference/specs/tracing.md item 24)::*
WorkerMemoryStore.write: POSTs /write; empty batch skips the round trip; failure throws with status/error[unit] src/core/memory/workerStore.test.ts::WorkerMemoryStore.write
Startup selection: disabled → none; worker + bearer → WorkerMemoryStore; custom tokenEnv; no worker or missing bearer → in-process + a warning naming the restart loss / env var[unit] src/core/memory/buildStore.test.ts
Live: deploy the Memory Worker, set memory.enabled: true + memory.worker.baseUrl + MEMORY_TOKEN on the bot; run a tool-using request in thread A; wrangler tail switchboard-memory shows a /write with inserted ≥ 1; restart the bot; in a new thread ask about the same topic — the system prompt (live-view page) carries a Background memory block quoting the fact from thread A; the bot's startup log shows no [memory] in-process warning[agent] Also probe the Worker directly: GET /healthz{"ok":true}; unauthenticated POST /retrieve → 401; an authed curl POST /retrieve reads thread A's records back independently of the bot (this out-of-process read of the SQLite DO stands in for the restart when a redeploy is not possible; the live-view page is behind Cloudflare Access, so the block may have to be inferred from the reply + wrangler tail instead).