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 insrc/core/dispatch/provision.ts(startMemoryRead: retrieve → inject before the model turn) andsrc/core/dispatch/reply.ts(afterReply: the reflection pass after the reply);scheduleReflectionafter the reply), store selection + drain insrc/index.ts, config insrc/config.ts; the Memory Worker indeploy/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)
- Flag-gated, off by default.
memory.enableddefaults false. When off (or thememorysection is absent) the dispatcher selects aNullMemoryStore, whoseretrievereturns[]andwriteis 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. - The seam. The core depends only on the
MemoryStoreinterface (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(aMap<scopeKey, MemoryRecord[]>, for tests/dev), and the durableWorkerMemoryStore(an HTTPS client to the Memory Worker, §15). The ranking and write rules are not per-store: they live in the pureengine.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. - 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). - Scope. Pure derivers map request identity → opaque, platform-namespaced keys (AGENTS.md invariant 4):
org→org:<organization>(the config'sorganization— the installation's one shared resource; the code names no org),user→user:<userId>(e.g.user:slack:U0123),repo→repo:<owner/name>(the run's bound repo),channel→channel:<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). - Retrieval scoring. Pure function:
score = α·keywordMatch + β·recency(α=0.7, β=0.3), wherekeywordMatchis 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) andrecencyis exponential decay overlastUsedAt ?? createdAt(τ ≈ 1 week). Decay lives in the score, so stale records sink without a sweeper.retrievereturns only records the query actually matches (relevance gate), ranked, and bumpslastUsedAt/useCounton the ones it returns. - Hard budget. At most
memory.limitrecords (default 8) andmemory.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 rawtext— so the<→<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. - Injection. In
dispatch(), beforebuildMessages, 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 lineBackground 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 throughsanitizeMemoryField, which strips control characters and newlines/line-separators, collapses whitespace, and escapes&→&(first, so the escaping is complete and unambiguous — a literal<becomes&lt;rather than rendering identically to an escaped<), then<→</>→>— so a poisoned record can never inject extra lines, forge a fakeSYSTEM:/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)systemis left exactly as it was. - Write.
MemoryStore.write(scopeKey, candidates)is implemented by both stores and driven by the reflection pass below.NullMemoryStoreno-ops.InMemoryMemoryStore: dedup — a candidate whose normalized text (trim, lowercase, collapse whitespace) equals an active record's bumps that record'suseCountinstead 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 carryingsupersedes: <id>flips that record tostatus: "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 carryingsupersedesdedups 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,useCountbumped), 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)
- When. In
dispatch(), aftersendAnswer(the reply has landed) the dispatcher callsscheduleReflection, 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 onactiveRunCount() + pendingReflectionCount()(same 15-minute deadline) so a restart doesn't drop a distillation mid-flight. A dispatch holds itsactiveRunsslot until its post-run steps (reply, review post, reflection scheduling) have run — released indispatch()'s outerfinally— so the drain can never observe "0 runs, 0 reflections" in the window between the run loop ending and the reflection being scheduled. - 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 inNO_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.agentNameabsent → 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.enabledfalse (default) →scheduleReflectionreturns immediately: zero behavior change, nothing written, no extra model call. - Model.
memory.modelis a<provider>/<model>ref parsed byparseModelRefand 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. - One call, no retry. Reflection makes exactly one
provider.completecall (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 bybuildReflectionInput: 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 emitsupersedes), 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 throughredactSecretsbefore 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. - Output validation (
parseReflection). Strict on the envelope (non-JSON / non-object /factsnot an array → rejected, nothing written; a ```json fence is tolerated), lenient inside: each fact needs a non-empty stringtextand a numericconfidencein[MIN_REFLECTION_CONFIDENCE (0.6), 1]— anything else is dropped, not fatal. At mostMAX_REFLECTION_FACTS(5) facts survive, plus onesummary(non-empty string) →MemoryCandidates of kindfact/summary, all stamped with provenance (sourceThreadKey= the thread,sourceRunId= the live-view run id).keywordsare kept only as a string array (lowercased, trimmed, deduped, ≤10), else omitted so the store tokenizes the text. Every text field and keyword isredactSecrets-ed.supersedessurvives only when it names one of the existing record ids the extractor was shown; anything else is dropped. - Write. Surviving candidates go to
store.writeper target scope (dedup/supersede per §8; routing per §22). Nothing durable (no facts, empty summary) → no write at all.
Behavior (PR3 — durable store)
- The Memory Worker (
deploy/cloudflare-memory/) is a plain Worker + one SQLite-backed Durable Object perscopeKey— 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 plusnorm(the dedup key) and an FTS5 shadow table overtext + keywordsthat 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 reconcilesrecords_ftsdown 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}; unauthenticatedGET /healthz. Everything else is 404; non-POST is 405. - 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:scopeKeynon-empty ≤200 chars with no whitespace/control chars;querystring ≤4000;limitinteger 1–50; ≤50 candidates per batch, each withkind ∈ {fact, summary}, non-emptytext≤4000,sourceThreadKey, optionalkeywords(≤20 short strings),sourceRunId,confidence ∈ [0,1],supersedes→ 400 with a reason otherwise. Before any parsing, a size fence: theContent-Lengthheader must be a plain digit string (RFC 9110) — absent (a chunked/streamed body), blank, or any non-digit formNumber()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 stampsContent-Length: 0on 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 theNumber(null)/Number("")→ 0 hole.) - 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 FTS5MATCH— user text can never reach the FTS query parser as syntax. TheMATCHuses 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 bybm25(records_fts)(best match first — NOT recency, which starved relevant-but-old records) and capped atmax(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 sharedrankRecords(whole-token relevance gate over the FULL query, keyword+recency score, limit — ONE algorithm with the in-process store); the returned rows getlastUsedAt/useCountbumped in one parameterizedUPDATE … WHERE id IN (…), not one statement per row. No query tokens →[]. Optional fields absent in storage are omitted on the wire, nevernull. - Write = shared engine plan, applied in SQL. The DO runs
planWriteper candidate (§8 rules exactly: dedup bump, supersede soft-delete, insert) over targeted lookups, never a full-active scan:planWriteonly ever inspects (a) the active rows whosenormequals the candidate's — an indexed hit onrecords_status_norm, ordered byseqso 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 rowsupersedesnames (WHERE id = ?) — the store's branch matchesplanWrite's truthiness test, so a validated-but-emptysupersedes: ""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 byplanWrite's own contract (it reads nothing else).seqis assigned fromMAX(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'srecords_ftsentry (the record row itself stays — soft delete). The whole batch runs insidectx.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 noawaitinside 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. WorkerMemoryStore(bot side) mirrorsResidentExecutor's remote plane:fetchwith a bearer, 5 sAbortSignal.timeoutper 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.- Store selection at startup (
buildMemoryStore, called once insrc/index.ts; the one instance is shared by every channel): memory disabled → no store (dispatcher usesNullMemoryStore);memory.worker.baseUrlset and its bearer (memory.worker.tokenEnv, defaultMEMORY_TOKEN) present →WorkerMemoryStore(the Cloudflare shim forwardsMEMORY_TOKENinto the container — without that the bot silently runs on the in-process store); otherwise →InMemoryMemoryStorewith a startup warning naming the restart loss and the missing config/env — a dev/test configuration, never prod.
Behavior (PR B — user scope)
- 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); andrepo:<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 toollessgeneralrequest has no repo scope). A scope without its input is refused (deriveScopeKeythrows), so there is never a shared anonymous bucket.listScopeKeysorders 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. - Read = one pool.
memoryContextBlockretrieves each scope (limiteach), 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 eachretrievehas just bumpedlastUsedAton 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-limitto the merged list — the limit/token caps are per request, not per scope. The block prefix names every scope read, inlistScopeKeysorder: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). - Write = audience routing, still one extractor call.
REFLECTION_SYSTEMasks for anaudienceper 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.parseReflectiontags 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 inuserfacts) — the prompt is the primary control, the inheritance rule the structural backstop.reflectthen 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, onestore.writeper target scope; the existing records shown to the extractor come from every scope the run has. Asupersedesfollows 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 (auserfact on an identity-less request, arepofact on a run with no bound repo) falls back to the org scope rather than being dropped. Theaudiencetag is reflection-internal: stripped beforestore.write, so theMemoryStorecontract 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 isauthorize(runActor, "memory:write", memory-scope { key, kind, originChannelVisibility }), where the origin is the run's stampedchannelVisibilityand the actor is the run's principal holding the run's own channel and repo as memberships (reflectionActor). Anorgwrite from aprivateordmorigin — or from an unstamped /unknownrun — 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 torepo(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 itssupersedes(the record it would retire lives in the scope the origin may not write; it stands).user/channel/repofacts are never moved up; apublicormachineorigin keeps the routing above. Reads (§22) are unchanged and never consult the policy.
Behavior (human controls: memory list / memory forget)
- Two registry commands, never a model turn (
src/core/commands/memory.ts—memory.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 throughCaller.originonly when the scope is asked for) and this channel's scopes when the request has them, and the shared org scope, newest first, at mostMEMORY_LIST_LIMIT(20) per scope — or--limit <n>up toMEMORY_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--scopeis absent; with--scopegiven, every query word stays filter text, and a first word that is not a scope name is never consumed.--limitmay sit anywhere (--limit 5or--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 besideactive/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. Becauseforgetmutates durable state it runs as an inline run (registry record + receipt,ok:falseon refusal/miss/failure);listis a plain inline reply likerepo list. - 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, anyrepo:…, anychannel:…): behind the same fail-closed admin gate as repo management (canManageRepos— admins only when nothing is configured), refused with a reason that points atmemory 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:/forgetruns 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. - Seam + Worker.
MemoryStoregainslist(scopeKey, limit, query?)andforget(scopeKey, id); all three stores implement them (Null:[]/false;InMemory;WorkerMemoryStore→POST /list {scopeKey, limit, query?}→{records}—querysent only when a filter was given; on the Worker an optionalquery(string, ≤4000 chars, else 400) becomes the same quoted-OR FTS5MATCHprefilter/retrieveuses, so user text never reaches the FTS parser as syntax, followed bystatus='active' ORDER BY seq DESC LIMIT ?with no usage bump — andPOST /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(scopeKeycaps,limit1–50,idnon-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= oneUPDATE … SET status='forgotten' WHERE id=? AND status='active',forgotten = rowsWritten > 0— plus, when a row flipped, aDELETE 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)
- A scope holds at most
memory.maxRecordsPerScopeACTIVE records (defaultDEFAULT_SCOPE_CAP= 500). The cap is enforced on write, in both stores (invariant 2): after a batch's inserts, the pureplanEviction(active, cap)(engine.ts) names exactlyactive − caprecords — the least recently used first (lastUsedAt ?? createdAtascending; ties broken by lowercreatedAt, so the older record goes first) — and the store flips them tostatus: "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 andmemory list. - Atomic with the batch. The in-process store evicts right after the batch loop; the Worker's Durable Object evicts inside the same
transactionSyncas 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 indexedCOUNT(*)) exceeds the cap — the common under-cap batch does no full scan — and each evicted row'srecords_ftsentry is deleted with the status flip (§15).writereturns anevictedcount; the Worker logs[write] <scope> <- n candidates (evicted m)only whenm > 0. - One value, every store.
buildMemoryStorereadsmemory.maxRecordsPerScopeonce and hands it to whichever store it builds;WorkerMemoryStoresends it ascapon everyPOST /write(omitted when unset → the Worker's own default 500). The Worker validatescapas an integer 1–10000 (else 400), andbuildMemoryStorevalidates the config value the same way up front — an out-of-range or non-integermaxRecordsPerScopelogs 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 sharecreatedAt, so their relative eviction order is unspecified — irrelevant in practice since a batch (≤50) is far below any sane cap.
Roadmap (gaps)
repo/channelderivers → §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
| Criterion | Proof |
|---|---|
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 repo | channelscope 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). |