Memory Layer System
How Noetic's memory layers inject long-term and short-term context into every LLM call through a slot-based, scoped architecture.
Overview
Noetic's memory system is built around MemoryLayer objects. Each layer occupies a numbered slot, declares a scope, and implements lifecycle hooks that run before and after every LLM call. The runtime merges all layer outputs into a single prompt view via assembleView().
MemoryLayer Interface
Every memory layer implements this interface:
interface MemoryLayer<TState = unknown> {
id: string;
name?: string;
slot: number;
scope: MemoryScope;
budget?: BudgetConfig;
hooks: MemoryHooks<TState>;
timeouts?: Partial<LayerTimeouts>;
onInitError?: 'throw' | 'disable';
recallMode?: 'atomic' | 'eventual';
}| Field | Type | Purpose |
|---|---|---|
id | string | Unique identifier for the layer |
name | string (optional) | Human-readable display name |
slot | number | Ordering priority -- lower slots appear first in the assembled view |
scope | MemoryScope | Persistence boundary for stored state |
budget | BudgetConfig | Token budget allocation for this layer |
hooks | MemoryHooks<TState> | Lifecycle callbacks |
timeouts | Partial<LayerTimeouts> | Per-hook timeout overrides in milliseconds |
onInitError | 'throw' | 'disable' | What to do when init throws: 'throw' (default) aborts the execution; 'disable' skips this layer for the run |
recallMode | 'atomic' | 'eventual' | Whether recall() blocks the model call ('atomic', default) or is served from a cache that refreshes after store() ('eventual') |
Slot Constants
Slots determine the order in which layer outputs are injected into the prompt. Lower numbers appear first.
const Slot = {
REMINDER: 80,
STEERING: 90,
WORKING_MEMORY: 100,
ENTITY: 150,
OBSERVATIONS: 200,
PROCEDURAL: 250,
EPISODIC: 300,
RAG: 350,
SEMANTIC_RECALL: 400,
} as const;You can use any number for custom layers. The built-in constants are guidelines, not hard constraints.
MemoryScope
Scope controls the persistence boundary of a layer's state:
| Scope | Meaning |
|---|---|
'thread' | State is isolated per conversation thread |
'resource' | State is shared across threads tied to the same resource (e.g., a project or document) |
'global' | State is shared across all executions |
'execution' | State lives only for the duration of a single agent run |
type MemoryScope = 'thread' | 'resource' | 'global' | 'execution';BudgetConfig
Controls how many tokens a layer can consume when injecting items into the prompt:
type BudgetConfig =
| number // fixed token count
| { min: number; max: number } // range -- allocator distributes spare tokens
| 'auto'; // let the runtime decideWhen using a { min, max } range, the budget allocator guarantees at least min tokens and distributes remaining capacity up to max. An omitted budget behaves like 'auto' — the layer splits the proportional pool with the other auto layers after finite layers take their share. The pool is conserved: finite shares plus the auto layers' split always account for the full layer pool. NaN budget inputs throw a NoeticConfigError (INVALID_BUDGET_INPUT); Infinity means uncapped.
ProjectionPolicy
The projection policy governs how the runtime assembles all layer outputs into the final prompt:
interface ProjectionPolicy {
tokenBudget: number;
responseReserve: number;
overflow: 'truncate' | 'summarize' | 'sliding_window';
overflowModel?: string;
windowSize?: number;
}| Field | Type | Purpose |
|---|---|---|
tokenBudget | number | Total token budget for all memory layers combined |
responseReserve | number | Tokens reserved for the model's response |
overflow | 'truncate' | 'summarize' | 'sliding_window' | Strategy when total recall exceeds the budget |
overflowModel | string | Model used for summarization overflow (when overflow is 'summarize') |
windowSize | number | Number of recent items to keep (when overflow is 'sliding_window') |
Hook Lifecycle
Memory hooks fire in a deterministic order during agent execution:
init → recall → [LLM call] → store → ... (loop) → onComplete → dispose
↓
onSpawn (child)
↓
onReturn (parent)Hook Summary
| Hook | When | Purpose |
|---|---|---|
init | Agent starts | Load persisted state from storage |
recall | Before each LLM call | Inject items into the prompt |
projectHistory | Before each LLM call, after recall | Cap or transform the history items projected to the LLM (read-side; never mutates itemLog) |
onItemAppend | Input items about to be appended | Filter, transform, or inject user/tool input items; may request a context re-render. Layers compose in slot order. Not called for LLM response items |
beforeToolCall | Before each tool execution | Steering: allow, deny, or guide the pending tool call |
afterModelCall | After each LLM response | Steering: review the response and allow, deny, or guide |
store | After each LLM response | Extract and persist new knowledge |
onSpawn | Child agent spawned | Decide what state the child inherits |
onReturn | Child agent completes | Merge child results back into parent state |
onComplete | Agent finishes | Final persistence with outcome metadata |
dispose | Cleanup | Release resources |
Hook Type Signatures
interface MemoryHooks<TState = unknown> {
init?: (params: InitParams) => Promise<InitResult<TState>>;
recall?: (params: RecallParams<TState>) => Promise<RecallResult<TState> | string | null>;
projectHistory?: (params: ProjectHistoryParams<TState>) => Promise<ProjectHistoryResult>;
onItemAppend?: (params: OnItemAppendParams<TState>) => Promise<OnItemAppendResult<TState>>;
beforeToolCall?: (params: BeforeToolCallParams<TState>) => Promise<BeforeToolCallResult<TState>>;
afterModelCall?: (params: AfterModelCallParams<TState>) => Promise<AfterModelCallResult<TState>>;
store?: (params: StoreParams<TState>) => Promise<StoreResult<TState> | undefined>;
onSpawn?: (params: SpawnParams<TState>) => Promise<SpawnResult<TState> | null>;
onReturn?: (params: ReturnParams<TState>) => Promise<ReturnResult<TState> | undefined>;
onComplete?: (params: CompleteParams<TState>) => Promise<void | { state: TState }>;
dispose?: (params: DisposeParams<TState>) => Promise<void>;
}recall may return a bare string as shorthand — the runtime wraps it in a developer message and estimates its token count — or null to contribute nothing this turn.
assembleView()
The standalone assembleView() function (from memory/projector.ts) merges system-prompt, layer recall output, and conversation-history Item objects into a single ordered list that is sent to the LLM. The runtime calls it for you on every turn — you rarely need it directly.
Unstable API. assembleView is intended for authors of custom memory backends and framework extensions. Import it from @noetic-tools/core/unstable; its signature may change in any minor release.
import type { Item } from '@noetic-tools/core';
import { assembleView } from '@noetic-tools/core/unstable';
declare const systemPromptItems: Item[];
declare const layerOutputItems: Item[]; // from layer recall, already sorted by slot
declare const historyItems: Item[];
const items: Item[] = assembleView({
systemPromptItems,
layerOutputItems,
historyItems,
});
// items = systemPrompt + layer outputs (slot order) + historyLayerTimeouts
Override the default timeout (in milliseconds) for any individual hook:
interface LayerTimeouts {
init?: number;
recall?: number;
store?: number;
onSpawn?: number;
onReturn?: number;
onComplete?: number;
dispose?: number;
beforeToolCall?: number;
afterModelCall?: number;
onItemAppend?: number;
projectHistory?: number;
}If a non-init hook exceeds its timeout, the runtime logs a diagnostic and continues without that layer's contribution for that phase. init is different: an init failure (including timeout) re-throws and aborts the execution by default — memory is load-bearing, and silently disabling a layer would hide failures. A layer opts into skip-on-failure by setting onInitError: 'disable', in which case the runtime logs a diagnostic and runs the execution without that layer.
Layer Provides API
Layers can expose typed data and functions via the provides field. Provided entries are accessible in code steps through ctx.memory['layerId'], and provided functions are automatically injected as LLM tools (namespaced as layerId/fnName).
Use the memory() builder and InferMemory<> utility type for compile-time type safety:
import { memory, workingMemory, type Context, type InferMemory } from '@noetic-tools/core';
const mem = memory([workingMemory()]);
type Mem = InferMemory<typeof mem>;
declare const ctx: Context<Mem>;
// In a step, ctx.memory is fully typed:
const snap = ctx.memory['working-memory'].snapshot; // WorkingMemoryState
await ctx.memory['working-memory'].update({ key: 'value' });See Custom Layers for how to define your own provides entries using layerData() and layerFn().
Next Steps
- Working Memory -- scratchpad for the current turn
- Observational Memory -- auto-extracted facts
- Temporal Memory -- timestamped fact ledger with relative-time recall
- Steering -- rule-based allow/deny/guide control over tool calls and responses
- File Reference --
#pathfile tracking with priority-scored content injection - Static Content -- load-once instructional content
- History Window -- cap items sent to the LLM
- Custom Layers -- build your own
- Plan Memory -- PRD authoring and plan execution lifecycle
- Tool Memory -- imperative state access and function-call memory