@sema-agent/core
Version:
Stateless, task-oriented AI agent core
209 lines (208 loc) • 14.3 kB
TypeScript
import type { MemoryNoteHeader, MemoryNoteRecord, MemoryStore } from "./memory.js";
/**
* Selective memory recall (design/65 §2.2 — port of CC `findRelevantMemories`). For a LARGE memory scope,
* instead of injecting the whole `<user_memory>` block (inject-all), we inject a compact **manifest** (one
* header line per note) + a side-query that picks the ≤K relevant note ids + only those bodies. Small
* scopes keep inject-all (the common case after consolidation) — this path is threshold-gated, so most
* tasks pay nothing.
*
* ⚠️ WHAT IS ACTUALLY IN THIS FILE (RB-446, corrected 2026-07-31 — the text below used to describe a
* DRIVER that no longer exists here, in the present tense). The 2.0.0 B19 export sweep removed the
* function that ran the pipeline; what remains, and what the rest of core consumes, is the set of PURE
* HELPERS plus the declared shape of that pipeline:
* · helpers, all live and in use: {@link formatMemoryAge}, {@link RECALL_CAVEAT},
* {@link buildManifestText}, {@link validateSelectedIds}, {@link resolveLinkedIds},
* {@link composeSelectiveBody}, {@link encodeSurfacedKey};
* · seam DECLARATIONS with no in-tree caller: {@link MemorySelector} /
* {@link SelectiveRecallOptions} / {@link SelectiveRecallResult} / {@link LayeredRecallOptions} /
* {@link LayeredRecallResult}. They stay exported because they are the published contract a
* deployment implements its own driver against — the helpers above are exactly the pieces such a
* driver assembles — but nothing under `src/` calls them today. Read them as a specification, not
* as a description of code that runs.
*
* The pipeline those declarations specify: pure + deterministic given a selector — validate the returned
* ids against the manifest (reject hallucinated ids, council #5), cap at K, apply a hard timeout
* (council #12), and on ANY selector failure/timeout DEGRADE to inject-all (council #4 — never silently
* drop knowledge). Output goes in the variable tail (manifest + selected bodies are per-user/volatile,
* like the current memory block). `validateSelectedIds` is the one rung of it that IS implemented here.
*/
/** Default max notes the side-query may select (CC `findRelevantMemories` ≤5). */
export declare const DEFAULT_MAX_SELECTED = 5;
/** Default max 1-hop link-followed notes appended after the selected ones (design/65 P3). */
export declare const DEFAULT_MAX_LINKED = 3;
/** Default side-query hard timeout (ms) — council #12; on timeout we degrade to inject-all. */
export declare const DEFAULT_SELECT_TIMEOUT_MS = 5000;
/**
* Render a memory note's age as a natural-language phrase (CC-parity P0-1, design/65). CC's eval found
* models reason about memory age much better from natural language ("3 days ago") than from a boolean
* "older note" flag (verify-claims 0/2 → 3/3 once the age was spelled out), because the model can weigh
* staleness proportionally instead of as a single cliff. This is the relative phrase only; the
* point-in-time / verify-before-trust discipline still lives in {@link RECALL_CAVEAT} + the per-note
* "verify it's still current" hint (the age is ADDITIONAL signal, not a replacement).
*
* Buckets (each chooses the coarsest unit that's ≥1): `today` (<1 day, incl. future mtime / clock skew →
* clamped to today, never a negative age), `yesterday` (1–2 days), `N days ago` (2–7 days),
* `N weeks ago` (1 week – ~1 month), `N months ago` (≥ ~1 month). N is floored to the whole unit.
*/
export declare function formatMemoryAge(ageMs: number): string;
/**
* Read-side recall caveat (design/65 §8#2 / §9#1 — ported from CC's MEMORY_DRIFT_CAVEAT +
* TRUSTING_RECALL_SECTION). The council found CC's two-section form is **eval-validated** (verify-claims
* 0/2→3/3) and that the structure carries the effect: the precise decision-point heading
* "## Before recommending from memory" scored 3/3 while an abstract heading or a buried bullet scored 0/3.
* So this is deliberately TWO sections — a drift bullet + a titled action section with the frame-shift
* sentence and concrete verify actions — NOT one merged paragraph (same semantics, 3× worse eval).
* MEMORY_SAFETY already carries the authority hierarchy; this adds the point-in-time / verify-before-trust
* discipline selective recall specifically needs.
*/
export declare const RECALL_CAVEAT: string;
export interface MemorySelectRequest {
/** The task objective the recall is for. */
objective: string;
/** The manifest the selector chooses from (id + description [+ type]). */
manifest: MemoryNoteHeader[];
/** Tools the task will use — for these, keep gotchas/warnings, skip pure usage notes (design/65 §2.2). */
recentTools?: string[];
}
/** The side-query seam: pick the relevant note ids from the manifest. MUST be fail-fast (throw → degrade). */
export type MemorySelector = (req: MemorySelectRequest, signal?: AbortSignal) => Promise<string[]>;
export interface SelectiveRecallOptions {
store: MemoryStore;
scope: string;
objective: string;
selector: MemorySelector;
recentTools?: string[];
/** Absolute time for the freshness caveat (default Date.now()). */
nowMs?: number;
maxSelected?: number;
/** Max 1-hop `[[name]]` link-followed notes appended after the selected ones (design/65 P3).
* `0` disables link-following. Default {@link DEFAULT_MAX_LINKED}. */
maxLinked?: number;
timeoutMs?: number;
signal?: AbortSignal;
/**
* CC-parity P1 (durable cross-worker recall de-dup). Note ids ALREADY surfaced to this logical
* conversation in earlier turns/runs (CC's in-process `alreadySurfaced`/readFileState filter, lifted
* to a **durable, caller-owned** set so it survives task hops, resume, and multi-worker fan-out). When
* provided, these ids are **filtered out of the manifest BEFORE the selector sees it** — the selector
* never sees an already-shown note, so it cannot re-select it, so a long/resumed/multi-worker session
* does not re-inject the same bodies. core does NOT store this set (like {@link CheckpointStore}, the
* durable store is the caller's): the caller persists {@link surfacedIds} from one call and feeds it
* back here as `priorSurfacedIds` on the next. Omitted ⇒ no filtering (the prior, non-de-duped behavior).
*/
priorSurfacedIds?: ReadonlySet<string>;
}
/**
* Result of a single-scope selective-recall pass. `surfacedIds` (CC-parity P1) is the set of note ids whose
* BODIES this call injected into the prompt — the caller appends them to its durable surfaced-set and
* feeds that back as {@link SelectiveRecallOptions.priorSurfacedIds} next time, so a long/resumed/
* multi-worker session does not re-inject the same notes. THREE shapes (all carry `surfacedIds`):
*
* - **block branch** (`{ block: string }`): a selective-recall block was rendered. `surfacedIds` = the
* validated selection + the 1-hop linked notes (everything whose body was rendered). The caller injects
* `block` verbatim.
*
* - **no-new branch** (`{ block: undefined }`): a SUCCESS, NOT a degrade. Every selectable candidate was
* already surfaced earlier (filtered out by {@link SelectiveRecallOptions.priorSurfacedIds}), so there is
* NOTHING NEW to inject. The caller injects NOTHING (no block) — it must NOT fall back to inject-all,
* because inject-all would re-dump the WHOLE scope including the already-shown notes, defeating the
* de-dup. `surfacedIds` is `[]` (this call added no new note to the durable set; the prior ids are
* already in the caller's store). Distinguished from `degrade` precisely so the caller does not
* re-inject (the bug this branch fixes). `"block" in result` is TRUE here (the key is present, value
* `undefined`), so the caller's `"degrade" in result` narrow routes this to the non-degrade path.
*
* - **degrade branch** (`{ degrade: true }`): a true FALLBACK — the store can't do selective recall
* (missing manifest/getByIds), the scope is empty, OR the selector failed/timed out. The caller falls
* back to inject-all (never drops knowledge, council #4), a best-effort FULL DUMP that
* `composeMemoryBlock` hard-truncates to a head slice (so it does NOT reliably render every body), and
* the caller already holds the prior surfaced ids. Degrade is therefore not a precise per-id surface that
* can be de-duped: `surfacedIds` is ALWAYS `[]` and degrade does **not** advance the durable surfaced-set.
* (Reporting the full header id list here would over-claim the truncated tail as surfaced → permanent
* silent under-recall on the next turn.)
*/
export type SelectiveRecallResult = {
block: string;
surfacedIds: string[];
} | {
block: undefined;
surfacedIds: string[];
} | {
degrade: true;
surfacedIds: string[];
};
export declare function resolveLinkedIds(headers: MemoryNoteHeader[], selected: MemoryNoteRecord[], max: number): string[];
/** Render the manifest as compact lines, double-capped (council #7). One line per note, guaranteed. */
export declare function buildManifestText(headers: MemoryNoteHeader[]): string;
/** Keep only ids present in the manifest (reject hallucinated, council #5), de-duped, capped at `max`. */
export declare function validateSelectedIds(headers: MemoryNoteHeader[], ids: string[], max: number): string[];
/**
* Render the BODY of a selective-recall block: the optional `recall` manifest index + the selected note
* bodies (+ 1-hop linked notes), WITHOUT the outer `<user_memory>` wrapper and WITHOUT {@link RECALL_CAVEAT}.
* The wrapper + caveat are the caller's job — a single-scope caller places ONE caveat
* inside ONE `<user_memory scope=… mode="selective">`, and the layered renderer places ONE caveat inside ONE
* `<user_memory mode="selective">` ahead of N `<scope>` subsections (MAJOR fix: no nested fences, no per-scope
* caveat duplication).
*
* `recallable` controls the "Memory index — load in full …" affordance (MAJOR fix): the on-demand recall
* tool is bound to a SINGLE scope, so the "load in full" instruction is only TRUE for that scope. Pass
* `false` for non-recallable layers — the selected bodies are already rendered inline, so the index is just a
* false affordance that wastes a tool call (model searches the wrong scope → "No memory matches").
*
* `recallToolName` (#181-F1, additive) is the WIRE name of the host-mounted recall tool the affordance
* teaches. Core mounts no such tool ("Recall" is a RETIRED name — tool-name-aliases.ts), so a hard-coded
* name here can only ever be stale; the host that mounted the tool passes the name it mounted it under.
* Omitted ⇒ a name-free "search the memory-recall tool" wording (honest without inventing a name).
*
* Notes are tagged with their natural-language age (CC-parity P0-1, design/65; >1-day-old ones keep the
* "verify it's still current" hint, council #8). `linked` (design/65 P3) are 1-hop `[[name]]`-followed notes,
* rendered in their own clearly-labeled subsection AFTER the selected ones — expanded as DATA under the same
* fence/sanitize/byte-cap discipline (design/65 §8#9: a followed link must not become markup or escalate authority).
*
* SEAT (test B14/B15): this is a HOST-side recall-pipeline API — a BYOM host
* that builds its own selective-recall lane composes the injected block through it. The engine's
* own `inject()` path deliberately does NOT route here (it assembles instruction+index+announcements
* only; live recall is the model-driven memory_search tool with its own caps). Public export with
* no in-engine consumer is the intended shape, not dead code.
*/
export declare function composeSelectiveBody(manifestText: string, selected: MemoryNoteRecord[], nowMs: number, linked?: MemoryNoteRecord[], recallable?: boolean, recallToolName?: string): string;
/** A `(scope, id)` composite key for the multi-scope durable de-dup (design/84 Seam A decision 5). Note ids
* are only unique WITHIN a scope, so the cross-scope `priorSurfacedKeys`/`surfacedKeys` set MUST be keyed on
* both. Core owns the FORMAT so a caller can't drift the wire format (decision 7) — the encoding is OPAQUE
* and length-prefixes the scope so a scope/id containing the separator cannot self-declare a collision.
*
* ONE-WAY by design ((d)): the key is only ever PRODUCED here (`surfacedKeys`) and compared by
* STRING EQUALITY against the caller's persisted set ({@link LayeredRecallOptions.priorSurfacedKeys}, applied
* at memory-recall.ts via `priorSurfacedKeys.has(encodeSurfacedKey(h.scope, h.id))`). Nothing ever needs the
* `(scope, id)` BACK out of a key, so there is intentionally NO `decodeSurfacedKey` — adding a decode would
* widen the public surface (and invite callers to parse the opaque format) for zero consumer. Treat the key
* as an opaque token: persist it, feed it back, compare for equality — never split it. */
export declare function encodeSurfacedKey(scope: string, id: string): string;
export interface LayeredRecallOptions {
store: MemoryStore;
/** Ordered scopes (design/84 Seam A): list order = priority; the last is highest. */
scopes: ReadonlyArray<string>;
objective: string;
selector: MemorySelector;
recentTools?: string[];
nowMs?: number;
maxSelected?: number;
maxLinked?: number;
timeoutMs?: number;
signal?: AbortSignal;
/** CC-parity P1, multi-scope: `(scope,id)` composite keys ({@link encodeSurfacedKey}) already surfaced to
* this logical conversation. Filtered out of the merged manifest BEFORE the selector sees it. */
priorSurfacedKeys?: ReadonlySet<string>;
}
/** Result of a multi-scope (layered) selective-recall pass. `surfacedKeys` are `(scope,id)` composite keys
* ({@link encodeSurfacedKey}) — the caller appends them to its durable set and feeds them back as
* {@link LayeredRecallOptions.priorSurfacedKeys}. Mirrors {@link SelectiveRecallResult}'s three shapes. */
export type LayeredRecallResult = {
block: string;
surfacedKeys: string[];
} | {
block: undefined;
surfacedKeys: string[];
} | {
degrade: true;
surfacedKeys: string[];
};