Signed-off-by: Andrew Barba <barba@hey.com>
63 KiB
issue, status, last_updated
| issue | status | last_updated |
|---|---|---|
| https://github.com/vercel/eve/issues/1510 | proposed | 2026-09-01 |
First-class memory
Proposal
Memory is a path-authored capability for scoped context that outlives one session. A memory provider owns how it stores, retrieves, and updates memory. eve owns when the provider participates in the agent lifecycle, and eve owns a small projection record model — item identity and supersession — so recalled context can be updated deterministically without a stale copy surviving in the prompt.
Each memory definition binds a provider to an application namespace, a trusted scope, and a recall visibility policy. It may also describe the slot's purpose to the model through provider tool descriptions. The provider contract has three surfaces:
recallreturns messages that eve applies to the slot's recalled context in durable history as user-role context. A message with anidinserts or replaces that item; a message without one appends immutably.captureobserves history before compaction and after a completed turn.toolscontributes model tools bound to the active memory scope.
eve calls recall and capture handlers at fixed boundaries. Each handler
receives a boundary-specific context with current turn coordinates, a stable
operation ID, and the locked memory scope resolved for the slot. Tools are
resolved once after turn-start recall through the same durable
dynamic-capability machinery as a turn.started defineDynamic tool resolver.
Memory definitions and provider tools compile through eve's canonical source
graph. Each selected memory slot retains its direct binding and induces one
derived tools/<slot>.ts module from a registered programmatic template. The
template exports an ordinary defineDynamic resolver. It qualifies provider
tool keys and passes them through the existing source composition, binding,
module-map, dynamic-tool, approval, authorization, and replay paths. Memory
does not add a runtime tool contribution seam or a second executable registry.
turn.started ---> recall["turn.started"] ---> tools
compaction.requested ---> capture["compaction.requested"]
compaction.completed ---> recall["compaction.completed"]
turn.completed ---> capture["turn.completed"]
eve owns namespace and scope resolution, invocation order, recall validation and application, recall-record attribution and visibility, projection, tool qualification, and replay behavior. The provider owns storage, retrieval, ranking, extraction, formatting, retention, and its model-facing operations. A hosted semantic service and a bounded text file implement the same provider contract without sharing a storage model.
Public API at a glance
| Import path | Public surface |
|---|---|
eve/memory |
defineMemory, defineMemoryProvider, defaultNamespace, provider contexts, scope types, and recall types |
eve/memory/scope |
byPrincipal |
eve/memory/file |
fileMemory, inMemory, and the portable document backend contract |
eve/memory/file/vercel |
vercelBlob |
The smallest complete memory slot uses the built-in file provider:
import { defineMemory } from "eve/memory";
import { fileMemory } from "eve/memory/file";
import { byPrincipal } from "eve/memory/scope";
export default defineMemory({
provider: fileMemory(),
scope: byPrincipal,
});
For agent/memory/user.ts, eve derives the slot name user. Provider tools are
qualified with that identity, such as user__save_memory and
user__remove_memory.
Authoring experience
An agent may declare one flat slot or a directory of named slots:
agent/memory.ts # one slot named "memory"
agent/memory/ # directory of named slots
user.ts # slot named "user"
workspace.ts # slot named "workspace"
The flat file and directory forms are mutually exclusive. Each module
default-exports defineMemory(...). The definition contains the provider, an
optional model-facing description, an optional namespace, a required trusted
scope, an optional eve-owned visibility policy, and an optional switch that
suppresses provider tools:
import { defineMemory } from "eve/memory";
import { byPrincipal } from "eve/memory/scope";
import { customMemory } from "../lib/custom-memory";
export default defineMemory({
description: "Personal preferences and durable facts for the authenticated user.",
provider: customMemory,
namespace: (ctx) => `${defaultNamespace(ctx)}:${process.env.DEPLOYMENT_REGION ?? "local"}`,
scope: byPrincipal,
});
The same provider instance may back several slots. Their recalled context, tools, and provider invocations remain independent. The default namespace includes the slot identity, so each slot receives a distinct provider scope key. Definitions that resolve the same custom namespace and scope intentionally share a scope key.
Compilation and provider tools
Memory is a source-graph primitive, not a post-compile runtime attachment. For
agent/memory/user.ts, the compiler selects and binds the memory definition,
then instantiates its registered provider-tool template at tools/user.ts:
memory/user.ts
└─ selected memory definition
└─ registered tools/user.ts template instance
└─ compiled defineDynamic resolver
└─ user__<provider tool key>
The instantiation API takes the memory candidate as its anchor and dependency:
instantiateProgrammaticTemplate({
anchor: memoryCandidate,
dependencies: { memory: memoryCandidate },
logicalPath: `tools/${slot}.ts`,
owner: { kind: "framework", feature: "memory" },
parameters: { memoryExportName, memoryLogicalPath, slot },
template: memoryWrapperTemplate,
});
The registry-issued template handle fixes the loader and module identity. The constructor inherits the anchor's node and layer, derives the source ID from the template, target path, and anchor, and rejects dependencies from another node. A direct target in the same layer wins over the derived module. Memory therefore needs no special registration mode, source layer, or source-ID convention.
The memory definition and wrapper each use the canonical candidate, composition, binding-validation, artifact, module-map, hydration, and runtime resolution pipeline used by other eve sources. The wrapper participates in source selection before normalization; it is never appended to a resolved agent, contributed after preamble resolution, or reconstructed from an optional manifest side table. Ordinary source composition owns replacement and disablement, and the ordinary dynamic-tool lifecycle owns public-name collisions and complete-result failure.
The inspection projection advances /eve/v1/info to version 4. It lists each
selected memory slot with its source provenance and preserves the generated
tool wrapper's required memory-source dependency in the public binding data.
Subagent summaries include their memory-slot count.
The wrapper resolves on turn.started after recall commits. It reads the
already locked slot, invokes provider.tools(context), requires a map of
branded defineTool() values, qualifies each key as
<slot>__<provider tool key>, and prepends the slot description. A disabled
slot, null, or an empty result contributes no tools. Provider factories use
eve's durable callback helpers when their callbacks cannot be stamped by
authored-source transformation.
Every module-map load also memoizes zero-argument definition-factory results
within each module namespace. The direct memory binding and its derived wrapper
therefore receive the same definition and provider instance, including when a
module exports () => defineMemory(...). A separately loaded compile or runtime
module map gets a fresh materialization; there is no process-global provider
registry.
This architecture is a completion gate for the core implementation: filesystem, in-memory, generated, bundled, and hydrated module maps must load the same selected wrapper binding, and a parked provider tool must resume after a fresh-process callback rebind. A memory-only registry, post-resolution tool merge, or logical-path loading fallback fails that gate.
Compiled dynamic resolvers may opt into missing-callback rebinding at the generic compiled-resolver boundary. The memory wrapper uses that path to register the callback implementation needed by a persisted provider tool. The rebind machinery contains no memory types, preserves the parked call's captured closure values, and never reruns provider mutation.
Model-facing description
description is an optional static description of the slot's purpose:
interface MemoryDefinition {
readonly description?: string;
readonly namespace?: MemoryNamespaceDefinition;
readonly provider: MemoryProvider;
readonly scope: MemoryScopeDefinition;
readonly visibility?: MemoryVisibility;
}
The consuming definition owns this description because one provider may back
several destinations with different purposes. For example, two fileMemory()
slots can distinguish personal preferences from shared channel conventions:
import { defineMemory } from "eve/memory";
import { fileMemory } from "eve/memory/file";
import { byPrincipal } from "eve/memory/scope";
export default defineMemory({
description: "Personal preferences belonging only to the authenticated user.",
provider: fileMemory(),
scope: byPrincipal,
});
import { defineMemory } from "eve/memory";
import { fileMemory } from "eve/memory/file";
import { channelScope } from "../lib/channel-scope";
export default defineMemory({
description: "Shared conventions for this channel. Do not store personal preferences here.",
provider: fileMemory(),
scope: channelScope,
});
When the provider returns tools, eve prepends the slot description and two
newline characters to every provider-authored tool description. For example,
the first slot's save_memory description begins with its personal-memory
purpose before the provider's generic save-tool guidance. eve performs this
composition after the tools resolver returns and before the dynamic tool
metadata is captured, so every model step and parked continuation sees the same
description. Omitting description preserves each provider tool description
unchanged. An empty or whitespace-only description is invalid; authors omit the
field when no slot-specific purpose is needed.
The description is trusted application-authored model guidance. eve does not derive it from the slot name, namespace, scope, or request context, and does not expose those values through it. The description is not added to recalled context or the prompt separately, so a provider without tools does not expose it to the model. It helps the model choose among qualified tools but does not enforce authorization or replace scope isolation.
Namespace
Namespace identifies the application-owned memory domain. It may be a string,
null, or a resolver:
interface MemoryNamespaceContext {
/** Absolute application root, used only for local namespace derivation. */
readonly appRoot: string;
/** Graph node that owns the slot. */
readonly node: string;
/** Path-derived slot identity, such as "memory" or "user". */
readonly slot: string;
}
type MemoryNamespaceDefinition =
string | null | ((context: MemoryNamespaceContext) => string | null | Promise<string | null>);
function defaultNamespace(context: MemoryNamespaceContext): string;
After resolving a non-null scope, eve invokes the namespace resolver when it
locks memory for a lifecycle operation. A resolved null disables the slot for
that operation. If namespace is omitted, eve uses the exported
defaultNamespace function as the resolver. defaultNamespace is a pure
function of its context and the deployment environment, so a custom resolver
composes with it:
namespace: (ctx) => `${defaultNamespace(ctx)}:${process.env.DEPLOYMENT_REGION ?? "local"}`,
The default policy separates deployment classes deliberately:
- Production combines the Vercel project, the stable target environment, the graph node, and the slot. Redeployments keep the same namespace.
- Preview additionally includes the deployment's branch or deployment identity, so unrelated Preview deployments in one project never share memory by accident. Redeployments of the same branch keep that branch's namespace.
- Local development combines a digest of the application root with the node and slot. The raw filesystem path is never persisted.
Set namespace to define a custom application domain:
import { defineMemory } from "eve/memory";
import { byPrincipal } from "eve/memory/scope";
import { customMemory } from "../lib/custom-memory";
export default defineMemory({
provider: customMemory,
namespace: "acme:production:support-agent:user-memory",
scope: byPrincipal,
});
A custom namespace is the complete namespace. eve does not append the application root, deployment environment, graph node, or slot to the resolved value.
Scope
Scope identifies the trusted audience or container within a namespace. A scope
resolver receives a read-only snapshot of the authenticated session and active
channel. It may return a string, an array of string components, or null:
type MemoryScopeResolverResult = string | readonly string[] | null;
interface MemoryScopeContext {
readonly abortSignal: AbortSignal;
readonly session: {
readonly id: string;
readonly auth: SessionAuth;
};
readonly channel: {
readonly kind?: string;
readonly continuationToken?: string;
readonly metadata?: Readonly<Record<string, unknown>>;
};
}
type MemoryScopeDefinition =
| string
| null
| ((
context: MemoryScopeContext,
) => MemoryScopeResolverResult | Promise<MemoryScopeResolverResult>);
eve/memory/scope exports the built-in principal resolver:
function byPrincipal(context: MemoryScopeContext): string | null;
Scope must come from authenticated session context, application data, or trusted
channel state. Scope is an authorization partition, not a model-selected
routing hint: the resolver context deliberately excludes messages, user-authored
turn input, and unprojected durable history. eve resolves scope before
namespace. A null scope disables the slot without invoking its namespace
resolver. Otherwise eve resolves the namespace. A null namespace also disables
the slot. A disabled slot does not call the provider, expose its tools, or
include its recalled context in the model request.
For an active slot, eve validates both values and derives the provider scope key from exactly the resolved namespace and scope:
interface MemoryScope {
/** Opaque scope key: a versioned digest of the canonical namespace and scope encodings. */
readonly key: string;
/** Resolved application-owned memory domain. */
readonly namespace: string;
/** Resolved scope exactly as the resolver returned it: a scalar or a tuple. */
readonly value: string | readonly string[];
}
Scope-key encoding
The namespace-plus-scope pair is the slot's scope key, and it is
collision-free by construction. eve never flattens a tuple with a delimiter.
Each input is canonically encoded with a versioned, type-tagged,
length-prefixed UTF-8 representation that distinguishes a scalar from a
one-element tuple, preserves tuple boundaries, and is safe for
delimiter-containing component values. Each encoding is hashed with SHA-256 and
emitted as a typed base64url key (memns1_… for namespaces, memscope1_… for
scopes). The provider-facing composite scope.key is derived from a versioned
encoding of those two opaque keys. Provider item IDs receive the same treatment
(memitem1_…) before they enter durable attribution.
Scalar "a:b:c", tuple ["a:b", "c"], tuple ["a", "b:c"], and scalar "a"
versus tuple ["a"] all produce distinct keys. Durable attribution stores only
the opaque namespace, scope, and item keys — never raw principal, channel,
namespace, scope, or provider item-ID values.
Every recall, capture, and tools call receives this scope. Tools close
over the same locked scope, so the model never selects a different user,
tenant, or container. A conforming provider must apply scope.key to every
downstream read and write. eve cannot prevent faulty provider code from
discarding the supplied scope, but the public contract provides no unscoped
provider invocation path.
eve locks scope for the active turn, including model steps and durable approved call continuations. A standalone manual compaction resolves and locks scope for that operation. Every recalled record remains attributed to the slot, namespace key, and scope key under which it was accepted.
Passing byPrincipal as the scope resolver identifies the authenticated caller
from principal type, authenticator, optional issuer, and principal ID. It is a
pure consumer of the supplied MemoryScopeContext; it does not read ambient or
private runtime state. The function returns null — disabling the slot — when
auth.current is null, for anonymous principals such as none() traffic,
and for runtime principals such as scheduled turns. Anonymous callers never
receive a memory scope, shared or otherwise. Local development authenticates
every request as the shared local-dev principal, so one development machine
resolves one scope.
Principal scope follows the same authenticated caller across channels. Memory
that is safe only within one channel or conversation must include the trusted
channel coordinates explicitly. Use isChannel to narrow authored channel
metadata before reading it:
import { isChannel } from "eve/channels";
import { defineMemory } from "eve/memory";
import { byPrincipal } from "eve/memory/scope";
import slack from "../channels/slack";
import { customMemory } from "../lib/custom-memory";
export default defineMemory({
provider: customMemory,
scope: (ctx) => {
if (!isChannel(ctx.channel, slack)) return null;
const principal = byPrincipal(ctx);
const { channelId, teamId } = ctx.channel.metadata;
return principal === null || channelId === null || teamId === null
? null
: [principal, teamId, channelId];
},
});
The returned array is a three-component tuple. Components may contain any characters, including delimiters, without colliding with another scope. Include a thread or conversation identifier as another component when the provider's data must not cross that boundary. Resolver output is evaluated once when eve locks the operation's memory scopes, and every provider call and tool in that operation uses the locked value.
Disabled-slot diagnostics
A null scope or namespace silently disables a slot — no provider call, no
tools, no recalled context. Because byPrincipal returns null for anonymous
and runtime principals, disablement is a common and correct state. In
development mode, eve emits a diagnostic when a slot resolves disabled, naming
the slot and whether the scope or namespace resolver returned null, without
logging resolved values. Disablement is never an error.
Recall visibility across scope changes
visibility is an eve-owned defineMemory option. It controls which
previously recalled records enter a model request when the slot resolves a
different scope. The option belongs to the consuming memory definition rather
than the provider because eve owns prompt assembly and the application owns the
session's audience boundary:
import { defineMemory } from "eve/memory";
import { byPrincipal } from "eve/memory/scope";
import { customMemory } from "../lib/custom-memory";
export default defineMemory({
provider: customMemory,
scope: byPrincipal,
visibility: "session",
});
type MemoryVisibility = "scope" | "session";
| Value | Model context after a scope change |
|---|---|
"scope" (default) |
Include recalled records whose slot, namespace key, and scope key match the active locks. Exclude records for earlier scopes. |
"session" |
Require slot and namespace key to match, but keep records across scope-key changes within that namespace visible in place. |
Namespace is always an isolation boundary. "session" intentionally retains
prior recall across scope-key changes within one namespace; it never exposes
recall from an earlier namespace. The default favors recalled-context isolation
over cache reuse when the authenticated participant changes.
For a Slack thread where one authenticated participant follows another,
"scope" removes the first participant's recalled memory before the second
participant's model call. "session" keeps both visible. The second mode is an
explicit cross-scope disclosure policy and is appropriate only when the session
participants form one trusted audience.
durable: ... -> participant A memory -> participant A turn -> assistant -> participant B memory -> participant B turn
scope: ... -> participant A turn -> assistant -> participant B memory -> participant B turn
session: ... -> participant A memory -> participant A turn -> assistant -> participant B memory -> participant B turn
This option never changes provider scope. recall, capture, and tools
still receive only the active turn's locked scope. The provider cannot select
the visibility mode. A null scope suppresses all recalled context belonging
to the slot in both modes.
Recall visibility changes only scope-attributed recall records in the model request. It does not remove ordinary user, assistant, or tool messages from durable history, and it cannot undo information an earlier assistant response derived from memory. Applications that require hard isolation between participants must use separate sessions. Provider tools that return memory content place that content in ordinary tool history and therefore own the same shared-session risk.
Provider contract
MemoryProvider defines the operations available at memory lifecycle
boundaries. recall is required. capture and tools are optional.
import type { ModelMessage } from "ai";
import type { SessionContext } from "eve/context";
import type { DynamicResolveContext } from "eve/tools";
interface MemoryRecallMessage {
/** Provider context recalled into durable model history as a user-role message. */
readonly content: string;
/**
* Optional provider-owned item identity, unique within the slot, namespace,
* and scope. A keyed message inserts or replaces its item; an unkeyed
* message appends immutably.
*/
readonly id?: string;
}
type MemoryRecallResult = { readonly messages: readonly MemoryRecallMessage[] } | null | undefined;
/** Mirrors the ambient `ctx.session.turn` coordinates plus the turn input. */
interface MemoryTurnContext {
/** Stable turn ID, matching `ctx.session.turn.id`. */
readonly id: string;
/** Zero-based durable turn sequence. The first turn is sequence 0. */
readonly sequence: number;
/** Normalized model messages accepted as input for this turn. */
readonly input: readonly ModelMessage[];
}
interface MemoryOperationContext extends SessionContext {
readonly abortSignal: AbortSignal;
/** Projected model history at this lifecycle boundary, including visible recalled context. */
readonly messages: readonly ModelMessage[];
/** Identifies one logical recall or capture operation across workflow replay. */
readonly operationId: string;
readonly memory: {
readonly scope: MemoryScope;
/** Path-derived slot identity, such as "memory" or "user". */
readonly slot: string;
};
}
interface MemoryTurnStartedContext extends MemoryOperationContext {
readonly turn: MemoryTurnContext;
}
interface MemoryCompactionCompletedContext extends MemoryOperationContext {
/** Null for standalone manual compaction. */
readonly turn: MemoryTurnContext | null;
readonly compaction: {
readonly modelId: string;
};
}
interface MemoryCompactionRequestedContext extends MemoryOperationContext {
/** Null for standalone manual compaction. */
readonly turn: MemoryTurnContext | null;
readonly compaction: {
readonly modelId: string;
readonly usageInputTokens: number | null;
};
}
interface MemoryTurnCompletedContext extends MemoryOperationContext {
readonly turn: MemoryTurnContext;
}
type MemoryRecallHandler<TContext extends MemoryOperationContext> = (
context: TContext,
) => MemoryRecallResult | Promise<MemoryRecallResult>;
type MemoryCaptureHandler<TContext extends MemoryOperationContext> = (
context: TContext,
) => void | Promise<void>;
interface MemoryToolsContext extends DynamicResolveContext {
readonly turn: MemoryTurnContext;
readonly memory: {
readonly scope: MemoryScope;
/** Path-derived slot identity, such as "memory" or "user". */
readonly slot: string;
};
}
type MemoryToolSet = Readonly<Record<string, MemoryToolDefinition>>;
interface MemoryProvider {
readonly recall: {
readonly "turn.started": MemoryRecallHandler<MemoryTurnStartedContext>;
readonly "compaction.completed"?: MemoryRecallHandler<MemoryCompactionCompletedContext>;
};
readonly capture?: {
readonly "compaction.requested"?: MemoryCaptureHandler<MemoryCompactionRequestedContext>;
readonly "turn.completed"?: MemoryCaptureHandler<MemoryTurnCompletedContext>;
};
readonly tools?: (context: MemoryToolsContext) => Promise<MemoryToolSet | null>;
}
Recalled context is always user-role. Recall is framework context appended
after the durable prefix and before the admitted turn input, so it preserves
the prompt-cache prefix in the normal case. eve does not offer a system-role
recall option: system-role content is position-independent, so keyed
supersession cannot compose with it, and any change to it invalidates the
prompt cache from message zero. Standing system-role guidance derived from
runtime state belongs in defineDynamic instructions.
defineMemoryProvider(...) is an identity-with-types helper. It does not add
storage behavior.
Recall messages
A recall result is a set of messages applied to the slot's recalled context,
not a replacement snapshot. The optional id on each message selects the
semantics:
| Message | Effect |
|---|---|
{ content, id } |
Insert or replace the one item identified by id. Identical content is a no-op; changed content supersedes the older version, which leaves model context. |
{ content } |
Append one immutable, unkeyed record. It cannot be updated later. |
A result of { messages: [] }, null, or undefined changes nothing at this
boundary. The semantics that hold at every boundary:
- Recall is observational. Providers must not use it for external mutations. eve makes the durable commit atomic, but it cannot roll back provider side effects.
- Accumulation is the only mode. An item missing from a later result
remains visible. Missing top-k retrieval results never imply removal. A
provider that wants content replaced returns the same
idwith new content. - Item identity is
(slot, namespaceKey, scopeKey, idKey), where each key is an eve-owned opaque digest. The same provideridunder two slots, namespaces, or scopes identifies two independent items; neither can update or reveal the other. - Unkeyed messages are never content-deduplicated. A repeated identical unkeyed message adds a second copy. Content a provider might return again must be keyed.
- Batches validate completely before applying. Empty or blank content, empty or oversized IDs, duplicate IDs within one batch, and unknown message keys are rejected, and a rejected batch applies nothing. Message order within a batch is preserved.
For example, keyed retrieval results [A, B] followed by [B, C] leave A,
B, and C visible, with B present once.
Example: append-only event log
A principal-scoped provider that surfaces each new observation exactly once uses unkeyed messages and never needs identity. Because unkeyed messages are never deduplicated, the provider consumes each note rather than re-reading the latest state:
import { defineMemoryProvider } from "eve/memory";
const auditMemory = defineMemoryProvider({
recall: {
async "turn.started"(ctx) {
const note = await takeUnreadNote(ctx.memory.scope.key);
return note === null ? null : { messages: [{ content: note }] };
},
},
});
Example: bounded sparse retrieval
A vector-retrieval provider returns a different top-k subset each turn.
Because accumulation is monotonic, an unbounded stream of new IDs would grow
context forever. Keying results by window position bounds the visible set by
construction: each recall reuses the same k identities, superseding the
previous occupant of each position:
import { defineMemoryProvider } from "eve/memory";
const retrievalMemory = defineMemoryProvider({
recall: {
async "turn.started"(ctx) {
const hits = await search(ctx.memory.scope.key, ctx.turn.input, { limit: 3 });
return {
messages: hits.map((hit, rank) => ({ id: `rank:${rank}`, content: hit.text })),
};
},
},
});
A provider that keys by document identity instead (id: hit.documentId) keeps
every previously recalled document visible until it overwrites that identity.
Both are valid; the provider chooses its window policy.
Rejected and deferred alternatives
Two richer recall shapes were considered and cut:
- A tagged change union with an explicit
retract. No current provider needs a keyed item to disappear rather than be replaced:fileMemory()supersedes its whole document, including an empty-state rendering when the last entry is removed, and bounded retrieval reuses window identities. If a provider later needs true keyed removal, a retract form is an additive extension, and dropping it now also drops the private tombstone representation that projection would otherwise have to hide. - A
coverage: "complete"inventory flag, from which eve synthesized retractions for omitted IDs. No first-party provider consumes it, and omission-means-deletion is the sharpest foot-gun in a sparse-retrieval API. An inventory result shape remains an additive extension if a real enumerable provider needs framework diffing.
The { messages } wrapper leaves room for future result-level metadata
without a breaking change.
Durable records and projection
Every accepted recall message becomes an internal durable record attributed with the slot, opaque namespace key, opaque scope key, opaque item key for keyed messages, operation ID, and batch position. Raw durable history is storage-only. Every message-bearing consumer — providers, dynamic resolvers, approval and tool contexts, token accounting, compaction, instrumentation callbacks, and the model request — receives a view derived from one canonical scope projection, never raw durable history.
Projection folds records per slot and identity:
- visible unkeyed records always remain, in position;
- only the latest version of each keyed identity is visible, in the position of its accepted batch; superseded versions are hidden;
- attribution is stripped before any authored callback, tool, or model boundary. There is no public accessor for recall attribution; providers use stable item IDs instead of scanning history.
Recalled context is not emitted as message.received; it is framework context
rather than new channel input.
Cache consequences
New items append at the tail and preserve the existing prompt prefix. Repeated identical keyed items change nothing. An explicit update must stop the model from seeing the prior value, and filtering that older occurrence necessarily invalidates the prompt cache from that point forward. This is a deliberate trade: pure append-only recall is strictly better for prompt cache on updates, because the old copy would keep its position — but it leaves the contradicted copy model-visible, which makes updates and removals nondeterministic after compaction. Keyed supersession spends a mutation-driven cache invalidation to buy deterministic visibility; it is not cache-neutral, and it never substitutes a natural-language correction while leaving the contradicted content visible.
Within one session, the model also sees the mutation narrative — the old recalled copy, then the tool calls that changed storage. Keyed supersession earns its complexity at the boundaries where that narrative dies: compaction drops tool results first, so a stale recalled copy can outlive the story of its own removal and be summarized as fact. Deterministic supersession, not model competence, is the design driver.
Lifecycle
Turn-start recall
After eve admits and normalizes a new turn, it resolves each memory scope and,
for a non-null scope, its namespace. It then calls the required
recall["turn.started"] handler for every active slot. The context contains
the zero-based turn sequence, stable turn ID, normalized input, and the
projected history before the turn, including prior visible recalled context.
Every active slot resolves independently against that same pre-recall view. Each result is normalized and validated, and the whole turn-wide batch commits atomically: one failing slot or one invalid batch applies nothing for any slot. Accepted messages are applied in stable slot-path order, after the durable prefix and before the admitted turn input, and the projected view is recomputed before tools resolve and the model runs.
The first turn has ctx.turn.sequence === 0. A provider does not need to
deduplicate its own recall: returning the current state as keyed messages every
turn is the intended pattern, because identical keyed messages are no-ops. A
provider may still use the turn coordinates to recall only on the first turn,
or use the current input as a retrieval query on every turn.
Compaction capture, canonicalization, and recall
Before automatic or manual compaction rewrites history, eve calls an
implemented capture["compaction.requested"] handler. The provider receives
the projected pre-rewrite history and the compaction model and usage metadata.
A provider may persist a checkpoint, extract facts the summary could omit, or
do nothing.
Compaction is the canonicalization boundary for memory records. Trusted internal code partitions every attributed memory record — across every slot, namespace, scope, and visibility mode — away from ordinary conversation, keeps one live keyed record per identity, and retains every immutable unkeyed append with its attribution. The free-form summary prompt is built from projected ordinary conversation only; no attributed memory record of any kind enters it. Canonical private memory state is reattached to the rewritten history, so items hidden from the currently active scope survive another scope's compaction, and sparse retrieval stays accumulative across compaction.
Because superseded versions and hidden scopes can grow while the visible prompt stays constant, eve triggers canonicalization on raw attributed-record growth independently of visible prompt size. eve never silently summarizes, evicts, or truncates provider items.
After a checkpoint is durably appended, eve calls an implemented
recall["compaction.completed"] handler. The provider receives the settled
post-compaction projected history and may return fresh messages, applied
through the same atomic path. Identical retained items are no-ops. The handler
runs after every successful automatic or manual compaction when registered,
even when the provider skipped ordinary turn-start recall.
Provider tools are not resolved during a standalone compaction because no model call follows that boundary.
Context clear
context.cleared rewrites history outside compaction. A clear wipes attributed
memory records together with the conversation: the cleared session starts with
no recalled context, and the next turn-start recall repopulates it from
provider storage through the normal path. Memory durability lives in provider
storage, not in session records, so a cleared conversation recalls current
memory exactly once, with no stale copies. This behavior requires a direct
test: clear, then assert the next turn recalls the provider's current state
exactly once.
Turn tools
After turn-start recall settles, eve resolves tools once for the active turn.
The function may be synchronous or asynchronous. Its context contains the same
session, authentication, channel, and message fields as a defineDynamic
resolver, plus the locked memory scope, slot, and turn. Its messages include the
projected turn-start recall results followed by the admitted turn input.
Returning null or an empty record exposes no tools for the slot.
The compiler-owned wrapper makes the memory definition implicit
defineDynamic authoring: it adapts each implemented tools method to a
turn.started dynamic resolver before source selection and normalization.
Provider tools are ordinary branded defineTool() entries and depend entirely
on the generic dynamic-tool engine and canonical source graph. Memory adds no
tool machinery of its own: no runtime contribution seam, memory-specific
approval methods, separate callback registry, parked-call origin store, or
defineMemoryTool wrapper.
Tools never write recalled context. A tool executor mutates provider storage and returns ordinary tool output; recall is the single writer of recalled context, and the consistency contract is that the next recall boundary reflects storage. Within the turn that mutated storage, the stale recalled item remains visible and the model's own tool-call narrative covers the gap; the next turn-start or post-compaction recall trues it up.
eve qualifies every returned key as <slot>__<tool> and binds the locked scope
to the tool implementation. Provider tools use the standard tool contract,
including input and output schemas, approval, authorization, and model-output
projection. In particular, once() approval is session-wide: approving one
qualified tool name also approves that name after a scope change. Providers
should use always() or a custom policy when approval must be participant- or
scope-specific.
The resolved tool set remains stable across every model step in the turn. Dynamic callback identity is the final qualified tool name plus callback phase, and closure values — including the locked memory scope — are snapshotted when the tool set resolves. After callback-registry loss or a redeploy, the compiled wrapper binding re-registers a missing implementation through the generic dynamic-tool lifecycle. A resumed call runs the latest deployed callback implementation with the captured closure values; a renamed or removed tool fails closed. Callback forms the descriptor mechanism cannot express fail with an explicit diagnostic at resolution time rather than silently degrading at replay.
A direct inline-authorization park follows the ordinary tool contract. The unfinished assistant/tool exchange does not enter durable history, and the callback makes the credential available only to its matching principal. It does not replay the original tool execution. A later model step uses the same turn-scoped tool set.
Workflow replay returns recorded results for committed resolutions. Resolver
code may run again when an uncommitted delivery is re-executed after a crash,
or once to rebind a missing session-scoped callback, so resolver side effects
must be idempotent; provider mutations belong in the returned tools' execute
functions. Callback rebinding may select newer deployed code, but it never
replaces the parked call's captured scope values with another turn's scope.
Completed-turn capture
After a turn reaches turn.completed, eve calls an implemented
capture["turn.completed"] handler. The provider receives the completed turn
input and the settled projected history, including the assistant response and
tool results. The handler does not run for failed, cancelled, input-deferred,
or adapter-consumed turns.
Completed-turn capture is a semantic memory boundary, not an instrumentation
export. It does not receive token usage, provider cost, latency, trace
identifiers, or unsuccessful outcomes. A provider that also consumes
instrumentation can correlate the two surfaces with ctx.session.id and
ctx.turn.id. The usageInputTokens field on compaction.requested is
specific to that boundary because it describes the context about to be
compacted.
eve awaits completed-turn captures before emitting session.waiting in
conversation mode or session.completed in task mode. A provider may capture
the turn, update a remote profile, enqueue its own work, or do nothing.
Replay and failures
Every recall and capture invocation receives an operationId for one logical
slot operation. eve reuses the ID across workflow replay, so it is not a unique
callback-attempt identifier. A provider must use it as the idempotency key for
externally visible capture side effects.
Recall operations for one session are serialized by construction: turn steps serialize through workflow hook-ownership claims, so two recall operations for the same slot, namespace, and scope cannot complete out of order. The message shape is therefore revision-free. eve persists each operation's accepted normalized batch, or its canonical digest, and a cold replay must reuse the stored batch or match its digest exactly; a replay that returns a reordered or changed batch for the same operation ID fails explicitly and mutates nothing. Batch ordinals identify entries only within that fixed accepted batch.
Failure behavior follows the point at which the method runs:
- A turn-start
recallfailure fails the active turn before the model runs. Any slot failure or invalid batch applies nothing for the entire turn-wide recall batch. - A
compaction.requestedcapture failure aborts compaction before history changes. - A post-compaction
recallfailure cannot undo the checkpoint. It fails the active turn when compaction was automatic; standalone compaction emits a diagnostic and returns the session to waiting. - A throwing or invalid
toolsresult is diagnosed and omitted for the turn, matchingdefineDynamictool resolution. - A completed-turn
capturefailure cannot rewrite the completed response. eve emits a content-free diagnostic and continues to the ready boundary. - A
nullscope skips operations; it never falls back to a shared key.
Limits
All limits reject invalid or oversized input with actionable errors. eve never silently truncates or evicts. Values below are the proposal-review targets; implementation uses exactly the reviewed constants.
| Constant | Value | Applies to |
|---|---|---|
| Namespace | 1,024 UTF-8 bytes | resolved namespace value |
| Scope component | 1,024 UTF-8 bytes | each scalar or tuple component |
| Scope tuple | 16 components | resolver array results |
| Canonical key input | 4,096 UTF-8 bytes | total namespace-plus-scope encoding |
| Provider item ID | non-empty, 1,024 UTF-8 bytes | keyed message id |
| Key prefixes | memns1_, memscope1_, memitem1_ |
SHA-256 digests, base64url |
| Raw-record canonicalization | 512 records or 262,144 bytes | superseded/hidden attributed records between compactions |
| File entry | 2,048 UTF-8 bytes | normalized save_memory text |
| File document | 65,536 UTF-8 bytes | exact serialized stored document, including header |
| Recalled file memory | maxCharacters, default 4,000 |
exact rendered keyed message after save_memory |
Built-in file memory
fileMemory() is the reference bounded-document provider. It stores one
indexed MEMORY.md-style document per memory scope key.
interface FileMemoryOptions {
readonly backend?: MemoryDocumentBackend;
/** Defaults to 4,000. */
readonly maxCharacters?: number;
}
function fileMemory(options?: FileMemoryOptions): MemoryProvider;
Recall: one whole-document keyed message
recall loads the current document at every turn start and after every
successful compaction, renders it — a heading that names the slot, explains the
indexed entries, and states the exact qualified removal tool name, followed by
each live index: text line — and returns exactly one keyed message under a
single stable provider ID. An unchanged document is an identical keyed message
and therefore a no-op; the provider never scans history. A mutated document
supersedes the previous copy, so a removed or edited entry deterministically
leaves model context at the next recall boundary, with no stale copy left
visible. A document whose last entry was removed renders an empty state — the
heading plus a line stating no memories are saved — so removal of the final
entry supersedes the old content the same way. A missing document, one never
created for this scope key, recalls nothing, so sessions that never save
memories carry no recalled context.
Whole-document granularity is a provider choice, not an API requirement: each mutation re-sends the document and invalidates the cache from the document's previous position, while non-mutation turns cost nothing. Per-entry keyed messages remain a legal finer-grained alternative for providers whose removals can supersede per item.
Tools
tools exposes save_memory({ text }) and remove_memory({ index }). Each
tool completes after its conditional write and returns no output; the next
recall reflects the updated document. save_memory normalizes whitespace,
treats duplicate text as a successful no-op, and fails when adding the entry
would make the exact rendered recall message exceed maxCharacters.
remove_memory is a no-op when its index is absent. The provider omits
capture: it does not run an extraction model or persist whole conversations.
Storage format
The stored document has a versioned header that persists lastAllocatedIndex
(starting at -1) alongside the live indexed lines. Indexes are allocated
monotonically and never renumbered or reused, including after the highest live
index is removed, so a model-facing index from earlier context can never alias
a different, later fact. Removing an entry frees character capacity without
resetting the high-water mark. When lastAllocatedIndex reaches
Number.MAX_SAFE_INTEGER, the next save fails explicitly. Removal visibility
comes from the changed document superseding the previous copy, so the document
needs no deletion tombstones. Header bytes count toward the document limit.
Backend contract and selection
The provider uses one portable conditional-document backend:
interface MemoryDocument {
readonly content: string;
readonly version: string;
}
interface MemoryDocumentBackend {
read(input: { key: string; signal: AbortSignal }): Promise<MemoryDocument | null>;
write(input: {
content: string;
expectedVersion: string | null;
key: string;
signal: AbortSignal;
}): Promise<MemoryDocument>;
}
Every backend implements the same optimistic read/replace contract. The default backend fails closed and resolves lazily on first storage access:
| Environment | Default backend |
|---|---|
| Vercel with Blob credentials (token, or attached store with OIDC) | Private Vercel Blob |
| Vercel without Blob configuration | Error: add file memory, run setup directly, or pass a backend |
eve development environment (eve dev) |
Shared process-local in-memory backend |
Every other environment, including unset or unknown NODE_ENV |
Error: explicit backend required |
The default Vercel backend prefers the file-memory namespace before generic
application bindings: EVE_MEMORY_BLOB_READ_WRITE_TOKEN, then
EVE_MEMORY_BLOB_STORE_ID with Vercel OIDC from the environment or request
context, then the corresponding generic BLOB_* credentials. Direct
vercelBlob() options retain their existing behavior.
Vercel detection takes precedence over development flags, a Blob token outside
Vercel never silently selects Blob, and NODE_ENV alone never proves a
development environment. An explicit fileMemory({ backend }) is used in every
environment. Tests pass inMemory() explicitly.
Vercel provisioning
The official memory/file registry item writes agent/memory/file.ts and
declares the trusted eve integration setup file-memory flow. After the
registry review authorizes setup, the shared setup runner owns Vercel CLI
installation, login, project creation or linking, cancellation, environment
pulling, and optional deployment. File memory adds no dashboard automation or
runtime provisioning dependency.
Preparation resolves the first region from the linked root's
vercel.json.regions, the project's resourceConfig.functionDefaultRegions,
the project's defaultResourceConfig.functionDefaultRegions, or iad1, in
that order. Apply shows the resolved project, deterministic store name, region,
target environments, and usage-charge warning before mutation. It creates a
private Blob store when needed and connects production, preview, and
development with the EVE_MEMORY_ prefix.
Reconciliation owns exactly one eve file-memory store per Vercel project. It
reuses one valid namespaced private connection, repairs the deterministic
unconnected private store left by a partial run, and otherwise creates a new
store. It never adopts arbitrary application stores, changes a generic
BLOB_* connection, or deletes or replaces a store. Ambiguous, public, and
incompatible eve-prefixed resources fail with recovery guidance. Region drift
on an already connected store warns and preserves the store so setup cannot
cause memory loss.
The same lifecycle also supports a hosted semantic provider:
| Boundary | Bounded file provider | Hosted semantic provider |
|---|---|---|
turn.started recall |
Recall the current document | Recall keyed retrieval results for the input |
| Post-compaction recall | Recall if context is absent | Refresh against the compacted history |
| Pre-compaction capture | Omit | Preserve facts or checkpoint provider state |
| Completed-turn capture | Omit | Capture the completed interaction |
| Turn tools | Save and remove entries | Provider-defined search, save, or forget |
At each boundary, a provider may return undefined from recall, omit or do
nothing in capture, or return null from tools.
Provider packaging
A provider package exports a MemoryProvider or provider factory. It may own
credentials, remote APIs, migrations, retrieval, capture, and model tools. The
consuming agent owns the slot path, namespace, scope, tool suppression, and
recall visibility. Mounted extensions cannot contribute memory slots.
Design invariants
- A slot is active only when both namespace and scope resolve to non-empty
values. Omitting
namespaceselectsdefaultNamespace;nulldisables the slot, with a development-mode diagnostic and never an error. - The resolved namespace and scope are the only variable inputs to the provider key, canonically encoded and digested so scalars, tuples, and delimiter-containing values cannot collide. Custom namespaces receive no path or deployment suffixes.
- One scope lock applies to every provider call, recalled record, model step, and durable tool continuation in an operation.
- Nested recall and capture handler keys identify the exact lifecycle boundary. Every provider context includes projected history and a replay-stable operation ID.
- Raw durable history is storage-only. Every message-bearing consumer receives one canonical scope projection with attribution stripped; no authored callback or model boundary sees hidden items or eve-owned metadata.
- A recall result is a validated batch of recalled messages, committed atomically per turn across all active slots. Omission never removes; unkeyed messages are immutable; an identical keyed message is a no-op; a changed keyed message supersedes only its own prior version.
- Item identity is
(slot, namespaceKey, scopeKey, idKey). The same provider ID under different locks is a different item. visibilitycontrols which attributed records enter a model request after a scope-key change within one namespace. Namespace is always an isolation boundary.- Compaction canonicalizes memory records without laundering any attributed record into a free-form summary and without dropping items hidden from the active scope.
- Provider tools are slot-qualified, scope-bound ordinary dynamic tools built entirely on the generic dynamic-tool engine. Each turn resolves one complete tool set, every model step uses it, and callback replay uses the captured closure values with the implementation registered for the same qualified name and phase. Tools never write recalled context.
- An optional static slot description is prepended to every provider tool description before durable metadata capture. It never derives from or exposes the namespace or scope, and it guides model routing without granting access.
- Completed-turn capture settles before the next ready boundary.
Non-goals
One earlier non-goal is explicitly reversed: this proposal gives eve a projection record model — item identity and supersession semantics for recalled context. eve owns how recalled items appear and update in model context. eve still owns no storage model: what a provider persists, how it ranks, embeds, extracts, or retains data remains entirely provider-defined.
Still out of scope:
- Framework-provided remember, forget, purge, export, or administrative APIs. Supersession changes model-visible context; it is not a storage-erasure guarantee, and storage erasure remains a provider operation.
- A built-in capture model, extractor, formatter, retention policy, or erasure guarantee.
- A memory-specific observability feed for usage, cost, latency, traces, errors, or cancelled turns.
- Cross-provider search, mutation, or record reconciliation.
- Model-selected alternate scopes or unscoped provider invocations.
- Treating recall visibility as complete participant isolation for ordinary conversation messages or provider tool results.
- Preventing faulty or malicious provider code from ignoring the supplied scope.
- Standardizing provider credentials, migrations, inspection tools, or deployment operations beyond the built-in file-memory setup.
Implementation boundary
The generic prerequisites are on main: the projected-history seam
(#2352), durable dynamic callbacks
(#2354), name-and-phase callback
identity (#2384), and the canonical
source graph, binding table, and invariant-safe derived-template API
(#2404,
#2516,
#2539). The runtime-tool contribution
proposal in #2347 is superseded;
memory uses the compile-time wrapper described above. The kernel-effects
follow-up to #2516 is not a prerequisite because provider tools have ordinary
executors rather than native kernel effects.
Implementation proceeds in three pull requests:
- The first-class memory core is implemented in
#2534, rebased directly onto
current
main. It uses #2539's registered template API and generic per-module-map factory materialization, then adds the memory authoring and compiler surface, namespace and scope locks, recall records and projection, lifecycle, compaction, agent-info v4, published documentation, and deterministic end-to-end coverage. It does not restack or reuse the custom runtime lifecycle from #2142. - The bounded
fileMemory()provider in #2580 is stacked directly on #2534. It retains only provider storage, document, backend, and concurrency work and includes final file-provider e2e coverage. The separate e2e tail in #2145 is superseded. - The official
memory/fileregistry item andeve integration setup file-memoryflow provision and reconcile a dedicated private Vercel Blob store through the existing registry-owned setup runner. Runtime selection prefers the namespaced binding and retains generic Blob credentials as the manual-attachment fallback.
#2534 implements the core boundary through one selected source and binding authority for every memory definition and provider-tool wrapper, one materialization per definition factory in a module-map load, one projected history for message-bearing consumers, private memory canonicalization outside ordinary summarization, and generic cold callback rebinding with captured closures. Those invariants, the committed E2E fixture, and the required CI checks remain the merge gate. M2 must not begin on the pre-M1 base.
Primary references
- Programmatic agent sources
- Supermemory: how it works
- Hermes Agent memory
- eve dynamic capabilities
- eve agent configuration
- eve turn execution
Review checklist
-
Make channel-aware scope authorable. Custom scope resolvers receive a read-only
MemoryScopeContextwith the abort signal, authenticated session, and channel metadata, but no messages or turn input.byPrincipalconsumes that public context without private runtime access. Resolver arrays are canonically encoded as collision-free tuples, whilenullpreserves slot disablement. The authoring guidance states that principal scope crosses channels and shows how to add team, channel, and conversation coordinates for private memory. This resolves the scope and Slack privacy thread. -
Keep settled-turn telemetry out of memory.
capture["turn.completed"]receives completed input and projected history, but not usage, cost, latency, trace identifiers, or unsuccessful outcomes. Instrumentation owns that data and can be correlated throughsession.idplusturn.id.compaction.requestedretains its input-token count because that value describes the context being compacted. This resolves the usage and trace thread and terminal outcome thread. -
Give the model a safe memory-slot purpose. The consuming
defineMemory(...)may supply an optional staticdescription. eve validates it and prepends it to every provider tool description before storing durable dynamic metadata. Omitting it preserves provider descriptions unchanged. The proposed wrapper neither derives nor exposes namespace or scope values, and the contract distinguishes model routing guidance from authorization. Core coverage must verify that two slots sharing one provider receive different descriptions without mutating provider tools; file-memory integration and e2e must exercise describedfileMemory()slots. This resolves the model-facing attribution thread. -
Define recall placement and tool invocation semantics. A recall result is a validated batch of recalled messages applied atomically at its lifecycle boundary as user-role context; a keyed message supersedes its prior version.
null,undefined, empty batches, and normalized empty content change nothing and never clear earlier history; omission never removes. Session visibility retains records across scope-key changes within one namespace; scope visibility filters earlier scopes. eve invokesrecalldeterministically and resolves one tool set per turn, while the model decides whether to call an exposed tool. This resolves the replacement and cache thread, suffix placement thread, recall versus tool thread, null sentinel thread, and falsy return thread. -
Reconcile review threads with the final contract. Provider
ctx.messagesis the projected history at each boundary, and framework identity deduplication replaces provider-side history scanning — there is no public attribution accessor. The namespace/scope split, lifecycle phases, telemetry boundary, and cross-provider behavior match the intended implementation. All outdated, approval-only, and superseded threads are resolved, including the cross-provider thread and the outdated turn-input thread.