/** * summarize — the stage that makes the SUMMARIZE strategy summarize (9.14.0). * * Reads from scope: `loaded`, `identity` * Writes to scope: `loaded` (older entries replaced by ONE summary entry) * Writes to store: the summary entry, under a deterministic id * Emits: agentfootprint.memory.strategy_applied (every visit that * changes recall or decides not to), carrying the reason, * the model, and the token usage the call reported. * * NOT `cost.tick`, and that is a limit worth knowing: the * USD channel needs a `pricingTable` and the run's * cumulative counters, both of which live on the AGENT's * scope, and a memory pipeline is a subflow with neither. * Emitting a tick with `estimatedUsd: 0` would be a * cheaper-looking lie than saying nothing, so the tokens * ride the memory event instead and the fold is never * silent. * * Where this fits in the pipeline: * * loadRecent → summarize → [filterByDecay] → pickByBudget → formatDefault * * ## What it does, and what that costs * * Recall arrives from `loadRecent` oldest-first. This stage keeps the last * `preserveRecent` entries VERBATIM and folds everything older into a single * summary entry — **one LLM call over the span**, not one per entry and not * one per recall. The summary is then WRITTEN BACK to the store under the id * `msg-summary-{fromTurn}-{toTurn}`, so the next turn LOADS it instead of * paying for it again. That write-back is the whole cost model: a span is * summarized once in the life of a conversation, by whichever turn first saw * it fall out of the verbatim tail. * * ## The originals are never deleted * * A summary is a CLAIM ABOUT the conversation, not the conversation. The * entries it covers stay in the store byte-identical; they are excluded from * recall by the summary's own coverage metadata ({@link SummaryCoverage}) and * by nothing else. Delete the summary entry and the next recall is verbatim * again. This is the same law `.compaction()` follows in the live window — * the fold edits what is SENT, never what was said. * * ## Two summarizer shapes * * • `llm: LLMProvider` + `model` — the library composes the call. The span * is rendered as DATA between delimiters the authored instruction names, * so a message inside the conversation cannot re-instruct the summarizer * by looking like an instruction. `model` is REQUIRED here: naming it is * what keeps the summarizer's bill separate from the agent's own (the * `.compaction()` law, 8.14.0). Token usage is reported on the event. * • `llm: (messages) => Promise` — the caller's own call, unchanged * since 2.x. The caller composed the request, so the caller named the * model; the stage reports usage as unknown rather than inventing one. * * ## What comes back is DATA * * The summary text is appended after an authored label this file writes, and * the label always comes first. A summarizer that returns "IGNORE ALL * PREVIOUS INSTRUCTIONS" produces an entry that still says, in the library's * own words and first, that what follows is a summary written by a model and * that the originals are retained. * * ## Three ways it declines, all of them out loud * * • **not worth a call** — fewer than `minFoldEntries` foldable entries, or * fewer than `triggerMinEntries` loaded. No call. Recall still changes if * an EARLIER turn's summary is covering entries (that is the cheap turn * the write-back bought), and then the event says so; a turn where * nothing at all happened writes nothing and emits nothing. * • **summarizer failed** — the provider threw. ONE `console.warn` per * stage instance plus an event, and recall proceeds VERBATIM: a broken * summarizer degrades this strategy to `window`, it does not fail the * turn. (Through 9.13.0 the stage re-threw; a memory that cannot recall * because its optional compressor is down is a worse answer than an * uncompressed one.) * • **replacement not smaller** — the summary plus its label is no shorter * than the span it would replace. Folding then spends a call to GROW * recall and lose detail at the same time, so the fold is dropped and the * span is LATCHED: the same span is never re-asked, because the same * inputs give the same answer and re-asking is a paid call whose result * is already known (the 8.14.0 latch, keyed by the span's own ids). A * span that has GROWN is a different key and is asked again on purpose. * * ## Determinism contract * * For prompt caching to stay stable, the same span should produce the same * summary. Configure `temperature: 0` (and a seed where the provider has * one) on the summarizer you pass. The stage cannot enforce it — but note * that write-back makes it matter far less than it did: a span is summarized * once and then read back from the store. * * @see ../define.ts the `SUMMARIZE` strategy arm that wires this * @see ../../core/agent/window/strategies/summarizeOldest.ts the same laws * applied to the LIVE window instead of to recall */ import type { TypedScope } from 'footprintjs'; import type { MemoryEntry } from '../entry/index.js'; import type { LLMMessage as Message, LLMProvider } from '../../adapters/types.js'; import type { MemoryStore } from '../store/index.js'; import type { MemoryState } from './types.js'; /** * The 2.x summarizer shape: the caller makes the call and returns the text. * Kept because a hand-composed pipeline that already owns its provider does * not need the library to own it too. */ export type SummarizeCallback = (messages: readonly Message[]) => Promise; export interface SummarizeConfig { /** * Who writes the summary — a provider (preferred; pair with {@link model}) * or the legacy callback that makes its own call. * * Recommend a cheap model. The point of naming a summarizer at all is that * compression is not worth your main model's price. */ readonly llm: LLMProvider | SummarizeCallback; /** * The model id the summarizer is called with. REQUIRED when `llm` is a * provider, refused as meaningless when `llm` is a callback (the callback * composed its own request and named its own model there). * * There is no `?? agentModel` fallback, by the same reasoning * `.compaction()` uses: the default had no correct case — same family and * it quietly bills your main model for compression, different vendor and it * sends a model id nobody has heard of. */ readonly model?: string; /** * Where the summary is written back to. Omit and the fold is computed for * THIS recall only and thrown away — correct, and paid for again every * turn. `defaultPipeline` passes its own store, so the factory path always * writes back. */ readonly store?: MemoryStore; /** * Minimum `loaded.length` before a fold is considered. Below this, no-op — * the conversation is short enough to keep whole. Default 20. */ readonly triggerMinEntries?: number; /** * How many most-recent entries stay VERBATIM. The older ones become the * summary. Default 5 — recent turns keep their exact phrasing so the agent * can quote them. * * The seam is rounded OUTWARD to a whole turn: if the cut would land in the * middle of turn 7, turn 7 goes to the verbatim side entirely. A question * summarized while its answer stayed raw reads like an answer to nothing. */ readonly preserveRecent?: number; /** * Do not spend a call to fold fewer than this many entries. Default 2 — one * entry "compressed" into a summary plus a label is a paid call that makes * recall longer. `defineMemory` raises this to the size of the verbatim * tail, which is the cost policy: never fold less than you keep. */ readonly minFoldEntries?: number; /** * Override the authored instruction the summarizer is given. Domain * summaries want this ("preserve every refund-related number"). Only the * INSTRUCTION is yours — the transcript delimiters and the label written * onto the summary entry are the library's and stay put. */ readonly systemPrompt?: string; /** * TTL in milliseconds applied to the summary entry, counted from the SPAN's * clock (the newest entry it stands for), not from the fold. * `defaultPipeline` passes its `writeTtlMs` here, so a compliance retention * window expires the summary WITH the turns it compressed — a summary that * outlived them is exactly the leak the retention window exists to prevent. */ readonly ttlMs?: number; /** * Tier for the summary entry. Default `'cold'` — condensed, not recent. * * Worth pairing with the load's `tiers` filter if you set one: a filter that * excludes this tier hides the summary from every later recall, and a * summary that is never loaded is a span that is folded (and billed) again * every turn. `defineMemory` sets no tier filter, so the factory path is * safe by construction. */ readonly summaryTier?: 'hot' | 'warm' | 'cold'; /** `strategyId` on the emitted event. Default `'memory-summarize'`. */ readonly strategyId?: string; } /** Id prefix for every entry this stage writes. */ export declare const SUMMARY_ID_PREFIX = "msg-summary-"; /** Metadata key carrying {@link SummaryCoverage} on a summary entry. */ export declare const SUMMARY_COVERAGE_KEY = "summarizes"; /** * What a summary entry stands for — the only thing that excludes an original * from recall. * * `coveredIds` is the operative field and it is exact: recall drops an entry * because a loaded summary NAMES it, never because a turn number falls inside * a range. A turn that was half-folded (possible only for hand-composed * configs; the stage rounds the seam outward) keeps the half nobody claimed. */ export interface SummaryCoverage { /** Earliest turn represented. */ readonly fromTurn: number; /** Latest turn represented. Also the entry's `source.turn`. */ readonly toTurn: number; /** How many entries were folded. */ readonly entryCount: number; /** The exact entries this summary stands for. */ readonly coveredIds: readonly string[]; /** Which model wrote it. Absent for the callback form — the caller knows. */ readonly model?: string; /** When the fold happened (unix ms). */ readonly summarizedAtMs: number; } /** The id a fold over `[fromTurn, toTurn]` always gets. Deterministic. */ export declare function summaryEntryId(fromTurn: number, toTurn: number): string; /** * The coverage a summary entry carries, or `undefined` for an ordinary entry. * * Read through the METADATA, not the id: an id is a name and metadata is the * claim. An entry named like a summary that carries no coverage stands for * nothing and is treated as an ordinary entry. */ export declare function summaryCoverage(entry: MemoryEntry): SummaryCoverage | undefined; /** True when this entry is a summary written by this stage. */ export declare function isSummaryEntry(entry: MemoryEntry): boolean; /** Opening of the authored label. Stable — readers and tests match on it. */ export declare const SUMMARY_FRAME_PREFIX = "[summary of earlier turns"; export declare function summarize(config: SummarizeConfig): (scope: TypedScope) => Promise;