diff --git a/packages/core/src/context/ir/episodeEditor.ts b/packages/core/src/context/ir/episodeEditor.ts deleted file mode 100644 index fc62eeb96a..0000000000 --- a/packages/core/src/context/ir/episodeEditor.ts +++ /dev/null @@ -1,211 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { Episode, IrNode } from './types.js'; - -export interface MutationRecord { - episodeId: string; - type: 'modified' | 'inserted' | 'replaced' | 'deleted'; - action: string; - originalIds?: string[]; // If replaced - episode?: Episode; // For new or modified -} - -export class EpisodeEditor { - private originalMap: Map; - private workingOrder: string[]; - private workingMap: Map; - private mutations: MutationRecord[] = []; - private targetNodes?: Set; - - constructor(episodes: Episode[], targetNodes?: Set) { - this.originalMap = new Map(episodes.map((e) => [e.id, e])); - this.workingOrder = episodes.map((e) => e.id); - this.workingMap = new Map(episodes.map((e) => [e.id, e])); - this.targetNodes = targetNodes; - } - - /** - * Provides a readonly view of the specific targets this processor is allowed to touch. - * If no targets were specified (e.g. fallback pipeline), it returns the entire history. - */ - get targets(): Array<{ episode: Episode; node: IrNode | Episode }> { - const results: Array<{ episode: Episode; node: IrNode | Episode }> = []; - - for (const epId of this.workingOrder) { - const ep = this.workingMap.get(epId)!; - - // If we don't have restricted targets, everything is a target - if (!this.targetNodes) { - results.push({ episode: ep, node: ep }); - continue; - } - - // Check episode itself - if (this.targetNodes.has(ep.id)) { - results.push({ episode: ep, node: ep }); - } - // Check trigger - if (this.targetNodes.has(ep.trigger.id)) { - results.push({ episode: ep, node: ep.trigger }); - } - // Check steps - for (const step of ep.steps) { - if (this.targetNodes.has(step.id)) { - results.push({ episode: ep, node: step }); - } - } - // Check yield - if (ep.yield && this.targetNodes.has(ep.yield.id)) { - results.push({ episode: ep, node: ep.yield }); - } - } - - return results; - } - - /** - * Returns the full history for READ-ONLY context purposes. - * Processors should not iterate over this array to decide what to mutate. - * They should iterate over `editor.targets`. - */ - getFullHistory(): readonly Episode[] { - return this.workingOrder.map((id) => this.workingMap.get(id)!); - } - - private isTargeted(episodeId: string): boolean { - if (!this.targetNodes) return true; - if (this.targetNodes.has(episodeId)) return true; - - const ep = this.workingMap.get(episodeId); - if (!ep) return false; - - if (this.targetNodes.has(ep.trigger.id)) return true; - if (ep.yield && this.targetNodes.has(ep.yield.id)) return true; - for (const step of ep.steps) { - if (this.targetNodes.has(step.id)) return true; - } - - return false; - } - - /** - * Safely edits an existing episode. - * The framework will handle deeply cloning the episode before passing it to the mutator. - * Throws an error if the processor attempts to edit a non-targeted node. - */ - editEpisode(id: string, action: string, mutator: (draft: Episode) => void) { - if (!this.isTargeted(id)) { - throw new Error(`EpisodeEditor: Processor attempted to edit Episode ${id} which is outside its allowed target scope.`); - } - - const ep = this.workingMap.get(id); - if (!ep) return; - - // Lazy deep clone only if it's the original reference - if (ep === this.originalMap.get(id)) { - const clone = structuredClone(ep); - this.workingMap.set(id, clone); - } - - const draft = this.workingMap.get(id)!; - mutator(draft); - - // Log mutation if not already tracked as modified/inserted/replaced - if (!this.mutations.find((m) => m.episodeId === id)) { - this.mutations.push({ - episodeId: id, - type: 'modified', - action, - episode: draft, - }); - } - } - - /** - * Inserts a brand new episode into the graph at the specified index. - */ - insertEpisode(index: number, newEpisode: Episode, action: string) { - this.workingMap.set(newEpisode.id, newEpisode); - this.workingOrder.splice(index, 0, newEpisode.id); - this.mutations.push({ - episodeId: newEpisode.id, - type: 'inserted', - action, - episode: newEpisode, - }); - } - - /** - * Replaces a set of older episodes with a single new episode (e.g., a Summary or Snapshot). - * It inserts the new episode at the lowest index of the removed episodes. - */ - replaceEpisodes(oldIds: string[], newEpisode: Episode, action: string) { - for (const id of oldIds) { - if (!this.isTargeted(id)) { - throw new Error(`EpisodeEditor: Processor attempted to replace Episode ${id} which is outside its allowed target scope.`); - } - } - - const indices = oldIds - .map((id) => this.workingOrder.indexOf(id)) - .filter((i) => i !== -1); - if (indices.length === 0) return; - - const insertIndex = Math.min(...indices); - - // Remove old - this.workingOrder = this.workingOrder.filter((id) => !oldIds.includes(id)); - for (const id of oldIds) { - this.workingMap.delete(id); - } - - // Insert new - this.workingOrder.splice(insertIndex, 0, newEpisode.id); - this.workingMap.set(newEpisode.id, newEpisode); - - this.mutations.push({ - episodeId: newEpisode.id, - type: 'replaced', - action, - originalIds: oldIds, - episode: newEpisode, - }); - } - - /** - * Removes episodes from the graph completely (e.g., emergency truncation). - */ - removeEpisodes(oldIds: string[], action: string) { - for (const id of oldIds) { - if (!this.isTargeted(id)) { - throw new Error(`EpisodeEditor: Processor attempted to remove Episode ${id} which is outside its allowed target scope.`); - } - } - - this.workingOrder = this.workingOrder.filter((id) => !oldIds.includes(id)); - for (const id of oldIds) { - this.workingMap.delete(id); - this.mutations.push({ episodeId: id, type: 'deleted', action }); - } - } - - /** - * Retrieves the final, finalized array of episodes. - * Called by the Orchestrator. - */ - getFinalEpisodes(): Episode[] { - return this.workingOrder.map((id) => this.workingMap.get(id)!); - } - - /** - * Retrieves a log of all structural and property mutations performed by this editor. - * Called by the Orchestrator to emit VariantReady events. - */ - getMutations(): MutationRecord[] { - return this.mutations; - } -} diff --git a/packages/core/src/context/pipeline.ts b/packages/core/src/context/pipeline.ts index 9cfb6dfd38..bd07dbc868 100644 --- a/packages/core/src/context/pipeline.ts +++ b/packages/core/src/context/pipeline.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { EpisodeEditor } from './ir/episodeEditor.js'; +import type { ConcreteNode, IrMetadata } from './ir/types.js'; /** * State object passed through the processing pipeline. @@ -19,36 +19,70 @@ export interface ContextAccountingState { readonly deficitTokens: number; /** - * Set of Episode IDs that the orchestrator has deemed highly protected. - * Processors should generally skip mutating these episodes unless doing proactive/required transforms. + * Set of Logical Node IDs (like Tasks or Episodes) that the orchestrator has deemed highly protected. + * Processors should generally skip mutating Concrete Nodes that belong to these parents. */ - readonly protectedEpisodeIds: Set; + readonly protectedLogicalIds: ReadonlySet; /** * True if currentTokens <= retainedTokens. */ readonly isBudgetSatisfied: boolean; +} - /** - * If this pipeline was triggered by a specific event (e.g., a new turn), - * this contains the specific Node IDs (Episodes, Steps, or Triggers) that should be evaluated. - * If undefined, the processor may evaluate the entire graph. +/** + * A declarative instruction from a processor on how to modify the Ship. + * Applied sequentially by the Orchestrator (Reducer). + */ +export interface ContextPatch { + /** The IDs of the Concrete Nodes to remove from the Ship. */ + readonly removedIds: ReadonlyArray; + + /** + * The new synthetic Concrete Nodes (e.g., MaskedTool, Snapshot) to insert. + * If omitted or empty, this patch acts as a pure deletion. */ - readonly targetNodeIds?: Set; + readonly insertedNodes?: ReadonlyArray; + + /** The index at which to insert the new nodes. If omitted, they replace the first removedId. */ + readonly insertionIndex?: number; + + /** Audit metadata explaining who made this patch, when, and why. */ + readonly metadata: IrMetadata; +} + +export interface ProcessArgs { + /** The flat, sequential array of current renderable nodes (The Ship). */ + readonly ship: ReadonlyArray; + + /** + * The specific subset of Concrete Node IDs that triggered this execution. + * For 'new_message', these are the new nodes. For 'retained_exceeded', the aged-out nodes. + */ + readonly triggerTargets: ReadonlySet; + + /** The token budget and accounting state. */ + readonly state: ContextAccountingState; + + /** + * An escape hatch allowing the processor to query the original, uncompressed + * state of a node from the Pristine Graph. + */ + readonly getPristineNode: (id: string) => ConcreteNode | undefined; } /** * Interface for all context degradation strategies. + * Processors are pure functions that return ContextPatches. */ export interface ContextProcessor { + /** Unique ID for registry mapping. */ + readonly id: string; /** Unique name for telemetry and logging. */ readonly name: string; - /** - * Processes the episodic history payload via the provided EpisodeEditor, based on the current accounting state. - * Processors should safely mutate or replace episodes using the editor's API. - */ - process(editor: EpisodeEditor, state: ContextAccountingState): Promise; + /** Returns an array of declarative patches to apply to the Ship. */ + process(args: ProcessArgs): Promise; } /** @@ -64,4 +98,4 @@ export interface BackstopTargetOptions { target?: 'incremental' | 'freeNTokens' | 'max'; /** If target is 'freeNTokens', this is the amount of tokens to clear. */ freeTokensTarget?: number; -} +} \ No newline at end of file