Long-term Memory
Add a route-local memory.ts when an agent needs durable, typed records rather than another prompt file. It declares the record shape, kind, namespace dimensions, and identity.
Declare a collectionCopy link to section: Declare a collection
Place memory.ts beside the agent route:
import { defineMemory } from "@b4run/sdk"
import { z } from "zod"
export default defineMemory({
kind: "semantic",
scope: ["workspace", "route"],
identity: ["subject", "attribute"],
schema: z.object({
subject: z.string(),
attribute: z.string(),
value: z.string(),
}),
})kindissemantic,episodic,reflection, orprocedural.scopeis a subset ofworkspace,route,tenant,user, andagent. It determines the namespace dimensions that isolate records.schemavalidatesremember.dataat typegen time and again at runtime.identityselects fields used to reconcile semantic facts. It defaults tosubjectandpredicate.
semantic, episodic, and reflection writes are wired today. procedural exists in the type system only, so remember rejects procedural writes.
Scope is addressing, not authenticationCopy link to section: Scope is addressing, not authentication
The runtime constructs the route and workspace dimensions. Additional dimensions can come from memory.resolveScope:
export default {
memory: {
resolveScope: ({ routePath, appRoot }) => {
console.info("resolving memory scope", { routePath, appRoot })
return { agent: process.env.B4_AGENT_SLOT ?? "primary" }
},
},
} satisfies import("@b4run/core").B4ConfigB4.run keeps only the dimensions the route declares, so this example needs agent in the scope of memory.ts.
The callback runs for every request and receives routePath, appRoot and principal, the caller src/auth.ts resolved for that request (undefined with no auth file, or for an anonymous request). It does not receive middleware context. Give each caller its own memory by deriving user or tenant from the principal, never from a route parameter, thread id, or request body:
export default {
memory: {
resolveScope: ({ principal }) => (principal ? { user: principal.id } : {}),
},
} satisfies import("@b4run/core").B4ConfigA dimension the route declares and resolveScope leaves without a value makes memory unavailable for that request. remember and recall answer that memory is unavailable, the memory index is empty, and nothing is read or written. It never falls back to the shared namespace, so an anonymous caller of a user-scoped route can't read or write anyone's memory. Test cross-tenant isolation anyway. See Persistence and Tenancy.
Generated recall and remember toolsCopy link to section: Generated recall and remember tools
Typegen adds generated recall and remember tools to any agent route with a memory.ts:
recall({ query?, kind?, tags?, limit?, since?, until? })reads active in-scope records. Recall and Retrieval covers ranking and time windows.remember({ data, content, tags?, confidence? })validates the typed data and stores a human-readablecontentstring. It's omitted whenmemory.writesis"off".
The model-facing data type follows your Zod schema, and runtime validation still runs when the model calls the tool.
Record identity and write behaviorCopy link to section: Record identity and write behavior
Semantic IDs are derived from the namespace and serialized data. With automatic writes, B4.run compares the configured identity fields against active semantic records:
| Match | Result |
|---|---|
| No identity match | Append a new active record |
| Same identity and same data | Update content, confidence, tags, and updatedAt on the existing record |
| Same identity and different data | Write the new record and supersede the old one |
Episodic and reflection records always append. Their IDs salt the namespace and data with the write timestamp, so repeated writes normally stay distinct. The store upserts by ID, so identical namespace and data written in the same millisecond get the same ID and overwrite each other.
Each record carries createdAt and updatedAt. Append kinds also use the request timestamp as effectiveAt. The model can supply content, tags, and confidence. It cannot set identity or timestamp fields.
Write governanceCopy link to section: Write governance
Let's configure the generated write tool in b4.config.ts:
export default {
memory: {
writes: "candidate",
},
} satisfies import("@b4run/core").B4Config| Mode | Behavior |
|---|---|
candidate | Default. Writes are stored for review and stay out of normal recall until approved. |
auto | Adds and reconciliations become active immediately. |
ask | Adds and idempotent updates act like auto. A semantic supersede goes through the memory permission gate. |
off | The generated remember tool is absent. |
Approving a semantic candidate reconciles its identity before activation. Episodic and reflection candidates just activate, because append records can't contradict earlier events or insights.
ask modeCopy link to section: ask mode
ask gates only the semantic contradiction branch. A new fact with no matching identity, or one that repeats the same data, writes without a prompt. Episodic and reflection writes never trigger the supersede gate.
In headless/non-interactive mode, an unknown decision is allowed, so ask behaves like auto. And without a permissions store, the supersede is also allowed. Explicit deny rules are still honored.
This makes ask a supervision affordance, not a security boundary. When a write must be prevented, use an explicit deny policy and application authorization. Keep in mind that an interactive decision lives in one process, so it won't authorize anything across replicas.
Reviewing candidatesCopy link to section: Reviewing candidates
During development, you can work the queue from the CLI:
b4 memory list
b4 memory approve <id>
b4 memory reject <id>The local runtime also exposes list, approve, and reject routes for candidates. They have no authentication of their own, so put them behind your own authentication and tenant authorization before exposing them. For a broader admin surface, follow Browse and Manage Memory.
Stores and lifecycleCopy link to section: Stores and lifecycle
The default Node store is SQLite at .b4/memory.sqlite. Set memory.store to use another backend. @b4run/memory-pgvector provides a shared Postgres store with vector candidate retrieval. It stays separate from the checkpoint, thread metadata, and permissions stores.
Long-term memory outlives the thread. Call the store's delete(id) to remove one record, and prune({ now, namespacePrefix?, cap? }) to remove expired records and enforce episodic caps. Account deletion, retention, backups, and audit across every namespace-bearing store are your application's job.
ConfigurationCopy link to section: Configuration
You set the store, governance mode, ranking, embeddings, episode recording, distillation, and scope resolution independently. Here are the first three:
import { sqliteMemoryStore } from "@b4run/memory"
export default {
memory: {
store: sqliteMemoryStore({ path: ".b4/memory.sqlite" }),
writes: "candidate",
recall: { candidatePool: 256 },
},
} satisfies import("@b4run/core").B4ConfigA custom store owns its search behavior and ignores the built-in SQLite ranker options. Keep provider credentials and application identity out of model-authored scope data.
TestingCopy link to section: Testing
Seed known records without asking a model to call remember:
import { basename } from "node:path"
import { fileURLToPath } from "node:url"
import { sqliteMemoryStore } from "@b4run/memory"
import { serializeNamespace } from "@b4run/memory/namespace"
import { seedMemory } from "@b4run/testing"
const appRoot = fileURLToPath(new URL("..", import.meta.url))
const store = sqliteMemoryStore({ path: ":memory:" })
await seedMemory(store, [{
id: "customer-locale",
namespace: serializeNamespace({
workspace: basename(appRoot),
route: "/support",
}),
kind: "semantic",
content: "Customer prefers French replies",
data: { subject: "customer", attribute: "locale", value: "fr" },
status: "active",
}])This builds the same workspace value the runtime uses: the basename of the app root, not the full filesystem path. The :memory: database is process-local, so there's no file to clean up.
Test the namespace, schema rejection, candidate approval, semantic update/supersede behavior, and retention separately. Fix timestamps in store tests so recency and expiry don't depend on the wall clock.
Verifying against a real modelCopy link to section: Verifying against a real model
Once the deterministic tests pass, add a narrow integration test that asks the configured model to recall a distinctive seeded fact and, separately, to propose a write. Assert on the tool event or stored record, not the model's wording. Keep candidate mode on unless the test exercises automatic writes.
What's deferredCopy link to section: What's deferred
A few things aren't built yet:
- The
proceduralkind is typed but has no generated write path. - Nothing migrates records between namespace schemes.
- There's no account-erasure transaction across stores.