@sema-agent/core
Version:
Stateless, task-oriented AI agent core
94 lines (93 loc) • 7.25 kB
TypeScript
import type { MemoryStore } from "./memory.js";
import { type ConsolidationLLM, type ConsolidationStats } from "./runner/memory-consolidation.js";
/**
* design/84 Seam B (M6+M10) — **cursor-incremental scope consolidation**. A thin function over the pure
* {@link runMemoryConsolidation}: it reads the scope's persisted cursor, feeds ONLY the notes appended
* AFTER it (the incremental batch) into one reconcile pass, then — IFF the whole pass succeeds — advances
* the cursor to the high-water mark it observed. This is the externally-triggerable periodic path (a
* deployment's timer / N-session gate fires it); the TRIGGER and any cross-process LOCK live OUTSIDE this
* function (the deployment shell's exec/persist axes — see {@link ConsolidateScopeOptions.acquire}).
*
* **Cursor marker semantics (no-miss / no-dup):** the cursor is an OPAQUE ordering marker the STORE defines
* (this core never parses it). In the three reference stores it is a note **id** — `uuidv7`, which is
* lexicographically time-sortable, so "after the cursor" is a string `>` comparison and the high-water mark
* is the lexicographic MAX. Each pass:
* - reads `cursor = getConsolidationCursor(scope)` (undefined ⇒ whole scope pending),
* - lists the scope's note headers (stable store ordering) and keeps those with `id > cursor` AND not
* already `consolidationGenerated` (a prior pass's own output — excluded so it is never re-merged),
* - reconciles that incremental batch via `runMemoryConsolidation` (which itself re-excludes
* `consolidationGenerated` + this-batch ids from each note's candidate set),
* - advances the cursor to the high-water mark of the fed batch ∪ `stats.addedIds`, but capped STRICTLY
* BELOW the smallest note this pass FAILED to process (`stats.failedIds`) — a contiguous successful-prefix
* marker, not the plain max (the MAJOR1 BLOCKER: a single max marker cannot keep a sub-max failed note
* pending; see {@link cappedHighWater}).
* No-dup: a note with `id <= cursor` is never re-fed. No-miss: a note appended after the advance, or one at/
* above a failure floor this pass, keeps `id > cursor` (uuidv7 monotonic) and is picked up by a later pass. A
* pass's own ADDs carry `consolidationGenerated:true` AND (when below the failure floor) are folded into the
* high-water mark, so they are excluded twice over.
*
* **Whole-pass advance (decision 3):** the cursor advances (up to the success boundary) ONLY when the pass
* returns (no throw). A pass that THROWS (LLM hard failure) does NOT advance at all — the batch is fully
* retried next time. A pass that returns with per-note failures advances only past the contiguous successful
* prefix below the smallest failure (fail-open: at worst a near-duplicate survives an extra pass, never data
* loss; consistent with the memory-store contract).
*
* **No-op safety (the BLOCKER §2 closes):** when the store lacks the cursor pair
* ({@link supportsPeriodicConsolidation} false) this is a NO-OP that calls `onWarn` — it NEVER degrades into a
* full re-consolidation of the whole scope (which would re-merge already-consolidated notes forever). It is
* also a no-op when the store can't consolidate at all ({@link supportsConsolidation} false) or the manifest
* read pair ({@link MemoryStore.listStructuredNotes} / {@link MemoryStore.getByIds}) is incomplete.
*/
export interface ConsolidateScopeDeps {
store: MemoryStore;
llm: ConsolidationLLM;
/** Routes diagnostics (skipped decisions, no-op-because-unsupported, lock-busy) — same sink as the Runner's
* `onError(phase:"memory")`. Never throws back into this function. */
onWarn?: (err: unknown) => void;
/**
* design/84 Seam B (TOC profile) — OPTIONAL cross-process consolidation lock. When provided,
* {@link consolidateScope} acquires it for `scope` before the pass and releases it after; a `release` of
* `undefined` (lock busy) makes the pass a NO-OP (another process is already consolidating this scope).
* The implementation (writeThenLink + stale-PID prune) lives in the deployment shell (`stores/file`), NOT
* core — core only DEFINES the injection point (the constitutional persist/exec-axis split).
*/
acquire?: (scope: string) => Promise<(() => void) | undefined> | (() => void) | undefined;
}
/** Tuning for one {@link consolidateScope} pass (mirrors the inline consolidation settings; all optional). */
export interface ConsolidateScopeOptions {
band?: {
lo: number;
hi: number;
};
searchLimit?: number;
/** Cap on notes fed into ONE pass (token bound). The cursor advances only past the contiguous SUCCESSFUL
* prefix of the FED batch (the first `maxNotes` pending) — never past the un-fed tail beyond the cap, and
* never past a note this pass FAILED to process — so a backlog larger than `maxNotes`, and any failed note,
* keep `id > cursor` and drain over successive passes without ever re-feeding the notes already handled. */
maxNotes?: number;
}
/**
* Run ONE cursor-incremental consolidation pass for `scope` (design/84 Seam B). Returns the pass stats
* (or `undefined` when it was a no-op: unsupported store, empty incremental batch, or lock busy). Never
* throws for a no-op reason; a hard LLM failure inside `runMemoryConsolidation` propagates (the caller's
* fire-and-forget envelope catches it) and the cursor is left UN-advanced (the batch retries next pass).
*/
export declare function consolidateScope(scope: string, deps: ConsolidateScopeDeps, opts?: ConsolidateScopeOptions): Promise<ConsolidationStats | undefined>;
/**
* design/84 Seam B (切片 4) — advance ONLY the cursor after an INLINE (task-end) consolidation pass has
* already run, so a later periodic {@link consolidateScope} starts from the inline-processed high-water mark
* (it does NOT re-consolidate the notes the inline pass just handled). The inline path consolidates the
* notes the model saved THIS task (the newest, highest-id notes); advancing the cursor over the PROCESSED
* ids ∪ addedIds makes inline and periodic share ONE cursor.
*
* **MAJOR1 BLOCKER:** the advance caps STRICTLY BELOW the smallest FAILED note (`failedIds`). A single
* max-marker cursor cannot keep a sub-max failed note pending, so a low-id note whose handling failed must
* stop the cursor below it even when a higher-id sibling succeeded — otherwise the failed note (id <= the
* plain max) would lose `id > cursor` and be exiled from consolidation forever. Pass the pass's
* `stats.consolidatedIds` (processed), `stats.addedIds`, and `stats.failedIds`.
*
* No-op when the store lacks the cursor pair (back-compat: an old store keeps the pure-inline behavior).
* Never throws — a cursor-advance failure routes to `onWarn` (the inline notes are already durable; the
* worst case is the next periodic pass re-sees them, which `runMemoryConsolidation` handles idempotently).
*/
export declare function advanceCursorAfterInline(store: MemoryStore, scope: string, seenNoteIds: ReadonlyArray<string | undefined>, addedIds: ReadonlyArray<string>, failedIds?: ReadonlyArray<string>, onWarn?: (err: unknown) => void): Promise<void>;