diff --git a/.gitignore b/.gitignore index c2abf00..d8fef40 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,5 @@ data/ # Vercel .vercel + +__local_notes/ diff --git a/web/docs/src/content/docs/architecture/handoff.md b/web/docs/src/content/docs/architecture/handoff.md new file mode 100644 index 0000000..d7c0435 --- /dev/null +++ b/web/docs/src/content/docs/architecture/handoff.md @@ -0,0 +1,128 @@ +--- +title: Memory Handoff Pipeline +description: The Tier 1 (Working Buffer) to Tier 2 (Long-Term Ledger) memory promotion architecture +sidebar: + order: 6 +--- + +:::tip[Status: Fully Implemented] +The handoff pipeline is fully integrated into the simulation step loop. The `HandoffEngine` and `checkHandoffTrigger` components process working memory entries, promoting them to the persistent episodic Ledger, while safely pruning the subjective working memory buffer. +::: + +The **Handoff** pipeline manages the promotion of subjective experiences from the short-term working memory (Tier 1 Buffer) to long-term episodic memory (Tier 2 Ledger). This process regulates context window utilization by deciding **when** promotion occurs, **how much** of the buffer is processed, and **what** details are preserved or pruned. + +To maintain performance and prevent context bloat, the pipeline separates the process into three decoupled stages: + +1. **Trigger Detection**: A deterministic, low-cost evaluation of entity and buffer state to determine if a promotion check is required. +2. **Watermark Partitioning**: A policy-driven boundary that separates recent memories (the watermark tail) from older promotion candidates. +3. **Structured Selection**: An LLM-driven synthesis step that summarizes, rates, and prunes candidate memories. + +--- + +## 1. Trigger Detection + +Trigger detection runs deterministically at the start of each entity's turn, without LLM overhead. The system categorizes triggers into voluntary (soft) and involuntary (hard) conditions. + +```ts +type HandoffTrigger = "none" | "voluntary" | "involuntary"; + +function checkHandoffTrigger( + entity: Entity, + bufferEntries: BufferEntry[], + now: Date, + maxContext?: number +): HandoffTrigger; +``` + +### Voluntary Triggers (Soft) +Voluntary triggers identify natural narrative boundaries where the entity is inactive or has transitioned contexts: +* **Scene Exit**: The entity's `locationId` changes relative to the location of the entries currently in the buffer, or the buffer contains entries spanning multiple different locations. +* **Idle Decay**: The entity has produced no external interactions (only monologue entries or no entries) for $N$ (default: 5) consecutive turns. +* **Attribute-Driven State**: A character attribute (e.g., `status: asleep` or `consciousness: unconscious`) signals low external activity. + +### Involuntary Triggers (Hard) +Involuntary triggers enforce context constraints before building the next prompt to prevent token limit overflows: +* **Buffer Ceiling**: The serialized length of the working memory section (as generated by `getMemorySectionLength`) exceeds a configured fraction (default: 60%) of the provider's context window. +* **Event Velocity**: The number of buffer entries exceeds a safe threshold (default: 20), indicating an event-heavy scene generating history faster than voluntary boundaries occur. + +If the buffer is empty, trigger detection exits immediately with `"none"`. + +--- + +## 2. Watermark Partitioning + +To preserve short-term narrative continuity and avoid immediate memory decay, recent events are protected from handoff. The pipeline divides the active buffer into a protected **Watermark Tail** and a **Candidate Pool**. + +The watermark boundary is computed as: + +$$\text{Watermark} = \max(K \text{ entries}, \text{entries bucketed as fresh by } \textit{naturalizeTime})$$ + +* **Hard Floor ($K = 8$)**: The last 8 entries in the buffer are always preserved. +* **Temporal Freshness**: Any entries that `naturalizeTime` classifies within the immediate temporal horizon (`"just now"`, `"moments ago"`, `"a few minutes ago"`, or `"several minutes ago"`) are also protected. + +Entries older than the watermark boundary constitute the **Candidate Pool** and are eligible for handoff processing. + +--- + +## 3. Structured Selection + +Handoff promotion is executed via a single, structured LLM call per run. The engine maps candidate entries to Zod schemas to ensure type-safe integration. + +```ts +const HandoffChunkSchema = z.object({ + sourceEntryIds: z.array(z.string()), // buffer entries consumed by this chunk + content: z.string(), // third-person summary -> LedgerEntry.content + quotes: z.array(z.string()), // verbatim, high-salience dialogue lines + importance: z.number().int().min(1).max(10), + involvedEntityIds: z.array(z.string()), + retainInBuffer: z.boolean(), // keeps entries in buffer despite promotion +}); + +const HandoffResultSchema = z.object({ + chunks: z.array(HandoffChunkSchema), +}); +``` + +### LLM Processing Rules +The prompt instructs the model to apply the following cognitive operations: +1. **Clustering**: Group related consecutive candidate entries into high-level narrative beats (e.g., consolidating an entire dialogue exchange into one beat). +2. **Third-Person Synthesis**: Write the `content` summary from a third-person narrative perspective. +3. **Dialogue Salience**: Extract verbatim quotes of high emotional or narrative relevance. +4. **Information Pruning (Forgetting)**: Any candidate entry ID omitted from all chunk `sourceEntryIds` is permanently deleted from the buffer and never saved to the ledger. This mirrors natural sensory attenuation. + +### Unresolved Thread Retention (Pinning) +When a chunk represents an unresolved high-stakes situation (e.g., an active conflict or standing threat), the LLM sets `retainInBuffer: true`. +* The summary is committed to the long-term Ledger as normal. +* The source buffer entries are exempted from pruning, remaining in the working memory buffer with `pinned: true` until a future handoff pass determines the thread is resolved. + +--- + +## 4. Transactional Execution + +To prevent permanent memory loss (e.g., buffer entries being deleted without ledger entries being committed), the handoff execution pipeline operates under a strict **fail-closed** design. + +```mermaid +flowchart TD + A[checkHandoffTrigger] -->|none| Z[No-op] + A -->|voluntary or involuntary| B[Partition Buffer] + B --> C{Candidates Available?} + C -->|No| Z + C -->|Yes| D[Inference: LLM Selection & Embedding Generation] + D -->|Failure / Invalid Output| E[Abort - Fail-Closed] + D -->|Success| F[[SQLite Transaction]] + F --> G[(Write Ledger Entries)] + F --> H[(Delete Pruned Buffer Entries)] + F --> I[(Update Pinned Buffer Entries)] +``` + +If LLM inference, Zod schema validation, or vector embedding generation fails, the process aborts immediately. No database changes are written, and the trigger re-evaluates on the next turn. + +--- + +## 5. Integration + +The handoff pipeline is exposed via `HandoffEngine` and is wired directly into the simulation loop: + +* **Turn Hook**: `SimulationManager.step()` invokes trigger evaluation and handoff resolution for all active entities at the start of the turn, before entities act. +* **Task Routing**: Handoff tasks are routed using the `"handoff"` routing key, enabling independent LLM provider selection and max context configuration. +* **Buffer Garbage Collection**: By combining memory promotion with automatic pruning, the handoff engine bounds buffer memory usage over long simulation runs, eliminating the need for a separate garbage collection mechanism. diff --git a/web/docs/src/content/docs/architecture/memory-tier2.md b/web/docs/src/content/docs/architecture/memory-tier2.md index c760b39..6150246 100644 --- a/web/docs/src/content/docs/architecture/memory-tier2.md +++ b/web/docs/src/content/docs/architecture/memory-tier2.md @@ -1,32 +1,38 @@ --- -title: Tier 2 Memory (Ledger) -description: Long-term episodic memory storage and retrieval +title: Tier 2 Memory (Long-Term Ledger) +description: Persistent episodic memory storage, semantic indexing, and retrieval mechanics --- -Tier 2 memory lives in between the memory buffer and tier 3 dossiers and arguably takes up the largest share of the context pie. +**Tier 2 Memory (the Ledger)** represents an entity's long-term episodic memory. It archives historical summaries of past events, providing a persistent record of character experiences that can be retrieved and injected into LLM prompts as needed. -Tier 2 memory (or long-term memory) stores historical events that happened to the entity in the past. It acts as an episodic ledger. +--- + +## 1. Data Model + +A long-term memory is represented by a `LedgerEntry`. It includes metadata for deterministic database-level filtering, structured narrative content, and vector embeddings for semantic similarity scoring. ```ts interface LedgerEntry { id: string; - ownerId: string; // whose subjective memory this belongs to - timestamp: string; // ISO, tied to WorldClock when the intent causing the event happened. - locationId: string | null; // where it happened - involvedEntityIds: string[]; // who else this event concerns + ownerId: string; // The entity to whom this subjective memory belongs + timestamp: string; // ISO timestamp matching the WorldClock at event time + locationId: string | null; // Location where the event transpired + involvedEntityIds: string[]; // Other entity IDs present during the event - content: string; // third-person narrative summary — recallable - quotes: string[]; // verbatim lines, only for high-salience dialogue - importance: number; // 1–10, salience assigned at handoff - embedding: number[]; // for semantic search (storage representation TBD at build time) + content: string; // Third-person narrative summary of the event + quotes: string[]; // Verbatim dialogue lines of high narrative salience + importance: number; // Salience score from 1 (trivial) to 10 (life-altering) + embedding: number[]; // 768-dimensional vector embedding of the content } ``` -### Storage Model +--- -Tier 2 memory is stored in relational tables to allow efficient deterministic filtering. Embeddings are stored as raw BLOBs (containing a serialized `Float32Array`). +## 2. Storage Model -To avoid the build and installation friction associated with native C-extensions like `sqlite-vec` (e.g. node-gyp issues across platforms), index optimization relies on standard SQLite secondary indices. These indices allow database queries to execute in microseconds, even with hundreds of thousands of memories: +To support fast, low-latency queries across large historical datasets, Tier 2 memory is stored in standard SQLite tables. Vector embeddings are stored in raw binary format as `Float32Array` BLOBs. + +Standard secondary indices optimize query execution time to microseconds, eliminating the compilation and cross-platform installation overhead of native vector database extensions (such as `sqlite-vec` or `node-gyp` binaries). ```sql CREATE TABLE IF NOT EXISTS ledger_entries ( @@ -54,59 +60,78 @@ CREATE INDEX IF NOT EXISTS idx_ledger_importance ON ledger_entries(importance); CREATE INDEX IF NOT EXISTS idx_ledger_involved_entity ON ledger_involved_entities(entity_id); ``` -### Handoff (Deferred) +--- -The process of moving memories from the Tier 1 working buffer into Tier 2 is called **Handoff**. -During handoff, an LLM chunk-summarizes raw buffer events, extracts salient quotes, and assigns an `importance` score (1-10). Routine actions score low, while life-altering events score high. +## 3. The Handoff Pipeline -Because this summarization requires an LLM call, it utilizes the standard `LLMProviderInstance` inference provider routing architecture just like all other callers in the system. This allows the simulation to route handoff processing to a specific model. +Working memory (Tier 1 Buffer) entries are promoted to the Ledger through the automated [Handoff Pipeline](./handoff). -*Note: The automated handoff pipeline is currently deferred for future implementation.* +During handoff: +1. Candidate entries are clustered into narrative beats. +2. Ambient stage business and redundant details are pruned. +3. Chunks are synthesized into third-person summaries and assigned importance scores. +4. Text embeddings are generated for the summary. +5. The processed memories are committed to the SQLite store, and the short-term buffer is pruned. -### Retrieval Architecture +--- -Retrieval happens in phases to manage context window limits without running expensive vector searches across an entity's entire lifetime of memories. +## 4. Retrieval Architecture -#### Phase 1: Deterministic Heuristic Filtering +To prevent context window overflow and contain inference costs, memory retrieval is split into a fast database query phase followed by an in-memory ranking phase. -This is the primary database-level retrieval mechanism. We use fast SQL queries to filter down to a relevant candidate pool based on immediate context: +```mermaid +flowchart TD + A[Start Retrieval] --> B[Phase 1: SQL Filter] + B -->|Filter by Location, Co-located Entities, and High Salience| C[Candidate Pool] + C --> D[Phase 2: In-Memory Ranker] + D -->|1. Cosine Similarity| E[Relevance Scores] + D -->|2. Exponential Recency Decay| F[Recency Scores] + E & F --> G[Compute Combined Score] + G --> H[Chronological Associative Chaining] + H --> I[Format Prompt Section] +``` -1. **Spatial Cues**: Fetch recent memories where `location_id` equals the entity's current location. -2. **Social Cues**: Fetch recent memories involving the `involvedEntityIds` currently in the entity's perception radius. -3. **High Salience**: Always fetch memories with `importance >= 8` regardless of spatial or social context. +### Phase 1: Deterministic Heuristic Filtering +The primary selection uses indexes to retrieve a candidate pool (capped at 100 entries) from the database: +1. **Spatial Cues**: Fetch entries matching the character's current `locationId`. +2. **Social Cues**: Fetch entries where `involvedEntityIds` intersects with entities currently inside the character's perception radius. +3. **High Salience**: Always retrieve high-salience entries where `importance >= 8`. -#### Phase 2: Semantic & Episodic Ranking +### Phase 2: Semantic & Episodic Ranking +Once the candidate pool is loaded, the ranking engine evaluates entries in application memory: +1. **Semantic Similarity**: Cosine similarity is computed directly in JS/TS memory between the current prompt context and the candidate embeddings. +2. **Multi-Factor Scoring**: Candidates are ranked using a weighted linear combination: + $$\text{Score} = (\alpha \times \text{recency}) + (\beta \times \text{importance}) + (\gamma \times \text{relevance})$$ + * **Recency** is modeled via exponential decay based on elapsed simulation hours: $\text{decayRate}^{\text{hoursElapsed}}$. + * **Importance** is the normalized salience score (1-10) assigned during handoff. + * **Relevance** is the cosine similarity score. +3. **Chronological Associative Chaining**: When a memory is selected, the system automatically pulls in its adjacent chronological neighbors (preceding and succeeding ledger entries) to preserve episodic continuity in the prompt. -This phase runs in application memory using the candidates returned from Phase 1: +--- -1. **Semantic Match**: Compute cosine similarity dynamically in JS/TS memory over the candidate pool (limit 100). Since Phase 1 narrows the pool down significantly, vector comparisons are highly performant in JS, eliminating the need for native vector database extensions. -2. **Scoring Combination**: Combine recency, importance, and semantic match: - $$\text{Score} = (\text{recencyWeight} \times \text{recency}) + (\text{importanceWeight} \times \text{importanceNorm}) + (\text{relevanceWeight} \times \text{relevance})$$ - Where `recency` uses an exponential decay based on elapsed hours ($\text{decayRate}^{\text{hoursElapsed}}$). -3. **Associative Chain**: When a memory is selected, automatically pull in its immediate chronological neighbors (preceding and succeeding ledger entries) to preserve episodic continuity (mirroring how remembering one event triggers the memory of what happened right after). +## 5. Active Focus & Attention Loop -### Retrieval Triggers & Active Focus +In crowded settings (e.g., a room with many characters), retrieving long-term memories for all co-located entities would exhaust the prompt context window. To prevent this, retrieval uses an **Active Focus** selection strategy: -In crowded locations (e.g. a tavern with 15 other characters), retrieving memories for all co-located entities simultaneously would cause **context explosion**. To prevent this, Omnia utilizes an **Active Focus** trigger strategy: +* **Active Focus Set**: The prompt builder scans the last 10 entries of the character's working memory buffer. Any entity targeted by, spoken to, or mentioned in these entries is added to the Active Focus set. +* **Dynamic Capacity Limits**: + * If the number of co-located characters is small ($\le 3$), long-term memory is retrieved for all of them. + * If the environment is crowded ($> 3$), long-term retrieval is strictly restricted to the top 3 characters in the Active Focus set. +* This creates a realistic attention loop: when a new character interacts with the actor, they enter the working buffer, triggering the retrieval of their long-term history on the subsequent turn. -- **Active Focus Scanning**: The prompt builder scans the last 10 entries of the entity's recent working memory (Tier 1 Buffer). Any character that the actor has recently spoken to, thought about, or was targeted by is placed in the "Active Focus" set. -- **Dynamic Thresholding**: - - If the number of co-located entities is small ($\le 3$), long-term memory is retrieved for all of them. - - If the location is crowded ($> 3$ entities), the system **strictly** limits long-term retrieval to the top 3 characters in "Active Focus". -- This creates a natural attention loop. When a new character interacts with the actor, they immediately enter "Active Focus" in the buffer, triggering the retrieval of their long-term history on the subsequent turn. +--- -### Integration into Prompts +## 6. Prompt Formatting -Recalled entries are formatted into the prompt using chronological relative time grouping. System-level metrics like salience/importance scores are omitted to preserve immersion, and system UUIDs are mapped to subjective aliases. +Retrieved ledger entries are formatted into the prompt chronologically and mapped to subjective aliases. Internal metrics (e.g., importance numbers and raw system IDs) are omitted to preserve immersion. -To frame the prompt naturally: -1. Tier 1 working buffer entries are presented under the header `=== RECENT EVENTS ===`, referring strictly to events happening in the present narrative context. -2. Tier 2 recalled entries are presented under the header `=== YOUR MEMORIES ===`, framing them simply as the entity's memories. +* Working memory entries are injected under `=== RECENT EVENTS ===` (narrated relative to the present moment). +* Recalled ledger entries are injected under `=== YOUR MEMORIES ===` (presented as long-term recollections). ```text === RECENT EVENTS === Moments ago - - you spoke to Strider: "Hello there" + - You spoke to Strider: "Hello there" === YOUR MEMORIES === A couple days ago