From 53411399f2aae8a6379675d9142a2405234e373f Mon Sep 17 00:00:00 2001 From: bubbltaco Date: Sat, 9 Aug 2025 22:46:40 -0500 Subject: [PATCH 1/8] Add content management and compression --- lib/backend.ts | 37 ++++++ lib/context.ts | 334 +++++++++++++++++++++++++++++++++++++++++++++++++ lib/engine.ts | 40 ++++-- lib/prompts.ts | 204 +++++++++++++++++++++++------- lib/schemas.ts | 5 + 5 files changed, 569 insertions(+), 51 deletions(-) create mode 100644 lib/context.ts diff --git a/lib/backend.ts b/lib/backend.ts index a3f3ba7e..ade9265f 100644 --- a/lib/backend.ts +++ b/lib/backend.ts @@ -18,6 +18,8 @@ export interface Backend { onToken?: TokenCallback, ): Promise; + getContextLength(): Promise; + abort(): void; isAbortError(error: unknown): boolean; @@ -33,6 +35,7 @@ export interface DefaultBackendSettings { export class DefaultBackend implements Backend { controller = new AbortController(); + contextLength: number | null = null; // Can be overridden by subclasses to provide custom settings. getSettings(): DefaultBackendSettings { @@ -171,6 +174,40 @@ export class DefaultBackend implements Backend { return schema.parse(JSON.parse(response)) as Type; } + async getContextLength(): Promise { + if (this.contextLength !== null) { + return this.contextLength; + } + const client = this.getClient(); + const modelName = this.getSettings().model; + const MAX_PAGES_TO_SEARCH = 3; + const FALLBACK_CONTEXT_LENGTH = 64000; + + let models = await client.models.list(); + let pagesSearched = 0; + + while (pagesSearched < MAX_PAGES_TO_SEARCH) { + const found = models.data.find((m) => m.id === modelName) as { context_length?: number; } | undefined; + if (found && typeof found.context_length === "number") { + this.contextLength = found.context_length; + return found.context_length; + } + + pagesSearched++; + + // Try to get the next page if we haven't reached the limit + if (pagesSearched < MAX_PAGES_TO_SEARCH && models.hasNextPage()) { + models = await models.getNextPage(); + } else { + break; + } + } + + // Fallback to default if not found + this.contextLength = FALLBACK_CONTEXT_LENGTH; + return this.contextLength; + } + abort(): void { this.controller.abort(); } diff --git a/lib/context.ts b/lib/context.ts new file mode 100644 index 00000000..62ce5c6c --- /dev/null +++ b/lib/context.ts @@ -0,0 +1,334 @@ +import { LocationChangeEvent, NarrationEvent, State, Event } from "./state"; + +/** + * Represents the level of summarization applied to an event. + * One means it's an event level summary. + * Two means it's a scene level summary. + */ +enum SummaryLevel { + None = 0, + One = 1, + Two = 2 +} + +/** + * In order to make context fit within our budget, we go through a series of compression strategies. + * + * We start by replacing the oldest events one by one and checking along the way if the context fits. + * We replace the oldest events with the summary level specified in the strategy until we reach the max percent of + * events we can compress with the current strategy. At the point we move on the to the next strategy. + * + * Each subsequent strategy keeps the compression of previous strategies and + * either increases summary level of older events or applies the summary level to newer events. + */ + +interface CompressionStrategy { + summaryLevel: SummaryLevel; + percent: number; +} + +const COMPRESSION_STRATEGIES: CompressionStrategy[] = [ + { summaryLevel: SummaryLevel.One, percent: .5 }, // up to 50% of the oldest events are at least SummaryLevel.One + { summaryLevel: SummaryLevel.Two, percent: .25 }, // up to 25% of the oldest events are at least SummaryLevel.Two + { summaryLevel: SummaryLevel.One, percent: .8 }, // up to 80% of the oldest events are at least SummaryLevel.One + { summaryLevel: SummaryLevel.Two, percent: .5 }, // up to 50% of the oldest events are at least SummaryLevel.Two + { summaryLevel: SummaryLevel.One, percent: 1 } // all events are least SummaryLevel.One +]; + + +interface ContextUnit { + type : "location_change" | "narration"; + summaryLevel: SummaryLevel; + text: string; + /** The number of tokens in this context unit at the current summary level */ + tokenCount: number; + /** original event index (0 = oldest) covered by this unit, inclusive */ + startEventIndex: number; + /** original event index (0 = oldest) covered by this unit, inclusive */ + endEventIndex: number; +} + +export function getContext(state: State, tokenBudget: number): string { + // we filter down to only narration and location change events, + // because the other event types like character_introduction are implied in the narration + const events = state.events.filter(event => event.type === "narration" || event.type === "location_change"); + + if (events.length === 0) { + return ""; + } + + // Create initial context units + let contextUnits = createInitialContextUnits(events, state); + // if no compression is needed just return the context + if (isContextWithinBudget(contextUnits, tokenBudget)) return convertContextUnitsToText(contextUnits); + + // Reverse to get oldest last prior to compression + // (since we'll be removing oldest events first and removing from the end is more efficient) + contextUnits.reverse(); + + // Apply compression strategies until we fit within budget + for (const strategy of COMPRESSION_STRATEGIES) { + contextUnits = applyCompressionStrategy(contextUnits, strategy, events, tokenBudget); + + if (isContextWithinBudget(contextUnits, tokenBudget)) { + break; + } + } + + // Reverse to get chronological order for output + contextUnits.reverse(); + return convertContextUnitsToText(contextUnits); +} + +/** + * Create the initial context without any compression. + * @param events events that should go in the context + * @param state current state + * @returns + */ +function createInitialContextUnits(events: (NarrationEvent | LocationChangeEvent)[], state: State): ContextUnit[] { + const units: ContextUnit[] = []; + + events.forEach((event, i) => { + if (event.type === "narration") { + const text = event.text; + units.push({ + type: "narration", + summaryLevel: SummaryLevel.None, + text, + tokenCount: event.tokens ?? getApproximateTokenCount(text), + startEventIndex: i, + endEventIndex: i + }); + } else if (event.type === "location_change") { + const text = convertLocationChangeEventToText(event, state); + units.push({ + type: "location_change", + summaryLevel: SummaryLevel.None, + text, + tokenCount: getApproximateTokenCount(text), + startEventIndex: i, + endEventIndex: i + }); + } + }); + + return units; +} + +/** + * Apply a compression strategy to events in the context until the token budget is met or we've reached the maximum number of + * events to compress with the strategy. + * @param contextUnits The context units to compress. + * @param strategy The compression strategy to apply. + * @param events The original events. + * @param tokenBudget The token budget. + * @returns The compressed context units. + */ +function applyCompressionStrategy( + contextUnits: ContextUnit[], + strategy: CompressionStrategy, + events: (NarrationEvent | LocationChangeEvent)[], + tokenBudget: number +): ContextUnit[] { + const numTotalEvents = events.length; + const maxEventsToCompress = Math.floor(numTotalEvents * strategy.percent); + + // to calculate the percentage of events compressed, we'll store the index of the latest event compressed at + // the level required by the strategy. + // ex: if we have 100 events and latestEventCompressedIndex is 30, we've compressed 30% of the events + let latestEventCompressedIndex = 0; + + // Work from oldest to newest, applying the summary level that's needed + // until we reach the max percent of events we can compress with this strategy. + for (let i = contextUnits.length - 1; i >= 0 && latestEventCompressedIndex < maxEventsToCompress - 1; i--) { + if (contextUnits[i].summaryLevel < strategy.summaryLevel) { + if (strategy.summaryLevel === SummaryLevel.One) { + contextUnits[i] = compressToEventSummary(contextUnits[i], events); + } else if (strategy.summaryLevel === SummaryLevel.Two) { + const compressed = compressToSceneSummary(contextUnits, i, events); + if (compressed) { + // Replace the units that were compressed into a scene summary + contextUnits.splice(compressed.removeStart, compressed.removeCount, compressed.unit); + // Adjust index since we modified the array + i = compressed.removeStart; + } + } + // after applying a compression to an event, check if we're under budget + if (isContextWithinBudget(contextUnits, tokenBudget)) { + return contextUnits; + } + } + latestEventCompressedIndex = contextUnits[i].endEventIndex; + } + + return contextUnits; +} + +/** + * Replace an event with its summary + */ +function compressToEventSummary( + unit: ContextUnit, + events: (NarrationEvent | LocationChangeEvent)[], +): ContextUnit { + if (unit.type === "location_change") { + // Skip location change events for event-level summaries + return unit; + } + + const event = events[unit.startEventIndex] as NarrationEvent; + if (event.summary) { + return { + ...unit, + summaryLevel: SummaryLevel.One, + text: event.summary, + tokenCount: event.summaryTokens || getApproximateTokenCount(event.summary) + }; + } + + // No summary available, return original + return unit; +} + +/** + * Replace a series of events in a scene with the scene summary from the location change event that ends the scene. + * @param contextUnits All current context units. + * @param unitIndex The index of the unit to start compressing from (should be within the scene to compress). + * @param events The original events. + * @returns The compressed context unit, which index to remove from, and how many to remove. + */ +function compressToSceneSummary( + contextUnits: ContextUnit[], + unitIndex: number, + events: (NarrationEvent | LocationChangeEvent)[], +): { unit: ContextUnit; removeStart: number; removeCount: number } | null { + // Remember: contextUnits are in reverse chronological order (oldest at end) + // Find the location change that starts the scene containing this unit + let sceneStartIndex = -1; + + // Look backwards (towards older events) to find the scene start + for (let i = unitIndex; i < contextUnits.length; i++) { + if (contextUnits[i].type === "location_change") { + sceneStartIndex = i; + break; + } + } + + if (sceneStartIndex === -1) { + return null; // No scene start found + } + + // Find where this scene ends by looking forward (towards newer events) + let sceneEndIndex = unitIndex; + for (let i = unitIndex - 1; i >= 0; i--) { + if (contextUnits[i].type === "location_change") { + sceneEndIndex = i + 1; // Scene ends just before this location change + break; + } + } + + if (sceneEndIndex <= 0) { + sceneEndIndex = 0; // Scene goes to the newest events + } + + // Get the location change event that ends this scene (contains the scene summary) + // This is the location change AFTER the scene start in the original chronological order + const sceneStartEventIndex = contextUnits[sceneStartIndex].startEventIndex; + const nextLocationChangeEvent = findNextLocationChangeEvent(events, sceneStartEventIndex); + + if (!nextLocationChangeEvent || !nextLocationChangeEvent.summary) { + return null; // No scene summary available + } + + // Create the scene summary unit + const sceneSummaryUnit: ContextUnit = { + type: "location_change", + summaryLevel: SummaryLevel.Two, + text: nextLocationChangeEvent.summary, + tokenCount: nextLocationChangeEvent.summaryTokens || getApproximateTokenCount(nextLocationChangeEvent.summary), + startEventIndex: contextUnits[sceneStartIndex].startEventIndex, + endEventIndex: contextUnits[sceneEndIndex].endEventIndex + }; + + return { + unit: sceneSummaryUnit, + removeStart: sceneEndIndex, + removeCount: sceneStartIndex - sceneEndIndex + 1 + }; +} + +/** + * Find the next location change event after a given index to start looking from. + * @param events The list of events to search. + * @param afterIndex The index to start searching from (exclusive). + * @returns The next location change event, or null if not found. + */ +function findNextLocationChangeEvent( + events: (NarrationEvent | LocationChangeEvent)[], + afterIndex: number +): LocationChangeEvent | null { + for (let i = afterIndex + 1; i < events.length; i++) { + if (events[i].type === "location_change") { + return events[i] as LocationChangeEvent; + } + } + return null; +} + +/** + * Check if the current context is within the token budget. + * @param contextUnits The context units to check. + * @param tokenBudget The token budget to compare against. + * @returns True if the context is within budget, false otherwise. + */ +function isContextWithinBudget(contextUnits: ContextUnit[], tokenBudget: number): boolean { + const totalTokens = contextUnits.reduce((sum: number, unit: ContextUnit) => sum + unit.tokenCount, 0); + return totalTokens <= tokenBudget; +} + +/** + * Converts a location change event to text. + * @param event The location change event to convert. + * @param state The current state + * @returns A string describing the location change event. + */ +function convertLocationChangeEventToText(event: LocationChangeEvent, state: State): string { + const location = state.locations[event.locationIndex]; + + return `----- + +LOCATION CHANGE + +${state.protagonist.name} is entering ${location.name}. ${location.description} + +The following characters are present at ${location.name}: + +${event.presentCharacterIndices + .map((index) => { + const character = state.characters[index]; + return `${character.name}: ${character.biography}`; + }) + .join("\n\n")} + +-----`; +} + +/** + * Converts an array of context units representing the context to text. + * @param units The context units to convert. + * @returns A string representation of the context. + */ +function convertContextUnitsToText(units: ContextUnit[]): string { + return units.map(unit => unit.text).join("\n\n"); +} + +/** + * Estimates the number of tokens in a text string assuming 3.3 characters per token and rounding up. + * @param text The text to analyze. + * @returns The estimated token count. + */ +export function getApproximateTokenCount(text: string): number { + const numCharacters = text.length; + return Math.ceil(numCharacters / 3.3); +}; \ No newline at end of file diff --git a/lib/engine.ts b/lib/engine.ts index 1efa4f23..3cc880b4 100644 --- a/lib/engine.ts +++ b/lib/engine.ts @@ -15,6 +15,8 @@ import { generateStartingLocationPrompt, generateWorldPrompt, narratePrompt, + summarizeNarrationEventPrompt, + summarizeScenePrompt, type Prompt, } from "./prompts"; import * as schemas from "./schemas"; @@ -63,7 +65,7 @@ export async function next( } }; - const narrate = async (action?: string) => { + const narrate = async (tokenBudget: number, action?: string) => { const event: NarrationEvent = { type: "narration", text: "", @@ -74,8 +76,18 @@ export async function next( state.events.push(event); step = ["Narrating", ""]; - event.text = await backend.getNarration(narratePrompt(state, action), (token: string, count: number) => { + event.text = await backend.getNarration(narratePrompt(state, tokenBudget, action), (token: string, count: number) => { event.text += token; + event.tokens = count; + onToken(token, count); + updateState(); + }); + + // create a summary of the narration + step = ["Summarizing", "This typically takes between 10 and 30 seconds"]; + event.summary = await backend.getNarration(summarizeNarrationEventPrompt(state, event), (token: string, count: number) => { + event.summary += token; + event.summaryTokens = count; onToken(token, count); updateState(); }); @@ -166,6 +178,9 @@ export async function next( state.view = "chat"; } else if (state.view === "chat") { + const contextLength = await backend.getContextLength(); + const tokenBudget = Math.floor(contextLength * 0.5); // Set token budget to 50% of context length + state.actions = []; updateState(); @@ -177,17 +192,17 @@ export async function next( updateState(); } - await narrate(action); + await narrate(tokenBudget, action); step = ["Checking for location change", "This typically takes a few seconds"]; - if (!(await getBoolean(checkIfSameLocationPrompt(state), onToken))) { + if (!(await getBoolean(checkIfSameLocationPrompt(state, tokenBudget), onToken))) { const schema = z.object({ newLocation: schemas.Location, accompanyingCharacters: z.enum(state.characters.map((character) => character.name)).array(), }); step = ["Generating location", "This typically takes between 10 and 30 seconds"]; - const newLocationInfo = await backend.getObject(generateNewLocationPrompt(state), schema, onToken); + const newLocationInfo = await backend.getObject(generateNewLocationPrompt(state, tokenBudget), schema, onToken); await onLocationChange(newLocationInfo.newLocation); @@ -204,7 +219,7 @@ export async function next( } // Must be called *before* adding the location change event to the state! - const generateCharactersPrompt = generateNewCharactersPrompt(state, newLocationInfo.accompanyingCharacters); + const generateCharactersPrompt = generateNewCharactersPrompt(state, newLocationInfo.accompanyingCharacters, tokenBudget); const event: LocationChangeEvent = { type: "location_change", @@ -212,6 +227,15 @@ export async function next( presentCharacterIndices: accompanyingCharacterIndices, }; + // summarize the previous scene (all events after the last location change) + step = ["Summarizing scene", "This typically takes between 10 and 30 seconds"]; + event.summary = await backend.getNarration(summarizeScenePrompt(state), (token: string, count: number) => { + event.summary += token; + event.summaryTokens = count; + onToken(token, count); + updateState(); + }); + state.events.push(event); updateState(); @@ -223,12 +247,12 @@ export async function next( event.presentCharacterIndices.push(i); } - await narrate(); + await narrate(tokenBudget); } step = ["Generating actions", "This typically takes a few seconds"]; state.actions = await backend.getObject( - generateActionsPrompt(state), + generateActionsPrompt(state, tokenBudget), schemas.Action.array().length(3), onToken, ); diff --git a/lib/prompts.ts b/lib/prompts.ts index 37c3a814..cfdda1c0 100644 --- a/lib/prompts.ts +++ b/lib/prompts.ts @@ -1,8 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2025 Philipp Emanuel Weidmann +import { getApproximateTokenCount, getContext } from "./context"; import * as schemas from "./schemas"; -import type { State } from "./state"; +import type { LocationChangeEvent, NarrationEvent, State } from "./state"; export interface Prompt { system: string; @@ -69,57 +70,35 @@ Include a short biography (100 words maximum) for each character. `); } -function makeMainPrompt(prompt: string, state: State): Prompt { - const context = state.events - .map((event) => { - if (event.type === "narration") { - return event.text; - } else if (event.type === "character_introduction") { - // Implied in the narration. - return null; - } else if (event.type === "location_change") { - // Also implied in the narration, but used to structure the story and describe available characters. - const location = state.locations[event.locationIndex]; - return normalize(` ------ +function makeMainPrompt(prompt: string, state: State, tokenBudget: number): Prompt { + const promptPreamble = +`This is a fantasy adventure RPG set in the world of ${state.world.name}. ${state.world.description} -LOCATION CHANGE - -${state.protagonist.name} is entering ${location.name}. ${location.description} +The protagonist (who you should refer to as "you" in your narration, as the adventure happens from their perspective) +is ${state.protagonist.name}. ${state.protagonist.biography} -The following characters are present at ${location.name}: +Here is what has happened so far:`; -${event.presentCharacterIndices - .map((index) => { - const character = state.characters[index]; - return `${character.name}: ${character.biography}`; - }) - .join("\n\n")} + // get the tokens used by the prompt and the preamble + const normalizedPrompt = normalize(prompt); + const promptTokens = getApproximateTokenCount(normalizedPrompt); + const preambleTokens = getApproximateTokenCount(promptPreamble); ------ -`); - } - }) - .filter((text) => !!text) - .join("\n\n"); + // get the context based on the token budget minus the prompt and preamble tokens + const contextTokenBudget = tokenBudget - promptTokens - preambleTokens; + const context = getContext(state, contextTokenBudget); return makePrompt(` -This is a fantasy adventure RPG set in the world of ${state.world.name}. ${state.world.description} - -The protagonist (who you should refer to as "you" in your narration, as the adventure happens from their perspective) -is ${state.protagonist.name}. ${state.protagonist.biography} - -Here is what has happened so far: - +${promptPreamble} ${context} -${normalize(prompt)} +${normalizedPrompt} `); } -export function narratePrompt(state: State, action?: string): Prompt { +export function narratePrompt(state: State, tokenBudget: number, action?: string): Prompt { return makeMainPrompt( ` ${action ? `The protagonist (${state.protagonist.name}) has chosen to do the following: ${action}.` : ""} @@ -134,10 +113,11 @@ Remember to refer to the protagonist (${state.protagonist.name}) as "you" in you Do not explicitly ask the protagonist for a response at the end; they already know what is expected of them. `, state, + tokenBudget, ); } -export function generateActionsPrompt(state: State): Prompt { +export function generateActionsPrompt(state: State, tokenBudget: number): Prompt { return makeMainPrompt( ` Suggest 3 options for what the protagonist (${state.protagonist.name}) could do or say next. @@ -145,20 +125,22 @@ Each option should be a single, short sentence that starts with a verb. Return the options as a JSON array of strings. `, state, + tokenBudget, ); } -export function checkIfSameLocationPrompt(state: State): Prompt { +export function checkIfSameLocationPrompt(state: State, tokenBudget: number): Prompt { return makeMainPrompt( ` Is the protagonist (${state.protagonist.name}) still at ${state.locations[state.protagonist.locationIndex].name}? Answer with "yes" or "no". `, state, + tokenBudget, ); } -export function generateNewLocationPrompt(state: State): Prompt { +export function generateNewLocationPrompt(state: State, tokenBudget: number): Prompt { return makeMainPrompt( ` The protagonist (${state.protagonist.name}) has left ${state.locations[state.protagonist.locationIndex].name}. @@ -166,11 +148,12 @@ Return the name and type of their new location, and a short description (100 wor Also include the names of the characters that are going to accompany ${state.protagonist.name} there, if any. `, state, + tokenBudget, ); } // Must be called *before* adding the location change event to the state! -export function generateNewCharactersPrompt(state: State, accompanyingCharacters: string[]): Prompt { +export function generateNewCharactersPrompt(state: State, accompanyingCharacters: string[], tokenBudget: number): Prompt { const location = state.locations[state.protagonist.locationIndex]; return makeMainPrompt( @@ -185,5 +168,140 @@ Return the character descriptions as an array of JSON objects. Include a short biography (100 words maximum) for each character. `, state, + tokenBudget, ); } + +export function summarizeNarrationEventPrompt(state: State, narrationEvent: NarrationEvent): Prompt { + const current = narrationEvent; + const protagonistName = state.protagonist.name; + + // Find most recent previous narration or location_change + let prevContext = ""; + for (let i = state.events.length - 2; i >= 0; i--) { + const ev = state.events[i]; + if (ev.type === "narration") { + prevContext = ev.text; + break; + } else if (ev.type === "location_change") { + const location = state.locations[ev.locationIndex]; + prevContext = normalize(` +${protagonistName} entered ${location.name}. ${location.description} + +The following characters are present at ${location.name}: +${ev.presentCharacterIndices + .map((index) => { + const character = state.characters[index]; + return `${character.name}: ${character.biography}`; + }) + .join("\n\n")} +`); + break; + } + } + + // Build the prompt + const userPrompt = ` +This is a fantasy adventure RPG set in the world of ${state.world.name}. ${state.world.description} + +The protagonist (who you should refer to as "you", as the adventure happens from their perspective) +is ${state.protagonist.name}. ${state.protagonist.biography} + +You will create a compact memory note of the latest event in this adventure. +This memory is used as long-term context for future generations. + +${prevContext ? `PREVIOUS CONTEXT (most recent prior event or location change)\n\n${prevContext}\n\n` : ""} + +CURRENT EVENT + +${current.text} + +TASK + +Write a single-paragraph short summary (no more than 100 words in total). Use proper names and refer to the protagonist as "you". +Capture only plot-relevant facts that will matter later: +- what ${protagonistName} does/learns/decides, +- other characters' impactful actions/agreements, +- changes to location, inventory, injuries, or relationships, +- discoveries/clues, +- unresolved goals, promises, threats, or timers. +Do not quote dialogue, add new facts, or include stylistic prose. + +OUTPUT + +Return only the summary paragraph with no preamble, labels, markdown or quotes. +`; + + return makePrompt(userPrompt); +} + +export function summarizeScenePrompt(state: State): Prompt { + const protagonistName = state.protagonist.name; + + // Find the start of the current scene (most recent location change in state). + let sceneStartIndex = -1; + for (let i = state.events.length - 1; i >= 0; i--) { + if (state.events[i].type === "location_change") { + sceneStartIndex = i; + break; + } + } + + // Build location + cast context from that location change + let sceneContext = ""; + const ev = state.events[sceneStartIndex]; + // ev is a LocationChangeEvent here + const location = state.locations[(ev as LocationChangeEvent).locationIndex]; + const cast = (ev as any).presentCharacterIndices + .map((idx: number) => { + const c = state.characters[idx]; + return `${c.name}: ${c.biography}`; + }) + .join("\n\n"); + + sceneContext = normalize(` +${protagonistName} is at ${location.name}. ${location.description} + +The following characters are present at ${location.name}: + +${cast} +`); + + // Gather all narration texts from this scene (after the last location change). + const narrationTexts = state.events + .slice(sceneStartIndex + 1) + .map((ev) => (ev.type === "narration" ? ev.text : null)) + .filter((t): t is string => !!t) + .join("\n\n"); + + const userPrompt = ` +This is a fantasy adventure RPG set in the world of ${state.world.name}. ${state.world.description} + +The protagonist (refer to them as "you") is ${protagonistName}. ${state.protagonist.biography} + +You will create a compact memory of the just-completed scene. This memory is used as long-term context for future generations. + +SCENE + +${sceneContext} + +${narrationTexts} + +TASK + +Write a 1-2 paragraph scene summary (no more than 300 words in total). Use proper names and refer to the protagonist as "you". +Capture only plot-relevant facts that will matter later: +- what ${protagonistName} does/learns/decides, +- other characters' impactful actions/agreements, +- changes to location, inventory, injuries, or relationships, +- discoveries/clues, +- unresolved goals, promises, threats, or timers. +Do not quote dialogue, add new facts, or include stylistic prose. + +OUTPUT + +Return only the summary with no preamble, labels, markdown or quotes. +`; + + return makePrompt(userPrompt); +} diff --git a/lib/schemas.ts b/lib/schemas.ts index a8ad5795..89455159 100644 --- a/lib/schemas.ts +++ b/lib/schemas.ts @@ -56,6 +56,9 @@ export const NarrationEvent = z.object({ text: Text.max(5000), locationIndex: Index, referencedCharacterIndices: Index.array(), + tokens: z.int().optional(), + summary: Text.max(2500).optional(), + summaryTokens: z.int().optional(), }); export const CharacterIntroductionEvent = z.object({ @@ -67,6 +70,8 @@ export const LocationChangeEvent = z.object({ type: z.literal("location_change"), locationIndex: Index, presentCharacterIndices: Index.array(), + summary: Text.max(5000).optional(), + summaryTokens: z.int().optional(), }); export const Event = z.discriminatedUnion("type", [ From 1ecaff46bf23b173d1077301610136d622eacd9d Mon Sep 17 00:00:00 2001 From: bubbltaco Date: Sun, 10 Aug 2025 14:27:10 -0500 Subject: [PATCH 2/8] changes based on feedback --- lib/backend.ts | 8 +------- lib/context.ts | 12 ++++++------ lib/engine.ts | 3 --- lib/schemas.ts | 3 --- 4 files changed, 7 insertions(+), 19 deletions(-) diff --git a/lib/backend.ts b/lib/backend.ts index ade9265f..ff596ee3 100644 --- a/lib/backend.ts +++ b/lib/backend.ts @@ -35,7 +35,6 @@ export interface DefaultBackendSettings { export class DefaultBackend implements Backend { controller = new AbortController(); - contextLength: number | null = null; // Can be overridden by subclasses to provide custom settings. getSettings(): DefaultBackendSettings { @@ -175,9 +174,6 @@ export class DefaultBackend implements Backend { } async getContextLength(): Promise { - if (this.contextLength !== null) { - return this.contextLength; - } const client = this.getClient(); const modelName = this.getSettings().model; const MAX_PAGES_TO_SEARCH = 3; @@ -189,7 +185,6 @@ export class DefaultBackend implements Backend { while (pagesSearched < MAX_PAGES_TO_SEARCH) { const found = models.data.find((m) => m.id === modelName) as { context_length?: number; } | undefined; if (found && typeof found.context_length === "number") { - this.contextLength = found.context_length; return found.context_length; } @@ -204,8 +199,7 @@ export class DefaultBackend implements Backend { } // Fallback to default if not found - this.contextLength = FALLBACK_CONTEXT_LENGTH; - return this.contextLength; + return FALLBACK_CONTEXT_LENGTH; } abort(): void { diff --git a/lib/context.ts b/lib/context.ts index 62ce5c6c..84a42ca9 100644 --- a/lib/context.ts +++ b/lib/context.ts @@ -1,4 +1,4 @@ -import { LocationChangeEvent, NarrationEvent, State, Event } from "./state"; +import type { LocationChangeEvent, NarrationEvent, State, Event } from "./state"; /** * Represents the level of summarization applied to an event. @@ -96,7 +96,7 @@ function createInitialContextUnits(events: (NarrationEvent | LocationChangeEvent type: "narration", summaryLevel: SummaryLevel.None, text, - tokenCount: event.tokens ?? getApproximateTokenCount(text), + tokenCount: getApproximateTokenCount(text), startEventIndex: i, endEventIndex: i }); @@ -183,7 +183,7 @@ function compressToEventSummary( ...unit, summaryLevel: SummaryLevel.One, text: event.summary, - tokenCount: event.summaryTokens || getApproximateTokenCount(event.summary) + tokenCount: getApproximateTokenCount(event.summary) }; } @@ -246,7 +246,7 @@ function compressToSceneSummary( type: "location_change", summaryLevel: SummaryLevel.Two, text: nextLocationChangeEvent.summary, - tokenCount: nextLocationChangeEvent.summaryTokens || getApproximateTokenCount(nextLocationChangeEvent.summary), + tokenCount: getApproximateTokenCount(nextLocationChangeEvent.summary), startEventIndex: contextUnits[sceneStartIndex].startEventIndex, endEventIndex: contextUnits[sceneEndIndex].endEventIndex }; @@ -324,11 +324,11 @@ function convertContextUnitsToText(units: ContextUnit[]): string { } /** - * Estimates the number of tokens in a text string assuming 3.3 characters per token and rounding up. + * Estimates the number of tokens in a text string assuming 3 characters per token and rounding up. * @param text The text to analyze. * @returns The estimated token count. */ export function getApproximateTokenCount(text: string): number { const numCharacters = text.length; - return Math.ceil(numCharacters / 3.3); + return Math.ceil(numCharacters / 3); }; \ No newline at end of file diff --git a/lib/engine.ts b/lib/engine.ts index 3cc880b4..b813f5f3 100644 --- a/lib/engine.ts +++ b/lib/engine.ts @@ -78,7 +78,6 @@ export async function next( step = ["Narrating", ""]; event.text = await backend.getNarration(narratePrompt(state, tokenBudget, action), (token: string, count: number) => { event.text += token; - event.tokens = count; onToken(token, count); updateState(); }); @@ -87,7 +86,6 @@ export async function next( step = ["Summarizing", "This typically takes between 10 and 30 seconds"]; event.summary = await backend.getNarration(summarizeNarrationEventPrompt(state, event), (token: string, count: number) => { event.summary += token; - event.summaryTokens = count; onToken(token, count); updateState(); }); @@ -231,7 +229,6 @@ export async function next( step = ["Summarizing scene", "This typically takes between 10 and 30 seconds"]; event.summary = await backend.getNarration(summarizeScenePrompt(state), (token: string, count: number) => { event.summary += token; - event.summaryTokens = count; onToken(token, count); updateState(); }); diff --git a/lib/schemas.ts b/lib/schemas.ts index 89455159..8ff1e058 100644 --- a/lib/schemas.ts +++ b/lib/schemas.ts @@ -56,9 +56,7 @@ export const NarrationEvent = z.object({ text: Text.max(5000), locationIndex: Index, referencedCharacterIndices: Index.array(), - tokens: z.int().optional(), summary: Text.max(2500).optional(), - summaryTokens: z.int().optional(), }); export const CharacterIntroductionEvent = z.object({ @@ -71,7 +69,6 @@ export const LocationChangeEvent = z.object({ locationIndex: Index, presentCharacterIndices: Index.array(), summary: Text.max(5000).optional(), - summaryTokens: z.int().optional(), }); export const Event = z.discriminatedUnion("type", [ From 97fac1757147a9d0dd5b6d3030b1ec9cdfd79e0a Mon Sep 17 00:00:00 2001 From: bubbltaco Date: Sun, 10 Aug 2025 16:01:46 -0500 Subject: [PATCH 3/8] replace context management algorithm --- lib/context.ts | 307 +++++++++++++++++++------------------------------ 1 file changed, 121 insertions(+), 186 deletions(-) diff --git a/lib/context.ts b/lib/context.ts index 84a42ca9..0ecf27b8 100644 --- a/lib/context.ts +++ b/lib/context.ts @@ -1,46 +1,9 @@ -import type { LocationChangeEvent, NarrationEvent, State, Event } from "./state"; - -/** - * Represents the level of summarization applied to an event. - * One means it's an event level summary. - * Two means it's a scene level summary. - */ -enum SummaryLevel { - None = 0, - One = 1, - Two = 2 -} - -/** - * In order to make context fit within our budget, we go through a series of compression strategies. - * - * We start by replacing the oldest events one by one and checking along the way if the context fits. - * We replace the oldest events with the summary level specified in the strategy until we reach the max percent of - * events we can compress with the current strategy. At the point we move on the to the next strategy. - * - * Each subsequent strategy keeps the compression of previous strategies and - * either increases summary level of older events or applies the summary level to newer events. - */ - -interface CompressionStrategy { - summaryLevel: SummaryLevel; - percent: number; -} - -const COMPRESSION_STRATEGIES: CompressionStrategy[] = [ - { summaryLevel: SummaryLevel.One, percent: .5 }, // up to 50% of the oldest events are at least SummaryLevel.One - { summaryLevel: SummaryLevel.Two, percent: .25 }, // up to 25% of the oldest events are at least SummaryLevel.Two - { summaryLevel: SummaryLevel.One, percent: .8 }, // up to 80% of the oldest events are at least SummaryLevel.One - { summaryLevel: SummaryLevel.Two, percent: .5 }, // up to 50% of the oldest events are at least SummaryLevel.Two - { summaryLevel: SummaryLevel.One, percent: 1 } // all events are least SummaryLevel.One -]; - +import type { LocationChangeEvent, NarrationEvent, State } from "./state"; interface ContextUnit { type : "location_change" | "narration"; - summaryLevel: SummaryLevel; text: string; - /** The number of tokens in this context unit at the current summary level */ + /** The number of tokens in this context unit */ tokenCount: number; /** original event index (0 = oldest) covered by this unit, inclusive */ startEventIndex: number; @@ -48,6 +11,12 @@ interface ContextUnit { endEventIndex: number; } +/** + * Get the context of events given the current state and token budget that we need to fit within. + * @param state The current state. + * @param tokenBudget The token budget. + * @returns The context as a string. + */ export function getContext(state: State, tokenBudget: number): string { // we filter down to only narration and location change events, // because the other event types like character_introduction are implied in the narration @@ -59,24 +28,27 @@ export function getContext(state: State, tokenBudget: number): string { // Create initial context units let contextUnits = createInitialContextUnits(events, state); - // if no compression is needed just return the context - if (isContextWithinBudget(contextUnits, tokenBudget)) return convertContextUnitsToText(contextUnits); - - // Reverse to get oldest last prior to compression - // (since we'll be removing oldest events first and removing from the end is more efficient) - contextUnits.reverse(); - // Apply compression strategies until we fit within budget - for (const strategy of COMPRESSION_STRATEGIES) { - contextUnits = applyCompressionStrategy(contextUnits, strategy, events, tokenBudget); + // Step 1: Try full text without summaries + if (isContextWithinBudget(contextUnits, tokenBudget)) { + return convertContextUnitsToText(contextUnits); + } - if (isContextWithinBudget(contextUnits, tokenBudget)) { - break; - } + // Step 2: Replace oldest narration events with their summaries one by one + contextUnits = replaceNarrationEventsWithSummaries(contextUnits, events, tokenBudget); + if (isContextWithinBudget(contextUnits, tokenBudget)) { + return convertContextUnitsToText(contextUnits); } - - // Reverse to get chronological order for output - contextUnits.reverse(); + + // Step 3: Replace oldest scenes with their scene summaries (except latest scene) + contextUnits = replaceScenesWithSummaries(contextUnits, events, tokenBudget); + if (isContextWithinBudget(contextUnits, tokenBudget)) { + return convertContextUnitsToText(contextUnits); + } + + // Step 4: Remove oldest scenes until we're under budget + contextUnits = removeOldestScenes(contextUnits, tokenBudget); + return convertContextUnitsToText(contextUnits); } @@ -94,7 +66,6 @@ function createInitialContextUnits(events: (NarrationEvent | LocationChangeEvent const text = event.text; units.push({ type: "narration", - summaryLevel: SummaryLevel.None, text, tokenCount: getApproximateTokenCount(text), startEventIndex: i, @@ -104,7 +75,6 @@ function createInitialContextUnits(events: (NarrationEvent | LocationChangeEvent const text = convertLocationChangeEventToText(event, state); units.push({ type: "location_change", - summaryLevel: SummaryLevel.None, text, tokenCount: getApproximateTokenCount(text), startEventIndex: i, @@ -117,163 +87,128 @@ function createInitialContextUnits(events: (NarrationEvent | LocationChangeEvent } /** - * Apply a compression strategy to events in the context until the token budget is met or we've reached the maximum number of - * events to compress with the strategy. - * @param contextUnits The context units to compress. - * @param strategy The compression strategy to apply. - * @param events The original events. + * Replace oldest narration events with their summaries one by one until under budget. + * @param contextUnits The current context units. + * @param events The original events array. * @param tokenBudget The token budget. - * @returns The compressed context units. + * @returns Updated context units. */ -function applyCompressionStrategy( +function replaceNarrationEventsWithSummaries( contextUnits: ContextUnit[], - strategy: CompressionStrategy, events: (NarrationEvent | LocationChangeEvent)[], tokenBudget: number ): ContextUnit[] { - const numTotalEvents = events.length; - const maxEventsToCompress = Math.floor(numTotalEvents * strategy.percent); - - // to calculate the percentage of events compressed, we'll store the index of the latest event compressed at - // the level required by the strategy. - // ex: if we have 100 events and latestEventCompressedIndex is 30, we've compressed 30% of the events - let latestEventCompressedIndex = 0; + const units = [...contextUnits]; - // Work from oldest to newest, applying the summary level that's needed - // until we reach the max percent of events we can compress with this strategy. - for (let i = contextUnits.length - 1; i >= 0 && latestEventCompressedIndex < maxEventsToCompress - 1; i--) { - if (contextUnits[i].summaryLevel < strategy.summaryLevel) { - if (strategy.summaryLevel === SummaryLevel.One) { - contextUnits[i] = compressToEventSummary(contextUnits[i], events); - } else if (strategy.summaryLevel === SummaryLevel.Two) { - const compressed = compressToSceneSummary(contextUnits, i, events); - if (compressed) { - // Replace the units that were compressed into a scene summary - contextUnits.splice(compressed.removeStart, compressed.removeCount, compressed.unit); - // Adjust index since we modified the array - i = compressed.removeStart; - } - } - // after applying a compression to an event, check if we're under budget - if (isContextWithinBudget(contextUnits, tokenBudget)) { - return contextUnits; + for (let i = 0; i < units.length; i++) { + if (isContextWithinBudget(units, tokenBudget)) { + break; + } + + if (units[i].type === "narration") { + // replace the narration text with its summary + const originalEvent = events[units[i].startEventIndex] as NarrationEvent; + if (originalEvent.summary) { + units[i] = { + ...units[i], + text: originalEvent.summary, + tokenCount: getApproximateTokenCount(originalEvent.summary) + }; } } - latestEventCompressedIndex = contextUnits[i].endEventIndex; - } - - return contextUnits; -} - -/** - * Replace an event with its summary - */ -function compressToEventSummary( - unit: ContextUnit, - events: (NarrationEvent | LocationChangeEvent)[], -): ContextUnit { - if (unit.type === "location_change") { - // Skip location change events for event-level summaries - return unit; - } - - const event = events[unit.startEventIndex] as NarrationEvent; - if (event.summary) { - return { - ...unit, - summaryLevel: SummaryLevel.One, - text: event.summary, - tokenCount: getApproximateTokenCount(event.summary) - }; } - // No summary available, return original - return unit; + return units; } /** - * Replace a series of events in a scene with the scene summary from the location change event that ends the scene. - * @param contextUnits All current context units. - * @param unitIndex The index of the unit to start compressing from (should be within the scene to compress). - * @param events The original events. - * @returns The compressed context unit, which index to remove from, and how many to remove. + * Replace oldest scenes with their scene summaries (except the latest scene). + * We consider a location_change to mark the start of a scene. + * Then the next location_change marks the end of that scene and the start of the next scene. + * So when we summarize, we replace all events including the starting location_change up to right before the ending location_change + * with the scene summary. + * @param contextUnits The current context units. + * @param events The original events array. + * @param tokenBudget The token budget. + * @returns Updated context units. */ -function compressToSceneSummary( +function replaceScenesWithSummaries( contextUnits: ContextUnit[], - unitIndex: number, events: (NarrationEvent | LocationChangeEvent)[], -): { unit: ContextUnit; removeStart: number; removeCount: number } | null { - // Remember: contextUnits are in reverse chronological order (oldest at end) - // Find the location change that starts the scene containing this unit - let sceneStartIndex = -1; + tokenBudget: number +): ContextUnit[] { + let units = [...contextUnits]; + let sceneIndex = 0; - // Look backwards (towards older events) to find the scene start - for (let i = unitIndex; i < contextUnits.length; i++) { - if (contextUnits[i].type === "location_change") { - sceneStartIndex = i; - break; + while (sceneIndex < units.length && !isContextWithinBudget(units, tokenBudget)) { + // Find the earliest unsummarized location change + // This marks that start of the scene we're summarizing + if (units[sceneIndex].type !== "location_change") { + sceneIndex++; + continue; } - } - - if (sceneStartIndex === -1) { - return null; // No scene start found - } - - // Find where this scene ends by looking forward (towards newer events) - let sceneEndIndex = unitIndex; - for (let i = unitIndex - 1; i >= 0; i--) { - if (contextUnits[i].type === "location_change") { - sceneEndIndex = i + 1; // Scene ends just before this location change + + // Find the next location change after sceneIndex + // that next location change marks the end of this scene we're summarizing + let endSceneLocationChangeIndex = -1; + for (let i = sceneIndex + 1; i < units.length; i++) { + if (units[i].type === "location_change") { + endSceneLocationChangeIndex = i; + break; + } + } + + // If no next location change found, + // means the scene never ended and we're at the latest scene - stop here + if (endSceneLocationChangeIndex === -1) { break; } + + // Get the scene summary (always stored on the location change event marking the end of the scene) + const endSceneLocationChangeEventIndex = units[endSceneLocationChangeIndex].startEventIndex; + const endSceneLocationChangeEvent = events[endSceneLocationChangeEventIndex] as LocationChangeEvent; + + if (endSceneLocationChangeEvent.summary) { + // Replace everything from sceneIndex to right before endSceneLocationChangeIndex with the summary + const summaryUnit: ContextUnit = { + type: "location_change", + text: endSceneLocationChangeEvent.summary, + tokenCount: getApproximateTokenCount(endSceneLocationChangeEvent.summary), + startEventIndex: units[sceneIndex].startEventIndex, + endEventIndex: units[endSceneLocationChangeIndex - 1].endEventIndex + }; + + // Replace the scene units with the summary unit + units = [ + ...units.slice(0, sceneIndex), + summaryUnit, + ...units.slice(endSceneLocationChangeIndex) + ]; + } + + // Move to the next scene (which is now at sceneIndex + 1) + sceneIndex++; } - if (sceneEndIndex <= 0) { - sceneEndIndex = 0; // Scene goes to the newest events - } - - // Get the location change event that ends this scene (contains the scene summary) - // This is the location change AFTER the scene start in the original chronological order - const sceneStartEventIndex = contextUnits[sceneStartIndex].startEventIndex; - const nextLocationChangeEvent = findNextLocationChangeEvent(events, sceneStartEventIndex); - - if (!nextLocationChangeEvent || !nextLocationChangeEvent.summary) { - return null; // No scene summary available - } - - // Create the scene summary unit - const sceneSummaryUnit: ContextUnit = { - type: "location_change", - summaryLevel: SummaryLevel.Two, - text: nextLocationChangeEvent.summary, - tokenCount: getApproximateTokenCount(nextLocationChangeEvent.summary), - startEventIndex: contextUnits[sceneStartIndex].startEventIndex, - endEventIndex: contextUnits[sceneEndIndex].endEventIndex - }; - - return { - unit: sceneSummaryUnit, - removeStart: sceneEndIndex, - removeCount: sceneStartIndex - sceneEndIndex + 1 - }; + return units; } /** - * Find the next location change event after a given index to start looking from. - * @param events The list of events to search. - * @param afterIndex The index to start searching from (exclusive). - * @returns The next location change event, or null if not found. + * Remove oldest scenes until we're under the token budget. + * At this point, all scenes have been replaced with their summaries, + * so we just remove context units from the beginning until we fit the budget. + * @param contextUnits The current context units. + * @param tokenBudget The token budget. + * @returns Updated context units. */ -function findNextLocationChangeEvent( - events: (NarrationEvent | LocationChangeEvent)[], - afterIndex: number -): LocationChangeEvent | null { - for (let i = afterIndex + 1; i < events.length; i++) { - if (events[i].type === "location_change") { - return events[i] as LocationChangeEvent; - } +function removeOldestScenes(contextUnits: ContextUnit[], tokenBudget: number): ContextUnit[] { + let units = [...contextUnits]; + + while (units.length > 0 && !isContextWithinBudget(units, tokenBudget)) { + units = units.slice(1); // Remove the first (oldest) context unit } - return null; + + return units; } /** @@ -331,4 +266,4 @@ function convertContextUnitsToText(units: ContextUnit[]): string { export function getApproximateTokenCount(text: string): number { const numCharacters = text.length; return Math.ceil(numCharacters / 3); -}; \ No newline at end of file +} From a725f02375c726d46994f9b83a6405dc04c20232 Mon Sep 17 00:00:00 2001 From: bubbltaco Date: Sun, 10 Aug 2025 16:19:13 -0500 Subject: [PATCH 4/8] remove MAX_PAGES_TO_SEARCH from getContextLength --- lib/backend.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/lib/backend.ts b/lib/backend.ts index ff596ee3..d5d9401a 100644 --- a/lib/backend.ts +++ b/lib/backend.ts @@ -176,22 +176,18 @@ export class DefaultBackend implements Backend { async getContextLength(): Promise { const client = this.getClient(); const modelName = this.getSettings().model; - const MAX_PAGES_TO_SEARCH = 3; const FALLBACK_CONTEXT_LENGTH = 64000; let models = await client.models.list(); - let pagesSearched = 0; - while (pagesSearched < MAX_PAGES_TO_SEARCH) { + while (true) { const found = models.data.find((m) => m.id === modelName) as { context_length?: number; } | undefined; if (found && typeof found.context_length === "number") { return found.context_length; } - pagesSearched++; - - // Try to get the next page if we haven't reached the limit - if (pagesSearched < MAX_PAGES_TO_SEARCH && models.hasNextPage()) { + // Try to get the next page + if (models.hasNextPage()) { models = await models.getNextPage(); } else { break; From e4d511bd5996608af31bf355488020279a646a92 Mon Sep 17 00:00:00 2001 From: bubbltaco Date: Sun, 17 Aug 2025 15:34:32 -0500 Subject: [PATCH 5/8] simplify summarization and fix DRY issues and error handling --- lib/backend.ts | 22 ++++++-- lib/context.ts | 133 ++++++++++++++++++++----------------------------- lib/engine.ts | 15 ++---- lib/prompts.ts | 113 ++++++----------------------------------- lib/schemas.ts | 1 - 5 files changed, 92 insertions(+), 192 deletions(-) diff --git a/lib/backend.ts b/lib/backend.ts index d5d9401a..a6c9abff 100644 --- a/lib/backend.ts +++ b/lib/backend.ts @@ -174,15 +174,28 @@ export class DefaultBackend implements Backend { } async getContextLength(): Promise { + // Some API providers do not provide a way to get the context length, + // so we hardcode values for those. + const settings = this.getSettings(); + if (settings.apiUrl.startsWith("https://api.deepseek.com")) { + // All DeepSeek models from official API support at least this size. + return 64000; + } else if (settings.apiUrl.startsWith("https://api.anthropic.com/")) { + // All Anthropic models support at least this size. + return 200000; + } + const client = this.getClient(); const modelName = this.getSettings().model; - const FALLBACK_CONTEXT_LENGTH = 64000; let models = await client.models.list(); while (true) { const found = models.data.find((m) => m.id === modelName) as { context_length?: number; } | undefined; - if (found && typeof found.context_length === "number") { + if (found) { + if (typeof found.context_length !== "number") { + throw new Error("Model does not have a valid context length"); + } return found.context_length; } @@ -193,9 +206,8 @@ export class DefaultBackend implements Backend { break; } } - - // Fallback to default if not found - return FALLBACK_CONTEXT_LENGTH; + // If the model was not found, throw an error + throw new Error("Model not found") } abort(): void { diff --git a/lib/context.ts b/lib/context.ts index 0ecf27b8..2587880f 100644 --- a/lib/context.ts +++ b/lib/context.ts @@ -1,15 +1,26 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2025 bubbltaco + import type { LocationChangeEvent, NarrationEvent, State } from "./state"; -interface ContextUnit { - type : "location_change" | "narration"; +// We'll use context unit types to build our context and replace parts of it with summaries as needed. +interface LocationChangeContextUnit { + type: "location_change"; + text: string; + tokenCount: number; + summary?: string; +} +interface NarrationContextUnit { + type: "narration"; + text: string; + tokenCount: number; +} +interface SummaryContextUnit { + type: "summary"; text: string; - /** The number of tokens in this context unit */ tokenCount: number; - /** original event index (0 = oldest) covered by this unit, inclusive */ - startEventIndex: number; - /** original event index (0 = oldest) covered by this unit, inclusive */ - endEventIndex: number; } +type ContextUnit = LocationChangeContextUnit | NarrationContextUnit | SummaryContextUnit; /** * Get the context of events given the current state and token budget that we need to fit within. @@ -34,20 +45,18 @@ export function getContext(state: State, tokenBudget: number): string { return convertContextUnitsToText(contextUnits); } - // Step 2: Replace oldest narration events with their summaries one by one - contextUnits = replaceNarrationEventsWithSummaries(contextUnits, events, tokenBudget); - if (isContextWithinBudget(contextUnits, tokenBudget)) { - return convertContextUnitsToText(contextUnits); - } - - // Step 3: Replace oldest scenes with their scene summaries (except latest scene) + // Step 2: Replace oldest scenes with their scene summaries (except latest scene) contextUnits = replaceScenesWithSummaries(contextUnits, events, tokenBudget); if (isContextWithinBudget(contextUnits, tokenBudget)) { return convertContextUnitsToText(contextUnits); } - // Step 4: Remove oldest scenes until we're under budget + // Step 3: Remove oldest scenes until we're under budget contextUnits = removeOldestScenes(contextUnits, tokenBudget); + // if we're still not able to fit within budget (just the current scene takes up more than the budget) throw an error + if (!isContextWithinBudget(contextUnits, tokenBudget)) { + throw new Error("Unable to fit context within token budget even after summarization"); + } return convertContextUnitsToText(contextUnits); } @@ -68,8 +77,6 @@ function createInitialContextUnits(events: (NarrationEvent | LocationChangeEvent type: "narration", text, tokenCount: getApproximateTokenCount(text), - startEventIndex: i, - endEventIndex: i }); } else if (event.type === "location_change") { const text = convertLocationChangeEventToText(event, state); @@ -77,8 +84,7 @@ function createInitialContextUnits(events: (NarrationEvent | LocationChangeEvent type: "location_change", text, tokenCount: getApproximateTokenCount(text), - startEventIndex: i, - endEventIndex: i + summary: event.summary, }); } }); @@ -86,41 +92,6 @@ function createInitialContextUnits(events: (NarrationEvent | LocationChangeEvent return units; } -/** - * Replace oldest narration events with their summaries one by one until under budget. - * @param contextUnits The current context units. - * @param events The original events array. - * @param tokenBudget The token budget. - * @returns Updated context units. - */ -function replaceNarrationEventsWithSummaries( - contextUnits: ContextUnit[], - events: (NarrationEvent | LocationChangeEvent)[], - tokenBudget: number -): ContextUnit[] { - const units = [...contextUnits]; - - for (let i = 0; i < units.length; i++) { - if (isContextWithinBudget(units, tokenBudget)) { - break; - } - - if (units[i].type === "narration") { - // replace the narration text with its summary - const originalEvent = events[units[i].startEventIndex] as NarrationEvent; - if (originalEvent.summary) { - units[i] = { - ...units[i], - text: originalEvent.summary, - tokenCount: getApproximateTokenCount(originalEvent.summary) - }; - } - } - } - - return units; -} - /** * Replace oldest scenes with their scene summaries (except the latest scene). * We consider a location_change to mark the start of a scene. @@ -141,7 +112,7 @@ function replaceScenesWithSummaries( let sceneIndex = 0; while (sceneIndex < units.length && !isContextWithinBudget(units, tokenBudget)) { - // Find the earliest unsummarized location change + // Find the earliest location change // This marks that start of the scene we're summarizing if (units[sceneIndex].type !== "location_change") { sceneIndex++; @@ -150,9 +121,12 @@ function replaceScenesWithSummaries( // Find the next location change after sceneIndex // that next location change marks the end of this scene we're summarizing + let endSceneLocationChange: LocationChangeContextUnit | null = null; let endSceneLocationChangeIndex = -1; for (let i = sceneIndex + 1; i < units.length; i++) { - if (units[i].type === "location_change") { + const contextUnit = units[i]; + if (contextUnit.type === "location_change") { + endSceneLocationChange = contextUnit; endSceneLocationChangeIndex = i; break; } @@ -160,22 +134,18 @@ function replaceScenesWithSummaries( // If no next location change found, // means the scene never ended and we're at the latest scene - stop here - if (endSceneLocationChangeIndex === -1) { + if (!endSceneLocationChange) { break; } // Get the scene summary (always stored on the location change event marking the end of the scene) - const endSceneLocationChangeEventIndex = units[endSceneLocationChangeIndex].startEventIndex; - const endSceneLocationChangeEvent = events[endSceneLocationChangeEventIndex] as LocationChangeEvent; - - if (endSceneLocationChangeEvent.summary) { + const sceneSummary = endSceneLocationChange.summary; + if (sceneSummary) { // Replace everything from sceneIndex to right before endSceneLocationChangeIndex with the summary - const summaryUnit: ContextUnit = { - type: "location_change", - text: endSceneLocationChangeEvent.summary, - tokenCount: getApproximateTokenCount(endSceneLocationChangeEvent.summary), - startEventIndex: units[sceneIndex].startEventIndex, - endEventIndex: units[endSceneLocationChangeIndex - 1].endEventIndex + const summaryUnit: SummaryContextUnit = { + type: "summary", + text: sceneSummary, + tokenCount: getApproximateTokenCount(sceneSummary), }; // Replace the scene units with the summary unit @@ -195,8 +165,8 @@ function replaceScenesWithSummaries( /** * Remove oldest scenes until we're under the token budget. - * At this point, all scenes have been replaced with their summaries, - * so we just remove context units from the beginning until we fit the budget. + * Assume that at this point, all scenes except the most recent have been replaced with their summaries, + * So this will keep removing the oldest summaries until it runs into the most recent non-summary event. * @param contextUnits The current context units. * @param tokenBudget The token budget. * @returns Updated context units. @@ -205,9 +175,15 @@ function removeOldestScenes(contextUnits: ContextUnit[], tokenBudget: number): C let units = [...contextUnits]; while (units.length > 0 && !isContextWithinBudget(units, tokenBudget)) { - units = units.slice(1); // Remove the first (oldest) context unit + // If the first event is a summary, remove it + if (units[0].type === "summary") { + units = units.slice(1); + } else { + // If the first event is not a summary, we've reached the most recent scene - stop + break; + } } - + return units; } @@ -228,8 +204,14 @@ function isContextWithinBudget(contextUnits: ContextUnit[], tokenBudget: number) * @param state The current state * @returns A string describing the location change event. */ -function convertLocationChangeEventToText(event: LocationChangeEvent, state: State): string { +export function convertLocationChangeEventToText(event: LocationChangeEvent, state: State): string { const location = state.locations[event.locationIndex]; + const cast = event.presentCharacterIndices + .map((index) => { + const character = state.characters[index]; + return `${character.name}: ${character.biography}`; + }) + .join("\n\n") return `----- @@ -239,12 +221,7 @@ ${state.protagonist.name} is entering ${location.name}. ${location.description} The following characters are present at ${location.name}: -${event.presentCharacterIndices - .map((index) => { - const character = state.characters[index]; - return `${character.name}: ${character.biography}`; - }) - .join("\n\n")} +${cast} -----`; } diff --git a/lib/engine.ts b/lib/engine.ts index b813f5f3..966e3233 100644 --- a/lib/engine.ts +++ b/lib/engine.ts @@ -15,7 +15,6 @@ import { generateStartingLocationPrompt, generateWorldPrompt, narratePrompt, - summarizeNarrationEventPrompt, summarizeScenePrompt, type Prompt, } from "./prompts"; @@ -82,14 +81,6 @@ export async function next( updateState(); }); - // create a summary of the narration - step = ["Summarizing", "This typically takes between 10 and 30 seconds"]; - event.summary = await backend.getNarration(summarizeNarrationEventPrompt(state, event), (token: string, count: number) => { - event.summary += token; - onToken(token, count); - updateState(); - }); - const referencedCharacterIndices = new Set(); // Character names in the text are surrounded with double asterisks @@ -177,7 +168,11 @@ export async function next( state.view = "chat"; } else if (state.view === "chat") { const contextLength = await backend.getContextLength(); - const tokenBudget = Math.floor(contextLength * 0.5); // Set token budget to 50% of context length + // Set token budget to 90% of context length + // This is because context length includes both input and output tokens + // so we need to allow space for output. Also some models have a fixed max input length + // that's less than the context length. This should help mitigate those issues. + const tokenBudget = Math.floor(contextLength * 0.9); state.actions = []; updateState(); diff --git a/lib/prompts.ts b/lib/prompts.ts index cfdda1c0..ca62577a 100644 --- a/lib/prompts.ts +++ b/lib/prompts.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2025 Philipp Emanuel Weidmann -import { getApproximateTokenCount, getContext } from "./context"; +import { convertLocationChangeEventToText, getApproximateTokenCount, getContext } from "./context"; import * as schemas from "./schemas"; import type { LocationChangeEvent, NarrationEvent, State } from "./state"; @@ -172,69 +172,6 @@ Include a short biography (100 words maximum) for each character. ); } -export function summarizeNarrationEventPrompt(state: State, narrationEvent: NarrationEvent): Prompt { - const current = narrationEvent; - const protagonistName = state.protagonist.name; - - // Find most recent previous narration or location_change - let prevContext = ""; - for (let i = state.events.length - 2; i >= 0; i--) { - const ev = state.events[i]; - if (ev.type === "narration") { - prevContext = ev.text; - break; - } else if (ev.type === "location_change") { - const location = state.locations[ev.locationIndex]; - prevContext = normalize(` -${protagonistName} entered ${location.name}. ${location.description} - -The following characters are present at ${location.name}: -${ev.presentCharacterIndices - .map((index) => { - const character = state.characters[index]; - return `${character.name}: ${character.biography}`; - }) - .join("\n\n")} -`); - break; - } - } - - // Build the prompt - const userPrompt = ` -This is a fantasy adventure RPG set in the world of ${state.world.name}. ${state.world.description} - -The protagonist (who you should refer to as "you", as the adventure happens from their perspective) -is ${state.protagonist.name}. ${state.protagonist.biography} - -You will create a compact memory note of the latest event in this adventure. -This memory is used as long-term context for future generations. - -${prevContext ? `PREVIOUS CONTEXT (most recent prior event or location change)\n\n${prevContext}\n\n` : ""} - -CURRENT EVENT - -${current.text} - -TASK - -Write a single-paragraph short summary (no more than 100 words in total). Use proper names and refer to the protagonist as "you". -Capture only plot-relevant facts that will matter later: -- what ${protagonistName} does/learns/decides, -- other characters' impactful actions/agreements, -- changes to location, inventory, injuries, or relationships, -- discoveries/clues, -- unresolved goals, promises, threats, or timers. -Do not quote dialogue, add new facts, or include stylistic prose. - -OUTPUT - -Return only the summary paragraph with no preamble, labels, markdown or quotes. -`; - - return makePrompt(userPrompt); -} - export function summarizeScenePrompt(state: State): Prompt { const protagonistName = state.protagonist.name; @@ -248,24 +185,8 @@ export function summarizeScenePrompt(state: State): Prompt { } // Build location + cast context from that location change - let sceneContext = ""; - const ev = state.events[sceneStartIndex]; - // ev is a LocationChangeEvent here - const location = state.locations[(ev as LocationChangeEvent).locationIndex]; - const cast = (ev as any).presentCharacterIndices - .map((idx: number) => { - const c = state.characters[idx]; - return `${c.name}: ${c.biography}`; - }) - .join("\n\n"); - - sceneContext = normalize(` -${protagonistName} is at ${location.name}. ${location.description} - -The following characters are present at ${location.name}: - -${cast} -`); + const mostRecentLocationChangeEvent = state.events[sceneStartIndex]; + const sceneContext = convertLocationChangeEventToText(mostRecentLocationChangeEvent as LocationChangeEvent, state); // Gather all narration texts from this scene (after the last location change). const narrationTexts = state.events @@ -280,27 +201,23 @@ This is a fantasy adventure RPG set in the world of ${state.world.name}. ${state The protagonist (refer to them as "you") is ${protagonistName}. ${state.protagonist.biography} You will create a compact memory of the just-completed scene. This memory is used as long-term context for future generations. +Write a 1-2 paragraph scene summary (no more than 300 words in total). +Use proper names and refer to the protagonist as "you". +Capture only plot-relevant facts that will matter later such as: +what ${protagonistName} does/learns/decides, +changes to location, inventory, injuries, or relationships, +discoveries/clues, +unresolved goals, promises, threats, or timers. +Do not quote dialogue, add new facts, or include stylistic prose. +Return only the summary with no preamble, labels, markdown or quotes. -SCENE +Here's the context for the scene to summarize: ${sceneContext} -${narrationTexts} - -TASK +Here's the scene to summarize: -Write a 1-2 paragraph scene summary (no more than 300 words in total). Use proper names and refer to the protagonist as "you". -Capture only plot-relevant facts that will matter later: -- what ${protagonistName} does/learns/decides, -- other characters' impactful actions/agreements, -- changes to location, inventory, injuries, or relationships, -- discoveries/clues, -- unresolved goals, promises, threats, or timers. -Do not quote dialogue, add new facts, or include stylistic prose. - -OUTPUT - -Return only the summary with no preamble, labels, markdown or quotes. +${narrationTexts} `; return makePrompt(userPrompt); diff --git a/lib/schemas.ts b/lib/schemas.ts index 8ff1e058..cb418e0f 100644 --- a/lib/schemas.ts +++ b/lib/schemas.ts @@ -56,7 +56,6 @@ export const NarrationEvent = z.object({ text: Text.max(5000), locationIndex: Index, referencedCharacterIndices: Index.array(), - summary: Text.max(2500).optional(), }); export const CharacterIntroductionEvent = z.object({ From 7671e93fc00ede675021c624f1ab4ef4c6fb7947 Mon Sep 17 00:00:00 2001 From: bubbltaco Date: Tue, 19 Aug 2025 00:00:40 -0500 Subject: [PATCH 6/8] rewrite context algorithm + small fixes --- lib/backend.ts | 2 +- lib/context.ts | 233 +++++++++++++++++++++++-------------------------- lib/prompts.ts | 17 ++-- 3 files changed, 117 insertions(+), 135 deletions(-) diff --git a/lib/backend.ts b/lib/backend.ts index a6c9abff..3bddab73 100644 --- a/lib/backend.ts +++ b/lib/backend.ts @@ -186,7 +186,7 @@ export class DefaultBackend implements Backend { } const client = this.getClient(); - const modelName = this.getSettings().model; + const modelName = settings.model; let models = await client.models.list(); diff --git a/lib/context.ts b/lib/context.ts index 2587880f..2f78a436 100644 --- a/lib/context.ts +++ b/lib/context.ts @@ -3,24 +3,15 @@ import type { LocationChangeEvent, NarrationEvent, State } from "./state"; -// We'll use context unit types to build our context and replace parts of it with summaries as needed. -interface LocationChangeContextUnit { - type: "location_change"; +// Type that represents each scene in context +interface Scene { + // text for the entire scene text: string; - tokenCount: number; + // the summary for the scene summary?: string; + // whether to use the scene summary in the context or not + summarize: boolean; } -interface NarrationContextUnit { - type: "narration"; - text: string; - tokenCount: number; -} -interface SummaryContextUnit { - type: "summary"; - text: string; - tokenCount: number; -} -type ContextUnit = LocationChangeContextUnit | NarrationContextUnit | SummaryContextUnit; /** * Get the context of events given the current state and token budget that we need to fit within. @@ -37,28 +28,28 @@ export function getContext(state: State, tokenBudget: number): string { return ""; } - // Create initial context units - let contextUnits = createInitialContextUnits(events, state); + // Create initial context + let context = createInitialContext(events, state); // Step 1: Try full text without summaries - if (isContextWithinBudget(contextUnits, tokenBudget)) { - return convertContextUnitsToText(contextUnits); + if (isContextWithinBudget(context, tokenBudget)) { + return convertContextToText(context); } // Step 2: Replace oldest scenes with their scene summaries (except latest scene) - contextUnits = replaceScenesWithSummaries(contextUnits, events, tokenBudget); - if (isContextWithinBudget(contextUnits, tokenBudget)) { - return convertContextUnitsToText(contextUnits); + context = replaceScenesWithSummaries(context, tokenBudget); + if (isContextWithinBudget(context, tokenBudget)) { + return convertContextToText(context); } // Step 3: Remove oldest scenes until we're under budget - contextUnits = removeOldestScenes(contextUnits, tokenBudget); + context = removeOldestScenes(context, tokenBudget); // if we're still not able to fit within budget (just the current scene takes up more than the budget) throw an error - if (!isContextWithinBudget(contextUnits, tokenBudget)) { + if (!isContextWithinBudget(context, tokenBudget)) { throw new Error("Unable to fit context within token budget even after summarization"); } - return convertContextUnitsToText(contextUnits); + return convertContextToText(context); } /** @@ -67,134 +58,126 @@ export function getContext(state: State, tokenBudget: number): string { * @param state current state * @returns */ -function createInitialContextUnits(events: (NarrationEvent | LocationChangeEvent)[], state: State): ContextUnit[] { - const units: ContextUnit[] = []; - - events.forEach((event, i) => { - if (event.type === "narration") { - const text = event.text; - units.push({ - type: "narration", - text, - tokenCount: getApproximateTokenCount(text), - }); - } else if (event.type === "location_change") { - const text = convertLocationChangeEventToText(event, state); - units.push({ - type: "location_change", - text, - tokenCount: getApproximateTokenCount(text), - summary: event.summary, - }); +function createInitialContext(events: (NarrationEvent | LocationChangeEvent)[], state: State): Scene[] { + const scenes: Scene[] = []; + + if (events.length === 0) { + return scenes; + } + + // We go through all the events, as we encounter location changes, we create a new scene + let currentSceneEvents: (NarrationEvent | LocationChangeEvent)[] = []; + for (let i = 0; i < events.length; i++) { + const event = events[i]; + if (event.type === "location_change") { + // If we have accumulated events from a previous scene, create a scene for them + if (currentSceneEvents.length > 0) { + const sceneText = createSceneText(currentSceneEvents, state); + scenes.push({ + text: sceneText, + summary: event.summary, // the summary for the previous scene is stored in this location change event + summarize: false // Initially all scenes use full text + }); + } + + // Start a new scene with this location change + currentSceneEvents = [event]; + } else if (event.type === "narration") { + // Add narration events to the current scene + currentSceneEvents.push(event); + } + } + + // Handle the final scene (if there are remaining events) + if (currentSceneEvents.length > 0) { + const sceneText = createSceneText(currentSceneEvents, state); + scenes.push({ + text: sceneText, + summary: undefined, // The last scene doesn't have a summary yet + summarize: false + }); + } + + return scenes; +} + +/** + * Convert a sequence of events for a single scene into text. + * @param events The events that make up the scene + * @param state The current state + * @returns The text representation of the scene + */ +function createSceneText(events: (NarrationEvent | LocationChangeEvent)[], state: State): string { + const sceneParts: string[] = []; + + for (const event of events) { + if (event.type === "location_change") { + sceneParts.push(convertLocationChangeEventToText(event, state)); + } else if (event.type === "narration") { + sceneParts.push(event.text); } - }); + } - return units; + return sceneParts.join("\n\n"); } /** * Replace oldest scenes with their scene summaries (except the latest scene). - * We consider a location_change to mark the start of a scene. - * Then the next location_change marks the end of that scene and the start of the next scene. - * So when we summarize, we replace all events including the starting location_change up to right before the ending location_change - * with the scene summary. - * @param contextUnits The current context units. - * @param events The original events array. + * @param context The current context. * @param tokenBudget The token budget. - * @returns Updated context units. + * @returns Updated context. */ function replaceScenesWithSummaries( - contextUnits: ContextUnit[], - events: (NarrationEvent | LocationChangeEvent)[], + context: Scene[], tokenBudget: number -): ContextUnit[] { - let units = [...contextUnits]; - let sceneIndex = 0; +): Scene[] { + const scenes = [...context]; - while (sceneIndex < units.length && !isContextWithinBudget(units, tokenBudget)) { - // Find the earliest location change - // This marks that start of the scene we're summarizing - if (units[sceneIndex].type !== "location_change") { - sceneIndex++; - continue; - } - - // Find the next location change after sceneIndex - // that next location change marks the end of this scene we're summarizing - let endSceneLocationChange: LocationChangeContextUnit | null = null; - let endSceneLocationChangeIndex = -1; - for (let i = sceneIndex + 1; i < units.length; i++) { - const contextUnit = units[i]; - if (contextUnit.type === "location_change") { - endSceneLocationChange = contextUnit; - endSceneLocationChangeIndex = i; + // Go through each scene (except the last one) and switch to summary if available + for (let i = 0; i < scenes.length - 1; i++) { + if (scenes[i].summary && !scenes[i].summarize) { + scenes[i] = { ...scenes[i], summarize: true }; + + // Check if we're now within budget + if (isContextWithinBudget(scenes, tokenBudget)) { break; } } - - // If no next location change found, - // means the scene never ended and we're at the latest scene - stop here - if (!endSceneLocationChange) { - break; - } - - // Get the scene summary (always stored on the location change event marking the end of the scene) - const sceneSummary = endSceneLocationChange.summary; - if (sceneSummary) { - // Replace everything from sceneIndex to right before endSceneLocationChangeIndex with the summary - const summaryUnit: SummaryContextUnit = { - type: "summary", - text: sceneSummary, - tokenCount: getApproximateTokenCount(sceneSummary), - }; - - // Replace the scene units with the summary unit - units = [ - ...units.slice(0, sceneIndex), - summaryUnit, - ...units.slice(endSceneLocationChangeIndex) - ]; - } - - // Move to the next scene (which is now at sceneIndex + 1) - sceneIndex++; } - return units; + return scenes; } /** * Remove oldest scenes until we're under the token budget. * Assume that at this point, all scenes except the most recent have been replaced with their summaries, - * So this will keep removing the oldest summaries until it runs into the most recent non-summary event. - * @param contextUnits The current context units. + * So this will keep removing the oldest summaries until the most recent scene. + * @param context The current context. * @param tokenBudget The token budget. - * @returns Updated context units. + * @returns Updated context. */ -function removeOldestScenes(contextUnits: ContextUnit[], tokenBudget: number): ContextUnit[] { - let units = [...contextUnits]; - - while (units.length > 0 && !isContextWithinBudget(units, tokenBudget)) { - // If the first event is a summary, remove it - if (units[0].type === "summary") { - units = units.slice(1); - } else { - // If the first event is not a summary, we've reached the most recent scene - stop - break; - } +function removeOldestScenes(context: Scene[], tokenBudget: number): Scene[] { + let scenes = [...context]; + + // Remove oldest scenes until we fit within budget or only have the current scene left + while (scenes.length > 1 && !isContextWithinBudget(scenes, tokenBudget)) { + scenes = scenes.slice(1); // Remove the oldest scene (first element) } - return units; + return scenes; } /** * Check if the current context is within the token budget. - * @param contextUnits The context units to check. + * @param context The context to check. * @param tokenBudget The token budget to compare against. * @returns True if the context is within budget, false otherwise. */ -function isContextWithinBudget(contextUnits: ContextUnit[], tokenBudget: number): boolean { - const totalTokens = contextUnits.reduce((sum: number, unit: ContextUnit) => sum + unit.tokenCount, 0); +function isContextWithinBudget(context: Scene[], tokenBudget: number): boolean { + const totalTokens = context.reduce( + (sum: number, scene) => sum + getApproximateTokenCount((scene.summarize && scene.summary) ? scene.summary : scene.text), + 0 + ); return totalTokens <= tokenBudget; } @@ -227,12 +210,12 @@ ${cast} } /** - * Converts an array of context units representing the context to text. - * @param units The context units to convert. + * Converts an array of scenes representing the context to text. + * @param scenes The context to convert. * @returns A string representation of the context. */ -function convertContextUnitsToText(units: ContextUnit[]): string { - return units.map(unit => unit.text).join("\n\n"); +function convertContextToText(scenes: Scene[]): string { + return scenes.map(scene => (scene.summarize && scene.summary) ? scene.summary : scene.text).join("\n\n"); } /** diff --git a/lib/prompts.ts b/lib/prompts.ts index ca62577a..84e97de6 100644 --- a/lib/prompts.ts +++ b/lib/prompts.ts @@ -70,14 +70,13 @@ Include a short biography (100 words maximum) for each character. `); } -function makeMainPrompt(prompt: string, state: State, tokenBudget: number): Prompt { - const promptPreamble = -`This is a fantasy adventure RPG set in the world of ${state.world.name}. ${state.world.description} +const makeMainPromptPreamble = (state: State): string => `This is a fantasy adventure RPG set in the world of ${state.world.name}. ${state.world.description} The protagonist (who you should refer to as "you" in your narration, as the adventure happens from their perspective) -is ${state.protagonist.name}. ${state.protagonist.biography} +is ${state.protagonist.name}. ${state.protagonist.biography}`; -Here is what has happened so far:`; +function makeMainPrompt(prompt: string, state: State, tokenBudget: number): Prompt { + const promptPreamble = makeMainPromptPreamble(state); // get the tokens used by the prompt and the preamble const normalizedPrompt = normalize(prompt); @@ -90,6 +89,8 @@ Here is what has happened so far:`; return makePrompt(` ${promptPreamble} + +Here is what has happened so far: ${context} @@ -196,9 +197,7 @@ export function summarizeScenePrompt(state: State): Prompt { .join("\n\n"); const userPrompt = ` -This is a fantasy adventure RPG set in the world of ${state.world.name}. ${state.world.description} - -The protagonist (refer to them as "you") is ${protagonistName}. ${state.protagonist.biography} +${makeMainPromptPreamble(state)} You will create a compact memory of the just-completed scene. This memory is used as long-term context for future generations. Write a 1-2 paragraph scene summary (no more than 300 words in total). @@ -207,7 +206,7 @@ Capture only plot-relevant facts that will matter later such as: what ${protagonistName} does/learns/decides, changes to location, inventory, injuries, or relationships, discoveries/clues, -unresolved goals, promises, threats, or timers. +unresolved goals, promises, threats, or deadlines. Do not quote dialogue, add new facts, or include stylistic prose. Return only the summary with no preamble, labels, markdown or quotes. From 7f8b22dafe5a5575e21d3a95076b23b3f72407ad Mon Sep 17 00:00:00 2001 From: bubbltaco Date: Tue, 26 Aug 2025 08:39:30 -0500 Subject: [PATCH 7/8] fix CI errors --- lib/backend.ts | 10 +++++----- lib/context.ts | 42 ++++++++++++++++++++---------------------- lib/engine.ts | 27 +++++++++++++++++++-------- lib/prompts.ts | 12 +++++++++--- 4 files changed, 53 insertions(+), 38 deletions(-) diff --git a/lib/backend.ts b/lib/backend.ts index 3bddab73..cc309470 100644 --- a/lib/backend.ts +++ b/lib/backend.ts @@ -187,18 +187,18 @@ export class DefaultBackend implements Backend { const client = this.getClient(); const modelName = settings.model; - + let models = await client.models.list(); - + while (true) { - const found = models.data.find((m) => m.id === modelName) as { context_length?: number; } | undefined; + const found = models.data.find((m) => m.id === modelName) as { context_length?: number } | undefined; if (found) { if (typeof found.context_length !== "number") { throw new Error("Model does not have a valid context length"); } return found.context_length; } - + // Try to get the next page if (models.hasNextPage()) { models = await models.getNextPage(); @@ -207,7 +207,7 @@ export class DefaultBackend implements Backend { } } // If the model was not found, throw an error - throw new Error("Model not found") + throw new Error("Model not found"); } abort(): void { diff --git a/lib/context.ts b/lib/context.ts index 2f78a436..26b45eeb 100644 --- a/lib/context.ts +++ b/lib/context.ts @@ -22,15 +22,15 @@ interface Scene { export function getContext(state: State, tokenBudget: number): string { // we filter down to only narration and location change events, // because the other event types like character_introduction are implied in the narration - const events = state.events.filter(event => event.type === "narration" || event.type === "location_change"); - + const events = state.events.filter((event) => event.type === "narration" || event.type === "location_change"); + if (events.length === 0) { return ""; } // Create initial context let context = createInitialContext(events, state); - + // Step 1: Try full text without summaries if (isContextWithinBudget(context, tokenBudget)) { return convertContextToText(context); @@ -56,7 +56,7 @@ export function getContext(state: State, tokenBudget: number): string { * Create the initial context without any compression. * @param events events that should go in the context * @param state current state - * @returns + * @returns */ function createInitialContext(events: (NarrationEvent | LocationChangeEvent)[], state: State): Scene[] { const scenes: Scene[] = []; @@ -76,7 +76,7 @@ function createInitialContext(events: (NarrationEvent | LocationChangeEvent)[], scenes.push({ text: sceneText, summary: event.summary, // the summary for the previous scene is stored in this location change event - summarize: false // Initially all scenes use full text + summarize: false, // Initially all scenes use full text }); } @@ -94,7 +94,7 @@ function createInitialContext(events: (NarrationEvent | LocationChangeEvent)[], scenes.push({ text: sceneText, summary: undefined, // The last scene doesn't have a summary yet - summarize: false + summarize: false, }); } @@ -127,24 +127,21 @@ function createSceneText(events: (NarrationEvent | LocationChangeEvent)[], state * @param tokenBudget The token budget. * @returns Updated context. */ -function replaceScenesWithSummaries( - context: Scene[], - tokenBudget: number -): Scene[] { +function replaceScenesWithSummaries(context: Scene[], tokenBudget: number): Scene[] { const scenes = [...context]; - + // Go through each scene (except the last one) and switch to summary if available for (let i = 0; i < scenes.length - 1; i++) { if (scenes[i].summary && !scenes[i].summarize) { scenes[i] = { ...scenes[i], summarize: true }; - + // Check if we're now within budget if (isContextWithinBudget(scenes, tokenBudget)) { break; } } } - + return scenes; } @@ -175,8 +172,9 @@ function removeOldestScenes(context: Scene[], tokenBudget: number): Scene[] { */ function isContextWithinBudget(context: Scene[], tokenBudget: number): boolean { const totalTokens = context.reduce( - (sum: number, scene) => sum + getApproximateTokenCount((scene.summarize && scene.summary) ? scene.summary : scene.text), - 0 + (sum: number, scene) => + sum + getApproximateTokenCount(scene.summarize && scene.summary ? scene.summary : scene.text), + 0, ); return totalTokens <= tokenBudget; } @@ -190,12 +188,12 @@ function isContextWithinBudget(context: Scene[], tokenBudget: number): boolean { export function convertLocationChangeEventToText(event: LocationChangeEvent, state: State): string { const location = state.locations[event.locationIndex]; const cast = event.presentCharacterIndices - .map((index) => { - const character = state.characters[index]; - return `${character.name}: ${character.biography}`; - }) - .join("\n\n") - + .map((index) => { + const character = state.characters[index]; + return `${character.name}: ${character.biography}`; + }) + .join("\n\n"); + return `----- LOCATION CHANGE @@ -215,7 +213,7 @@ ${cast} * @returns A string representation of the context. */ function convertContextToText(scenes: Scene[]): string { - return scenes.map(scene => (scene.summarize && scene.summary) ? scene.summary : scene.text).join("\n\n"); + return scenes.map((scene) => (scene.summarize && scene.summary ? scene.summary : scene.text)).join("\n\n"); } /** diff --git a/lib/engine.ts b/lib/engine.ts index 966e3233..b72350cf 100644 --- a/lib/engine.ts +++ b/lib/engine.ts @@ -15,8 +15,8 @@ import { generateStartingLocationPrompt, generateWorldPrompt, narratePrompt, - summarizeScenePrompt, type Prompt, + summarizeScenePrompt, } from "./prompts"; import * as schemas from "./schemas"; import { getState, initialState, type Location, type LocationChangeEvent, type NarrationEvent } from "./state"; @@ -75,11 +75,14 @@ export async function next( state.events.push(event); step = ["Narrating", ""]; - event.text = await backend.getNarration(narratePrompt(state, tokenBudget, action), (token: string, count: number) => { - event.text += token; - onToken(token, count); - updateState(); - }); + event.text = await backend.getNarration( + narratePrompt(state, tokenBudget, action), + (token: string, count: number) => { + event.text += token; + onToken(token, count); + updateState(); + }, + ); const referencedCharacterIndices = new Set(); @@ -195,7 +198,11 @@ export async function next( }); step = ["Generating location", "This typically takes between 10 and 30 seconds"]; - const newLocationInfo = await backend.getObject(generateNewLocationPrompt(state, tokenBudget), schema, onToken); + const newLocationInfo = await backend.getObject( + generateNewLocationPrompt(state, tokenBudget), + schema, + onToken, + ); await onLocationChange(newLocationInfo.newLocation); @@ -212,7 +219,11 @@ export async function next( } // Must be called *before* adding the location change event to the state! - const generateCharactersPrompt = generateNewCharactersPrompt(state, newLocationInfo.accompanyingCharacters, tokenBudget); + const generateCharactersPrompt = generateNewCharactersPrompt( + state, + newLocationInfo.accompanyingCharacters, + tokenBudget, + ); const event: LocationChangeEvent = { type: "location_change", diff --git a/lib/prompts.ts b/lib/prompts.ts index 84e97de6..9ba421e0 100644 --- a/lib/prompts.ts +++ b/lib/prompts.ts @@ -3,7 +3,7 @@ import { convertLocationChangeEventToText, getApproximateTokenCount, getContext } from "./context"; import * as schemas from "./schemas"; -import type { LocationChangeEvent, NarrationEvent, State } from "./state"; +import type { LocationChangeEvent, State } from "./state"; export interface Prompt { system: string; @@ -70,7 +70,9 @@ Include a short biography (100 words maximum) for each character. `); } -const makeMainPromptPreamble = (state: State): string => `This is a fantasy adventure RPG set in the world of ${state.world.name}. ${state.world.description} +const makeMainPromptPreamble = ( + state: State, +): string => `This is a fantasy adventure RPG set in the world of ${state.world.name}. ${state.world.description} The protagonist (who you should refer to as "you" in your narration, as the adventure happens from their perspective) is ${state.protagonist.name}. ${state.protagonist.biography}`; @@ -154,7 +156,11 @@ Also include the names of the characters that are going to accompany ${state.pro } // Must be called *before* adding the location change event to the state! -export function generateNewCharactersPrompt(state: State, accompanyingCharacters: string[], tokenBudget: number): Prompt { +export function generateNewCharactersPrompt( + state: State, + accompanyingCharacters: string[], + tokenBudget: number, +): Prompt { const location = state.locations[state.protagonist.locationIndex]; return makeMainPrompt( From ec9e68b17e6bd4aee9f45cb220e5bb22d9c16534 Mon Sep 17 00:00:00 2001 From: bubbltaco Date: Sun, 31 Aug 2025 12:03:29 -0500 Subject: [PATCH 8/8] remove getContextLength and tokenBudget --- lib/backend.ts | 39 --------------------------------------- lib/engine.ts | 42 ++++++++++++------------------------------ lib/prompts.ts | 23 +++++++---------------- 3 files changed, 19 insertions(+), 85 deletions(-) diff --git a/lib/backend.ts b/lib/backend.ts index cc309470..a3f3ba7e 100644 --- a/lib/backend.ts +++ b/lib/backend.ts @@ -18,8 +18,6 @@ export interface Backend { onToken?: TokenCallback, ): Promise; - getContextLength(): Promise; - abort(): void; isAbortError(error: unknown): boolean; @@ -173,43 +171,6 @@ export class DefaultBackend implements Backend { return schema.parse(JSON.parse(response)) as Type; } - async getContextLength(): Promise { - // Some API providers do not provide a way to get the context length, - // so we hardcode values for those. - const settings = this.getSettings(); - if (settings.apiUrl.startsWith("https://api.deepseek.com")) { - // All DeepSeek models from official API support at least this size. - return 64000; - } else if (settings.apiUrl.startsWith("https://api.anthropic.com/")) { - // All Anthropic models support at least this size. - return 200000; - } - - const client = this.getClient(); - const modelName = settings.model; - - let models = await client.models.list(); - - while (true) { - const found = models.data.find((m) => m.id === modelName) as { context_length?: number } | undefined; - if (found) { - if (typeof found.context_length !== "number") { - throw new Error("Model does not have a valid context length"); - } - return found.context_length; - } - - // Try to get the next page - if (models.hasNextPage()) { - models = await models.getNextPage(); - } else { - break; - } - } - // If the model was not found, throw an error - throw new Error("Model not found"); - } - abort(): void { this.controller.abort(); } diff --git a/lib/engine.ts b/lib/engine.ts index b72350cf..8ab0c396 100644 --- a/lib/engine.ts +++ b/lib/engine.ts @@ -64,7 +64,7 @@ export async function next( } }; - const narrate = async (tokenBudget: number, action?: string) => { + const narrate = async (action?: string) => { const event: NarrationEvent = { type: "narration", text: "", @@ -75,14 +75,11 @@ export async function next( state.events.push(event); step = ["Narrating", ""]; - event.text = await backend.getNarration( - narratePrompt(state, tokenBudget, action), - (token: string, count: number) => { - event.text += token; - onToken(token, count); - updateState(); - }, - ); + event.text = await backend.getNarration(narratePrompt(state, action), (token: string, count: number) => { + event.text += token; + onToken(token, count); + updateState(); + }); const referencedCharacterIndices = new Set(); @@ -170,13 +167,6 @@ export async function next( state.view = "chat"; } else if (state.view === "chat") { - const contextLength = await backend.getContextLength(); - // Set token budget to 90% of context length - // This is because context length includes both input and output tokens - // so we need to allow space for output. Also some models have a fixed max input length - // that's less than the context length. This should help mitigate those issues. - const tokenBudget = Math.floor(contextLength * 0.9); - state.actions = []; updateState(); @@ -188,21 +178,17 @@ export async function next( updateState(); } - await narrate(tokenBudget, action); + await narrate(action); step = ["Checking for location change", "This typically takes a few seconds"]; - if (!(await getBoolean(checkIfSameLocationPrompt(state, tokenBudget), onToken))) { + if (!(await getBoolean(checkIfSameLocationPrompt(state), onToken))) { const schema = z.object({ newLocation: schemas.Location, accompanyingCharacters: z.enum(state.characters.map((character) => character.name)).array(), }); step = ["Generating location", "This typically takes between 10 and 30 seconds"]; - const newLocationInfo = await backend.getObject( - generateNewLocationPrompt(state, tokenBudget), - schema, - onToken, - ); + const newLocationInfo = await backend.getObject(generateNewLocationPrompt(state), schema, onToken); await onLocationChange(newLocationInfo.newLocation); @@ -219,11 +205,7 @@ export async function next( } // Must be called *before* adding the location change event to the state! - const generateCharactersPrompt = generateNewCharactersPrompt( - state, - newLocationInfo.accompanyingCharacters, - tokenBudget, - ); + const generateCharactersPrompt = generateNewCharactersPrompt(state, newLocationInfo.accompanyingCharacters); const event: LocationChangeEvent = { type: "location_change", @@ -250,12 +232,12 @@ export async function next( event.presentCharacterIndices.push(i); } - await narrate(tokenBudget); + await narrate(); } step = ["Generating actions", "This typically takes a few seconds"]; state.actions = await backend.getObject( - generateActionsPrompt(state, tokenBudget), + generateActionsPrompt(state), schemas.Action.array().length(3), onToken, ); diff --git a/lib/prompts.ts b/lib/prompts.ts index 9ba421e0..3c4139db 100644 --- a/lib/prompts.ts +++ b/lib/prompts.ts @@ -77,7 +77,7 @@ const makeMainPromptPreamble = ( The protagonist (who you should refer to as "you" in your narration, as the adventure happens from their perspective) is ${state.protagonist.name}. ${state.protagonist.biography}`; -function makeMainPrompt(prompt: string, state: State, tokenBudget: number): Prompt { +function makeMainPrompt(prompt: string, state: State): Prompt { const promptPreamble = makeMainPromptPreamble(state); // get the tokens used by the prompt and the preamble @@ -86,7 +86,7 @@ function makeMainPrompt(prompt: string, state: State, tokenBudget: number): Prom const preambleTokens = getApproximateTokenCount(promptPreamble); // get the context based on the token budget minus the prompt and preamble tokens - const contextTokenBudget = tokenBudget - promptTokens - preambleTokens; + const contextTokenBudget = state.inputLength - promptTokens - preambleTokens; const context = getContext(state, contextTokenBudget); return makePrompt(` @@ -101,7 +101,7 @@ ${normalizedPrompt} `); } -export function narratePrompt(state: State, tokenBudget: number, action?: string): Prompt { +export function narratePrompt(state: State, action?: string): Prompt { return makeMainPrompt( ` ${action ? `The protagonist (${state.protagonist.name}) has chosen to do the following: ${action}.` : ""} @@ -116,11 +116,10 @@ Remember to refer to the protagonist (${state.protagonist.name}) as "you" in you Do not explicitly ask the protagonist for a response at the end; they already know what is expected of them. `, state, - tokenBudget, ); } -export function generateActionsPrompt(state: State, tokenBudget: number): Prompt { +export function generateActionsPrompt(state: State): Prompt { return makeMainPrompt( ` Suggest 3 options for what the protagonist (${state.protagonist.name}) could do or say next. @@ -128,22 +127,20 @@ Each option should be a single, short sentence that starts with a verb. Return the options as a JSON array of strings. `, state, - tokenBudget, ); } -export function checkIfSameLocationPrompt(state: State, tokenBudget: number): Prompt { +export function checkIfSameLocationPrompt(state: State): Prompt { return makeMainPrompt( ` Is the protagonist (${state.protagonist.name}) still at ${state.locations[state.protagonist.locationIndex].name}? Answer with "yes" or "no". `, state, - tokenBudget, ); } -export function generateNewLocationPrompt(state: State, tokenBudget: number): Prompt { +export function generateNewLocationPrompt(state: State): Prompt { return makeMainPrompt( ` The protagonist (${state.protagonist.name}) has left ${state.locations[state.protagonist.locationIndex].name}. @@ -151,16 +148,11 @@ Return the name and type of their new location, and a short description (100 wor Also include the names of the characters that are going to accompany ${state.protagonist.name} there, if any. `, state, - tokenBudget, ); } // Must be called *before* adding the location change event to the state! -export function generateNewCharactersPrompt( - state: State, - accompanyingCharacters: string[], - tokenBudget: number, -): Prompt { +export function generateNewCharactersPrompt(state: State, accompanyingCharacters: string[]): Prompt { const location = state.locations[state.protagonist.locationIndex]; return makeMainPrompt( @@ -175,7 +167,6 @@ Return the character descriptions as an array of JSON objects. Include a short biography (100 words maximum) for each character. `, state, - tokenBudget, ); }