Memory
Block-based memory primitives for prompts, agents, stores, and observability.
import {
memory,
memoryBlock,
recentMessages,
workingState,
episodes,
facts,
procedures,
reflections,
} from "@use-crux/core/memory";memory(config)
Composes memory blocks into a prompt-usable memory object.
const mem = memory({
id: "assistant",
records,
namespace: ({ input }) => `user:${input.userId}`,
capture: { mode: "deferred" },
budget: { maxTokens: 1200 },
blocks: [
recentMessages({ id: "recent" }),
facts({
id: "facts",
embed: dense,
render: {
strategy: "semantic",
query: ({ input }) => String(input.message),
limit: 5,
},
}),
],
});memory() returns an object with _tag: 'Memory', asContext(), asTools(), captureTurn(), captureToolEvent(), flush(), and proposals.
Prompts can use the memory object directly:
prompt({
use: [mem],
// ...
});MemoryConfig
interface MemoryConfig {
id: string;
storage?: Storage;
records?: RecordStore;
vectors?: VectorStore;
namespace: MemoryNamespace;
blocks: readonly MemoryBlock[];
capture?: MemoryCaptureConfig;
budget?: MemoryBudget;
}Dynamic namespaces are resolved consistently for context rendering, capture, direct block calls, proposal operations, and tools. asTools() is synchronous, so async namespaces or async block tools throw clear errors at that boundary instead of falling back to an empty namespace or silently dropping tools.
budget.maxTokens is enforced for the composed memory context. Blocks render in priority order; lower-priority sections are trimmed or dropped before higher-priority sections.
memoryBlock(config)
Creates a custom memory block.
const block = memoryBlock({
id: "custom",
kind: "custom",
priority: 50,
budget: { maxTokens: 300 },
render: async (ctx) => "Rendered memory",
captureTurn: async (turn, ctx) => {},
tools: (ctx) => ({}),
});A block can implement render, tools, turn capture, tool-event capture, flush, and proposal approval independently.
Built-In Blocks
recentMessages({ id, maxMessages?, priority? }) stores a bounded rolling message window.
Methods: addTurn(turn, options), list(options), clear(options).
workingState({ id, schema, priority? }) stores one Zod-validated value.
Methods: get(options), set(value, options), patch(value, options), clear(options).
episodes({ id, embed?, priority?, retention? }) stores append-only events and recalls them with a dense embedding when available. retention (e.g. '90d') is a descriptive eviction-window policy surfaced on every read/write event's metadata.
Methods: record(entry, options), recall(query, options), list(options), delete(key, options), evict(key, options).
evict(key, options) is delete for retention sweeps: it removes the entry and emits an evict write op carrying GC telemetry (lastGcAt, lastGcEvicted) so evictions are attributable in devtools rather than silent. options extends the runtime options with evictedCount? and gcAt?.
facts(config) stores extracted facts. procedures(config) stores extracted operating memory. Both support proposed writes by default.
Methods: add(entry, options), find(query, options), list(options), delete(key, options), render(options).
Entry-style memory blocks accept built-in render strategies:
type MemoryEntryRenderStrategy =
| {
strategy?: "list" | "recent";
limit?: number;
filter?: Record<string, unknown>;
}
| {
strategy: "semantic";
query: string | ((ctx: MemoryBlockContext) => string | Promise<string>);
limit?: number;
filter?: Record<string, unknown>;
};episodes, facts, and procedures support render: false to store memory without rendering it. facts and procedures also accept a custom render function.
reflections(config) stores generated reflections on supplied memory text. It is a small wrapper for reflection workflows and can be expanded by custom blocks when products need richer behavior.
Methods: reflect(options), plus the extractive block's add(entry, options), find(query, options), list(options), delete(key, options), and render(options).
Runtime Options
Direct block methods accept:
type MemoryRuntimeOptions = {
storage?: Storage;
records: RecordStore;
vectors?: VectorStore;
namespace: string;
memoryId?: string;
traceId?: string;
promptId?: string;
};Use the same memoryId as the composed memory() object when direct block calls should share storage keys with prompt-bound memory.
Capture
captureTurn(turn, options) captures a completed user/assistant turn and its settled tool events. Memory invokes every block's turn hook first, then each event's block hooks in event order. captureToolEvent(event, options) remains available for standalone tool events with toolCallId, toolName, args, result, and error.
Adapters submit each completed turn once. Memory owns tool-event fan-out, scheduling, and settlement.
await mem.captureToolEvent({
toolCallId: "call_1",
toolName: "search",
args: { query: "pricing" },
result: { hits: 3 },
});capture.mode defaults to deferred:
deferreduses retained execution-scope work when the active host supports it. Without retained capability, the same capture runs inline and is awaited; development builds warn once with host-setup guidance.inlinealways awaits capture and propagates capture errors to the caller.
Configure retention once through config({ host }). A retained capture failure cannot reject an operation that has already returned. flush() waits for work accepted before its call-time cutoff, reports the first unobserved deferred failure, then runs block flush hooks in declaration order. Later captures belong to the next flush.
Eval cells suppress default deferred capture. Use inline with isolated storage when an Eval intentionally needs real writes.
Migration: rename afterResponse to deferred, replace detached with deferred, and remove capture.waitUntil in favor of the shared host binding.
Inspecting capture
The two Devtools surfaces expose separate parts of the contract:
- Catalog → Memory → Binding shows the effective configured mode. An omitted mode appears as
deferred; Catalog cannot predict runtime host retention. - Runs →
memory.captureshows the requested mode, actual disposition, outcome, operation, sequence, block/tool-event counts, and duration for one invocation. The capture span belongs to the owning generation Run.
Runtime disposition is one of inline, inline-fallback, retained, or eval-captured. inline-fallback means deferred was requested but no retained host accepted the work, so capture ran inline. eval-captured records deferred intent without invoking memory hooks.
memory.capture lifecycle evidence never retains messages, tool payloads, namespaces, raw errors, or stacks. Failures expose only explicit failure state and, when available, a sanitized stable machine code. Nested memory.write spans remain the evidence for storage work.
Proposals
memory().proposals manages proposed long-term memory writes:
const pending = await mem.proposals.list({
namespace: "user:123",
blockId: "facts",
status: "pending",
});
await mem.proposals.approve(pending[0].id);
await mem.proposals.edit(pending[1].id, { content: "Edited fact" });
await mem.proposals.reject(pending[2].id, { reason: "Not true" });facts() and procedures() default to write: { mode: 'propose' }. Use write: { mode: 'auto' } when the product has a safe automatic write path. With write: { mode: 'manual' }, capture extracts nothing automatically and writes happen only through direct block method calls.
Proposal transitions are pending-only. Approved and rejected proposals cannot be approved again, rejected again, or edited into duplicate writes.
Observability
Block memory emits memory.read and memory.write spans with:
{
memoryType: 'block',
memoryId: string,
blockId: string,
blockKind: 'recent' | 'working' | 'episodes' | 'facts' | 'procedures' | 'reflections' | 'custom',
namespaceHash: string,
}Reads that return entries also attach memory.recall artifacts with returned and blocks[] summaries (blockKind, key, preview, and optional score). Empty reads emit the read span but do not attach memory.recall, which keeps devtools from showing empty recalled-block cards. Writes with inspectable before/after state attach memory.diff artifacts with before, after, and added/removed/updated block summaries. Write events may also include writeMode and proposalStatus: a proposed fact write appears with writeMode: "propose" and proposalStatus: "pending", and later write observations can show proposalStatus: "approved" or "rejected" once the proposal is resolved.
The runtime keeps raw namespaces out of trace attributes. Use namespaceHash for correlation, then inspect the attached artifact for the safe summary.
These spans flow through the standard observability surfaces: devtools groups memory in the Memory Explorer, the CLI includes it in memory stats, and @use-crux/otel adds block attributes to memory spans.
Budgeted memory rendering emits a composed memory.read span with a memory.snapshot artifact that records candidateBlocks, includedBlocks, trimmedBlocks, droppedBlocks, usedTokens, and maxTokens.
{
primitive: "memory.read",
attributes: {
memoryId: "assistant",
memoryType: "memory",
operation: "render",
namespaceHash: "f0112a03",
budgetMaxTokens: 1200,
budgetIncludedBlocks: ["facts"],
budgetTrimmedBlocks: ["facts"],
budgetDroppedBlocks: ["episodes"],
},
artifact: {
kind: "memory.snapshot",
preview: {
budget: {
maxTokens: 1200,
usedTokens: 1190,
includedBlocks: ["facts"],
trimmedBlocks: ["facts"],
droppedBlocks: ["episodes"],
},
},
},
}