agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
551 lines (550 loc) • 26.7 kB
TypeScript
/**
* Memory subsystem — public type surface.
*
* THE 2D mental model the library teaches:
*
* MEMORY = TYPE × STRATEGY × STORE
*
* TYPE — what shape of memory you're keeping
* (Episodic messages / Semantic facts / Narrative beats /
* Causal footprintjs snapshots)
* STRATEGY — how to fit content into the next LLM call
* (Window / Budget / Summarize / TopK / Extract / Decay / Hybrid)
* STORE — where the bytes live
* (InMemoryStore / Redis / Postgres / DynamoDB / Vector ...)
*
* Strategy is universal — same Window works for Episodic and for Causal.
* That's why examples are organized by strategy (the discipline) not by
* type (the shape).
*
* Pattern: Single-Source-of-Truth const objects + discriminated union.
* Mirrors `src/lib/conventions.ts` (SUBFLOW_IDS, INJECTION_KEYS).
* NEVER enums (TS enums emit runtime objects + opacity).
* Const-as-const erases at compile time, accepts string literals,
* and gives consumers IDE autocomplete + refactor safety.
*
* Role: Layer-1 contract for the memory subsystem. Step 2's
* `defineMemory()` factory consumes these to build pipelines;
* Step 4's `Agent.memory()` builder mounts the resulting
* definitions; Step 5's Causal machinery extends them.
*
* Emits: Indirectly — every memory pipeline emits the unified
* `agentfootprint.context.injected` event with `source: 'memory'`
* when its read subflow places content into the system-prompt
* slot (every shipped formatter writes `role: 'system'`, and
* system-role recall composes into `inject.systemPrompt`).
*
* @see ./define.ts for the `defineMemory()` factory itself
* @see ../../docs-next for guides + the 7 strategy examples
* @see MEMORY.md for the load-bearing design memory
*/
import type { LLMProvider } from '../provider-contract/types.js';
import type { Embedder } from './embedding/index.js';
import type { MemoryStore } from './store/index.js';
import type { MemoryNamespaceScope, MemoryWriteOptionsConfig } from './store/writeOptions.js';
import type { MemoryIdentity } from './identity/index.js';
import type { RetrievalStrategy } from './retrieval/types.js';
/**
* What shape of memory you're keeping.
*
* - `EPISODIC` — raw conversation messages, replayed on next turn
* - `SEMANTIC` — extracted structured facts, deduped on key
* - `NARRATIVE` — beats / summaries of prior runs (append-only)
* - `CAUSAL` — footprintjs execution snapshots, the differentiator
* (replays stored decisions + tool evidence for "why?"
* follow-ups — harvested automatically per run)
*/
export declare const MEMORY_TYPES: {
readonly EPISODIC: "episodic";
readonly SEMANTIC: "semantic";
readonly NARRATIVE: "narrative";
readonly CAUSAL: "causal";
};
export type MemoryType = (typeof MEMORY_TYPES)[keyof typeof MEMORY_TYPES];
/**
* How content is selected / compressed for the next LLM call.
*
* A `WINDOW` strategy on an Episodic store keeps the last N messages; on
* Semantic / Narrative it keeps the last N facts / beats. NOT universal: the
* `CAUSAL` type accepts ONLY `TOP_K` — its snapshots are matched semantically
* against the new query, never by recency, so `buildCausalPipeline` throws on
* any other strategy kind. Mix and match the non-Causal types.
*
* These are BARE STRINGS, which is enough to write one and not enough to
* OFFER one. `listMemoryStrategies()` (./strategies.ts) is the same seven
* described — what each does, which TYPES accept it, and what the host must
* supply (`requirements`) — so a picker can grey out what this deployment
* cannot run instead of finding out from an exception.
*/
export declare const MEMORY_STRATEGIES: {
readonly WINDOW: "window";
readonly BUDGET: "budget";
readonly SUMMARIZE: "summarize";
readonly TOP_K: "topK";
readonly EXTRACT: "extract";
readonly DECAY: "decay";
readonly HYBRID: "hybrid";
};
export type MemoryStrategyKind = (typeof MEMORY_STRATEGIES)[keyof typeof MEMORY_STRATEGIES];
/**
* When the memory's READ subflow runs.
*
* Default `TURN_START` reads memory once per `agent.run()`. Use
* `EVERY_ITERATION` only when the strategy is sensitive to in-loop tool
* results — every-iteration multiplies store-latency by iteration-count.
*/
export declare const MEMORY_TIMING: {
readonly EVERY_ITERATION: "every-iteration";
readonly TURN_START: "turn-start";
};
export type MemoryTiming = (typeof MEMORY_TIMING)[keyof typeof MEMORY_TIMING];
/**
* For Causal memory only — which slice of a footprintjs snapshot to
* inject. Snapshots can run 100KB+; projecting prevents context blowup.
*
* - `DECISIONS` — `decide()`/`select()` evidence only (the "why" chain)
* - `COMMITS` — commitLog only (every state write, ordered)
* - `NARRATIVE` — narrative entries only (human-readable trace)
* - `FULL` — entire snapshot (use sparingly)
*/
export declare const SNAPSHOT_PROJECTIONS: {
readonly DECISIONS: "decisions";
readonly COMMITS: "commits";
readonly NARRATIVE: "narrative";
readonly FULL: "full";
};
export type SnapshotProjection = (typeof SNAPSHOT_PROJECTIONS)[keyof typeof SNAPSHOT_PROJECTIONS];
/**
* Window — keep the last `size` entries. Pure rule, no LLM, no embedder.
*
* NAMING (7.27.1): its siblings below are `BudgetStrategy`, `TopKStrategy`
* and so on, unprefixed. This one carries the `Memory` prefix because
* `WindowStrategy` is already taken, at the package root, by something else
* entirely: the conversation-window seam (`{ name, plan(input) }`,
* core/agent/window/strategy.ts, public since 7.17.0). Two exported types of
* the same name and incompatible shapes, reachable from two entry points, is
* a trap for anyone importing from the wrong one — so the memory config
* record took the prefix. Through 8.x `agentfootprint/memory` also exported
* the old bare name as a deprecated alias; 9.0.0 removed it, so the collision
* is gone in both directions.
*/
export interface MemoryWindowStrategy {
readonly kind: typeof MEMORY_STRATEGIES.WINDOW;
readonly size: number;
}
/**
* Budget — pick entries that fit within a token budget. Used as a
* decider stage: skip-if-empty | skip-if-no-budget | pick-by-tokens.
*/
export interface BudgetStrategy {
readonly kind: typeof MEMORY_STRATEGIES.BUDGET;
/** Reserve N tokens for prompt headers / new user message. Default 256. */
readonly reserveTokens?: number;
/** Skip injection below this token floor. Default 100. */
readonly minimumTokens?: number;
/** Hard cap on entries to inject — guards against "lost-in-the-middle". */
readonly maxEntries?: number;
}
/**
* Summarize — when recall grows long, one LLM call compresses the older
* entries into a summary that is STORED, while the most recent `recent`
* entries stay raw. The standard answer to "long conversations blow context".
*
* Wired in 9.14.0. Through 9.13.0 this type existed, `llm` was required, and
* the pipeline `defineMemory` built loaded the last `recent` entries and
* stopped — a strategy that named a summarizer and never called it. What runs
* now:
*
* 1. load up to `size` entries (default 20);
* 2. keep the last `recent` verbatim, seam rounded outward to a whole turn;
* 3. fold everything older into ONE summary entry — one call, over the span;
* 4. WRITE that entry back to the same store under
* `msg-summary-{fromTurn}-{toTurn}`, so the next turn reads it instead of
* paying for it again;
* 5. recall is `[summary, ...recent verbatim]`.
*
* The folded originals are NOT deleted. They stay in the store and are
* excluded from recall by the summary's coverage metadata — delete the
* summary entry and recall is verbatim again.
*/
export interface SummarizeStrategy {
readonly kind: typeof MEMORY_STRATEGIES.SUMMARIZE;
/**
* Keep this many most-recent entries uncompressed. The seam is rounded
* outward to a whole turn, so a question is never summarized while its
* answer stays raw.
*/
readonly recent: number;
/**
* How much history to load per turn — the pool the fold is taken from.
* Default 20 (`loadRecent`'s own default). Must be greater than `recent`:
* a window no larger than the verbatim tail never has anything older to
* compress, which is `window` with a summarizer bolted on.
*/
readonly size?: number;
/** LLM that does the compression — recommend a cheap model (haiku). */
readonly llm: LLMProvider;
/**
* Which model `llm` is called with. REQUIRED (9.14.0), and there is no
* fall back to the agent's own model — the same law `.compaction()` has
* enforced since 8.14.0. A default here had no correct case: the same
* provider family quietly bills your MAIN model for compression, and a
* different vendor is sent a model id it has never heard of.
*
* `Agent.memory()` additionally refuses a summarizer that is the agent's own
* provider INSTANCE at the agent's own model: those two calls look identical
* and are not — the agent's runs through reliability, decorators and the
* cache, and this one runs through none of them.
*/
readonly model: string;
/**
* Override the instruction the summarizer is given (the transcript
* delimiters and the authored label on the stored summary stay the
* library's). Use it for domain summaries: "preserve every refund-related
* number".
*/
readonly systemPrompt?: string;
}
/**
* Top-K — embed the user's query, retrieve top-K by cosine similarity.
* STRICT threshold: when no entry meets the threshold, return EMPTY.
* No fallback — garbage in context is worse than no memory.
*/
/**
* Top-K retrieval, in either of its two spellings — and never both.
*
* The arms EXCLUDE (8.8.0). `{ topK, threshold }` is the shorthand;
* `{ retrieval }` is the same rule written as a {@link RetrievalStrategy},
* which is also how a different rule gets in. Accepting both would mean
* one of two `k`s silently loses and the recording would name a `k` the
* run did not use — so the type refuses it at the keystroke, and
* `defineMemory` refuses it again at runtime for JavaScript callers.
*/
export type TopKStrategy = TopKShorthandStrategy | TopKRetrievalStrategy;
/** The historical spelling: two loose numbers. Unchanged since 2.x. */
export interface TopKShorthandStrategy {
readonly kind: typeof MEMORY_STRATEGIES.TOP_K;
readonly topK: number;
/** Min cosine similarity. Strict — no fallback below this. Default 0.7. */
readonly threshold?: number;
/**
* The embedder that turns the query into a vector.
*
* Optional since 9.3.0 for ONE case: a store declaring
* `ranksBy: 'server-text'` ranks the question as words on the backend's
* side, so there is nothing here to embed. Omitted anywhere else,
* `defineMemory` refuses by name — the requirement moved from the type to a
* runtime check because it now depends on the STORE, which the type cannot
* see.
*/
readonly embedder?: Embedder;
/**
* Stable id of the embedder, filtered against `MemoryEntry.embeddingModel`
* at search time so a later embedder swap cannot silently mix two vector
* spaces. Pair it with the same value passed to `indexDocuments`.
*
* Wired in 8.8.0. `defineRAG` has accepted an `embedderId` since 7.x and
* never forwarded it — an option the run did not read.
*/
readonly embedderId?: string;
/** See {@link TopKRetrievalStrategy.maxChars}. */
readonly maxChars?: number;
readonly retrieval?: never;
}
/** The spelled-out rule (8.8.0) — and the seam a re-ranker will arrive through. */
export interface TopKRetrievalStrategy {
readonly kind: typeof MEMORY_STRATEGIES.TOP_K;
/** See {@link TopKShorthandStrategy.embedder} — optional for server-text stores only. */
readonly embedder?: Embedder;
readonly retrieval: RetrievalStrategy;
/** See {@link TopKShorthandStrategy.embedderId}. */
readonly embedderId?: string;
/**
* A character budget for the admitted passages, spent in rank order
* (8.19.0). Default none.
*
* It lives on BOTH arms, unlike `topK`/`threshold`, because it is not a
* second spelling of the retrieval rule: the rule decides WHICH
* candidates and how many, this bounds how much TEXT the winners are
* allowed to be. A count bound is not a size bound, so the two compose
* rather than exclude — `retrieval: topK({ k: 10 })` with
* `maxChars: 3000` is a coherent request and is honoured as written.
*/
readonly maxChars?: number;
readonly topK?: never;
readonly threshold?: never;
}
/**
* Extract — on WRITE, an LLM (or pattern matcher) distills entries from
* raw messages into structured shapes (facts/beats). Usually paired with
* a load-side strategy like TopK or Window for the read direction.
*/
export interface ExtractStrategy {
readonly kind: typeof MEMORY_STRATEGIES.EXTRACT;
/** Pattern-based (regex heuristics, free) or LLM-based (paid). */
readonly extractor: 'pattern' | 'llm';
/** Required when `extractor: 'llm'`. */
readonly llm?: LLMProvider;
/** Discard extractions below this confidence. Default 0.7. */
readonly minConfidence?: number;
/** Cap entries extracted per turn. Default 5. */
readonly maxPerTurn?: number;
}
/**
* Decay — let old memory fade. Each loaded entry is scored by AGE against
* `halfLifeMs` (`2^(-age / halfLife)`) and dropped below `minScore`, so a
* long-running agent stops rehearsing last month. Free: arithmetic on a
* timestamp, no LLM and no embedder. EPISODIC only.
*
* Wired in 9.5.0. Before that, this type existed, `MEMORY_STRATEGIES.DECAY`
* existed, and `defineMemory` threw "not yet wired" on it.
*
* AGE, not use. The underlying model (`computeDecayFactor`) also has an
* access term, and the read path passes a neutral `1` for it: `accessCount`
* is bumped by `store.get()`, and no shipped read path calls `get()` — they
* `list()` or `search()`. A knob for it here would be wired to a counter
* that never moves, so there isn't one. Nothing stored is mutated either:
* an entry that decays out of one turn is still in the store, and the next
* turn scores it again.
*/
export interface DecayStrategy {
readonly kind: typeof MEMORY_STRATEGIES.DECAY;
/**
* How long before an untouched entry is worth half as much, in
* milliseconds. A day is `86_400_000`. Must be finite and non-negative —
* a negative half-life would score OLDER entries higher, so it is refused.
*/
readonly halfLifeMs: number;
/**
* Drop entries scoring below this. Default 0.1 — roughly "older than
* three-and-a-bit half-lives". `0` keeps everything.
*/
readonly minScore?: number;
}
/**
* Hybrid — compose multiple strategies. Each sub-strategy runs as its
* own selector branch; results are merged in the order listed.
*/
export interface HybridStrategy {
readonly kind: typeof MEMORY_STRATEGIES.HYBRID;
readonly strategies: ReadonlyArray<Exclude<Strategy, HybridStrategy>>;
}
/** The full strategy union — discriminated by `kind`. */
export type Strategy = MemoryWindowStrategy | BudgetStrategy | SummarizeStrategy | TopKStrategy | ExtractStrategy | DecayStrategy | HybridStrategy;
/**
* The opaque value `defineMemory()` returns. `Agent.memory()` consumes
* one of these per memory the consumer registers; multiple definitions
* layer cleanly via per-id scope keys (`memoryInjection_${id}`).
*
* Generic `T` is the payload shape stored — `Message` for episodic,
* `Fact` for semantic, `NarrativeBeat` for narrative, `RunSnapshot` for
* causal. The factory infers `T` from `type`.
*/
export interface MemoryDefinition<T = unknown> {
/** Stable identifier. Becomes the scope-key suffix and the Lens label. */
readonly id: string;
/** Surfaces in narrative / Lens hover. */
readonly description?: string;
/** Which TYPE shape — gates legal STRATEGY combinations. */
readonly type: MemoryType;
/**
* The strategy KIND this memory was compiled from, and the two names that
* ride with it (9.41.0).
*
* Carried in the open for the same reason {@link MemoryDefinition.store} is:
* the compiled subflows already closed over all of it, and a host looking at
* the definition could see WHICH memory was mounted but not WHICH RULE it
* runs. The run-configuration manifest
* (`agentfootprint.agent.run_configured`) names the strategies a run used so
* two runs can be grouped into two arms, and it fires before any subflow
* has — so the rule has to be readable from here, not from the retrieval
* that eventually happens.
*
* NAMES ONLY, deliberately: the numbers (`k`, `threshold`, `size`) stay
* inside the pipeline. They are what the rule is TUNED to, not which rule it
* is, and the manifest reports strategies rather than settings.
*
* Optional because a hand-built `MemoryDefinition` declares none — and
* absent has to keep meaning "did not say", never a guessed default.
*/
readonly strategy?: MemoryStrategyKind;
/** `RetrievalStrategy.name`, when the strategy was given the spelled-out
* `retrieval` rule rather than the `{ topK, threshold }` shorthand. */
readonly retrieval?: string;
/** The embedder in play, by `Embedder.id` — or the declared `embedderId`
* when the store embeds server-side and no embedder was passed. */
readonly embedderId?: string;
/** Compiled read subflow (built by the factory from type × strategy). */
readonly read: ReadonlyMemoryFlowChart<T>;
/** Compiled write subflow. Optional — `EPHEMERAL`-style configs omit. */
readonly write?: ReadonlyMemoryFlowChart<T>;
/**
* The store this memory reads and writes (9.6.0).
*
* The compiled subflows already closed over it — this carries it in the
* open for the one question a subflow cannot answer from inside itself:
* **which turn of this conversation is about to happen?** A turn number
* is the key in `msg-{turn}-{index}` / `snap-{turn}` / `beat-{turn}-{i}`,
* and the only thing that remembers how far a conversation has got —
* across processes, across a fresh `Agent` per turn — is the store. The
* Agent reads this to resolve the turn once per run (`resolveTurnNumber`)
* before any memory subflow runs.
*
* Optional because a `MemoryDefinition` may be hand-built; a definition
* without it simply keeps whatever turn number its host supplies, which
* is what every release before 9.6.0 did.
*/
readonly store?: MemoryStore;
/** When `read` runs. Default `TURN_START`. */
readonly timing: MemoryTiming;
/**
* What this memory's OWN LLM work bills to (9.14.0) — present only for a
* strategy that calls a model on the host's behalf, which today is
* `SUMMARIZE`.
*
* Carried in the open for one reason: `Agent.memory()` is where the agent's
* own provider and model are known, and it refuses a summarizer that is that
* exact instance at that exact model (the 8.14.0 rule `.compaction()` and
* `.window()` already enforce). `defineMemory` cannot make that check — it
* has never heard of an agent — so it declares the billing and the builder
* checks it. Same field name and same shape as `WindowStrategy.billing`, so
* one refusal serves both.
*/
readonly billing?: {
readonly provider: LLMProvider;
readonly model: string;
};
/** Snapshot projection — only meaningful when `type === CAUSAL`. */
readonly projection?: SnapshotProjection;
/**
* The namespace this memory reads and writes (8.8.0).
*
* Absent — the historical behaviour, and still the right one for
* conversation memory — means "whatever identity the run was given",
* so each conversation remembers its own turns.
*
* Present means "always this namespace, whoever is asking", which is
* what a shared document corpus is: a corpus does not belong to a
* conversation, and reading it under a per-run conversation id is why
* `defineRAG`'s own documented example retrieved nothing at all before
* 8.8.0. `defineRAG` defaults it to `{ conversationId: '_global' }`,
* matching `indexDocuments`'s default so the two sides meet.
*/
readonly corpus?: MemoryIdentity;
/**
* Which claim this memory's injected block makes (8.8.0). `'memory'`
* is conversation recall; `'rag'` is corpus retrieval, and it is the
* value that reaches the recording as `ContextInjectedPayload.source`.
*
* The event vocabulary has carried a `'rag'` source since 2.x and
* nothing ever emitted it — a declared value no run can produce is a
* gap in the contract, not a spare.
*/
readonly flavor?: MemoryFlavor;
}
/** What an injected memory block is claiming to be. */
export type MemoryFlavor = 'memory' | 'rag';
/**
* Opaque tag for the compiled flowchart the factory hands back.
* The actual type is `FlowChart<MemoryState>` from footprintjs but we
* keep it nominal here so consumers can't reach in. The phantom type
* parameter is preserved so consumers can write `ReadonlyMemoryFlowChart<MyShape>`
* for documentation, even though the brand erases the parameter at runtime.
*/
export type ReadonlyMemoryFlowChart<_T> = {
readonly __brand: 'ReadonlyMemoryFlowChart';
};
/**
* Common options for every memory type. Type-specific options layer on
* top via discriminated `type` field in the next overload set (Step 2).
*/
export interface DefineMemoryOptionsBase {
readonly id: string;
readonly description?: string;
readonly store: MemoryStore;
readonly strategy: Strategy;
readonly timing?: MemoryTiming;
/**
* Read and write under THIS namespace instead of the run's identity
* (8.8.0). See {@link MemoryDefinition.corpus}.
*/
readonly corpus?: MemoryIdentity;
/**
* Build a read-only memory (8.8.0): no write subflow is compiled, so
* nothing this memory sees is ever stored back.
*
* The reason it exists: a retrieval corpus and a conversation log are
* two different things sharing one pipeline. Writing the conversation
* into the corpus makes the user's own question the best-scoring
* "document" in it. `defineRAG` sets this.
*/
readonly readOnly?: boolean;
/** Which claim the injected block makes. See {@link MemoryDefinition.flavor}. */
readonly flavor?: MemoryFlavor;
/**
* What every write of this memory asks the store to do with it — today
* `extraction: 'skip'`, which keeps the writes out of long-term extraction.
*
* Checked here, before any turn runs: an option the store does not declare in
* `MemoryStore.writeOptions` is refused by name. Refused with `readOnly` too —
* a memory that never writes has nothing to apply them to.
*/
readonly writeOptions?: MemoryWriteOptionsConfig;
/**
* The namespace scope of this memory — usually one tenant's — for its writes
* AND its reads: `(identity) => ({ tenantid: … })`, resolved from the run's
* identity and from nothing else (never the message, the model or a tool).
*
* Writes are filed under it (`MemoryWriteOptions.namespaceScope`) and every
* search is confined to it (`SearchOptions.namespaceScope`), so a scope can
* never make a later read see another tenant's records. Allowed on a
* `readOnly` memory — the one that reads what the service extracted. Checked
* here, before any turn runs: a store that does not declare `'namespaceScope'`
* in `MemoryStore.writeOptions` is refused by name. A scope that comes back
* empty or malformed refuses that write or search.
*/
readonly namespaceScope?: MemoryNamespaceScope;
}
export interface DefineEpisodicOptions extends DefineMemoryOptionsBase {
readonly type: typeof MEMORY_TYPES.EPISODIC;
}
export interface DefineSemanticOptions extends DefineMemoryOptionsBase {
readonly type: typeof MEMORY_TYPES.SEMANTIC;
}
export interface DefineNarrativeOptions extends DefineMemoryOptionsBase {
readonly type: typeof MEMORY_TYPES.NARRATIVE;
}
export interface DefineCausalOptions extends DefineMemoryOptionsBase {
readonly type: typeof MEMORY_TYPES.CAUSAL;
/** Slice of the snapshot to inject. Default `DECISIONS`. */
readonly projection?: SnapshotProjection;
}
/** Discriminated by `type`. The factory uses this to pick the pipeline. */
export type DefineMemoryOptions = DefineEpisodicOptions | DefineSemanticOptions | DefineNarrativeOptions | DefineCausalOptions;
export declare function isMemoryType(value: string): value is MemoryType;
export declare function isMemoryStrategyKind(value: string): value is MemoryStrategyKind;
export declare function isMemoryTiming(value: string): value is MemoryTiming;
export declare function isSnapshotProjection(value: string): value is SnapshotProjection;
/**
* Scope-key prefix used when mounting multiple `.memory()` definitions
* on the same Agent. Each memory writes to `memoryInjection_${id}` so
* registrations never collide. Formatter merges all keys with this
* prefix in registration order.
*/
export declare const MEMORY_INJECTION_KEY_PREFIX: "memoryInjection_";
export declare function memoryInjectionKey(id: string): string;
export declare function isMemoryInjectionKey(key: string): boolean;
/**
* Scope-key prefix for the retrieval record a memory lifts to the parent
* scope (8.8.0), one key per memory id — the same layering rule as
* `memoryInjection_`.
*
* This key is the reason a backward slice can now reach a passage. It is
* ORDINARY root state, so `sliceForKey('finalContent')` walks the
* system-prompt write, and the record naming every candidate id and score
* is one hop away. Before 8.8.0 the scores existed only inside the memory
* subflow, which the root commit log never sees.
*/
export declare const RETRIEVAL_EVIDENCE_KEY_PREFIX: "retrievalEvidence_";
export declare function retrievalEvidenceKey(id: string): string;
export declare function isRetrievalEvidenceKey(key: string): boolean;