/** * engine.ts — Layer 4 (PERSIST / checkpoint) orchestration. * * Ties the Sprint 1–2 primitives into the compaction pipeline the extension * calls. Pure of any pi runtime type: it consumes EngineMessage[] and talks to * the on-disk VectorStore. The extension adapts pi messages -> EngineMessage * (see adapt.ts) and reports status. * * Pipeline (mirrors the PLAN Trident stack): * SUPERSEDE (drop obsolete file reads) * -> COLLAPSE (summarize the compacted slice) * -> CLUSTER (embed + persist a checkpoint to the vector store) */ import { findSuperseded, supersede } from "./supersede.js"; import { summarizeMessages, mergeCompactSummaries, formatCompactSummary } from "./compact.js"; import { extractiveSummarize } from "./extractive.js"; import { estimateSessionTokens, estimateBlockTokens } from "./tokens.js"; import { computeRegionHash, vectorWasInjected, vectorSearch, VectorStore, type SearchHit } from "./vectorStore.js"; import type { EngineMessage } from "./types.js"; export interface CompactInput { sessionId: string; messages: EngineMessage[]; /** Index (into `messages`) of the first message to keep verbatim. Everything * before this is eligible to be compacted. Defaults to `preserveRecent` * from the tail. */ keepFrom?: number; /** Optional explicit summary; when omitted, COLLAPSE heuristics build one. */ summary?: string; /** Region text the checkpoint is keyed on (for dedup). Defaults to the * compacted slice's joined text. */ regionText?: string; keyDecisions?: string[]; nextSteps?: string[]; filesModified?: string[]; tokenEstimate?: number; timestamp?: number; /** When true (default), use extractive summary instead of raw concatenation. */ useExtractiveSummary?: boolean; /** Context-window pressure (0–1): how close the session is to the model * limit. Drives adaptive compression strength in the stored checkpoint * (Fix E). 0/undefined = room to spare; 1 = at the limit. */ compressionPressure?: number; /** Sync progress callback fired by the store as each dedup tier is evaluated * (L0→L1→L2→new). Lets the UI render a live "L0 ✓ → L1 ✓ → L2 0.91 → stored" * progress line during compaction. Never awaited; must be side-effect-free-ish * and cheap. Optional; back-compat with callers that don't pass it. */ onTier?: (ev: TierProgress) => void; } /** Progress event emitted by the store as each dedup tier is evaluated. */ export interface TierProgress { /** Tier being evaluated: "L0" | "L1" | "L2" | "new". */ tier: "L0" | "L1" | "L2" | "new"; /** "scanning" while the tier is being evaluated, "deduped" when it matched, * "passed" when no match, "stored" at the final outcome. */ status: "scanning" | "deduped" | "passed" | "stored"; /** Optional detail — e.g. the L2 cosine sim ("0.91") or the dedup reason. */ detail?: string; } export interface CompactResult { /** True when nothing was compacted (slice empty / below floor). */ skipped: boolean; /** True when the region was a duplicate of an already-stored checkpoint. */ deduped: boolean; /** Which dedup tier matched: regionHash | summaryHash | contentSimilarity. */ dedupReason?: string; checkpointId?: string; summary: string; regionHash: string; tokenEstimate: number; /** Files touched by the compacted region (surfaced to the UI for a live * "compressing " activity line). May be empty if not captured. */ filesModified: string[]; /** Token count of the original dropped region (before compaction). The honest * "tokens saved" base = originalTokenEstimate − tokenEstimate (stored), or the * full originalTokenEstimate when the region deduped onto an existing * checkpoint (nothing new stored). Computed over the FULL compactable slice * (including superseded messages) so the dedup branch books the whole region; * this is the value persisted into the checkpoint record and read by the * dashboard / vector-read paths. */ originalTokenEstimate: number; /** F5: token count of the filtered KEEP set (post-supersede) — the honest base * for the stored-vs-original compaction delta. `originalTokenEstimate` minus * this is the supersede savings; this minus `tokenEstimate` is the pure * compaction savings. Not persisted (return-only); callers that want honest * per-layer reporting should prefer this over `originalTokenEstimate`. */ keepTokenEstimate: number; /** F5: tokens dropped by the SUPERSEDE layer = originalTokenEstimate − * keepTokenEstimate. Reported separately so supersede savings are not booked * as compaction savings. Not persisted (return-only). */ supersedeTokenSavings: number; /** Index in `messages` where the compacted slice begins (for the caller to * build a drop range). */ compactedFrom: number; } /** Default store used by the convenience `compactSession`. */ let defaultStore: VectorStore | undefined; export function getDefaultStore(stateDir?: string): VectorStore { if (!defaultStore) defaultStore = new VectorStore({ stateDir }); return defaultStore; } /** Replace the default store (used by tests to inject a temp dir). */ export function setDefaultStore(store: VectorStore | undefined): void { defaultStore = store; } /** * Run the Trident pipeline over a message slice and persist a checkpoint. * * `messages` is the FULL session view; `keepFrom` marks where the verbatim tail * starts, so indices stay absolute and the caller can map the drop range back * onto the real (pi) message array via adapt.ts. Returns a `skipped` result * when the compactable slice is empty. */ export function compactSession(input: CompactInput, store: VectorStore = getDefaultStore()): CompactResult { // F6: clamp keepFrom defensively. Upstream (the extension adapter) already // clamps, but a bad keepFrom (negative or > length) would produce a misleading // drop range or an empty compactable slice; belt-and-braces, clamp here too. const keepFrom = Math.max(0, Math.min(input.keepFrom ?? input.messages.length, input.messages.length)); const compactable = input.messages.slice(0, keepFrom); const compactedFrom = keepFrom; if (compactable.length === 0) { return { skipped: true, deduped: false, summary: "", regionHash: "", tokenEstimate: 0, filesModified: [], originalTokenEstimate: 0, keepTokenEstimate: 0, supersedeTokenSavings: 0, compactedFrom, }; } // LAYER 1 — SUPERSEDE: zero-cost factual pruning of obsolete file reads. const supersededIdx = new Set(findSuperseded(compactable)); const keep = compactable.filter((_m, i) => !supersededIdx.has(i)); // LAYER 2 — COLLAPSE: build (or accept) the summary. // When useExtractiveSummary is enabled (default), use the deterministic // extractive engine that compresses ~70K tokens → ~2K tokens with structured // fields populated. Falls back to legacy concatenation when disabled. const useExtractive = input.useExtractiveSummary !== false; let summary: string; let topicSummary: string | undefined; let keyDecisions: string[]; let nextSteps: string[]; let filesModified: string[]; if (useExtractive && !input.summary) { const ext = extractiveSummarize(keep); summary = ext.topicSummary; topicSummary = ext.topicSummary; keyDecisions = input.keyDecisions ?? ext.keyDecisions; nextSteps = input.nextSteps ?? ext.nextSteps; filesModified = input.filesModified ?? ext.filesModified; } else { const collapsed = input.summary ?? summarizeMessages(keep); summary = formatCompactSummary(collapsed); topicSummary = undefined; keyDecisions = input.keyDecisions ?? []; nextSteps = input.nextSteps ?? []; filesModified = input.filesModified ?? []; } // Honest "tokens saved" accounting: // - originalTokenEstimate = the dropped region's token count (what context // held before compaction) = the compacted slice's tokens. Computed over the // FULL compactable slice (incl. superseded) so the dedup branch books the // whole region; this is the value persisted into the checkpoint record. // - keepTokenEstimate (F5) = the filtered keep set's tokens (post-supersede). // The honest base for the stored-vs-original compaction delta: the pure // compaction savings = keepTokenEstimate − storedTokens, and the supersede // savings = originalTokenEstimate − keepTokenEstimate. Without this, the // supersede savings get booked as compaction savings. // - storedTokens = the persisted summary's token count, computed from the // actual summary string so it's honest for BOTH the extractive and legacy // COLLAPSE paths (the legacy path's fallback estimateSessionTokens is the // *original* size, not the stored size). const originalTokenEstimate = estimateSessionTokens(compactable); const keepTokenEstimate = estimateSessionTokens(keep); const supersedeTokenSavings = Math.max(0, originalTokenEstimate - keepTokenEstimate); const storedTokens = estimateBlockTokens(summary); // Region text = the compacted slice, used for dedup + embedding. const regionText = input.regionText ?? keep.map((m) => m.text).join("\n"); const regionHash = computeRegionHash(regionText); const add = store.add({ sessionId: input.sessionId, summary, topicSummary, keyDecisions, nextSteps, filesModified, regionText, tokenEstimate: storedTokens, originalTokenEstimate, timestamp: input.timestamp ?? 0, onTier: input.onTier, compressionPressure: input.compressionPressure, }); return { skipped: false, deduped: add.deduped, dedupReason: add.reason, checkpointId: add.checkpoint.checkpointId, summary, regionHash, tokenEstimate: storedTokens, filesModified, originalTokenEstimate, keepTokenEstimate, supersedeTokenSavings, compactedFrom, }; } export interface RecallInput { sessionId: string; query: string; limit?: number; /** Skip checkpoints already injected this session (recall dedup). */ skipInjected?: boolean; } export interface RecallResult { hits: SearchHit[]; /** Indices into `hits` that were *not* already injected (ready to inline). */ newHits: SearchHit[]; } /** * Layer 5 (query side, shared by auto-inline + on-demand): search the store and * drop any checkpoint already injected this session. The caller decides how to * inject (Sprint 4 wires injection); this module only does the deduped search. */ export function recall(input: RecallInput, store: VectorStore = getDefaultStore()): RecallResult { const hits = vectorSearch(store, input.sessionId, input.query, input.limit ?? 3); const newHits = input.skipInjected === false ? hits : hits.filter((h) => !vectorWasInjected(store, input.sessionId, h.checkpoint.checkpointId)); return { hits, newHits }; } /** Merge a freshly compacted summary into the prior persisted summary text. */ export function mergeSummary(existing: string | undefined, next: string): string { return mergeCompactSummaries(existing, next); } /** Exposed for callers that want raw supersede stats (status reporting). */ export function supersededCount(messages: EngineMessage[]): number { return new Set(findSuperseded(messages)).size; } /** Re-export so the extension has one import surface. */ export { supersede, summarizeMessages, formatCompactSummary };