NOETIC
Framework
Memory

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';
}
FieldTypePurpose
idstringUnique identifier for the layer
namestring (optional)Human-readable display name
slotnumberOrdering priority -- lower slots appear first in the assembled view
scopeMemoryScopePersistence boundary for stored state
budgetBudgetConfigToken budget allocation for this layer
hooksMemoryHooks<TState>Lifecycle callbacks
timeoutsPartial<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:

ScopeMeaning
'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 decide

When 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;
}
FieldTypePurpose
tokenBudgetnumberTotal token budget for all memory layers combined
responseReservenumberTokens reserved for the model's response
overflow'truncate' | 'summarize' | 'sliding_window'Strategy when total recall exceeds the budget
overflowModelstringModel used for summarization overflow (when overflow is 'summarize')
windowSizenumberNumber 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

HookWhenPurpose
initAgent startsLoad persisted state from storage
recallBefore each LLM callInject items into the prompt
projectHistoryBefore each LLM call, after recallCap or transform the history items projected to the LLM (read-side; never mutates itemLog)
onItemAppendInput items about to be appendedFilter, transform, or inject user/tool input items; may request a context re-render. Layers compose in slot order. Not called for LLM response items
beforeToolCallBefore each tool executionSteering: allow, deny, or guide the pending tool call
afterModelCallAfter each LLM responseSteering: review the response and allow, deny, or guide
storeAfter each LLM responseExtract and persist new knowledge
onSpawnChild agent spawnedDecide what state the child inherits
onReturnChild agent completesMerge child results back into parent state
onCompleteAgent finishesFinal persistence with outcome metadata
disposeCleanupRelease 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) + history

LayerTimeouts

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

On this page