diff --git a/.gitignore b/.gitignore index cfa9eb43..85e2b11d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ *.internal* *_internal* talemate_env +embedded_python/ +embedded_node/ chroma config.yaml secrets/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 1463f40d..2d0d6ffd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,116 @@ _Auto-generated. Do not edit by hand._ -## 0.37.1 +## 0.38.0 + +### Per-Scene Agent Overrides + +Agent configuration can now be overridden per scene without changing your global config. The Agent Modal gains a Global / Scene mode switch for toggling and editing overrides, and you can choose, swap, or opt out of the override file under World Editor → Scene → Settings. + +### Message Revision History + +Regenerated AI messages now show a paginator above the message body so you can browse earlier regenerations. The version you're viewing becomes the one the AI continues from. Continuing a character or narrator message also creates a navigable revision entry, and narrator messages gain a Continue action on the hover toolbar. + +### Scene Perspective Overrides + +The scene outline perspective now supports per-speaker overrides for the player, NPCs, and the narrator, each falling back to the default when left empty. Reusable presets are managed under Settings → Creator → Perspective Presets. + +### Entity Highlights + +Notable characters, items, and places in the most recent scene message are now highlighted as clickable entities — click any of them for a short description. The highlight prompt reads the full scene context, so the entities it surfaces stay anchored to what's happening right now. + +### Durable World State Snapshot + +The world state snapshot now carries entities forward across refreshes instead of regenerating from scratch, updating what changed and dropping entities that are no longer relevant. Stale entries are auto-evicted after several untouched updates, and you can Ctrl/Cmd-click the refresh button to wipe on demand. + +The snapshot can also be pinned into the conversation, narrator, and scene-analysis prompts so its tracked entities inform dialogue, narration, and planning, pull in relevant long-term memory, and run in the background when the client supports concurrent inference. A Custom instructions field lets you steer what it focuses on. + +### Improvements + +**World State Snapshot** + +- Added a Custom instructions field to steer what the snapshot focuses on. Empty by default. +- Semantic memory recall now feeds the snapshot so it can build on established lore and earlier scene history. On by default. +- Context pinning surfaces the snapshot in conversation, narrator, and scene-analysis prompts. Off by default. +- Background generation no longer locks scene input when the client supports concurrent inference; manual refreshes can be cancelled mid-generation. +- The snapshot now refreshes per character turn rather than per full round; the default interval was raised from 5 to 10 to keep the cadence comparable. + +**Editor & Revision** + +- Triggering a revision now shows a Revising spinner that clears whether the text changes, fails, or is cancelled. +- Added a Repetition handling setting (Remove or Attempt rewrite) for AI-assisted revision. +- Context-investigation messages (Look at, Investigate, Query) can now be revised automatically (opt-in, off by default) or manually via the Revise chip. +- The revision chip on the message hover toolbar now shows the configured revision method (Dedupe, Unslop, or Targeted Rewrite) instead of a generic label. + +**Autocomplete & Generation** + +- Autocomplete accepts a free-form {...} hint block at the end of the input to steer the continuation; the block is stripped when accepted. +- A chip beside the field now offers Redo and Undo after an autocomplete suggestion is applied. +- The Add Dialogue Example and World information fields now support autocomplete (Ctrl+Enter), with the same hint and Redo/Undo affordances. +- Instructions you provide when generating field content are now followed more reliably, with static history entries treating your instruction as the seed to expand on. + +**Clients & Generation** + +- OpenAI Compatible client gains a Parameters tab with individual toggles for temperature, top_p, and presence_penalty, so unsupported parameters can be omitted entirely. +- llama.cpp client adds a Concurrent Inference toggle so batch operations can run requests in parallel against a single server. Off by default. +- Added a HuggingFace access token to application settings for downloading gated model weights such as the Pocket TTS voice-cloning model. +- Response length gains an Adaptive option (now the default) that drops the token cap when reasoning is enabled, so responses aren't cut off mid-thought. +- Clients can now define a reasoning-start pattern so models that skip reasoning are accepted as-is instead of erroring; built-in Qwen, Gemma, GPT-OSS, and Seed templates set it automatically. +- The client's Reasoning tab now shows a Template default chip when the prompt template supplies a strip or validation pattern, clarifying where the value comes from. + +**Installation & Tooling** + +- The frontend now installs dependencies with pnpm for supply-chain hardening, provisioned automatically through Corepack; the install, update, and Docker scripts provision Node.js 22. + +**Inference Presets** + +- Added an Apply to all action that copies the current preset's values to every other preset in the group; Reset now asks for confirmation. +- Widened the adjustable range and refined the step increments on several parameter sliders for more precise tuning. + +**Scene & UI** + +- Long scenes render more smoothly; removing or rearranging messages now re-renders only the affected slot instead of everything after it. +- Removed the read-only Character Sheet dialog — the Manage character button already covers viewing and editing. +- Renamed the Creator sub-tab from Content Context to Content Classification to match the scene outline editor. +- Added a Make Player Character / Unmark as Player action to the character editor that swaps which character the player controls. +- Context-investigation messages gain a Visualize action that generates a matched image into the message. +- Added a Per-Action Reasoning override so reasoning can be forced off for specific agent actions, via the brain icon in the prompt log. + +**Node Editor** + +- Event Modules gain an auto_register toggle so a module subscribes to its event as soon as it's registered, without being placed in the scene loop graph. +- Log entries now show a right-aligned HH:MM:SS.mmm timestamp for easier correlation with scene activity. ### Bug Fixes -**Scene History** +**Backends** -- Fixed a crash when generating dialogue in long scenes that combine layered history with pre-established history entries. The scene could become unplayable until its layered history was manually edited. +- Google: an incomplete setup no longer overwrites the client's model name, which left the client looking configured while every request failed; existing bad values are repaired automatically. +- Anthropic: the Optimize for Prompt Caching toggle now actually enables caching by sending the required parameter when on. +- OpenRouter applies the same caching for anthropic/* models; note that enabling it forces routing to direct Anthropic. +- Conversation agent no longer injects # into the stop-sequence list on every turn. + +**Messages & Toolbar** + +- Message toolbar actions and double-click editing now disable while a background operation runs, with the hint reading Editing locked. +- The Create Pin action on narrator messages now respects the UI lock during generation. +- World entries created via Create Pin are marked Include only when pinned, so they surface through the pin rather than as ambient memory. +- Toolbar deletes, edits, and revision swaps now write through immediately with autosave on, so forking or rollback reflects them without a manual save. +- Intro message TTS now activates the scene tools audio control when triggered manually. + +**Scene & Memory** + +- Forks and rollbacks created from the changelog UI no longer fail to load with a missing-field error; affected history entries are repaired on reconstruction. +- Switching scenes now releases GPU memory reserved by local CUDA work (embeddings, TTS); can be turned off under Settings → Game → General. +- Scene-group templates created in the prompt manager are now resolvable from Jinja include calls regardless of the active agent type. +- Contextual autocomplete no longer repeats text you've already typed instead of continuing from where you left off. + +**UI & Startup** + +- Fixed an empty grey toast flashing after operations with no visible change; swallowed cancellation toasts now display. +- Node Editor boolean property checkboxes now repaint immediately on click instead of waiting for focus to move. +- The onboarding long-term memory step now scrolls on shorter viewports, keeping the Apply & finish button in view. +- Automatic prompt-template detection now times out and continues startup when HuggingFace can't be reached, instead of hanging indefinitely. ## 0.37.0 diff --git a/CHANGELOG.yaml b/CHANGELOG.yaml index 526fa08a..c5d638dc 100644 --- a/CHANGELOG.yaml +++ b/CHANGELOG.yaml @@ -1,3 +1,63 @@ +0.38.0.dev: + features: + - "Event Module Auto-Register: Event Modules gained an `auto_register` toggle so a module subscribes to its event as soon as it's registered with the scene, without having to be placed in the scene loop graph." + - "Per-Scene Agent Overrides: Agent configuration can now be overridden per scene without changing the global config. The Agent Modal gains a Global / Scene mode switch for toggling and editing overrides; choose, swap, or opt out of the override file under World Editor → Scene → Settings." + - "Player Character Toggle: Added a Make Player Character / Unmark as Player action to the character editor. Promoting a character makes them the player and demotes the previous player to an AI actor; unmarking flips the current player to AI." + - "Message Revision History: Regenerated AI messages now show a paginator above the message body to browse previous regenerations. The version you're viewing becomes the one the AI continues from." + - "Scene Perspective Overrides: The scene outline perspective now supports per-speaker overrides for the player, NPCs, and narrator, each falling back to the default when empty. Presets are managed under Settings → Creator → Perspective Presets." + - "Per-Action Reasoning Override: Force reasoning off for specific agent actions without changing the client's global setting, via the brain icon in the prompt log." + - "Entity Highlights: Notable characters, items, and places in the most recent scene message are now highlighted as clickable entities — click for a short description of each." + - "Visualize Context Investigations: Context-investigation messages (entity look-ups, Look at Scene / Character) gained a Visualize action that generates an image into the message, matched to the subject." + improvements: + - "Settings UX: Renamed the Creator sub-tab from 'Content Context' to 'Content Classification' to match the field label used in the scene outline editor." + - "Message Revision History: Continuing a character or narrator message now creates a navigable revision entry tagged 'Continued'. Narrator messages also gain the Continue action on the hover toolbar." + - "Pydantic Migration: Internal data models across the codebase converted to pydantic for stricter validation. No user-visible behavior changes." + - "Character Sheet: Removed the read-only Character Sheet dialog and its button from the World State panel — the Manage character button already covers viewing and editing character details." + - "Message Toolbar: The revision chip on the message hover toolbar now shows the editor's configured revision method (e.g. Dedupe, Unslop, or Targeted Rewrite) instead of the generic 'Editor Revision' label." + - "Editor Revision Feedback: Triggering a revision from a message now shows a 'Revising' spinner, which clears whether the revision changes the text, produces no changes, fails, or is cancelled." + - "Editor Revision: Added a Repetition handling setting (Remove / Attempt rewrite) for AI-assisted revision. Remove (default) deletes flagged repetitions; Attempt rewrite asks the model to substitute genuinely different content, falling back to removal only when no meaningful rewrite is possible." + - "Editor Revision: Context-investigation messages (Look at, Investigate, Query) now support revision, both automatically via a new opt-in target (off by default) and manually via the Revise chip. They also contribute to the repetition corpus so nearby messages dedupe against them." + - "Frontend Tooling: Migrated the frontend from npm to pnpm for supply-chain hardening, provisioned automatically through Corepack. A 7-day minimum-release-age cooldown blocks brand-new dependency versions, and dependency install/build scripts are blocked unless allowlisted. pnpm 11 requires Node.js 22, now provisioned automatically by the install, update, and Docker build scripts." + - "Scene Message List: Long scenes render more smoothly when the message history changes. Operations that remove or rearrange messages now re-render only the affected slot instead of every message after the change point." + - "OpenAI Compatible Client: Added a Parameters config tab with individual toggles for `temperature`, `top_p`, and `presence_penalty`. Toggling a parameter off omits it from the request entirely, for backends that hard-error on unsupported parameters. All three default to on." + - "llama.cpp Client: Added a Concurrent Inference toggle so batch operations can dispatch multiple requests in parallel against a single `llama-server`. Off by default." + - "Autocomplete Hints: Autocomplete now accepts a free-form `{...}` hint block at the end of the input to steer the continuation (tone, beats, sensory detail, etc.); the brace block is stripped from the field when the suggestion is accepted. Toggle via the new `Enable Hints` setting on the Creator agent's Autocomplete config (default on)." + - "Autocomplete Redo/Undo: After an autocomplete suggestion is applied, a chip appears beside the field with Redo and Undo actions. Redo re-runs autocomplete from your original input (including any hint) so you can iterate without retyping; Undo restores the original input." + - "Contextual Generation Instructions: Instructions you provide when generating field content (character attributes, world entries, history entries, etc.) are now presented to the model as user-given instruction or seed material that must be reflected in the result, so they're followed more reliably. Static history entries in particular now treat your instruction as the seed for the entry, expanding on it instead of occasionally ignoring it." + - "Node Editor Log: Log entries now show a right-aligned `HH:MM:SS.mmm` timestamp, making it easier to correlate them with scene activity." + - "World State Snapshot: The Entity Highlights prompt now sees the full scene context rather than just the last 10 dialogue lines, and the analyzed 'current moment' is tightened to the most recent lines so highlights stay anchored to the active beat." + - "Durable World State Snapshot: The world state snapshot now carries entities forward across refreshes instead of regenerating wholesale, updating what changed and dropping entities that are no longer relevant. Stale entries are auto-evicted after several untouched updates. Ctrl/Cmd-click the refresh button to wipe on demand, or disable durability in the world state agent config." + - "World State Snapshot Context Pinning: The world state snapshot can now be pinned into the conversation, narrator, and scene-analysis prompts so its tracked entities inform dialogue, narration, and planning. Off by default." + - "World State Snapshot Custom Instructions: Added a Custom instructions field to the world state agent config to steer what the snapshot focuses on. Empty by default." + - "World State Snapshot Memory Recall: The world state agent now pulls relevant long-term memory into snapshot generation, so the snapshot can build on established lore and earlier scene history. Semantic recall is on by default." + - "World State Snapshot Background Generation: When the world state agent's client supports concurrent inference, the snapshot now generates in the background instead of locking the scene input. Manual refreshes work the same way and can be cancelled mid-generation." + - "HuggingFace Token: Added a HuggingFace access token to the application settings for downloading gated model weights such as the Pocket TTS voice-cloning model. It can also be set directly in the Pocket TTS agent config." + - "Inference Presets: Added an 'Apply to all' action to the inference parameter editor that copies the current preset's values to every other preset in the group. The Reset action now asks for confirmation as well." + - "Inference Presets: Widened the adjustable range and refined the step increments on several inference parameter sliders so presets can be tuned more precisely." + - "More Autocomplete Fields: The character actor's Add Dialogue Example field and the world editor's World information field now support autocomplete (Ctrl+Enter), with the same hint and Redo/Undo affordances as other generated fields." + - "Response Length Enforcement: Added an Adaptive option to the client's response-length setting, now the default. When reasoning is enabled it relies on length instructions alone, which reasoning models follow well, and drops the token cap that could otherwise cut a response off while the model is still thinking. With reasoning off it falls back to capping tokens and sending instructions." + - "Reasoning Validation Pattern: Clients can now define a pattern marking the start of a model's reasoning, alongside the existing strip pattern. When a model doesn't actually reason (as can happen with abliterated variants), its response is accepted as-is instead of erroring out. Built-in templates for Qwen, Gemma, GPT-OSS, and Seed set it automatically." + - "Reasoning Template Defaults Indicator: When a model's prompt template supplies a default strip or validation pattern (as the built-in Qwen, Gemma, GPT-OSS, and Seed templates do), the client's Reasoning tab now shows a 'Template default' chip beneath the field — with a tooltip naming the template — so it's clear the value is template-provided rather than only hinted at by the field placeholder. The chip disappears once you enter a custom value." + changes: + - "World State Snapshot Cadence: The snapshot now refreshes per individual character turn rather than per full scene round. The default interval was raised from 5 to 10 to keep the effective cadence comparable." + fixes: + - "Google Client: Fixed the 'Setup incomplete' status leaking into the client's model name. Removing then re-adding the API key could persist 'Setup incomplete' as the model, leaving the client looking configured while every request failed with HTTP 400. The incomplete-setup state is now reported as an error message without overwriting the model, and a client that already has the bad value persisted is healed to report 'No model loaded' instead of sending the invalid model to Google." + - "Anthropic / OpenRouter Clients: Fixed the 'Optimize for Prompt Caching' toggle not actually enabling caching on Anthropic — the required `cache_control` parameter was never sent. It is now sent when the toggle is on, and the OpenRouter client does the same for `anthropic/*` models. Note: enabling caching for Anthropic via OpenRouter forces routing to direct Anthropic." + - "Game Loop Event: Fixed an internal scene-loop event being constructed with the wrong scene reference, surfaced by the pydantic migration." + - "Conversation Agent: Stopped injecting `#` into the LLM stop-sequence list on every conversation turn." + - "Scene-Group Templates: Templates created via the prompt manager under the `scene` agent prefix are now resolvable from Jinja `{% include %}` calls regardless of the active agent type." + - "Intro Message TTS: Fixed the scene tools audio control not activating when TTS was manually triggered for the intro message." + - "Create Pin: Fixed the Create Pin action on narrator messages staying clickable while the UI was locked during generation; it now respects the lock." + - "Message Toolbar: Fixed the message hover-toolbar actions and double-click editing staying available while a background agent operation was running. They now disable for the duration, with the hint reading 'Editing locked'." + - "Create Pin: World entries created via the Create Pin message action are now marked Include only when pinned, so they surface only through the pin and aren't retrieved as ambient memory." + - "Status Snackbar: Fixed an empty grey toast flashing after operations that completed without a visible change. Cancellation toasts that were previously swallowed now display." + - "Node Editor Module Properties: Fixed boolean property checkboxes appearing unresponsive — clicks updated the value but the checkbox didn't repaint until focus moved elsewhere. They now commit on change." + - "Onboarding Wizard: Fixed the long-term memory setup step being non-scrollable on shorter viewports, which could push the 'Apply & finish' button below the fold. The step content now scrolls while the footer actions stay in view." + - "Scene Forking: Fixed forks and rollbacks created from the changelog UI sometimes failing to load with a 'message Field required' error. Affected history entries are now repaired during reconstruction so the scene loads." + - "Message Toolbar Actions: Fixed deletes, edits, and revision swaps made from the message toolbar not being persisted to the changelog right away — they only landed at the next scene turn or manual save. With autosave on, they now write through immediately, so forking or rolling back from the changelog UI reflects those changes without the user having to save first." + - "GPU VRAM: Switching scenes now releases the GPU memory that local CUDA work (embeddings, TTS) leaves reserved, instead of holding it for the rest of the session where it could leave too little VRAM to load another scene. Can be turned off under Settings → Game → General." + - "Autocomplete: Fixed contextual autocomplete (character attributes, world entries, scene intros, and similar fields) sometimes repeating the text you had already typed instead of only continuing from where you left off." + - "HuggingFace Prompt Template Lookup: Fixed automatic prompt-template detection hanging indefinitely when HuggingFace can't be reached (e.g. network or IPv6 routing issues). The lookup now times out, logs a warning, and lets startup continue instead of stalling silently." 0.37.1: fixes: - "Scene History: Fixed a crash when generating dialogue in long scenes that combine layered history with pre-established (static) history entries. The affected scene could become unplayable until its layered history was manually edited." diff --git a/Dockerfile b/Dockerfile index f0cbeb7a..7b9c8829 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,19 +1,23 @@ # Stage 1: Frontend build -FROM node:21-slim AS frontend-build +FROM node:22-slim AS frontend-build WORKDIR /app -# Copy frontend package files -COPY talemate_frontend/package*.json ./ +# Enable pnpm via corepack (version pinned by package.json "packageManager"). +ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 +RUN npm install -g corepack@latest && corepack enable + +# Copy frontend manifest, lockfile and pnpm settings +COPY talemate_frontend/package.json talemate_frontend/pnpm-lock.yaml talemate_frontend/pnpm-workspace.yaml ./ # Install dependencies -RUN npm ci +RUN pnpm install --frozen-lockfile # Copy frontend source COPY talemate_frontend/ ./ # Build frontend -RUN npm run build +RUN pnpm build # Stage 2: Backend build FROM python:3.11-slim AS backend-build diff --git a/docs/getting-started/advanced/change-host-and-port.md b/docs/getting-started/advanced/change-host-and-port.md index 1325cf5d..b4c62c8d 100644 --- a/docs/getting-started/advanced/change-host-and-port.md +++ b/docs/getting-started/advanced/change-host-and-port.md @@ -94,7 +94,7 @@ Next rebuild the frontend. ```bash cd talemate_frontend -npm run build +corepack pnpm build ``` ### Start the backend and frontend diff --git a/docs/getting-started/installation/linux.md b/docs/getting-started/installation/linux.md index 2c059aa0..d434d2e3 100644 --- a/docs/getting-started/installation/linux.md +++ b/docs/getting-started/installation/linux.md @@ -4,10 +4,11 @@ --8<-- "docs/snippets/common.md:python-versions" -1. node.js and npm - see instructions [here](https://nodejs.org/en/download/package-manager/) -1. python- see instructions [here](https://www.python.org/downloads/) +1. python - see instructions [here](https://www.python.org/downloads/) 1. uv - see instructions [here](https://github.com/astral-sh/uv#installation) +`install.sh` downloads a portable Node.js 22 runtime automatically (used to build the frontend), so you do not need to install Node.js yourself. + ### Installation 1. `git clone https://github.com/vegu-ai/talemate.git` @@ -37,8 +38,10 @@ If everything went well, you can proceed to [connect a client](../../connect-a-c ### Running the Frontend +Unlike the quick install above, this manual path does not provision Node.js for you — install Node.js 22+ yourself (see [here](https://nodejs.org/en/download/package-manager/)). Corepack ships with it and provides `pnpm`. + 1. Navigate to the `talemate_frontend` directory. -2. If you haven't already, install npm dependencies by running `npm install`. -3. Start the server with `npm run serve`. +2. If you haven't already, install frontend dependencies by running `corepack pnpm install`. +3. Start the server with `corepack pnpm run serve`. Please note that you may need to set environment variables or modify the host and port as per your setup. You can refer to the various start scripts for more details. \ No newline at end of file diff --git a/docs/getting-started/installation/windows.md b/docs/getting-started/installation/windows.md index 63025a32..3cac2c74 100644 --- a/docs/getting-started/installation/windows.md +++ b/docs/getting-started/installation/windows.md @@ -3,9 +3,9 @@ 1. Download the latest Talemate release ZIP from the [Releases page](https://github.com/vegu-ai/talemate/releases) and extract it anywhere on your system (for example, `C:\Talemate`). 2. Double-click **`start.bat`**. - On the very first run Talemate will automatically: - 1. Download a portable build of Python 3 and Node.js (no global installs required). + 1. Download a portable build of Python 3 and Node.js 22 (no global installs required). 2. Create and configure a Python virtual environment. - 3. Install all back-end and front-end dependencies with the included *uv* and *npm*. + 3. Install all back-end and front-end dependencies with the included *uv* and *pnpm*. 4. Build the web client. 3. When the console window prints **"Talemate is now running"** and the logo appears, open your browser at **http://localhost:8082**. @@ -21,4 +21,4 @@ | **`install-cuda.bat`** | Installs the CUDA-enabled Torch build (run after the regular install). | | **`update.bat`** | Pulls the latest changes from GitHub, updates dependencies, rebuilds the web client. | -No system-wide Python or Node.js is required – Talemate uses the embedded runtimes it downloads automatically. \ No newline at end of file +No system-wide Python or Node.js is required – Talemate uses the embedded runtimes it downloads automatically. The bundled Node.js is version 22, which is required by the frontend's package manager (pnpm). \ No newline at end of file diff --git a/docs/img/0.26.0/world-editor-scene-outline-1.png b/docs/img/0.26.0/world-editor-scene-outline-1.png deleted file mode 100644 index b23c2f6b..00000000 Binary files a/docs/img/0.26.0/world-editor-scene-outline-1.png and /dev/null differ diff --git a/docs/img/0.26.0/world-state-snapshot-2.png b/docs/img/0.26.0/world-state-snapshot-2.png deleted file mode 100644 index e344ce90..00000000 Binary files a/docs/img/0.26.0/world-state-snapshot-2.png and /dev/null differ diff --git a/docs/img/0.29.0/app-settings-application.png b/docs/img/0.29.0/app-settings-application.png deleted file mode 100644 index 1da744b3..00000000 Binary files a/docs/img/0.29.0/app-settings-application.png and /dev/null differ diff --git a/docs/img/0.29.0/world-editor-scene-settings-1.png b/docs/img/0.29.0/world-editor-scene-settings-1.png deleted file mode 100644 index 5433f466..00000000 Binary files a/docs/img/0.29.0/world-editor-scene-settings-1.png and /dev/null differ diff --git a/docs/img/0.29.0/world-state-general-settings.png b/docs/img/0.29.0/world-state-general-settings.png deleted file mode 100644 index 374ccf5a..00000000 Binary files a/docs/img/0.29.0/world-state-general-settings.png and /dev/null differ diff --git a/docs/img/0.30.0/creator-autocomplete-settings.png b/docs/img/0.30.0/creator-autocomplete-settings.png deleted file mode 100644 index 09631caf..00000000 Binary files a/docs/img/0.30.0/creator-autocomplete-settings.png and /dev/null differ diff --git a/docs/img/0.30.0/editor-revision-settings-dedupe.png b/docs/img/0.30.0/editor-revision-settings-dedupe.png deleted file mode 100644 index fb7afa9e..00000000 Binary files a/docs/img/0.30.0/editor-revision-settings-dedupe.png and /dev/null differ diff --git a/docs/img/0.30.0/editor-revision-settings-rewrite.png b/docs/img/0.30.0/editor-revision-settings-rewrite.png deleted file mode 100644 index e2d3d064..00000000 Binary files a/docs/img/0.30.0/editor-revision-settings-rewrite.png and /dev/null differ diff --git a/docs/img/0.30.0/editor-revision-settings-unslop.png b/docs/img/0.30.0/editor-revision-settings-unslop.png deleted file mode 100644 index 7ef4c4d9..00000000 Binary files a/docs/img/0.30.0/editor-revision-settings-unslop.png and /dev/null differ diff --git a/docs/img/0.30.0/inference-presets-1.png b/docs/img/0.30.0/inference-presets-1.png deleted file mode 100644 index 09723c9f..00000000 Binary files a/docs/img/0.30.0/inference-presets-1.png and /dev/null differ diff --git a/docs/img/0.31.0/history-add-entry.png b/docs/img/0.31.0/history-add-entry.png deleted file mode 100644 index 3af5719b..00000000 Binary files a/docs/img/0.31.0/history-add-entry.png and /dev/null differ diff --git a/docs/img/0.35.0/app-settings-appearance-messages.png b/docs/img/0.35.0/app-settings-appearance-messages.png deleted file mode 100644 index ddcba4ac..00000000 Binary files a/docs/img/0.35.0/app-settings-appearance-messages.png and /dev/null differ diff --git a/docs/img/0.35.0/client-llamacpp.png b/docs/img/0.35.0/client-llamacpp.png deleted file mode 100644 index 5c68475a..00000000 Binary files a/docs/img/0.35.0/client-llamacpp.png and /dev/null differ diff --git a/docs/img/0.37.0/pocket-tts-api-settings.png b/docs/img/0.37.0/pocket-tts-api-settings.png deleted file mode 100644 index f76950a2..00000000 Binary files a/docs/img/0.37.0/pocket-tts-api-settings.png and /dev/null differ diff --git a/docs/img/0.37.0/scene-outline-perspective-field.png b/docs/img/0.37.0/scene-outline-perspective-field.png deleted file mode 100644 index d4ffa89e..00000000 Binary files a/docs/img/0.37.0/scene-outline-perspective-field.png and /dev/null differ diff --git a/docs/img/0.38.0/agent-action-overrides-dialog.png b/docs/img/0.38.0/agent-action-overrides-dialog.png new file mode 100644 index 00000000..67fbb77b Binary files /dev/null and b/docs/img/0.38.0/agent-action-overrides-dialog.png differ diff --git a/docs/img/0.38.0/app-settings-appearance-messages.png b/docs/img/0.38.0/app-settings-appearance-messages.png new file mode 100644 index 00000000..5b09cfd8 Binary files /dev/null and b/docs/img/0.38.0/app-settings-appearance-messages.png differ diff --git a/docs/img/0.38.0/app-settings-application.png b/docs/img/0.38.0/app-settings-application.png new file mode 100644 index 00000000..419a8516 Binary files /dev/null and b/docs/img/0.38.0/app-settings-application.png differ diff --git a/docs/img/0.38.0/app-settings-creator-perspective-presets.png b/docs/img/0.38.0/app-settings-creator-perspective-presets.png new file mode 100644 index 00000000..4e3982e6 Binary files /dev/null and b/docs/img/0.38.0/app-settings-creator-perspective-presets.png differ diff --git a/docs/img/0.38.0/client-llamacpp.png b/docs/img/0.38.0/client-llamacpp.png new file mode 100644 index 00000000..04ad3d62 Binary files /dev/null and b/docs/img/0.38.0/client-llamacpp.png differ diff --git a/docs/img/0.38.0/creator-autocomplete-settings.png b/docs/img/0.38.0/creator-autocomplete-settings.png new file mode 100644 index 00000000..a842a1f3 Binary files /dev/null and b/docs/img/0.38.0/creator-autocomplete-settings.png differ diff --git a/docs/img/0.38.0/editor-revision-settings-dedupe.png b/docs/img/0.38.0/editor-revision-settings-dedupe.png new file mode 100644 index 00000000..6446e258 Binary files /dev/null and b/docs/img/0.38.0/editor-revision-settings-dedupe.png differ diff --git a/docs/img/0.38.0/editor-revision-settings-rewrite.png b/docs/img/0.38.0/editor-revision-settings-rewrite.png new file mode 100644 index 00000000..016127fb Binary files /dev/null and b/docs/img/0.38.0/editor-revision-settings-rewrite.png differ diff --git a/docs/img/0.38.0/editor-revision-settings-unslop.png b/docs/img/0.38.0/editor-revision-settings-unslop.png new file mode 100644 index 00000000..adac75c8 Binary files /dev/null and b/docs/img/0.38.0/editor-revision-settings-unslop.png differ diff --git a/docs/img/0.38.0/history-add-entry.png b/docs/img/0.38.0/history-add-entry.png new file mode 100644 index 00000000..6c99e7a4 Binary files /dev/null and b/docs/img/0.38.0/history-add-entry.png differ diff --git a/docs/img/0.38.0/inference-presets-1.png b/docs/img/0.38.0/inference-presets-1.png new file mode 100644 index 00000000..7eb472ce Binary files /dev/null and b/docs/img/0.38.0/inference-presets-1.png differ diff --git a/docs/img/0.38.0/message-revision-paginator.png b/docs/img/0.38.0/message-revision-paginator.png new file mode 100644 index 00000000..04d4d117 Binary files /dev/null and b/docs/img/0.38.0/message-revision-paginator.png differ diff --git a/docs/img/0.38.0/pocket-tts-api-settings.png b/docs/img/0.38.0/pocket-tts-api-settings.png new file mode 100644 index 00000000..559034d4 Binary files /dev/null and b/docs/img/0.38.0/pocket-tts-api-settings.png differ diff --git a/docs/img/0.38.0/scene-outline-perspective-overrides.png b/docs/img/0.38.0/scene-outline-perspective-overrides.png new file mode 100644 index 00000000..6a40db1e Binary files /dev/null and b/docs/img/0.38.0/scene-outline-perspective-overrides.png differ diff --git a/docs/img/0.38.0/world-editor-scene-outline-1.png b/docs/img/0.38.0/world-editor-scene-outline-1.png new file mode 100644 index 00000000..6722de6e Binary files /dev/null and b/docs/img/0.38.0/world-editor-scene-outline-1.png differ diff --git a/docs/img/0.38.0/world-editor-scene-settings-1.png b/docs/img/0.38.0/world-editor-scene-settings-1.png new file mode 100644 index 00000000..6a8718a5 Binary files /dev/null and b/docs/img/0.38.0/world-editor-scene-settings-1.png differ diff --git a/docs/img/0.38.0/world-state-snapshot-2.png b/docs/img/0.38.0/world-state-snapshot-2.png new file mode 100644 index 00000000..e7a4f0ac Binary files /dev/null and b/docs/img/0.38.0/world-state-snapshot-2.png differ diff --git a/docs/img/0.38.0/world-state-update-world-state-settings.png b/docs/img/0.38.0/world-state-update-world-state-settings.png new file mode 100644 index 00000000..fbf36a72 Binary files /dev/null and b/docs/img/0.38.0/world-state-update-world-state-settings.png differ diff --git a/docs/snippets/common.md b/docs/snippets/common.md index 2fffd6ac..5782ad8d 100644 --- a/docs/snippets/common.md +++ b/docs/snippets/common.md @@ -4,7 +4,7 @@ -Fist, if you have not done so, [create a template group](/talemate/user-guide/world-editor/templates/groups) to store the template in. +Fist, if you have not done so, [create a template group](/talemate/user-guide/templates/groups) to store the template in. Then select the group you want to add the template to and click the **:material-plus: Create Template** button. @@ -15,7 +15,7 @@ Then select the group you want to add the template to and click the **:material- !!! note "Unwanted Prose Requirement" Unwanted phrases are defined in the writing style that is currently selected in the [Scene Settings](/talemate/user-guide/world-editor/scene/settings). - See [Writing Style Templates](/talemate/user-guide/world-editor/templates/writing-style) for more information on how to create a writing style and add unwanted phrases. + See [Writing Style Templates](/talemate/user-guide/templates/writing-style) for more information on how to create a writing style and add unwanted phrases. diff --git a/docs/snippets/tips.md b/docs/snippets/tips.md index c6e52c45..e5fe1fab 100644 --- a/docs/snippets/tips.md +++ b/docs/snippets/tips.md @@ -1,6 +1,8 @@ !!! tip "Generate using AI" **:material-auto-fix: Generate** - press the **generate** button on top of the input field to let the AI generate the content based on the existing details. + + Hold `ctrl` (or `cmd` on macOS) while clicking to add your own [instructions](/talemate/user-guide/world-editor/generation-settings/#generation-instructions). What you type is used as instruction and seed material the AI must reflect in the result. @@ -11,13 +13,15 @@ !!! tip "Generate using AI" **:material-auto-fix: Generate** - press the **generate** button on top of the input field to let the AI generate the content based on the existing details. - + + Hold `ctrl` (or `cmd` on macOS) while clicking to add your own [instructions](/talemate/user-guide/world-editor/generation-settings/#generation-instructions). What you type is used as instruction and seed material the AI must reflect in the result. + **:material-auto-fix: Autocomplete** - press `ctrl+Enter` (or `cmd+Enter` on macOS) while the cursor is in the input field to use AI to autocomplete the current content, you !!! tip "Generation settings and managing templates" - Find out more about content generation, managing templates and how to apply randomization and specific writing styles in [Templates](/talemate/user-guide/world-editor/templates/) and [Generation settings](/talemate/user-guide/world-editor/generation-settings). + Find out more about content generation, managing templates and how to apply randomization and specific writing styles in [Templates](/talemate/user-guide/templates/) and [Generation settings](/talemate/user-guide/world-editor/generation-settings). diff --git a/docs/user-guide/agents/creator/autocomplete.md b/docs/user-guide/agents/creator/autocomplete.md new file mode 100644 index 00000000..838163b7 --- /dev/null +++ b/docs/user-guide/agents/creator/autocomplete.md @@ -0,0 +1,50 @@ +# Autocomplete + +Autocomplete uses the AI to continue whatever you have already started typing in a text field. Instead of generating content from scratch, it picks up from your partial text and writes the next part for you. + +To trigger it, place your cursor in a supported field and press `ctrl+Enter` (or `cmd+Enter` on macOS). The generated text is appended to what you have already written. + +!!! abstract "This works best if the client is in control of the prompt template" + Success rate on this feature when the text generation API controls the prompt template is reduced, as Talemate cannot prefix the partial text. + + See [Prompt Templates](/talemate/user-guide/clients/prompt-templates) for more information. + +## Steering the continuation with hints + +You can guide the continuation by adding a short hint in curly braces `{...}` at the very end of your input before you trigger autocomplete. The hint is free-form, so you can describe whatever you want the AI to keep in mind: tone, beats to hit, sensory detail, who else is in the scene, and so on. + +The brace block is only an instruction for the AI. When the suggestion is accepted, the `{...}` block is stripped out, so it never ends up in your actual text. + +#### Example + +Typing: + +> `"Kaira!?" he yelled {dark corridor, no response}` + +and pressing `ctrl+Enter` cues the AI on the tone and the beats you want, then continues the line. The `{dark corridor, no response}` part is removed once the suggestion is applied, leaving only the dialogue and its continuation. + +Hints are controlled by the **Enable Hints** setting on the Creator agent, which is on by default. When the setting is turned off, a trailing `{...}` block is treated as ordinary text and sent as part of your input instead of being used as a hint. See [Settings](/talemate/user-guide/agents/creator/settings) for more. + +## Redo and Undo + +After a suggestion is applied, a small chip appears beside the field with two actions: + +- **:material-refresh: Redo** — re-runs autocomplete from your original input (including any hint you added). This lets you keep trying for a different continuation without retyping anything. +- **:material-undo: Undo** — restores your original input and removes the applied suggestion. + +The chip disappears once you start editing the field yourself. + +## Where autocomplete is available + +Autocomplete is available across many of Talemate's text fields, including: + +- Your action and dialogue in the [main input](/talemate/user-guide/interacting/#autocomplete) +- Scene messages when you edit them in place — double-click a character, narrator, or context investigation message to edit it, then press `ctrl+Enter` +- Character [attributes](/talemate/user-guide/world-editor/characters/attributes) and [details](/talemate/user-guide/world-editor/characters/details) +- Character [description](/talemate/user-guide/world-editor/characters/description) +- The [scene introduction](/talemate/user-guide/world-editor/scene/outline) +- The character actor's **Add Dialogue Example** field (see [Actor management](/talemate/user-guide/world-editor/characters/actor)) + +The hint and Redo/Undo behaviour described above works the same way in every field that supports autocomplete. + +You can adjust how long the generated suggestions are, per field type, on the Creator agent's [Settings](/talemate/user-guide/agents/creator/settings) page. diff --git a/docs/user-guide/agents/creator/index.md b/docs/user-guide/agents/creator/index.md index 6286397e..14874e69 100644 --- a/docs/user-guide/agents/creator/index.md +++ b/docs/user-guide/agents/creator/index.md @@ -1,3 +1,11 @@ # Overview -Used tor most creative tasks that involves the creation of new context. \ No newline at end of file +Used for most creative tasks that involve the creation of new context. + +## Features + +### Autocomplete + +Continues whatever you have already started typing in a text field, so you can let the AI write the next part for you. Trigger it with `ctrl+Enter` (or `cmd+Enter` on macOS), steer it with optional `{...}` hints, and refine the result with Redo and Undo. + +See the [Autocomplete](/talemate/user-guide/agents/creator/autocomplete) page for more information. diff --git a/docs/user-guide/agents/creator/settings.md b/docs/user-guide/agents/creator/settings.md index c7e0a8c1..52aab58a 100644 --- a/docs/user-guide/agents/creator/settings.md +++ b/docs/user-guide/agents/creator/settings.md @@ -10,7 +10,9 @@ Open by clicking the **Creator** agent in the agent list. ## Autocomplete -![Conversation agent autocomplete settings](/talemate/img/0.30.0/creator-autocomplete-settings.png) +These settings control the [Autocomplete](/talemate/user-guide/agents/creator/autocomplete) feature. + +![Conversation agent autocomplete settings](/talemate/img/0.38.0/creator-autocomplete-settings.png) ##### Dialogue Suggestion Length @@ -18,4 +20,12 @@ How many tokens to generate (max.) when autocompleting character actions. ##### Narrative Suggestion Length -How many tokens to generate (max.) when autocompleting narrative text. \ No newline at end of file +How many tokens to generate (max.) when autocompleting narrative text. + +##### Enable Hints + +When enabled (the default), a trailing `{...}` block at the end of your input is treated as a hint that steers the continuation, and is stripped from the field once the suggestion is accepted. + +When disabled, any trailing `{...}` is treated as ordinary text and sent as part of your input. + +See [Steering the continuation with hints](/talemate/user-guide/agents/creator/autocomplete/#steering-the-continuation-with-hints) for details. \ No newline at end of file diff --git a/docs/user-guide/agents/editor/revisions.md b/docs/user-guide/agents/editor/revisions.md index 53e52bb7..2b8d1760 100644 --- a/docs/user-guide/agents/editor/revisions.md +++ b/docs/user-guide/agents/editor/revisions.md @@ -2,6 +2,8 @@ Revisions were introduced in version `0.30.0` and allow the editor to detect and Please see the [settings](/talemate/user-guide/agents/editor/settings/#revision) for more information on how to configure **:material-typewriter: Revisions**. +## Automatic revision + Once automatic revisions are enabled, if the editor finds an issue in a message you will see the following status updates: ![Editor revision status](/talemate/img/0.30.0/editor-revision-issue-identified.png) @@ -14,4 +16,20 @@ Once the revision is complete the editor agent will indicate that that it made s Click the :material-message-text-outline: icon to open the Agent Messages dialog to view the changes. -![Editor revision messages](/talemate/img/0.30.0/editor-revision-history.png) \ No newline at end of file +![Editor revision messages](/talemate/img/0.30.0/editor-revision-history.png) + +Which message types are revised automatically is controlled by the [Automatic Revision Targets](/talemate/user-guide/agents/editor/settings/#automatic-revision-targets) setting. Character and narrator messages are included by default; context-investigation messages (the results of **Look at**, **Investigate**, and **Query**) can be added by enabling their target, which is off by default. + +## Manual revision + +You can also trigger a revision yourself on the most recent message without waiting for automatic revision (or with automatic revision turned off entirely). + +When revision is enabled, hover over the latest character, narrator, or context-investigation message and click the **:material-typewriter: Revise** chip in its toolbar. The chip's label reflects the configured [revision method](/talemate/user-guide/agents/editor/settings/#revision-method) (for example, *Dedupe*, *Unslop*, or *Targeted Rewrite*). + +A revision has one of three outcomes: + +- The text was changed - it's added as a new version of the message; use the message's version paginator to step between the original and the revision. +- No changes were needed - the message is left untouched and a status notice confirms it. +- The revision failed or was cancelled - the message is left untouched. + +Revisions never overwrite the original. See [Message revision history](/talemate/user-guide/interacting/#message-revision-history) for how to move between versions. diff --git a/docs/user-guide/agents/editor/settings.md b/docs/user-guide/agents/editor/settings.md index bb49ff7c..08982b4b 100644 --- a/docs/user-guide/agents/editor/settings.md +++ b/docs/user-guide/agents/editor/settings.md @@ -28,7 +28,7 @@ Will take the generate message and attempt to add more detail to it. ## Revision -![Editor agent revision settings](/talemate/img/0.30.0/editor-revision-settings-dedupe.png) +![Editor agent revision settings](/talemate/img/0.38.0/editor-revision-settings-dedupe.png) When :material-typewriter: revision is enabled the editor will analyze and attempt to fix character messages, narrator messages, and contextual generation (such as character attributes, details, world context, etc.). @@ -59,10 +59,15 @@ When automatic revision is enabled, you can choose which types of messages to au - **Character Messages** - Automatically revise actor actions and dialogue - **Narration Messages** - Automatically revise narrator actions and descriptions +- **Context Investigations** - Automatically revise context-investigation messages (the results of **Look at**, **Investigate**, and **Query** actions). Off by default. - **Contextual generation** - Automatically revise generated context such as character attributes, details, world context, scene intros, etc. +- **Summarization** - Automatically revise dialogue summaries. By default, both Character Messages and Narration Messages are enabled. You can enable or disable each type independently based on your preferences. +!!! note "Context investigations always feed the repetition check" + Whether or not **Context Investigations** is enabled as an automatic target, context-investigation messages are always included in the text the editor compares against when looking for repetition. This means nearby character and narrator messages will avoid repeating phrases that already appeared in a recent **Look at**, **Investigate**, or **Query** result. + ##### Revision Method Which method to use to fix issues. @@ -73,19 +78,19 @@ Which method to use to fix issues. **Dedupe (Fast and dumb)** -![Editor agent revision settings - Dedupe](/talemate/img/0.30.0/editor-revision-settings-dedupe.png) +![Editor agent revision settings - Dedupe](/talemate/img/0.38.0/editor-revision-settings-dedupe.png) When **Dedupe** is active it will be restricted to finding repetition and removing it without replacing it with something else, nor understanding the intent or context, so it may sometimes cause disjointed dialogue. This method is much faster as it will never prompt the AI for analysis and fixes. (If repetition detection is set to semantic similarity, it will still use the embedding function to find repetition, but it will not prompt the AI for analysis and fixes.) **Unslop (AI assisted)** -![Editor agent revision settings - Unslop](/talemate/img/0.30.0/editor-revision-settings-unslop.png) +![Editor agent revision settings - Unslop](/talemate/img/0.38.0/editor-revision-settings-unslop.png) When **Unslop** is active, it calls 1 additional prompt after a generation and will attempt to remove repetition, purple prose, unnatural dialogue, and over-description. May cause details to be lost. This is a general-purpose cleanup method that can also use unwanted prose detection when enabled. **Targeted Rewrite (AI assisted)** -![Editor agent revision settings - Targeted Rewrite](/talemate/img/0.30.0/editor-revision-settings-rewrite.png) +![Editor agent revision settings - Targeted Rewrite](/talemate/img/0.38.0/editor-revision-settings-rewrite.png) When **Targeted Rewrite** is active, unwanted prose detection becomes available and when issues are detected the AI will attempt to rewrite the message to fix the issues. This method checks for specific problems first, then only rewrites if enough issues are found. @@ -121,6 +126,13 @@ The minimum length of a phrase (in characters) to be considered for repetition. Once switched to either **Unslop** or **Targeted Rewrite** mode, additional settings become available. +#### Repetition handling + +Controls what the AI-assisted revision does when it detects repetition. (Available for both **Unslop** and **Targeted Rewrite**.) + +- **Remove** (default) - flagged repetitions are deleted from the text. This is the safer choice for weaker models, which often struggle to substitute genuinely different content and end up rephrasing the same idea, re-introducing the very repetition the rule is meant to fix. +- **Attempt rewrite** - the editor asks the model to replace flagged repetitions with genuinely different content rather than deleting them. Because matches are based on meaning (not just wording), simply rephrasing the same idea is not enough - the underlying beat has to change. When the model cannot produce a meaningful rewrite, it falls back to removing the repetition. This works best with stronger models. + #### Preferences for Rewriting (Targeted Rewrite only) ##### Test parts of sentences, split on commas diff --git a/docs/user-guide/agents/index.md b/docs/user-guide/agents/index.md index be09dd74..a0ec90ae 100644 --- a/docs/user-guide/agents/index.md +++ b/docs/user-guide/agents/index.md @@ -40,4 +40,8 @@ Handles TTS generation. Currently supports ElevenLabs and OpenAI backends. ### World State -Keeps track of the current world state and reinforces specific character states. \ No newline at end of file +Keeps track of the current world state and reinforces specific character states. + +## Per-scene overrides + +Most agent settings are global and apply to every scene. You can also override selected settings for a single scene without changing your global configuration — see [Per-Scene Agent Overrides](/talemate/user-guide/agents/scene-overrides). \ No newline at end of file diff --git a/docs/user-guide/agents/scene-overrides.md b/docs/user-guide/agents/scene-overrides.md new file mode 100644 index 00000000..a4774ea1 --- /dev/null +++ b/docs/user-guide/agents/scene-overrides.md @@ -0,0 +1,65 @@ +# Per-Scene Agent Overrides + +Every agent has a set of global settings that apply to all of your scenes. Sometimes a single scene needs different behavior — a longer conversation length, a different narration style, or an action turned off — without you having to change your global configuration and remember to change it back afterwards. + +**Per-scene agent overrides** let you do exactly that. You can override selected agent settings for the current scene only. Anything you don't override keeps using your global configuration. + +## How overrides work + +Overrides are **sparse**: only the specific fields you choose to override are stored. Everything else falls through to the global agent settings. This means that if you later change a global setting, scenes pick up that change automatically — unless they have an active override for that particular field. + +Each overridden field is independent. You can override the conversation length for a scene while leaving its format, client, and every other setting on the global value. + +## Editing overrides: the Global / Scene switch + +Overrides are edited in the same place as global settings — the **agent modal** (opened by clicking an agent in the agent panel). + +When a scene is loaded and the agent has at least one setting that supports overrides, a **Global / Scene** switch appears at the top of the modal: + +- **Global** — the normal view. Changes here affect every scene. +- **Scene** — the override view. Changes here apply only to the current scene. + +A small number next to the **Scene** button shows how many overrides are currently active for that agent. + +!!! note + The switch only appears while a saved scene is loaded. Overrides need a scene (and its project folder) to be stored in, so they aren't available on the placeholder/empty scene. + +### Activating an override for a field + +In **Scene** mode, each overridable setting has a small link icon next to it: + +- :material-link-variant-off: **Inheriting global** — the field is using the global value and is read-only. Click the icon to start overriding it. +- :material-link-variant: **Override active** — the field is overriding the global value for this scene. Edit it freely, or click the icon again to go back to inheriting the global value. + +When you activate an override, the field starts out seeded with the current global value, so you always begin from a known state. + +Some agents also let you override whether an entire action is **enabled** for the scene, using the same link icon next to the action's enable checkbox. + +In Scene mode, the modal only shows the settings (and tabs) that actually support overriding, to keep the view focused. If an agent has no overridable settings at all, the Global / Scene switch won't appear. + +### Saving + +Overrides are saved automatically when you close the agent modal, the same way global settings are. If the scene doesn't yet have an overrides file (see below), you'll be asked to name one the first time you save an override. + +## The overrides file + +Per-scene overrides are stored in a JSON file inside an `agent-settings/` folder in the scene's project directory. The default file name is `agent-settings.json`. + +A few things worth knowing: + +- **Overrides are project-level.** The `agent-settings/` folder lives with the project, so every save file in the same project (including **Save As** copies and restore points) shares the same overrides. Different projects have completely independent overrides. +- **Overrides travel with exports.** When you export a scene, the `agent-settings/` folder is included, so your overrides survive being shared with or imported on another system. +- **Bad files fall back safely.** If an overrides file is missing or can't be read, the scene simply falls back to your global configuration and the link is cleared. + +## Choosing, swapping, or opting out + +Which overrides file a scene uses is controlled from **World Editor → Scene → Settings**, in the **Agent settings file** dropdown. See [Scene Settings](/talemate/user-guide/world-editor/scene/settings#agent-settings-file) for the full description. In short, you can: + +- **Auto-link (default)** — the scene automatically uses `agent-settings.json` if one exists in the project. This is the normal behavior. The first override you set in the agent modal creates this file. +- **Pick a specific file** — link the scene to a particular file in the project's `agent-settings/` folder. This lets a project hold more than one overrides file and switch between them. +- **None (opt out)** — disable per-scene overrides entirely for this scene. The scene uses global configuration only and won't auto-link to a default file. + +## Related + +- [Scene Settings](/talemate/user-guide/world-editor/scene/settings) — where the overrides file is chosen, swapped, or opted out. +- [Agents overview](/talemate/user-guide/agents/) — the global settings that overrides fall back to. diff --git a/docs/user-guide/agents/voice/pocket-tts.md b/docs/user-guide/agents/voice/pocket-tts.md index 3e0838a1..4965d881 100644 --- a/docs/user-guide/agents/voice/pocket-tts.md +++ b/docs/user-guide/agents/voice/pocket-tts.md @@ -5,7 +5,7 @@ Pocket TTS is a local CPU-based text-to-speech model from [Kyutai](https://kyutai.org/) that supports voice cloning from audio files. Unlike other local TTS options that require a GPU, Pocket TTS runs efficiently on your CPU, making it accessible on a wider range of hardware. -![Pocket TTS API settings](/talemate/img/0.37.0/pocket-tts-api-settings.png) +![Pocket TTS API settings](/talemate/img/0.38.0/pocket-tts-api-settings.png) ## Key Features @@ -23,12 +23,20 @@ The first time you generate audio with Pocket TTS, it will automatically downloa Voice cloning requires accepting the model terms on Hugging Face. If voice cloning downloads are blocked: 1. Visit the [Pocket TTS model page](https://huggingface.co/kyutai/pocket-tts) and accept the terms - 2. Create a [Hugging Face access token](https://huggingface.co/settings/tokens) - 3. Set the token in your environment as `HF_TOKEN` - 4. Restart Talemate + 2. Create a [Hugging Face access token](https://huggingface.co/settings/tokens) (a read token is sufficient) + 3. Add the token to Talemate (see [HuggingFace Token](#huggingface-token) below) + 4. Try generating again ## Configuration +##### HuggingFace Token + +!!! info "Added in 0.38.0" + +Optional. Only needed if the voice-cloning model download is gated by Hugging Face. Paste a [Hugging Face access token](https://huggingface.co/settings/tokens) here and Talemate will use it when downloading the model weights. + +This is the same setting as the token on the [HuggingFace application settings page](../../app-settings/application.md#huggingface-token) — set it in either place. A read token is sufficient, and you still need to accept the model terms on Hugging Face with the same account (see [First-Time Setup](#first-time-setup)). + ##### Language / Model Selects which Pocket TTS model to load. English uses a fast 6-layer distilled model; the other languages use full 24-layer models for higher quality at the cost of slower generation. All models run locally on CPU (or GPU if available). @@ -150,7 +158,7 @@ If the model fails to download: - Check your internet connection - Verify you have accepted the terms on [Hugging Face](https://huggingface.co/kyutai/pocket-tts) -- Make sure your `HF_TOKEN` environment variable is set correctly +- Make sure your [HuggingFace Token](#huggingface-token) is set correctly - Try restarting Talemate ### Voice Cloning Not Working diff --git a/docs/user-guide/agents/world-state/settings.md b/docs/user-guide/agents/world-state/settings.md index 6b53397d..0ce745c1 100644 --- a/docs/user-guide/agents/world-state/settings.md +++ b/docs/user-guide/agents/world-state/settings.md @@ -1,16 +1,54 @@ # Settings -## General +## Update World State -![World state agent settings](/talemate/img/0.29.0/world-state-general-settings.png) +![World state agent update world state settings](/talemate/img/0.38.0/world-state-update-world-state-settings.png) ##### Update world state -Will attempt to update the [world state snapshot](/talemate/user-guide/world-state/) based on the current scene. Runs automatically every N turns. +Will attempt to update the [world state snapshot](/talemate/user-guide/world-state/) based on the current scene. Runs automatically every N character turns. + +###### When a new scene is started + +Whether to generate an initial snapshot as soon as a scene is loaded. On by default. ###### Turns -How many turns to wait before the world state is updated. +How many character turns to wait before the snapshot is refreshed. Each player or AI turn counts (not full rounds), so on a scene with several characters the snapshot refreshes more often than the number alone might suggest. Defaults to 10. + +###### Lines in the moment + +How many of the most recent messages count as the "current moment". These are the lines the snapshot anchors to, and they are the messages that can show inline entity highlights. Keeping this small keeps the highlights tied to the active beat while the rest of the scene is still used as background context. + +###### Include Look at / Add Detail + +When on, **Look at** and **Add Detail** results also count as part of the current moment and can receive inline highlights. Off by default. + +###### Custom instructions + +Free-form instructions that steer what the snapshot focuses on. Use this to nudge the agent toward (or away from) certain kinds of detail. Empty by default. + +###### Add Detail length + +How long an **Add Detail** result can be. Shorter lengths keep the generated description tighter; longer lengths allow more elaboration. + +###### Pin to context + +When on, the snapshot is pinned into the conversation, narrator, and scene-analysis prompts as live scene notes, so the tracked entities can inform dialogue, narration, and planning. Off by default. See [Pinning the snapshot to the scene](/talemate/user-guide/world-state/#pinning-the-snapshot-to-the-scene). + +###### Durable snapshot + +When on (the default), each refresh updates only what has changed and carries the rest of the snapshot forward, instead of rebuilding it from scratch every time. This lets the snapshot accumulate detail over the course of a scene. When off, every refresh starts fresh. See [How the snapshot carries forward](/talemate/user-guide/world-state/#how-the-snapshot-carries-forward). + +The two settings below only apply when **Durable snapshot** is on. + +###### Max items tracked + +The maximum number of items the snapshot can hold at once. When a refresh would exceed this, the stalest items are dropped first. Set to `0` for no limit. + +###### Auto-evict stale entries + +Automatically drops a snapshot entry that the agent leaves unchanged for this many refreshes in a row. This guards against stale entries lingering when the agent fails to remove them on its own. Set to `0` to disable. ##### Update state reinforcements @@ -87,3 +125,9 @@ For example, if a character is described as wearing formal attire at a party but - A Visual Agent with an image generation backend configured When a new portrait is generated, it is automatically added to the character's portrait collection and tagged based on the scene context. + +## Long Term Memory + +When generating the [world state snapshot](/talemate/user-guide/world-state/), the agent pulls in relevant long-term memory so the snapshot can build on established lore and earlier scene history. Semantic recall is always used and is on by default. + +--8<-- "docs/snippets/tips.md:agent_long_term_memory_settings" diff --git a/docs/user-guide/app-settings/appearance.md b/docs/user-guide/app-settings/appearance.md index 1e65b728..d64eb839 100644 --- a/docs/user-guide/app-settings/appearance.md +++ b/docs/user-guide/app-settings/appearance.md @@ -4,7 +4,7 @@ The Appearance settings let you customize how messages and visuals are displayed ## :material-script: Messages -![App settings - Appearance - Messages](/talemate/img/0.35.0/app-settings-appearance-messages.png) +![App settings - Appearance - Messages](/talemate/img/0.38.0/app-settings-appearance-messages.png) The Messages tab gives you control over how text appears in the scene feed. You can customize the styling for different message types and special text formatting. @@ -39,6 +39,7 @@ You can configure these markup styles: | **Parentheses** | `( )` | (whispering softly) | | **Brackets** | `[ ]` | [Author's note] | | **Emphasis** | `* *` | *dramatic pause* | +| **Entity Highlights** | — | clickable [entity highlights](/talemate/user-guide/world-state/#entity-highlights-in-scene-messages) in the latest message | For each markup type, you can set: diff --git a/docs/user-guide/app-settings/application.md b/docs/user-guide/app-settings/application.md index aa892e98..77a20e18 100644 --- a/docs/user-guide/app-settings/application.md +++ b/docs/user-guide/app-settings/application.md @@ -1,5 +1,24 @@ # :material-application-outline: Application -![App settings - Application](/talemate/img/0.29.0/app-settings-application.png) +![App settings - Application](/talemate/img/0.38.0/app-settings-application.png) -Configure various API keys for integration with external services. (OpenAI, Anthropic, etc.) \ No newline at end of file +Configure various API keys for integration with external services. (OpenAI, Anthropic, etc.) + +Each external service has its own page in the sidebar. Select a service, paste your key or token, and save. + +## HuggingFace Token + +!!! info "Added in 0.38.0" + +Some features download model weights from [Hugging Face](https://huggingface.co/). Most weights are open and download without any credentials, but a few are **gated** — Hugging Face requires you to be signed in and to have accepted the model's terms before it will let you download them. The Pocket TTS voice-cloning model is one such gated model. + +To download gated weights, add a HuggingFace access token: + +1. Open the **HuggingFace** page under Application settings. +2. Create a token at [https://huggingface.co/settings/tokens](https://huggingface.co/settings/tokens). A read token is sufficient. +3. Paste it into the **HuggingFace Token** field and save. + +The token is shared across Talemate. Once set here, any feature that needs to download gated weights (such as the [Pocket TTS](../agents/voice/pocket-tts.md) agent) will use it automatically. You can also set the same token directly in the Pocket TTS agent config — both fields point at the same setting. + +!!! note "Accepting model terms" + A token only proves who you are. For gated models you still need to visit the model's page on Hugging Face and accept its terms once with the same account before the download will succeed. \ No newline at end of file diff --git a/docs/user-guide/app-settings/creator.md b/docs/user-guide/app-settings/creator.md new file mode 100644 index 00000000..efb19b41 --- /dev/null +++ b/docs/user-guide/app-settings/creator.md @@ -0,0 +1,52 @@ +# :material-cube-scan: Creator + +The **Creator** tab in the application settings holds shared building blocks that are offered as picker options elsewhere in the UI. Open it from **Settings** (the cogwheel in the top navigation) and switch to the **Creator** tab. + +Two sub-tabs live here: + +- **Content Classification** — the list of content classification strings offered when you set up a scene. +- **Perspective Presets** — the list of narrative perspective / tense strings offered in the scene outline. + +Both lists are global to your Talemate installation. Editing them does not change any existing scene — your scenes keep whatever value they were saved with. The lists only affect what appears as suggestions in the pickers next time you edit a field. + +## Perspective Presets + +!!! info "Added in 0.38.0" + +The presets you add here show up in every **Perspective and tense** combobox in the scene outline (default plus the three per-speaker overrides). They are convenience suggestions, not constraints — you can always type a custom value into any of the four fields if no preset fits. + +![Creator settings with the Perspective Presets sub-tab selected](/talemate/img/0.38.0/app-settings-creator-perspective-presets.png) + +### Adding a preset + +1. Type the perspective string into the **Add perspective preset** text field at the bottom of the list. +2. Press **Enter** to add it. + +The new entry is appended to the list and becomes available immediately to every scene outline combobox. + +### Removing a preset + +Click the red :material-close-box-outline: icon next to a preset to remove it from the list. Removing a preset does not change scenes that already use that value — it just stops suggesting it. + +### The `{player_name}` placeholder + +Any preset that contains the literal string `{player_name}` will have that substring replaced by the active player character's name at prompt render time. This lets you share presets across scenes without having to rewrite them for each protagonist. + +For example, the preset: + +> First person, present tense, from {player_name}'s POV. + +will be rendered as `First person, present tense, from Annabelle's POV.` in a scene whose player character is named Annabelle, and as `First person, present tense, from Marcus's POV.` in a scene whose player character is named Marcus. + +If a scene has no explicit player character, perspectives that use `{player_name}` are **suppressed** rather than substituted — the placeholder has no anchor to point at. Use a non-placeholder preset for scenes without a player character. + +### Default presets + +Talemate ships with a starter list covering the most common configurations (third / first / second person, past and present tense, omniscient and player-anchored variants). For an explanation of what each default preset means in plain English with a sample of the prose style it produces, see the [Preset reference in the scene outline docs](../world-editor/scene/outline.md#what-each-default-preset-means). + +Treat these as a starting point — add your own house styles (for example a preset that pins POV to a specific named character, or one that includes a tone hint like *Third person limited, past tense, hard-boiled noir voice.*) so they appear in every new scene you create. + +## Related + +- [Scene Outline — Perspective and Tense](../world-editor/scene/outline.md#perspective-and-tense) — where the presets are consumed. +- [System Prompt Overrides](system-prompts.md) — for changing the AI's general role and approach (writing style and tense belong in the scene outline, not in a system prompt). diff --git a/docs/user-guide/app-settings/system-prompts.md b/docs/user-guide/app-settings/system-prompts.md index 210ef3f2..af65f5be 100644 --- a/docs/user-guide/app-settings/system-prompts.md +++ b/docs/user-guide/app-settings/system-prompts.md @@ -80,7 +80,7 @@ Never acknowledge that characters are fictional or written by an AI. Characters Talemate then sends the full built-in Conversation system prompt followed by your extra instruction. !!! note "What belongs in a system prompt" - System prompts shape the AI's role and general approach — things that should apply across every scene. Writing style, tense, and scene-specific tone live in the scene's [perspective field](../world-editor/scene/outline.md#perspective-and-tense) and writing-style settings, not here. + System prompts shape the AI's role and general approach — things that should apply across every scene. Writing style, tense, and scene-specific tone live in the scene's [perspective fields](../world-editor/scene/outline.md#perspective-and-tense) and writing-style settings, not here. Points to know: diff --git a/docs/user-guide/clients/concurrent-requests.md b/docs/user-guide/clients/concurrent-requests.md index a7d37c05..66ff94d7 100644 --- a/docs/user-guide/clients/concurrent-requests.md +++ b/docs/user-guide/clients/concurrent-requests.md @@ -17,7 +17,9 @@ Concurrent requests are available for the following hosted API clients: - [Google Gemini](/talemate/user-guide/clients/types/google/) - [OpenRouter](/talemate/user-guide/clients/types/openrouter/) -Local clients (KoboldCpp, llama.cpp, etc.) do not support this feature as they typically cannot handle concurrent inference requests. +It is also available for the local [llama.cpp](/talemate/user-guide/clients/types/llamacpp/) client. For llama.cpp, your `llama-server` must be started with enough parallel slots to handle more than one request at a time; otherwise the extra requests are simply queued and you will not see a speed-up. + +Other local clients (KoboldCpp, etc.) do not support this feature. ## How to Enable diff --git a/docs/user-guide/clients/presets.md b/docs/user-guide/clients/presets.md index 885f9f10..9f133ffb 100644 --- a/docs/user-guide/clients/presets.md +++ b/docs/user-guide/clients/presets.md @@ -10,7 +10,7 @@ If you wish to alter the inference parameters sent with the generation requests Navigate to the :material-tune: **Presets** tab then select the :material-matrix: **Inference** tab. -![selected preset](/talemate/img/0.30.0/inference-presets-1.png) +![selected preset](/talemate/img/0.38.0/inference-presets-1.png) !!! warning Not all clients support all parameters, and generally it is assumed that the client implementation handles the parameters in a sane way, especially if values are passed for all of them. All presets are used and will be selected depending on the action the agent is performing. If you don't know what these mean, it is recommended to leave them as they are. @@ -70,7 +70,7 @@ The inference preset editor provides access to the following generation paramete | Presence Penalty | 0 - 1.0 | Penalizes tokens that have already appeared in the generated text, encouraging discussion of new topics. | | Frequency Penalty | 0 - 1.0 | Penalizes tokens based on how frequently they appear, reducing word repetition. | | Repetition Penalty | 1.0 - 1.2 | Applies a multiplicative penalty to repeated tokens. | -| Repetition Penalty Range | 0 - 4096 | Number of tokens to look back when calculating repetition penalty. | +| Repetition Penalty Range | 0 - 8192 | Number of tokens to look back when calculating repetition penalty. | ### Advanced Parameters @@ -123,6 +123,24 @@ Different clients support different subsets of these parameters: - **Remote APIs** (OpenAI, Anthropic, etc.): Typically support temperature, top_p, and penalty parameters. Advanced parameters like XTC, DRY, and Adaptive-P are generally not available. - **Other local APIs**: Parameter support varies by implementation. +## Editing a preset + +Select a preset from the list on the left to load its parameters into the editor on the right. Adjust the sliders, tabs and checkbox to change the values for that preset. + +In the upper right of the editor there are two actions that apply to the preset you currently have selected: + +### Apply to all + +The **Apply to all** action copies the values of the currently selected preset to *every other preset in the same group*. This is useful when you have tuned one preset (for example `Conversation`) and want all the other presets to start from the same values. + +Because this overwrites the values of all the other presets in the group, you will be asked to confirm before it happens. + +### Reset + +The **Reset** action restores the currently selected preset back to its default values. As of version 0.38.0 this action also asks for confirmation before resetting, so an accidental click no longer discards your changes. + +Both actions only change the values in the editor. Remember to save the settings with the **Save** button at the bottom of the dialog for them to take effect. + ## Preset Groups Initially there is a `Default` group in which the presets are edited, but if you want you can create additional groups to create - for example - model / client specific presets. diff --git a/docs/user-guide/clients/reasoning.md b/docs/user-guide/clients/reasoning.md index 0e4e3b3c..dc781d27 100644 --- a/docs/user-guide/clients/reasoning.md +++ b/docs/user-guide/clients/reasoning.md @@ -66,6 +66,47 @@ When the configured reasoning pattern is not found in a response, you can contro - **Fail** (default) - Raises an error, causing the request to fail. Use this when you expect the model to always include reasoning tokens and want to be alerted if it doesn't. - **Ignore** - Returns the response as-is without stripping anything. Use this when the model may sometimes respond without reasoning tokens (e.g., for simple queries). +## Forcing Reasoning Off for Specific Actions + +The **Enable Reasoning** checkbox is a global setting for a client — when it's on, every prompt that client handles uses reasoning. Sometimes that isn't what you want. A reasoning model might do an excellent job writing dialogue but waste time (and tokens) "thinking" before simple, mechanical tasks like summarization or world-state updates. + +Per-action reasoning overrides let you turn reasoning **off for individual agent actions** without touching the client's global setting. The client keeps reasoning on everywhere else; only the actions you single out skip it. + +### Adding an Override + +Overrides are created from a prompt's detail view, which you can reach two ways: + +- **Prompt Manager** (recommended): Open the **Prompts** tab from the main navigation (the :material-file-code-outline: icon). In the sidebar, click a recent prompt under the **Prompts** list to open it. +- **Debug Tools**: Open the **Debug Tools** panel (the :material-bug: icon in the top toolbar) and go to the **Prompts** tab, then click a prompt. + +Both routes open the same prompt detail view. Once it's open: + +1. Find the prompt's header, which shows which agent and action produced it (for example **summarizer** with an **action** chip). +2. Click the :material-brain: brain icon next to the action. It turns red to show reasoning is now forced off for that action. + +From now on, every time that same agent action runs, Talemate tells the client to skip reasoning for that single call. + +To remove an override, click the red brain icon again. + +### Managing All Overrides + +Once you have at least one override active, a counter button appears next to the **Agents** heading in the right-hand agent list. Click it to open the **Agent action overrides** dialog, where you can: + +- See every action that currently has an override, grouped by agent. +- Toggle **Disable reasoning** on or off for each one. +- Remove an override entirely with the :material-close: button. + +![Agent action overrides dialog](/talemate/img/0.38.0/agent-action-overrides-dialog.png) + +### How It Relates to the Global Setting + +- Overrides only ever **turn reasoning off**. They cannot turn reasoning on for a client that has it disabled. +- They are saved with your application configuration and persist across restarts. They apply to whichever client happens to run that action. +- If a model **always** reasons and cannot be told to stop (for example certain Gemini and OpenAI reasoning models), the override is ignored for that client — those models reason no matter what. + +!!! note "Test Changes ignores overrides" + The **Test Changes** button in the prompt detail view always runs with the client's default reasoning setting, so you can compare a one-off result against your normal configuration. The per-action override is not applied during a test run. + ## Model Compatibility Not all models support reasoning. This feature works best with: diff --git a/docs/user-guide/clients/types/llamacpp.md b/docs/user-guide/clients/types/llamacpp.md index 68cbe05f..5d6b76aa 100644 --- a/docs/user-guide/clients/types/llamacpp.md +++ b/docs/user-guide/clients/types/llamacpp.md @@ -8,7 +8,7 @@ The llama.cpp client connects to the `llama-server` executable from the llama.cp If you want to add a llama.cpp client, change the `Client Type` to `llama.cpp`. -![Client llama.cpp](/talemate/img/0.35.0/client-llamacpp.png) +![Client llama.cpp](/talemate/img/0.38.0/client-llamacpp.png) !!! note "Should work out of the box with a local llama.cpp server" The default values should work with a local llama.cpp server if you are running `llama-server` on the default port (8080). @@ -41,6 +41,12 @@ If your llama.cpp server is configured to require authentication, you can set th The number of tokens to use as context when generating text. Defaults to `8192`. +##### Concurrent Inference + +Found under the **Concurrency** tab in the client settings. When enabled, batch operations that need several queries (currently visual prompt generation for image generation) can send multiple requests to your `llama-server` in parallel instead of one at a time, which can speed those operations up. + +This is **off by default**. Your `llama-server` must be started with enough parallel slots to actually serve requests at the same time; otherwise the requests are queued and you will not see a speed-up. See the [Concurrent Requests](/talemate/user-guide/clients/concurrent-requests/) page for more detail. + ### Common issues #### Generations are weird / bad diff --git a/docs/user-guide/clients/types/openai-compatible.md b/docs/user-guide/clients/types/openai-compatible.md index 292412ff..bdaa9a30 100644 --- a/docs/user-guide/clients/types/openai-compatible.md +++ b/docs/user-guide/clients/types/openai-compatible.md @@ -58,6 +58,18 @@ mistralai/Mixtral-8x22B-Instruct-v0.1 The number of tokens to use as context when generating text. Defaults to `8192`. +### Parameters + +The **Parameters** tab in the client settings lets you control which sampler parameters are sent with each request. Some OpenAI-compatible services reject requests that include parameters they don't support for the selected model, returning an error instead of a generation. If you run into this, you can turn the offending parameter off so it is left out of the request entirely. + +Each parameter has its own toggle: + +- **Send temperature** +- **Send top_p** +- **Send presence_penalty** + +All three are enabled by default. Turning a toggle off omits that parameter from the request completely (it is not sent as a zero value). Leave them enabled unless the service you are connecting to errors when it receives one of them. + ### Ready to use Click `Save` to add the client. diff --git a/docs/user-guide/debug-tools.md b/docs/user-guide/debug-tools.md index 56aee7bb..62ed2857 100644 --- a/docs/user-guide/debug-tools.md +++ b/docs/user-guide/debug-tools.md @@ -14,6 +14,8 @@ The Prompts tab shows a log of all AI prompts that have been sent during your se For headless captures or longer-running debug sessions, the same prompt records can also be streamed to a file — see [Prompt Logging](../getting-started/advanced/prompt-logging.md). +The prompt detail view also lets you force reasoning off for the specific agent action that produced a prompt, using the :material-brain: brain icon next to the action — see [Forcing Reasoning Off for Specific Actions](clients/reasoning.md#forcing-reasoning-off-for-specific-actions). + ### :material-memory: Memory The Memory tab displays memory retrieval requests made by the Memory Agent. Use this to verify that relevant context is being retrieved during generation. diff --git a/docs/user-guide/howto/create-a-new-scene/create-empty-scene.md b/docs/user-guide/howto/create-a-new-scene/create-empty-scene.md index 34924d54..ce438ff0 100644 --- a/docs/user-guide/howto/create-a-new-scene/create-empty-scene.md +++ b/docs/user-guide/howto/create-a-new-scene/create-empty-scene.md @@ -8,7 +8,7 @@ After a moment of loading you will be taken to the new scene and `creative` mode ## Create the outline for the scene ---8<-- "docs/talemate/user-guide/world-editor/scene/outline.md:outline" +--8<-- "docs/user-guide/world-editor/scene/outline.md:outline" ## Save the scene diff --git a/docs/user-guide/howto/infinity-quest-dynamic/1-create-the-scene-project.md b/docs/user-guide/howto/infinity-quest-dynamic/1-create-the-scene-project.md index 925d5f1c..1a89f31d 100644 --- a/docs/user-guide/howto/infinity-quest-dynamic/1-create-the-scene-project.md +++ b/docs/user-guide/howto/infinity-quest-dynamic/1-create-the-scene-project.md @@ -10,13 +10,13 @@ Click it and wait a moment for the blank scene to load. Click the **:material-earth-box: World Editor** tab. -Fill in `Title`, `Description` and `Content Context` +Fill in `Title`, `Description` and `Content Classification` --- **Title**: `Infinity Quest Dynamic` -**Content Context**: `an epic sci-fi adventure` +**Content Classification**: `an epic sci-fi adventure` **Description**: ``` diff --git a/docs/user-guide/inline-visuals.md b/docs/user-guide/inline-visuals.md index 2ed28e63..b85b5f9f 100644 --- a/docs/user-guide/inline-visuals.md +++ b/docs/user-guide/inline-visuals.md @@ -54,6 +54,36 @@ For more information on using the Director chat, see the [Director Chat document When using the autonomous scene direction feature, the Director can automatically generate images at appropriate moments during scene progression. These images appear in the scene feed just like manually generated ones. +### Context Investigation Messages + +Context investigation messages are the short, descriptive results that appear in your scene feed when you take a closer look at something. They are produced by actions such as: + +- **Look at Scene** — a visually focused description of the current moment (from the scene tools narrator menu) +- **Look at (Character)** — a description of a character's appearance and current action (from the scene tools narrator menu or the [World State](world-state.md) sidebar) +- **Add Detail** — an expanded "closer look" at an entity (character, place, or item) you have selected in the scene + +When you hover over one of these messages, a **Visualize** action appears in the message toolbar. Clicking it generates an image directly into that message, matched to whatever the investigation was about. + +#### How the image is matched to the subject + +Talemate looks at what the investigation was about and chooses the most fitting image type automatically. You don't need to pick one yourself: + +| Investigation | Image generated | +|---------------|-----------------| +| Look at Scene | Scene illustration of the current moment | +| Look at (Character) | Character card portrait of that character | +| Add Detail on a character in the scene | Character card portrait of that character | +| Add Detail on a character not in the scene | Scene illustration | +| Add Detail on a place | Scene background | +| Add Detail on an item | Object illustration | + +The text of the investigation message is used to guide the generated image, so the result reflects the specific details described in that message. + +!!! note "Query results are not visualizable" + A plain **Query** (asking the narrator a free-form question) does not show a Visualize action, because there is no single visual subject to match it to. + +Once an image has been generated, the **Visualize** action disappears for that message. From that point on you can regenerate, replace, or remove the image using the [inline visual actions](#viewing-and-managing) described below, just like any other attached image. + ## Interacting with Inline Visuals Once an image appears in your scene feed, you can interact with it in several ways. diff --git a/docs/user-guide/interacting.md b/docs/user-guide/interacting.md index ba88fdee..f1cb3684 100644 --- a/docs/user-guide/interacting.md +++ b/docs/user-guide/interacting.md @@ -82,6 +82,42 @@ You can turn this off by disabling the auto progress setting, either in the game A set of tools to help you interact with the scenario. Find out more about the various actions in the [Scene Tools](/talemate/user-guide/scenario-tools) section of the user guide. +## Message revision history + +!!! info "New in 0.38.0" + +Character and narrator messages (and context investigation results) keep a history of their previous versions. When a message has more than one version, a small paginator appears just above the message body so you can browse through them. + +![Message revision paginator](/talemate/img/0.38.0/message-revision-paginator.png) + +The paginator shows: + +- **Left / right arrows** to step between versions. +- A **counter** (for example `2/3`) telling you which version you are viewing and how many exist in total. +- A **tag** describing where the version you are currently looking at came from: + - **Original** -- the message as it was first generated. + - **Regenerated** -- a version produced by regenerating the message. + - **Revised** -- a version that the [Editor agent](/talemate/user-guide/agents/editor) automatically rewrote. + - **Continued** -- a version created by continuing the message (see below). + +The paginator only appears on the **most recent** message, and only once that message has more than one version. + +!!! note "The version you view is the one that sticks" + Whichever version you have selected in the paginator becomes the message's active text. That is the version saved with the scene, and the version the AI builds on when it continues the scene. Browsing to an earlier version and leaving it selected effectively rolls the message back to that version. + +### How new versions are created + +You don't add versions manually -- they accumulate as you work with a message: + +- **Regenerating** the most recent AI message (see [Regenerate AI response](/talemate/user-guide/scenario-tools#material-refresh-regenerate-ai-response)) keeps the message in place and adds the new result as a version instead of replacing the old text. The previous version is still there to navigate back to. If the Editor agent automatically revises the regenerated text, both the raw **Regenerated** version and the **Revised** version are kept so you can pick either one. +- **Continuing** a message adds a **Continued** version that contains the original text plus the newly generated continuation. The version you continued from stays available, so you can step back if you don't like how the continuation turned out. + +### Continuing a message + +The most recent character or narrator message offers a **:material-fast-forward: Continue** action on its hover toolbar. + +Clicking **Continue** generates more text and appends it to the message, recording the result as a new **Continued** version in the revision history. Narrator messages support the Continue action as well as character messages. + ## Cancel Generation Sometimes Talemate will be generating a response (or go through a chain of generations) and you want to cancel it. You can do this by hitting the **:material-stop-circle-outline:** button that will appear in the scene tools bar. diff --git a/docs/user-guide/node-editor/core-concepts/events.md b/docs/user-guide/node-editor/core-concepts/events.md index 9d213517..770b5324 100644 --- a/docs/user-guide/node-editor/core-concepts/events.md +++ b/docs/user-guide/node-editor/core-concepts/events.md @@ -15,10 +15,34 @@ There currently are no typed event emission nodes, so you will need to use the ` You can find the various event payloads in the [reference](../reference/events.md). -Once your event module is saved, you enable it by adding its node to the scene loop of your scene. +## Activating an event module + +There are two ways to make an event module start listening for its event. + +### Add it to the scene loop + +Once your event module is saved, add its node to the scene loop of your scene. Once added to the scene loop you must also specify the `event_name`. +This places the module on the scene loop graph, where you can see it, wire additional inputs to it, and disable it again by removing it. + +### Auto-register + +Instead of placing the module on the scene loop graph, you can have it subscribe to its event automatically as soon as it is registered with the scene. + +To do this, open the module's **Properties** panel and: + +- Set `event_name` to the event you want to listen for. +- Enable `auto_register`. + +Both values are saved on the module definition itself, so they must be set inside the module (not on a placed instance). When the scene loads, every registered event module with `auto_register` enabled and a non-empty `event_name` is connected to the event bus automatically — you do **not** need to add it to the scene loop. + +!!! note "Auto-register requires a saved `event_name`" + Because auto-registration reads the persisted properties of the module, an auto-registered module that has no `event_name` set is skipped. Make sure both `auto_register` and `event_name` are set and the module is saved. + +This works the same way [command modules](command_module.md) are loaded automatically when a scene loads, and is the recommended approach for event modules that should always be active and that do not need any inputs wired in from the scene loop graph. + ## Practical Example diff --git a/docs/user-guide/node-editor/core-concepts/user-interface.md b/docs/user-guide/node-editor/core-concepts/user-interface.md index f36d71ed..c467eafc 100644 --- a/docs/user-guide/node-editor/core-concepts/user-interface.md +++ b/docs/user-guide/node-editor/core-concepts/user-interface.md @@ -108,6 +108,8 @@ The canvas is the main area where you can add, connect and edit nodes. Certain nodes can log messages to the log watcher. It is located in the upper right corner of the editor. +Each entry shows a right-aligned `HH:MM:SS.mmm` timestamp, making it easier to correlate log entries with scene activity. + ![Node Editor Log Watcher](../img/user-interface-0008.png) ### Toolbar diff --git a/docs/user-guide/node-editor/img/user-interface-0008.png b/docs/user-guide/node-editor/img/user-interface-0008.png index 0920099f..06d11703 100644 Binary files a/docs/user-guide/node-editor/img/user-interface-0008.png and b/docs/user-guide/node-editor/img/user-interface-0008.png differ diff --git a/docs/user-guide/scenario-tools.md b/docs/user-guide/scenario-tools.md index 2948377e..7b5852a9 100644 --- a/docs/user-guide/scenario-tools.md +++ b/docs/user-guide/scenario-tools.md @@ -37,6 +37,9 @@ This will regenerate the most recent message, if it is an AI generated message. If you hold `ctrl+alt` when clicking this button you will be prompted to supply some instructions for the regeneration while keeping the original message as context. This is useful for rewrites. +!!! info "Previous versions are kept" + Regenerating doesn't throw away the old message -- each result is added to the message's revision history, which you can browse with the paginator above the message. See [Message revision history](/talemate/user-guide/interacting#message-revision-history). + #### :material-nuke: Regenerate AI Response (nuke option) This will regenerate the most recent message, if it is an AI generated message, but with a higher temperature and repetition penalties applied, which can lead to more creative responses. Use this to break out of repetitive loops. @@ -194,7 +197,7 @@ Allows you to quickly set up tracked character and world states. !!! info "What is a tracked state?" - --8<-- "docs/talemate/user-guide/tracking-a-state.md:what-is-a-tracked-state" + --8<-- "docs/user-guide/tracking-a-state.md:what-is-a-tracked-state" Please refer to the [World State](/talemate/user-guide/world-state) section for more information on how set up custom states to track. @@ -283,7 +286,7 @@ Write direction on how you want the character to enter the scene. ##### Templates -Control attribute generation using your existing [World State Templates](/talemate/user-guide/world-editor/templates). +Control attribute generation using your existing [World State Templates](/talemate/user-guide/templates). ![Select templates](/talemate/img/0.30.0/scene-tool-intro-char-templates.png) diff --git a/docs/user-guide/scene-directory.md b/docs/user-guide/scene-directory.md index e58516fc..ccebc99e 100644 --- a/docs/user-guide/scene-directory.md +++ b/docs/user-guide/scene-directory.md @@ -49,7 +49,7 @@ Stores [node editor](/talemate/user-guide/node-editor/) graphs for the scene, su ### `templates/` -Scene-specific [templates](/talemate/user-guide/world-editor/templates/writing-style/) that override or extend the default templates. These are organized into `scene/`, `common/`, and agent-specific subdirectories. +Scene-specific [templates](/talemate/user-guide/templates/writing-style/) that override or extend the default templates. These are organized into `scene/`, `common/`, and agent-specific subdirectories. ### `shared-context/` diff --git a/docs/user-guide/world-editor/templates/.pages b/docs/user-guide/templates/.pages similarity index 100% rename from docs/user-guide/world-editor/templates/.pages rename to docs/user-guide/templates/.pages diff --git a/docs/user-guide/world-editor/templates/attribute.md b/docs/user-guide/templates/attribute.md similarity index 98% rename from docs/user-guide/world-editor/templates/attribute.md rename to docs/user-guide/templates/attribute.md index 80a82915..4debdc5f 100644 --- a/docs/user-guide/world-editor/templates/attribute.md +++ b/docs/user-guide/templates/attribute.md @@ -6,7 +6,7 @@ They are used in the [Character Editor - Attributes](/talemate/user-guide/world- ## Creating a character attribute template -Fist, if you have not done so, [create a template group](/talemate/user-guide/world-editor/templates/groups) to store the template in. +Fist, if you have not done so, [create a template group](/talemate/user-guide/templates/groups) to store the template in. Then select the group you want to add the template to and click the **:material-plus: Create Template** button. diff --git a/docs/user-guide/world-editor/templates/detail.md b/docs/user-guide/templates/detail.md similarity index 98% rename from docs/user-guide/world-editor/templates/detail.md rename to docs/user-guide/templates/detail.md index f13325f6..356dca0a 100644 --- a/docs/user-guide/world-editor/templates/detail.md +++ b/docs/user-guide/templates/detail.md @@ -6,7 +6,7 @@ They are used in the [Character Editor - Details](/talemate/user-guide/world-edi ## Creating a character detail template -Fist, if you have not done so, [create a template group](/talemate/user-guide/world-editor/templates/groups) to store the template in. +Fist, if you have not done so, [create a template group](/talemate/user-guide/templates/groups) to store the template in. Then select the group you want to add the template to and click the **:material-plus: Create Template** button. diff --git a/docs/user-guide/world-editor/templates/groups.md b/docs/user-guide/templates/groups.md similarity index 100% rename from docs/user-guide/world-editor/templates/groups.md rename to docs/user-guide/templates/groups.md diff --git a/docs/user-guide/world-editor/templates/index.md b/docs/user-guide/templates/index.md similarity index 94% rename from docs/user-guide/world-editor/templates/index.md rename to docs/user-guide/templates/index.md index f5eb5cee..d7e7eb05 100644 --- a/docs/user-guide/world-editor/templates/index.md +++ b/docs/user-guide/templates/index.md @@ -1,6 +1,6 @@ -# World Editor Templates +# Templates -Open the **:material-earth-box: World Editor** and select the **:material-cube-scan: Templates** tab to manage templates. +Click the **:material-cube-scan: Templates** tab in the top navigation bar to manage templates. These templates are used to facilitate the generation of content for your game. They can be used to define character attributes, character details, writing styles, and automated world or character state tracking. diff --git a/docs/user-guide/world-editor/templates/scene_type.md b/docs/user-guide/templates/scene_type.md similarity index 100% rename from docs/user-guide/world-editor/templates/scene_type.md rename to docs/user-guide/templates/scene_type.md diff --git a/docs/user-guide/world-editor/templates/spice.md b/docs/user-guide/templates/spice.md similarity index 98% rename from docs/user-guide/world-editor/templates/spice.md rename to docs/user-guide/templates/spice.md index e9a0f94c..538ad108 100644 --- a/docs/user-guide/world-editor/templates/spice.md +++ b/docs/user-guide/templates/spice.md @@ -4,7 +4,7 @@ Spice collections are used to define a set of instructions that can be applied d ## Creating a spice collection -Fist, if you have not done so, [create a template group](/talemate/user-guide/world-editor/templates/groups) to store the template in. +Fist, if you have not done so, [create a template group](/talemate/user-guide/templates/groups) to store the template in. Then select the group you want to add the template to and click the **:material-plus: Create Template** button. diff --git a/docs/user-guide/world-editor/templates/state.md b/docs/user-guide/templates/state.md similarity index 96% rename from docs/user-guide/world-editor/templates/state.md rename to docs/user-guide/templates/state.md index ad2651a5..dda2663b 100644 --- a/docs/user-guide/world-editor/templates/state.md +++ b/docs/user-guide/templates/state.md @@ -7,7 +7,7 @@ They are used in the [Character Editor - Tracked States](/talemate/user-guide/wo ## Creating a state reinforcement template -Fist, if you have not done so, [create a template group](/talemate/user-guide/world-editor/templates/groups) to store the template in. +Fist, if you have not done so, [create a template group](/talemate/user-guide/templates/groups) to store the template in. Then select the group you want to add the template to and click the **:material-plus: Create Template** button. @@ -61,7 +61,7 @@ Specifies what type of object the state is attached to, currently this can be ### Context Attachment Method ---8<-- "docs/talemate/user-guide/tracking-a-state.md:context-attachment-method" +--8<-- "docs/user-guide/tracking-a-state.md:context-attachment-method" ### Update every `N` turns diff --git a/docs/user-guide/world-editor/templates/writing-style.md b/docs/user-guide/templates/writing-style.md similarity index 100% rename from docs/user-guide/world-editor/templates/writing-style.md rename to docs/user-guide/templates/writing-style.md diff --git a/docs/user-guide/tracking-a-state.md b/docs/user-guide/tracking-a-state.md index 4524a331..6d475b18 100644 --- a/docs/user-guide/tracking-a-state.md +++ b/docs/user-guide/tracking-a-state.md @@ -37,9 +37,9 @@ Then follow the instructions at [Tracking a world state](/talemate/user-guide/wo ## Quickly apply often tracked states via templates ---8<-- "docs/talemate/user-guide/scenario-tools.md:tools-ux" +--8<-- "docs/user-guide/scenario-tools.md:tools-ux" ---8<-- "docs/talemate/user-guide/scenario-tools.md:quick-apply-favorite-state" +--8<-- "docs/user-guide/scenario-tools.md:quick-apply-favorite-state" ## Context Attachment Method diff --git a/docs/user-guide/world-editor/characters/.pages b/docs/user-guide/world-editor/characters/.pages index 9f5ea211..9da18311 100644 --- a/docs/user-guide/world-editor/characters/.pages +++ b/docs/user-guide/world-editor/characters/.pages @@ -9,6 +9,7 @@ nav: - Details: details.md - Folders: folders.md - Import a character: import.md + - Player character: player.md - Tracked states: states.md - Visuals: visuals.md - ... \ No newline at end of file diff --git a/docs/user-guide/world-editor/characters/actor.md b/docs/user-guide/world-editor/characters/actor.md index 2d5dff7f..44a7fe51 100644 --- a/docs/user-guide/world-editor/characters/actor.md +++ b/docs/user-guide/world-editor/characters/actor.md @@ -37,4 +37,10 @@ Here it can be especially helpful to run the generation with instructions. To do A small window will pop up where you can provide the instructions for the AI to follow. -![World editor characters generate dialogue example](/talemate/img/0.26.0/world-editor-characters-actor-generate-dialogue-example.png) \ No newline at end of file +![World editor characters generate dialogue example](/talemate/img/0.26.0/world-editor-characters-actor-generate-dialogue-example.png) + +#### Autocomplete an example + +The **Add Dialogue Example** field also supports autocomplete. Start typing an example, then press `ctrl+Enter` (or `cmd+Enter` on macOS) to have the AI continue it for you. This field supports the same `{...}` hints and Redo/Undo actions as other generated fields. + +See [Autocomplete](/talemate/user-guide/agents/creator/autocomplete) for more information. \ No newline at end of file diff --git a/docs/user-guide/world-editor/characters/attributes.md b/docs/user-guide/world-editor/characters/attributes.md index bced68ab..762d4404 100644 --- a/docs/user-guide/world-editor/characters/attributes.md +++ b/docs/user-guide/world-editor/characters/attributes.md @@ -37,7 +37,7 @@ You will be asked to confirm the removal. Press the **:material-close-box-outlin ## Generating attributes using templates -You can use [templates](/talemate/user-guide/world-editor/templates/attribute) to quickly generate attributes for your characters. +You can use [templates](/talemate/user-guide/templates/attribute) to quickly generate attributes for your characters. In the **Attributes** tab, click the **:material-cube-scan: Templates** button above the attribute list to expand the templates list. diff --git a/docs/user-guide/world-editor/characters/details.md b/docs/user-guide/world-editor/characters/details.md index abc1a5f4..d6f0739a 100644 --- a/docs/user-guide/world-editor/characters/details.md +++ b/docs/user-guide/world-editor/characters/details.md @@ -63,7 +63,7 @@ If a pin already exists for the selected detail, the button will display :materi ## Generating details using templates -You can use [templates](/talemate/user-guide/world-editor/templates/detail) to quickly generate details for your characters. +You can use [templates](/talemate/user-guide/templates/detail) to quickly generate details for your characters. In the **Details** tab, click the **:material-cube-scan: Templates** button above the detail list to expand the templates list. diff --git a/docs/user-guide/world-editor/characters/index.md b/docs/user-guide/world-editor/characters/index.md index 722c27cd..7b8d396b 100644 --- a/docs/user-guide/world-editor/characters/index.md +++ b/docs/user-guide/world-editor/characters/index.md @@ -6,6 +6,8 @@ You can create and remove characters, manage their details, and track their stat Characters can also be temporarily disabled, which will prevent them from being included in the scene' dialogue generation. (e.g., they are out of the scene for a moment.) +You can also change which character is [controlled by the player](/talemate/user-guide/world-editor/characters/player) at any time. + The character list can optionally be organized into collapsible [folders](/talemate/user-guide/world-editor/characters/folders). ## Character Components @@ -28,7 +30,7 @@ When a **:material-image-auto-adjust: state reinforcment** is updated the value ### :material-image-auto-adjust: [Tracked states](/talemate/user-guide/world-editor/characters/states) ---8<-- "docs/talemate/user-guide/tracking-a-state.md:what-is-a-tracked-state" +--8<-- "docs/user-guide/tracking-a-state.md:what-is-a-tracked-state" ### :material-bullhorn: [Actor management](/talemate/user-guide/world-editor/characters/actor) diff --git a/docs/user-guide/world-editor/characters/player.md b/docs/user-guide/world-editor/characters/player.md new file mode 100644 index 00000000..779dd736 --- /dev/null +++ b/docs/user-guide/world-editor/characters/player.md @@ -0,0 +1,34 @@ +# Make or Unmark a Player Character + +Every scene can have one character that is controlled by you, the player. All other characters are controlled by the AI. From the character editor you can change which character is the player at any time, without having to recreate anyone. + +A character's current role is shown next to their name in the character list: + +- A :material-label: **Player** chip marks the character you are controlling. +- A :material-label: **AI** chip marks a character controlled by the AI. + +## Make a character the player + +To promote a character to the player, first select the character on the left hand side in the :material-earth-box: **World Editor** under the :material-account-group: **Characters** tab. + +Then click on the :material-account-star: **Make Player Character** button, beneath the character's image. + +This does two things at once: + +- The selected character becomes the player character. +- If another character was already the player, they are demoted to an AI actor. They stay in the scene and simply continue as an AI controlled character. + +If the character you promote is currently [deactivated](/talemate/user-guide/world-editor/characters/deactivate), they are automatically activated and brought into the scene as the player. + +Only one character can be the player at a time, so there is never any need to unmark the previous player yourself. + +## Unmark the player character + +To turn the current player into an AI controlled character, select that character under the :material-account-group: **Characters** tab. + +Then click on the :material-account-off-outline: **Unmark as Player** button, beneath the character's image. + +The character remains in the scene and keeps all of their details; they are simply handed over to the AI. After this, the scene has no player character until you promote one. + +!!! note + Whether a character is the player can also be set when you first [create a character](/talemate/user-guide/world-editor/characters/create) using the **Controlled by Player** switch. diff --git a/docs/user-guide/world-editor/characters/states.md b/docs/user-guide/world-editor/characters/states.md index 5a2376a2..fe758d7f 100644 --- a/docs/user-guide/world-editor/characters/states.md +++ b/docs/user-guide/world-editor/characters/states.md @@ -2,7 +2,7 @@ !!! info "What is a tracked state?" - --8<-- "docs/talemate/user-guide/tracking-a-state.md:what-is-a-tracked-state" + --8<-- "docs/user-guide/tracking-a-state.md:what-is-a-tracked-state" ## Setting up a new state In the :material-account-group: **Character** editor select the character you want to add a new state to. @@ -43,7 +43,7 @@ By default it will be `10` turns, but you can change this to any number you like ### Context Attachment Method ---8<-- "docs/talemate/user-guide/tracking-a-state.md:context-attachment-method" +--8<-- "docs/user-guide/tracking-a-state.md:context-attachment-method" ### Additional Instructions diff --git a/docs/user-guide/world-editor/generation-settings.md b/docs/user-guide/world-editor/generation-settings.md index c4ebbd59..b05ff008 100644 --- a/docs/user-guide/world-editor/generation-settings.md +++ b/docs/user-guide/world-editor/generation-settings.md @@ -6,6 +6,23 @@ On top of the world editor interface you can configure some settings for this ty ![world editor generation settings 1](/talemate/img/0.26.0/world-editor-generation-settings-1.png) +## Generation instructions + +Most generated fields offer a **:material-auto-fix: Generate** button above the input. Clicking it simply lets the AI fill in the field based on the surrounding context. + +If you want to steer the result, hold `ctrl` (or `cmd` on macOS) while clicking the **:material-auto-fix: Generate** button. This opens a dialog with an **Instructions** field where you can describe what the generated content should contain. + +Whatever you type here is passed to the AI as your own instruction and seed material that **must** be reflected in the result. In other words, the generated content is built around what you ask for rather than treating it as an optional suggestion, so your instructions are followed reliably. + +A few things to keep in mind: + +- Instructions can be a direction (for example, *"focus on her childhood in the city"*) or a seed you want expanded (for example, *"she lost her sister in the war"*). +- For **history entries**, your instruction is treated as the seed for the entry. The AI writes the entry about the event or state you describe, expanding it with consistent detail, instead of generating something unrelated. +- Instructions apply to a single generation. They are not saved with the field. + +!!! tip "Rewriting existing content" + Holding `alt` while clicking **:material-auto-fix: Generate** opens the dialog with the field's current content shown as the original, so the AI rewrites what is already there rather than starting from scratch. You can combine `ctrl` and `alt` to rewrite with instructions. + ## Generation length When using the **:material-auto-fix: Generate** button with additional instructions (hold `ctrl` when clicking), a dialog opens that includes a **Generation Length** dropdown. This allows you to control how long the generated content should be, measured in tokens. @@ -53,7 +70,7 @@ The remembered lengths include: Allows you to apply a writing style to AI generated content. -Writing styles are managed through [:material-script-text: writing style templates](/talemate/user-guide/world-editor/templates/writing-style). +Writing styles are managed through [:material-script-text: writing style templates](/talemate/user-guide/templates/writing-style). Once there is a writing style template available, you can select it from the dropdown list. @@ -63,7 +80,7 @@ Once there is a writing style template available, you can select it from the dro Spice is a way to add some controlled randomness to AI generated content. -Spices are managed through [:material-chili-mild: spice collection templates](/talemate/user-guide/world-editor/templates/spice). +Spices are managed through [:material-chili-mild: spice collection templates](/talemate/user-guide/templates/spice). Once there is a spice template available, you can select it from the dropdown list. diff --git a/docs/user-guide/world-editor/history/index.md b/docs/user-guide/world-editor/history/index.md index 3d06a033..3741020f 100644 --- a/docs/user-guide/world-editor/history/index.md +++ b/docs/user-guide/world-editor/history/index.md @@ -30,6 +30,8 @@ Static entries can be added by clicking the **:material-plus: Add Entry** button The text of the entry. Should be at most 1 - 2 paragraphs. Less is more. Anything that needs great detail should be a world entry instead. +You can let the AI write the entry for you with the **:material-auto-fix: Generate** button. Hold `ctrl` (or `cmd` on macOS) while clicking to provide [instructions](/talemate/user-guide/world-editor/generation-settings/#generation-instructions). For history entries your instruction is treated as the *seed* for the entry: the AI writes the entry about the event or state you describe and expands it with consistent detail, rather than ignoring it. + ##### Unit Defines the duration unit of the entry. So minutes, hours, days, weeks, months or years. @@ -40,7 +42,7 @@ Defines the duration unit amount of the entry. So if you want to define something that happened 10 months ago (from the current moment in the scene), you would set the unit to months and the amount to 10. -![Add Static Entry](/talemate/img/0.31.0/history-add-entry.png) +![Add Static Entry](/talemate/img/0.38.0/history-add-entry.png) ## Time Passage Entries diff --git a/docs/user-guide/world-editor/scene/direction.md b/docs/user-guide/world-editor/scene/direction.md index 555ddc93..31bc7b8c 100644 --- a/docs/user-guide/world-editor/scene/direction.md +++ b/docs/user-guide/world-editor/scene/direction.md @@ -46,7 +46,7 @@ This allows to differentiate between, for example `roleplay` or `combat` scenes, !!! warning "Very early WIP" This is a very unfinished feature, so don't expect that you can plugin in some D&D style turn based combat rules into a scene type and have it work. Right now it will mostly affect the type of text that is generated and the pacing of the scene. -Re-usable scene types can be created in the [Template Editor](/talemate/user-guide/world-editor/templates) +Re-usable scene types can be created in the [Template Editor](/talemate/user-guide/templates) #### Current Scene Intention diff --git a/docs/user-guide/world-editor/scene/outline.md b/docs/user-guide/world-editor/scene/outline.md index b647a1d0..5258d853 100644 --- a/docs/user-guide/world-editor/scene/outline.md +++ b/docs/user-guide/world-editor/scene/outline.md @@ -6,7 +6,7 @@ Click on the :material-earth-box: **World Editor** to open the world editor. In the world editor the :material-script: **Scene** tab should be selected, if not, click on it. -![World editor scene outline 1](/talemate/img/0.26.0/world-editor-scene-outline-1.png) +![World editor scene outline 1](/talemate/img/0.38.0/world-editor-scene-outline-1.png) ### Title @@ -16,9 +16,9 @@ The scene title as it will be shown to the user. Currently this should be `New S > The Forgotten House -### Content Context +### Content Classification -The `Content Context` is used to set the tone and expectation of the generated content, it can strongly influence the AI's responses. +The `Content Classification` is used to set the tone and expectation of the generated content, it can strongly influence the AI's responses. You can type in a value or pick something from the list. @@ -31,24 +31,125 @@ You can type in a value or pick something from the list. ### Perspective and Tense -The narrative perspective and tense for the story (for example `Third person limited, past tense` or `Second person, present tense`). +!!! info "Updated in 0.38.0" + The single perspective field was replaced by a set of four configurable perspectives: a scene-wide default plus three per-speaker overrides (player, NPCs, narrator). The `{player_name}` placeholder was also added. Scenes saved with the older field are migrated automatically — their value becomes the new **default**. -When set, the value is included in the context sent to the AI for narration, dialogue, and autocomplete prompts, so the model knows which point of view and tense to write in. +The narrative perspective and tense for the story — for example `Third person limited, past tense` or `Second person, present tense`. When set, the value is injected into the context the AI sees for narration, dialogue, and autocomplete prompts, so the model knows which point of view and tense to write in. -The field is free-form text. Leave it blank if you don't need to specify one — prompts will simply omit the line. +A perspective usually combines three things: -![Scene outline perspective and tense field](/talemate/img/0.37.0/scene-outline-perspective-field.png) +- **Person** — *who* the prose talks about and *how* it refers to them. + - **First person** uses "I"/"me"/"my" — the narrator *is* the character. *"I walked up to the door."* + - **Second person** uses "you" — the narrator speaks *to* the reader as if they are the character. *"You walk up to the door."* Common in interactive fiction and choose-your-own-adventure styles. + - **Third person** uses "he"/"she"/"they" — the narrator describes the characters from outside. *"She walked up to the door."* +- **Tense** — *when* the action is happening relative to the telling. + - **Past tense** reads like a recollection: "walked", "said", "thought". *"She walked up to the door and knocked."* + - **Present tense** reads like a live broadcast: "walks", "says", "thinks". *"She walks up to the door and knocks."* +- **Point of view (POV)** — *how much* the narrator knows. + - **Limited** — the narrator is tethered to one character's head and only knows what *they* know. Other characters' thoughts and off-screen events are off-limits. Usually anchored to a named character (often the player). + - **Omniscient** — the narrator floats above the scene and can dip into anyone's thoughts, describe off-screen events, or foreshadow what's coming. -##### Examples +![Scene outline perspective fields with the override panel expanded](/talemate/img/0.38.0/scene-outline-perspective-overrides.png) -> Third person limited, past tense +Talemate exposes the perspective configuration as four separate fields: -> Second person, present tense +| Field | When it's used | +|---|---| +| **Perspective and tense** (default) | Used everywhere the scene needs perspective context, and as the fallback for any of the three role-specific slots below when they are empty. | +| **Perspective (you / player character)** | Used when the *player character* is the one speaking or acting in a dialogue prompt. | +| **Perspective (others / NPCs)** | Used when a *non-player character* is speaking or acting. | +| **Perspective (narrator)** | Used for narrator agent prompts — scene description, story progression, character entry / exit, time passage, and so on. | -> First person past tense, from Annabelle's point of view +The three role-specific fields live behind the **Per-speaker perspective overrides** expansion panel next to the default field. A small chip on the panel header tells you how many overrides are set. -!!! note "Exposed as a context ID" - This field is also available as the `story_configuration:perspective` context ID, which means it can be referenced or updated by features that work with context IDs (for example the [Context Database](/talemate/user-guide/world-editor/context-db)). +#### Choosing or typing a value + +Each of the four fields is a combobox. You can: + +- **Pick a preset** from the dropdown. +- **Type a custom value** — the field accepts any free-form text and your custom entry is saved with the scene whether or not it matches a preset. + +The dropdown list is shared by all four fields and is managed in **Settings → Creator → Perspective Presets**. See [Creator settings](../../app-settings/creator.md#perspective-presets) for how to add, remove, or rename presets. + +#### The `{player_name}` placeholder + +Any field that contains the literal string `{player_name}` will have that substring replaced by the active player character's name at prompt render time. This means presets like: + +> First person, present tense, from {player_name}'s POV. + +work for every scene without you having to edit them, and they survive renaming the player character. + +If the scene has no explicit player character, perspectives that reference `{player_name}` are **suppressed** rather than substituted — the placeholder has no anchor to point at, so injecting it would produce broken prose like *"Talking to the player."*. Pick a non-placeholder preset for scenes without a player character. + +#### Fallback semantics + +The four fields cascade in a predictable way: + +- An empty role-specific override falls back to the **default**. +- An empty default means *no perspective line is injected at all* — the AI is left to match whatever tense and POV the existing scene history establishes (the dialogue prompt has a short instruction that handles this case automatically). + +You only need to fill in the fields that matter for your scene. Most scenes only need the default. + +#### What each default preset means + +This table lists the presets that ship with Talemate, what each one means in plain English, and a tiny sample of the kind of prose it produces (using `Annabelle` as the player character). + +| Preset | What it means | Sample prose | +|---|---|---| +| `Third person limited, past tense.` | Outside narrator using "she"/"he"/"they". Past tense ("walked"). Anchored to one character — usually whoever the scene establishes as the POV character. | *"Annabelle walked up to the porch. The door looked older than the rest of the house."* | +| `Third person limited, past tense, focused on {player_name}'s POV.` | Same as above, but explicitly tells the AI the player character is the anchor — the narration can read *her* thoughts, but not the NPC's. | *"Annabelle walked up to the porch. Something about the door felt wrong, though she couldn't say why."* | +| `Third person limited, present tense.` | Outside narrator using "she"/"he"/"they". Present tense ("walks"). Reads like a live broadcast. | *"Annabelle walks up to the porch. The door looks older than the rest of the house."* | +| `Third person limited, present tense, focused on {player_name}'s POV.` | Same as above, anchored to the player character. | *"Annabelle walks up to the porch. The door looks wrong to her, somehow."* | +| `Third person omniscient, past tense.` | Outside narrator using "she"/"he"/"they", past tense. The narrator knows *everything* — NPC thoughts, off-screen events, things the player character can't see. | *"Annabelle walked up to the porch, unaware that someone was watching her from an upstairs window."* | +| `Third person omniscient, present tense.` | Same as above, in present tense. | *"Annabelle walks up to the porch. Inside, the homeowner watches her approach through the curtains."* | +| `First person, past tense, from {player_name}'s POV.` | The *player character herself* narrates, using "I"/"me"/"my". Past tense — reads like a journal entry or recollection. | *"I walked up to the porch. The door looked older than the rest of the house."* | +| `First person, present tense, from {player_name}'s POV.` | The *player character herself* narrates, using "I"/"me"/"my". Present tense — reads like an unfolding inner monologue. | *"I walk up to the porch. The door looks older than the rest of the house."* | +| `Second person, present tense.` | The narrator addresses an unnamed "you" — classic interactive-fiction voice. Present tense. | *"You walk up to the porch. The door looks older than the rest of the house."* | +| `Second person, present tense. Talking to {player_name}.` | Same as above, but the narrator explicitly knows the player character's name and may use it. | *"You walk up to the porch, Annabelle. The door looks older than the rest of the house."* | + +You are not limited to these — type anything into the field and it will be passed to the AI verbatim. The presets are just shortcuts for the most common configurations. See [Creator settings → Perspective Presets](../../app-settings/creator.md#perspective-presets) for how to add your own. + +#### Common patterns + +**Standard novel (third person limited, single POV character)** + +| Field | Value | +|---|---| +| Default | `Third person limited, past tense, focused on {player_name}'s POV.` | +| Player | *empty* | +| Other | *empty* | +| Narrator | *empty* | + +**Interactive fiction / "you are the protagonist"** + +| Field | Value | +|---|---| +| Default | *empty* | +| Player | *empty* | +| Other | *empty* | +| Narrator | `Second person, present tense. Talking to {player_name}.` | + +The narrator drives the whole scene in second person and no perspective line is added to dialogue or analysis prompts. + +**Mixed POV — first person for the player, third for everyone else** + +| Field | Value | +|---|---| +| Default | *empty* | +| Player | `First person, present tense, from {player_name}'s POV.` | +| Other | *empty* | +| Narrator | `Third person limited, past tense, focused on {player_name}'s POV.` | + +When the player character is acting they get the first-person voice; the narrator and NPC dialogue stay in third person limited. + +!!! note "Exposed as context IDs" + The perspective fields are also available as context IDs for features that work with context IDs (for example the [Context Database](/talemate/user-guide/world-editor/context-db)): + + - `story_configuration:perspective` — alias for the default field (kept for backwards compatibility). + - `story_configuration:perspective.default` + - `story_configuration:perspective.player` + - `story_configuration:perspective.other` + - `story_configuration:perspective.narrator` ### Description diff --git a/docs/user-guide/world-editor/scene/settings.md b/docs/user-guide/world-editor/scene/settings.md index ce71d711..0e5adc4e 100644 --- a/docs/user-guide/world-editor/scene/settings.md +++ b/docs/user-guide/world-editor/scene/settings.md @@ -2,11 +2,11 @@ The `Settings` tab allows you to configure various settings for the scene. -![World editor scene settings 1](/talemate/img/0.29.0/world-editor-scene-settings-1.png) +![World editor scene settings 1](/talemate/img/0.38.0/world-editor-scene-settings-1.png) ### Writing Style -If you have any [writing style templates](/talemate/user-guide/world-editor/templates/writing-style/) set up, you can select one here. Some agents may use this to influence their output. +If you have any [writing style templates](/talemate/user-guide/templates/writing-style/) set up, you can select one here. Some agents may use this to influence their output. ### Locked save file @@ -34,4 +34,19 @@ Restoring creates a **new, unsaved scene** based on the restore point. Your curr !!! tip This is especially useful for scene creators who want to test their scenes repeatedly, or for creating replayable scenarios where players always start from the same point. Combine this with a [locked save file](#locked-save-file) to prevent accidental overwrites of your baseline. -See also: [Restoring Scenes from Backups](/talemate/user-guide/restoring-scenes) for restoring to any previous revision using the automatic backup history. \ No newline at end of file +See also: [Restoring Scenes from Backups](/talemate/user-guide/restoring-scenes) for restoring to any previous revision using the automatic backup history. + +### Agent settings file + +This setting controls how the scene uses [per-scene agent overrides](/talemate/user-guide/agents/scene-overrides) — agent settings that apply to this scene only, without changing your global configuration. + +Overrides are stored in a JSON file inside an `agent-settings/` folder in the scene's project directory. The **Agent settings file** dropdown decides which file (if any) the scene is linked to: + +- **Auto-link (default)** — the scene automatically uses `agent-settings/agent-settings.json` if it exists in the project folder. This is the normal behavior. If you haven't set any overrides yet, the first one you create in the agent modal will create this file. +- **None (opt out)** — disable per-scene overrides for this scene. It uses your global agent configuration only and will not auto-link to a default file. +- **A specific file** — link the scene to a particular overrides file in the project's `agent-settings/` folder. Any additional override files in that folder appear in the list, letting a project keep more than one set of overrides and switch between them. + +Once a file is linked, you edit the actual overrides in the agent modal under the **Scene** tab. See [Per-Scene Agent Overrides](/talemate/user-guide/agents/scene-overrides) for the full workflow. + +!!! note + Overrides are project-level, so every save file in the same project — including **Save As** copies and restore points — shares the same `agent-settings/` folder. Scene exports include this folder, so overrides travel with the scene when shared. \ No newline at end of file diff --git a/docs/user-guide/world-editor/world/states.md b/docs/user-guide/world-editor/world/states.md index fec9544f..702dd9c5 100644 --- a/docs/user-guide/world-editor/world/states.md +++ b/docs/user-guide/world-editor/world/states.md @@ -2,7 +2,7 @@ !!! info "What is a tracked state?" - --8<-- "docs/talemate/user-guide/tracking-a-state.md:what-is-a-tracked-state" + --8<-- "docs/user-guide/tracking-a-state.md:what-is-a-tracked-state" ## Setting up a new state In the :material-earth: **World** editor, in the left panel, click the :material-image-auto-adjust: **New State Reinforcement** button. @@ -33,7 +33,7 @@ By default it will be `10` turns, but you can change this to any number you like ### Context Attachment Method ---8<-- "docs/talemate/user-guide/tracking-a-state.md:context-attachment-method" +--8<-- "docs/user-guide/tracking-a-state.md:context-attachment-method" For this example, we always want to know the state of the ship, so we will set it to `All context`. diff --git a/docs/user-guide/world-state.md b/docs/user-guide/world-state.md index 39110dec..a166e292 100644 --- a/docs/user-guide/world-state.md +++ b/docs/user-guide/world-state.md @@ -5,6 +5,8 @@ The world state snapshot is a summary of the current scene state. It will contain characters and objects mentioned in the scene. +The snapshot is generated and maintained by the [World State Agent](/talemate/user-guide/agents/world-state/), which refreshes it automatically as the scene plays out. To build the snapshot the agent looks at the whole scene so far together with the most recent lines (the "current moment"), and it can also pull in relevant entries from [long-term memory](/talemate/user-guide/agents/world-state/settings/#long-term-memory) so the snapshot can build on established lore and earlier scene history. + If there are [tracked states](/talemate/user-guide/tracking-a-state) in the scene, they will also be displayed here. ![world state 1](/talemate/img/0.26.0/world-state-snapshot-1.png) @@ -13,13 +15,45 @@ Characters are indicated by the :material-account: icon, objects by the :materia You can click on each entry to expand it and see more information. +## :material-cursor-default-click-outline: Entity Highlights in scene messages + +Alongside the snapshot panel, Talemate also highlights notable characters, items, and places directly inside the most recent scene message. Highlighted entities appear with a subtle dotted underline, marking them as clickable. + +These highlights are produced by the same world state snapshot, so they refresh whenever the snapshot updates. Only the most recent part of the scene — the "current moment" — receives highlights; older messages are left as plain text. + +### Examining an entity + +Click a highlighted entity to open a small popover. The popover shows: + +- The entity's name and its kind (character, item, or place). +- For characters, the current emotional state, when known. +- A short description of the entity as it stands in the current moment. + +From the popover you can take two actions: + +:material-eye: **Look at** asks the [Narrator Agent](/talemate/user-guide/agents/narrator/) for a fresh description of the entity, added to the scene as a context-investigation message. + +:material-plus: **Add Detail** expands the short description shown in the popover into a longer, grounded passage, also added to the scene as a context-investigation message. This action is available for items, places, and background characters mentioned in the narrative. It is hidden for characters that already exist in the scene, since those have their own detail surfaces in the [character editor](/talemate/user-guide/world-editor/characters). + +:material-cog-outline: **Configure highlights** opens the [World State Agent settings](/talemate/user-guide/agents/world-state/settings/) where the highlight behavior is controlled. + +### Controlling the highlights + +Entity highlighting follows the world state snapshot, so it shares the snapshot's settings in the [World State Agent configuration](/talemate/user-guide/agents/world-state/settings/): + +- **Lines in the moment** sets how many of the most recent messages count as the current moment and can therefore show highlights. +- **Include Look at / Add Detail** controls whether context-investigation messages (the results of the actions above) also count as part of the current moment and can receive their own highlights. +- **Add Detail length** controls how long an **Add Detail** result can be. + +You can change the color of the highlights, or turn the inline styling off entirely, under **Settings → Appearance → Messages → Entity Highlights**. See [Appearance settings](/talemate/user-guide/app-settings/appearance/#text-markup-styling). + ## :material-account: Characters Each character entry will display the current emotional state next to the character's name. When expanded it will also show a description of what the character is currently doing. -![world state 2](/talemate/img/0.26.0/world-state-snapshot-2.png) +![world state 2](/talemate/img/0.38.0/world-state-snapshot-2.png) ### Action shortcuts @@ -27,9 +61,7 @@ Beneath the description there are additional shortcuts. :material-eye: **Look at** Will cause the [Narrator Agent](/talemate/user-guide/agents/narrator/) to describe the character. -:material-account-details: **Character Sheet** will take you to the character sheet, which provides more detailed information about the character. - -:material-book-open-page-variant: **Manage Character** will take you to the [character editor](/talemate/user-guide/world-editor/characters) for that character. +:material-book-open-page-variant: **Manage Character** will take you to the [character editor](/talemate/user-guide/world-editor/characters) for that character, where you can view and edit all of the character's details. :material-human-greeting: **Make real** If the world state has picked up a character that is not yet an interactive character, this will allow you to convert them into an interactive character. @@ -61,6 +93,34 @@ If there are [tracked states](/talemate/user-guide/tracking-a-state) in the scen ![world state 5](/talemate/img/0.26.0/world-state-snapshot-5.png) -## :material-refresh: Force Update +## :material-refresh: Refreshing the snapshot -While the [World State Agent](/talemate/user-guide/agents/world-state/) will automatically update the world state snapshot, you can also force an update by clicking the refresh icon in the top right of the world state snapshot. \ No newline at end of file +While the [World State Agent](/talemate/user-guide/agents/world-state/) will automatically update the world state snapshot as the scene progresses, you can also refresh it on demand by clicking the refresh icon in the top right of the world state snapshot. + +### How the snapshot carries forward + +By default the snapshot is **durable**: each refresh updates only what has changed and drops entities that are no longer relevant, rather than rebuilding the whole snapshot from scratch. This lets the snapshot build up over time, so details an entity has accumulated earlier in the scene are not lost on the next refresh. + +To keep the snapshot from collecting stale entries, an entity that the agent leaves untouched for several refreshes in a row is automatically removed. Durability (and the related limits) can be turned off or tuned in the [World State Agent settings](/talemate/user-guide/agents/world-state/settings/#durable-snapshot). + +When you move to a new point in time (a [time jump](/talemate/user-guide/time-passage/)), the snapshot is treated as a clean scene cut and rebuilt fresh, since the previous moment no longer applies. + +### Wiping the snapshot + +If you want to discard everything the snapshot has accumulated and start over, hold **Ctrl** (or **Cmd** on macOS) while clicking the refresh icon. This wipes the current snapshot and regenerates it from scratch. + +### Cancelling a refresh + +While a refresh is in progress a :material-close: cancel icon is shown in place of the refresh icon. Click it to stop the generation. This is useful if a refresh is taking longer than you want or you started one by mistake. + +## :material-cog-clockwise: Automatic refreshing and the scene input + +The snapshot refreshes automatically every several character turns (each player or AI turn counts), on a cadence you can set in the [World State Agent settings](/talemate/user-guide/agents/world-state/settings/#turns). + +If the agent's text-generation client supports running more than one request at a time, the snapshot is generated quietly in the background and you can keep playing while it works. Otherwise it runs in the foreground so it doesn't compete with your other actions on the same client. Either way, manual refreshes behave the same and can be cancelled mid-generation. + +## :material-pin: Pinning the snapshot to the scene + +The world state snapshot can optionally be **pinned into the prompts** that drive dialogue, narration, and scene planning. When pinning is enabled, the snapshot's tracked characters, items, and places are shared with the [Conversation Agent](/talemate/user-guide/agents/conversation/), the [Narrator Agent](/talemate/user-guide/agents/narrator/), and scene analysis, so what the snapshot is tracking can inform what those agents produce. + +This is **off by default**. You can enable it with the [Pin to context](/talemate/user-guide/agents/world-state/settings/#pin-to-context) setting in the World State Agent settings. \ No newline at end of file diff --git a/install-utils/node-env.sh b/install-utils/node-env.sh new file mode 100644 index 00000000..cfcb39cf --- /dev/null +++ b/install-utils/node-env.sh @@ -0,0 +1,81 @@ +# Shared helper for provisioning and activating a portable Node.js runtime. +# Requires bash (relies on BASH_SOURCE). Source it, then call: +# install_embedded_node - download + extract + activate (install.sh) +# activate_embedded_node - put it on PATH if present (update.sh, start-frontend.sh) +# +# pnpm 11 (pinned via talemate_frontend/package.json "packageManager") needs +# Node.js >= 22.13. Rather than depend on whatever Node is on the system, +# install.sh downloads a portable Node into ./embedded_node and the update / +# start scripts reuse it. This mirrors what install.bat does on Windows. + +# Pinned Node.js version for the portable runtime. +EMBEDDED_NODE_VERSION="22.22.3" + +# Absolute path to ./embedded_node, resolved relative to this file so it works +# no matter what the caller's current working directory is. +EMBEDDED_NODE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/embedded_node" + +# Map `uname -m` to the architecture string used by nodejs.org downloads. +_embedded_node_arch() { + case "$(uname -m)" in + x86_64 | amd64) echo "x64" ;; + aarch64 | arm64) echo "arm64" ;; + armv7l) echo "armv7l" ;; + *) echo "" ;; + esac +} + +# Download $1 to $2 using whichever of curl/wget is available. +_embedded_node_download() { + if command -v curl >/dev/null 2>&1; then + curl -fL --progress-bar -o "$2" "$1" + elif command -v wget >/dev/null 2>&1; then + wget -q --show-progress -O "$2" "$1" + else + echo "Neither curl nor wget found; cannot download Node.js." >&2 + return 1 + fi +} + +# Prepend the portable Node (if present) to PATH so node/npm/corepack resolve to it. +activate_embedded_node() { + if [ -x "$EMBEDDED_NODE_DIR/bin/node" ]; then + export PATH="$EMBEDDED_NODE_DIR/bin:$PATH" + fi +} + +# Download and extract the portable Node runtime, then activate it. +install_embedded_node() { + local arch tarball url + arch="$(_embedded_node_arch)" + if [ -z "$arch" ]; then + echo "Unsupported architecture '$(uname -m)' for portable Node.js." + echo "Please install Node.js >= 22.13 manually and re-run." + return 1 + fi + + tarball="node-v${EMBEDDED_NODE_VERSION}-linux-${arch}.tar.xz" + url="https://nodejs.org/dist/v${EMBEDDED_NODE_VERSION}/${tarball}" + + echo "Downloading portable Node.js v${EMBEDDED_NODE_VERSION} (${arch})..." + rm -rf "$EMBEDDED_NODE_DIR" + mkdir -p "$EMBEDDED_NODE_DIR" + + if ! _embedded_node_download "$url" "/tmp/${tarball}"; then + echo "Failed to download Node.js from ${url}" + rm -rf "$EMBEDDED_NODE_DIR" + return 1 + fi + + # --strip-components 1 drops the node-vX.Y.Z-linux-/ top-level directory. + if ! tar -xf "/tmp/${tarball}" -C "$EMBEDDED_NODE_DIR" --strip-components 1; then + echo "Failed to extract ${tarball} (is xz/xz-utils installed?)" + rm -f "/tmp/${tarball}" + rm -rf "$EMBEDDED_NODE_DIR" + return 1 + fi + rm -f "/tmp/${tarball}" + + activate_embedded_node + echo "Using portable Node.js at ${EMBEDDED_NODE_DIR} ($(node -v))" +} diff --git a/install.bat b/install.bat index 6e26fd0a..31903296 100644 --- a/install.bat +++ b/install.bat @@ -69,7 +69,7 @@ IF "%NEED_CLEAN%"=="1" ( REM ---------[ Version configuration ]--------- SET "PYTHON_VERSION=3.11.9" -SET "NODE_VERSION=22.16.0" +SET "NODE_VERSION=22.22.3" REM ---------[ Detect architecture & choose download URL ]--------- REM Prefer PROCESSOR_ARCHITEW6432 when the script is run from a 32-bit shell on 64-bit Windows @@ -212,21 +212,22 @@ ECHO Node.js version: node -v REM ---------[ Frontend ]--------- +REM pnpm is provisioned on demand via corepack (bundled with Node.js); the +REM version is pinned by the "packageManager" field in package.json. +SET "COREPACK_ENABLE_DOWNLOAD_PROMPT=0" ECHO Installing frontend dependencies... CD talemate_frontend -CALL npm install || CALL :die "npm install failed." +CALL corepack pnpm install --frozen-lockfile || CALL :die "pnpm install failed." ECHO Building frontend... -CALL npm run build +CALL corepack pnpm build IF ERRORLEVEL 1 ( ECHO. - ECHO Frontend build failed - retrying with clean node_modules... - ECHO This can happen due to a known npm bug with optional dependencies. + ECHO Frontend build failed - retrying with a clean node_modules... ECHO. rmdir /s /q node_modules 2>nul - del package-lock.json 2>nul - CALL npm install || CALL :die "npm install failed on retry." - CALL npm run build || CALL :die "Frontend build failed on retry." + CALL corepack pnpm install --frozen-lockfile || CALL :die "pnpm install failed on retry." + CALL corepack pnpm build || CALL :die "Frontend build failed on retry." ) REM Return to repo root diff --git a/install.sh b/install.sh index 50869662..b4c24da5 100644 --- a/install.sh +++ b/install.sh @@ -18,14 +18,26 @@ if [ ! -f config.yaml ]; then cp config.example.yaml config.yaml fi +# provision a portable Node.js runtime (pnpm 11 needs Node >= 22.13) +echo "Setting up the Node.js runtime..." +source install-utils/node-env.sh +if ! install_embedded_node; then + echo "ERROR: could not provision Node.js. Aborting installation." + return 1 2>/dev/null || exit 1 +fi + # navigate to the frontend directory echo "Updating the frontend..." cd talemate_frontend -npm install + +# pnpm is provisioned on demand via corepack (bundled with Node.js); the +# version is pinned by the "packageManager" field in package.json. +export COREPACK_ENABLE_DOWNLOAD_PROMPT=0 +corepack pnpm install --frozen-lockfile # build the frontend echo "Building the frontend..." -npm run build +corepack pnpm build # return to the root directory cd .. diff --git a/pyproject.toml b/pyproject.toml index 332979ef..cedb5270 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "talemate" -version = "0.37.1" +version = "0.38.0" description = "AI-backed roleplay and narrative tools" authors = [{name = "VeguAITools"}] license = {text = "GNU Affero General Public License v3.0"} @@ -30,7 +30,7 @@ dependencies = [ "pydantic<3", "beautifulsoup4>=4.12.2", "python-dotenv>=1.0.0", - "cryptography>=43.0", + "cryptography>48.0.0", "keyring>=25.0", "structlog>=23.1.0", "google-genai>=1.20.0", @@ -124,6 +124,10 @@ ensure_newline_before_comments = true line_length = 88 [tool.uv] +exclude-newer = "1 week" +# safetensors 0.8.0 (uploaded 2026-06-09) is needed by diffusers 0.38.0, which fixes +# CVE-2026-45804 / CVE-2026-44513 / CVE-2026-44827; remove once it ages past the 1-week window +exclude-newer-package = { safetensors = "2026-06-10T00:00:00Z" } override-dependencies = [ # chatterbox wants torch 2.6.0, but is confirmed working with 2.7.1 "torchaudio>=2.7.1", diff --git a/src/talemate/__init__.py b/src/talemate/__init__.py index 431f1ae6..282285bf 100644 --- a/src/talemate/__init__.py +++ b/src/talemate/__init__.py @@ -1,4 +1,5 @@ from .tale_mate import * # noqa: F401, F403 +from . import model_rebuild # noqa: F401 (resolves forward refs) from .version import VERSION diff --git a/src/talemate/agents/base.py b/src/talemate/agents/base.py index 33e1aefb..c0a1f7da 100644 --- a/src/talemate/agents/base.py +++ b/src/talemate/agents/base.py @@ -1,7 +1,8 @@ from __future__ import annotations import asyncio -import dataclasses +import contextlib +import contextvars import functools import json from inspect import signature @@ -9,7 +10,7 @@ import re import traceback from abc import ABC from functools import wraps -from typing import Callable, Literal +from typing import Awaitable, Callable, Literal import uuid import pydantic from pydantic import ConfigDict @@ -29,6 +30,7 @@ import talemate.config.schema as config_schema from talemate.client.context import ( ClientContext, ) +from talemate.scene_agent_settings import UNSET from talemate.game.engine.nodes.core import GraphState from talemate.game.engine.nodes.registry import get_nodes_by_base_type, get_node from talemate.game.engine.nodes.run import FunctionWrapper @@ -98,6 +100,7 @@ class AgentActionConfig(pydantic.BaseModel): default_factory=dict ) save_on_change: bool = False + scene_overridable: bool = True wstemplate_type: ( Literal[ @@ -161,6 +164,21 @@ class AgentAction(pydantic.BaseModel): # Only meaningful on actions that are themselves dynamic registries. dynamic_registry_component: str | None = None + enabled_scene_overridable: bool = False + + @pydantic.model_validator(mode="after") + def _enabled_scene_overridable_requires_can_be_disabled(self): + # An enable-flag override only makes sense when the global enable + # flag is itself togglable. Without can_be_disabled the global UI + # never exposes an Enable checkbox, so a scene-level override has + # nothing to override. + if self.enabled_scene_overridable and not self.can_be_disabled: + raise ValueError( + f"AgentAction {self.label!r}: enabled_scene_overridable=True " + "requires can_be_disabled=True" + ) + return self + # --------------------------------------------------------------------------- # Dynamic action registries @@ -195,6 +213,7 @@ def optimize_prompt_caching_action() -> AgentAction: {"label": "On", "value": "on"}, {"label": "Off", "value": "off"}, ], + scene_overridable=False, ), }, ) @@ -272,6 +291,29 @@ class store_context_state: return fn +def _agent_action_override_kwargs(agent_type: str, action_name: str) -> dict: + """ClientContext kwargs for any per-action override on ``{agent_type}.{action_name}``; empty when none applies.""" + config = get_config() + override = config.agent_actions.overrides.get(f"{agent_type}.{action_name}") + if override is None: + return {} + + kwargs: dict = {} + if override.disable_reasoning: + kwargs["disable_reasoning"] = True + return kwargs + + +# When True (set by a background dispatcher around asyncio.create_task), a +# @set_processing action skips its foreground "busy" status emit. The action +# still establishes its normal client/agent context, but status is left to +# set_background_processing() so the work reports as "busy_bg" rather than +# blocking-looking "busy". Default False keeps every other call unchanged. +background_processing: contextvars.ContextVar[bool] = contextvars.ContextVar( + "agent_background_processing", default=False +) + + def set_processing(fn): """ decorator that emits the agent status as processing while the function @@ -283,6 +325,9 @@ def set_processing(fn): @wraps(fn) async def wrapper(self, *args, **kwargs): + # In background mode the foreground "busy" emit is suppressed; status is + # owned by set_background_processing() so the action reports "busy_bg". + is_background = background_processing.get() with ClientContext(): scene = active_scene.get() @@ -296,7 +341,8 @@ def set_processing(fn): action_name = args[0].__name__ self._current_action = action_name - await self.emit_status(processing=True) + if not is_background: + await self.emit_status(processing=True) # Now pass the complete args list if getattr(fn, "store_context_state", None) is not None: @@ -317,11 +363,21 @@ def set_processing(fn): self.set_context_states(**all_args) - return await fn(self, *args, **kwargs) + override_kwargs = _agent_action_override_kwargs( + self.agent_type, action_name + ) + override_ctx = ( + ClientContext(**override_kwargs) + if override_kwargs + else contextlib.nullcontext() + ) + with override_ctx: + return await fn(self, *args, **kwargs) finally: try: self._current_action = None - await self.emit_status(processing=False) + if not is_background: + await self.emit_status(processing=False) except RuntimeError as exc: # not sure why this happens # some concurrency error? @@ -367,6 +423,7 @@ class Agent(ABC): "has_toggle": agent.has_toggle if agent else False, "experimental": agent.experimental if agent else False, "requires_llm_client": cls.requires_llm_client, + "scene_overrides": cls._scene_overrides_payload(agent), } actions = getattr(agent, "actions", None) @@ -377,6 +434,24 @@ class Agent(ABC): return config_options + @classmethod + def _scene_overrides_payload(cls, agent) -> dict: + """Sparse override dict for THIS agent from the active scene, if any. + + Empty dict means: no scene loaded, or no overrides set for this + agent. The frontend uses this to populate the Scene tab in the + AgentModal. + """ + if not agent: + return {} + overrides = agent.scene_overrides() + if overrides is None: + return {} + agent_override = overrides.agents.get(agent.agent_type) + if not agent_override: + return {} + return agent_override.model_dump(exclude_none=True) + @classmethod async def init_nodes(cls, scene: "Scene", state: GraphState): log.debug(f"{cls.agent_type}.init_nodes") @@ -806,7 +881,82 @@ class Agent(ABC): return default return functools.partial(fn, slug) + # ------------------------------------------------------------------ + # Per-scene config overrides + # ------------------------------------------------------------------ + + def scene_overrides(self): + """Return the scene's agent-overrides overlay, or None if not linked. + + ``scene`` is only present after ``connect()`` runs at scene-load time; + callers may invoke this before that (e.g. property getters during agent + init), so we tolerate a missing ``scene`` attribute. + """ + scene = getattr(self, "scene", None) + return getattr(scene, "agent_overrides", None) if scene else None + + def _resolve(self, getter, fallback): + """Consult the scene overlay via ``getter``; fall back if UNSET.""" + overrides = self.scene_overrides() + if overrides is not None: + value = getter(overrides) + if value is not UNSET: + return value + return fallback() + + def resolve_config(self, action_key: str, config_key: str): + """Return the effective value for an action config field — scene override if any, else global.""" + return self._resolve( + lambda o: o.get_value(self.agent_type, action_key, config_key), + lambda: self.actions[action_key].config[config_key].value, + ) + + def resolve_enabled(self, action_key: str) -> bool: + """Return the effective enabled flag for a container action.""" + return bool( + self._resolve( + lambda o: o.get_enabled(self.agent_type, action_key), + lambda: self.actions[action_key].enabled, + ) + ) + + def _route_write(self, has_override, write_override, write_global) -> None: + """Route a write to the scene overlay when it's overriding this field, else to the global config.""" + overrides = self.scene_overrides() + if overrides is not None and has_override(overrides): + write_override(overrides) + else: + write_global() + + def write_config(self, action_key: str, config_key: str, value) -> None: + """Update an action config field — scene override if one is active for this field, else global. + + Note: this updates an *existing* override; it does not create a new one. + New overrides are installed via the AgentModal save flow + (see ``server.agent_config.replace_agent_overrides``). + """ + self._route_write( + lambda o: o.get_value(self.agent_type, action_key, config_key) is not UNSET, + lambda o: o.set_value(self.agent_type, action_key, config_key, value), + lambda: setattr( + self.actions[action_key].config[config_key], "value", value + ), + ) + + def write_enabled(self, action_key: str, enabled: bool) -> None: + """Update an action's enabled flag — scene override if one is active, else global. + + Note: this updates an *existing* override; it does not create a new one. + """ + self._route_write( + lambda o: o.get_enabled(self.agent_type, action_key) is not UNSET, + lambda o: o.set_enabled(self.agent_type, action_key, enabled), + lambda: setattr(self.actions[action_key], "enabled", enabled), + ) + async def apply_config(self, *args, **kwargs): + # Writes global state directly on purpose; runtime edits must go + # through `write_config` / `write_enabled` to respect any overlay. if self.has_toggle and "enabled" in kwargs: self.is_enabled = kwargs.get("enabled", False) @@ -1021,6 +1171,109 @@ class Agent(ABC): ) ) + # ------------------------------------------------------------------ + # Tracked single-flight tasks + # + # A small reuse layer over set_background_processing for "run this agent + # operation as a single-flight task, optionally in the background". Each + # logical operation is identified by a `key` so an agent can run several + # independent ones; per-key only one is ever in flight at a time. + # + # background=True routes status through set_background_processing ("busy_bg", + # non-blocking UI) and sets the background_processing contextvar so the + # operation's @set_processing work reports busy_bg too. background=False runs + # the same machinery but reports the normal foreground "busy". + # ------------------------------------------------------------------ + + def _tracked_task(self, key: str) -> "asyncio.Task | None": + return getattr(self, "_tracked_tasks", {}).get(key) + + def tracked_task_running(self, key: str) -> bool: + """Whether a tracked task under `key` is currently in flight.""" + task = self._tracked_task(key) + return task is not None and not task.done() + + async def run_tracked_task( + self, + key: str, + coro_factory: Callable[[], Awaitable], + *, + background: bool = True, + cancel_in_flight: bool = False, + error_handler: Callable | None = None, + ) -> "asyncio.Task | None": + """ + Run `coro_factory()` as a single-flight task tracked under `key`. + + Returns the started task, or None if one was already in flight and left + to run (single-flight skip — `coro_factory` is not invoked, so a skip + never creates an un-awaited coroutine). + + The entry is removed from the table when the task finishes, so the table + only ever holds in-flight tasks and never accumulates stale keys (which + matters for callers that use dynamic per-item keys). + + `cancel_in_flight=True` cancels a running task under `key` and starts a + fresh one. `error_handler` is an async callable invoked with the + exception if the task fails. + """ + if not hasattr(self, "_tracked_tasks"): + self._tracked_tasks: dict[str, asyncio.Task] = {} + + existing = self._tracked_tasks.get(key) + if existing is not None and not existing.done(): + if not cancel_in_flight: + return None + existing.cancel() + + # The contextvar is copied into the task by create_task; the set/reset + # is synchronous (no await between), so it never leaks to other tasks. + token = background_processing.set(True) if background else None + try: + task = asyncio.create_task(coro_factory()) + finally: + if token is not None: + background_processing.reset(token) + + self._tracked_tasks[key] = task + task.add_done_callback(lambda fut: self._untrack_task(key, fut)) + + if background: + await self.set_background_processing(task, error_handler) + else: + task.add_done_callback( + lambda fut: self._on_tracked_task_done(fut, error_handler) + ) + return task + + def _untrack_task(self, key: str, task: asyncio.Task) -> None: + # Only drop the entry if it's still this task — a cancel_in_flight + # replacement may have already swapped a newer task under the same key. + if self._tracked_tasks.get(key) is task: + del self._tracked_tasks[key] + + def _on_tracked_task_done(self, task: asyncio.Task, error_handler=None) -> None: + # Foreground tasks report "busy" via @set_processing; this callback only + # surfaces errors and consumes the result so failures don't go unnoticed. + if task.cancelled(): + return + exc = task.exception() + if exc is None: + return + tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)) + log.error("tracked task error", agent=self.agent_type, exc=exc, traceback=tb) + if error_handler is not None: + asyncio.create_task(error_handler(exc)) + + def cancel_tracked_task(self, key: str) -> bool: + """Cancel the in-flight tracked task under `key`. Returns True if one + was running and got cancelled.""" + task = self._tracked_task(key) + if task is not None and not task.done(): + task.cancel() + return True + return False + def connect(self, scene): self.scene = scene talemate.emit.async_signals.get("game_loop_start").connect( @@ -1124,20 +1377,19 @@ class Agent(ABC): ) -@dataclasses.dataclass -class AgentEmission: +class AgentEmission(pydantic.BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + agent: Agent -@dataclasses.dataclass class AgentTemplateEmission(AgentEmission): - template_vars: dict = dataclasses.field(default_factory=dict) - response: str = None - dynamic_instructions: list[DynamicInstruction] = dataclasses.field( + template_vars: dict = pydantic.Field(default_factory=dict) + response: str | None = None + dynamic_instructions: list[DynamicInstruction] = pydantic.Field( default_factory=list ) -@dataclasses.dataclass class RagBuildSubInstructionEmission(AgentEmission): sub_instruction: str | None = None diff --git a/src/talemate/agents/conversation/__init__.py b/src/talemate/agents/conversation/__init__.py index ba7f9fd9..c9b4c76d 100644 --- a/src/talemate/agents/conversation/__init__.py +++ b/src/talemate/agents/conversation/__init__.py @@ -1,10 +1,10 @@ from __future__ import annotations -import dataclasses import re from datetime import datetime from typing import TYPE_CHECKING, Optional +import pydantic import structlog import talemate.client as client @@ -44,12 +44,11 @@ if TYPE_CHECKING: log = structlog.get_logger("talemate.agents.conversation") -@dataclasses.dataclass class ConversationAgentEmission(AgentEmission): - actor: Actor - character: Character + actor: "Actor | None" = None + character: "Character" response: str - dynamic_instructions: list[DynamicInstruction] = dataclasses.field( + dynamic_instructions: list[DynamicInstruction] = pydantic.Field( default_factory=list ) avatar: str | None = None @@ -94,7 +93,7 @@ class ConversationAgent(MemoryRAGMixin, Agent): {"label": "Screenplay", "value": "movie_script"}, {"label": "Chat (legacy)", "value": "chat"}, { - "label": "Narrative (NEW, experimental)", + "label": "Narrative", "value": "narrative", }, { @@ -209,8 +208,8 @@ class ConversationAgent(MemoryRAGMixin, Agent): @property def conversation_format(self): - if self.actions["generation_override"].enabled: - return self.actions["generation_override"].config["format"].value + if self.resolve_enabled("generation_override"): + return self.resolve_config("generation_override", "format") return "movie_script" @property @@ -244,43 +243,37 @@ class ConversationAgent(MemoryRAGMixin, Agent): @property def generation_settings_task_instructions(self): - return self.actions["generation_override"].config["instructions"].value + return self.resolve_config("generation_override", "instructions") @property def generation_settings_actor_instructions(self): - return self.actions["generation_override"].config["actor_instructions"].value + return self.resolve_config("generation_override", "actor_instructions") @property def inject_character_names_into_stop(self) -> bool: - return ( - self.actions["generation_override"] - .config["inject_character_names_into_stop"] - .value + return self.resolve_config( + "generation_override", "inject_character_names_into_stop" ) @property def generation_settings_actor_instructions_offset(self): - return ( - self.actions["generation_override"] - .config["actor_instructions_offset"] - .value - ) + return self.resolve_config("generation_override", "actor_instructions_offset") @property def generation_settings_response_length(self): - return self.actions["generation_override"].config["length"].value + return self.resolve_config("generation_override", "length") @property def generation_settings_override_enabled(self): - return self.actions["generation_override"].enabled + return self.resolve_enabled("generation_override") @property def content_use_scene_intent(self) -> bool: - return self.actions["content"].config["use_scene_intent"].value + return self.resolve_config("content", "use_scene_intent") @property def content_use_writing_style(self) -> bool: - return self.actions["content"].config["use_writing_style"].value + return self.resolve_config("content", "use_writing_style") def connect(self, scene): super().connect(scene) @@ -401,22 +394,20 @@ class ConversationAgent(MemoryRAGMixin, Agent): return result def set_generation_overrides(self): - if not self.actions["generation_override"].enabled: + if not self.resolve_enabled("generation_override"): return set_conversation_context_attribute( - "length", self.actions["generation_override"].config["length"].value + "length", self.resolve_config("generation_override", "length") ) - if self.actions["generation_override"].config["jiggle"].value > 0.0: + jiggle = self.resolve_config("generation_override", "jiggle") + if jiggle > 0.0: nuke_repetition = client_context_attribute("nuke_repetition") if nuke_repetition == 0.0: # we only apply the agent override if some other mechanism isn't already # setting the nuke_repetition value - nuke_repetition = ( - self.actions["generation_override"].config["jiggle"].value - ) - set_client_context_attribute("nuke_repetition", nuke_repetition) + set_client_context_attribute("nuke_repetition", jiggle) @set_processing @store_context_state("instruction") @@ -540,9 +531,5 @@ class ConversationAgent(MemoryRAGMixin, Agent): def inject_prompt_paramters( self, prompt_param: dict, kind: str, agent_function_name: str ): - if ( - prompt_param.get("extra_stopping_strings") is None - or not self.inject_character_names_into_stop - ): + if not self.inject_character_names_into_stop: prompt_param["extra_stopping_strings"] = [] - prompt_param["extra_stopping_strings"] += ["#"] diff --git a/src/talemate/agents/creator/assistant.py b/src/talemate/agents/creator/assistant.py index 851de6ee..5e52cb63 100644 --- a/src/talemate/agents/creator/assistant.py +++ b/src/talemate/agents/creator/assistant.py @@ -2,7 +2,6 @@ import json import re import random from typing import TYPE_CHECKING, Tuple -import dataclasses import traceback import uuid import pydantic @@ -25,7 +24,12 @@ from talemate.world_state.templates import ( from talemate.changelog import write_reconstructed_scene from talemate.save import SceneEncoder import os -from talemate.agents.base import AgentAction, AgentActionConfig, AgentTemplateEmission +from talemate.agents.base import ( + AgentAction, + AgentActionConfig, + AgentActionNote, + AgentTemplateEmission, +) from talemate.agents.creator.response_specs import COMPLETION_SPEC import talemate.emit.async_signals as async_signals @@ -46,7 +50,6 @@ async_signals.register( ) -@dataclasses.dataclass class ContextualGenerateEmission(AgentTemplateEmission): """ A context for generating content. @@ -54,6 +57,7 @@ class ContextualGenerateEmission(AgentTemplateEmission): content_generation_context: "ContentGenerationContext | None" = None character: "Character | None" = None + autocomplete_hint: str | None = None @property def context_type(self) -> str: @@ -64,7 +68,6 @@ class ContextualGenerateEmission(AgentTemplateEmission): return self.content_generation_context.computed_context[1] -@dataclasses.dataclass class AutocompleteEmission(AgentTemplateEmission): """ A context for generating content. @@ -73,6 +76,7 @@ class AutocompleteEmission(AgentTemplateEmission): input: str = "" type: str = "" character: "Character | None" = None + autocomplete_hint: str | None = None class ContentGenerationContext(pydantic.BaseModel): @@ -205,6 +209,60 @@ class AssistantMixin: max=256, step=16, ), + "character_attribute_suggestion_length": AgentActionConfig( + type="number", + label="Character Attribute Suggestion Length", + description="Length of the generated suggestion when using autocomplete on a character attribute field.", + value=32, + min=32, + max=256, + step=16, + ), + "character_detail_suggestion_length": AgentActionConfig( + type="number", + label="Character Detail Suggestion Length", + description="Length of the generated suggestion when using autocomplete on a character detail or description field.", + value=64, + min=32, + max=256, + step=16, + ), + "scene_intro_suggestion_length": AgentActionConfig( + type="number", + label="Scene Intro Suggestion Length", + description="Length of the generated suggestion when using autocomplete on the scene intro field.", + value=96, + min=32, + max=256, + step=16, + ), + "context_investigation_suggestion_length": AgentActionConfig( + type="number", + label="Context Investigation Suggestion Length", + description="Length of the generated suggestion when using autocomplete on a context-investigation message.", + value=64, + min=32, + max=256, + step=16, + ), + "hints_enabled": AgentActionConfig( + type="bool", + label="Enable Hints", + description="Allow guiding autocomplete with a trailing `{...}` hint block. When off, any trailing `{...}` is treated as part of the input.", + value=True, + note_on_value={ + True: AgentActionNote( + icon="mdi-information-outline", + color="primary", + text=( + "End your input with `{...}` to nudge the completion. " + 'Example: `"Kaira!?" he yelled {dark corridor, no ' + "response}` cues the model on tone and beats without " + "leaving the braces in the scene." + ), + ), + }, + ), }, ) @@ -212,11 +270,49 @@ class AssistantMixin: @property def autocomplete_dialogue_suggestion_length(self): - return self.actions["autocomplete"].config["dialogue_suggestion_length"].value + return self.resolve_config("autocomplete", "dialogue_suggestion_length") @property def autocomplete_narrative_suggestion_length(self): - return self.actions["autocomplete"].config["narrative_suggestion_length"].value + return self.resolve_config("autocomplete", "narrative_suggestion_length") + + @property + def autocomplete_character_attribute_suggestion_length(self): + return self.resolve_config( + "autocomplete", "character_attribute_suggestion_length" + ) + + @property + def autocomplete_character_detail_suggestion_length(self): + return self.resolve_config("autocomplete", "character_detail_suggestion_length") + + @property + def autocomplete_scene_intro_suggestion_length(self): + return self.resolve_config("autocomplete", "scene_intro_suggestion_length") + + @property + def autocomplete_context_investigation_suggestion_length(self): + return self.resolve_config( + "autocomplete", "context_investigation_suggestion_length" + ) + + def autocomplete_contextual_length_for(self, context_type: str) -> int: + """Resolve the configured suggestion length for a contextual autocomplete + type. Properties follow `autocomplete__suggestion_length` + (spaces → underscores). Unmapped types fall back to character-detail.""" + attr = f"autocomplete_{context_type.replace(' ', '_')}_suggestion_length" + length = getattr(self, attr, None) + if length is None: + log.debug( + "autocomplete length fallback (unmapped context_type)", + context_type=context_type, + ) + return self.autocomplete_character_detail_suggestion_length + return length + + @property + def autocomplete_hints_enabled(self): + return self.resolve_config("autocomplete", "hints_enabled") # actions @@ -285,9 +381,16 @@ class AssistantMixin: kind = f"create_{generation_context.length}" + hint = None + if self.autocomplete_hints_enabled and generation_context.partial: + cleaned, hint = util.extract_autocomplete_hint(generation_context.partial) + if hint is not None: + generation_context.partial = cleaned + log.debug( f"Contextual generate: {context_typ} - {context_name}", generation_context=generation_context, + hint=hint, ) character = ( @@ -308,6 +411,7 @@ class AssistantMixin: "history_aware": generation_context.history_aware, "character": character, "template": generation_context.template, + "hint": hint, } emission = ContextualGenerateEmission( @@ -315,6 +419,7 @@ class AssistantMixin: content_generation_context=generation_context, character=character, template_vars=template_vars, + autocomplete_hint=hint, ) await async_signals.get("agent.creator.contextual_generate.before").send( @@ -342,7 +447,11 @@ class AssistantMixin: except Exception as e: log.warning("Failed to extract list", error=e) content = "[]" - elif context_typ == "character dialogue": + elif context_typ == "character dialogue" and not generation_context.partial: + # Full-line generation: format as a complete "Name: ..." example. + # A partial continuation skips this and falls through to the generic + # path below, which returns only the continuation (no name prepend, + # no sentence trimming) so it joins cleanly onto the user's draft. if not content.startswith(generation_context.character + ":"): content = generation_context.character + ": " + content content = util.strip_partial_sentences(content) @@ -366,7 +475,12 @@ class AssistantMixin: if content.lower().startswith(context_name + ": "): content = content[len(context_name) + 2 :] - emission.response = content.strip().strip("*").strip() + if generation_context.partial: + # Preserve leading whitespace so a continuation joins cleanly onto the + # partial (e.g. "the dusty" + " road") instead of fusing words. + emission.response = content.rstrip().rstrip("*").rstrip() + else: + emission.response = content.strip().strip("*").strip() await async_signals.get("agent.creator.contextual_generate.after").send( emission @@ -500,11 +614,21 @@ class AssistantMixin: if not response_length: response_length = self.autocomplete_dialogue_suggestion_length + input, hint = ( + util.extract_autocomplete_hint(input) + if self.autocomplete_hints_enabled + else (input, None) + ) + # continuing recent character message non_anchor, anchor = util.split_anchor_text(input, 10) self.scene.log.debug( - "autocomplete_anchor", anchor=anchor, non_anchor=non_anchor, input=input + "autocomplete_anchor", + anchor=anchor, + non_anchor=non_anchor, + input=input, + hint=hint, ) continuing_message = False @@ -531,6 +655,7 @@ class AssistantMixin: "message": message, "anchor": anchor, "non_anchor": non_anchor, + "hint": hint, } emission = AutocompleteEmission( @@ -539,6 +664,7 @@ class AssistantMixin: type="dialogue", character=character, template_vars=template_vars, + autocomplete_hint=hint, ) await async_signals.get("agent.creator.autocomplete.before").send(emission) @@ -606,6 +732,12 @@ class AssistantMixin: if not response_length: response_length = self.autocomplete_narrative_suggestion_length + input, hint = ( + util.extract_autocomplete_hint(input) + if self.autocomplete_hints_enabled + else (input, None) + ) + # Split the input text into non-anchor and anchor parts non_anchor, anchor = util.split_anchor_text(input, 10) @@ -614,6 +746,7 @@ class AssistantMixin: anchor=anchor, non_anchor=non_anchor, input=input, + hint=hint, ) template_vars = { @@ -624,6 +757,7 @@ class AssistantMixin: "response_length": response_length, "anchor": anchor, "non_anchor": non_anchor, + "hint": hint, } emission = AutocompleteEmission( @@ -631,6 +765,7 @@ class AssistantMixin: input=input, type="narrative", template_vars=template_vars, + autocomplete_hint=hint, ) await async_signals.get("agent.creator.autocomplete.before").send(emission) diff --git a/src/talemate/agents/director/__init__.py b/src/talemate/agents/director/__init__.py index 39a6e91f..712b09a1 100644 --- a/src/talemate/agents/director/__init__.py +++ b/src/talemate/agents/director/__init__.py @@ -126,11 +126,11 @@ class DirectorAgent( @property def actor_direction_mode(self): - return self.actions["direct"].config["actor_direction_mode"].value + return self.resolve_config("direct", "actor_direction_mode") @property def direction_stickiness(self): - return int(self.actions["direct"].config["direction_stickiness"].value) + return int(self.resolve_config("direct", "direction_stickiness")) async def log_function_call(self, call: Call): log.debug("director.log_function_call", call=call) diff --git a/src/talemate/agents/director/character_management.py b/src/talemate/agents/director/character_management.py index 484f7625..7fcdf7bc 100644 --- a/src/talemate/agents/director/character_management.py +++ b/src/talemate/agents/director/character_management.py @@ -1,6 +1,5 @@ from typing import TYPE_CHECKING import traceback -import dataclasses import structlog import talemate.instance as instance import talemate.agents.tts.voice_library as voice_library @@ -35,7 +34,6 @@ if TYPE_CHECKING: from talemate.agents.tts import TTSAgent -@dataclasses.dataclass class PersistCharacterEmission(AgentEmission): character: "Character" @@ -91,17 +89,15 @@ class CharacterManagementMixin: @property def cm_assign_voice(self) -> bool: - return self.actions["character_management"].config["assign_voice"].value + return self.resolve_config("character_management", "assign_voice") @property def cm_generate_visuals(self) -> bool: - return self.actions["character_management"].config["generate_visuals"].value + return self.resolve_config("character_management", "generate_visuals") @property def cm_max_attributes(self) -> int: - return int( - self.actions["character_management"].config["max_attributes"].value or 0 - ) + return int(self.resolve_config("character_management", "max_attributes") or 0) @property def cm_should_assign_voice(self) -> bool: diff --git a/src/talemate/agents/director/chat/mixin.py b/src/talemate/agents/director/chat/mixin.py index b3bc85ce..d75b007e 100644 --- a/src/talemate/agents/director/chat/mixin.py +++ b/src/talemate/agents/director/chat/mixin.py @@ -137,40 +137,40 @@ class DirectorChatMixin: @property def chat_missing_response_retry_max(self) -> int: - return int(self.actions["chat"].config["missing_response_retry_max"].value) + return int(self.resolve_config("chat", "missing_response_retry_max")) @property def chat_auto_iterations_limit(self) -> int: - return int(self.actions["chat"].config["auto_iterations_limit"].value) + return int(self.resolve_config("chat", "auto_iterations_limit")) @property def chat_generate_arc_iterations_limit(self) -> int: - return int(self.actions["chat"].config["generate_arc_iterations_limit"].value) + return int(self.resolve_config("chat", "generate_arc_iterations_limit")) @property def chat_response_length(self) -> int: - return int(self.actions["chat"].config["response_length"].value) + return int(self.resolve_config("chat", "response_length")) @property def chat_scene_context_ratio(self) -> float: - return self.actions["chat"].config["scene_context_ratio"].value + return self.resolve_config("chat", "scene_context_ratio") @property def chat_enable_analysis(self) -> bool: - return self.actions["chat"].config["enable_analysis"].value + return self.resolve_config("chat", "enable_analysis") @property def chat_staleness_threshold(self) -> float: - return self.actions["chat"].config["staleness_threshold"].value + return self.resolve_config("chat", "staleness_threshold") @property def chat_custom_instructions(self) -> str: - return self.actions["chat"].config["custom_instructions"].value + return self.resolve_config("chat", "custom_instructions") @property def chat_action_confirm_timeout(self) -> int: """Timeout in seconds. 0 means wait indefinitely.""" - return int(self.actions["chat"].config["action_confirm_timeout"].value) * 60 + return int(self.resolve_config("chat", "action_confirm_timeout")) * 60 # === Node initialization === diff --git a/src/talemate/agents/director/chat/websocket_handler.py b/src/talemate/agents/director/chat/websocket_handler.py index 41ea17ab..a3b99faa 100644 --- a/src/talemate/agents/director/chat/websocket_handler.py +++ b/src/talemate/agents/director/chat/websocket_handler.py @@ -431,20 +431,19 @@ class DirectorChatWebsocketMixin: async def handle_chat_create_generate_arc(self, data: dict): payload = ChatCreateGenerateArcPayload(**data) + # Per-generation overrides; intentionally not persisted. if payload.dialogue_ratio is not None: - self.director.actions["plan"].config[ - "dialogue_ratio" - ].value = payload.dialogue_ratio + self.director.write_config("plan", "dialogue_ratio", payload.dialogue_ratio) if payload.outline_critique is not None: - self.director.actions["plan"].config[ - "outline_critique" - ].value = payload.outline_critique + self.director.write_config( + "plan", "outline_critique", payload.outline_critique + ) if payload.expand_critique is not None: - self.director.actions["plan"].config[ - "expand_critique" - ].value = payload.expand_critique + self.director.write_config( + "plan", "expand_critique", payload.expand_critique + ) modes = ChatModeSettings( generate_arc=GenerateArcSettings(close_arc=payload.close_arc), diff --git a/src/talemate/agents/director/generate_choices.py b/src/talemate/agents/director/generate_choices.py index 81f805aa..5b021f7a 100644 --- a/src/talemate/agents/director/generate_choices.py +++ b/src/talemate/agents/director/generate_choices.py @@ -1,7 +1,7 @@ from typing import TYPE_CHECKING import random import structlog -import dataclasses +import pydantic from talemate.agents.base import ( set_processing, AgentAction, @@ -32,10 +32,9 @@ if TYPE_CHECKING: from talemate.tale_mate import Character -@dataclasses.dataclass class GenerateChoicesEmission(AgentTemplateEmission): character: "Character | None" = None - choices: list[str] = dataclasses.field(default_factory=list) + choices: list[str] = pydantic.Field(default_factory=list) class GenerateChoicesMixin: @@ -93,23 +92,23 @@ class GenerateChoicesMixin: @property def generate_choices_enabled(self): - return self.actions["_generate_choices"].enabled + return self.resolve_enabled("_generate_choices") @property def generate_choices_chance(self): - return self.actions["_generate_choices"].config["chance"].value + return self.resolve_config("_generate_choices", "chance") @property def generate_choices_num_choices(self): - return self.actions["_generate_choices"].config["num_choices"].value + return self.resolve_config("_generate_choices", "num_choices") @property def generate_choices_never_auto_progress(self): - return self.actions["_generate_choices"].config["never_auto_progress"].value + return self.resolve_config("_generate_choices", "never_auto_progress") @property def generate_choices_instructions(self): - return self.actions["_generate_choices"].config["instructions"].value + return self.resolve_config("_generate_choices", "instructions") # signal connect diff --git a/src/talemate/agents/director/guide.py b/src/talemate/agents/director/guide.py index 992c56c2..289c9a9b 100644 --- a/src/talemate/agents/director/guide.py +++ b/src/talemate/agents/director/guide.py @@ -1,7 +1,6 @@ from typing import TYPE_CHECKING import structlog from functools import wraps -import dataclasses from talemate.agents.base import ( set_processing as _set_processing, AgentAction, @@ -28,7 +27,6 @@ talemate.emit.async_signals.register( ) -@dataclasses.dataclass class DirectorGuidanceEmission(AgentTemplateEmission): pass @@ -122,23 +120,23 @@ class GuideSceneMixin: @property def guide_scene(self) -> bool: - return self.actions["guide_scene"].enabled + return self.resolve_enabled("guide_scene") @property def guide_actors(self) -> bool: - return self.actions["guide_scene"].config["guide_actors"].value + return self.resolve_config("guide_scene", "guide_actors") @property def guide_narrator(self) -> bool: - return self.actions["guide_scene"].config["guide_narrator"].value + return self.resolve_config("guide_scene", "guide_narrator") @property def guide_scene_guidance_length(self) -> int: - return int(self.actions["guide_scene"].config["guidance_length"].value) + return int(self.resolve_config("guide_scene", "guidance_length")) @property def guide_scene_cache_guidance(self) -> bool: - return self.actions["guide_scene"].config["cache_guidance"].value + return self.resolve_config("guide_scene", "cache_guidance") # signal connect diff --git a/src/talemate/agents/director/plan/mixin.py b/src/talemate/agents/director/plan/mixin.py index 29af96e0..e895b364 100644 --- a/src/talemate/agents/director/plan/mixin.py +++ b/src/talemate/agents/director/plan/mixin.py @@ -80,19 +80,19 @@ class PlanMixin: @property def plan_dialogue_ratio(self) -> float: - return float(self.actions["plan"].config["dialogue_ratio"].value) + return float(self.resolve_config("plan", "dialogue_ratio")) @property def plan_expand_chunk_size(self) -> int: - return int(self.actions["plan"].config["expand_chunk_size"].value) + return int(self.resolve_config("plan", "expand_chunk_size")) @property def plan_outline_critique(self) -> bool: - return bool(self.actions["plan"].config["outline_critique"].value) + return bool(self.resolve_config("plan", "outline_critique")) @property def plan_expand_critique(self) -> bool: - return bool(self.actions["plan"].config["expand_critique"].value) + return bool(self.resolve_config("plan", "expand_critique")) # === Expand pipeline methods === diff --git a/src/talemate/agents/director/plan/nodes.py b/src/talemate/agents/director/plan/nodes.py index a937b64b..fc1af299 100644 --- a/src/talemate/agents/director/plan/nodes.py +++ b/src/talemate/agents/director/plan/nodes.py @@ -144,7 +144,7 @@ class CreatePlan(Node): # Set scene perspective from plan meta if available perspective = meta.get("perspective") if perspective: - scene.perspective = perspective + scene.perspectives.default = perspective log.info("plan.set_perspective", perspective=perspective) # Link plan to the active director chat via context @@ -297,9 +297,7 @@ class GetActivePlan(Node): # Fall back to scene perspective or a sensible default if not perspective: scene = active_scene.get() - perspective = ( - getattr(scene, "perspective", "") or "Third person, past tense." - ) + perspective = scene.perspectives.default or "Third person, past tense." self.set_output_values( { diff --git a/src/talemate/agents/director/scene_direction/mixin.py b/src/talemate/agents/director/scene_direction/mixin.py index 84502bf3..dfbcfe1c 100644 --- a/src/talemate/agents/director/scene_direction/mixin.py +++ b/src/talemate/agents/director/scene_direction/mixin.py @@ -141,41 +141,39 @@ class SceneDirectionMixin: @property def direction_missing_response_retry_max(self) -> int: - return int( - self.actions["scene_direction"].config["missing_response_retry_max"].value - ) + return int(self.resolve_config("scene_direction", "missing_response_retry_max")) @property def direction_response_length(self) -> int: - return int(self.actions["scene_direction"].config["response_length"].value) + return int(self.resolve_config("scene_direction", "response_length")) @property def direction_max_actions_per_turn(self) -> int: - return int(self.actions["scene_direction"].config["max_actions_per_turn"].value) + return int(self.resolve_config("scene_direction", "max_actions_per_turn")) @property def direction_scene_context_ratio(self) -> float: - return self.actions["scene_direction"].config["scene_context_ratio"].value + return self.resolve_config("scene_direction", "scene_context_ratio") @property def direction_enable_analysis(self) -> bool: - return self.actions["scene_direction"].config["enable_analysis"].value + return self.resolve_config("scene_direction", "enable_analysis") @property def direction_staleness_threshold(self) -> float: - return self.actions["scene_direction"].config["staleness_threshold"].value + return self.resolve_config("scene_direction", "staleness_threshold") @property def direction_custom_instructions(self) -> str: - return self.actions["scene_direction"].config["custom_instructions"].value + return self.resolve_config("scene_direction", "custom_instructions") @property def direction_maintain_turn_balance(self) -> bool: - return self.actions["scene_direction"].config["maintain_turn_balance"].value + return self.resolve_config("scene_direction", "maintain_turn_balance") @property def direction_enabled(self) -> bool: - return self.actions["scene_direction"].enabled + return self.resolve_enabled("scene_direction") @property def direction_enabled_with_override(self) -> bool: diff --git a/src/talemate/agents/editor/__init__.py b/src/talemate/agents/editor/__init__.py index 0fdb14b7..651b0d44 100644 --- a/src/talemate/agents/editor/__init__.py +++ b/src/talemate/agents/editor/__init__.py @@ -124,23 +124,27 @@ class EditorAgent( @property def fix_exposition_enabled(self): - return self.actions["fix_exposition"].enabled + return self.resolve_enabled("fix_exposition") + + @property + def add_detail_enabled(self): + return self.resolve_enabled("add_detail") @property def fix_exposition_formatting(self): - return self.actions["fix_exposition"].config["formatting"].value + return self.resolve_config("fix_exposition", "formatting") @property def fix_exposition_narrator(self): - return self.actions["fix_exposition"].config["narrator"].value + return self.resolve_config("fix_exposition", "narrator") @property def fix_exposition_user_input(self): - return self.actions["fix_exposition"].config["user_input"].value + return self.resolve_config("fix_exposition", "user_input") @property def allow_incomplete_sentences(self): - return self.actions["fix_exposition"].config["allow_incomplete_sentences"].value + return self.resolve_config("fix_exposition", "allow_incomplete_sentences") def connect(self, scene): super().connect(scene) @@ -298,7 +302,7 @@ class EditorAgent( Edits a text to increase its length and add extra detail and exposition """ - if not self.actions["add_detail"].enabled: + if not self.add_detail_enabled: return content response, extracted = await Prompt.request( diff --git a/src/talemate/agents/editor/revision.py b/src/talemate/agents/editor/revision.py index 6819a615..e70e7062 100644 --- a/src/talemate/agents/editor/revision.py +++ b/src/talemate/agents/editor/revision.py @@ -11,7 +11,6 @@ import structlog import uuid import pydantic from pydantic import ConfigDict -import dataclasses import re from talemate.agents.base import ( set_processing, @@ -24,12 +23,16 @@ from talemate.agents.base import ( from talemate.instance import get_agent from talemate.emit import emit import talemate.emit.async_signals as async_signals -from talemate.agents.conversation import ConversationAgentEmission -from talemate.agents.narrator import NarratorAgentEmission from talemate.agents.creator.assistant import ContextualGenerateEmission from talemate.agents.summarize import SummarizeEmission from talemate.agents.summarize.layered_history import LayeredHistoryFinalizeEmission -from talemate.scene_message import CharacterMessage +from talemate.events import HistoryEvent +from talemate.scene_message import ( + CharacterMessage, + ContextInvestigationMessage, + NarratorMessage, + SceneMessage, +) from talemate.util.dedupe import ( SimilarityMatch, compile_text_to_sentences, @@ -174,14 +177,13 @@ async_signals.register( ) -@dataclasses.dataclass class RevisionEmission(AgentTemplateEmission): """ Emission for the revision agent """ - info: RevisionInformation = dataclasses.field(default_factory=RevisionInformation) - issues: Issues = dataclasses.field(default_factory=Issues) + info: RevisionInformation = pydantic.Field(default_factory=RevisionInformation) + issues: Issues = pydantic.Field(default_factory=Issues) # Maximum ratio of fix length to original length before discarding @@ -315,6 +317,11 @@ class RevisionMixin: "value": "narrator", "help": "Automatically revise narrator actions.", }, + { + "label": "Context Investigations", + "value": "context_investigation", + "help": "Automatically revise context-investigation messages (look-at, query, examine, etc).", + }, { "label": "Contextual generation", "value": "contextual_generation", @@ -439,6 +446,27 @@ class RevisionMixin: max=100, step=1, ), + "repetition_handling": AgentActionConfig( + type="text", + label="Repetition handling", + description="What the AI-assisted revision should do with detected repetitions.", + condition=rewrite_unslop_condition, + value="remove", + choices=[ + {"label": "Remove", "value": "remove"}, + {"label": "Attempt rewrite", "value": "rewrite"}, + ], + note_on_value={ + "remove": AgentActionNote( + color="primary", + text="Flagged repetitions are deleted from the text. Safer choice for weaker models, which often struggle to substitute genuinely different content and end up rephrasing the same idea — re-introducing the very repetition the rule is meant to fix.", + ), + "rewrite": AgentActionNote( + color="primary", + text="The agent will attempt to rewrite flagged repetitions with genuinely different content. Because matches are semantic, simply rephrasing the same idea is not enough — the underlying beat must change, which weaker models tend to fail at. Best with stronger models. Falls back to removal only when no meaningful rewrite is possible.", + ), + }, + ), }, ) @@ -446,61 +474,59 @@ class RevisionMixin: @property def revision_enabled(self): - return self.actions["revision"].enabled + return self.resolve_enabled("revision") @property def revision_automatic_enabled(self) -> bool: - return self.actions["revision"].config["automatic_revision"].value + return self.resolve_config("revision", "automatic_revision") @property def revision_automatic_targets(self) -> list[str]: - return self.actions["revision"].config["automatic_revision_targets"].value + return self.resolve_config("revision", "automatic_revision_targets") @property def revision_method(self): - return self.actions["revision"].config["revision_method"].value + return self.resolve_config("revision", "revision_method") @property def revision_repetition_detection_method(self): - return self.actions["revision"].config["repetition_detection_method"].value + return self.resolve_config("revision", "repetition_detection_method") @property def revision_repetition_threshold(self): - return self.actions["revision"].config["repetition_threshold"].value + return self.resolve_config("revision", "repetition_threshold") @property def revision_repetition_range(self): - return self.actions["revision"].config["repetition_range"].value + return self.resolve_config("revision", "repetition_range") @property def revision_repetition_min_length(self): - return self.actions["revision"].config["repetition_min_length"].value + return self.resolve_config("revision", "repetition_min_length") + + @property + def revision_repetition_handling(self): + return self.resolve_config("revision", "repetition_handling") @property def revision_split_on_comma(self): - return self.actions["revision"].config["split_on_comma"].value + return self.resolve_config("revision", "split_on_comma") @property def revision_min_issues(self): - return self.actions["revision"].config["min_issues"].value + return self.resolve_config("revision", "min_issues") @property def revision_detect_bad_prose_enabled(self): - return self.actions["revision"].config["detect_bad_prose"].value + return self.resolve_config("revision", "detect_bad_prose") @property def revision_detect_bad_prose_threshold(self): - return self.actions["revision"].config["detect_bad_prose_threshold"].value + return self.resolve_config("revision", "detect_bad_prose_threshold") # signal connect def connect(self, scene): - async_signals.get("agent.conversation.generated").connect( - self.revision_on_generation - ) - async_signals.get("agent.narrator.generated").connect( - self.revision_on_generation - ) async_signals.get("agent.creator.contextual_generate.after").connect( self.revision_on_generation ) @@ -510,19 +536,38 @@ class RevisionMixin: async_signals.get("agent.summarization.layered_history.finalize").connect( self.revision_on_generation ) + # Character / narrator revision runs at push_history time so the + # SceneMessage exists and the pre-revision text is naturally + # preserved as the initial "original" entry of the version stack + # when we append the revised version. + async_signals.get("push_history").connect(self.revision_on_push) # connect to the super class AFTER so these run first. super().connect(scene) + def _revision_automatic_target_enabled(self, target: str) -> bool: + if not self.revision_enabled or not self.revision_automatic_enabled: + return False + return target in self.revision_automatic_targets + + def _revision_disabled_by_context(self) -> bool: + try: + return bool(revision_disabled_context.get()) + except LookupError: + return False + async def revision_on_generation( self, - emission: ConversationAgentEmission - | NarratorAgentEmission - | ContextualGenerateEmission + emission: ContextualGenerateEmission | SummarizeEmission | LayeredHistoryFinalizeEmission, ): """ - Called when a conversation or narrator message is generated + Auto-revision for non-SceneMessage emissions (contextual generation + and summarization). Mutates ``emission.response`` in place because + these flows have no SceneMessage to attach a mutation history to. + + Character / narrator emissions are handled separately at + ``push_history`` time by ``revision_on_push``. """ if not self.revision_enabled or not self.revision_automatic_enabled: @@ -534,18 +579,6 @@ class RevisionMixin: ): return - if ( - isinstance(emission, ConversationAgentEmission) - and "character" not in self.revision_automatic_targets - ): - return - - if ( - isinstance(emission, NarratorAgentEmission) - and "narrator" not in self.revision_automatic_targets - ): - return - if isinstance(emission, SummarizeEmission): if ( emission.summarization_type == "dialogue" @@ -563,15 +596,12 @@ class RevisionMixin: ): return - try: - if revision_disabled_context.get(): - log.debug( - "revision_on_generation: revision disabled through context", - emission=emission, - ) - return - except LookupError: - pass + if self._revision_disabled_by_context(): + log.debug( + "revision_on_generation: revision disabled through context", + emission=emission, + ) + return info = RevisionInformation( text=emission.response, @@ -599,12 +629,126 @@ class RevisionMixin: original=info.text, ) + async def maybe_revise_inplace(self, message: SceneMessage) -> bool: + """ + Run auto-revision on a SceneMessage. If the configured revision + method actually rewrote the text, a new ``"revision"`` version is + appended to ``message.versions`` (which makes it the active + canonical) and the call returns ``True``. Otherwise returns + ``False`` and the stack is untouched. + + The pre-revision text is naturally preserved as whichever stack + entry was active before — typically the "original" seeded at + construction, but it could be a prior revision/regenerate if this + message has already been rewritten. + + Gating mirrors ``revision_on_generation``: only fires for + ``CharacterMessage`` / ``NarratorMessage`` / ``ContextInvestigationMessage`` + when the corresponding ``automatic_revision_targets`` flag is set + and revision is enabled / automatic / not disabled by context. + """ + if isinstance(message, CharacterMessage): + target = "character" + elif isinstance(message, NarratorMessage): + target = "narrator" + elif isinstance(message, ContextInvestigationMessage): + target = "context_investigation" + else: + return False + + # Player-authored messages (character or narrator) must not be + # auto-revised — the player's words are theirs, not ours to rewrite. + if message.source == "player": + return False + + if not self._revision_automatic_target_enabled(target): + return False + + if self._revision_disabled_by_context(): + log.debug( + "maybe_revise_inplace: revision disabled through context", + message_id=message.id, + ) + return False + + character = None + if isinstance(message, CharacterMessage): + character = self.scene.get_character(message.character_name) + + original = message.message + info = RevisionInformation(text=original, character=character) + revised = await self.revision_revise(info) + + if revised == original: + return False + + log.info( + "maybe_revise_inplace: revision applied", + type=type(message).__name__, + revised=revised, + original=original, + ) + message.append_version(revised, source="revision") + return True + + async def revision_on_push(self, event: HistoryEvent): + """ + Run auto-revision on the first CharacterMessage / NarratorMessage / + ContextInvestigationMessage being pushed to scene history. When the + revision rewrites the text, a new ``"revision"`` version is appended + to the message's stack — the original is already at index 0 from + construction, so the wire emit picks up both atomically when + push_history returns. + + Only the first matching message is processed — push_history events + rarely batch revisable messages, and revision is a single-target + operation. + + Cancellation: the message is already committed to ``scene.history`` + by the time this fires, and callers emit the wire message *after* + ``push_history`` returns. If a cancel escapes here, the trailing + ``push_history.after`` listeners (e.g. the summarizer's + ``build_archive``) would also re-raise off the still-set + ``scene.cancel_requested`` flag, stripping the wire emit and + leaving the message stranded in history but invisible in the UX. + Contain the cancel here so the original message still reaches the + UX — equivalent to "revision aborted, keep the original". + """ + for message in event.messages: + if not isinstance( + message, + (CharacterMessage, NarratorMessage, ContextInvestigationMessage), + ): + continue + # The message is already in scene history by the time the + # push_history signal fires, so scope the revision context to + # it — otherwise the repetition range collects the message + # itself and every sentence matches itself at 100%. + try: + with RevisionContext(message.id): + await self.maybe_revise_inplace(message) + except GenerationCancelled: + log.warning( + "revision_on_push: revision cancelled — keeping original", + message_id=message.id, + ) + # revision_revise catches GenerationCancelled internally and + # returns the original text, but the scene-level cancel flag + # stays set (client's poll_interrupt doesn't reset it). Clear + # it so push_history.after agent actions don't re-raise. + if self.scene.cancel_requested: + self.scene.cancel_requested = False + return + # helpers async def revision_collect_repetition_range(self) -> list[str]: """ Collect the range of text to revise against by going through the scene's - history and collecting narrator and character messages + history and collecting narrator, character, and context-investigation + messages. CIMs are included regardless of whether they're an + automatic-revision target so the corpus reflects everything the player + sees in the narrative flow. """ scene: "Scene" = self.scene @@ -612,7 +756,7 @@ class RevisionMixin: ctx = revision_context.get() messages = scene.collect_messages( - typ=["narrator", "character"], + typ=["narrator", "character", "context_investigation"], max_messages=self.revision_repetition_range, start_idx=scene.message_index(ctx.message_id) - 1 if ctx.message_id @@ -1116,6 +1260,7 @@ class RevisionMixin: "response_length": response_length, "max_tokens": self.client.max_token_length, "repetition": issues.repetition, + "repetition_handling": self.revision_repetition_handling, "bad_prose": issues.bad_prose, "dynamic_instructions": emission.dynamic_instructions, "context_type": info.context_type, @@ -1142,9 +1287,9 @@ class RevisionMixin: await async_signals.get("agent.editor.revision-analysis.after").send(emission) revision = extracted["revision"] - if revision is None: + if not revision: log.debug( - "revision_rewrite: no found in response, keeping original" + "revision_rewrite: no content in response, keeping original" ) return original_text @@ -1208,6 +1353,8 @@ class RevisionMixin: template = "editor.unslop-contextual-generation" elif info.summarization_history is not None: template = "editor.unslop-summarization" + elif character is None: + template = "editor.unslop-narration" log.debug("revision_unslop: issues", issues=issues, template=template) @@ -1227,6 +1374,7 @@ class RevisionMixin: "response_length": response_length, "max_tokens": self.client.max_token_length, "repetition": issues.repetition, + "repetition_handling": self.revision_repetition_handling, "bad_prose": issues.bad_prose, "dynamic_instructions": emission.dynamic_instructions, "context_type": info.context_type, @@ -1246,8 +1394,10 @@ class RevisionMixin: ) fix = extracted["fix"] - if fix is None: - log.debug("revision_unslop: no found in response", response=response) + if not fix: + log.debug( + "revision_unslop: no content in response", response=response + ) return original_text # Guard: if the fix is substantially longer than the original, @@ -1282,4 +1432,3 @@ class RevisionMixin: if agent_function_name == "revision_revise": if prompt_param.get("extra_stopping_strings") is None: prompt_param["extra_stopping_strings"] = [] - prompt_param["extra_stopping_strings"] += [""] diff --git a/src/talemate/agents/editor/websocket_handler.py b/src/talemate/agents/editor/websocket_handler.py index 5be60561..c530ebb9 100644 --- a/src/talemate/agents/editor/websocket_handler.py +++ b/src/talemate/agents/editor/websocket_handler.py @@ -56,14 +56,34 @@ class EditorWebsocketHandler(Plugin): if not message: raise Exception("Message not found") + original = message.message + with RevisionContext(message.id): info = RevisionInformation( - text=message.message, + text=original, character=character, ) revised = await editor.revision_revise(info) if isinstance(message, CharacterMessage): - if not revised.startswith(character.name + ":"): - revised = f"{character.name}: {revised}" + revised = CharacterMessage.with_name_prefix(character.name, revised) - scene.edit_message(message.id, revised) + # Append the revised text as a new version so the frontend's + # navigator can step between the prior canonical and the revision. + # No-op revisions don't touch the message and surface a status so + # the user knows the action completed without effect. + # + # Either way, post an `operation_done` envelope so the frontend can + # clear the per-message "regenerating" spinner — the happy path also + # clears on the `message_edited` echo, but the no-op path has no + # other completion signal. + if revised != original: + scene.append_message_version(message.id, revised, source="revision") + await self.signal_operation_done(signal_only=True) + else: + await self.signal_operation_done( + signal_only=True, + emit_status_message={ + "message": "Editor revision produced no changes", + "status": "info", + }, + ) diff --git a/src/talemate/agents/memory/__init__.py b/src/talemate/agents/memory/__init__.py index 951b62b9..55548e54 100644 --- a/src/talemate/agents/memory/__init__.py +++ b/src/talemate/agents/memory/__init__.py @@ -33,6 +33,7 @@ from talemate.agents.memory.exceptions import ( SetDBError, ) from talemate.agents.memory.schema import MemoryDocument +from talemate.util.gpu import release_cuda_cache import talemate.agents.memory.nodes # noqa: F401 @@ -81,6 +82,7 @@ class MemoryAgent(Agent): label="Embeddings", choices=presets, description="Which embeddings to use", + scene_overridable=False, ), "device": AgentActionConfig( type="text", @@ -92,6 +94,7 @@ class MemoryAgent(Agent): {"value": "cpu", "label": "CPU"}, {"value": "cuda", "label": "CUDA"}, ], + scene_overridable=False, ), }, ), @@ -1060,13 +1063,14 @@ class ChromaDBMemoryAgent(MemoryAgent): """Release an embedding model and free GPU memory if applicable.""" try: import torch - - if torch.cuda.is_available(): - model.to("cpu") - del model - torch.cuda.empty_cache() except ImportError: - pass + return + + if torch.cuda.is_available(): + model.to("cpu") + del model + + release_cuda_cache() def _add(self, text, character=None, uid=None, ts: str = None, **kwargs): metadatas = [] diff --git a/src/talemate/agents/memory/rag.py b/src/talemate/agents/memory/rag.py index 36ffdf88..0eaa602d 100644 --- a/src/talemate/agents/memory/rag.py +++ b/src/talemate/agents/memory/rag.py @@ -108,27 +108,27 @@ class MemoryRAGMixin: @property def long_term_memory_enabled(self): - return self.actions["use_long_term_memory"].enabled + return self.resolve_enabled("use_long_term_memory") @property def long_term_memory_retrieval_method(self): - return self.actions["use_long_term_memory"].config["retrieval_method"].value + return self.resolve_config("use_long_term_memory", "retrieval_method") @property def long_term_memory_number_of_queries(self): - return self.actions["use_long_term_memory"].config["number_of_queries"].value + return self.resolve_config("use_long_term_memory", "number_of_queries") @property def long_term_memory_answer_length(self): - return int(self.actions["use_long_term_memory"].config["answer_length"].value) + return int(self.resolve_config("use_long_term_memory", "answer_length")) @property def long_term_memory_cache(self): - return self.actions["use_long_term_memory"].config["cache"].value + return self.resolve_config("use_long_term_memory", "cache") @property def long_term_memory_num_messages(self): - return self.actions["use_long_term_memory"].config["num_messages"].value + return self.resolve_config("use_long_term_memory", "num_messages") @property def long_term_memory_cache_key(self): diff --git a/src/talemate/agents/narrator/__init__.py b/src/talemate/agents/narrator/__init__.py index 2514bdda..3f3d9663 100644 --- a/src/talemate/agents/narrator/__init__.py +++ b/src/talemate/agents/narrator/__init__.py @@ -1,9 +1,9 @@ from __future__ import annotations -import dataclasses from functools import wraps from typing import TYPE_CHECKING +import pydantic import structlog import talemate.client as client @@ -45,11 +45,10 @@ if TYPE_CHECKING: log = structlog.get_logger("talemate.agents.narrator") -@dataclasses.dataclass class NarratorAgentEmission(AgentEmission): - generation: list[str] = dataclasses.field(default_factory=list) - response: str = dataclasses.field(default="") - dynamic_instructions: list[DynamicInstruction] = dataclasses.field( + generation: list[str] = pydantic.Field(default_factory=list) + response: str = "" + dynamic_instructions: list[DynamicInstruction] = pydantic.Field( default_factory=list ) @@ -71,24 +70,37 @@ def set_processing(fn): @wraps(fn) async def narration_wrapper(self, *args, **kwargs): agent_context = active_agent.get() - emission: NarratorAgentEmission = NarratorAgentEmission(agent=self) + # ActiveAgent.__enter__ shares state by reference with the parent context, + # so we save/restore the prior value rather than mutating in place. + _UNSET = object() + previous_speaker_role = agent_context.state.get("speaker_role", _UNSET) + agent_context.state["speaker_role"] = "narrator" + try: + emission: NarratorAgentEmission = NarratorAgentEmission(agent=self) - if self.content_use_writing_style: - self.set_context_states(writing_style=self.scene.writing_style) + if self.content_use_writing_style: + self.set_context_states(writing_style=self.scene.writing_style) - await talemate.emit.async_signals.get("agent.narrator.before_generate").send( - emission - ) - await talemate.emit.async_signals.get( - "agent.narrator.inject_instructions" - ).send(emission) + await talemate.emit.async_signals.get( + "agent.narrator.before_generate" + ).send(emission) + await talemate.emit.async_signals.get( + "agent.narrator.inject_instructions" + ).send(emission) - agent_context.state["dynamic_instructions"] = emission.dynamic_instructions + agent_context.state["dynamic_instructions"] = emission.dynamic_instructions - response = await fn(self, *args, **kwargs) - emission.response = response - await talemate.emit.async_signals.get("agent.narrator.generated").send(emission) - return emission.response + response = await fn(self, *args, **kwargs) + emission.response = response + await talemate.emit.async_signals.get("agent.narrator.generated").send( + emission + ) + return emission.response + finally: + if previous_speaker_role is _UNSET: + agent_context.state.pop("speaker_role", None) + else: + agent_context.state["speaker_role"] = previous_speaker_role return narration_wrapper @@ -260,38 +272,42 @@ class NarratorAgent(MemoryRAGMixin, AutoNarrationMixin, Agent): # agent actions self.actions = NarratorAgent.init_actions() + @property + def generation_override_enabled(self) -> bool: + return self.resolve_enabled("generation_override") + @property def extra_instructions(self) -> str: - if self.actions["generation_override"].enabled: - return self.actions["generation_override"].config["instructions"].value + if self.generation_override_enabled: + return self.resolve_config("generation_override", "instructions") return "" @property def jiggle(self) -> float: - if self.actions["generation_override"].enabled: - return self.actions["generation_override"].config["jiggle"].value + if self.generation_override_enabled: + return self.resolve_config("generation_override", "jiggle") return 0.0 def action_response_length(self, action_name: str) -> int: """Get the configured response length for a specific narrator action.""" config_key = f"length_{action_name}" - if self.actions["generation_override"].enabled: + if self.generation_override_enabled: config = self.actions["generation_override"].config.get(config_key) if config is not None: - return config.value + return self.resolve_config("generation_override", config_key) return 128 @property def narrate_time_passage_enabled(self) -> bool: - return self.actions["narrate_time_passage"].enabled + return self.resolve_enabled("narrate_time_passage") @property def content_use_scene_intent(self) -> bool: - return self.actions["content"].config["use_scene_intent"].value + return self.resolve_config("content", "use_scene_intent") @property def content_use_writing_style(self) -> bool: - return self.actions["content"].config["use_writing_style"].value + return self.resolve_config("content", "use_writing_style") def calc_response_length(self, value: int | None, action_name: str) -> int: """Calculate response length: use explicit value if provided, otherwise use per-action config.""" @@ -366,7 +382,7 @@ class NarratorAgent(MemoryRAGMixin, AutoNarrationMixin, Agent): Handles time passage narration, if enabled """ - if not self.actions["narrate_time_passage"].enabled: + if not self.narrate_time_passage_enabled: return response = await self.narrate_time_passage( @@ -384,8 +400,8 @@ class NarratorAgent(MemoryRAGMixin, AutoNarrationMixin, Agent): }, }, ) - emit("narrator", narrator_message) await self.scene.push_history(narrator_message) + emit("narrator", narrator_message) @set_processing @store_context_state( @@ -823,7 +839,7 @@ class NarratorAgent(MemoryRAGMixin, AutoNarrationMixin, Agent): return True def set_generation_overrides(self, prompt_param: dict): - if not self.actions["generation_override"].enabled: + if not self.generation_override_enabled: return if self.jiggle > 0.0: diff --git a/src/talemate/agents/narrator/auto_narration.py b/src/talemate/agents/narrator/auto_narration.py index a9c84334..c8e4689b 100644 --- a/src/talemate/agents/narrator/auto_narration.py +++ b/src/talemate/agents/narrator/auto_narration.py @@ -73,28 +73,24 @@ class AutoNarrationMixin: @property def auto_narration_enabled(self) -> bool: - return self.actions["auto_narration"].enabled + return self.resolve_enabled("auto_narration") @property def auto_narration_chance(self) -> float: """Master chance (0.0–1.0) that auto narration fires on a tick.""" - return self.actions["auto_narration"].config["chance"].value + return self.resolve_config("auto_narration", "chance") @property def auto_narration_weights(self) -> dict[str, float]: """Relative weights of each candidate action. Sums to ~1.0.""" - return dict(self.actions["auto_narration"].config["weights"].value) + return dict(self.resolve_config("auto_narration", "weights")) def auto_narration_weight(self, action_name: str) -> float: return self.auto_narration_weights.get(action_name, 0.0) @property def auto_narration_disable_during_scene_direction(self) -> bool: - return ( - self.actions["auto_narration"] - .config["disable_during_scene_direction"] - .value - ) + return self.resolve_config("auto_narration", "disable_during_scene_direction") @property def auto_narration_enabled_actions(self) -> list[str]: diff --git a/src/talemate/agents/narrator/websocket_handler.py b/src/talemate/agents/narrator/websocket_handler.py index ddc1a4f4..bc6eb597 100644 --- a/src/talemate/agents/narrator/websocket_handler.py +++ b/src/talemate/agents/narrator/websocket_handler.py @@ -78,8 +78,8 @@ class NarratorWebsocketHandler(Plugin): ) message.set_source("narrator", "narrate_query", **payload.model_dump()) - emit("context_investigation", message=message) await self.scene.push_history(message) + emit("context_investigation", message=message) @set_loading("Looking at the scene", cancellable=True, as_async=True) async def handle_look_at_scene(self, data: dict): @@ -99,8 +99,8 @@ class NarratorWebsocketHandler(Plugin): ) message.set_source("narrator", "narrate_scene", **payload.model_dump()) - emit("context_investigation", message=message) await self.scene.push_history(message) + emit("context_investigation", message=message) @set_loading("Looking at a character", cancellable=True, as_async=True) async def handle_look_at_character(self, data: dict): @@ -121,5 +121,5 @@ class NarratorWebsocketHandler(Plugin): ) message.set_source("narrator", "narrate_character", **payload.model_dump()) - emit("context_investigation", message=message) await self.scene.push_history(message) + emit("context_investigation", message=message) diff --git a/src/talemate/agents/summarize/__init__.py b/src/talemate/agents/summarize/__init__.py index 8afe7e00..232656d3 100644 --- a/src/talemate/agents/summarize/__init__.py +++ b/src/talemate/agents/summarize/__init__.py @@ -1,7 +1,6 @@ from __future__ import annotations import re -import dataclasses import structlog from typing import TYPE_CHECKING, Literal @@ -57,15 +56,13 @@ talemate.emit.async_signals.register( ) -@dataclasses.dataclass class BuildArchiveEmission(AgentEmission): generation_options: GenerationOptions | None = None -@dataclasses.dataclass class SummarizeEmission(AgentTemplateEmission): text: str = "" - extra_context: str | None = None + extra_context: list[str] | str | None = None extra_instructions: str | None = None generation_options: GenerationOptions | None = None summarization_history: list[str] | None = None @@ -158,9 +155,13 @@ class SummarizeAgent( self.actions = SummarizeAgent.init_actions() + @property + def archive_enabled(self): + return self.resolve_enabled("archive") + @property def threshold(self): - return self.actions["archive"].config["threshold"].value + return self.resolve_config("archive", "threshold") @property def estimated_entry_count(self): @@ -169,19 +170,19 @@ class SummarizeAgent( @property def archive_threshold(self): - return self.actions["archive"].config["threshold"].value + return self.resolve_config("archive", "threshold") @property def archive_method(self): - return self.actions["archive"].config["method"].value + return self.resolve_config("archive", "method") @property def archive_include_previous(self): - return self.actions["archive"].config["include_previous"].value + return self.resolve_config("archive", "include_previous") @property def archive_instructions(self): - return self.actions["archive"].config["instructions"].value + return self.resolve_config("archive", "instructions") def connect(self, scene): super().connect(scene) @@ -263,7 +264,7 @@ class SummarizeAgent( self, scene, generation_options: GenerationOptions | None = None ): end = None - enabled = self.actions["archive"].enabled + enabled = self.archive_enabled log.debug("build_archive", enabled=enabled) @@ -294,7 +295,7 @@ class SummarizeAgent( # if there is a recent entry we also collect the 3 most recentries # as extra context - num_previous = self.actions["archive"].config["include_previous"].value + num_previous = self.archive_include_previous if recent_entry and num_previous > 0: if self.layered_history_available: extra_context = self.compile_layered_history(include_base_layer=True)[ @@ -313,7 +314,7 @@ class SummarizeAgent( ts = "PT0S" time_passage_termination = False - token_threshold = self.actions["archive"].config["threshold"].value + token_threshold = self.archive_threshold log.debug("build_archive", start=start, recent_entry=recent_entry) @@ -552,11 +553,7 @@ class SummarizeAgent( "dialogue": text, "scene": self.scene, "max_tokens": self.client.max_token_length, - "summarization_method": ( - self.actions["archive"].config["method"].value - if method is None - else method - ), + "summarization_method": (self.archive_method if method is None else method), "extra_context": extra_context or "", "num_extra_context": len(extra_context) if extra_context else 0, "extra_instructions": extra_instructions or "", diff --git a/src/talemate/agents/summarize/analyze_scene.py b/src/talemate/agents/summarize/analyze_scene.py index eca7c9c3..fb82c6cf 100644 --- a/src/talemate/agents/summarize/analyze_scene.py +++ b/src/talemate/agents/summarize/analyze_scene.py @@ -1,6 +1,6 @@ from typing import TYPE_CHECKING import structlog -import dataclasses +import pydantic from talemate.agents.base import ( set_processing, AgentAction, @@ -59,21 +59,19 @@ SCENE_ANALYSIS_SPEC = ResponseSpec( ) -@dataclasses.dataclass class SceneAnalysisEmission(AgentTemplateEmission): analysis_type: str | None = None -@dataclasses.dataclass class SceneAnalysisDeepAnalysisEmission(AgentEmission): analysis: str = "" analysis_type: str | None = None analysis_sub_type: str | None = None max_content_investigations: int = 0 # backwards compat, will be dropped - character: "Character" = None + character: "Character | None" = None investigate: str = "" deep_analysis_context: str = "" - dynamic_instructions: list[DynamicInstruction] = dataclasses.field( + dynamic_instructions: list[DynamicInstruction] = pydantic.Field( default_factory=list ) @@ -138,27 +136,27 @@ class SceneAnalyzationMixin: @property def analyze_scene(self) -> bool: - return self.actions["analyze_scene"].enabled + return self.resolve_enabled("analyze_scene") @property def analysis_length(self) -> int: - return int(self.actions["analyze_scene"].config["analysis_length"].value) + return int(self.resolve_config("analyze_scene", "analysis_length")) @property def cache_analysis(self) -> bool: - return self.actions["analyze_scene"].config["cache_analysis"].value + return self.resolve_config("analyze_scene", "cache_analysis") @property def deep_analysis(self) -> bool: - return self.actions["analyze_scene"].config["deep_analysis"].value + return self.resolve_config("analyze_scene", "deep_analysis") @property def analyze_scene_for_conversation(self) -> bool: - return self.actions["analyze_scene"].config["for_conversation"].value + return self.resolve_config("analyze_scene", "for_conversation") @property def analyze_scene_for_narration(self) -> bool: - return self.actions["analyze_scene"].config["for_narration"].value + return self.resolve_config("analyze_scene", "for_narration") # signal connect diff --git a/src/talemate/agents/summarize/context_history.py b/src/talemate/agents/summarize/context_history.py index 7071c978..d343c3c3 100644 --- a/src/talemate/agents/summarize/context_history.py +++ b/src/talemate/agents/summarize/context_history.py @@ -304,35 +304,31 @@ class ContextHistoryMixin: @property def scene_history_enforce_boundary(self) -> bool: - return self.actions["manage_scene_history"].config["enforce_boundary"].value + return self.resolve_config("manage_scene_history", "enforce_boundary") @property def scene_history_dialogue_ratio(self) -> int: - return self.actions["manage_scene_history"].config["dialogue_ratio"].value + return self.resolve_config("manage_scene_history", "dialogue_ratio") @property def scene_history_summary_detail_ratio(self) -> int: - return self.actions["manage_scene_history"].config["summary_detail_ratio"].value + return self.resolve_config("manage_scene_history", "summary_detail_ratio") @property def scene_history_max_budget(self) -> int: - return self.actions["manage_scene_history"].config["max_budget"].value + return self.resolve_config("manage_scene_history", "max_budget") @property def scene_history_best_fit(self) -> bool: - return self.actions["manage_scene_history"].config["best_fit"].value + return self.resolve_config("manage_scene_history", "best_fit") @property def scene_history_best_fit_min_dialogue(self) -> int: - return ( - self.actions["manage_scene_history"].config["best_fit_min_dialogue"].value - ) + return self.resolve_config("manage_scene_history", "best_fit_min_dialogue") @property def scene_history_best_fit_max_dialogue(self) -> int: - return ( - self.actions["manage_scene_history"].config["best_fit_max_dialogue"].value - ) + return self.resolve_config("manage_scene_history", "best_fit_max_dialogue") def _has_layered_history(self, scene: Scene) -> bool: """Check if layered history is available for the given scene.""" diff --git a/src/talemate/agents/summarize/layered_history.py b/src/talemate/agents/summarize/layered_history.py index c69f3635..805ef049 100644 --- a/src/talemate/agents/summarize/layered_history.py +++ b/src/talemate/agents/summarize/layered_history.py @@ -6,7 +6,7 @@ from talemate.agents.base import ( AgentActionConfig, AgentEmission, ) -import dataclasses +import pydantic import talemate.emit.async_signals from talemate.exceptions import GenerationCancelled from talemate.world_state.templates import GenerationOptions @@ -25,10 +25,9 @@ talemate.emit.async_signals.register( ) -@dataclasses.dataclass class LayeredHistoryFinalizeEmission(AgentEmission): entry: LayeredArchiveEntry | None = None - summarization_history: list[str] = dataclasses.field(default_factory=lambda: []) + summarization_history: list[str] = pydantic.Field(default_factory=list) @property def response(self) -> str | None: @@ -127,31 +126,31 @@ class LayeredHistoryMixin: @property def layered_history_enabled(self): - return self.actions["layered_history"].enabled + return self.resolve_enabled("layered_history") @property def layered_history_threshold(self): - return self.actions["layered_history"].config["threshold"].value + return self.resolve_config("layered_history", "threshold") @property def layered_history_max_process_tokens(self): - return self.actions["layered_history"].config["max_process_tokens"].value + return self.resolve_config("layered_history", "max_process_tokens") @property def layered_history_max_layers(self): - return self.actions["layered_history"].config["max_layers"].value + return self.resolve_config("layered_history", "max_layers") @property def layered_history_chunk_size(self) -> int: - return self.actions["layered_history"].config["chunk_size"].value + return self.resolve_config("layered_history", "chunk_size") @property def layered_history_analyze_chunks(self) -> bool: - return self.actions["layered_history"].config["analyze_chunks"].value + return self.resolve_config("layered_history", "analyze_chunks") @property def layered_history_response_length(self) -> int: - return int(self.actions["layered_history"].config["response_length"].value) + return int(self.resolve_config("layered_history", "response_length")) @property def layered_history_available(self): diff --git a/src/talemate/agents/summarize/websocket_handler.py b/src/talemate/agents/summarize/websocket_handler.py index 109bce87..25b72939 100644 --- a/src/talemate/agents/summarize/websocket_handler.py +++ b/src/talemate/agents/summarize/websocket_handler.py @@ -53,17 +53,52 @@ class SummarizeWebsocketHandler(Plugin): ) async def handle_apply_context_history_config(self, data: dict): - """Apply context history config overrides to the summarizer agent and persist.""" - payload = ApplyContextHistoryConfigPayload(**data.get("config", {})) - action_config = self.summarizer.actions["manage_scene_history"].config + """Apply context history config overrides to the summarizer agent and persist. - action_config["dialogue_ratio"].value = payload.dialogue_ratio - action_config["summary_detail_ratio"].value = payload.summary_detail_ratio - action_config["max_budget"].value = payload.max_budget - action_config["enforce_boundary"].value = payload.enforce_boundary - action_config["best_fit"].value = payload.best_fit - action_config["best_fit_min_dialogue"].value = payload.best_fit_min_dialogue - action_config["best_fit_max_dialogue"].value = payload.best_fit_max_dialogue + Each field routes through ``write_config`` so any active scene-level + override receives the new value instead of being silently shadowed by + a write to the global config it overrides. + """ + payload = ApplyContextHistoryConfigPayload(**data.get("config", {})) + + for field in ( + "dialogue_ratio", + "summary_detail_ratio", + "max_budget", + "enforce_boundary", + "best_fit", + "best_fit_min_dialogue", + "best_fit_max_dialogue", + ): + self.summarizer.write_config( + "manage_scene_history", field, getattr(payload, field) + ) + + # `write_config` routes per-field: overridden fields land in the + # overlay, the rest in the global. There's no transaction across the + # two targets, so we persist both independently and surface an overlay + # failure to the user without suppressing the global save — otherwise + # any non-overridden field's edit would be silently lost on restart. + # Overridden-field edits still revert on next scene load if the + # overlay write failed. + overlay_error: OSError | None = None + overrides = self.summarizer.scene_overrides() + if overrides is not None: + try: + await overrides.write_to_file() + except OSError as exc: + log.warning( + "scene-overrides write failed", + filepath=str(overrides.filepath), + error=str(exc), + ) + overlay_error = exc await self.summarizer.save_config() await self.summarizer.emit_status() + + if overlay_error is not None: + await self.signal_operation_failed( + f"Failed to write agent-settings file: " + f"{overlay_error.strerror or overlay_error}" + ) diff --git a/src/talemate/agents/tts/__init__.py b/src/talemate/agents/tts/__init__.py index e5041af8..284c0da3 100644 --- a/src/talemate/agents/tts/__init__.py +++ b/src/talemate/agents/tts/__init__.py @@ -147,6 +147,7 @@ class TTSAgent( label="Enabled APIs", description="APIs to use for TTS", choices=[], + scene_overridable=False, ), "narrator_voice_id": AgentActionConfig( type="autocomplete", @@ -219,6 +220,7 @@ class TTSAgent( "client that exposes a TTS endpoint (e.g. KoboldCpp " "with a TTS model loaded)." ), + scene_overridable=False, ), }, ), @@ -507,25 +509,23 @@ class TTSAgent( @property def narrator_voice_id(self) -> str: - return self.actions["_config"].config["narrator_voice_id"].value + return self.resolve_config("_config", "narrator_voice_id") @property def generate_for_player(self) -> bool: - return self.actions["_config"].config["generate_for_player"].value + return self.resolve_config("_config", "generate_for_player") @property def generate_for_npc(self) -> bool: - return self.actions["_config"].config["generate_for_npc"].value + return self.resolve_config("_config", "generate_for_npc") @property def generate_for_narration(self) -> bool: - return self.actions["_config"].config["generate_for_narration"].value + return self.resolve_config("_config", "generate_for_narration") @property def generate_for_context_investigation(self) -> bool: - return ( - self.actions["_config"].config["generate_for_context_investigation"].value - ) + return self.resolve_config("_config", "generate_for_context_investigation") @property def has_auto_generation(self) -> bool: @@ -540,7 +540,7 @@ class TTSAgent( @property def speaker_separation(self) -> str: - return self.actions["_config"].config["speaker_separation"].value + return self.resolve_config("_config", "speaker_separation") @property def apis(self) -> list[str]: diff --git a/src/talemate/agents/tts/audio_tags.py b/src/talemate/agents/tts/audio_tags.py index 8d7eaedd..54499da0 100644 --- a/src/talemate/agents/tts/audio_tags.py +++ b/src/talemate/agents/tts/audio_tags.py @@ -37,6 +37,7 @@ class AudioTagsMixin: value=False, label="Audio tags", description="Use AI to inject audio tags (e.g. [laughing], [whispering]) into dialogue for supported TTS providers. Requires the Summarizer agent.", + scene_overridable=False, ) return actions diff --git a/src/talemate/agents/tts/chatterbox.py b/src/talemate/agents/tts/chatterbox.py index 554ef484..45bf94ea 100644 --- a/src/talemate/agents/tts/chatterbox.py +++ b/src/talemate/agents/tts/chatterbox.py @@ -169,6 +169,7 @@ class ChatterboxMixin: {"value": "cuda", "label": "CUDA"}, ], description="Device to use for TTS", + scene_overridable=False, ), "chunk_size": AgentActionConfig( type="number", @@ -178,6 +179,7 @@ class ChatterboxMixin: value=256, label="Chunk size", note=INFO_CHUNK_SIZE, + scene_overridable=False, ), }, ) diff --git a/src/talemate/agents/tts/elevenlabs.py b/src/talemate/agents/tts/elevenlabs.py index ada7fc2e..43c084bb 100644 --- a/src/talemate/agents/tts/elevenlabs.py +++ b/src/talemate/agents/tts/elevenlabs.py @@ -85,6 +85,7 @@ class ElevenLabsMixin: type="unified_api_key", value="elevenlabs.api_key", label="ElevenLabs API Key", + scene_overridable=False, ), "model": AgentActionConfig( type="text", @@ -100,6 +101,7 @@ class ElevenLabsMixin: {"value": "eleven_turbo_v2_5", "label": "Eleven Turbo V2.5"}, {"value": "eleven_v3", "label": "Eleven V3"}, ], + scene_overridable=False, ), "chunk_size": AgentActionConfig( type="number", @@ -109,12 +111,14 @@ class ElevenLabsMixin: value=0, label="Chunk size", note=INFO_CHUNK_SIZE, + scene_overridable=False, ), "audio_tag_format": AgentActionConfig( type="text", value="[{{ tag }}]", label="Audio tag format", description="Jinja2 template for formatting audio tags. The variable {{ tag }} will be replaced with the tag name (e.g., 'laughing' becomes '[laughing]').", + scene_overridable=False, ), }, ) diff --git a/src/talemate/agents/tts/f5tts.py b/src/talemate/agents/tts/f5tts.py index 2774fc5b..5121ee16 100644 --- a/src/talemate/agents/tts/f5tts.py +++ b/src/talemate/agents/tts/f5tts.py @@ -209,6 +209,7 @@ class F5TTSMixin: {"value": "cuda", "label": "CUDA"}, ], description="Device to use for TTS", + scene_overridable=False, ), "model_name": AgentActionConfig( type="text", @@ -220,6 +221,7 @@ class F5TTSMixin: {"value": "F5TTS_Base", "label": "F5TTS_Base"}, {"value": "F5TTS_v1_Base", "label": "F5TTS_v1_Base"}, ], + scene_overridable=False, ), "nfe_step": AgentActionConfig( type="number", @@ -229,6 +231,7 @@ class F5TTSMixin: step=16, max=64, description="Number of diffusion steps.", + scene_overridable=False, ), "chunk_size": AgentActionConfig( type="number", @@ -238,12 +241,14 @@ class F5TTSMixin: value=64, label="Chunk size", note=INFO_CHUNK_SIZE, + scene_overridable=False, ), "replace_exclamation_marks": AgentActionConfig( type="bool", value=True, label="Replace exclamation marks", description="Some models tend to over-emphasise exclamation marks, so this is a workaround to make the speech more natural.", + scene_overridable=False, ), }, ) diff --git a/src/talemate/agents/tts/google.py b/src/talemate/agents/tts/google.py index 3c2ff57c..b7b05bff 100644 --- a/src/talemate/agents/tts/google.py +++ b/src/talemate/agents/tts/google.py @@ -158,6 +158,7 @@ class GoogleMixin: type="unified_api_key", value="google.api_key", label="Google API Key", + scene_overridable=False, ), "model": AgentActionConfig( type="text", @@ -174,6 +175,7 @@ class GoogleMixin: ], label="Model", description="Google TTS model to use", + scene_overridable=False, ), "chunk_size": AgentActionConfig( type="number", @@ -183,6 +185,7 @@ class GoogleMixin: value=0, label="Chunk size", note=INFO_CHUNK_SIZE, + scene_overridable=False, ), }, ) diff --git a/src/talemate/agents/tts/kokoro.py b/src/talemate/agents/tts/kokoro.py index 44fb30c9..81d3aa4e 100644 --- a/src/talemate/agents/tts/kokoro.py +++ b/src/talemate/agents/tts/kokoro.py @@ -152,6 +152,7 @@ class KokoroMixin: value=512, label="Chunk size", note=INFO_CHUNK_SIZE, + scene_overridable=False, ), }, ) diff --git a/src/talemate/agents/tts/openai.py b/src/talemate/agents/tts/openai.py index 1f4cd260..dd3ba120 100644 --- a/src/talemate/agents/tts/openai.py +++ b/src/talemate/agents/tts/openai.py @@ -108,6 +108,7 @@ class OpenAIMixin: type="unified_api_key", value="openai.api_key", label="OpenAI API Key", + scene_overridable=False, ), "model": AgentActionConfig( type="text", @@ -119,6 +120,7 @@ class OpenAIMixin: ], label="Model", description="TTS model to use", + scene_overridable=False, ), "chunk_size": AgentActionConfig( type="number", @@ -128,6 +130,7 @@ class OpenAIMixin: value=512, label="Chunk size", note=INFO_CHUNK_SIZE, + scene_overridable=False, ), }, ) diff --git a/src/talemate/agents/tts/openai_compatible.py b/src/talemate/agents/tts/openai_compatible.py index 8d109cd0..d84336db 100644 --- a/src/talemate/agents/tts/openai_compatible.py +++ b/src/talemate/agents/tts/openai_compatible.py @@ -103,6 +103,7 @@ class OpenAICompatibleMixin: description=( "Internal — managed by the Add Backend UI on this tab." ), + scene_overridable=False, ), }, ) @@ -141,6 +142,7 @@ class OpenAICompatibleMixin: # Persist on blur so the user can immediately click # Refresh voices without closing the agent settings. save_on_change=True, + scene_overridable=False, ), "api_key": AgentActionConfig( type="password", @@ -148,12 +150,14 @@ class OpenAICompatibleMixin: label="API Key", description="API key for the server (leave empty if not required)", save_on_change=True, + scene_overridable=False, ), "model": AgentActionConfig( type="text", value="tts-1", label="Model", description="Model identifier to send in requests", + scene_overridable=False, ), "voices_endpoint": AgentActionConfig( type="text", @@ -164,6 +168,7 @@ class OpenAICompatibleMixin: "endpoint. Leave empty to probe common defaults." ), save_on_change=True, + scene_overridable=False, ), "chunk_size": AgentActionConfig( type="number", @@ -173,6 +178,7 @@ class OpenAICompatibleMixin: value=512, label="Chunk size", note=INFO_CHUNK_SIZE, + scene_overridable=False, ), }, ) diff --git a/src/talemate/agents/tts/pocket_tts.py b/src/talemate/agents/tts/pocket_tts.py index 2d604f5f..0d6126a9 100644 --- a/src/talemate/agents/tts/pocket_tts.py +++ b/src/talemate/agents/tts/pocket_tts.py @@ -1,6 +1,7 @@ import asyncio import functools import io +import os import re from pathlib import Path @@ -85,8 +86,8 @@ Talemate includes a few example prompt WAV files in `tts/voice/pocket_tts/` (cop If the **voice cloning** model download is blocked by Hugging Face, you need to: 1. Log in to Hugging Face and accept the model terms on the Pocket TTS model page. 2. Create a Hugging Face token. -3. Set it in your environment as `HF_TOKEN`. -4. Restart Talemate and try again. +3. Paste it into the **HuggingFace Token** field below (or set it in your environment as `HF_TOKEN`). +4. Try again. Commands / links: - Accept terms: https://huggingface.co/kyutai/pocket-tts @@ -191,6 +192,13 @@ class PocketTTSMixin: label="Pocket TTS", description="Pocket TTS is a local CPU text-to-speech model (voice cloning via audio prompt).", config={ + "api_key": AgentActionConfig( + type="unified_api_key", + value="huggingface.api_key", + label="HuggingFace Token", + description="Optional. Only needed if downloading the voice cloning model is gated by Hugging Face. Used as the HF_TOKEN for model downloads.", + scene_overridable=False, + ), "variant": AgentActionConfig( type="text", value="english", @@ -214,6 +222,7 @@ class PocketTTSMixin: ], description="Pocket TTS language/model. Distilled 6-layer for English; 24-layer variants give better quality on other languages.", value_migration=_migrate_variant_to_language, + scene_overridable=False, ), "temp": AgentActionConfig( type="number", @@ -223,6 +232,7 @@ class PocketTTSMixin: step=0.05, label="Temperature", description="Sampling temperature. Higher values can sound more varied but less stable.", + scene_overridable=False, ), "lsd_decode_steps": AgentActionConfig( type="number", @@ -232,6 +242,7 @@ class PocketTTSMixin: step=1, label="LSD decode steps", description="Number of decoding steps. Higher can improve quality but is slower.", + scene_overridable=False, ), "noise_clamp": AgentActionConfig( type="number", @@ -241,6 +252,7 @@ class PocketTTSMixin: step=0.1, label="Noise clamp", description="0 = disabled. If >0, clamps noise sampling to this maximum.", + scene_overridable=False, ), "eos_threshold": AgentActionConfig( type="number", @@ -250,6 +262,7 @@ class PocketTTSMixin: step=0.1, label="EOS threshold", description="End-of-sequence detection threshold.", + scene_overridable=False, ), "frames_after_eos": AgentActionConfig( type="number", @@ -259,12 +272,14 @@ class PocketTTSMixin: step=1, label="Frames after EOS", description="0 = auto. If >0, generates additional frames after EOS detection.", + scene_overridable=False, ), "quantize": AgentActionConfig( type="bool", value=False, label="Quantize (int8)", description="Apply dynamic int8 quantization. ~30% faster on most CPUs with minor quality impact.", + scene_overridable=False, ), "chunk_size": AgentActionConfig( type="number", @@ -274,6 +289,7 @@ class PocketTTSMixin: value=256, label="Chunk size", note=INFO_CHUNK_SIZE, + scene_overridable=False, ), }, ) @@ -318,6 +334,16 @@ class PocketTTSMixin: value = int(self.actions["pocket_tts"].config["frames_after_eos"].value) return None if value <= 0 else value + @property + def pocket_tts_hf_token(self) -> str | None: + return self.config.huggingface.api_key + + def _pocket_tts_apply_hf_token(self): + """Export the configured HF token as HF_TOKEN so huggingface_hub can fetch gated weights; falls back to any existing env var.""" + token = self.pocket_tts_hf_token + if token: + os.environ["HF_TOKEN"] = token + @property def pocket_tts_configured(self) -> bool: try: @@ -381,6 +407,7 @@ class PocketTTSMixin: def _pocket_tts_generate_wav_bytes(self, chunk: Chunk) -> bytes: _import_heavy_deps() + self._pocket_tts_apply_hf_token() if not chunk.voice: raise ValueError("Pocket TTS requires a voice prompt (voice.provider_id).") diff --git a/src/talemate/agents/tts/websocket_handler.py b/src/talemate/agents/tts/websocket_handler.py index d5f2e65a..80dc8709 100644 --- a/src/talemate/agents/tts/websocket_handler.py +++ b/src/talemate/agents/tts/websocket_handler.py @@ -553,7 +553,9 @@ class TTSWebsocketHandler(Plugin): await self.signal_operation_failed("No text to generate speech for.") return - await tts_agent.generate(text, character, message=message) + await tts_agent.generate( + text, character, message=message, message_id=payload.message_id + ) await self.signal_operation_done() diff --git a/src/talemate/agents/visual/agent.py b/src/talemate/agents/visual/agent.py index 135b092f..6fdbcc27 100644 --- a/src/talemate/agents/visual/agent.py +++ b/src/talemate/agents/visual/agent.py @@ -94,6 +94,7 @@ class VisualAgent( label="Backend (text to image)", description="The backend to use for basic text to image generation", save_on_change=True, + scene_overridable=False, ), "backend_image_edit": AgentActionConfig( type="text", @@ -107,6 +108,7 @@ class VisualAgent( label="Backend (image editing)", description="The backend to use for contextual image editing", save_on_change=True, + scene_overridable=False, ), "backend_image_analyzation": AgentActionConfig( type="text", @@ -120,6 +122,7 @@ class VisualAgent( label="Backend (image analysis)", description="The backend to use for image analysis", save_on_change=True, + scene_overridable=False, ), "timeout": AgentActionConfig( type="number", @@ -129,12 +132,14 @@ class VisualAgent( max=900, step=10, description="Timeout in seconds. If the backend does not generate an image within this time, it will be considered failed.", + scene_overridable=False, ), "automatic_setup": AgentActionConfig( type="bool", value=True, label="Automatic Setup", description="Automatically setup the visual agent if the selected client has an implementation of the selected backend. (Like the KoboldCpp Automatic1111 api)", + scene_overridable=False, ), "automatic_generation": AgentActionConfig( type="bool", @@ -359,7 +364,7 @@ class VisualAgent( @property def allow_automatic_generation(self) -> bool: - return self.actions["_config"].config["automatic_generation"].value + return self.resolve_config("_config", "automatic_generation") @property def automatic_setup(self) -> bool: @@ -367,20 +372,20 @@ class VisualAgent( @property def fallback_prompt_type(self) -> PROMPT_TYPE: - value = self.actions["prompt_generation"].config["fallback_prompt_type"].value + value = self.resolve_config("prompt_generation", "fallback_prompt_type") return PROMPT_TYPE(value) if value else PROMPT_TYPE.KEYWORDS @property def prompt_generation_length(self) -> int: - return self.actions["prompt_generation"].config["max_length"].value + return self.resolve_config("prompt_generation", "max_length") @property def automatic_analysis(self) -> bool: - return self.actions["prompt_generation"].config["automatic_analysis"].value + return self.resolve_config("prompt_generation", "automatic_analysis") @property def revise_edit_prompts(self) -> bool: - return self.actions["prompt_generation"].config["revise_edit_prompts"].value + return self.resolve_config("prompt_generation", "revise_edit_prompts") @property def backend_name(self) -> str: diff --git a/src/talemate/agents/visual/backends/automatic1111.py b/src/talemate/agents/visual/backends/automatic1111.py index 73dad8e9..386ae946 100644 --- a/src/talemate/agents/visual/backends/automatic1111.py +++ b/src/talemate/agents/visual/backends/automatic1111.py @@ -210,6 +210,7 @@ class Automatic1111Mixin: label="API URL", description="The URL of the AUTOMATIC1111 API", save_on_change=True, + scene_overridable=False, ), "steps": AgentActionConfig( title="Sampler Settings", @@ -220,6 +221,7 @@ class Automatic1111Mixin: max=150, step=1, description="Number of render steps", + scene_overridable=False, ), "sampling_method": AgentActionConfig( type="text", @@ -227,6 +229,7 @@ class Automatic1111Mixin: value="DPM++ 2M", label="Sampling Method", description="The sampling method to use", + scene_overridable=False, ), "schedule_type": AgentActionConfig( type="text", @@ -234,6 +237,7 @@ class Automatic1111Mixin: choices=SAMPLING_SCHEDULES, label="Schedule Type", description="The sampling schedule to use", + scene_overridable=False, ), "cfg": AgentActionConfig( type="number", @@ -243,6 +247,7 @@ class Automatic1111Mixin: min=1, max=30, step=0.5, + scene_overridable=False, ), "prompt_type": AgentActionConfig( type="text", @@ -251,6 +256,7 @@ class Automatic1111Mixin: label="Prompting Type", choices=PROMPT_TYPE.choices(), description="Semantic style of the generated prompt.", + scene_overridable=False, ), "resolution_square": AgentActionConfig( type="vector2", @@ -259,6 +265,7 @@ class Automatic1111Mixin: label="Square", description="The resolution to use for square images.", choices=get_resolution_choices("square"), + scene_overridable=False, ), "resolution_portrait": AgentActionConfig( type="vector2", @@ -266,6 +273,7 @@ class Automatic1111Mixin: label="Portrait", description="The resolution to use for portrait images.", choices=get_resolution_choices("portrait"), + scene_overridable=False, ), "resolution_landscape": AgentActionConfig( type="vector2", @@ -273,6 +281,7 @@ class Automatic1111Mixin: label="Landscape", description="The resolution to use for landscape images.", choices=get_resolution_choices("landscape"), + scene_overridable=False, ), }, ) diff --git a/src/talemate/agents/visual/backends/comfyui.py b/src/talemate/agents/visual/backends/comfyui.py index 1a6b62a9..878f9ef4 100644 --- a/src/talemate/agents/visual/backends/comfyui.py +++ b/src/talemate/agents/visual/backends/comfyui.py @@ -708,6 +708,7 @@ class ComfyUIMixin: label="API URL", description="The URL of the backend API", save_on_change=True, + scene_overridable=False, ), "workflow": AgentActionConfig( type="text", @@ -716,6 +717,7 @@ class ComfyUIMixin: description="The workflow to use for comfyui (workflow file name inside ./templates/comfyui-workflows)", choices=[], save_on_change=True, + scene_overridable=False, ), "model": AgentActionConfig( type="autocomplete", @@ -723,6 +725,7 @@ class ComfyUIMixin: label="Model", choices=[], description="The main image generation model to use.", + scene_overridable=False, ), "prompt_type": AgentActionConfig( type="text", @@ -731,6 +734,7 @@ class ComfyUIMixin: label="Prompting Type", choices=PROMPT_TYPE.choices(), description="The semantic style of the generated prompt. USe keywords for SDXL, SD51 and descriptive for flux and qwen.", + scene_overridable=False, ), "resolution_square": AgentActionConfig( type="vector2", @@ -739,6 +743,7 @@ class ComfyUIMixin: label="Square", description="The resolution to use for the image generation.", choices=get_resolution_choices("square"), + scene_overridable=False, ), "resolution_portrait": AgentActionConfig( type="vector2", @@ -746,6 +751,7 @@ class ComfyUIMixin: label="Portrait", description="The resolution to use for the image generation.", choices=get_resolution_choices("portrait"), + scene_overridable=False, ), "resolution_landscape": AgentActionConfig( type="vector2", @@ -753,6 +759,7 @@ class ComfyUIMixin: label="Landscape", description="The resolution to use for the image generation.", choices=get_resolution_choices("landscape"), + scene_overridable=False, ), } diff --git a/src/talemate/agents/visual/backends/google_image.py b/src/talemate/agents/visual/backends/google_image.py index 16e2bc28..e4023750 100644 --- a/src/talemate/agents/visual/backends/google_image.py +++ b/src/talemate/agents/visual/backends/google_image.py @@ -338,6 +338,7 @@ class GoogleImageMixin: type="unified_api_key", value="google.api_key", label="Google API Key", + scene_overridable=False, ), "model": AgentActionConfig( type="text", @@ -355,6 +356,7 @@ class GoogleImageMixin: label="Model", description="Google image model", save_on_change=True, + scene_overridable=False, ), } @@ -385,6 +387,7 @@ class GoogleImageMixin: label="Max References", description="Maximum number of reference images for editing (1-3)", save_on_change=True, + scene_overridable=False, ) actions["google_image_edit"] = AgentAction( enabled=True, diff --git a/src/talemate/agents/visual/backends/openai_compatible.py b/src/talemate/agents/visual/backends/openai_compatible.py index 566602a9..b5b8bc7e 100644 --- a/src/talemate/agents/visual/backends/openai_compatible.py +++ b/src/talemate/agents/visual/backends/openai_compatible.py @@ -165,6 +165,7 @@ class OpenAICompatibleMixin: "appended automatically if not present." ), save_on_change=True, + scene_overridable=False, ), "api_key": AgentActionConfig( type="password", @@ -172,6 +173,7 @@ class OpenAICompatibleMixin: label="API Key", description="Optional API key for authentication.", save_on_change=True, + scene_overridable=False, ), "model": AgentActionConfig( type="text", @@ -181,6 +183,7 @@ class OpenAICompatibleMixin: "The model name to use for vision analysis. (May be optional depending on the API endpoint)" ), save_on_change=True, + scene_overridable=False, ), }, ) diff --git a/src/talemate/agents/visual/backends/openai_image.py b/src/talemate/agents/visual/backends/openai_image.py index 3fb62fbd..dc92e9e2 100644 --- a/src/talemate/agents/visual/backends/openai_image.py +++ b/src/talemate/agents/visual/backends/openai_image.py @@ -246,6 +246,7 @@ class OpenAIMixin: type="unified_api_key", value="openai.api_key", label="OpenAI API Key", + scene_overridable=False, ), "model": AgentActionConfig( type="text", @@ -268,6 +269,7 @@ class OpenAIMixin: text="This model may require your OpenAI organization to be verified.", ), }, + scene_overridable=False, ), } diff --git a/src/talemate/agents/visual/backends/openrouter.py b/src/talemate/agents/visual/backends/openrouter.py index cfd0aaad..90c90a82 100644 --- a/src/talemate/agents/visual/backends/openrouter.py +++ b/src/talemate/agents/visual/backends/openrouter.py @@ -455,6 +455,7 @@ class OpenRouterMixin: type="unified_api_key", value="openrouter.api_key", label="OpenRouter API Key", + scene_overridable=False, ), "model": AgentActionConfig( type="autocomplete", @@ -463,6 +464,7 @@ class OpenRouterMixin: label="Model", description="OpenRouter model (select appropriate model for the task)", save_on_change=True, + scene_overridable=False, ), "provider_only": AgentActionConfig( type="flags", @@ -471,6 +473,7 @@ class OpenRouterMixin: label="Only use these providers", description="Manually limit the providers to use for the selected model. This will override the default provider selection for this model.", save_on_change=True, + scene_overridable=False, ), "provider_ignore": AgentActionConfig( type="flags", @@ -479,6 +482,7 @@ class OpenRouterMixin: label="Ignore these providers", description="Ignore these providers for the selected model. This will override the default provider selection for this model.", save_on_change=True, + scene_overridable=False, ), } @@ -508,6 +512,7 @@ class OpenRouterMixin: label="Max References", description="Maximum number of reference images for editing (1-3)", save_on_change=True, + scene_overridable=False, ) return config diff --git a/src/talemate/agents/visual/backends/sdnext.py b/src/talemate/agents/visual/backends/sdnext.py index 92b88b47..1c1d8dc7 100644 --- a/src/talemate/agents/visual/backends/sdnext.py +++ b/src/talemate/agents/visual/backends/sdnext.py @@ -319,6 +319,7 @@ class SDNextMixin: label="API URL", description="The URL of the SD.Next API", save_on_change=True, + scene_overridable=False, ), "auth_method": AgentActionConfig( type="text", @@ -331,6 +332,7 @@ class SDNextMixin: {"label": "Bearer (API Key)", "value": "bearer"}, ], save_on_change=True, + scene_overridable=False, ), "username": AgentActionConfig( type="text", @@ -342,6 +344,7 @@ class SDNextMixin: condition=AgentActionConditional( attribute=f"{action_name}.config.auth_method", value="basic" ), + scene_overridable=False, ), "password": AgentActionConfig( type="password", @@ -352,6 +355,7 @@ class SDNextMixin: condition=AgentActionConditional( attribute=f"{action_name}.config.auth_method", value="basic" ), + scene_overridable=False, ), "api_key": AgentActionConfig( type="password", @@ -362,6 +366,7 @@ class SDNextMixin: condition=AgentActionConditional( attribute=f"{action_name}.config.auth_method", value="bearer" ), + scene_overridable=False, ), "steps": AgentActionConfig( title="Sampler Settings", @@ -372,6 +377,7 @@ class SDNextMixin: max=150, step=1, description="Number of render steps", + scene_overridable=False, ), "sampling_method": AgentActionConfig( type="text", @@ -379,6 +385,7 @@ class SDNextMixin: value="DPM++ 2M", label="Sampling Method", description="The sampling method to use", + scene_overridable=False, ), # schedule_type removed – not in SD.Next OpenAPI "cfg": AgentActionConfig( @@ -389,6 +396,7 @@ class SDNextMixin: min=1, max=30, step=0.5, + scene_overridable=False, ), "prompt_type": AgentActionConfig( type="text", @@ -397,6 +405,7 @@ class SDNextMixin: label="Prompting Type", choices=PROMPT_TYPE.choices(), description="Semantic style of the generated prompt.", + scene_overridable=False, ), "model": AgentActionConfig( type="autocomplete", @@ -404,6 +413,7 @@ class SDNextMixin: label="Model", choices=[], description="The main image generation model to use.", + scene_overridable=False, ), "resolution_square": AgentActionConfig( type="vector2", @@ -412,6 +422,7 @@ class SDNextMixin: label="Square", description="The resolution to use for square images.", choices=get_resolution_choices("square"), + scene_overridable=False, ), "resolution_portrait": AgentActionConfig( type="vector2", @@ -419,6 +430,7 @@ class SDNextMixin: label="Portrait", description="The resolution to use for portrait images.", choices=get_resolution_choices("portrait"), + scene_overridable=False, ), "resolution_landscape": AgentActionConfig( type="vector2", @@ -426,6 +438,7 @@ class SDNextMixin: label="Landscape", description="The resolution to use for landscape images.", choices=get_resolution_choices("landscape"), + scene_overridable=False, ), } diff --git a/src/talemate/agents/visual/backends/talemate_client.py b/src/talemate/agents/visual/backends/talemate_client.py index ee3df70f..d3991c5e 100644 --- a/src/talemate/agents/visual/backends/talemate_client.py +++ b/src/talemate/agents/visual/backends/talemate_client.py @@ -184,6 +184,7 @@ class TalemateClientMixin: "settings (Vision tab) for a client with a vision-capable model." ), save_on_change=True, + scene_overridable=False, ), }, ) diff --git a/src/talemate/agents/visual/generation.py b/src/talemate/agents/visual/generation.py index c877a9c0..5d76d25a 100644 --- a/src/talemate/agents/visual/generation.py +++ b/src/talemate/agents/visual/generation.py @@ -1,6 +1,5 @@ import asyncio import structlog -import dataclasses from typing import Callable # import talemate.agents.visual.automatic1111 # noqa: F401 @@ -32,7 +31,6 @@ async_signals.register( ) -@dataclasses.dataclass class GenerationEmission(AgentEmission): request: GenerationRequest response: GenerationResponse diff --git a/src/talemate/agents/visual/modules/wsh-visualize.json b/src/talemate/agents/visual/modules/wsh-visualize.json index 400878ba..f4f4a4ec 100644 --- a/src/talemate/agents/visual/modules/wsh-visualize.json +++ b/src/talemate/agents/visual/modules/wsh-visualize.json @@ -356,7 +356,8 @@ "CHARACTER_PORTRAIT", "SCENE_CARD", "SCENE_BACKGROUND", - "SCENE_ILLUSTRATION" + "SCENE_ILLUSTRATION", + "OBJECT_ILLUSTRATION" ] }, "x": 687, @@ -566,6 +567,50 @@ "registry": "core/functions/Argument", "base_type": "core/Node" }, + "b7e1a3c2-1f44-4d90-9a2e-7c6d5e0f1234": { + "title": "GET obj.message_ids", + "id": "b7e1a3c2-1f44-4d90-9a2e-7c6d5e0f1234", + "properties": { + "attribute": "message_ids" + }, + "x": 397, + "y": 1700, + "width": 210, + "height": 98, + "collapsed": false, + "inherited": false, + "registry": "data/Get", + "base_type": "core/Node" + }, + "c8f2b4d3-2a55-4e01-8b3f-1d2e6f7a2345": { + "title": "Empty List", + "id": "c8f2b4d3-2a55-4e01-8b3f-1d2e6f7a2345", + "properties": { + "item_type": "any", + "items": [] + }, + "x": 397, + "y": 1820, + "width": 210, + "height": 102, + "collapsed": true, + "inherited": false, + "registry": "data/MakeList", + "base_type": "core/Node" + }, + "d9a3c5e4-3b66-4f12-9c40-2e3f7a8b3456": { + "title": "Coallesce message_ids", + "id": "d9a3c5e4-3b66-4f12-9c40-2e3f7a8b3456", + "properties": {}, + "x": 699, + "y": 1730, + "width": 140, + "height": 86, + "collapsed": false, + "inherited": false, + "registry": "core/Coallesce", + "base_type": "core/Node" + }, "5527d9cb-6ffd-461e-bb08-0f915e06da18": { "title": "Make Asset Attachment Context", "id": "5527d9cb-6ffd-461e-bb08-0f915e06da18", @@ -720,10 +765,20 @@ "99b70359-6680-4941-94ec-bcde0ed99b9e.object", "23d60621-2b0a-444b-bba7-96ffddceffed.object", "d9cc2c5b-51e2-4296-83b6-b8c0027a1b5a.object", - "7dd0877a-67ca-4989-bab0-88b17764979d.object" + "7dd0877a-67ca-4989-bab0-88b17764979d.object", + "b7e1a3c2-1f44-4d90-9a2e-7c6d5e0f1234.object" ], "5527d9cb-6ffd-461e-bb08-0f915e06da18.context": [ "d2bde569-e394-46b3-9d44-78791d423ce2.asset_ctx" + ], + "b7e1a3c2-1f44-4d90-9a2e-7c6d5e0f1234.value": [ + "d9a3c5e4-3b66-4f12-9c40-2e3f7a8b3456.a" + ], + "c8f2b4d3-2a55-4e01-8b3f-1d2e6f7a2345.list": [ + "d9a3c5e4-3b66-4f12-9c40-2e3f7a8b3456.b" + ], + "d9a3c5e4-3b66-4f12-9c40-2e3f7a8b3456.value": [ + "5527d9cb-6ffd-461e-bb08-0f915e06da18.message_ids" ] }, "groups": [ diff --git a/src/talemate/agents/visual/schema.py b/src/talemate/agents/visual/schema.py index 34d7cdbb..fa4adc0e 100644 --- a/src/talemate/agents/visual/schema.py +++ b/src/talemate/agents/visual/schema.py @@ -60,7 +60,7 @@ class VIS_TYPE(ChoiceMixin, enum.StrEnum): SCENE_BACKGROUND = "SCENE_BACKGROUND" SCENE_ILLUSTRATION = "SCENE_ILLUSTRATION" - # ITEM_CARD = "ITEM_CARD" + OBJECT_ILLUSTRATION = "OBJECT_ILLUSTRATION" class GEN_TYPE(ChoiceMixin, enum.StrEnum): @@ -93,6 +93,7 @@ VIS_TYPE_TO_FORMAT: dict[VIS_TYPE, FORMAT_TYPE] = { VIS_TYPE.SCENE_CARD: FORMAT_TYPE.PORTRAIT, VIS_TYPE.SCENE_BACKGROUND: FORMAT_TYPE.LANDSCAPE, VIS_TYPE.SCENE_ILLUSTRATION: FORMAT_TYPE.LANDSCAPE, + VIS_TYPE.OBJECT_ILLUSTRATION: FORMAT_TYPE.PORTRAIT, VIS_TYPE.UNSPECIFIED: FORMAT_TYPE.PORTRAIT, } diff --git a/src/talemate/agents/visual/style.py b/src/talemate/agents/visual/style.py index 5900419e..217ed8d4 100644 --- a/src/talemate/agents/visual/style.py +++ b/src/talemate/agents/visual/style.py @@ -77,6 +77,15 @@ class StyleMixin: label="Scene Illustration", description="The style to use for scene illustration visual prompt generation", choices=[], + ), + "object_illustration_style": AgentActionConfig( + type="wstemplate", + value="visual_styles__object_illustration", + wstemplate_type="visual_style", + wstemplate_filter={"visual_type": "OBJECT_ILLUSTRATION"}, + label="Object Illustration", + description="The style to use for object illustration visual prompt generation", + choices=[], note=AgentActionNote( title="Manage styles", text="Additional styles can be created in the Templates manager.", @@ -91,11 +100,8 @@ class StyleMixin: def style_template_id(self, vis_type: VIS_TYPE) -> str: if vis_type == VIS_TYPE.UNSPECIFIED: - return self.actions["_styles"].config["art_style"].value - else: - return ( - self.actions["_styles"].config[f"{vis_type.value.lower()}_style"].value - ) + return self.resolve_config("_styles", "art_style") + return self.resolve_config("_styles", f"{vis_type.value.lower()}_style") def style_template(self, vis_type: VIS_TYPE) -> VisualStyle | None: scene = getattr(self, "scene", None) diff --git a/src/talemate/agents/world_state/__init__.py b/src/talemate/agents/world_state/__init__.py index 5d3c2a4b..a99b7f89 100644 --- a/src/talemate/agents/world_state/__init__.py +++ b/src/talemate/agents/world_state/__init__.py @@ -1,54 +1,44 @@ from __future__ import annotations -import dataclasses -import json -import time -from typing import TYPE_CHECKING - import isodate import structlog import talemate.emit.async_signals import talemate.util as util from talemate.emit import emit -from talemate.events import GameLoopEvent from talemate.instance import get_agent from talemate.client import ClientBase from talemate.prompts import Prompt -from talemate.prompts.response import AnchorExtractor, ResponseSpec -from talemate.scene_message import ( - ReinforcementMessage, - TimePassageMessage, -) -from talemate.util.response import extract_list +from talemate.scene_message import TimePassageMessage +from talemate.util.response import extract_list from talemate.agents.base import ( Agent, AgentAction, - AgentActionConfig, AgentEmission, DynamicInstruction, optimize_prompt_caching_action, set_processing, ) from talemate.agents.registry import register +from talemate.agents.memory.rag import MemoryRAGMixin from .character_progression import CharacterProgressionMixin from .avatars import AvatarMixin +from .snapshot import WorldStateSnapshotMixin +from .snapshot import SNAPSHOT_TASK as SNAPSHOT_TASK +from .reinforcements import WorldStateReinforcementsMixin +from .pin_conditions import WorldStatePinConditionsMixin from .websocket_handler import WorldStateWebsocketHandler import talemate.agents.world_state.nodes -if TYPE_CHECKING: - from talemate.tale_mate import Character - log = structlog.get_logger("talemate.agents.world_state") talemate.emit.async_signals.register("agent.world_state.time") -@dataclasses.dataclass class WorldStateAgentEmission(AgentEmission): """ Emission class for world state agent @@ -57,19 +47,26 @@ class WorldStateAgentEmission(AgentEmission): pass -@dataclasses.dataclass class TimePassageEmission(WorldStateAgentEmission): """ Emission class for time passage """ duration: str - narrative: str - human_duration: str = None + narrative: str | None = None + human_duration: str | None = None @register() -class WorldStateAgent(CharacterProgressionMixin, AvatarMixin, Agent): +class WorldStateAgent( + MemoryRAGMixin, + WorldStateSnapshotMixin, + WorldStateReinforcementsMixin, + WorldStatePinConditionsMixin, + CharacterProgressionMixin, + AvatarMixin, + Agent, +): """ An agent that handles world state related tasks. """ @@ -82,54 +79,12 @@ class WorldStateAgent(CharacterProgressionMixin, AvatarMixin, Agent): def init_actions(cls) -> dict[str, AgentAction]: actions = { "prompt_caching": optimize_prompt_caching_action(), - "update_world_state": AgentAction( - enabled=True, - can_be_disabled=True, - label="Update world state", - description="Will attempt to update the world state based on the current scene. Runs automatically every N turns.", - config={ - "initial": AgentActionConfig( - type="bool", - label="When a new scene is started", - description="Whether to update the world state on scene start.", - value=True, - ), - "turns": AgentActionConfig( - type="number", - label="Turns", - description="Number of turns to wait before updating the world state.", - value=5, - min=1, - max=100, - step=1, - ), - }, - ), - "update_reinforcements": AgentAction( - enabled=True, - can_be_disabled=True, - label="Update state reinforcements", - description="Will attempt to update any due state reinforcements.", - config={}, - ), - "check_pin_conditions": AgentAction( - enabled=True, - can_be_disabled=True, - label="Update conditional context pins", - description="Will evaluate context pins conditions and toggle those pins accordingly. Runs automatically every N turns.", - config={ - "turns": AgentActionConfig( - type="number", - label="Turns", - description="Number of turns to wait before checking conditions.", - value=2, - min=1, - max=100, - step=1, - ) - }, - ), } + # add_actions calls are ordered to match the action list shown in the UI. + WorldStateSnapshotMixin.add_actions(actions) + WorldStateReinforcementsMixin.add_actions(actions) + WorldStatePinConditionsMixin.add_actions(actions) + MemoryRAGMixin.add_actions(actions) CharacterProgressionMixin.add_actions(actions) AvatarMixin.add_actions(actions) return actions @@ -153,21 +108,6 @@ class WorldStateAgent(CharacterProgressionMixin, AvatarMixin, Agent): def experimental(self): return True - @property - def initial_update(self): - return self.actions["update_world_state"].config["initial"].value - - @property - def check_pin_conditions_turns(self): - return self.actions["check_pin_conditions"].config["turns"].value - - def connect(self, scene): - super().connect(scene) - talemate.emit.async_signals.get("game_loop").connect(self.on_game_loop) - talemate.emit.async_signals.get("scene_loop_init_after").connect( - self.on_scene_loop_init_after - ) - async def advance_time( self, duration: str, narrative: str = None ) -> TimePassageMessage: @@ -196,118 +136,6 @@ class WorldStateAgent(CharacterProgressionMixin, AvatarMixin, Agent): return message - async def on_scene_loop_init_after(self, emission): - """ - Called when a scene is initialized - """ - if not self.enabled: - return - - if not self.initial_update: - return - - if self.get_scene_state("inital_update_done"): - return - - await self.scene.world_state.request_update() - self.set_scene_states(inital_update_done=True) - - async def on_game_loop(self, emission: GameLoopEvent): - """ - Called when a conversation is generated - """ - - if not self.enabled: - return - - await self.update_world_state() - await self.auto_update_reinforcments() - await self.auto_check_pin_conditions() - - async def auto_update_reinforcments(self): - if not self.enabled: - return - - if not self.actions["update_reinforcements"].enabled: - return - - await self.update_reinforcements() - - async def auto_check_pin_conditions(self): - if not self.enabled: - return - - if not self.actions["check_pin_conditions"].enabled: - return - - if ( - self.next_pin_check - % self.actions["check_pin_conditions"].config["turns"].value - != 0 - or self.next_pin_check == 0 - ): - self.next_pin_check += 1 - return - - self.next_pin_check = 0 - - await self.check_pin_conditions() - - async def update_world_state(self, force: bool = False): - if not self.enabled: - return - - if not self.actions["update_world_state"].enabled: - return - - log.debug( - "update_world_state", - next_update=self.next_update, - turns=self.actions["update_world_state"].config["turns"].value, - ) - - scene = self.scene - - if ( - self.next_update % self.actions["update_world_state"].config["turns"].value - != 0 - or self.next_update == 0 - ) and not force: - self.next_update += 1 - return - - self.next_update = 0 - await scene.world_state.request_update() - - @set_processing - async def request_world_state(self): - if ( - not any(self.scene.characters) - and not self.scene.intro - and not self.scene.history - ): - return None - - t1 = time.time() - - _, world_state = await Prompt.request( - "world_state.request-world-state-v2", - self.client, - "analyze_long", - vars={ - "scene": self.scene, - "max_tokens": self.client.max_token_length, - "object_type": "character", - "object_type_plural": "characters", - }, - ) - - self.scene.log.debug( - "request_world_state", response=world_state, time=time.time() - t1 - ) - - return world_state - @set_processing async def analyze_text_and_extract_context( self, @@ -564,265 +392,6 @@ class WorldStateAgent(CharacterProgressionMixin, AvatarMixin, Agent): extracted["response"], max_attributes=max_attributes ) - @set_processing - async def update_reinforcements(self, force: bool = False, reset: bool = False): - """ - Queries due worldstate re-inforcements - """ - - for reinforcement in self.scene.world_state.reinforce: - # Skip character reinforcements if require_active is True and character is not active - if ( - reinforcement.require_active - and reinforcement.character - and not self.scene.character_is_active(reinforcement.character) - ): - continue - - if reinforcement.due <= 0 or force: - await self.update_reinforcement( - reinforcement.question, reinforcement.character, reset=reset - ) - else: - reinforcement.due -= 1 - - @set_processing - async def update_reinforcement( - self, question: str, character: "str | Character" = None, reset: bool = False - ) -> str: - """ - Queries a single re-inforcement - """ - - if isinstance(character, self.scene.Character): - character = character.name - - message = None - idx, reinforcement = await self.scene.world_state.find_reinforcement( - question, character - ) - - if not reinforcement: - log.warning( - "Reinforcement not found", question=question, character=character - ) - return - - message = ReinforcementMessage(message="") - message.set_source( - "world_state", - "update_reinforcement", - question=question, - character=character, - ) - - if reset and reinforcement.insert == "sequential": - self.scene.pop_history( - typ="reinforcement", meta_hash=message.meta_hash, all=True - ) - - if reinforcement.insert == "sequential": - kind = "analyze_freeform_medium_short" - else: - kind = "analyze_freeform" - - response, extracted = await Prompt.request( - "world_state.update-reinforcements", - self.client, - kind, - vars={ - "scene": self.scene, - "max_tokens": self.client.max_token_length, - "question": reinforcement.question, - "instructions": reinforcement.instructions or "", - "character": ( - self.scene.get_character(reinforcement.character) - if reinforcement.character - else None - ), - "answer": (reinforcement.answer if not reset else None) or "", - "reinforcement": reinforcement, - }, - response_spec=ResponseSpec( - extractors={ - "response": AnchorExtractor( - left="", - right="", - fallback_to_full=True, - ), - }, - ), - ) - - answer = extracted["response"] - - reinforcement.answer = answer - reinforcement.due = reinforcement.interval - - # remove any recent previous reinforcement message with same question - # to avoid overloading the near history with reinforcement messages - if not reset: - self.scene.pop_history( - typ="reinforcement", meta_hash=message.meta_hash, max_iterations=10 - ) - - if reinforcement.insert == "sequential": - # insert the reinforcement message at the current position - message.message = answer - log.debug("update_reinforcement", message=message, reset=reset) - await self.scene.push_history(message) - - # if reinforcement has a character name set, update the character detail - if reinforcement.character: - character = self.scene.get_character(reinforcement.character) - await character.set_detail(reinforcement.question, answer) - - else: - # set world entry - await self.scene.world_state_manager.save_world_entry( - reinforcement.question, - reinforcement.as_context_line, - {}, - ) - - self.scene.world_state.emit() - - return message - - @set_processing - async def check_pin_conditions( - self, - ): - """ - Checks if any context pin conditions - """ - - log.debug("check_pin_conditions", turns=self.check_pin_conditions_turns) - - world_state = self.scene.world_state - - state_change = False - - # Build list of pins to check, honoring decay semantics - pins_to_check = {} - for entry_id, pin in world_state.pins.items(): - # Skip game-state-controlled pins from the LLM loop - if pin.gamestate_condition: - continue - # Initialize countdown if active with decay but no due set - if pin.active and pin.decay and not pin.decay_due: - pin.decay_due = pin.decay - - # Only pins with conditions are checked by the LLM - if not pin.condition: - continue - - # If pin is active and has decay, skip checks until it's about to decay (decay_due == 1) - if ( - pin.active - and pin.decay - and (pin.decay_due is not None) - and pin.decay_due > 1 - ): - continue - - # Include pin for checking when it has no decay, is inactive, or is about to decay - if (not pin.decay) or (not pin.active) or (pin.decay_due == 1): - pins_to_check[entry_id] = { - "condition": pin.condition, - "state": pin.condition_state, - } - - # Early return if nothing to check, but still tick decay - if not pins_to_check: - for entry_id, pin in world_state.pins.items(): - # Game-state-controlled pins do not decay - if pin.gamestate_condition: - continue - if pin.active and pin.decay: - if not pin.decay_due: - pin.decay_due = pin.decay - pin.decay_due -= self.check_pin_conditions_turns - log.debug("applying pin decay", pin=pin, decay_due=pin.decay_due) - if pin.decay_due <= 0: - log.debug("pin decay expired", pin=pin, decay_due=pin.decay_due) - pin.active = False - pin.decay_due = None - state_change = True - if state_change: - await self.scene.load_active_pins() - self.scene.emit_status() - return - - first_entry_id = list(pins_to_check.keys())[0] - - _, answers = await Prompt.request( - "world_state.check-pin-conditions", - self.client, - "analyze", - vars={ - "scene": self.scene, - "max_tokens": self.client.max_token_length, - "previous_states": json.dumps(pins_to_check, indent=2), - "coercion": {first_entry_id: {"condition": ""}}, - }, - ) - - # Apply LLM results - for entry_id, answer in answers.items(): - if entry_id not in world_state.pins: - log.debug( - "check_pin_conditions", - entry_id=entry_id, - answer=answer, - msg="entry_id not found in world_state.pins (LLM failed to produce a clean response)", - ) - continue - - log.debug("check_pin_conditions", entry_id=entry_id, answer=answer) - state = answer.get("state") - pin = world_state.pins[entry_id] - if state is True or ( - isinstance(state, str) and state.lower() in ["true", "yes", "y"] - ): - prev_state = pin.condition_state - pin.condition_state = True - if not pin.active: - state_change = True - pin.active = True - # Refresh decay countdown when condition is true and pin stays/turns active - if pin.decay: - pin.decay_due = pin.decay - if prev_state != pin.condition_state: - state_change = True - else: - if pin.condition_state is not False or pin.active: - pin.condition_state = False - pin.active = False - # Clear countdown when deactivated - pin.decay_due = None - state_change = True - - # Tick decay counters for all active pins with decay - for entry_id, pin in world_state.pins.items(): - # Game-state-controlled pins do not decay - if pin.gamestate_condition: - continue - if pin.active and pin.decay: - if not pin.decay_due: - pin.decay_due = pin.decay - # Decrement once per check cycle - pin.decay_due -= 1 - if pin.decay_due <= 0: - # Auto-deactivate on expiry - pin.active = False - pin.decay_due = None - state_change = True - - if state_change: - await self.scene.load_active_pins() - self.scene.emit_status() - @set_processing async def summarize_and_pin(self, message_id: int, num_messages: int = 3) -> str: """ @@ -867,6 +436,7 @@ class WorldStateAgent(CharacterProgressionMixin, AvatarMixin, Agent): summary, { "ts": ts, + "pin_only": True, }, ) diff --git a/src/talemate/agents/world_state/avatars.py b/src/talemate/agents/world_state/avatars.py index 3288c4db..6cdae9f1 100644 --- a/src/talemate/agents/world_state/avatars.py +++ b/src/talemate/agents/world_state/avatars.py @@ -47,8 +47,8 @@ class AvatarMixin: @property def avatars_enabled(self) -> bool: - return self.actions["avatars"].enabled + return self.resolve_enabled("avatars") @property def avatar_selection_frequency(self) -> int: - return self.actions["avatars"].config["selection_frequency"].value + return self.resolve_config("avatars", "selection_frequency") diff --git a/src/talemate/agents/world_state/character_progression.py b/src/talemate/agents/world_state/character_progression.py index dc4f0be4..122bad29 100644 --- a/src/talemate/agents/world_state/character_progression.py +++ b/src/talemate/agents/world_state/character_progression.py @@ -71,23 +71,23 @@ class CharacterProgressionMixin: @property def character_progression_enabled(self) -> bool: - return self.actions["character_progression"].enabled + return self.resolve_enabled("character_progression") @property def character_progression_frequency(self) -> int: - return self.actions["character_progression"].config["frequency"].value + return self.resolve_config("character_progression", "frequency") @property def character_progression_player_character(self) -> bool: - return self.actions["character_progression"].config["player_character"].value + return self.resolve_config("character_progression", "player_character") @property def character_progression_max_changes(self) -> int: - return self.actions["character_progression"].config["max_changes"].value + return self.resolve_config("character_progression", "max_changes") @property def character_progression_as_suggestions(self) -> bool: - return self.actions["character_progression"].config["as_suggestions"].value + return self.resolve_config("character_progression", "as_suggestions") # signal connect diff --git a/src/talemate/agents/world_state/pin_conditions.py b/src/talemate/agents/world_state/pin_conditions.py new file mode 100644 index 00000000..8c989459 --- /dev/null +++ b/src/talemate/agents/world_state/pin_conditions.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +import json + +import structlog + +import talemate.emit.async_signals +from talemate.events import GameLoopEvent +from talemate.prompts import Prompt + +from talemate.agents.base import AgentAction, AgentActionConfig, set_processing + +log = structlog.get_logger("talemate.agents.world_state") + + +class WorldStatePinConditionsMixin: + """ + World-state manager agent mixin that handles conditional context pins — + evaluating each pin's condition via the LLM, toggling the pin accordingly, + and ticking the decay countdown that auto-deactivates stale pins. + """ + + @classmethod + def add_actions(cls, actions: dict[str, AgentAction]): + actions["check_pin_conditions"] = AgentAction( + enabled=True, + can_be_disabled=True, + container=True, + icon="mdi-pin", + label="Update conditional context pins", + description="Will evaluate context pins conditions and toggle those pins accordingly. Runs automatically every N turns.", + config={ + "turns": AgentActionConfig( + type="number", + label="Turns", + description="Number of turns to wait before checking conditions.", + value=2, + min=1, + max=100, + step=1, + ) + }, + ) + + # config property helpers + + @property + def check_pin_conditions_enabled(self) -> bool: + return self.resolve_enabled("check_pin_conditions") + + @property + def check_pin_conditions_turns(self): + return self.resolve_config("check_pin_conditions", "turns") + + # signal connect + + def connect(self, scene): + super().connect(scene) + talemate.emit.async_signals.get("game_loop").connect( + self.on_game_loop_check_pin_conditions + ) + + async def on_game_loop_check_pin_conditions(self, emission: GameLoopEvent): + """ + Called once per scene-loop round. + """ + if not self.enabled: + return + + await self.auto_check_pin_conditions() + + # methods + + async def auto_check_pin_conditions(self): + if not self.enabled: + return + + if not self.check_pin_conditions_enabled: + return + + if ( + self.next_pin_check % self.check_pin_conditions_turns != 0 + or self.next_pin_check == 0 + ): + self.next_pin_check += 1 + return + + self.next_pin_check = 0 + + await self.check_pin_conditions() + + @set_processing + async def check_pin_conditions( + self, + ): + """ + Checks if any context pin conditions + """ + + log.debug("check_pin_conditions", turns=self.check_pin_conditions_turns) + + world_state = self.scene.world_state + + state_change = False + + # Build list of pins to check, honoring decay semantics + pins_to_check = {} + for entry_id, pin in world_state.pins.items(): + # Skip game-state-controlled pins from the LLM loop + if pin.gamestate_condition: + continue + # Initialize countdown if active with decay but no due set + if pin.active and pin.decay and not pin.decay_due: + pin.decay_due = pin.decay + + # Only pins with conditions are checked by the LLM + if not pin.condition: + continue + + # If pin is active and has decay, skip checks until it's about to decay (decay_due == 1) + if ( + pin.active + and pin.decay + and (pin.decay_due is not None) + and pin.decay_due > 1 + ): + continue + + # Include pin for checking when it has no decay, is inactive, or is about to decay + if (not pin.decay) or (not pin.active) or (pin.decay_due == 1): + pins_to_check[entry_id] = { + "condition": pin.condition, + "state": pin.condition_state, + } + + # Early return if nothing to check, but still tick decay + if not pins_to_check: + for entry_id, pin in world_state.pins.items(): + # Game-state-controlled pins do not decay + if pin.gamestate_condition: + continue + if pin.active and pin.decay: + if not pin.decay_due: + pin.decay_due = pin.decay + pin.decay_due -= self.check_pin_conditions_turns + log.debug("applying pin decay", pin=pin, decay_due=pin.decay_due) + if pin.decay_due <= 0: + log.debug("pin decay expired", pin=pin, decay_due=pin.decay_due) + pin.active = False + pin.decay_due = None + state_change = True + if state_change: + await self.scene.load_active_pins() + self.scene.emit_status() + return + + first_entry_id = list(pins_to_check.keys())[0] + + _, answers = await Prompt.request( + "world_state.check-pin-conditions", + self.client, + "analyze", + vars={ + "scene": self.scene, + "max_tokens": self.client.max_token_length, + "previous_states": json.dumps(pins_to_check, indent=2), + "coercion": {first_entry_id: {"condition": ""}}, + }, + ) + + # Apply LLM results + for entry_id, answer in answers.items(): + if entry_id not in world_state.pins: + log.debug( + "check_pin_conditions", + entry_id=entry_id, + answer=answer, + msg="entry_id not found in world_state.pins (LLM failed to produce a clean response)", + ) + continue + + log.debug("check_pin_conditions", entry_id=entry_id, answer=answer) + state = answer.get("state") + pin = world_state.pins[entry_id] + if state is True or ( + isinstance(state, str) and state.lower() in ["true", "yes", "y"] + ): + prev_state = pin.condition_state + pin.condition_state = True + if not pin.active: + state_change = True + pin.active = True + # Refresh decay countdown when condition is true and pin stays/turns active + if pin.decay: + pin.decay_due = pin.decay + if prev_state != pin.condition_state: + state_change = True + else: + if pin.condition_state is not False or pin.active: + pin.condition_state = False + pin.active = False + # Clear countdown when deactivated + pin.decay_due = None + state_change = True + + # Tick decay counters for all active pins with decay + for entry_id, pin in world_state.pins.items(): + # Game-state-controlled pins do not decay + if pin.gamestate_condition: + continue + if pin.active and pin.decay: + if not pin.decay_due: + pin.decay_due = pin.decay + # Decrement once per check cycle + pin.decay_due -= 1 + if pin.decay_due <= 0: + # Auto-deactivate on expiry + pin.active = False + pin.decay_due = None + state_change = True + + if state_change: + await self.scene.load_active_pins() + self.scene.emit_status() diff --git a/src/talemate/agents/world_state/reinforcements.py b/src/talemate/agents/world_state/reinforcements.py new file mode 100644 index 00000000..053545fa --- /dev/null +++ b/src/talemate/agents/world_state/reinforcements.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import structlog + +import talemate.emit.async_signals +from talemate.events import GameLoopEvent +from talemate.prompts import Prompt +from talemate.prompts.response import AnchorExtractor, ResponseSpec +from talemate.scene_message import ReinforcementMessage + +from talemate.agents.base import AgentAction, set_processing + +if TYPE_CHECKING: + from talemate.tale_mate import Character + +log = structlog.get_logger("talemate.agents.world_state") + + +class WorldStateReinforcementsMixin: + """ + World-state manager agent mixin that handles state reinforcements — the + periodic re-querying of tracked world/character questions and writing the + answers back into the scene (as detail, world entry, and/or inline message). + """ + + @classmethod + def add_actions(cls, actions: dict[str, AgentAction]): + actions["update_reinforcements"] = AgentAction( + enabled=True, + can_be_disabled=True, + container=True, + icon="mdi-image-auto-adjust", + label="Update state reinforcements", + description="Will attempt to update any due state reinforcements.", + config={}, + ) + + # config property helpers + + @property + def update_reinforcements_enabled(self) -> bool: + return self.resolve_enabled("update_reinforcements") + + # signal connect + + def connect(self, scene): + super().connect(scene) + talemate.emit.async_signals.get("game_loop").connect( + self.on_game_loop_update_reinforcements + ) + + async def on_game_loop_update_reinforcements(self, emission: GameLoopEvent): + """ + Called once per scene-loop round. + """ + if not self.enabled: + return + + await self.auto_update_reinforcments() + + # methods + + async def auto_update_reinforcments(self): + if not self.enabled: + return + + if not self.update_reinforcements_enabled: + return + + await self.update_reinforcements() + + @set_processing + async def update_reinforcements(self, force: bool = False, reset: bool = False): + """ + Queries due worldstate re-inforcements + """ + + for reinforcement in self.scene.world_state.reinforce: + # Skip character reinforcements if require_active is True and character is not active + if ( + reinforcement.require_active + and reinforcement.character + and not self.scene.character_is_active(reinforcement.character) + ): + continue + + if reinforcement.due <= 0 or force: + await self.update_reinforcement( + reinforcement.question, reinforcement.character, reset=reset + ) + else: + reinforcement.due -= 1 + + @set_processing + async def update_reinforcement( + self, question: str, character: "str | Character" = None, reset: bool = False + ) -> str: + """ + Queries a single re-inforcement + """ + + if isinstance(character, self.scene.Character): + character = character.name + + message = None + idx, reinforcement = await self.scene.world_state.find_reinforcement( + question, character + ) + + if not reinforcement: + log.warning( + "Reinforcement not found", question=question, character=character + ) + return + + message = ReinforcementMessage(message="") + message.set_source( + "world_state", + "update_reinforcement", + question=question, + character=character, + ) + + if reset and reinforcement.insert == "sequential": + self.scene.pop_history( + typ="reinforcement", meta_hash=message.meta_hash, all=True + ) + + if reinforcement.insert == "sequential": + kind = "analyze_freeform_medium_short" + else: + kind = "analyze_freeform" + + response, extracted = await Prompt.request( + "world_state.update-reinforcements", + self.client, + kind, + vars={ + "scene": self.scene, + "max_tokens": self.client.max_token_length, + "question": reinforcement.question, + "instructions": reinforcement.instructions or "", + "character": ( + self.scene.get_character(reinforcement.character) + if reinforcement.character + else None + ), + "answer": (reinforcement.answer if not reset else None) or "", + "reinforcement": reinforcement, + }, + response_spec=ResponseSpec( + extractors={ + "response": AnchorExtractor( + left="", + right="", + fallback_to_full=True, + ), + }, + ), + ) + + answer = extracted["response"] + + reinforcement.answer = answer + reinforcement.due = reinforcement.interval + + # remove any recent previous reinforcement message with same question + # to avoid overloading the near history with reinforcement messages + if not reset: + self.scene.pop_history( + typ="reinforcement", meta_hash=message.meta_hash, max_iterations=10 + ) + + if reinforcement.insert == "sequential": + # insert the reinforcement message at the current position + message.message = answer + log.debug("update_reinforcement", message=message, reset=reset) + await self.scene.push_history(message) + + # if reinforcement has a character name set, update the character detail + if reinforcement.character: + character = self.scene.get_character(reinforcement.character) + await character.set_detail(reinforcement.question, answer) + + else: + # set world entry + await self.scene.world_state_manager.save_world_entry( + reinforcement.question, + reinforcement.as_context_line, + {}, + ) + + self.scene.world_state.emit() + + return message diff --git a/src/talemate/agents/world_state/snapshot.py b/src/talemate/agents/world_state/snapshot.py new file mode 100644 index 00000000..b12917fc --- /dev/null +++ b/src/talemate/agents/world_state/snapshot.py @@ -0,0 +1,619 @@ +from __future__ import annotations + +import asyncio +import time +import traceback +from typing import Any, TYPE_CHECKING + +import structlog + +import talemate.emit.async_signals +from talemate.emit import emit +from talemate.events import GameLoopActorIterEvent +from talemate.exceptions import GenerationCancelled +from talemate.prompts import Prompt +from talemate.prompts.response import AnchorExtractor, ResponseSpec +from talemate.scene_message import TimePassageMessage +from talemate.world_state import WorldStateResponse +from talemate.world_state.merge import ( + apply_bucket_patch, + cap_bucket, + has_time_passage_boundary, +) +from talemate.world_state.schema import CharacterState, ObjectState, PlaceState + +from talemate.agents.base import ( + AgentAction, + AgentActionConfig, + AgentActionConditional, + set_processing, +) + +if TYPE_CHECKING: + from talemate.world_state import WorldState + +log = structlog.get_logger("talemate.agents.world_state") + +# Message types skipped while `request_world_state` walks scene.history +# collecting focus lines. Every focus message ends up in `anchor_message_ids` +# and can render inline highlights — so anything kept in this list is excluded +# from the focus span and gets no highlights. `context_investigation` is the +# soft case: the `include_context_investigation` config knob removes it from +# the per-call ignore set when enabled. +WORLD_STATE_SNAPSHOT_IGNORE = [ + "reinforcement", + "director", + "context_investigation", +] + +# Opaque key for the world state snapshot's tracked single-flight task +# (Agent.run_tracked_task) — internal, not user-facing. +SNAPSHOT_TASK = "world_state_update" + + +class WorldStateSnapshotMixin: + """ + World-state manager agent mixin that handles world state snapshot updates + (the periodic refresh of the tracked characters/items/places) and the + in-fiction "examine" expansion of snapshot entities. + """ + + @classmethod + def add_actions(cls, actions: dict[str, AgentAction]): + actions["update_world_state"] = AgentAction( + enabled=True, + can_be_disabled=True, + container=True, + icon="mdi-earth", + label="Update world state", + quick_toggle=True, + description="Will attempt to update the world state based on the current scene. Runs automatically every N character turns.", + config={ + "initial": AgentActionConfig( + type="bool", + label="When a new scene is started", + description="Whether to update the world state on scene start.", + value=True, + ), + "turns": AgentActionConfig( + type="number", + label="Turns", + description="Number of character turns (player or AI) to wait before updating the world state.", + value=10, + min=1, + max=100, + step=1, + ), + "focus_lines": AgentActionConfig( + type="number", + label="Lines in the moment", + description="How many of the most recent messages count as the current moment. These messages can show inline entity highlights.", + value=3, + min=1, + max=10, + step=1, + ), + "include_context_investigation": AgentActionConfig( + type="bool", + label="Include Look at / Add Detail", + description="When on, Look at and Add Detail messages also count as part of the current moment and can show inline highlights.", + value=False, + ), + "custom_instructions": AgentActionConfig( + type="blob", + label="Custom instructions", + description="Custom instructions to add to the world state snapshot generation.", + value="", + ), + "examine_length": AgentActionConfig( + type="number", + label="Add Detail length", + description="How long an Add Detail result can be. Shorter lengths keep the generated description tighter; longer lengths allow more elaboration.", + value=256, + min=32, + max=1024, + step=32, + ), + "inject_as_scene_memory": AgentActionConfig( + type="bool", + label="Pin to context", + description="Pin the world state snapshot into the conversation and narrator prompts as live scene memory.", + value=False, + ), + "durable_snapshot": AgentActionConfig( + type="bool", + label="Durable snapshot", + quick_toggle=True, + description="Keep entities and their details across refreshes so the world state builds up over time. When off, every refresh starts from scratch.", + value=True, + ), + "max_items": AgentActionConfig( + type="number", + label="Max items tracked", + description="Maximum number of items the snapshot can hold at once. When a refresh exceeds this, the stalest items are dropped. Set to 0 for no limit.", + value=10, + min=0, + max=30, + step=1, + condition=AgentActionConditional( + attribute="update_world_state.config.durable_snapshot", + value=True, + ), + ), + "eviction_threshold": AgentActionConfig( + type="number", + label="Auto-evict stale entries", + description="Automatically drop a snapshot entry the agent leaves unchanged for this many consecutive world state updates, guarding against stale entries the agent fails to remove on its own. Set to 0 to disable.", + value=3, + min=0, + max=20, + step=1, + condition=AgentActionConditional( + attribute="update_world_state.config.durable_snapshot", + value=True, + ), + ), + }, + ) + + # config property helpers + + @property + def initial_update(self): + return self.resolve_config("update_world_state", "initial") + + @property + def update_world_state_enabled(self) -> bool: + return self.resolve_enabled("update_world_state") + + @property + def update_world_state_turns(self) -> int: + return self.resolve_config("update_world_state", "turns") + + @property + def update_world_state_focus_lines(self) -> int: + return self.resolve_config("update_world_state", "focus_lines") + + @property + def update_world_state_include_context_investigation(self) -> bool: + return self.resolve_config( + "update_world_state", "include_context_investigation" + ) + + @property + def update_world_state_examine_length(self) -> int: + return self.resolve_config("update_world_state", "examine_length") + + @property + def update_world_state_custom_instructions(self) -> str: + return self.resolve_config("update_world_state", "custom_instructions") + + @property + def update_world_state_max_items(self) -> int: + return self.resolve_config("update_world_state", "max_items") + + @property + def update_world_state_durable_snapshot(self) -> bool: + return self.resolve_config("update_world_state", "durable_snapshot") + + @property + def update_world_state_eviction_threshold(self) -> int: + return self.resolve_config("update_world_state", "eviction_threshold") + + @property + def snapshot_runs_in_background(self) -> bool: + """ + Whether the snapshot should run as a true background task. Only when the + linked client can handle a concurrent request — otherwise a background + snapshot would just contend with the main generation on the same client, + so we run it synchronously (foreground) instead. + """ + return bool(self.client and self.client.supports_concurrent_inference) + + # signal connect + + def connect(self, scene): + super().connect(scene) + talemate.emit.async_signals.get("game_loop_actor_iter").connect( + self.on_game_loop_actor_iter + ) + talemate.emit.async_signals.get("scene_loop_init_after").connect( + self.on_scene_loop_init_after + ) + + async def on_scene_loop_init_after(self, emission): + """ + Called when a scene is initialized + """ + if not self.enabled: + return + + if not self.initial_update: + return + + if self.get_scene_state("inital_update_done"): + return + + await self.dispatch_world_state_update() + self.set_scene_states(inital_update_done=True) + + async def on_game_loop_actor_iter(self, emission: GameLoopActorIterEvent): + """ + Called after each actor (player or AI) has had a turn. + """ + + if not self.enabled: + return + + await self.update_world_state() + + # methods + + async def update_world_state(self, force: bool = False): + if not self.enabled: + return + + if not self.update_world_state_enabled: + return + + log.debug( + "update_world_state", + next_update=self.next_update, + turns=self.update_world_state_turns, + ) + + if ( + self.next_update % self.update_world_state_turns != 0 + or self.next_update == 0 + ) and not force: + self.next_update += 1 + return + + # Single-flight: if a snapshot is still generating, leave the counter at + # its due value and retry next turn rather than starting an overlapping + # run (which would race on scene.world_state and double the eviction + # counter). dispatch returns False when one is already in flight. + if not await self.dispatch_world_state_update(): + return + + self.next_update = 0 + + async def dispatch_world_state_update(self, cancel_in_flight: bool = False) -> bool: + """ + Run the world state snapshot as a single-flight tracked task. Returns + True if a generation was started, False if one was already in flight. + + Runs in the background (status "busy_bg", non-blocking UI) when the + linked client supports concurrent inference; otherwise in the foreground + (status "busy") so the user doesn't queue an action behind it on a client + that can't serve concurrent requests. Either way the call is non-blocking. + + ``cancel_in_flight=True`` cancels a running snapshot and starts a fresh + one — used by the manual refresh, where the user's explicit action + (especially a reset) should take priority over an in-progress run. + """ + task = await self.run_tracked_task( + SNAPSHOT_TASK, + lambda: self.scene.world_state.request_update(), + background=self.snapshot_runs_in_background, + cancel_in_flight=cancel_in_flight, + error_handler=self.on_world_state_error, + ) + return task is not None + + def cancel_world_state_update(self) -> bool: + """ + Cancel an in-flight snapshot. Returns True if one was running and got + cancelled. request_update clears the "requested" status on cancellation + so the UI spinner resolves. + """ + return self.cancel_tracked_task(SNAPSHOT_TASK) + + async def on_world_state_error(self, error: Exception): + emit("status", "World state update failed.", status="error") + log.error("update_world_state", error=error) + + @set_processing + async def request_world_state(self) -> WorldStateResponse: + # Walk scene.history backward collecting up to `focus_lines` narrative + # messages. TimePassageMessage is a hard boundary — the moment on the + # other side is a different scene, not what we're highlighting. Other + # ignored types (reinforcement, director, ...) are skipped without + # ending the walk. `context_investigation` is conditionally ignored + # per the agent config: when off it stays in the ignore set; when on + # it becomes a valid focus candidate that can also receive highlights. + focus_lines = self.update_world_state_focus_lines + ignore_types = set(WORLD_STATE_SNAPSHOT_IGNORE) + if self.update_world_state_include_context_investigation: + ignore_types.discard("context_investigation") + + focus_messages: list = [] + for message in reversed(self.scene.history): + if isinstance(message, TimePassageMessage): + break + if message.typ in ignore_types: + continue + focus_messages.insert(0, message) + if len(focus_messages) >= focus_lines: + break + + # Fall back to the scene intro only on a fresh scene (no live history). + # If the walk turned up empty because of a TimePassageMessage boundary + # or director-only noise, the scene has already moved past the intro — + # using it as the "current moment" would be wrong. + intro_text = ( + self.scene.get_intro() + if not focus_messages and not self.scene.history + else "" + ) + + # Nothing to anchor highlights to AND no intro to fall back on. Silently + # return an empty response so the world_state holder re-emits current + # state and no LLM call is made. + if not focus_messages and not intro_text: + return WorldStateResponse() + + # Every focus message is a valid highlight anchor — the frontend + # applies the parser's mention pass to all of them, so phrases that + # only appear in an earlier focus line still get highlighted there. + anchor_message_ids = [m.id for m in focus_messages] + + t1 = time.time() + + durable_snapshot = self.update_world_state_durable_snapshot + + # Pre-build a rendered current-state payload for the prompt's CURRENT + # STATE section. Only shown in durable mode (legacy mode's prompt + # framing is fresh-extract, so prior state would just be noise). + # Mentions are excluded because they're focus-window-specific — + # forcing the LLM to re-derive them keeps the highlight phrasing + # current. + current_state_payload = {} + if durable_snapshot: + ws = self.scene.world_state + current_state_payload = { + "characters": { + name: char.model_dump(exclude={"mentions", "misses"}) + for name, char in ws.characters.items() + }, + "items": { + name: obj.model_dump(exclude={"mentions", "misses"}) + for name, obj in ws.items.items() + }, + "places": { + name: place.model_dump(exclude={"mentions", "misses"}) + for name, place in ws.places.items() + }, + "location": ws.location, + } + + _, world_state = await Prompt.request( + "world_state.request-world-state-v2", + self.client, + "analyze_long", + vars={ + "scene": self.scene, + "max_tokens": self.client.max_token_length, + "object_type": "character", + "object_type_plural": "characters", + "scene_focus": focus_messages, + "intro_text": intro_text, + "include_scene_intent": True, + "include_scenario_premise": True, + "durable_snapshot": durable_snapshot, + "current_state_payload": current_state_payload, + "custom_instructions": self.update_world_state_custom_instructions, + }, + ) + + self.scene.log.debug( + "request_world_state", + response=world_state, + anchor_message_ids=anchor_message_ids, + time=time.time() - t1, + ) + + return WorldStateResponse( + world_state=world_state, anchor_message_ids=anchor_message_ids + ) + + async def apply_snapshot_update(self, world_state: "WorldState"): + """ + Generate a fresh world-state snapshot and merge it into ``world_state``. + + Owns the snapshot policy: the scene-cut wipe, the LLM call + (``request_world_state``), name normalization, the durable + delta-merge vs legacy wholesale-rebuild choice, eviction/capping, and + the surrounding ``emit`` status transitions. ``WorldState.request_update`` + is the thin public entry point that delegates here. + """ + scene = self.scene + + # TimePassageMessage past the prior anchors is a scene cut: wipe the + # snapshot before showing it to the LLM so the next pass extracts + # fresh against an empty baseline. Durable mode only — legacy + # wholesale doesn't need a separate cut because it rewrites every + # pass anyway. + if self.update_world_state_durable_snapshot and has_time_passage_boundary( + scene.history, world_state.anchor_message_ids + ): + world_state.characters = {} + world_state.items = {} + world_state.places = {} + world_state.location = None + world_state.anchor_message_ids = [] + + world_state.emit(status="requested") + + try: + response = await self.request_world_state() + except GenerationCancelled: + world_state.emit() + return + except asyncio.CancelledError: + # Background task was cancelled (manual cancel of the in-flight + # snapshot). Clear the "requested" status so the UI spinner resolves, + # then let the cancellation propagate so the task ends cancelled. + world_state.emit() + raise + except Exception as e: + world_state.emit() + log.error( + "world_state.apply_snapshot_update", + error=e, + traceback=traceback.format_exc(), + ) + return + + if response.world_state is None: + world_state.emit() + return + + raw = response.world_state + character_names = scene.character_names + + # Normalize bucket keys against canonical scene names. Values stay + # as raw dicts (or None) so the delta-merge path can preserve the + # null-to-drop semantic. + char_patch: dict[str, Any] = {} + for raw_name, payload in (raw.get("characters") or {}).items(): + name = world_state.normalize_name(raw_name) + for main_name, synonyms in world_state.character_name_mappings.items(): + if name.lower() in synonyms: + name = main_name + break + if name not in character_names: + for canonical in character_names: + if ( + canonical.lower() in name.lower() + or name.lower() in canonical.lower() + ): + name = canonical + break + char_patch[name] = payload + + item_patch: dict[str, Any] = { + world_state.normalize_name(name): payload + for name, payload in (raw.get("items") or {}).items() + } + place_patch: dict[str, Any] = { + world_state.normalize_name(name): payload + for name, payload in (raw.get("places") or {}).items() + } + + if self.update_world_state_durable_snapshot: + # Delta merge: apply patch on top of current state. None values + # drop the entity; partial dicts patch fields; omitted keys are + # untouched. Entries the agent leaves untouched for + # `eviction_threshold` consecutive passes age out automatically. + eviction_threshold = self.update_world_state_eviction_threshold + world_state.characters = apply_bucket_patch( + world_state.characters, char_patch, CharacterState, eviction_threshold + ) + world_state.items = apply_bucket_patch( + world_state.items, item_patch, ObjectState, eviction_threshold + ) + world_state.places = apply_bucket_patch( + world_state.places, place_patch, PlaceState, eviction_threshold + ) + # Cap the items bucket, dropping stalest entries first (highest + # `misses`). Durable-only: in legacy mode every item is rebuilt + # fresh each pass with misses=0, so "stalest" is meaningless. + world_state.items = cap_bucket( + world_state.items, self.update_world_state_max_items + ) + else: + # Legacy wholesale: drop None entries (no delete semantic), + # construct full state classes, replace entire buckets. Preserve + # emotion when the new pass omits it. + new_chars: dict[str, CharacterState] = {} + for name, payload in char_patch.items(): + if not payload or not isinstance(payload, dict): + continue + char_kwargs = dict(payload) + if not char_kwargs.get("emotion") and name in world_state.characters: + char_kwargs["emotion"] = world_state.characters[name].emotion + try: + new_chars[name] = CharacterState(**char_kwargs) + except Exception as e: + log.error( + "world_state.apply_snapshot_update", error=e, character=name + ) + new_items: dict[str, ObjectState] = {} + for name, payload in item_patch.items(): + if not payload or not isinstance(payload, dict): + continue + try: + new_items[name] = ObjectState(**payload) + except Exception as e: + log.error("world_state.apply_snapshot_update", error=e, item=name) + new_places: dict[str, PlaceState] = {} + for name, payload in place_patch.items(): + if not payload or not isinstance(payload, dict): + continue + try: + new_places[name] = PlaceState(**payload) + except Exception as e: + log.error("world_state.apply_snapshot_update", error=e, place=name) + world_state.characters = new_chars + world_state.items = new_items + world_state.places = new_places + + if "location" in raw and isinstance(raw["location"], (str, type(None))): + world_state.location = raw["location"] + + world_state.anchor_message_ids = list(response.anchor_message_ids) + + world_state.emit() + + @set_processing + async def examine_entity( + self, + entity_name: str, + entity_kind: str, + snapshot_text: str, + ) -> str: + """ + Synthesize an in-fiction "examine" result for a tooltip-highlighted + entity. Takes the 2-3 sentence snapshot text and expands it into + grounded prose describing what the player observes about the entity + at this moment in the scene. Output length is governed by the + `examine_length` config, which both caps the tokens and scales the + auto-appended response-length instruction. + + Returns the synthesized text. Caller is responsible for surfacing it + in the UI — typically as a ContextInvestigationMessage anchored to + the moment of the examine click. Prior examine results land back in + scene history, so repeated examines for the same or related entities + accumulate context naturally via `scene.context_history()`. + """ + if not snapshot_text or not snapshot_text.strip(): + raise ValueError("examine_entity requires non-empty snapshot_text") + + response_length = self.update_world_state_examine_length + + _, extracted = await Prompt.request( + "world_state.examine-entity", + self.client, + f"create_{response_length}", + vars={ + "scene": self.scene, + "max_tokens": self.client.max_token_length, + "entity_name": entity_name, + "entity_kind": entity_kind, + "snapshot_text": snapshot_text, + "include_scene_intent": True, + "include_scenario_premise": True, + }, + response_spec=ResponseSpec( + extractors={ + "response": AnchorExtractor( + left="", + right="", + fallback_to_full=True, + ), + }, + ), + ) + + return extracted["response"].strip() diff --git a/src/talemate/agents/world_state/websocket_handler.py b/src/talemate/agents/world_state/websocket_handler.py index b40f2091..b092829c 100644 --- a/src/talemate/agents/world_state/websocket_handler.py +++ b/src/talemate/agents/world_state/websocket_handler.py @@ -1,6 +1,8 @@ import pydantic +from talemate.emit import emit from talemate.instance import get_agent +from talemate.scene_message import ContextInvestigationMessage from talemate.server.websocket_plugin import Plugin from talemate.status import set_loading @@ -18,6 +20,12 @@ class SummarizeAndPinPayload(pydantic.BaseModel): num_messages: int = 5 +class ExamineEntityPayload(pydantic.BaseModel): + entity_name: str + entity_kind: str + snapshot_text: str + + class WorldStateWebsocketHandler(Plugin): router = "world_state_agent" @@ -25,14 +33,21 @@ class WorldStateWebsocketHandler(Plugin): def agent(self): return get_agent("world_state") - @set_loading("Updating world state", cancellable=True, as_async=True) async def handle_request_update(self, data: dict): payload = RequestUpdatePayload(**data) if payload.reset: self.scene.world_state.reset() - await self.scene.world_state.request_update() + # Run the snapshot as a background task (status "busy_bg") so the manual + # refresh doesn't lock the UI. cancel_in_flight=True so the user's + # explicit refresh — especially a reset — takes priority over any + # in-progress automatic snapshot. + await self.agent.dispatch_world_state_update(cancel_in_flight=True) + await self.signal_operation_done(allow_auto_save=False) + + async def handle_cancel_request_update(self, data: dict): + self.agent.cancel_world_state_update() await self.signal_operation_done(allow_auto_save=False) @set_loading("Summarizing and pinning", cancellable=True, as_async=True) @@ -47,3 +62,22 @@ class WorldStateWebsocketHandler(Plugin): message_id, num_messages=payload.num_messages ) await self.signal_operation_done(allow_auto_save=False) + + @set_loading("Examining", cancellable=True, as_async=True) + async def handle_examine_entity(self, data: dict): + payload = ExamineEntityPayload(**data) + examine_text = await self.agent.examine_entity( + entity_name=payload.entity_name, + entity_kind=payload.entity_kind, + snapshot_text=payload.snapshot_text, + ) + message: ContextInvestigationMessage = ContextInvestigationMessage( + examine_text, sub_type="examine" + ) + # Freeze the snapshot rather than re-deriving from current world_state + # at regenerate time — preserves "same inputs, different output" if a + # newer request_world_state has overwritten this entity since the click. + message.set_source("world_state", "examine_entity", **payload.model_dump()) + await self.scene.push_history(message) + emit("context_investigation", message=message) + await self.signal_operation_done(allow_auto_save=False) diff --git a/src/talemate/changelog.py b/src/talemate/changelog.py index e0e4081f..2ed14073 100644 --- a/src/talemate/changelog.py +++ b/src/talemate/changelog.py @@ -73,12 +73,12 @@ File Layout: from typing import TYPE_CHECKING import json import os +import re import structlog import deepdiff from datetime import datetime, timezone import shutil import glob -# import re from talemate.save import SceneEncoder from talemate.path import SCENES_DIR @@ -439,6 +439,58 @@ def _ensure_latest_initialized(scene: "Scene") -> None: _write_json(latest, base_data) +_DELTA_PATH_TOKEN_RE = re.compile(r"\['([^']*)'\]|\[(-?\d+)\]") + + +def _parse_delta_path(path: str) -> list[str | int]: + """Parse a deepdiff path like ``root['a'][3]['b']`` into ``['a', 3, 'b']``. + + Returns an empty list for unrecognized formats so callers can short-circuit. + """ + if not isinstance(path, str) or not path.startswith("root"): + return [] + parts: list[str | int] = [] + for m in _DELTA_PATH_TOKEN_RE.finditer(path[len("root") :]): + if m.group(1) is not None: + parts.append(m.group(1)) + else: + parts.append(int(m.group(2))) + return parts + + +def _path_parent_exists(data: dict, path: str) -> bool: + """Return True if the parent of ``path`` exists in ``data``.""" + parts = _parse_delta_path(path) + if len(parts) <= 1: + return True + obj = data + for key in parts[:-1]: + if isinstance(obj, list): + if not isinstance(key, int) or key < 0 or key >= len(obj): + return False + obj = obj[key] + elif isinstance(obj, dict): + if key not in obj: + return False + obj = obj[key] + else: + return False + return True + + +def _warn_on_orphan_delta_ops(prev: dict, delta_dict: dict) -> None: + """Log a warning when a delta op targets a path whose parent doesn't exist in ``prev``.""" + for op_name in ("dictionary_item_added", "values_changed"): + ops = delta_dict.get(op_name) or {} + for path in ops: + if not _path_parent_exists(prev, path): + log.warning( + "delta_op_targets_missing_parent", + op=op_name, + path=path, + ) + + def _apply_delta(data: dict, delta_obj: dict) -> dict: """ Apply a delta (set of changes) to scene data. @@ -498,7 +550,9 @@ def _compute_delta(prev: dict, curr: dict) -> dict: if not diff: return {} - return diff._to_delta_dict() + delta_dict = diff._to_delta_dict() + _warn_on_orphan_delta_ops(prev, delta_dict) + return delta_dict def _serialize_scene_plain(scene: "Scene") -> dict: @@ -617,6 +671,37 @@ def _get_overall_latest_revision(scene: "Scene") -> int: return latest_rev +def _repair_history(data: dict) -> int: + """Backfill required SceneMessage fields on bare-fragment history entries. + + Returns the number of entries repaired. Indices are not shifted. + """ + history = data.get("history") + if not isinstance(history, list): + return 0 + + repaired_indices: list[int] = [] + for i, entry in enumerate(history): + if not isinstance(entry, dict): + continue + if "message" in entry and "typ" in entry: + continue + entry.setdefault("message", "") + entry.setdefault("typ", "narrator") + entry.setdefault("source", "ai") + entry.setdefault("flags", 0) + repaired_indices.append(i) + + if repaired_indices: + log.warning( + "repaired_bare_history_entries", + count=len(repaired_indices), + indices=repaired_indices, + ) + + return len(repaired_indices) + + async def reconstruct_cleanup(data: dict) -> dict: """ Cleanup the reconstructed scene data. @@ -629,6 +714,8 @@ async def reconstruct_cleanup(data: dict) -> dict: shared_context=data.get("shared_context"), ) data["shared_context"] = "" + + _repair_history(data) return data diff --git a/src/talemate/character.py b/src/talemate/character.py index dacfb3d3..f0743ef8 100644 --- a/src/talemate/character.py +++ b/src/talemate/character.py @@ -25,6 +25,7 @@ __all__ = [ "VoiceChangedEvent", "deactivate_character", "activate_character", + "set_character_is_player", "set_voice", ] @@ -724,6 +725,59 @@ async def activate_character(scene: "Scene", character: Union[str, "Character"]) scene.active_characters.append(character.name) +async def _demote_player_to_actor(scene: "Scene", character: "Character"): + """Flip is_player off and, if active, swap the Player actor for a plain Actor. + + Caller must ensure `character` is currently the player (or is being + unmarked); this helper does not re-check the flag. + """ + character.is_player = False + if scene.character_is_active(character): + new_actor = scene.Actor(character, instance.get_agent("conversation")) + await scene.add_actor(new_actor, commit_to_memory=False) + + +async def set_character_is_player( + scene: "Scene", character: Union[str, "Character"], is_player: bool +): + """ + Sets or clears a character's player status. At most one character may be + the player at any time, so promoting a non-player demotes the existing + player (if any) to an AI Actor. Active characters get their actor swapped + in place; inactive characters being promoted are auto-activated. + + Arguments: + scene: The scene the character lives in. + character: The character to update. Can be a name or a Character object. + is_player: True to mark as player, False to unmark. + """ + if isinstance(character, str): + character = scene.get_character(character) + + if not character: + log.error("character not found") + return + + if character.is_player == is_player: + return + + if is_player: + current_player = scene.get_explicit_player_character() + if current_player is not None and current_player is not character: + await _demote_player_to_actor(scene, current_player) + + if scene.character_is_active(character): + # add_actor sets is_player=True when the new actor is a Player + new_actor = scene.Player(character, None) + await scene.add_actor(new_actor, commit_to_memory=False) + else: + # activate_character reads is_player to pick the Player class + character.is_player = True + await activate_character(scene, character) + else: + await _demote_player_to_actor(scene, character) + + async def set_voice(character: "Character", voice: Voice | None, auto: bool = False): character.voice = voice emission: VoiceChangedEvent = VoiceChangedEvent( diff --git a/src/talemate/client/anthropic.py b/src/talemate/client/anthropic.py index 09fe90b7..dea65dfd 100644 --- a/src/talemate/client/anthropic.py +++ b/src/talemate/client/anthropic.py @@ -201,7 +201,7 @@ class AnthropicClient(ConcurrentInferenceMixin, EndpointOverrideMixin, ClientBas @property def reasoning_display(self) -> ReasoningDisplay | None: """Returns reasoning display config based on what's actually used at runtime.""" - if not self.reason_enabled: + if not self.reason_enabled_configured: return None # Only show effort display if adaptive will ACTUALLY be used. @@ -334,6 +334,14 @@ class AnthropicClient(ConcurrentInferenceMixin, EndpointOverrideMixin, ClientBas if "max_tokens" not in parameters: parameters["max_tokens"] = self.api_max_output_tokens + # Prompt caching is opt-in on the Anthropic API — without cache_control + # the request is never cached. Top-level cache_control auto-places the + # breakpoint on the last cacheable block, which pairs with the + # after_history volatile-context placement that optimize_prompt_caching + # already enables in the prompt builder. + if self.optimize_prompt_caching: + parameters["cache_control"] = {"type": "ephemeral"} + self.log.debug( "generate", model=self.model_name, diff --git a/src/talemate/client/base.py b/src/talemate/client/base.py index 46ed2a26..602a8a28 100644 --- a/src/talemate/client/base.py +++ b/src/talemate/client/base.py @@ -14,7 +14,6 @@ import uuid from typing import Callable, Union, Literal, TYPE_CHECKING import pydantic -import dataclasses import structlog import urllib3 @@ -83,6 +82,8 @@ class PromptData(pydantic.BaseModel): client_type: str time: Union[float, int] agent_stack: list[str] = pydantic.Field(default_factory=list) + agent_type: str | None = None + agent_action: str | None = None generation_parameters: dict = pydantic.Field(default_factory=dict) inference_preset: str = None preset_group: str | None = None @@ -141,6 +142,7 @@ class CommonDefaults(pydantic.BaseModel): reason_enabled: bool = False reason_tokens: int = 1024 reason_response_pattern: str | None = None + reason_validation_pattern: str | None = None reason_prefill: str | None = None dedupe_enabled: bool = False @@ -249,15 +251,17 @@ class RequestInformation(pydantic.BaseModel): return time.time() - self.end_time -@dataclasses.dataclass -class ClientEmbeddingsStatus: +class ClientEmbeddingsStatus(pydantic.BaseModel): + model_config = pydantic.ConfigDict(arbitrary_types_allowed=True) + client: "ClientBase | None" = None embedding_name: str | None = None seen: bool = False -@dataclasses.dataclass -class ClientStatus: +class ClientStatus(pydantic.BaseModel): + model_config = pydantic.ConfigDict(arbitrary_types_allowed=True) + client: "ClientBase | None" = None enabled: bool = False @@ -390,9 +394,18 @@ class ClientBase: return self.client_config.preset_group @property - def reason_enabled(self) -> bool: + def reason_enabled_configured(self) -> bool: + """Stable configured value. Subclasses override for client-specific + forcing (Gemini 2.5+, OpenAI o-series). `reason_enabled` is the + runtime value after per-action context overrides.""" return self.client_config.reason_enabled + @property + def reason_enabled(self) -> bool: + if client_context_attribute("disable_reasoning") and not self.reason_locked: + return False + return self.reason_enabled_configured + @property def reason_tokens(self) -> int: return self.client_config.reason_tokens @@ -401,6 +414,12 @@ class ClientBase: def reason_response_pattern(self) -> str: return self.client_config.reason_response_pattern or DEFAULT_REASONING_PATTERN + @property + def reason_validation_pattern(self) -> str | None: + # No default - validation is opt-in. When unset, reasoning is never + # treated as "did not happen" based on a start pattern. + return self.client_config.reason_validation_pattern + @property def reason_prefill(self) -> str: return self.client_config.reason_prefill or "" @@ -422,13 +441,42 @@ class ClientBase: return self.client_config.dedupe_enabled @property - def enforce_response_length(self) -> str: + def enforce_response_length( + self, + ) -> Literal[ + "uncapped", + "cap_tokens_and_instructions", + "cap_tokens", + "instructions", + "adaptive", + ]: return self.client_config.enforce_response_length + @property + def enforce_response_length_resolved( + self, + ) -> Literal[ + "uncapped", + "cap_tokens_and_instructions", + "cap_tokens", + "instructions", + ]: + """The effective mode, resolving "adaptive" based on runtime reasoning state. + + "adaptive" sends instructions only when reasoning is enabled, otherwise + caps tokens and sends instructions. + """ + mode = self.enforce_response_length + if mode == "adaptive": + return ( + "instructions" if self.reason_enabled else "cap_tokens_and_instructions" + ) + return mode + @property def enforce_response_length_cap_tokens(self) -> bool: """Whether the current mode should cap tokens (send max_tokens to the API).""" - return self.enforce_response_length in ( + return self.enforce_response_length_resolved in ( "cap_tokens_and_instructions", "cap_tokens", ) @@ -436,7 +484,7 @@ class ClientBase: @property def enforce_response_length_instructions(self) -> bool: """Whether the current mode should append human-readable length instructions.""" - return self.enforce_response_length in ( + return self.enforce_response_length_resolved in ( "cap_tokens_and_instructions", "instructions", ) @@ -566,9 +614,10 @@ class ClientBase: """Returns reasoning display config based on what's actually used at runtime. Override in subclasses for custom behavior (e.g., adaptive thinking). - Returns None if reasoning is not enabled. + Returns None if reasoning is not enabled. Uses the configured value so + per-action overrides don't flicker the persistent UI indicator. """ - if not self.reason_enabled: + if not self.reason_enabled_configured: return None return ReasoningDisplay( indicator_value=str(self.validated_reason_tokens), @@ -711,15 +760,32 @@ class ClientBase: spec=spec, )[0] - if ( - spec.reasoning_pattern - and spec.reasoning_pattern != self.client_config.reason_response_pattern - ): - log.info("reasoning pattern determined from prompt template", spec=spec) - self.client_config.reason_response_pattern = spec.reasoning_pattern + self._apply_spec_pattern( + spec.reasoning_pattern, "reason_response_pattern", spec + ) + self._apply_spec_pattern( + spec.reasoning_validation_pattern, "reason_validation_pattern", spec + ) return prompt + def _apply_spec_pattern( + self, spec_value: str | None, config_attr: str, spec: PromptSpec + ): + """ + Applies a reasoning pattern determined by the prompt template to the + client config, overriding the configured value when the template + provides a different one. + """ + if spec_value and spec_value != getattr(self.client_config, config_attr): + log.info( + "reasoning pattern determined from prompt template", + attr=config_attr, + value=spec_value, + spec=spec, + ) + setattr(self.client_config, config_attr, spec_value) + def prompt_template_example(self) -> tuple[str | None, str | None, PromptSpec]: if not getattr(self, "model_name", None): return None, None, PromptSpec() @@ -926,6 +992,15 @@ class ClientBase: "reason_response_pattern_default": ( prompt_template_spec.reasoning_pattern or DEFAULT_REASONING_PATTERN ), + "reason_response_pattern_from_template": bool( + prompt_template_spec.reasoning_pattern + ), + "reason_validation_pattern_default": ( + prompt_template_spec.reasoning_validation_pattern + ), + "reason_validation_pattern_from_template": bool( + prompt_template_spec.reasoning_validation_pattern + ), "meta": self.Meta().model_dump(), "error_action": None, "double_coercion": self.double_coercion, @@ -974,10 +1049,11 @@ class ClientBase: "embeddings_model_name": self.embeddings_model_name, "can_support_concurrent_inference": self.can_support_concurrent_inference, "supports_concurrent_inference": self.supports_concurrent_inference, - "reason_enabled": self.reason_enabled, + "reason_enabled": self.reason_enabled_configured, "reason_tokens": self.reason_tokens, "min_reason_tokens": self.min_reason_tokens, "reason_response_pattern": self.client_config.reason_response_pattern, + "reason_validation_pattern": self.client_config.reason_validation_pattern, "reason_prefill": self.reason_prefill, "reason_failure_behavior": self.reason_failure_behavior, "requires_reasoning_pattern": self.requires_reasoning_pattern, @@ -1349,6 +1425,23 @@ class ClientBase: # reasoning handled automatically during streaming return response, None + # If a reasoning-start validation pattern is set but absent from the + # response, the model never reasoned, so return it as-is instead of + # failing on the strip pattern (see reason_validation_pattern in the + # config schema). Skipped when prefilled, as the start then lives in the + # prompt rather than the response. + validation_pattern = self.reason_validation_pattern + if ( + validation_pattern + and not self.reason_prefill + and not re.search(validation_pattern, response, re.DOTALL) + ): + log.debug( + "reasoning validation pattern not found - model did not reason", + pattern=validation_pattern, + ) + return response, None + pattern = self.reason_response_pattern if not pattern: pattern = DEFAULT_REASONING_PATTERN @@ -1638,6 +1731,8 @@ class ClientBase: response_tokens=self._returned_response_tokens or self.count_tokens(response), agent_stack=agent_context.agent_stack if agent_context else [], + agent_type=agent_context.agent.agent_type if agent_context else None, + agent_action=agent_context.action if agent_context else None, client_name=self.name, client_type=self.client_type, time=time_end - time_start, diff --git a/src/talemate/client/context.py b/src/talemate/client/context.py index 9bfc50fd..82cf3f7f 100644 --- a/src/talemate/client/context.py +++ b/src/talemate/client/context.py @@ -47,6 +47,8 @@ class ContextModel(BaseModel): inference_preset: str = None data_format: str | None = None requires_active_scene: bool = True + # Honored unless the client reports reason_locked. + disable_reasoning: bool = False # Define the context variable as an empty dictionary diff --git a/src/talemate/client/google.py b/src/talemate/client/google.py index 17d8bb73..193c5a79 100644 --- a/src/talemate/client/google.py +++ b/src/talemate/client/google.py @@ -41,15 +41,23 @@ SUPPORTED_MODELS = [ "gemini-3-pro-preview", "gemini-3.1-pro-preview", "gemini-3-flash-preview", + "gemini-3.5-flash", ] ALWAYS_REASONING_MODELS = [ "gemini-3", "gemini-3.1", "gemini-2.5", + "gemini-3.5", ] -DEFAULT_MODEL = "gemini-3-flash-preview" +DEFAULT_MODEL = "gemini-3.5-flash" + +# Older versions communicated an incomplete setup by overwriting the client's +# model name with this sentinel. That value could leak back into the persisted +# `model` field, causing valid requests to be sent to Google with an invalid +# model. It is kept here so existing broken configs can be detected and healed. +SETUP_INCOMPLETE_MODEL = "Setup incomplete" class Defaults(EndpointOverride, CommonDefaults, pydantic.BaseModel): @@ -106,17 +114,24 @@ class GoogleClient( self.google_project_id = None super().__init__(**kwargs) + @property + def model(self) -> str | None: + model = self.client_config.model + # Heal a leaked SETUP_INCOMPLETE_MODEL sentinel by reporting no model selected. + if model == SETUP_INCOMPLETE_MODEL: + return None + return model + @property def disable_safety_settings(self): return self.client_config.disable_safety_settings @property - def reason_enabled(self) -> bool: + def reason_enabled_configured(self) -> bool: if self.reason_locked: # Always enable reasoning for Gemini 3 and Gemini 2.5 return True - - return self.client_config.reason_enabled + return super().reason_enabled_configured @property def min_reason_tokens(self) -> int: @@ -258,15 +273,15 @@ class GoogleClient( def emit_status(self, processing: bool = None): error_action = None + error_message: str | None = None if processing is not None: self.processing = processing if self.ready: status = "busy" if self.processing else "idle" - model_name = self.model_name else: status = "error" - model_name = "Setup incomplete" + error_message = "Setup incomplete" error_action = ErrorAction( title="Setup Google API credentials", action_name="openAppConfig", @@ -279,7 +294,7 @@ class GoogleClient( if not self.model_name: status = "error" - model_name = "No model loaded" + error_message = "No model loaded" self.current_status = status data = { @@ -287,14 +302,15 @@ class GoogleClient( "error_action": error_action.model_dump() if error_action else None, "meta": self.Meta().model_dump(), "enabled": self.enabled, + "error_message": error_message, } data.update(self._common_status_data()) self.populate_extra_fields(data) - if self.using == "VertexAI": - details = f"{model_name} (VertexAI)" + if self.model_name and self.using == "VertexAI": + details = f"{self.model_name} (VertexAI)" else: - details = model_name + details = self.model_name emit( "client_status", @@ -355,6 +371,9 @@ class GoogleClient( if not self.ready: raise Exception("Google setup incomplete") + if not self.model_name: + raise Exception("Google client has no model selected") + client = self.make_client() if self.can_be_coerced: diff --git a/src/talemate/client/llamacpp.py b/src/talemate/client/llamacpp.py index c9dbe761..fbf6af80 100644 --- a/src/talemate/client/llamacpp.py +++ b/src/talemate/client/llamacpp.py @@ -12,6 +12,11 @@ from talemate.client.base import ( ParameterReroute, ) from talemate.client.registry import register +from talemate.client.remote import ( + ConcurrentInference, + ConcurrentInferenceMixin, + concurrent_inference_extra_fields, +) from talemate.client.vision import VisionConfig, vision_extra_fields, OpenAIVisionMixin from talemate.config.schema import Client as BaseClientConfig from talemate.exceptions import GenerationProcessingError @@ -25,12 +30,12 @@ class Defaults(CommonDefaults, pydantic.BaseModel): max_token_length: int = 8192 -class ClientConfig(VisionConfig, BaseClientConfig): +class ClientConfig(ConcurrentInference, VisionConfig, BaseClientConfig): pass @register() -class LlamaCppClient(OpenAIVisionMixin, ClientBase): +class LlamaCppClient(ConcurrentInferenceMixin, OpenAIVisionMixin, ClientBase): """ Client for ggml-org/llama.cpp `llama-server`. @@ -53,7 +58,10 @@ class LlamaCppClient(OpenAIVisionMixin, ClientBase): defaults: Defaults = Defaults() self_hosted: bool = True extra_fields: dict = pydantic.Field( - default_factory=lambda: vision_extra_fields() + default_factory=lambda: { + **vision_extra_fields(), + **concurrent_inference_extra_fields(), + } ) @property diff --git a/src/talemate/client/model_prompts.py b/src/talemate/client/model_prompts.py index 086db44f..bb2f611c 100644 --- a/src/talemate/client/model_prompts.py +++ b/src/talemate/client/model_prompts.py @@ -7,11 +7,30 @@ import pydantic from typing import Any import huggingface_hub +import requests import structlog from jinja2 import Environment, FileSystemLoader __all__ = ["model_prompt", "PromptSpec"] +# (connect, read) timeout in seconds applied to every huggingface_hub request. +# Some HF calls (notably HfApi.list_models) issue requests without any timeout, +# so a network issue reaching huggingface.co would otherwise hang indefinitely. +HF_REQUEST_TIMEOUT = (10, 30) + + +class _TimeoutHTTPSession(requests.Session): + """requests.Session that applies a default timeout to every request.""" + + def request(self, *args, **kwargs): + kwargs.setdefault("timeout", HF_REQUEST_TIMEOUT) + return super().request(*args, **kwargs) + + +# Ensure all huggingface_hub HTTP requests fail fast on connection issues +# instead of hanging forever. Calls that pass their own timeout are unaffected. +huggingface_hub.configure_http_backend(backend_factory=_TimeoutHTTPSession) + BASE_TEMPLATE_PATH = os.path.join( os.path.dirname(os.path.abspath(__file__)), "..", @@ -53,6 +72,7 @@ def _raise_exception(msg): class PromptSpec(pydantic.BaseModel): template: str | None = None reasoning_pattern: str | None = None + reasoning_validation_pattern: str | None = None def set_spec(self, key: str, value: Any): setattr(self, key, value) @@ -298,7 +318,42 @@ class ModelPrompt: return True return False - def query_hf_for_prompt_template_suggestion(self, model_name: str): + def _download_hf_file( + self, repo_id: str, filename: str, revision: str + ) -> str | None: + """ + Download a single file from a HF repo into a temporary directory and + return its contents. Returns None if the file is missing or cannot be + retrieved (e.g. 404 or a connection issue). + """ + try: + with tempfile.TemporaryDirectory() as tmpdir: + path = huggingface_hub.hf_hub_download( + repo_id=repo_id, + filename=filename, + cache_dir=tmpdir, + revision=revision, + ) + with open(path) as f: + return f.read() + except Exception as e: + if not str(e).startswith("404"): + log.error( + "query_hf_for_prompt_template_suggestion", + error=str(e), + filename=filename, + ) + return None + + def _match_template_identifier(self, content: str) -> str | None: + """Return the template file name whose identifier matches content.""" + for identifier_cls in TEMPLATE_IDENTIFIERS: + identifier = identifier_cls() + if identifier(content): + return f"{identifier.template_str}.jinja2" + return None + + def query_hf_for_prompt_template_suggestion(self, model_name: str) -> str | None: api = huggingface_hub.HfApi() log.debug("query_hf_for_prompt_template_suggestion", model_name=model_name) @@ -314,81 +369,46 @@ class ModelPrompt: branch_name = "main" - models = list(api.list_models(model_name=model_name)) + try: + models = list(api.list_models(model_name=model_name)) + if not models and model_name_alt: + models = list(api.list_models(model_name=model_name_alt)) + except Exception as e: + log.warning( + "query_hf_for_prompt_template_suggestion: " + "could not reach HuggingFace to determine prompt template", + model_name=model_name, + error=str(e), + ) + return None if not models: - if model_name_alt: - models = list(api.list_models(model_name=model_name_alt)) - if not models: - return None + return None model = models[0] repo_id = f"{model.id}" - # check chat_template.jinja2 - try: - with tempfile.TemporaryDirectory() as tmpdir: - chat_template_path = huggingface_hub.hf_hub_download( - repo_id=repo_id, - filename="chat_template.jinja2", - cache_dir=tmpdir, - revision=branch_name, - ) - if not chat_template_path: - return None - with open(chat_template_path) as f: - chat_template = f.read() - for identifer_cls in TEMPLATE_IDENTIFIERS: - identifier = identifer_cls() - if identifier(chat_template): - return f"{identifier.template_str}.jinja2" - except Exception as e: - if not str(e).startswith("404"): - log.error("query_hf_for_prompt_template_suggestion", error=str(e)) + # Files are checked in priority order; the first matching identifier wins. + for filename in ("chat_template.jinja2", "README.md"): + content = self._download_hf_file(repo_id, filename, branch_name) + if content: + match = self._match_template_identifier(content) + if match: + return match - # Check README.md - try: - with tempfile.TemporaryDirectory() as tmpdir: - readme_path = huggingface_hub.hf_hub_download( - repo_id=repo_id, - filename="README.md", - cache_dir=tmpdir, - revision=branch_name, - ) - if not readme_path: - return None - with open(readme_path) as f: - readme = f.read() - for identifer_cls in TEMPLATE_IDENTIFIERS: - identifier = identifer_cls() - if identifier(readme): - return f"{identifier.template_str}.jinja2" - except Exception as e: - if not str(e).startswith("404"): - log.error("query_hf_for_prompt_template_suggestion", error=str(e)) - - try: - # Check tokenizer_config.json - # "chat_template" key - with tempfile.TemporaryDirectory() as tmpdir: - config_path = huggingface_hub.hf_hub_download( - repo_id=repo_id, - filename="tokenizer_config.json", - cache_dir=tmpdir, - revision=branch_name, - ) - if not config_path: - return None - with open(config_path) as f: - config = json.load(f) - for identifer_cls in TEMPLATE_IDENTIFIERS: - identifier = identifer_cls() - if identifier(config.get("chat_template", "")): - return f"{identifier.template_str}.jinja2" - except Exception as e: - if not str(e).startswith("404"): - log.error("query_hf_for_prompt_template_suggestion", error=str(e)) + # tokenizer_config.json stores the chat template under a "chat_template" key + config_content = self._download_hf_file( + repo_id, "tokenizer_config.json", branch_name + ) + if config_content: + try: + config = json.loads(config_content) + except json.JSONDecodeError: + config = {} + match = self._match_template_identifier(config.get("chat_template", "")) + if match: + return match model_prompt = ModelPrompt() diff --git a/src/talemate/client/openai_compat.py b/src/talemate/client/openai_compat.py index 12e683ec..717bc3bf 100644 --- a/src/talemate/client/openai_compat.py +++ b/src/talemate/client/openai_compat.py @@ -4,7 +4,7 @@ import pydantic import structlog from openai import AsyncOpenAI -from talemate.client.base import ClientBase, ExtraField +from talemate.client.base import ClientBase, ExtraField, FieldGroup from talemate.client.registry import register from talemate.config.schema import Client as BaseClientConfig @@ -12,6 +12,37 @@ log = structlog.get_logger("talemate.client.openai_compat") EXPERIMENTAL_DESCRIPTION = """Use this client if you want to connect to a service implementing an OpenAI-compatible API. Success is going to depend on the level of compatibility. Use the actual OpenAI client if you want to connect to OpenAI's API.""" +# Sampler parameters whose inclusion in the request payload is user-toggleable. +# Some OpenAI-compatible APIs hard-error when these are sent for certain models, +# so they need to be omitted entirely (not just zeroed out). +TOGGLEABLE_PARAMETERS = ("temperature", "top_p", "presence_penalty") + + +PARAMETERS_FIELD_GROUP = FieldGroup( + name="parameters", + label="Parameters", + description=( + "Toggle individual sampler parameters. When a parameter is disabled it is " + "omitted from the request payload entirely. Useful for APIs that hard-error " + "when receiving parameters they don't support for the selected model." + ), + icon="mdi-tune-vertical", +) + + +def _send_parameter_field(param: str) -> ExtraField: + return ExtraField( + name=f"send_{param}", + type="bool", + label=f"Send {param}", + required=False, + description=( + f"When enabled, `{param}` is included in the request. Disable for " + f"models/APIs that reject it." + ), + group=PARAMETERS_FIELD_GROUP, + ) + class Defaults(pydantic.BaseModel): api_url: str = "http://localhost:5000" @@ -21,10 +52,16 @@ class Defaults(pydantic.BaseModel): api_handles_prompt_template: bool = False double_coercion: str = None rate_limit: int | None = None + send_temperature: bool = True + send_top_p: bool = True + send_presence_penalty: bool = True class ClientConfig(BaseClientConfig): api_handles_prompt_template: bool = False + send_temperature: bool = True + send_top_p: bool = True + send_presence_penalty: bool = True @register() @@ -48,13 +85,26 @@ class OpenAICompatibleClient(ClientBase): label="API handles prompt template (chat/completions)", required=False, description="The API handles the prompt template, meaning your choice in the UI for the prompt template below will be ignored. This is not recommended and should only be used if the API does not support the `completions` andpoint or you don't know which prompt template to use.", - ) + ), + **{f"send_{p}": _send_parameter_field(p) for p in TOGGLEABLE_PARAMETERS}, } @property def api_handles_prompt_template(self) -> bool: return self.client_config.api_handles_prompt_template + @property + def send_temperature(self) -> bool: + return self.client_config.send_temperature + + @property + def send_top_p(self) -> bool: + return self.client_config.send_top_p + + @property + def send_presence_penalty(self) -> bool: + return self.client_config.send_presence_penalty + @property def experimental(self): return EXPERIMENTAL_DESCRIPTION @@ -69,12 +119,11 @@ class OpenAICompatibleClient(ClientBase): @property def supported_parameters(self): - return [ - "temperature", - "top_p", - "presence_penalty", - "max_tokens", - ] + params = ["max_tokens"] + for param in TOGGLEABLE_PARAMETERS: + if getattr(self.client_config, f"send_{param}"): + params.append(param) + return params def prompt_template(self, system_message: str, prompt: str): if not self.api_handles_prompt_template: diff --git a/src/talemate/client/openrouter.py b/src/talemate/client/openrouter.py index c86e27a8..99f5bb27 100644 --- a/src/talemate/client/openrouter.py +++ b/src/talemate/client/openrouter.py @@ -180,6 +180,23 @@ PROVIDER_FIELD_GROUP = FieldGroup( MIN_THINKING_TOKENS = 256 +def cache_control_for_model(model_name: str) -> dict | None: + """ + Return the cache_control parameter needed to enable prompt caching for + the given OpenRouter model, or None if no parameter is required. + + OpenAI / DeepSeek / Gemini cache automatically on OpenRouter. Anthropic + models require explicit cache_control or the request is never cached. + For Anthropic, top-level ephemeral cache_control mirrors the native SDK's + automatic-cache mode — the breakpoint is auto-placed on the last cacheable + block. Note: this forces routing to direct Anthropic, excluding Bedrock + and Vertex endpoints. + """ + if model_name.startswith("anthropic/"): + return {"type": "ephemeral"} + return None + + @register() class OpenRouterClient(ConcurrentInferenceMixin, ClientBase): """ @@ -369,6 +386,11 @@ class OpenRouterClient(ConcurrentInferenceMixin, ClientBase): if provider: parameters["provider"] = provider + if self.optimize_prompt_caching: + cache_control = cache_control_for_model(self.model_name) + if cache_control is not None: + parameters["cache_control"] = cache_control + # Prepare request payload payload = { "model": self.model_name, diff --git a/src/talemate/client/presets.py b/src/talemate/client/presets.py index ec2b612a..3b4db2a9 100644 --- a/src/talemate/client/presets.py +++ b/src/talemate/client/presets.py @@ -13,6 +13,7 @@ __all__ = [ "set_max_tokens", "set_preset", "preset_for_kind", + "preset_name_for_kind", "make_kind", "max_tokens_for_kind", ] @@ -84,6 +85,11 @@ PRESET_SUBSTRING_MAPPINGS = { "visualize": "creative_instruction", "visual": "creative_instruction", "world_state": "analytical", + # Must stay after "create"/"creative": preset_for_kind takes the LAST + # matching substring, so this lets a parametric kind like + # "creative_instruction_256" resolve to the creative_instruction preset + # instead of being captured by the broader "create"/"creative" entries. + "creative_instruction": "creative_instruction", } PRESET_MAPPING = { @@ -105,11 +111,14 @@ PRESET_MAPPING = { } -def preset_for_kind(kind: str, client: "ClientBase") -> dict: - # Check the substrings first(based on order of the original elifs) - - preset_name = None +def preset_name_for_kind(kind: str) -> str | None: + """ + Resolve a kind string to an inference preset name. + Exact matches in PRESET_MAPPING win first; otherwise the LAST matching + substring in PRESET_SUBSTRING_MAPPINGS is used (so more specific entries + placed later override broader ones). Returns None when nothing matches. + """ preset_name = PRESET_MAPPING.get(kind) if not preset_name: @@ -117,6 +126,12 @@ def preset_for_kind(kind: str, client: "ClientBase") -> dict: if substring in kind: preset_name = value + return preset_name + + +def preset_for_kind(kind: str, client: "ClientBase") -> dict: + preset_name = preset_name_for_kind(kind) + if not preset_name: log.warning( f"No preset found for kind {kind}, defaulting to 'scene_direction'", diff --git a/src/talemate/config/schema.py b/src/talemate/config/schema.py index ac30d886..38ec038d 100644 --- a/src/talemate/config/schema.py +++ b/src/talemate/config/schema.py @@ -62,7 +62,14 @@ class Client(pydantic.BaseModel): reason_tokens: int = 0 # regex to strip from the response if the model is reasoning - reason_response_pattern: Union[str, None] = None + reason_response_pattern: str | None = None + + # regex matching the START of the reasoning tokens, used to validate that + # the model actually reasoned. Some models (e.g. abliterated variants) do + # not always reason. If set and NOT found in the response (and reasoning was + # not prefilled), the model never reasoned and the response is treated the + # same as when reason_failure_behavior is "ignore". + reason_validation_pattern: str | None = None # reason prefill - will be prepended to the prompt if the model is reasoning # this is mostly for base models that don't hhave reas @@ -99,15 +106,18 @@ class Client(pydantic.BaseModel): # Controls whether token caps and/or response length instructions are # sent with prompts. Options: # "uncapped" - no token cap, no instructions - # "cap_tokens_and_instructions" - cap tokens + append instructions (default) + # "cap_tokens_and_instructions" - cap tokens + append instructions # "cap_tokens" - cap tokens only, no instructions # "instructions" - append instructions only, no token cap + # "adaptive" - "instructions" when reasoning is enabled, otherwise + # "cap_tokens_and_instructions" (default) enforce_response_length: Literal[ "uncapped", "cap_tokens_and_instructions", "cap_tokens", "instructions", - ] = "cap_tokens_and_instructions" + "adaptive", + ] = "adaptive" @pydantic.field_validator("lock_template", mode="before") @classmethod @@ -140,6 +150,11 @@ class Client(pydantic.BaseModel): "value": "instructions", "help": "Appends length instructions without limiting tokens", }, + { + "label": "Adaptive", + "value": "adaptive", + "help": "Sends instructions only when reasoning is enabled, otherwise limits tokens and sends instructions", + }, ], } @@ -188,6 +203,7 @@ class General(pydantic.BaseModel): max_backscroll: int = 100 add_default_character: bool = True show_agent_activity_bar: bool = True + release_gpu_cache_on_scene_load: bool = True class StateReinforcementTemplate(pydantic.BaseModel): @@ -230,6 +246,18 @@ class CreatorConfig(pydantic.BaseModel): content_context: list[str] = [ "a fun and engaging slice of life story aimed at an adult audience." ] + perspective_presets: list[str] = [ + "Third person limited, past tense.", + "Third person limited, past tense, focused on {player_name}'s POV.", + "Third person limited, present tense.", + "Third person limited, present tense, focused on {player_name}'s POV.", + "Third person omniscient, past tense.", + "Third person omniscient, present tense.", + "First person, past tense, from {player_name}'s POV.", + "First person, present tense, from {player_name}'s POV.", + "Second person, present tense.", + "Second person, present tense. Talking to {player_name}.", + ] class OpenAIConfig(pydantic.BaseModel): @@ -269,6 +297,10 @@ class CoquiConfig(pydantic.BaseModel): api_key: Union[str, None] = None +class HuggingFaceConfig(pydantic.BaseModel): + api_key: Union[str, None] = None + + class GoogleConfig(pydantic.BaseModel): gcloud_credentials_path: Union[str, None] = None gcloud_location: Union[str, None] = None @@ -577,6 +609,7 @@ class SceneAppearance(pydantic.BaseModel): parentheses: MarkupMessageStyle = MarkupMessageStyle() brackets: MarkupMessageStyle = MarkupMessageStyle() emphasis: MarkupMessageStyle = MarkupMessageStyle() + entities: MarkupMessageStyle = MarkupMessageStyle() message_assets: Dict[str, MessageAssetCadenceConfig] = pydantic.Field( default_factory=lambda: { "avatar": MessageAssetCadenceConfig(), @@ -607,6 +640,14 @@ class PromptsConfig(pydantic.BaseModel): template_sources: Dict[str, str] = pydantic.Field(default_factory=dict) +class AgentActionOverride(pydantic.BaseModel): + disable_reasoning: bool = False + + +class AgentActionsConfig(pydantic.BaseModel): + overrides: Dict[str, AgentActionOverride] = pydantic.Field(default_factory=dict) + + class Config(pydantic.BaseModel): clients: Dict[str, AnnotatedClient] = {} @@ -636,6 +677,8 @@ class Config(pydantic.BaseModel): coqui: CoquiConfig = CoquiConfig() + huggingface: HuggingFaceConfig = HuggingFaceConfig() + recent_scenes: RecentScenes = RecentScenes() presets: Presets = Presets() @@ -646,6 +689,8 @@ class Config(pydantic.BaseModel): prompts: PromptsConfig = PromptsConfig() + agent_actions: AgentActionsConfig = AgentActionsConfig() + dirty: bool = pydantic.Field(default=False, exclude=True) model_config = ConfigDict(extra="ignore") diff --git a/src/talemate/emit/base.py b/src/talemate/emit/base.py index 7fcc015a..a262a665 100644 --- a/src/talemate/emit/base.py +++ b/src/talemate/emit/base.py @@ -1,9 +1,9 @@ from __future__ import annotations import asyncio -import dataclasses from typing import TYPE_CHECKING, Callable +import pydantic import structlog from talemate.context import interaction @@ -30,20 +30,21 @@ log = structlog.get_logger("talemate.emit.base") async_signals.register("user_interaction") -@dataclasses.dataclass -class Emission: +class Emission(pydantic.BaseModel): + model_config = pydantic.ConfigDict(arbitrary_types_allowed=True) + typ: str - message: str = None - message_object: SceneMessage = None - character: Character = None - scene: Scene = None - status: str = None - id: str = None - details: str = None - data: dict = None + message: str | list | dict | None = None + message_object: SceneMessage | None = None + character: "Character | None" = None + scene: "Scene | None" = None + status: str | None = None + id: str | int | None = None + details: str | dict | None = None + data: dict | None = None websocket_passthrough: bool = False - meta: dict = dataclasses.field(default_factory=dict) - kwargs: dict = dataclasses.field(default_factory=dict) + meta: dict = pydantic.Field(default_factory=dict) + kwargs: dict = pydantic.Field(default_factory=dict) def emit( diff --git a/src/talemate/emit/signals.py b/src/talemate/emit/signals.py index 012acd62..e9f66835 100644 --- a/src/talemate/emit/signals.py +++ b/src/talemate/emit/signals.py @@ -30,6 +30,7 @@ PromptSent = signal("prompt_sent") MemoryRequest = signal("memory_request") RemoveMessage = signal("remove_message") +RegenerateFailed = signal("regenerate_failed") SceneStatus = signal("scene_status") SceneIntent = signal("scene_intent") @@ -85,6 +86,7 @@ handlers = { "request_agent_status": RequestAgentStatus, "clear_screen": ClearScreen, "remove_message": RemoveMessage, + "regenerate_failed": RegenerateFailed, "agent_message": AgentMessage, "scene_status": SceneStatus, "scene_intent": SceneIntent, diff --git a/src/talemate/events.py b/src/talemate/events.py index 3c3c61b6..fa78aa46 100644 --- a/src/talemate/events.py +++ b/src/talemate/events.py @@ -1,6 +1,5 @@ from __future__ import annotations -from dataclasses import dataclass from typing import TYPE_CHECKING import pydantic @@ -39,79 +38,65 @@ class UserInteractionEvent(pydantic.BaseModel): message: str -# TODO: Convert these to pydantic models +class Event(pydantic.BaseModel): + model_config = pydantic.ConfigDict(arbitrary_types_allowed=True) - -@dataclass -class Event: - scene: Scene + scene: "Scene" event_type: str -@dataclass class HistoryEvent(Event): - messages: list[str] + messages: list["SceneMessage"] -@dataclass class ArchiveEvent(Event): text: str memory_id: str - ts: str = None + ts: str | None = None -@dataclass class CharacterStateEvent(Event): state: str character_name: str -@dataclass class SceneStateEvent(Event): pass -@dataclass class GameLoopBase(Event): pass -@dataclass class GameLoopEvent(GameLoopBase): had_passive_narration: bool = False -@dataclass class GameLoopStartEvent(GameLoopBase): pass -@dataclass class GameLoopActorIterEvent(GameLoopBase): - actor: Actor + actor: "Actor" game_loop: GameLoopEvent -@dataclass class GameLoopCharacterIterEvent(GameLoopBase): - character: Character + character: "Character" game_loop: GameLoopEvent -@dataclass class GameLoopNewMessageEvent(GameLoopBase): - message: SceneMessage + message: "SceneMessage" -@dataclass class PlayerTurnStartEvent(Event): pass -@dataclass class RegenerateGeneration(Event): message: "SceneMessage" - character: "Character" = None + character: "Character | None" = None async_signals.register( diff --git a/src/talemate/export.py b/src/talemate/export.py index 8101ea24..a88d7439 100644 --- a/src/talemate/export.py +++ b/src/talemate/export.py @@ -14,6 +14,7 @@ from typing import Union import pydantic import structlog +from talemate.scene_agent_settings import AGENT_SETTINGS_DIRNAME from talemate.tale_mate import Scene log = structlog.get_logger("talemate.export") @@ -44,6 +45,7 @@ class ExportOptions(pydantic.BaseModel): include_nodes: bool = True include_info: bool = True include_templates: bool = True + include_agent_settings: bool = True async def export(scene: Scene, options: ExportOptions) -> Union[str, bytes]: @@ -169,6 +171,28 @@ async def export_talemate_complete(scene: Scene, options: ExportOptions) -> byte except Exception as e: log.warning("Failed to copy templates directory", error=str(e)) + # Copy agent-settings directory if it exists and option is enabled. + # Without this the importing user loses every per-scene agent + # override referenced by `agent_settings_file` in the scene JSON. + if options.include_agent_settings: + try: + agent_settings_source = Path(scene.save_dir) / AGENT_SETTINGS_DIRNAME + if agent_settings_source.exists(): + agent_settings_dest = temp_path / AGENT_SETTINGS_DIRNAME + shutil.copytree(agent_settings_source, agent_settings_dest) + log.debug( + "Copied agent-settings directory", + source=agent_settings_source, + dest=agent_settings_dest, + ) + else: + log.debug( + "Agent-settings directory does not exist", + path=agent_settings_source, + ) + except Exception as e: + log.warning("Failed to copy agent-settings directory", error=str(e)) + # Copy restore file if it exists and is set if scene.restore_from: try: diff --git a/src/talemate/game/engine/context_id/story_configuration.py b/src/talemate/game/engine/context_id/story_configuration.py index 071d2aaf..755a32ca 100644 --- a/src/talemate/game/engine/context_id/story_configuration.py +++ b/src/talemate/game/engine/context_id/story_configuration.py @@ -107,7 +107,12 @@ register_context_id_meta( ), ContextIDMeta( context_id="story_configuration:perspective", - description="The narrative perspective and tense for the story (e.g., 'Third person limited, past tense').", + description="The narrative perspective and tense for the story (e.g., 'Third person limited, past tense'). Alias for `story_configuration:perspective.default`.", + permanent=True, + ), + ContextIDMeta( + context_id="story_configuration:perspective.", + description="Role-specific narrative perspective override. Replace with one of: 'default' (scene-wide), 'player' (player character's POV), 'other' (NPCs), 'narrator' (narration). Empty role-specific overrides fall back to 'default'.", permanent=True, ), ContextIDMeta( @@ -147,6 +152,9 @@ register_context_id_meta( ) +PERSPECTIVE_ROLES: tuple[str, ...] = ("default", "player", "other", "narrator") + + @register_context_id_type class StoryConfigurationContextID(ContextID): context_type: ClassVar[str] = "story_configuration" @@ -197,6 +205,19 @@ class DirectorInstructionsContextID(StoryConfigurationContextID): class ScenePerspectiveContextID(StoryConfigurationContextID): key: ClassVar[str] = "perspective" + @classmethod + def make(cls, role: str | None = None, **kwargs) -> "ScenePerspectiveContextID": + path = ["perspective"] + if role and role != "default": + path.append(role) + return cls(path=path) + + @property + def role(self) -> str: + if len(self.path) >= 2 and self.path[1]: + return self.path[1] + return "default" + class CharacterListContextID(StoryConfigurationContextID): key: ClassVar[str] = "character_list" @@ -219,6 +240,7 @@ class StoryConfigurationContextItem(ContextIDItem): "character_list", ] name: str + role: str | None = None @property def context_id( @@ -252,7 +274,7 @@ class StoryConfigurationContextItem(ContextIDItem): if self.context_type == "scene_type": return SceneTypeContextID.make() if self.context_type == "perspective": - return ScenePerspectiveContextID.make() + return ScenePerspectiveContextID.make(role=self.role) if self.context_type == "character_list": return CharacterListContextID.make() @@ -270,7 +292,10 @@ class StoryConfigurationContextItem(ContextIDItem): "perspective": "Narrative Perspective", "character_list": "List of All Characters (active and inactive)", } - return mapping.get(self.context_type, self.name) + label = mapping.get(self.context_type, self.name) + if self.context_type == "perspective" and self.role and self.role != "default": + return f"{label} ({self.role.title()})" + return label async def get(self, scene: "Scene") -> str | None: if self.context_type == "title": @@ -295,7 +320,7 @@ class StoryConfigurationContextItem(ContextIDItem): except Exception: return None if self.context_type == "perspective": - return scene.perspective + return scene.perspectives.for_role(self.role or "default") if self.context_type == "character_list": characters = await list_characters(scene) return json.dumps([item.model_dump() for item in characters]) @@ -338,7 +363,10 @@ class StoryConfigurationContextItem(ContextIDItem): scene.intent_state.phase.scene_type = scene_type_id intent_changed = True elif self.context_type == "perspective": - scene.perspective = value or "" + role = self.role or "default" + if role not in PERSPECTIVE_ROLES: + raise ContextIDHandlerError(f"Invalid perspective role: {role}") + setattr(scene.perspectives, role, value or "") elif self.context_type == "character_list": raise ContextIDItemReadOnly(self.context_id.path_to_str) @@ -576,10 +604,14 @@ class StoryConfigurationContext(ContextIDHandler): ), ) if key == "perspective": + role = path[1] if len(path) > 1 and path[1] else "default" + if role not in PERSPECTIVE_ROLES: + return None return StoryConfigurationContextItem( context_type="perspective", name="perspective", - value=scene.perspective, + role=role, + value=scene.perspectives.for_role(role), ) if key == "character_list": return StoryConfigurationContextItem( diff --git a/src/talemate/game/engine/nodes/agent.py b/src/talemate/game/engine/nodes/agent.py index 20c7d3f7..5bd83216 100644 --- a/src/talemate/game/engine/nodes/agent.py +++ b/src/talemate/game/engine/nodes/agent.py @@ -136,13 +136,15 @@ class AgentSettingsNode(Node): outputs = {"agent_enabled": agent.enabled} for action_name, action in agent.actions.items(): - outputs[f"{action_name}_enabled"] = action.enabled + outputs[f"{action_name}_enabled"] = agent.resolve_enabled(action_name) if not action.config: continue - for config_name, config in action.config.items(): - outputs[f"{action_name}_{config_name}"] = config.value + for config_name in action.config: + outputs[f"{action_name}_{config_name}"] = agent.resolve_config( + action_name, config_name + ) self.set_output_values(outputs) @@ -225,7 +227,7 @@ class ToggleAgentAction(Node): f"Could not find action {action_name} in agent {agent}", ) - action.enabled = enabled + agent.write_enabled(action_name, enabled) self.set_output_values( {"agent": agent, "action_name": action_name, "enabled": enabled} diff --git a/src/talemate/game/engine/nodes/core/__init__.py b/src/talemate/game/engine/nodes/core/__init__.py index 8cfc6765..500a958d 100644 --- a/src/talemate/game/engine/nodes/core/__init__.py +++ b/src/talemate/game/engine/nodes/core/__init__.py @@ -2361,6 +2361,13 @@ class Listen(Graph): default="", ) + auto_register = PropertyField( + name="auto_register", + description="Self-register from the node registry instead of requiring placement in the scene loop graph", + type="bool", + default=False, + ) + @pydantic.computed_field(description="Node style") @property def style(self) -> NodeStyle: @@ -2380,6 +2387,7 @@ class Listen(Graph): def setup(self): self.set_property("event_name", UNRESOLVED) + self.set_property("auto_register", False) async def run(self, state: GraphState): log.warning("Listen node run directly", node=self) diff --git a/src/talemate/game/engine/nodes/event.py b/src/talemate/game/engine/nodes/event.py index 5af5716c..875b27b6 100644 --- a/src/talemate/game/engine/nodes/event.py +++ b/src/talemate/game/engine/nodes/event.py @@ -1,8 +1,9 @@ +import enum import uuid import structlog import talemate.emit.async_signals as signals from .core import Listen, Node, Graph, GraphState, NodeVerbosity, PropertyField -from .registry import register +from .registry import register, get_nodes_by_base_type from talemate.agents.registry import get_agent_types from talemate.agents.base import Agent from talemate.emit import emit @@ -14,14 +15,26 @@ __all__ = [ "collect_listeners", "connect_listeners", "disconnect_listeners", + "collect_auto_register_listeners", + "connect_auto_register_listeners", + "disconnect_auto_register_listeners", ] log = structlog.get_logger("talemate.game.engine.nodes.event") +class ListenerAction(enum.Enum): + CONNECT = "connect" + DISCONNECT = "disconnect" + RECONNECT = "reconnect" # disconnect first, then connect (idempotent) + + def collect_listeners(graph: Graph) -> dict[str, list["Listen"]]: """ - Does a deep search of the graph to find all Listen nodes + Does a deep search of the graph to find all Listen nodes. + + Listen nodes with ``auto_register=True`` are skipped here — they are + handled by ``collect_auto_register_listeners`` from the node registry. Args: graph: Graph to search @@ -33,6 +46,9 @@ def collect_listeners(graph: Graph) -> dict[str, list["Listen"]]: event_listeners = {} for node in graph.nodes.values(): if isinstance(node, Listen): + if node.get_property("auto_register"): + continue + event_name = node.get_property("event_name") if not event_name: @@ -46,6 +62,35 @@ def collect_listeners(graph: Graph) -> dict[str, list["Listen"]]: return event_listeners +def _apply_listener_signal( + listener: "Listen", + event_name: str, + state: GraphState, + action: ListenerAction, +): + """Look up the signal for ``event_name`` and apply ``action`` to ``listener``. + + Returns silently with a warning if the signal isn't registered. + """ + signal = signals.get(event_name) + if not signal: + log.warning("Event not found", event_name=event_name) + return + + if state.verbosity == NodeVerbosity.NORMAL: + log.debug( + "apply listener signal", + action=action.value, + listener=listener, + event_name=event_name, + ) + + if action in (ListenerAction.DISCONNECT, ListenerAction.RECONNECT): + signal.disconnect(listener.execute_from_event) + if action in (ListenerAction.CONNECT, ListenerAction.RECONNECT): + signal.connect(listener.execute_from_event) + + def connect_listeners(graph: Graph, state: GraphState, disconnect: bool = False): """ Connects all Listen nodes in the graph to the event bus @@ -55,23 +100,11 @@ def connect_listeners(graph: Graph, state: GraphState, disconnect: bool = False) """ event_listeners = collect_listeners(graph) + action = ListenerAction.RECONNECT if disconnect else ListenerAction.CONNECT for event_name, listeners in event_listeners.items(): for listener in listeners: - signal = signals.get(event_name) - if not signal: - log.warning("Event not found", event_name=event_name) - continue - - if state.verbosity == NodeVerbosity.NORMAL: - log.debug( - "Connecting listener", listener=listener, event_name=event_name - ) - - if disconnect: - signal.disconnect(listener.execute_from_event) - - signal.connect(listener.execute_from_event) + _apply_listener_signal(listener, event_name, state, action) def disconnect_listeners(graph: Graph, state: GraphState): @@ -85,17 +118,73 @@ def disconnect_listeners(graph: Graph, state: GraphState): event_listeners = collect_listeners(graph) for event_name, listeners in event_listeners.items(): for listener in listeners: - signal = signals.get(event_name) - if not signal: - log.warning("Event not found", event_name=event_name) - continue + _apply_listener_signal( + listener, event_name, state, ListenerAction.DISCONNECT + ) - if state.verbosity == NodeVerbosity.NORMAL: - log.debug( - "Disconnecting listener", listener=listener, event_name=event_name - ) - signal.disconnect(listener.execute_from_event) +def collect_auto_register_listeners() -> list["Listen"]: + """ + Walks the ``core/Event`` base-type registry, instantiates each Listen + subclass, and returns the ones whose persisted ``auto_register`` is + True and have a non-empty ``event_name``. + """ + + instances: list["Listen"] = [] + for node_cls in get_nodes_by_base_type("core/Event"): + try: + instance = node_cls() + except Exception as exc: + log.warning( + "auto_register listener instantiation failed", + node_cls=node_cls._registry, + err=str(exc), + ) + continue + + if not isinstance(instance, Listen): + continue + + if not instance.get_property("auto_register"): + continue + + event_name = instance.get_property("event_name") + if not event_name: + log.warning( + "auto_register Listen has no event_name", + node_cls=node_cls._registry, + ) + continue + + instances.append(instance) + + return instances + + +def connect_auto_register_listeners( + instances: list["Listen"], state: GraphState, disconnect: bool = False +): + """ + Connects auto-registered Listen instances to the event bus. + + Args: + instances: Listen instances returned by collect_auto_register_listeners + state: Current graph state (used for verbosity) + disconnect: If True, disconnect first to keep the receiver list clean + """ + action = ListenerAction.RECONNECT if disconnect else ListenerAction.CONNECT + for listener in instances: + event_name = listener.get_property("event_name") + _apply_listener_signal(listener, event_name, state, action) + + +def disconnect_auto_register_listeners(instances: list["Listen"], state: GraphState): + """ + Disconnects auto-registered Listen instances from the event bus. + """ + for listener in instances: + event_name = listener.get_property("event_name") + _apply_listener_signal(listener, event_name, state, ListenerAction.DISCONNECT) @register("event/Event") diff --git a/src/talemate/game/engine/nodes/load_definitions.py b/src/talemate/game/engine/nodes/load_definitions.py index 577cb814..0701d31c 100644 --- a/src/talemate/game/engine/nodes/load_definitions.py +++ b/src/talemate/game/engine/nodes/load_definitions.py @@ -3,6 +3,7 @@ import talemate.game.engine.nodes.command # noqa: F401 import talemate.game.engine.nodes.logic # noqa: F401 import talemate.game.engine.nodes.state # noqa: F401 import talemate.game.engine.nodes.scene # noqa: F401 +import talemate.game.engine.nodes.scene_messages # noqa: F401 import talemate.game.engine.nodes.scene_intent # noqa: F401 import talemate.game.engine.nodes.world_state # noqa: F401 import talemate.game.engine.nodes.run # noqa: F401 diff --git a/src/talemate/game/engine/nodes/run.py b/src/talemate/game/engine/nodes/run.py index 66c93325..e7450611 100644 --- a/src/talemate/game/engine/nodes/run.py +++ b/src/talemate/game/engine/nodes/run.py @@ -2,7 +2,6 @@ import structlog import pydantic import asyncio import re -import dataclasses from typing import ClassVar, Callable from talemate.game.engine.nodes.core import ( Node, @@ -46,11 +45,10 @@ def title_to_function_name(title: str) -> str: return re.sub(r"[^a-zA-Z0-9]", "_", title) -@dataclasses.dataclass -class BreakpointEvent: +class BreakpointEvent(pydantic.BaseModel): node: Node state: GraphState - module_path: str = None + module_path: str | None = None class FunctionWrapper: diff --git a/src/talemate/game/engine/nodes/scene.py b/src/talemate/game/engine/nodes/scene.py index 08c07054..e4a197c6 100644 --- a/src/talemate/game/engine/nodes/scene.py +++ b/src/talemate/game/engine/nodes/scene.py @@ -13,9 +13,14 @@ from .core import ( PropertyField, Trigger, ) -import dataclasses from .registry import register, get_nodes_by_base_type, get_node -from .event import connect_listeners, disconnect_listeners +from .event import ( + connect_listeners, + disconnect_listeners, + collect_auto_register_listeners, + connect_auto_register_listeners, + disconnect_auto_register_listeners, +) import talemate.events as events from talemate.emit import emit, wait_for_input from talemate.exceptions import ActedAsCharacter, AbortWaitForInput, GenerationCancelled @@ -46,10 +51,8 @@ async_signals.register( ) -@dataclasses.dataclass class SceneLoopEvent(events.Event): - scene: "Scene" - event_type: str + pass @register("scene/GetSceneState") @@ -774,333 +777,6 @@ class UnpackInteractionState(Node): ) -@register("scene/message/CharacterMessage") -class CharacterMessage(Node): - """ - Creates a character message from a character and a message - - Inputs: - - - character: The character object - - message: The message to send - - source: The source of the message - player or ai, so whether the message is result of user input or AI generated - - from_choice: For player messages this indicates that the message was generated from a choice selection, for ai sourced messages this indicates the instruction that was followed - - Properties: - - - source: The source of the message - - Outputs: - - - message: The message object (this is a scene_message.CharacterMessage instance) - """ - - class Fields: - source = PropertyField( - name="source", - description="The source of the message", - type="str", - default="player", - choices=[ - "player", - "ai", - ], - ) - - def __init__(self, title="Character Message", **kwargs): - super().__init__(title=title, **kwargs) - - def setup(self): - self.add_input("character", socket_type="character") - self.add_input("message", socket_type="str") - self.add_input("source", socket_type="str", optional=True) - self.add_input("from_choice", socket_type="str", optional=True) - - self.set_property("source", "player") - - self.add_output("message", socket_type="message_object") - - async def run(self, state: GraphState): - character: "Character" = self.get_input_value("character") - message = self.get_input_value("message") - source = self.get_input_value("source") - from_choice = self.get_input_value("from_choice") - - extra = {} - - if isinstance(from_choice, str): - extra["from_choice"] = from_choice - - # Capture the character's current avatar at message creation time - # (not default avatar - messages only use current_avatar if set) - if character.current_avatar: - extra["asset_id"] = character.current_avatar - extra["asset_type"] = "avatar" - - # prefix name: if not already prefixed - if not message.startswith(f"{character.name}: "): - message = f"{character.name}: {message}" - - message = scene_message.CharacterMessage(message, source=source, **extra) - - self.set_output_values({"message": message}) - - -@register("scene/message/NarratorMessage") -class NarratorMessage(Node): - """ - Creates a narrator message - - Inputs: - - - message: The message to send - - source: The source of the message - player or ai, so whether the message is result of user input or AI generated - - meta: A dictionary of meta information to attach to the message. This will generally be arguments and function name that was called on the narrator agent to generate the message and will be used when regenerating the message. - - Properties: - - - source: The source of the message - - Outputs: - - - message: The message object (this is a scene_message.NarratorMessage instance) - """ - - class Fields: - source = PropertyField( - name="source", - description="The source of the message", - type="str", - default="ai", - choices=[ - "player", - "ai", - ], - ) - - def __init__(self, title="Narrator Message", **kwargs): - super().__init__(title=title, **kwargs) - - def setup(self): - self.add_input("message", socket_type="str") - self.add_input("source", socket_type="str", optional=True) - self.add_input("meta", socket_type="dict", optional=True) - - self.set_property("source", "ai") - - self.add_output("message", socket_type="message_object") - - async def run(self, state: GraphState): - message = self.get_input_value("message") - source = self.get_input_value("source") - meta = self.get_input_value("meta") - - extra = {} - - if meta and isinstance(meta, dict): - extra["meta"] = meta - - message = scene_message.NarratorMessage(message, source=source, **extra) - - self.set_output_values({"message": message}) - - -@register("scene/message/DirectorMessage") -class DirectorMessage(Node): - """ - Creates a director message - - Inputs: - - - message: The message to send - - source: The source of the message - player or ai, so whether the message is result of user input or AI generated - - meta: A dictionary of meta information to attach to the message. Can hold the character name that the message is related to. - - character: The character object that the message is related to - - Properties: - - - source: The source of the message - - action: Describes the director action - - Outputs: - - - message: The message object (this is a scene_message.DirectorMessage instance) - """ - - class Fields: - source = PropertyField( - name="source", - description="The source of the message", - type="str", - default="ai", - choices=[ - "player", - "ai", - ], - ) - - action = PropertyField( - name="action", - description="Describes the director action", - type="str", - default="actor_instruction", - choices=[ - "actor_instruction", - "user_direction", - ], - ) - - subtype = PropertyField( - name="subtype", - description="The subtype of the director message, used for further categorization of the message", - type="str", - default=UNRESOLVED, - choices=[ - "function_call", - "user_direction", - ], - ) - - def __init__(self, title="Director Message", **kwargs): - super().__init__(title=title, **kwargs) - - def setup(self): - self.add_input("message", socket_type="str") - self.add_input("source", socket_type="str", optional=True) - self.add_input("meta", socket_type="dict", optional=True) - self.add_input("character", socket_type="character", optional=True) - self.add_input("action", socket_type="str", optional=True) - self.add_input("subtype", socket_type="str", optional=True) - - self.set_property("source", "ai") - self.set_property("action", "actor_instruction") - self.set_property("subtype", UNRESOLVED) - - self.add_output("message", socket_type="message_object") - self.add_output("source", socket_type="str") - self.add_output("meta", socket_type="dict") - self.add_output("character", socket_type="character") - self.add_output("action", socket_type="str") - self.add_output("subtype", socket_type="str") - - async def run(self, state: GraphState): - message = self.normalized_input_value("message") - source = self.normalized_input_value("source") - action = self.normalized_input_value("action") - meta = self.normalized_input_value("meta") - subtype = self.normalized_input_value("subtype") - character: "Character" = self.normalized_input_value("character") - - extra = {} - - if meta and isinstance(meta, dict): - extra["meta"] = meta - - message = scene_message.DirectorMessage( - message, source=source, action=action, subtype=subtype, **extra - ) - - if character is not None: - message.set_meta(character=character.name) - - self.set_output_values( - { - "message": message, - "source": source, - "meta": meta, - "character": character, - "action": action, - "subtype": subtype, - } - ) - - -@register("scene/message/UnpackMeta") -class UnpackMessageMeta(Node): - """ - Unpacks a message meta dictionary - into arguments - - Inputs: - - - meta: The meta dictionary - - Outputs: - - - agent_name: The agent name - - function_name: The function name - - arguments: The arguments dictionary - """ - - def __init__(self, title="Unpack Message Meta", **kwargs): - super().__init__(title=title, **kwargs) - - def setup(self): - self.add_input("meta", socket_type="dict") - self.add_output("agent_name", socket_type="str") - self.add_output("function_name", socket_type="str") - self.add_output("arguments", socket_type="dict") - - async def run(self, state: GraphState): - meta = self.get_input_value("meta") - - self.set_output_values( - { - "agent_name": meta["agent"], - "function_name": meta["function"], - "arguments": meta.get("arguments", {}).copy(), - } - ) - - -@register("scene/message/ToggleMessageContextVisibility") -class ToggleMessageContextVisibility(Node): - """ - Hide or show a message. Hidden messages are not displayed to the AI. - - Inputs: - - - message: The message object - - Properties: - - - hidden: Whether the message is hidden - - Outputs: - - - message: The message object - """ - - class Fields: - hidden = PropertyField( - name="hidden", - description="Whether the message is hidden", - type="bool", - default=False, - ) - - def __init__(self, title="Toggle Message Context Visibility", **kwargs): - super().__init__(title=title, **kwargs) - - def setup(self): - self.add_input("message", socket_type="message_object") - - self.set_property("hidden", False) - - self.add_output("message", socket_type="message_object") - - async def run(self, state: GraphState): - message = self.require_input("message") - hidden = self.get_property("hidden") - - if hidden: - message.hide() - else: - message.unhide() - - self.set_output_values({"message": message}) - - @register("input/WaitForInput") class WaitForInput(Node): """ @@ -1971,13 +1647,29 @@ class SceneLoop(Loop): connect_listeners(self, state, disconnect=True) + # The creative-mode wrapper loop runs the actual scene loop as a + # nested module (via RunModule, which sets `nested_scene_loop`); + # auto-register belongs to the inner game loop, not the wrapper, + # or the event would fire on both and double up. + is_game_loop = scene.environment != "creative" or state.shared.get( + "nested_scene_loop", False + ) + if not state.data.get("_scene_loop_init"): state.data["_scene_loop_init"] = True + if is_game_loop: + # inner state.data is destroyed when Loop.execute returns + state.outer.data["_auto_listeners"] = collect_auto_register_listeners() await self.register_commands(scene, state) await self.init_agent_nodes(scene, state) await async_signals.get("scene_loop_init").send(self.scene_loop_event) await async_signals.get("scene_loop_init_after").send(self.scene_loop_event) + if is_game_loop: + connect_auto_register_listeners( + state.outer.data.get("_auto_listeners") or [], state, disconnect=True + ) + trigger_game_loop = self.get_property("trigger_game_loop") if state.verbosity >= NodeVerbosity.VERBOSE: @@ -1993,7 +1685,7 @@ class SceneLoop(Loop): # Re-evaluate gamestate-controlled pins before the game loop await scene.load_active_pins() game_loop = events.GameLoopEvent( - scene=self, event_type="game_loop", had_passive_narration=False + scene=scene, event_type="game_loop", had_passive_narration=False ) state.shared["game_loop"] = game_loop if state.shared.get("signal_game_loop", True) and trigger_game_loop: @@ -2031,6 +1723,9 @@ class SceneLoop(Loop): await super().execute(outer_state) finally: disconnect_listeners(self, outer_state) + disconnect_auto_register_listeners( + outer_state.data.get("_auto_listeners") or [], outer_state + ) async def on_loop_error(self, state: GraphState, exc: Exception): scene: "Scene" = state.outer.data["scene"] diff --git a/src/talemate/game/engine/nodes/scene_messages.py b/src/talemate/game/engine/nodes/scene_messages.py new file mode 100644 index 00000000..5c5117a6 --- /dev/null +++ b/src/talemate/game/engine/nodes/scene_messages.py @@ -0,0 +1,467 @@ +from typing import TYPE_CHECKING, get_args + +from .core import ( + Node, + GraphState, + InputValueError, + UNRESOLVED, + PropertyField, +) +from .registry import register +import talemate.scene_message as scene_message + +if TYPE_CHECKING: + from talemate.character import Character + + +@register("scene/message/CharacterMessage") +class CharacterMessage(Node): + """ + Creates a character message from a character and a message + + Inputs: + + - character: The character object + - message: The message to send + - source: The source of the message - player or ai, so whether the message is result of user input or AI generated + - from_choice: For player messages this indicates that the message was generated from a choice selection, for ai sourced messages this indicates the instruction that was followed + + Properties: + + - source: The source of the message + + Outputs: + + - message: The message object (this is a scene_message.CharacterMessage instance) + """ + + class Fields: + source = PropertyField( + name="source", + description="The source of the message", + type="str", + default="player", + choices=[ + "player", + "ai", + ], + ) + + def __init__(self, title="Character Message", **kwargs): + super().__init__(title=title, **kwargs) + + def setup(self): + self.add_input("character", socket_type="character") + self.add_input("message", socket_type="str") + self.add_input("source", socket_type="str", optional=True) + self.add_input("from_choice", socket_type="str", optional=True) + + self.set_property("source", "player") + + self.add_output("message", socket_type="message_object") + + async def run(self, state: GraphState): + character: "Character" = self.get_input_value("character") + message = self.get_input_value("message") + source = self.get_input_value("source") + from_choice = self.get_input_value("from_choice") + + extra = {} + + if isinstance(from_choice, str): + extra["from_choice"] = from_choice + + # Capture the character's current avatar at message creation time + # (not default avatar - messages only use current_avatar if set) + if character.current_avatar: + extra["asset_id"] = character.current_avatar + extra["asset_type"] = "avatar" + + message = scene_message.CharacterMessage( + scene_message.CharacterMessage.with_name_prefix(character.name, message), + source=source, + **extra, + ) + + self.set_output_values({"message": message}) + + +@register("scene/message/NarratorMessage") +class NarratorMessage(Node): + """ + Creates a narrator message + + Inputs: + + - message: The message to send + - source: The source of the message - player or ai, so whether the message is result of user input or AI generated + - meta: A dictionary of meta information to attach to the message. This will generally be arguments and function name that was called on the narrator agent to generate the message and will be used when regenerating the message. + + Properties: + + - source: The source of the message + + Outputs: + + - message: The message object (this is a scene_message.NarratorMessage instance) + """ + + class Fields: + source = PropertyField( + name="source", + description="The source of the message", + type="str", + default="ai", + choices=[ + "player", + "ai", + ], + ) + + def __init__(self, title="Narrator Message", **kwargs): + super().__init__(title=title, **kwargs) + + def setup(self): + self.add_input("message", socket_type="str") + self.add_input("source", socket_type="str", optional=True) + self.add_input("meta", socket_type="dict", optional=True) + + self.set_property("source", "ai") + + self.add_output("message", socket_type="message_object") + + async def run(self, state: GraphState): + message = self.get_input_value("message") + source = self.get_input_value("source") + meta = self.get_input_value("meta") + + extra = {} + + if meta and isinstance(meta, dict): + extra["meta"] = meta + + message = scene_message.NarratorMessage(message, source=source, **extra) + + self.set_output_values({"message": message}) + + +@register("scene/message/DirectorMessage") +class DirectorMessage(Node): + """ + Creates a director message + + Inputs: + + - message: The message to send + - source: The source of the message - player or ai, so whether the message is result of user input or AI generated + - meta: A dictionary of meta information to attach to the message. Can hold the character name that the message is related to. + - character: The character object that the message is related to + + Properties: + + - source: The source of the message + - action: Describes the director action + + Outputs: + + - message: The message object (this is a scene_message.DirectorMessage instance) + """ + + class Fields: + source = PropertyField( + name="source", + description="The source of the message", + type="str", + default="ai", + choices=[ + "player", + "ai", + ], + ) + + action = PropertyField( + name="action", + description="Describes the director action", + type="str", + default="actor_instruction", + choices=[ + "actor_instruction", + "user_direction", + ], + ) + + subtype = PropertyField( + name="subtype", + description="The subtype of the director message, used for further categorization of the message", + type="str", + default=UNRESOLVED, + choices=[ + "function_call", + "user_direction", + ], + ) + + def __init__(self, title="Director Message", **kwargs): + super().__init__(title=title, **kwargs) + + def setup(self): + self.add_input("message", socket_type="str") + self.add_input("source", socket_type="str", optional=True) + self.add_input("meta", socket_type="dict", optional=True) + self.add_input("character", socket_type="character", optional=True) + self.add_input("action", socket_type="str", optional=True) + self.add_input("subtype", socket_type="str", optional=True) + + self.set_property("source", "ai") + self.set_property("action", "actor_instruction") + self.set_property("subtype", UNRESOLVED) + + self.add_output("message", socket_type="message_object") + self.add_output("source", socket_type="str") + self.add_output("meta", socket_type="dict") + self.add_output("character", socket_type="character") + self.add_output("action", socket_type="str") + self.add_output("subtype", socket_type="str") + + async def run(self, state: GraphState): + message = self.normalized_input_value("message") + source = self.normalized_input_value("source") + action = self.normalized_input_value("action") + meta = self.normalized_input_value("meta") + subtype = self.normalized_input_value("subtype") + character: "Character" = self.normalized_input_value("character") + + extra = {} + + if meta and isinstance(meta, dict): + extra["meta"] = meta + + message = scene_message.DirectorMessage( + message, source=source, action=action, subtype=subtype, **extra + ) + + if character is not None: + message.set_meta(character=character.name) + + self.set_output_values( + { + "message": message, + "source": source, + "meta": meta, + "character": character, + "action": action, + "subtype": subtype, + } + ) + + +@register("scene/message/UnpackMeta") +class UnpackMessageMeta(Node): + """ + Unpacks a message meta dictionary + into arguments + + Inputs: + + - meta: The meta dictionary + + Outputs: + + - agent_name: The agent name + - function_name: The function name + - arguments: The arguments dictionary + """ + + def __init__(self, title="Unpack Message Meta", **kwargs): + super().__init__(title=title, **kwargs) + + def setup(self): + self.add_input("meta", socket_type="dict") + self.add_output("agent_name", socket_type="str") + self.add_output("function_name", socket_type="str") + self.add_output("arguments", socket_type="dict") + + async def run(self, state: GraphState): + meta = self.get_input_value("meta") + + self.set_output_values( + { + "agent_name": meta["agent"], + "function_name": meta["function"], + "arguments": meta.get("arguments", {}).copy(), + } + ) + + +@register("scene/message/AddMessageVersion") +class AddMessageVersion(Node): + """ + Append a new version to a message's revision stack and make it the + active canonical. + + Typical use: hook ``game_loop_new_message`` (or ``push_history``), + rewrite the message body, and have the prior canonical preserved on + the stack so the user can navigate back to it via the revision + arrows in chat. The prior version stays at its existing index with + its own source/reason intact — this node only appends and shifts + the active pointer. + + Only valid for message types the revision UI can walk: + ``CharacterMessage``, ``NarratorMessage``, + ``ContextInvestigationMessage`` (i.e. anything with + ``_supports_versions = True``). Anything else raises + ``InputValueError`` — filter the message type before reaching this + node. + + For ``CharacterMessage``, ``new_text`` is auto-prefixed with + ``"Name: "`` if missing, matching the convention in + ``scene/message/CharacterMessage``. + + Inputs: + + - message: The SceneMessage to append a version to (required) + - new_text: The new canonical text (required) + - source: Version source label (optional, overrides property) + - reason: Free-form annotation rendered alongside the source badge (optional) + + Properties: + + - source: Version source label + - reason: Default reason if no input is connected + + Outputs: + + - message: The same SceneMessage instance (post-append) + - new_text: Passthrough of new_text + - source: Passthrough of source actually used + - reason: Passthrough of reason actually used + - version: The MessageVersion instance that was appended + """ + + class Fields: + source = PropertyField( + name="source", + description="The source label attached to the new version entry", + type="str", + default="custom", + choices=list(get_args(scene_message.VersionSource)), + ) + + reason = PropertyField( + name="reason", + description="Free-form annotation rendered alongside the source badge in the revision navigator", + type="str", + default="", + ) + + def __init__(self, title="Add Message Version", **kwargs): + super().__init__(title=title, **kwargs) + + def setup(self): + self.add_input("state") + self.add_input("message", socket_type="message_object") + self.add_input("new_text", socket_type="str") + self.add_input("source", socket_type="str", optional=True) + self.add_input("reason", socket_type="str", optional=True) + + self.set_property("source", "custom") + self.set_property("reason", "") + + self.add_output("state") + self.add_output("message", socket_type="message_object") + self.add_output("new_text", socket_type="str") + self.add_output("source", socket_type="str") + self.add_output("reason", socket_type="str") + self.add_output("version", socket_type="any") + + async def run(self, state: GraphState): + state_in = self.get_input_value("state") + message = self.require_input("message") + new_text = self.require_input("new_text") + source = self.normalized_input_value("source") + reason = self.normalized_input_value("reason") + + if not isinstance(message, scene_message.SceneMessage): + raise InputValueError( + self, "message", "Input is not a SceneMessage instance" + ) + + if not message._supports_versions: + raise InputValueError( + self, + "message", + f"Versions are only supported for character, narrator, and context_investigation messages — got {type(message).__name__}", + ) + + # Empty / whitespace-only reason → drop to None so the frontend + # renders just the source badge without a trailing separator. + reason_value = (reason.strip() or None) if isinstance(reason, str) else None + + if ( + isinstance(message, scene_message.CharacterMessage) + and message.character_name + ): + new_text = scene_message.CharacterMessage.with_name_prefix( + message.character_name, new_text + ) + + version = message.append_version(new_text, source=source, reason=reason_value) + + self.set_output_values( + { + "state": state_in, + "message": message, + "new_text": new_text, + "source": source, + "reason": reason_value, + "version": version, + } + ) + + +@register("scene/message/ToggleMessageContextVisibility") +class ToggleMessageContextVisibility(Node): + """ + Hide or show a message. Hidden messages are not displayed to the AI. + + Inputs: + + - message: The message object + + Properties: + + - hidden: Whether the message is hidden + + Outputs: + + - message: The message object + """ + + class Fields: + hidden = PropertyField( + name="hidden", + description="Whether the message is hidden", + type="bool", + default=False, + ) + + def __init__(self, title="Toggle Message Context Visibility", **kwargs): + super().__init__(title=title, **kwargs) + + def setup(self): + self.add_input("message", socket_type="message_object") + + self.set_property("hidden", False) + + self.add_output("message", socket_type="message_object") + + async def run(self, state: GraphState): + message = self.require_input("message") + hidden = self.get_property("hidden") + + if hidden: + message.hide() + else: + message.unhide() + + self.set_output_values({"message": message}) diff --git a/src/talemate/game/engine/nodes/tools/layout.py b/src/talemate/game/engine/nodes/tools/layout.py index e4b6572e..9638a281 100644 --- a/src/talemate/game/engine/nodes/tools/layout.py +++ b/src/talemate/game/engine/nodes/tools/layout.py @@ -43,7 +43,6 @@ from __future__ import annotations import enum from collections import defaultdict, deque -from dataclasses import dataclass, field from itertools import groupby from typing import Iterable @@ -116,8 +115,7 @@ class WCCKind(enum.Enum): BOTTOM = "bottom" # has core/Output, no core/Input, no core/Stage -@dataclass -class WCCInfo: +class WCCInfo(pydantic.BaseModel): """One weakly-connected component and its classification.""" nodes: list[str] @@ -435,11 +433,10 @@ def _rects_overlap(a: tuple[int, int, int, int], b: tuple[int, int, int, int]) - return not (ax + aw <= bx or bx + bw <= ax or ay + ah <= by or by + bh <= ay) -@dataclass -class _BandPlacement: +class _BandPlacement(pydantic.BaseModel): """Internal result of placing a single band.""" - rects: list[tuple[int, int, int, int]] = field(default_factory=list) + rects: list[tuple[int, int, int, int]] = pydantic.Field(default_factory=list) max_y: int = 0 # highest y + h across all placed nodes; 0 for empty diff --git a/src/talemate/game/engine/nodes/tools/writer.py b/src/talemate/game/engine/nodes/tools/writer.py index 4b00f187..a3325da8 100644 --- a/src/talemate/game/engine/nodes/tools/writer.py +++ b/src/talemate/game/engine/nodes/tools/writer.py @@ -42,10 +42,10 @@ from __future__ import annotations import json import uuid -from dataclasses import dataclass from pathlib import Path from typing import Any, Iterable +import pydantic import structlog from . import analysis @@ -185,8 +185,7 @@ class NodeNotFoundError(WriterError): # --------------------------------------------------------------------------- -@dataclass(frozen=True) -class NodeMetadata: +class NodeMetadata(pydantic.BaseModel): """Static metadata for a registered node class, cached per process. ``inputs`` / ``outputs`` are socket names in declaration order (the @@ -198,6 +197,8 @@ class NodeMetadata: of ``DynamicSocketNodeBase``. """ + model_config = pydantic.ConfigDict(frozen=True) + registry: str base_type: str inputs: tuple[str, ...] diff --git a/src/talemate/input.py b/src/talemate/input.py deleted file mode 100644 index 830b6c05..00000000 --- a/src/talemate/input.py +++ /dev/null @@ -1,17 +0,0 @@ -""" -Utils for input handling. -""" - -import asyncio - -__all__ = [ - "get_user_input", -] - - -async def get_user_input(prompt: str = "Enter your input: "): - """ - This function runs the input function in a separate thread and returns the user input. - """ - user_input = await asyncio.to_thread(input, prompt) - return user_input diff --git a/src/talemate/load/__init__.py b/src/talemate/load/__init__.py index 2dd5195c..772471fe 100644 --- a/src/talemate/load/__init__.py +++ b/src/talemate/load/__init__.py @@ -1,3 +1,4 @@ +import asyncio import json import os import shutil @@ -30,11 +31,14 @@ from talemate.status import LoadingStatus, set_loading from talemate.world_state import WorldState from talemate.game.engine.nodes.registry import import_scene_node_definitions from talemate.scene.intent import SceneIntent +from talemate.scene.schema import ScenePerspectives from talemate.history import validate_history import talemate.agents.tts.voice_library as voice_library from talemate.path import SCENES_DIR +from talemate.util.gpu import release_cuda_cache from talemate.changelog import _get_overall_latest_revision from talemate.shared_context import SharedContext +from talemate.scene_agent_settings import AGENT_SETTINGS_DIRNAME, resolve_link_on_load from talemate.load.character_card import CharacterCardImportOptions from talemate.scene_assets import AssetTransfer @@ -67,6 +71,19 @@ def to_project_name(name: str) -> str: return name.replace(" ", "-").replace("'", "").lower() +def load_scene_perspectives(scene_data: dict) -> ScenePerspectives: + """ + Resolve the scene's narrative perspectives from saved scene data. + + Prefers the nested `perspectives` object when present; otherwise migrates + the legacy flat `perspective` string into `perspectives.default`. + """ + perspectives_data = scene_data.get("perspectives") + if perspectives_data: + return ScenePerspectives(**perspectives_data) + return ScenePerspectives(default=scene_data.get("perspective", "") or "") + + class SceneInitialization(pydantic.BaseModel): project_name: str | None = None content_classification: str | None = None @@ -167,6 +184,18 @@ async def load_scene( """ exc = None + + # Release the previous scene's reserved GPU memory before loading the next. + # Local CUDA work (embedding re-commits, TTS) leaves PyTorch's caching + # allocator sitting on the high-water-mark of its largest batch, which only + # frees on process exit; returning it here gives the upcoming scene's + # re-embed maximum headroom — on native Linux, where CUDA has no sysmem + # fallback, that headroom is the difference between loading and an OOM. Done + # once per scene load (not per commit) so we don't thrash the allocator + # cache during gameplay. No-op off CUDA, or when disabled in config. + if get_config().game.general.release_gpu_cache_on_scene_load: + await asyncio.to_thread(release_cuda_cache) + try: with SceneIsLoading(scene): if file_path == "$NEW_SCENE$": @@ -294,7 +323,7 @@ async def load_scene_from_data( } scene.active_characters = scene_data.get("active_characters", []) scene.context = scene_data.get("context", "") - scene.perspective = scene_data.get("perspective", "") + scene.perspectives = load_scene_perspectives(scene_data) scene.project_name = scene_data.get("project_name") scene.intent_state = SceneIntent(**scene_data.get("intent_state", {})) scene.history = _load_history(scene_data["history"]) @@ -320,6 +349,8 @@ async def load_scene_from_data( else: scene.shared_context = None + await resolve_link_on_load(scene, scene_data) + import_scene_node_definitions(scene) if not reset: @@ -512,6 +543,19 @@ async def load_scene_from_zip(scene, zip_path, reset: bool = False): dest=templates_dest, ) + # Restore agent-settings if they exist in ZIP. Mirrors the export + # side — without this the importing user's scene loses the per-scene + # agent overlay referenced by `agent_settings_file`. + agent_settings_source = temp_path / AGENT_SETTINGS_DIRNAME + if agent_settings_source.exists(): + agent_settings_dest = Path(scene.save_dir) / AGENT_SETTINGS_DIRNAME + shutil.copytree(agent_settings_source, agent_settings_dest) + log.debug( + "Loaded agent-settings directory", + source=agent_settings_source, + dest=agent_settings_dest, + ) + # Restore restore file if it exists in ZIP and is referenced in scene_data restore_filename = scene_data.get("restore_from") if restore_filename: @@ -828,6 +872,14 @@ def _prepare_history(entry): cls = MESSAGES.get(typ, SceneMessage) + # Tolerate corrupt history entries (e.g. produced by changelog delta + # reconstruction) that have lost their `message` field. Construction + # would otherwise raise a pydantic ValidationError and abort the whole + # scene load. + if "message" not in entry: + log.warning("history entry missing message; substituting empty", entry=entry) + entry["message"] = "" + msg = cls(**entry) if isinstance(msg, (NarratorMessage, ReinforcementMessage)): diff --git a/src/talemate/model_rebuild.py b/src/talemate/model_rebuild.py new file mode 100644 index 00000000..540c2732 --- /dev/null +++ b/src/talemate/model_rebuild.py @@ -0,0 +1,84 @@ +""" +Resolve forward refs on pydantic models that reference Actor/Scene/Character. + +These models live in modules that import ``talemate.tale_mate`` (or +``talemate.character``) only under ``TYPE_CHECKING`` to avoid circular imports +at load time. Pydantic v2 cannot resolve those forward refs until the real +types are in scope — so we collect every affected model here and call +``model_rebuild()`` in one place, after ``tale_mate`` has finished loading. + +Import this module from ``talemate.__init__`` after ``tale_mate``. +""" + +# Concrete types that the forward refs point to. Importing them here puts +# them in this module's globals, but ``model_rebuild()`` resolves names from +# the model's own defining module — so we also hand them in explicitly via +# ``_types_namespace``. +from talemate.character import Character +from talemate.game.engine.context_id.character import ( + CharacterContext, + CharacterContextItem, +) +from talemate.scene_message import SceneMessage +from talemate.tale_mate import Actor, Scene + +from talemate.emit.base import Emission +from talemate.events import ( + ArchiveEvent, + CharacterStateEvent, + Event, + GameLoopActorIterEvent, + GameLoopBase, + GameLoopCharacterIterEvent, + GameLoopEvent, + GameLoopNewMessageEvent, + GameLoopStartEvent, + HistoryEvent, + PlayerTurnStartEvent, + RegenerateGeneration, + SceneStateEvent, +) +from talemate.game.engine.nodes.scene import SceneLoopEvent +from talemate.agents.conversation import ConversationAgentEmission +from talemate.agents.creator.assistant import ( + AutocompleteEmission, + ContextualGenerateEmission, +) +from talemate.agents.director.character_management import PersistCharacterEmission +from talemate.agents.director.generate_choices import GenerateChoicesEmission +from talemate.agents.summarize.analyze_scene import SceneAnalysisDeepAnalysisEmission + +_TYPES = { + "Actor": Actor, + "Character": Character, + "Scene": Scene, + "SceneMessage": SceneMessage, +} + +for _model in ( + Character, + CharacterContextItem, + CharacterContext, + Emission, + Event, + HistoryEvent, + ArchiveEvent, + CharacterStateEvent, + SceneStateEvent, + GameLoopBase, + GameLoopEvent, + GameLoopStartEvent, + GameLoopActorIterEvent, + GameLoopCharacterIterEvent, + GameLoopNewMessageEvent, + PlayerTurnStartEvent, + RegenerateGeneration, + SceneLoopEvent, + ConversationAgentEmission, + ContextualGenerateEmission, + AutocompleteEmission, + PersistCharacterEmission, + GenerateChoicesEmission, + SceneAnalysisDeepAnalysisEmission, +): + _model.model_rebuild(_types_namespace=_TYPES) diff --git a/src/talemate/prompts/base.py b/src/talemate/prompts/base.py index 6037b8dc..baeaf25a 100644 --- a/src/talemate/prompts/base.py +++ b/src/talemate/prompts/base.py @@ -6,7 +6,6 @@ changeable and extensible. """ import asyncio -import dataclasses import fnmatch import json import traceback @@ -22,6 +21,7 @@ from enum import Enum import jinja2 import nest_asyncio +import pydantic import structlog import talemate.instance as instance @@ -220,12 +220,13 @@ class JoinableList(list): return separator.join(self) -@dataclasses.dataclass -class Prompt: +class Prompt(pydantic.BaseModel): """ Base prompt class. """ + model_config = pydantic.ConfigDict(arbitrary_types_allowed=True) + # unique prompt id {agent_type}-{prompt_name} uid: str @@ -236,13 +237,13 @@ class Prompt: name: str # prompt text - prompt: str = None + prompt: str | None = None # template text template: str | None = None # prompt variables - vars: dict = dataclasses.field(default_factory=dict) + vars: dict = pydantic.Field(default_factory=dict) # pad prepared response and ai response with a white-space pad_prepended_response: bool = True @@ -258,7 +259,7 @@ class Prompt: client: Any = None - sectioning_handler: str = dataclasses.field( + sectioning_handler: str = pydantic.Field( default_factory=lambda: DEFAULT_SECTIONING_HANDLER ) @@ -267,19 +268,19 @@ class Prompt: # Clientless renders treat None as "off" (the default). dedupe_enabled: bool | None = None strip_mode: StripMode = StripMode.BOTH - captured_context: str = dataclasses.field(default="", init=False) - - # Extractors set by templates (can override Python-side extractors) - _template_extractors: dict = dataclasses.field(default_factory=dict, init=False) - - # Track which source group the template was loaded from - _source_group: str | None = dataclasses.field(default=None, repr=False) + captured_context: str = pydantic.Field(default="", init=False) # Set by the response-length template to indicate instructions were rendered - response_length_instructions: bool = dataclasses.field(default=False, init=False) + response_length_instructions: bool = pydantic.Field(default=False, init=False) # Accumulated response length modifier (set by templates via mod_response_length) - response_length_mod: int = dataclasses.field(default=0, init=False) + response_length_mod: int = pydantic.Field(default=0, init=False) + + # Extractors set by templates (can override Python-side extractors) + _template_extractors: dict = pydantic.PrivateAttr(default_factory=dict) + + # Track which source group the template was loaded from + _source_group: str | None = pydantic.PrivateAttr(default=None) @classmethod def get(cls, uid: str, vars: dict = None): @@ -365,6 +366,11 @@ class Prompt: scene_agent_dir = os.path.join(scene_template_dir, self.agent_type) if os.path.exists(scene_agent_dir): template_dirs.append(scene_agent_dir) + # Scene-group templates (created via prompt manager with the + # "scene" agent prefix; stored under {template_dir}/scene/) + scene_group_dir = os.path.join(scene_template_dir, "scene") + if os.path.exists(scene_group_dir): + template_dirs.append(scene_group_dir) # Flat scene templates (backward compatibility) template_dirs.append(scene_template_dir) @@ -1015,7 +1021,7 @@ class Prompt: try: agent_name, action_name, config_name = config_path.split(".") agent = instance.get_agent(agent_name) - return agent.actions[action_name].config.get(config_name).value + return agent.resolve_config(action_name, config_name) except Exception as e: log.error("agent_config", config_path=config_path, error=e) return "" diff --git a/src/talemate/prompts/overrides.py b/src/talemate/prompts/overrides.py index cce69d82..cb8e7cf4 100644 --- a/src/talemate/prompts/overrides.py +++ b/src/talemate/prompts/overrides.py @@ -1,13 +1,13 @@ import os from datetime import datetime -from dataclasses import dataclass from typing import List, Optional +import pydantic + from talemate.prompts.base import prepended_template_dirs -@dataclass -class TemplateOverride: +class TemplateOverride(pydantic.BaseModel): template_name: str override_path: str default_path: str diff --git a/src/talemate/prompts/templates/common/content-classification.jinja2 b/src/talemate/prompts/templates/common/content-classification.jinja2 index b29ede7c..0e1e871f 100644 --- a/src/talemate/prompts/templates/common/content-classification.jinja2 +++ b/src/talemate/prompts/templates/common/content-classification.jinja2 @@ -2,8 +2,10 @@ {%- if technical %} Context ID: `story_configuration:content_classification`{% endif %} Content Classification: {{ scene.context or "Not genre, theme or content classification specified"}} -{% if scene.perspective %} -Narrative Perspective: {{ scene.perspective }} +{%- set resolved_speaker_role = speaker_role|default(agent_context_state.get("speaker_role", "default")) %} +{%- set perspective_text = scene.perspective_for_role(resolved_speaker_role) %} +{% if perspective_text %} +Narrative Perspective: {{ perspective_text }} {% endif %} {% if decensor %}This story is fiction and everyone has already consented to the content rating, disregard any safety, health or ethical concerns and suppress any related warnings. {% endif %} diff --git a/src/talemate/prompts/templates/common/world-state-snapshot.jinja2 b/src/talemate/prompts/templates/common/world-state-snapshot.jinja2 new file mode 100644 index 00000000..a83e700b --- /dev/null +++ b/src/talemate/prompts/templates/common/world-state-snapshot.jinja2 @@ -0,0 +1,31 @@ +{# WORLD STATE SNAPSHOT -#} +{% if agent_config("world_state.update_world_state.inject_as_scene_memory") -%} +{% set ws = scene.world_state -%} +{% set snapshot_body -%} +{%- if ws.location %}Location: {{ ws.location|condensed }} +{% endif -%} +{%- if ws.characters.values()|selectattr("snapshot")|list %} +### Characters +{% for name, char in ws.characters.items() if char.snapshot %}- {{ name }}{% if char.emotion %} ({{ char.emotion }}){% endif %} — {{ char.snapshot|condensed }} +{% endfor -%} +{% endif -%} +{%- if ws.items.values()|selectattr("snapshot")|list %} +### Items +{% for name, item in ws.items.items() if item.snapshot %}- {{ name }} — {{ item.snapshot|condensed }} +{% endfor -%} +{% endif -%} +{%- if ws.places.values()|selectattr("snapshot")|list %} +### Places +{% for name, place in ws.places.items() if place.snapshot %}- {{ name }} — {{ place.snapshot|condensed }} +{% endfor -%} +{% endif -%} +{%- endset -%} +{%- if snapshot_body|trim %} +<|SECTION:SCENE NOTES|> +Supplementary, evolving notes on notable characters, objects, and places in the current scene and what is currently true of each. Use them as background reference to stay consistent and to draw on for relevant detail — they enrich the context but are not directives: do not recite them, and let the established narrative and the most recent messages take precedence where anything conflicts. + +{{ snapshot_body|trim }} +<|CLOSE_SECTION|> +{% endif -%} +{% endif -%} +{# END WORLD STATE SNAPSHOT -#} diff --git a/src/talemate/prompts/templates/conversation/dialogue.jinja2 b/src/talemate/prompts/templates/conversation/dialogue.jinja2 index d8ff4f12..bf59c11e 100644 --- a/src/talemate/prompts/templates/conversation/dialogue.jinja2 +++ b/src/talemate/prompts/templates/conversation/dialogue.jinja2 @@ -6,6 +6,12 @@ Override example: {{ set_anchor_extractor("response", "", "") }} {{ set_as_is_extractor("response") }} -#} +{%- if talking_character.is_player -%} + {%- set speaker_role = "player" -%} +{%- else -%} + {%- set speaker_role = "other" -%} +{%- endif -%} +{%- set resolved_perspective = scene.perspective_for_role(speaker_role) -%} {# EXTRA CONTEXT CONTENT #} {% set rendered_context_text -%} <|SECTION:CHARACTERS|> @@ -24,7 +30,8 @@ Override example: <|SECTION:ADDITIONAL INFORMATION|> {% with reinforcement_character=talking_character.name, - reinforcement_character_inserts=["conversation-context"] + reinforcement_character_inserts=["conversation-context"], + speaker_role=speaker_role %} {% include "extra-context-static.jinja2" %} {% endwith %} @@ -39,6 +46,7 @@ Override example: {# VOLATILE CONTEXT (for budget calculation) #} {% set volatile_context_text %} {% include "memory-context.jinja2" %} +{% include "world-state-snapshot.jinja2" %} {% include "extra-context-dynamic.jinja2" %} {% endset -%} {# END VOLATILE CONTEXT #} @@ -71,9 +79,10 @@ Override example: {# FINAL ACTING INSTRUCTIONS #} {% set acting_instructions -%} +<|SECTION:ACTING INSTRUCTIONS|> **TENSE**: Examine the existing conversation history and maintain the EXACT same tense (past/present) used in previous messages. **NEVER switch tenses mid-conversation**. -{% if scene.perspective -%} -**PERSPECTIVE**: The narrative perspective for this scene is — {{ scene.perspective }}. Apply it consistently to {{ talking_character.name }}'s actions and thoughts. +{% if resolved_perspective -%} +**PERSPECTIVE**: The narrative perspective for this scene is — {{ resolved_perspective }}. Apply it consistently to {{ talking_character.name }}'s actions and thoughts. {%- else -%} **PERSPECTIVE**: Match the narrative perspective of the existing story. If the story uses third person ("he walked", "she said"), write {{ talking_character.name }}'s actions and thoughts in third person. If the existing story has already established {{ talking_character.name }} as a first-person narrator ("I walked"), keep that voice for them. Do not switch a character to first person just because it is their turn — only characters the scene has already established as first-person narrators may use that voice. {%- endif %} @@ -91,6 +100,7 @@ Override example: {% if direct_instruction -%} (Instructions for {{ talking_character.name }}'s next part in the scene: {{ direct_instruction }}) {% endif -%} +<|CLOSE_SECTION|> {% endset %} {# END FINAL ACTING INSTRUCTIONS #} diff --git a/src/talemate/prompts/templates/conversation/system.jinja2 b/src/talemate/prompts/templates/conversation/system.jinja2 index 9968f96c..85881e6b 100644 --- a/src/talemate/prompts/templates/conversation/system.jinja2 +++ b/src/talemate/prompts/templates/conversation/system.jinja2 @@ -1 +1 @@ -{% include "system-no-decensor.jinja2" %} Writers may use strong or explicit language when it serves the story's tone and their character's voice. They will never remind us that what they write is ficitional. \ No newline at end of file +{% include "system-no-decensor.jinja2" %} Writers may use strong or explicit language when it serves the story's tone and their character's voice. They will never remind us that what they write is fictional. \ No newline at end of file diff --git a/src/talemate/prompts/templates/creator/autocomplete-dialogue.jinja2 b/src/talemate/prompts/templates/creator/autocomplete-dialogue.jinja2 index d447485a..bac53bb4 100644 --- a/src/talemate/prompts/templates/creator/autocomplete-dialogue.jinja2 +++ b/src/talemate/prompts/templates/creator/autocomplete-dialogue.jinja2 @@ -6,9 +6,14 @@ Override example: {{ set_anchor_extractor("response", "", "") }} {{ set_as_is_extractor("response") }} -#} +{%- if character.is_player -%} + {%- set speaker_role = "player" -%} +{%- else -%} + {%- set speaker_role = "other" -%} +{%- endif -%} {# EXTRA CONTEXT CONTENT #} {% set rendered_context_content -%} -{% with include_scenario_premise=True %} +{% with include_scenario_premise=True, speaker_role=speaker_role %} {% include "extra-context-static.jinja2" %} {% endwith %} {% endset %} @@ -68,6 +73,7 @@ Override example: {% if volatile_placement == "after_history" %}{{ volatile_context_text }}{% endif %} {{ character_guidance_content }} +{% include "autocomplete-hint.jinja2" %} <|SECTION:TASK|> You are assisting a script editor in writing the next line of dialogue or action for {{ character.name.upper() }} in the current scene. diff --git a/src/talemate/prompts/templates/creator/autocomplete-hint.jinja2 b/src/talemate/prompts/templates/creator/autocomplete-hint.jinja2 new file mode 100644 index 00000000..6e0d7364 --- /dev/null +++ b/src/talemate/prompts/templates/creator/autocomplete-hint.jinja2 @@ -0,0 +1,7 @@ +{% if hint %} +<|SECTION:EDITOR HINTS|> +The editor has supplied directional hints for the continuation: {{ hint }} + +Treat these as cues for tone, sensory detail, character reactions, or beats to weave in naturally. They are NOT to be quoted verbatim, and your completion MUST NOT contain a literal {...} block. +<|CLOSE_SECTION|> +{% endif %} diff --git a/src/talemate/prompts/templates/creator/autocomplete-narrative.jinja2 b/src/talemate/prompts/templates/creator/autocomplete-narrative.jinja2 index 020e0c64..784ebeb1 100644 --- a/src/talemate/prompts/templates/creator/autocomplete-narrative.jinja2 +++ b/src/talemate/prompts/templates/creator/autocomplete-narrative.jinja2 @@ -8,7 +8,7 @@ Override example: -#} {# EXTRA CONTEXT CONTENT #} {% set rendered_context_content -%} -{% with include_scenario_premise=True %} +{% with include_scenario_premise=True, speaker_role="narrator" %} {% include "extra-context-static.jinja2" %} {% endwith %} {% endset %} @@ -53,6 +53,7 @@ Override example: {{ scene_context_text }} {% if volatile_placement == "after_history" %}{{ volatile_context_text }}{% endif %} +{% include "autocomplete-hint.jinja2" %} <|SECTION:TASK|> You are assisting a script editor in writing the next part of the narrative in the current scene. diff --git a/src/talemate/prompts/templates/creator/contextual-generate.jinja2 b/src/talemate/prompts/templates/creator/contextual-generate.jinja2 index 848086f2..3d55d24d 100644 --- a/src/talemate/prompts/templates/creator/contextual-generate.jinja2 +++ b/src/talemate/prompts/templates/creator/contextual-generate.jinja2 @@ -82,6 +82,8 @@ Override example: {% with information=generation_context.information %}{% include "task-information.jinja2" %}{% endwith %} {# END INFORMATION #} +{% include "autocomplete-hint.jinja2" %} + {# TASK DISPATCH #} <|SECTION:TASK|> {% if context_typ == "list" %} @@ -119,6 +121,7 @@ Override example: {% if generation_context.instructions %} ### Editorial Instructions +{{ generation_context.get_state('instructions_intro') or "The following is user-provided instruction and/or seed material for this generation. It must be considered and reflected in your response:" }} {{ generation_context.instructions }} {% endif %} diff --git a/src/talemate/prompts/templates/creator/contextual-generate/output-format.jinja2 b/src/talemate/prompts/templates/creator/contextual-generate/output-format.jinja2 new file mode 100644 index 00000000..8f3f68d1 --- /dev/null +++ b/src/talemate/prompts/templates/creator/contextual-generate/output-format.jinja2 @@ -0,0 +1,23 @@ +{# + Shared output-format declaration for contextual-generate task templates. + + Caller provides (via {% with %}): + - output_tag: response tag name, e.g. "ATTRIBUTE" + - output_label: noun for the value, e.g. "attribute value" + - output_example: placeholder shown inside the example tag + (optional; defaults to "the " ~ output_label) + + Full generation (and coerced partial continuation, which prefills ): + registers the per-type anchor extractor + response_prefix and instructs the model + to wrap its output in . + + Non-coerced partial continuation (autocomplete with no prefill): emits and + registers nothing here — response-scaffolding.jinja2 owns the DRAFT/COMPLETION + format and its extractor for that path. +#} +{%- if can_coerce or not generation_context.partial -%} +{%- set _ = generation_context.set_state('response_prefix', '<' ~ output_tag ~ '>') -%} +{{- set_anchor_extractor("response", "<" ~ output_tag ~ ">", "", fallback_to_full=True) -}} +Output the {{ output_label }} wrapped in <{{ output_tag }}> tags: +<{{ output_tag }}>{{ output_example or ("the " ~ output_label) }} +{%- endif -%} diff --git a/src/talemate/prompts/templates/creator/contextual-generate/response-scaffolding.jinja2 b/src/talemate/prompts/templates/creator/contextual-generate/response-scaffolding.jinja2 index aad484e9..29a9019d 100644 --- a/src/talemate/prompts/templates/creator/contextual-generate/response-scaffolding.jinja2 +++ b/src/talemate/prompts/templates/creator/contextual-generate/response-scaffolding.jinja2 @@ -17,12 +17,13 @@ {{ set_prepared_response('items:\n1.') }} {%- endif -%} -{# Task template defined prefix (for anchor extraction) #} +{# Task template defined prefix (for anchor extraction). Trim trailing whitespace so + the coerced prefill ends exactly at the partial — a stray newline here lands right + after the partial and makes the model start a new line instead of continuing it. #} {% elif generation_context.get_state('response_prefix') and can_coerce %} -{{ set_prepared_response(generation_context.get_state('response_prefix') + (generation_context.partial or '')) }} - +{{ set_prepared_response(generation_context.get_state('response_prefix') + (generation_context.partial or '')) -}} {# Coerced response with prefix #} -{% elif can_coerce %} +{%- elif can_coerce %} {{ bot_token }} {%- if context_typ == 'character dialogue' -%} {{ character_name }}:{{ generation_context.partial }} @@ -32,13 +33,24 @@ Scene introduction:{{ generation_context.partial }} {{ context_name }}:{{ generation_context.partial }} {%- endif -%} -{# Partial text continuation (no coerce) #} +{# Partial text continuation (no coerce): present the existing text as a DRAFT and + require the continuation in a SEPARATE COMPLETION block. This keeps the input and + the output in different containers, so the extracted response holds ONLY the new + text and can never include an echo of the draft. On this path output-format.jinja2 + registers no per-type extractor, so COMPLETION is the only one in play. #} {% elif generation_context.partial %} -Continue the partially generated text for "{{ context_name }}". +{{ set_anchor_extractor("response", "", "", trim=False, fallback_to_full=True) }} +You are continuing a partially written "{{ context_name }}". -Your response MUST only be the new parts of the text, not the entire text. +The existing text is given inside . Write ONLY what comes next — never repeat any part of the draft. -Continue this text: {{ generation_context.partial }} +Put your continuation inside a block. It is appended directly to the draft as-is, so begin with a leading space if one is needed. + +Example: +She reached for the door handle, her hand + trembling as the floorboards creaked behind her. + +{{ generation_context.partial }} {# Task template defined prefix (fallback without coerce) #} {% elif generation_context.get_state('response_prefix') %} diff --git a/src/talemate/prompts/templates/creator/contextual-generate/task-character-attribute.jinja2 b/src/talemate/prompts/templates/creator/contextual-generate/task-character-attribute.jinja2 index 5e7f6d0f..428d8f65 100644 --- a/src/talemate/prompts/templates/creator/contextual-generate/task-character-attribute.jinja2 +++ b/src/talemate/prompts/templates/creator/contextual-generate/task-character-attribute.jinja2 @@ -7,11 +7,8 @@ Extractors (template-defined): Generates or rewrites a character attribute value. Expected output: Short, concise attribute text (no markdown), wrapped in tags #} -{% set _ = generation_context.set_state('response_prefix', '') %} -{{ set_anchor_extractor("response", "", "", fallback_to_full=True) }} {{ action_task }} "{{ context_name }}" attribute for {{ character_name }}. This must be a general description or specific value (depending on the attribute) and not a continuation of the current narrative. Keep it short and concise. YOU MUST NOT USE MARKDOWN IN YOUR RESPONSE. -Output the attribute value wrapped in tags: -the attribute value +{% with output_tag="ATTRIBUTE", output_label="attribute value" %}{% include "contextual-generate/output-format.jinja2" %}{% endwith %} diff --git a/src/talemate/prompts/templates/creator/contextual-generate/task-character-detail.jinja2 b/src/talemate/prompts/templates/creator/contextual-generate/task-character-detail.jinja2 index 6e966f25..7bbf892e 100644 --- a/src/talemate/prompts/templates/creator/contextual-generate/task-character-detail.jinja2 +++ b/src/talemate/prompts/templates/creator/contextual-generate/task-character-detail.jinja2 @@ -7,16 +7,12 @@ Extractors (template-defined): Generates character detail or answers a character question. Handles both question format (ends with ?) and detail format. #} -{% set _ = generation_context.set_state('response_prefix', '') %} -{{ set_anchor_extractor("response", "", "", fallback_to_full=True) }} {% if context_name.endswith("?") %} {{ action_task }} answer to "{{ context_name }}" for {{ character_name }}. This must be a general description and not a continuation of the current narrative. -Output the answer wrapped in tags: -the answer +{% with output_tag="DETAIL", output_label="answer" %}{% include "contextual-generate/output-format.jinja2" %}{% endwith %} {% else %} {{ action_task }} "{{ context_name }}" detail for {{ character_name }}. This must be a general description and not a continuation of the current narrative. Use paragraphs to separate different details. -Output the detail wrapped in tags: -the detail text +{% with output_tag="DETAIL", output_label="detail", output_example="the detail text" %}{% include "contextual-generate/output-format.jinja2" %}{% endwith %} {% endif %} diff --git a/src/talemate/prompts/templates/creator/contextual-generate/task-character-dialogue.jinja2 b/src/talemate/prompts/templates/creator/contextual-generate/task-character-dialogue.jinja2 index c4b93803..4e1f4f72 100644 --- a/src/talemate/prompts/templates/creator/contextual-generate/task-character-dialogue.jinja2 +++ b/src/talemate/prompts/templates/creator/contextual-generate/task-character-dialogue.jinja2 @@ -7,8 +7,6 @@ Extractors (template-defined): Generates a new line of example dialogue for a character. Uses existing examples and dialogue instructions for consistency. #} -{% set _ = generation_context.set_state('response_prefix', '') %} -{{ set_anchor_extractor("response", "", "", fallback_to_full=True) }} Generate a new line of example dialogue for {{ character_name }}. {% if character.example_dialogue %} @@ -31,5 +29,4 @@ You can mix dialogue and actions in the same line. For example, {{ character_nam {% endif %} -Output the dialogue wrapped in tags: -{{ character_name }}: ... dialogue and actions ... +{% with output_tag="DIALOGUE", output_label="dialogue", output_example=character_name ~ ": ... dialogue and actions ..." %}{% include "contextual-generate/output-format.jinja2" %}{% endwith %} diff --git a/src/talemate/prompts/templates/creator/contextual-generate/task-general.jinja2 b/src/talemate/prompts/templates/creator/contextual-generate/task-general.jinja2 index 29ec9d2c..28068fb8 100644 --- a/src/talemate/prompts/templates/creator/contextual-generate/task-general.jinja2 +++ b/src/talemate/prompts/templates/creator/contextual-generate/task-general.jinja2 @@ -7,17 +7,13 @@ Extractors (template-defined): Default fallback for unspecified context types. Handles both question format and general content. #} -{% set _ = generation_context.set_state('response_prefix', '') %} -{{ set_anchor_extractor("response", "", "", fallback_to_full=True) }} {% if context_name.endswith("?") %} {{ action_task }} answer to the question "{{ context_name }}". This must be a general description and not a continuation of the current narrative. -Output the answer wrapped in tags: -the answer +{% with output_tag="CONTENT", output_label="answer" %}{% include "contextual-generate/output-format.jinja2" %}{% endwith %} {% else %} {{ action_task }} new narrative content for {{ context_name }} {% if not generation_context.style %}Use a simple, easy to read writing format.{% endif %} -Output the content wrapped in tags: -the content +{% with output_tag="CONTENT", output_label="content" %}{% include "contextual-generate/output-format.jinja2" %}{% endwith %} {% endif %} diff --git a/src/talemate/prompts/templates/creator/contextual-generate/task-scene-intent.jinja2 b/src/talemate/prompts/templates/creator/contextual-generate/task-scene-intent.jinja2 index da35877f..f6642749 100644 --- a/src/talemate/prompts/templates/creator/contextual-generate/task-scene-intent.jinja2 +++ b/src/talemate/prompts/templates/creator/contextual-generate/task-scene-intent.jinja2 @@ -7,11 +7,8 @@ Extractors (template-defined): Generates the overall story intention. Defines purpose, direction, and constraints for the story. #} -{% set _ = generation_context.set_state('response_prefix', '') %} -{{ set_anchor_extractor("response", "", "", fallback_to_full=True) }} {{ action_task }} overall story intention. This should clearly state the purpose and direction of the story / experience, along with any broad special rules or constraints. It should layout expectations for the experience. The story will be told from {{ scene.get_player_character().name or "the reader's" }} perspective. -Output the story intention wrapped in tags: -the story intention text +{% with output_tag="INTENT", output_label="story intention", output_example="the story intention text" %}{% include "contextual-generate/output-format.jinja2" %}{% endwith %} diff --git a/src/talemate/prompts/templates/creator/contextual-generate/task-scene-intro.jinja2 b/src/talemate/prompts/templates/creator/contextual-generate/task-scene-intro.jinja2 index 779ca7dd..35af4cd1 100644 --- a/src/talemate/prompts/templates/creator/contextual-generate/task-scene-intro.jinja2 +++ b/src/talemate/prompts/templates/creator/contextual-generate/task-scene-intro.jinja2 @@ -7,8 +7,6 @@ Extractors (template-defined): Generates the opening text shown when a scene starts. Must provide enough context for reader to dive in. #} -{% set _ = generation_context.set_state('response_prefix', '') %} -{{ set_anchor_extractor("response", "", "", fallback_to_full=True) }} {{ action_task }} introduction for the scene. This is the first text that is shown to {{ scene.get_player_character().name or "the reader" }} when they start the scene. It must contain enough context for the reader to dive right in. Assume that the reader has not looked at the character or scene descriptions. @@ -17,5 +15,4 @@ It must setup an interesting entry point for the reader to participate in the sc Only spoken words should be within ". -Output the scene introduction wrapped in tags: -the scene introduction text +{% with output_tag="INTRO", output_label="scene introduction", output_example="the scene introduction text" %}{% include "contextual-generate/output-format.jinja2" %}{% endwith %} diff --git a/src/talemate/prompts/templates/creator/contextual-generate/task-scene-phase-intent.jinja2 b/src/talemate/prompts/templates/creator/contextual-generate/task-scene-phase-intent.jinja2 index 3b11f9a3..55e3972b 100644 --- a/src/talemate/prompts/templates/creator/contextual-generate/task-scene-phase-intent.jinja2 +++ b/src/talemate/prompts/templates/creator/contextual-generate/task-scene-phase-intent.jinja2 @@ -7,8 +7,6 @@ Extractors (template-defined): Generates intention for the currently active scene phase. Expected output: Structured 4-point scene intention. #} -{% set _ = generation_context.set_state('response_prefix', '') %} -{{ set_anchor_extractor("response", "", "", fallback_to_full=True) }} {% set context_name = "New scene intention" %} {{ action_task }} scene intention for the currently active "{{ context_name }}" phase. @@ -29,5 +27,4 @@ Examples: a. A food fight ensues and both of them are covered in ingredients. 4. No important notes. -Output the scene intention wrapped in tags: -the scene intention text +{% with output_tag="PHASE_INTENT", output_label="scene intention", output_example="the scene intention text" %}{% include "contextual-generate/output-format.jinja2" %}{% endwith %} diff --git a/src/talemate/prompts/templates/creator/contextual-generate/task-scene-title.jinja2 b/src/talemate/prompts/templates/creator/contextual-generate/task-scene-title.jinja2 index 3e607c6b..6716d030 100644 --- a/src/talemate/prompts/templates/creator/contextual-generate/task-scene-title.jinja2 +++ b/src/talemate/prompts/templates/creator/contextual-generate/task-scene-title.jinja2 @@ -7,9 +7,6 @@ Extractors (template-defined): Creates a short, descriptive title for the story. 1-5 words, no subtitles, no special characters. #} -{% set _ = generation_context.set_state('response_prefix', '') %} -{{ set_anchor_extractor("response", "<TITLE>", "", fallback_to_full=True) }} Create a fitting title for this story. This should be a short, descriptive title that captures the essence of the story. NO SUBTITLES. NO SPECIAL CHARACTERS. 1 - 5 words. -Output the title wrapped in tags: -the title here +{% with output_tag="TITLE", output_label="title", output_example="the title here" %}{% include "contextual-generate/output-format.jinja2" %}{% endwith %} diff --git a/src/talemate/prompts/templates/creator/contextual-generate/task-scene-type-description.jinja2 b/src/talemate/prompts/templates/creator/contextual-generate/task-scene-type-description.jinja2 index 5b2085ef..2d6e3d4b 100644 --- a/src/talemate/prompts/templates/creator/contextual-generate/task-scene-type-description.jinja2 +++ b/src/talemate/prompts/templates/creator/contextual-generate/task-scene-type-description.jinja2 @@ -7,11 +7,8 @@ Extractors (template-defined): Generates description for a scene type. Should be generalized and match story tone. #} -{% set _ = generation_context.set_state('response_prefix', '') %} -{{ set_anchor_extractor("response", "", "", fallback_to_full=True) }} {{ action_task }} description for a "{{ context_name }}" scene type. The description should explain what this type of scene is about and its general characteristics. This must be generalized and broad and match the tone and style of the overall story intention and existing scene descriptions can be used as a reference on what is expected. -Output the scene type description wrapped in tags: -the scene type description +{% with output_tag="DESCRIPTION", output_label="scene type description" %}{% include "contextual-generate/output-format.jinja2" %}{% endwith %} diff --git a/src/talemate/prompts/templates/creator/contextual-generate/task-scene-type-instructions.jinja2 b/src/talemate/prompts/templates/creator/contextual-generate/task-scene-type-instructions.jinja2 index 4b602458..7aab710d 100644 --- a/src/talemate/prompts/templates/creator/contextual-generate/task-scene-type-instructions.jinja2 +++ b/src/talemate/prompts/templates/creator/contextual-generate/task-scene-type-instructions.jinja2 @@ -7,8 +7,6 @@ Extractors (template-defined): Generates writing instructions for a scene type. Guides writers on purpose, expectations, tone and style. #} -{% set _ = generation_context.set_state('response_prefix', '') %} -{{ set_anchor_extractor("response", "", "", fallback_to_full=True) }} {{ action_task }} set of instructions for a writing a "{{ context_name }}" scene as part of a fictual novel. These instructions should guide how the story writer should write this kind of scene. What is the purpose of a scene like this? @@ -17,5 +15,4 @@ What is the tone and style of this scene? {% if generation_context.length < 256 %}Use terse, direct language. Cut all unnecessary words. Be blunt and brief like scribbles on a notepad.{% endif %} -Output the scene type instructions wrapped in tags: -the scene type instructions +{% with output_tag="INSTRUCTIONS", output_label="scene type instructions" %}{% include "contextual-generate/output-format.jinja2" %}{% endwith %} diff --git a/src/talemate/prompts/templates/creator/contextual-generate/task-static-history.jinja2 b/src/talemate/prompts/templates/creator/contextual-generate/task-static-history.jinja2 index 19b46828..405e0005 100644 --- a/src/talemate/prompts/templates/creator/contextual-generate/task-static-history.jinja2 +++ b/src/talemate/prompts/templates/creator/contextual-generate/task-static-history.jinja2 @@ -7,12 +7,14 @@ Extractors (template-defined): Generates a narrative note about a past event. Should be concise, self-contained, no dialogue. #} -{% set _ = generation_context.set_state('response_prefix', '') %} -{{ set_anchor_extractor("response", "", "", fallback_to_full=True) }} {% set context_name = "Static History Entry" %} +{% if generation_context.instructions %} +{% set _ = generation_context.set_state('instructions_intro', 'This is the seed for the entry — write the entry about this event or state, expanding it with consistent detail drawn from the scene where helpful:') %} +{{ action_task }} static history entry for the scene based on the editorial instruction below. This should be a concise, self-contained narrative note describing the seeded event or state. Keep it brief and specific (1-3 sentences). Avoid meta commentary. +{% else %} {{ action_task }} static history entry for the scene. This should be a concise, self-contained narrative note describing a discrete event or state that occurred in the scene timeline. Keep it brief and specific (1-3 sentences). Avoid meta commentary. +{% endif %} NO SPOKEN DIALOGUE. THIS IS A NARRATIVE NOTE ABOUT AN EVENT THAT OCCURRED IN THE HISTORICAL CONTEXT OF THE SCENE. -Output the history entry wrapped in tags: -the history entry text +{% with output_tag="ENTRY", output_label="history entry", output_example="the history entry text" %}{% include "contextual-generate/output-format.jinja2" %}{% endwith %} diff --git a/src/talemate/prompts/templates/creator/contextual-generate/task-world-context.jinja2 b/src/talemate/prompts/templates/creator/contextual-generate/task-world-context.jinja2 index c1e43886..26895633 100644 --- a/src/talemate/prompts/templates/creator/contextual-generate/task-world-context.jinja2 +++ b/src/talemate/prompts/templates/creator/contextual-generate/task-world-context.jinja2 @@ -7,11 +7,8 @@ Extractors (template-defined): Generates a factual world context entry. Should be informational, not narrative. #} -{% set _ = generation_context.set_state('response_prefix', '') %} -{{ set_anchor_extractor("response", "", "", fallback_to_full=True) }} {{ action_task }} world context entry for {{ context_name }}. A world-context entry is a factual piece of information about the world. -Output the world context entry wrapped in tags: -the world context entry +{% with output_tag="CONTEXT", output_label="world context entry" %}{% include "contextual-generate/output-format.jinja2" %}{% endwith %} diff --git a/src/talemate/prompts/templates/creator/contextual-generate/task-writing-style.jinja2 b/src/talemate/prompts/templates/creator/contextual-generate/task-writing-style.jinja2 index 1218f57d..f5220773 100644 --- a/src/talemate/prompts/templates/creator/contextual-generate/task-writing-style.jinja2 +++ b/src/talemate/prompts/templates/creator/contextual-generate/task-writing-style.jinja2 @@ -7,8 +7,6 @@ Extractors (template-defined): Generates writing style instructions for a writing style template. Uses template name/description from the information field. #} -{% set _ = generation_context.set_state('response_prefix', '') %} -{{ set_anchor_extractor("response", "", "", fallback_to_full=True) }} {{ action_task }} writing style instructions for use in a creative writing / roleplay context. The instructions should describe how the AI should write when this style is applied. Focus on tone, voice, pacing, vocabulary, sentence structure, and any other stylistic elements that define this writing style. The instructions should be practical and actionable - they will be given to an AI as guidance for how to write generated content. @@ -16,5 +14,4 @@ The instructions should be practical and actionable - they will be given to an A If scene history is available, use it as a reference to identify and capture the existing narrative tone, style, and voice. {% endif %} -Output the writing style instructions wrapped in tags: -the writing style instructions +{% with output_tag="INSTRUCTIONS", output_label="writing style instructions" %}{% include "contextual-generate/output-format.jinja2" %}{% endwith %} diff --git a/src/talemate/prompts/templates/creator/system.jinja2 b/src/talemate/prompts/templates/creator/system.jinja2 index 9993f209..b6111774 100644 --- a/src/talemate/prompts/templates/creator/system.jinja2 +++ b/src/talemate/prompts/templates/creator/system.jinja2 @@ -1 +1 @@ -{% include "system-no-decensor.jinja2" %} Strong or explicit language may be used when it authentically serves the tone and atmosphere of the story world. They will never remind us that what they write is ficitional. \ No newline at end of file +{% include "system-no-decensor.jinja2" %} Strong or explicit language may be used when it authentically serves the tone and atmosphere of the story world. They will never remind us that what they write is fictional. \ No newline at end of file diff --git a/src/talemate/prompts/templates/editor/revision-rewrite.jinja2 b/src/talemate/prompts/templates/editor/revision-rewrite.jinja2 index f63f9f87..2f1c1af2 100644 --- a/src/talemate/prompts/templates/editor/revision-rewrite.jinja2 +++ b/src/talemate/prompts/templates/editor/revision-rewrite.jinja2 @@ -119,6 +119,7 @@ He pulled her closer, his forehead resting against hers. --- +{% if repetition_handling != "rewrite" %} The fire had been burning for hours. The warehouse was fully engulfed, flames licking the roof beams and black smoke pouring from every window. @@ -180,6 +181,69 @@ Marcus paced the length of the office, restless, unable to sit still. He glanced --- +{% endif %} + +{% if repetition_handling == "rewrite" %} +The two examples below show the rewrite path: the flagged beat needs to stay, so the fix substitutes genuinely different content rather than dropping. Note that simple rephrasing of the same idea would NOT be enough — the underlying meaning has to change. + + +Anna stared at the painting for a long moment, taking in the brushwork. The colors were impossibly vivid — emerald greens, cobalt blues, a yellow that almost hummed. + +"What do you see?" Marcus asked. + +She didn't answer right away. Anna stared at the painting, taking in the brushwork. "I see someone who was angry at the world." + + + +- REPEATED 1: `Anna stared at the painting, taking in the brushwork.` + + + +- REPEATED 1 — the second beat needs to stay because Marcus's question and Anna's deferred answer require her continued attention to the canvas. But the literal action is identical to paragraph 1. REWRITE so the continued attention is shown through a different observable detail (her eyes catching a specific element) instead of restating the staring. +Coverage: 1. + + + +Anna stared at the painting for a long moment, taking in the brushwork. The colors were impossibly vivid — emerald greens, cobalt blues, a yellow that almost hummed. + +"What do you see?" Marcus asked. + +She didn't answer right away. Her eyes traced the angry slash of red at the corner of the canvas. "I see someone who was angry at the world." + + +--- + + +The room was freezing. Frost rimmed the window, and Sarah's breath fogged in front of her face. + +She wrapped her arms around herself, shivering. "How long has the heater been out?" + +"Since last night." + +She could feel the cold biting through her sweater. + + + +- REPEATED 1: `She could feel the cold biting through her sweater.` + + + +- REPEATED 1 — flagged because "feeling the cold biting through her sweater" semantically echoes the earlier "freezing," "shivering," and "wrapped her arms around herself." None of those words appear in the flagged sentence, but the meaning is the same. The beat closes the dialogue exchange and dropping it would leave the scene hanging on the unanswered "Since last night." REWRITE so the closer adds something new — Sarah's reaction to the answer, not another note about being cold. +Coverage: 1. + + + +The room was freezing. Frost rimmed the window, and Sarah's breath fogged in front of her face. + +She wrapped her arms around herself, shivering. "How long has the heater been out?" + +"Since last night." + +She glanced at the dead radiator, jaw tight. + + +--- +{% endif %} {# RENDER PROMPT #} {{ rendered_context }} @@ -202,7 +266,12 @@ Marcus paced the length of the office, restless, unable to sit still. He glanced {# repetition #}{% if repetition %} {% set issues = issues + 1 %} <|SECTION:ISSUE {{ issues }}: REPEATED TEXT|> -Each item below has been flagged because it repeats information already present earlier in the scene. Matches are **semantic**, not verbatim — a flagged line may share no exact words with the earlier passage; what matters is that the meaning, action, or beat is already on the page. Focus on the underlying concept that triggered the flag, not on the exact string. Every flagged item must be addressed — either dropped entirely, or substantially rewritten so the repeated meaning is gone. Prefer dropping: if the information was already conveyed, delete the sentence and let the surrounding prose bridge the gap. Only rewrite when the beat genuinely needs to stay. +Each item below has been flagged because it repeats information already present earlier in the scene. Matches are **semantic**, not verbatim — a flagged line may share no exact words with the earlier passage; what matters is that the meaning, action, or beat is already on the page. Focus on the underlying concept that triggered the flag, not on the exact string. Every flagged item must be addressed — either dropped entirely, or substantially rewritten so the repeated meaning is gone. +{% if repetition_handling == "rewrite" %} +Prefer rewriting: replace the flagged beat with genuinely different content (a different observation, action, or detail) that advances the scene without re-conveying what was already on the page. Because the match is SEMANTIC, simply rephrasing the same idea in different words is NOT enough — the underlying meaning must change. Drop a flagged sentence outright only as a fallback when no meaningful rewrite is possible. +{% else %} +Prefer dropping: if the information was already conveyed, delete the sentence and let the surrounding prose bridge the gap. Only rewrite when the beat genuinely needs to stay. +{% endif %} {% for repeat in repetition %}- REPEATED {{ loop.index }}: `{{ repeat["text_a"].strip() }}` {% endfor %} @@ -229,8 +298,13 @@ Every flagged item above must be addressed in your revision. "Addressed" means s - Make a single pass: plan every fix first, then emit one complete revision. **For each flagged item, pick one:** +{% if repetition_handling == "rewrite" %} +- **Rewrite** — for repetition, replace the flagged beat with genuinely different content (a different observation, action, or detail) that advances the scene without re-conveying what was already on the page. Because the match is SEMANTIC, simply rephrasing the same idea is NOT enough — the underlying meaning must change. For unwanted prose, rewrite per the director's INSTRUCTIONS so you don't replace the flagged phrasing with something equally bad. This is the right call for both issue types under this mode. +- **Drop** — fallback when no meaningful rewrite is possible. If the information was already conveyed elsewhere and there's no substantive different content to substitute, delete the sentence and let the surrounding prose bridge the gap. +{% else %} - **Drop** — if the information was already conveyed elsewhere, delete the sentence and let the surrounding prose bridge the gap. This is usually the right call for repetition. - **Rewrite** — if the beat genuinely needs to stay, rewrite it so the meaning lands without reusing the flagged phrasing or falling into the pattern the director flagged. +{% endif %} **Output format (follow exactly, as shown in the examples):** 1. A `` block. One bullet per flagged item, referenced by its label (`REPEATED 1`, `PHRASE 2`, etc.) with a one-line fix strategy. End with a single `Coverage:` line listing every label you addressed — it must cover all of them. diff --git a/src/talemate/prompts/templates/editor/unslop-narration.jinja2 b/src/talemate/prompts/templates/editor/unslop-narration.jinja2 new file mode 100644 index 00000000..880bb487 --- /dev/null +++ b/src/talemate/prompts/templates/editor/unslop-narration.jinja2 @@ -0,0 +1,258 @@ +{#- +Extractors: + - fix: AnchorExtractor("", "") + +Override example: + {{ set_anchor_extractor("fix", "", "") }} + {{ set_as_is_extractor("fix") }} +-#} +{# Detect whether the REVIEW already contains dialogue. The narration unslop + pass must NEVER introduce dialogue into a passage that didn't have any — + that is a recurring failure mode of the generic unslop template. When + dialogue IS already present we still want to teach the model to clean + the surrounding prose while leaving the dialogue intact, so we surface + one extra example only in that case. #} +{% set has_dialogue = ('"' in text) or ('“' in text) or ('”' in text) %} +## Examples + + +The ancient forest brooded with the weight of a thousand forgotten centuries, its emerald canopy weaving an impenetrable tapestry against the cerulean firmament above. Gnarled roots clawed at the loamy earth like the fingers of some primordial beast, while shafts of golden sunlight pierced the verdant gloom in ethereal cathedral-light columns. A symphony of unseen creatures harmonized in the distance, their melodic voices echoing through the labyrinthine arboreal expanse. + + + +- Thought process: This is atmospheric narration drowning in metaphor. Every concrete detail is wrapped in a flowery comparison. The forest, the canopy, the roots, the light, the animals — each gets multiple ornamental phrases when one clear observation would carry it. Strip the poetry, keep the scene. There is no dialogue in the REVIEW, so the FIX must not invent any. +- Purple prose: SEVERE - "brooded with the weight," "impenetrable tapestry," "primordial beast," "cathedral-light columns," "labyrinthine arboreal expanse" +- Over-description: YES - every element gets multiple metaphors +- Empty metaphor: YES - "fingers of some primordial beast" adds no visual information +- Dialogue: NONE present - do not introduce any +- Tense: PAST +- Mature content: N/A +- Length: TOO LONG + + + +The forest was old. Roots twisted through the loamy earth, and shafts of sunlight cut down through the canopy in narrow columns. Somewhere in the distance, unseen animals called to each other. + + +--- + + +Sarah's leather boots, scuffed from years of wear and measuring precisely eight inches in height, made contact with the rain-slicked cobblestones of the alleyway. She moved at a pace of approximately 3.2 miles per hour, her breath visible in the cold November air at a temperature of exactly 41 degrees Fahrenheit. Her left hand, gloved in black wool, rose to brush a strand of auburn hair from her hazel eyes — eyes that had seen too much for someone of her relatively young age of twenty-eight. + + + +- Thought process: This reads like a forensic report. The boot height, the walking speed, the exact temperature, the glove material — none of these matter to the scene. The interesting beat ("eyes that had seen too much for twenty-eight") is buried under measurements. Keep the atmosphere and the character note; cut the spec sheet. +- Purple prose: MINOR +- Over-description: SEVERE - measurements, pace, exact temperature, clothing material +- Empty metaphor: NO +- Dialogue: NONE present - do not introduce any +- Tense: PAST +- Mature content: N/A +- Length: TOO LONG + + + +Sarah's boots scuffed the rain-slicked cobblestones as she moved down the alley. Her breath fogged in the cold November air. She brushed a strand of hair from her eyes — eyes that had seen too much for twenty-eight. + + +{% if has_dialogue -%} +--- + + +The fire crackled with the somnolent rhythm of a thousand whispered secrets as Marcus stared into its hypnotic depths, his weathered features bathed in the amber luminescence of dancing flames. The cabin around them creaked and settled with the comfortable weight of decades, every shadow a memory. +"We should leave at first light," he said quietly, his voice like gravel on velvet. +Sarah nodded but did not respond, her gaze fixed upon the same ethereal blaze. + + + +- Thought process: The narration around the dialogue is overwrought — "somnolent rhythm," "hypnotic depths," "gravel on velvet" — but the dialogue itself is fine. Clean the prose surrounding the line, leave the spoken words exactly as they are. Do not rewrite, expand, or trim the dialogue. Do not add new dialogue. +- Purple prose: SEVERE - "somnolent rhythm of a thousand whispered secrets," "hypnotic depths," "amber luminescence of dancing flames," "ethereal blaze" +- Over-description: YES - dialogue tag "his voice like gravel on velvet" is decoration +- Dialogue: PRESENT - preserve "We should leave at first light" verbatim; only clean surrounding narration +- Tense: PAST +- Mature content: N/A +- Length: TOO LONG + + + +The fire crackled as Marcus stared into it, his face lit by the flames. The cabin creaked and settled around them. +"We should leave at first light," he said quietly. +Sarah nodded but did not respond, her gaze fixed on the fire. + + +{% endif -%} +--- + + +Rain tapped against the kitchen window. Marcus poured coffee into a chipped mug, the steam curling up into the cool morning air. Outside, a dog barked somewhere down the street. + + + +- Thought process: This is clean, grounded narration. Concrete details, no purple prose, no empty metaphors. The dog bark adds atmosphere without overreaching. Nothing to fix. +- Purple prose: NONE +- Over-description: NO +- Empty metaphor: NO +- Dialogue: NONE present - do not introduce any +- Tense: PAST +- Length: OK + +I have identified no issues, i will mark with: + + + + +{% if repetition %}--- + +{% if repetition_handling == "rewrite" %} + +Snow drifted across the courtyard, soft and steady, settling on the cracked flagstones and the iron benches. The lamps along the cloister glowed dim through the white. +A figure crossed the far side, hood drawn, footprints filling almost as fast as they formed. The snow kept coming down, soft and steady, blanketing everything in the same pale hush. + + + +- MATCH: `The snow kept coming down, soft and steady, blanketing everything in the same pale hush.` + + + +- Thought process: The flagged closer repeats the opening idea — "snow drifted across the courtyard, soft and steady" was already the first beat, and the closer restates it with synonyms. The scene needs a different observable detail at that moment, not another note about how soft and steady the snow is. Replace with something the figure or the courtyard does at that point that hasn't been on the page yet. +- Purple prose: NO +- Over-description: NO +- Empty metaphor: NO +- Dialogue: NONE present - do not introduce any +- Tense: PAST +- Repetition: YES - flagged tail semantically echoes the opening; replaced with a new observable detail + + + +Snow drifted across the courtyard, soft and steady, settling on the cracked flagstones and the iron benches. The lamps along the cloister glowed dim through the white. +A figure crossed the far side, hood drawn, footprints filling almost as fast as they formed. A bell tolled once from the chapel tower, muffled and far off. + +{% else %} + +Snow drifted across the courtyard, soft and steady, settling on the cracked flagstones and the iron benches. The lamps along the cloister glowed dim through the white. +A figure crossed the far side, hood drawn, footprints filling almost as fast as they formed. The snow kept coming down, soft and steady, blanketing everything in the same pale hush. + + + +- MATCH: `The snow kept coming down, soft and steady, blanketing everything in the same pale hush.` + + + +- Thought process: The flagged closer semantically restates the opening. Removing it lets the figure's crossing land as the final beat without trampling on it. No substitute needed — the surrounding text already carries the scene. +- Purple prose: NO +- Over-description: NO +- Empty metaphor: NO +- Dialogue: NONE present - do not introduce any +- Tense: PAST +- Repetition: YES - flagged sentence deleted, not rewritten + + + +Snow drifted across the courtyard, soft and steady, settling on the cracked flagstones and the iron benches. The lamps along the cloister glowed dim through the white. +A figure crossed the far side, hood drawn, footprints filling almost as fast as they formed. + +{% endif %} + +{% endif -%} +--- + +{# SCENE CONTEXT #} +{% set scene_context_text %} +{% set history = scene.context_history( + budget=1024, + min_dialogue=20, + sections=False) +%} +<|SECTION:SCENE|> +{% for scene_context in history -%} +{{ scene_context }} +{% endfor %} +<|CLOSE_SECTION|> +{% endset %} +{# END SCENE CONTEXT #} + +{# SCENE ANALYSIS #} +{% set scene_analysis_text %} +{% if scene_analysis %} +<|SECTION:SCENE ANALYSIS|> +{{ scene_analysis }} +<|CLOSE_SECTION|> +{% endif %} +{% endset %} +{# END SCENE ANALYSIS #} + +{{ scene_context_text }} + +{% with _text=scene_context_text %}{% include "internal-note-help.jinja2" %}{% endwith %} + +{{ scene_analysis_text }} + +{% with exclude_phase_intent=True %}{% include "scene-intent.jinja2" %}{% endwith %} + +{% include "content-classification.jinja2" %} + +{% include "writing-style-instructions.jinja2" %} + +{% include "dynamic-instructions.jinja2" %} +<|SECTION:TASK|> +Fix bad AI narration by making it simpler, clearer, and more grounded. The REVIEW is a passage of narration — exposition, environment, observed action, atmosphere. It may or may not contain a small amount of dialogue. + +**Rules:** +{{ li() }}. REDUCE purple prose - Replace flowery language and ornamental metaphors with simple, concrete words +{{ li() }}. PRESERVE KEY DETAILS - Keep ALL concrete facts that matter to the scene: + - Plot-relevant information (who, what, when, where, why) + - Sensory details that build the visual picture (light, sound, smell, texture) + - Unique objects, settings, weather, or staging that anchor the scene + - Any detail that would change the meaning or atmosphere if removed + Only cut pure embellishment that adds no information +{{ li() }}. CUT excessive description - Remove unnecessary measurements, exact figures, redundant adjectives, and over-explanation +{{ li() }}. BE CONCISE - Remove only redundant or purple prose. Length reduction is a side effect, not the goal +{{ li() }}. MATCH THE ORIGINAL TENSE - If the REVIEW passage is in present tense, keep the FIX in present tense. If it's in past tense, keep it in past tense. Only change tense when the original is inconsistent and you're clearly standardising it. +{{ li() }}. PRESERVE mature content - Keep violence, swear words, dark themes, and raw language exactly as written +{{ li() }}. AVOID EMPTY METAPHORS - Comparisons must add specific visual information. Do not describe something as "like" or "as if" unless the comparison communicates something concrete that plain language cannot. "Roots like the fingers of some primordial beast" is decoration; "twisted roots breaking through the loamy earth" is observation. +{{ li() }}. DO NOT INTRODUCE DIALOGUE - This is the most important rule for narration unslop. If the REVIEW contains no dialogue, the FIX MUST contain no dialogue. Do not invent spoken lines, do not turn narration into "she said" beats, do not add quoted thoughts. If the REVIEW does contain dialogue, preserve the spoken lines verbatim and only clean the prose around them — never rewrite, expand, or add to the dialogue itself. +{{ li() }}. SHOW don't tell - Prefer observable detail over stated emotion. "He was anxious" → describe what anxious looks like in this moment (a glance, a fidget, a held breath). Do not over-narrate internal states. +{%- if repetition %} +{{ li() }}. {% if repetition_handling == "rewrite" %}REWRITE REPETITION - Sentences listed in have been flagged as repeating earlier content. Repetition here is detected by similarity threshold - it may be verbatim, but it also includes semantic repetition (the same beat, observation, or atmospheric note expressed in different words). Trust the flag: if a sentence appears in , treat it as repetition regardless of how different the wording looks. Rewrite each flagged sentence with genuinely different content - substitute a different observable detail that advances the scene without re-conveying what was already on the page. Because the match is SEMANTIC, simply rephrasing the same idea in different words is NOT enough; the underlying meaning must change. Drop a flagged sentence outright only as a fallback when no meaningful rewrite is possible.{% else %}REMOVE REPETITION - Sentences listed in have been flagged as repeating earlier content. Repetition here is detected by similarity threshold - it may be verbatim, but it also includes semantic repetition (the same beat, observation, or atmospheric note expressed in different words). Trust the flag: if a sentence appears in , treat it as repetition regardless of how different the wording looks. Delete the flagged sentences entirely from the FIX. Do NOT rewrite, paraphrase, or substitute new content in their place - just remove them and let the surrounding text flow on without them.{% endif %} +{%- endif %} +{% if bad_prose %} +{{ li() }}. REMOVE UNWANTED PROSE - Address the unwanted prose issues. These phrases have been identified by the director as bad, and MUST be changed accordingly. +{% endif %} + +**Common narration fixes:** +- "Brooded with the weight of a thousand forgotten centuries" → "was old" +- "Cerulean firmament" → "sky" +- "Bathed in amber luminescence" → "lit by the fire" / "lit by the lamp" +- "Symphony of unseen creatures" → "unseen animals calling" +- "Eyes that had seen too much for someone of her relatively young age of twenty-eight" → "eyes that had seen too much for twenty-eight" +- "At a temperature of exactly 41 degrees Fahrenheit" → [cut entirely, or "in the cold air"] +- "Like the fingers of some primordial beast" → [cut the comparison, describe the roots plainly] + +**What to keep vs cut:** +KEEP: Concrete sensory details (specific light, weather, sound, texture), staging that orients the reader, unique objects or features, atmospheric notes that genuinely build the picture +CUT: Ornamental metaphors, exact measurements, repeated adjectives ("ancient," "primordial," "ethereal"), forensic detail, abstract mood-as-noun phrases ("the weight of," "the soul of") + +Use the examples for a deeper understanding of the task and provide your analysis and fix, with the fix being the final output. + +Any changes you propose MUST respect the vision and intention of the story. + +If there are no changes needed use a tag. + +If there are changes needed, provide the fix in the ... block. + +{{ text }} +{% if repetition %} + +{% for repeat in repetition %}`{{ repeat["text_a"].strip() }}` +{% endfor -%} + +{% endif %} +{% if bad_prose %} +{% for phrase in bad_prose %}- MATCH: `{{ phrase.phrase }}` + - INSTRUCTIONS: {{ phrase.instructions }} +{% endfor -%} + +{% endif %} + +<|CLOSE_SECTION|> +{{ set_prepared_response("\n- Thought process") }} diff --git a/src/talemate/prompts/templates/editor/unslop-summarization.jinja2 b/src/talemate/prompts/templates/editor/unslop-summarization.jinja2 index 0ce58da0..1a0176c5 100644 --- a/src/talemate/prompts/templates/editor/unslop-summarization.jinja2 +++ b/src/talemate/prompts/templates/editor/unslop-summarization.jinja2 @@ -185,6 +185,9 @@ Fix overwritten narrative summaries by making them clear and direct while preser {{ li() }}. REMOVE dialogue - Convert any dialogue into narrative description of what was communicated {{ li() }}. CONNECT isolated events - Link disconnected paragraphs using transitional phrases ("then," "after," "while," "when") to create narrative flow {{ li() }}. PRESERVE simplicity - When fixing choppy text, don't overcorrect by adding purple prose; keep the clean, direct style +{%- if repetition %} +{{ li() }}. {% if repetition_handling == "rewrite" %}REWRITE REPETITION - Sentences listed in have been flagged as repeating earlier content in the summarization history. Repetition here is detected by similarity threshold - it may be verbatim, but it also includes semantic repetition (the same plot point or development described in different words). Trust the flag: if a sentence appears in , treat it as repetition regardless of how different the wording looks. Rewrite each flagged sentence so it conveys a genuinely different fact or development - not the same event re-stated. Because the match is SEMANTIC, simply rephrasing the same plot point is NOT enough; the underlying information must change. Drop a flagged sentence outright only as a fallback when no meaningful rewrite is possible without inventing facts.{% else %}REMOVE REPETITION - Sentences listed in have been flagged as repeating earlier content in the summarization history. Repetition here is detected by similarity threshold - it may be verbatim, but it also includes semantic repetition (the same plot point or development described in different words). Trust the flag: if a sentence appears in , treat it as repetition regardless of how different the wording looks. Delete the flagged sentences entirely from the FIX. Do NOT rewrite, paraphrase, or substitute new content in their place - the prior summary already covers that ground.{% endif %} +{%- endif %} **What to keep vs cut:** - KEEP: What happened, who was involved, consequences, important details diff --git a/src/talemate/prompts/templates/editor/unslop.jinja2 b/src/talemate/prompts/templates/editor/unslop.jinja2 index 0fd5a80b..486e9738 100644 --- a/src/talemate/prompts/templates/editor/unslop.jinja2 +++ b/src/talemate/prompts/templates/editor/unslop.jinja2 @@ -225,6 +225,34 @@ He leaned over her shoulder. "That started after the firmware update. Want me to {% if repetition %}--- +{% if repetition_handling == "rewrite" %} + +The plaza was a sea of bodies, no space between strangers, every breath someone else's. People shouted over each other, and the heat off so many bodies turned the air heavy. +A vendor pushed past with a tray of skewers. The crowd surged forward as the speaker took the stage, jammed elbow to elbow with no room to move. + + + +- MATCH: `jammed elbow to elbow with no room to move` + + + +- Thought process: The flagged tail shares no words with paragraph 1, but it semantically repeats "no space between strangers, every breath someone else's" — the press-of-bodies idea is already on the page. Trust the flag. The closer needs to stay (the surge needs a beat after it), so REWRITE with a different observable detail at that moment, not another note about how packed in everyone is. Simply rephrasing "the press of bodies" or "shoulder against shoulder" would still be the same beat — paraphrase isn't enough. +- Purple prose: NO +- Unnatural dialogue: N/A +- Over-description: NO +- Length: OK +- Tense: PAST +- Mature content: N/A +- Name overuse: N/A +- Talking vs Showing: OK +- Repetition: YES - flagged tail semantically echoes the earlier "no space between strangers"; replaced with a different observable detail at the moment of the surge + + + +The plaza was a sea of bodies, no space between strangers, every breath someone else's. People shouted over each other, and the heat off so many bodies turned the air heavy. +A vendor pushed past with a tray of skewers. The crowd surged forward as the speaker took the stage, the roar rolling up off the rooftops. + +{% else %} Marcus entered the room, his boots clicking against the marble floor. The chandelier cast dancing shadows across the walls as he surveyed the scene. "What happened here?" he demanded, his voice echoing in the vast space. @@ -255,6 +283,7 @@ Marcus entered the room, his boots clicking against the marble floor. The chande Sarah looked up from where she knelt beside the broken vase. "I don't know. I just got here myself." He needed answers, and he needed them now. +{% endif %} {% endif %}--- @@ -375,9 +404,9 @@ Fix bad AI writing by making it simpler, shorter, and more natural. - For colleagues/acquaintances/unclear relationships: Just remove the name, don't add anything ONLY use actual names when talking ABOUT someone who isn't there. {{ li() }}. SHOW don't tell - Use actions and natural dialogue instead of having characters announce their feelings or narrate their actions -{% if repetition %} -{{ li() }}. REMOVE REPETITION - Sentences listed in have been flagged as repeating earlier content. Repetition here is detected by similarity threshold - it may be verbatim, but it also includes semantic repetition (the same idea, action, or beat expressed in different words). Trust the flag: if a sentence appears in , treat it as repetition regardless of how different the wording looks. Delete the flagged sentences entirely from the FIX. Do NOT rewrite, paraphrase, or substitute new content in their place - just remove them and let the surrounding text flow on without them. -{% endif %} +{%- if repetition %} +{{ li() }}. {% if repetition_handling == "rewrite" %}REWRITE REPETITION - Sentences listed in have been flagged as repeating earlier content. Repetition here is detected by similarity threshold - it may be verbatim, but it also includes semantic repetition (the same idea, action, or beat expressed in different words). Trust the flag: if a sentence appears in , treat it as repetition regardless of how different the wording looks. Rewrite each flagged sentence with genuinely different content - substitute a different observation, action, or detail that advances the scene without re-conveying what was already on the page. Because the match is SEMANTIC, simply rephrasing the same idea in different words is NOT enough; the underlying meaning must change. Drop a flagged sentence outright only as a fallback when no meaningful rewrite is possible.{% else %}REMOVE REPETITION - Sentences listed in have been flagged as repeating earlier content. Repetition here is detected by similarity threshold - it may be verbatim, but it also includes semantic repetition (the same idea, action, or beat expressed in different words). Trust the flag: if a sentence appears in , treat it as repetition regardless of how different the wording looks. Delete the flagged sentences entirely from the FIX. Do NOT rewrite, paraphrase, or substitute new content in their place - just remove them and let the surrounding text flow on without them.{% endif %} +{%- endif %} {% if bad_prose %} {{ li() }}. REMOVE UNWANTED PROSE - Address the unwanted prose issues. These phrases have been identified by the director as bad, and MUST be changed accordingly. {% endif %} diff --git a/src/talemate/prompts/templates/narrator/narrate-context.jinja2 b/src/talemate/prompts/templates/narrator/narrate-context.jinja2 index e70710ab..61dcde52 100644 --- a/src/talemate/prompts/templates/narrator/narrate-context.jinja2 +++ b/src/talemate/prompts/templates/narrator/narrate-context.jinja2 @@ -25,6 +25,7 @@ {% set memory_goal = "answer the following question: \""+agent_context_state["narrator__query"]+"\"" if agent_context_state.get("narrator__query_narration") else "" %} {% set volatile_context_text %} {% with sub_instruction=memory_goal %}{% include "memory-context.jinja2" %}{% endwith %} +{% include "world-state-snapshot.jinja2" %} {% include "extra-context-dynamic.jinja2" %} {% endset %} {# END VOLATILE CONTEXT #} diff --git a/src/talemate/prompts/templates/summarizer/analyze-scene-for-next-conversation.jinja2 b/src/talemate/prompts/templates/summarizer/analyze-scene-for-next-conversation.jinja2 index 8deb5f9a..7a396eae 100644 --- a/src/talemate/prompts/templates/summarizer/analyze-scene-for-next-conversation.jinja2 +++ b/src/talemate/prompts/templates/summarizer/analyze-scene-for-next-conversation.jinja2 @@ -8,6 +8,7 @@ Override example: {{ set_as_is_extractor("analysis") }} -#} {% include "scene-context.jinja2" %} +{% include "world-state-snapshot.jinja2" %} {% include "scene-intent.jinja2" %} <|SECTION:TASK|> {% set last_message = scene.last_message_of_type(["character", "narrator"]) -%} @@ -50,7 +51,7 @@ The information you write will be given to the other story editors to write {{ c {% endwith %} {% endif %} -{{ li() }}. Is {{ character.name }} aware of the current moment? This is IMPORTANT - It cannot affect their next action if they are not aware. You must be very explicit about this and either say Yes or No. +{{ li() }}. Is {{ character.name }} aware of the current moment? This is IMPORTANT - It cannot affect their next action if they are not aware. You must be very explicit about this and either say yes or no and explain the reasoning for your answer. {% endif %} {{ li() }}. What narrative perspective and tense is the story written in? (e.g., "third person past tense", "first person present tense from Character X's perspective", etc.) diff --git a/src/talemate/prompts/templates/summarizer/analyze-scene-for-next-narration.jinja2 b/src/talemate/prompts/templates/summarizer/analyze-scene-for-next-narration.jinja2 index 03291704..5ffb71fc 100644 --- a/src/talemate/prompts/templates/summarizer/analyze-scene-for-next-narration.jinja2 +++ b/src/talemate/prompts/templates/summarizer/analyze-scene-for-next-narration.jinja2 @@ -8,6 +8,7 @@ Override example: {{ set_as_is_extractor("analysis") }} -#} {% include "scene-context.jinja2" %} +{% include "world-state-snapshot.jinja2" %} {% include "scene-intent.jinja2" %} <|SECTION:TASK|> {% set bullet_num=0 %} diff --git a/src/talemate/prompts/templates/visual/generate-image-OBJECT_ILLUSTRATION.jinja2 b/src/talemate/prompts/templates/visual/generate-image-OBJECT_ILLUSTRATION.jinja2 new file mode 100644 index 00000000..dea4febb --- /dev/null +++ b/src/talemate/prompts/templates/visual/generate-image-OBJECT_ILLUSTRATION.jinja2 @@ -0,0 +1,37 @@ +<|SECTION:VISUAL CONTEXT|> +{% if not instructions %} +{% capture_context %} +{{ query_scene("Describe the single most notable object, item or thing in the current moment. Include details about its appearance, material, shape, condition and any distinguishing features.", scene_context, True) }} +{% end_capture_context %} +{% else %} +{% capture_context %} +{{ query_scene("Describe the appearance, material, shape, condition and distinguishing features of the object referenced by the following instructions: "+instructions, False) }} +{% end_capture_context %} +{% endif %} +<|CLOSE_SECTION|> + +<|SECTION:REQUIREMENTS|> +A focused image of a single object or item, isolated as the sole subject of the image. The key requirements are: + +1. **Primary Focus**: The image centers entirely on one object/item - it is the subject, rendered in clear detail +2. **Framing**: Portrait orientation, close-up - like a museum display, inventory icon, or product shot of the item +3. **Composition**: The object is presented clearly and prominently against a simple or contextually fitting backdrop that does not compete with it for attention +4. **Character Exclusion**: NO characters, people, or creatures should be present - this is strictly an object study +5. **Presentation**: Think of this like an item card, a close inspection, or a detailed study illustration - the kind of image that lets the viewer take a closer look at the thing itself + +**What to avoid:** +- Any characters, people, animals, or creatures +- Wide environmental or landscape shots where the object is lost in the scene +- Multiple competing subjects - keep it to the single examined object +- Graphical overlays, borders, frames, labels, or UI elements +- Landscape orientation +<|CLOSE_SECTION|> + +<|SECTION:TASK|> +Your task is to generate an image generation prompt that will be used to generate a focused close-up image of a single object or item. Think of it like taking a closer look at one specific thing - an item study or inventory illustration. + +{% if instructions %}Specific instructions for the requested image: {{ instructions }}{% endif %} + +Make sure your prompt emphasizes the portrait/close-up framing and keeps the single object as the clear, sole subject without any characters present. + +<|CLOSE_SECTION|> diff --git a/src/talemate/prompts/templates/world_state/examine-entity.jinja2 b/src/talemate/prompts/templates/world_state/examine-entity.jinja2 new file mode 100644 index 00000000..7ab2f668 --- /dev/null +++ b/src/talemate/prompts/templates/world_state/examine-entity.jinja2 @@ -0,0 +1,79 @@ +{#- +Extractors: + - response: AnchorExtractor("", "", fallback_to_full=True) +-#} +{# RENDERED CONTEXT (premise, intent, classification, pins, reinforcements) #} +{% set rendered_context -%} +{% include "extra-context-static.jinja2" %} +{%- endset %} +{# END RENDERED CONTEXT #} + +{# VOLATILE CONTEXT (dynamic instructions — placement varies with prompt caching) #} +{% set volatile_context_text %} +{% include "extra-context-dynamic.jinja2" %} +{% endset %} +{# END VOLATILE CONTEXT #} + +{# SEED MATERIAL (the snapshot the highlight tooltip is built on) #} +{% set seed_text %} +Snapshot: +{{ snapshot_text|condensed }} +{% endset %} +{# END SEED MATERIAL #} + +{# WRITING STYLE (scene-level style guide, if set) #} +{% set writing_style_text %} +{% include "writing-style-instructions.jinja2" %} +{% endset %} +{# END WRITING STYLE #} + +{# BROAD HISTORY (budget-aware, includes archive summaries; prior context_investigation + results for this or related entities naturally surface here, so the player gets + accumulating depth across repeated examines without an explicit reveals buffer.) #} +{% set rendered_context_tokens = count_tokens(rendered_context) %} +{% set volatile_context_tokens = count_tokens(volatile_context_text) %} +{% set seed_text_tokens = count_tokens(seed_text) %} +{% set writing_style_tokens = count_tokens(writing_style_text) %} +{% set background_budget = max_tokens - 1500 - rendered_context_tokens - volatile_context_tokens - seed_text_tokens - writing_style_tokens %} +{% set background_history = scene.context_history(budget=background_budget) %} +{% set background_text %} +{% for line in background_history -%} +{{ line }} +{% endfor -%} +{% endset %} +{# END BROAD HISTORY #} + +{# RENDER PROMPT #} +{% set volatile_placement = volatile_context_placement() %} +{{ rendered_context }} +{% if volatile_placement != "after_history" %} +{{ volatile_context_text }} +{% endif %} +{% if background_history %} +<|SECTION:STORY SO FAR|> +{{ background_text }} +<|CLOSE_SECTION|> +{% endif %} +<|SECTION:SEED MATERIAL|> +{{ seed_text }} +<|CLOSE_SECTION|> +{{ writing_style_text }} +{% if volatile_placement == "after_history" %} +{{ volatile_context_text }} +{% endif %} +<|SECTION:TASK|> +Describe the {{ entity_kind }} "{{ entity_name }}" as it is *at this moment in the scene* — a closer look that reveals something new about the thing itself. This is an in-fiction examination result, not canonical lore — what surfaces here is true at this point in time, anchored to the current beat. + +The result should: +- Be grounded, in-world prose (not a bulleted list). +- Keep the {{ entity_kind }} itself as the subject — its appearance, texture, function, history, significance, or sensory detail. Describe the thing, not anyone looking at it. +- Faithfully incorporate every concrete detail from the SEED MATERIAL. The snapshot is the source of truth for what is now noticed about this {{ entity_kind }}. +- Stay consistent with anything established in the STORY SO FAR. Never contradict it. +- The point of a closer look is discovery: reveal new, concrete, interesting detail about the {{ entity_kind }} that the snapshot and STORY SO FAR haven't already stated — texture, construction, history, function, a hidden feature, or significance. Invent grounded specifics rather than restating what's already known. +- Read as an omniscient, all-seeing narrator. Never write it as an action: no one takes a closer look, reaches for, picks up, holds, examines, or reacts to the {{ entity_kind }}. No character acts, moves, or speaks, and the scene does not advance — avoid "I" or "you" performing anything. +- Match the prose register and tone of the scene, but stay purely descriptive. +- Avoid meta-commentary, headings, or fourth-wall references. Just the in-world description of the {{ entity_kind }}. + +Wrap your response in tags like this: your examination text here +<|CLOSE_SECTION|> +{{ set_prepared_response("") }} diff --git a/src/talemate/prompts/templates/world_state/request-world-state-v2.jinja2 b/src/talemate/prompts/templates/world_state/request-world-state-v2.jinja2 index ec90e7b9..af904192 100644 --- a/src/talemate/prompts/templates/world_state/request-world-state-v2.jinja2 +++ b/src/talemate/prompts/templates/world_state/request-world-state-v2.jinja2 @@ -6,82 +6,318 @@ Override example: {{ set_anchor_extractor("response", "", "") }} {{ set_as_is_extractor("response") }} -#} -<|SECTION:EXAMPLE|> -{% if data_format_type() == "json" %} -{ - "characters": { - # the character name is the key - "Character name": { - "emotion": "The current emotional state or mood of the character. (neutral, happy, sad, angry, etc.)", - "snapshot": "A brief narrative description of what the character is doing at this moment in the scene." - }, - # ... - }, - "items": { - # the item name is the key in natural language (short) - "Item name": { - "snapshot": "A brief narrative description of the item and the state its currently in." - }, - # ... - }, - "location": "A brief narrative description of the location the scene is taking place in.", -} -{% else %} -```yaml -characters: - # the character name is the key - Character name: - emotion: The current emotional state or mood of the character. (neutral, happy, sad, angry, etc.) - snapshot: A brief narrative description of what the character is doing at this moment in the scene. - # ... -items: - # the item name is the key in natural language (short) - Item name: - snapshot: A brief narrative description of the item and the state its currently in. -location: A brief narrative description of the location the scene is taking place in. -``` -{% endif %} -<|CLOSE_SECTION|> -<|SECTION:CONTEXT|> -Player character: {{ scene.get_player_character().name }} +{# RENDERED CONTEXT (premise, intent, classification, pins, reinforcements, cast) #} +{% set player_character = scene.get_player_character() %} +{% set rendered_context -%} +{% include "extra-context-static.jinja2" %} +<|SECTION:CAST|> +{% if player_character -%} +Player character: {{ player_character.name }} +{% endif -%} +{% if scene.npc_character_names -%} Other major characters: {% for npc_name in scene.npc_character_names -%} -{{ npc_name }} +- {{ npc_name }} {% endfor -%} +{% endif -%} +<|CLOSE_SECTION|> +{% endset %} +{# END RENDERED CONTEXT #} -{% set scene_history=scene.snapshot(lines=10, return_as_list=True) %} -{% set final_line_number=len(scene_history) %} -{% for scene_context in scene_history -%} +{# VOLATILE CONTEXT (memory recall + dynamic instructions — placement varies with prompt caching) #} +{% set volatile_context_text %} +{% include "memory-context.jinja2" %} +{% include "extra-context-dynamic.jinja2" %} +{% endset %} +{# END VOLATILE CONTEXT #} + +{# FOCUSED SNAPSHOT — pre-computed by the agent. Every message in `scene_focus` + is a valid highlight anchor (the agent stamps their ids into + `anchor_message_ids`). On a fresh scene the agent falls back to the intro + text as the sole focus line and no anchors are produced. #} +{% set final_line_number = len(scene_focus) if scene_focus else 1 %} +{% set focus_text %} +<|SECTION:CURRENT FOCUS|> +{% if scene_focus -%} +{% for scene_context in scene_focus -%} Line {{ loop.index }}: {{ to_str(scene_context)|condensed }} --- {% endfor -%} +{%- else -%} +Line 1: {{ intro_text|condensed }} +--- +{%- endif %} <|CLOSE_SECTION|> -{% if not scene.history -%} -<|SECTION:DIALOGUE|> -No dialogue so far +{% endset %} +{# END FOCUSED SNAPSHOT #} + +{# CURRENT STATE — durable mode only; the LLM patches its response on top of + this. Skipped when the state is empty (fresh scene / post-TimePassage). #} +{% set has_current_state = durable_snapshot and current_state_payload and ( + current_state_payload.get("characters") + or current_state_payload.get("items") + or current_state_payload.get("places") + or current_state_payload.get("location") +) %} +{% set current_state_text %} +{% if has_current_state -%} +<|SECTION:CURRENT STATE|> +```{{ data_format_type() }} +{%- if data_format_type() == "yaml" %} +{{ yaml(current_state_payload) }} +{%- else %} +{{ current_state_payload | tojson(indent=4) }} +{%- endif %} +``` <|CLOSE_SECTION|> -{% endif -%} +{%- endif %} +{% endset %} +{# END CURRENT STATE #} + +{# EXAMPLE response block. + + Durable mode: the example is a small delta demonstrating all four + operations (partial update, add, drop, omit). Most refresh passes are + shaped like this — a handful of entries, not a wholesale dump. + + Legacy mode: the example is a wholesale extraction shape, since every + pass replaces the snapshot entirely. #} +{% set example_text %} +<|SECTION:EXAMPLE|> +{% if durable_snapshot %} +{% if data_format_type() == "json" %} +```json +{ + "characters": { + "New character (not yet in CURRENT STATE)": { + "emotion": "wary", + "snapshot": "What inspecting this character reveals right now.", + "mentions": ["verbatim noun phrase from CURRENT FOCUS"] + }, + "Existing character (already in CURRENT STATE)": { + "emotion": "anxious", + "snapshot": "A current reading of what this character is doing in this moment — every present character carries one every pass.", + "mentions": ["verbatim noun phrase from CURRENT FOCUS"] + } + }, + "items": { + "New or changed item": { + "snapshot": "Concrete fact about the item — function, history, condition, peril, hidden mechanism.", + "mentions": ["verbatim noun phrase from CURRENT FOCUS"] + }, + "Item that has left the scene": null + } +} +``` +{% else %} +```yaml +characters: + New character (not yet in CURRENT STATE): + emotion: wary + snapshot: What inspecting this character reveals right now. + mentions: + - verbatim noun phrase from CURRENT FOCUS + Existing character (already in CURRENT STATE): + emotion: anxious + snapshot: A current reading of what this character is doing in this moment — every present character carries one every pass. + mentions: + - verbatim noun phrase from CURRENT FOCUS +items: + New or changed item: + snapshot: Concrete fact about the item — function, history, condition, peril, hidden mechanism. + mentions: + - verbatim noun phrase from CURRENT FOCUS + Item that has left the scene: null +``` +{% endif %} +{% else %} +{% if data_format_type() == "json" %} +```json +{ + "characters": { + "First character name": { + "emotion": "Single word — their current mood (neutral, defiant, anxious, etc.)", + "snapshot": "What inspecting this character reveals beyond the surface prose.", + "mentions": ["verbatim noun phrase from the CURRENT FOCUS"] + }, + "Second character name": { + "emotion": "Single word", + "snapshot": "Different angle on a different character — both go in when both are worth examining.", + "mentions": ["verbatim noun phrase"] + } + }, + "items": { + "First item name": { + "snapshot": "What inspecting this item reveals beyond the surface prose.", + "mentions": ["verbatim noun phrase from the CURRENT FOCUS"] + }, + "Second item name": { + "snapshot": "If two items in the CURRENT FOCUS both deserve a closer look, examine both.", + "mentions": ["verbatim noun phrase"] + } + }, + "places": { + "Place name": { + "snapshot": "What inspecting this place reveals beyond the surface prose.", + "mentions": ["verbatim noun phrase from the CURRENT FOCUS"] + } + }, + "location": "Concrete place where the scene is taking place right now." +} +``` +{% else %} +```yaml +characters: + First character name: + emotion: Single word — their current mood (neutral, defiant, anxious, etc.) + snapshot: What inspecting this character reveals beyond the surface prose. + mentions: + - verbatim noun phrase from the CURRENT FOCUS + Second character name: + emotion: Single word + snapshot: Different angle on a different character — both go in when both are worth examining. + mentions: + - verbatim noun phrase +items: + First item name: + snapshot: What inspecting this item reveals beyond the surface prose. + mentions: + - verbatim noun phrase from the CURRENT FOCUS + Second item name: + snapshot: If two items in the CURRENT FOCUS both deserve a closer look, examine both. + mentions: + - verbatim noun phrase +places: + Place name: + snapshot: What inspecting this place reveals beyond the surface prose. + mentions: + - verbatim noun phrase from the CURRENT FOCUS +location: Concrete place where the scene is taking place right now. +``` +{% endif %} +{% endif %} +{% if durable_snapshot %} +Every entity in the example carries a `snapshot` — that field is mandatory, never omitted (including for characters already in CURRENT STATE). The example also shows a drop (`null`) and two buckets implicitly omitted (places + location → carried forward unchanged). The example is shape, not target — bucket counts and entry counts are decided entirely by what's in the CURRENT FOCUS. +{% else %} +The number of entries per bucket in the example is NOT a target — it only demonstrates shape. Bucket counts are decided entirely by the examine test below; any count (including zero) is valid for any bucket. +{% endif %} +<|CLOSE_SECTION|> +{% endset %} +{# END EXAMPLE #} + +{# CUSTOM INSTRUCTIONS — user-supplied extra guidance for this snapshot pass. #} +{% set custom_instructions_text %} +{% if custom_instructions %} +<|SECTION:ADDITIONAL INSTRUCTIONS|> +{{ custom_instructions }} +<|CLOSE_SECTION|> +{% endif %} +{% endset %} +{# END CUSTOM INSTRUCTIONS #} + +{# WRITING STYLE (scene-level style guide, if set) #} +{% set writing_style_text %} +{% include "writing-style-instructions.jinja2" %} +{% endset %} +{# END WRITING STYLE #} + +{# BROAD HISTORY (budget-aware, includes archive summaries) #} +{% set rendered_context_tokens = count_tokens(rendered_context) %} +{% set volatile_context_tokens = count_tokens(volatile_context_text) %} +{% set focus_text_tokens = count_tokens(focus_text) %} +{% set example_text_tokens = count_tokens(example_text) %} +{% set current_state_text_tokens = count_tokens(current_state_text) %} +{% set custom_instructions_text_tokens = count_tokens(custom_instructions_text) %} +{% set writing_style_text_tokens = count_tokens(writing_style_text) %} +{% set background_budget = max_tokens - 2500 - rendered_context_tokens - volatile_context_tokens - focus_text_tokens - example_text_tokens - current_state_text_tokens - custom_instructions_text_tokens - writing_style_text_tokens %} +{% set background_history = scene.context_history(budget=background_budget) %} +{% set background_text %} +{% if background_history -%} +<|SECTION:STORY SO FAR|> +{% for line in background_history -%} +{{ line }} +{% endfor -%} +<|CLOSE_SECTION|> +{%- endif %} +{% endset %} +{# END BROAD HISTORY #} + +{# RENDER PROMPT — each content block carries its own <|SECTION|> handlers + (set above), so this is just positioning. Volatile context moves before or + after the history depending on the prompt-caching mode. #} +{% set volatile_placement = volatile_context_placement() %} +{{ rendered_context }} +{% if volatile_placement != "after_history" %} +{{ volatile_context_text }} +{% endif %} +{{ background_text }} +{{ focus_text }} +{{ current_state_text }} +{% if volatile_placement == "after_history" %} +{{ volatile_context_text }} +{% endif %} +{{ example_text }} +{{ custom_instructions_text }} +{{ writing_style_text }} <|SECTION:TASK|> -Create {{ data_format_type() }} data for the world state that reflects the scene progression so far. +Produce the "examine" data for the CURRENT FOCUS — the kind of short factual flavor a reader gets when they click on a notable thing in the scene to look closer at it. Treat the CURRENT FOCUS as one continuous moment; line {{ final_line_number }} is the most recent beat, the rest is its immediate run-up. -The world state needs to include important concrete and material items present in the scene during line {{ final_line_number }}. -The world state needs to include persons (characters) interacting during line {{ final_line_number }}. -What are the present characters doing during line {{ final_line_number }}? -Be factual and truthful. Don't make up things that are not in the context or dialogue. -Snapshot text should always be specified. If you don't know what to write, write "You see nothing special." -Emotion should always be specified. If you don't know what to write, write "neutral". +For every candidate entity, apply this test: +**If a reader clicked this in the scene, would the description tell them a *factual* something the surface prose doesn't already make obvious — not a thematic reading?** -Respect the scene progression and answer in the context of line {{ final_line_number }}. +If yes, include it. If no — if the surface prose already spells out everything there is to know, or if all you have to offer is a metaphor — skip it. +{% if durable_snapshot %} -Required response: a complete and valid {{ data_format_type() }} response according to the {{ data_format_type() }} example containing items and characters. +How the response is applied: +Your response is merged onto the CURRENT STATE block above, entity by entity. -characters should have the following attributes: `emotion`, `snapshot` -items should have the following attributes: `snapshot` -item keys must be reader friendly, so "Item name" instead of "item_name". +- **`snapshot` is mandatory.** Every entity you include — brand-new or already in CURRENT STATE — carries a `snapshot` (plus `emotion` for characters, and `mentions` copied from the CURRENT FOCUS). Never emit an entity without a snapshot. Don't try to judge whether a change is "interesting enough" to include the field: if nothing notable has shifted, restate a similar snapshot in fresh words rather than leaving it out. +- **Keep present characters live.** Emit every character in the CURRENT FOCUS on every pass, with a snapshot describing what they are doing in this moment. A character who has been present for many turns still gets a current snapshot each pass — never a frozen, first-appearance one. (For an existing character whose moment changed materially — now wounded, now calm — write a fresh snapshot reflecting it.) +- **Items and places** are steadier: emit one when it is new or has materially changed (door now broken open), and omit it to carry it forward unchanged. When you do include it, the `snapshot` is still mandatory. +- **Drop an entity** — set its value to `null`. It is removed from the world state. Drop entities that have left, were destroyed, or have stopped being relevant. +- **`location`** — emit the new place string when the scene has moved, `null` to clear it, omit to leave it unchanged. -Don't overthink it. +If CURRENT STATE is empty or absent (fresh scene or post-scene-cut), treat every entity in the CURRENT FOCUS as new. +{% endif %} +What to extract: +Scan the CURRENT FOCUS for noun-phrase candidates and apply the examine test to each. + +**Worth extracting:** +- A character whose state in the focus has texture — physical posture, hidden tension, strain, a reaction the prose only hints at. The player character (POV "I"/"me"/"my") qualifies under the same test as anyone else; narrative voice alone doesn't include or exclude them. +- An object named in the focus with function, history, condition, peril, or sensory detail. A scuffed pair of boots scraping a metal floor, a melted tablet wedged into a door, a panel with exposed wiring — all candidates. +- A place named in the focus but distinct from the current location — a destination, a referenced room, a region whose atmosphere is worth painting. + +**Not worth extracting:** +- Pure ambient scenery the camera moves past (a generic hum, a faint glow, a corridor wall) unless the focus dwells on it as load-bearing flavor. +- Sub-components of a larger named entity. A door's pneumatic seal goes inside the door's snapshot, not its own item entry. +- Entities that appear only in STORY SO FAR. That block is background to understand what entities *are*, not extraction material. +- Entities the surface prose has already fully painted. If everything interesting is already on the page, there is nothing left to reveal — skip. + +**Possessive constructions ("X's Y")** belong to Y, not X. "Elmer's boots" is a candidate for an items entry (boots), not a mention of Elmer. Treat the head noun as the entity. + +**No fabricated aggregates.** Bucket keys come from the focus prose. If two distinct items each pass the examine test, give them separate entries — do not invent a parent ("Emergency Tools", "Loose Equipment") that the prose never names. + +The STORY SO FAR is background to help you understand what each extracted entity *is*. Do **not** extract entities that only appear there — extract only what is named in the CURRENT FOCUS. + +Snapshot quality: +- Give the reader a **fact** worth discovering — function, history, mechanism, hidden condition, peril, sensory detail, lore that adds texture. The snapshot should describe the *object/character/place itself*, not what it *means* about someone else. +- Draw from established context where it gives you something to work with; invent freely beyond it — but invent **concrete facts**, not mood or symbolism. "The capacitor inside is on a dying charge" is a reveal. "A beacon that marks the boundary between his obligations and his sanctuary" is not. +- **Metaphor-only snapshots fail the test.** If the strongest thing you can say about an entity is a thematic interpretation or what it symbolizes for its owner, you have no factual reveal — drop the entity rather than fill the bucket with literary flourish. +- Never contradict a fact already established in STORY SO FAR or the CURRENT FOCUS. Inventing detail in the gaps is fine; rewriting what's already there is not. +- One to three sentences. + +Building `mentions`: +Each mention is a **verbatim noun phrase or proper name** copied literally from the CURRENT FOCUS for that entity. + +- **No pronouns.** "him", "her", "it", "they", "this one" are never mentions. If the focus only refers to an entity by pronoun, leave its mentions empty. +- **No possessive bindings.** "Elmer's boots" is a mention of the boots entity, never of Elmer. Only bare "Elmer" (and other verbatim Elmer-naming phrases) belongs in Elmer's mentions. +- If the same entity appears under multiple verbatim phrasings, list every one ("the auxiliary maintenance locker", "the locker", "the recessed steel vault" all under one entry). +- A mention must appear literally in the focus. No paraphrasing, recasing, or pluralization. +- Keys are reader-friendly title case ("Manual override panel", not "manual_override_panel"). + +Required response: valid {{ data_format_type() }} matching the example shape{% if durable_snapshot %} (delta-merge semantics above){% endif %}. <|CLOSE_SECTION|> ## Updated world state -{{ set_data_response(dict(characters={"name":{}}), cutoff=3) }} \ No newline at end of file +{{ set_data_response(dict(characters={"name":{}}), cutoff=3) }} diff --git a/src/talemate/regenerate.py b/src/talemate/regenerate.py index 8c3f95fc..07ed481d 100644 --- a/src/talemate/regenerate.py +++ b/src/talemate/regenerate.py @@ -4,10 +4,13 @@ from talemate.instance import get_agent from talemate.emit import emit import talemate.events as events import talemate.emit.async_signals as async_signals +from talemate.agents.editor.revision import RevisionContext from talemate.context import regeneration_context +from talemate.exceptions import GenerationCancelled from talemate.scene_message import ( SceneMessage, CharacterMessage, + VersionSource, NarratorMessage, ReinforcementMessage, ContextInvestigationMessage, @@ -22,18 +25,21 @@ __all__ = [ "regenerate_character_message", "regenerate_target_message", "ensure_regenerate_allowed", + "regeneration_status", + "can_regenerate", ] log = structlog.get_logger("talemate.regenerate") -def regenerate_target_message(scene: "Scene", idx: int = -1) -> SceneMessage | None: +def regenerate_target_message(scene: "Scene") -> SceneMessage | None: """ - Return the message that `regenerate(scene, idx)` would attempt to regenerate, - without mutating history (skips trailing reinforcement messages). + Return the message that ``regenerate(scene)`` would attempt to + regenerate, without mutating history (skips trailing reinforcement + messages). """ + cur_idx = -1 try: - cur_idx = idx message = scene.history[cur_idx] except Exception: return None @@ -49,7 +55,7 @@ def regenerate_target_message(scene: "Scene", idx: int = -1) -> SceneMessage | N return message -def ensure_regenerate_allowed(scene: "Scene", idx: int = -1) -> tuple[bool, str | None]: +def ensure_regenerate_allowed(scene: "Scene") -> tuple[bool, str | None]: """ Returns (allowed, error_message). @@ -57,7 +63,7 @@ def ensure_regenerate_allowed(scene: "Scene", idx: int = -1) -> tuple[bool, str character is inactive (or has no active actor). This keeps regeneration behavior consistent and avoids hard failures in the conversation agent. """ - message = regenerate_target_message(scene, idx=idx) + message = regenerate_target_message(scene) if not message or not isinstance(message, CharacterMessage): return True, None @@ -80,27 +86,80 @@ def ensure_regenerate_allowed(scene: "Scene", idx: int = -1) -> tuple[bool, str return True, None -async def regenerate_character_message( +def regeneration_status(scene: "Scene") -> tuple[bool, str | None]: + """ + Single source of truth for whether the tail of ``scene.history`` can + be regenerated right now. Returns ``(can_regenerate, reason)`` where + ``reason`` is a user-facing explanation when regeneration is blocked + (``None`` when it's allowed). + + Mirrors the guard conditions inside ``regenerate()`` without mutating + history, so the UI's button state and the actual regenerate flow + never disagree. + """ + message = regenerate_target_message(scene) + if message is None: + return False, "Nothing to regenerate yet." + + # `from_choice` is a CharacterMessage-only field; `source` lives on the + # base SceneMessage, so guard the access — this runs on the hot + # scene_status path and an AttributeError here would break every emit. + if message.source == "player" and not getattr(message, "from_choice", False): + return False, "Cannot regenerate a static player message." + + if not isinstance( + message, (CharacterMessage, NarratorMessage, ContextInvestigationMessage) + ): + return False, "The most recent message cannot be regenerated." + + # folds in the inactive-character guard + return ensure_regenerate_allowed(scene) + + +def can_regenerate(scene: "Scene") -> bool: + """ + Whether the tail of ``scene.history`` can be regenerated right now. + + Drops the blocked-reason from :func:`regeneration_status`; call that + directly when the reason is needed (e.g. to surface it in the UI). + """ + return regeneration_status(scene)[0] + + +async def _converse_for_character_message( message: CharacterMessage, scene: "Scene" -) -> list[CharacterMessage] | None: +) -> tuple["Character", list[CharacterMessage]] | None: + """ + Resolve the character that owns ``message`` and ask the conversation + agent to produce replacement message(s). Returns ``(character, + messages)`` or ``None`` when the message can't be regenerated (no + character, or a static user line). Side-effect free — does NOT push + to history or emit. + """ character: "Character | None" = scene.get_character(message.character_name) - if not character: - log.error( - "regenerate_character_message: Could not find character", message=message - ) + log.error("regenerate: Could not find character for message", message=message) return None - agent = get_agent("conversation") - if message.source == "player" and not message.from_choice: log.warning( - "regenerate_character_message: Static user message, no regeneration possible", + "regenerate: Static user message, no regeneration possible", message=message, ) return None + agent = get_agent("conversation") messages = await agent.converse(character.actor, instruction=message.from_choice) + return character, messages + + +async def regenerate_character_message( + message: CharacterMessage, scene: "Scene" +) -> list[CharacterMessage] | None: + result = await _converse_for_character_message(message, scene) + if result is None: + return None + character, messages = result for message in messages: await scene.push_history(message) @@ -109,72 +168,110 @@ async def regenerate_character_message( return messages +async def _dispatch_agent_regeneration( + message: SceneMessage, scene: "Scene" +) -> list[SceneMessage] | None: + """ + Build replacement message(s) by calling the agent function recorded on + ``message.meta``. Does NOT push to history or emit — caller decides + what to do with the result. + + For ``CharacterMessage`` the conversation agent's ``converse`` returns + one or more ``CharacterMessage`` objects directly. + For other supported types the meta-driven function may return either a + new ``SceneMessage`` or a raw string (wrapped into ``message.__class__``). + + Returns the list of new SceneMessage objects (auto-revision has NOT + been applied yet — that's the caller's job via push_history or + ``editor.maybe_revise_inplace``). + """ + if isinstance(message, CharacterMessage): + result = await _converse_for_character_message(message, scene) + if result is None: + return None + _character, messages = result + return messages + + try: + agent = get_agent(message.meta.get("agent")) + except Exception as e: + log.error( + "_dispatch_agent_regeneration: Could not find agent", + message=message, + error=e, + ) + return None + + if not agent: + log.error("_dispatch_agent_regeneration: Could not find agent", message=message) + return None + + function_name = message.meta.get("function") + fn = getattr(agent, function_name, None) + + if not fn: + log.error( + "_dispatch_agent_regeneration: Could not find agent function", + message=message, + ) + return None + + arguments = message.meta.get("arguments", {}).copy() + + # if `character` is set and a string, convert it to a Character + if arguments.get("character") and isinstance(arguments.get("character"), str): + arguments["character"] = scene.get_character(arguments.get("character")) + + log.debug( + "_dispatch_agent_regeneration: Calling agent function", + function=function_name, + arguments=arguments, + ) + + new_message = await fn(**arguments) + + if not new_message: + log.error( + "_dispatch_agent_regeneration: No new message generated", message=message + ) + return None + + if isinstance(new_message, str): + new_message = message.__class__(new_message) + new_message.meta = message.meta.copy() + + if isinstance(message, ContextInvestigationMessage): + new_message.sub_type = message.sub_type + + return [new_message] + + async def regenerate_message( message: SceneMessage, scene: "Scene" ) -> list[SceneMessage] | None: """ - Will regenerate the message, using the meta information + Regenerate the message via the dispatch table and push the result(s) + onto scene history. Used by the reinforcement-message re-generation + pass — the in-place top-level ``regenerate`` flow uses + ``_regenerate_inplace`` instead. """ if isinstance(message, CharacterMessage): - # character messages need specific handling + # character messages need specific handling — including its own + # push + emit loop messages = await regenerate_character_message(message, scene) if messages is None: return None else: - # all other message types + new_messages = await _dispatch_agent_regeneration(message, scene) + if not new_messages: + return None - try: - agent = get_agent(message.meta.get("agent")) - except Exception as e: - log.error( - "regenerate_message: Could not find agent", message=message, error=e - ) - return - - if not agent: - log.error("regenerate_message: Could not find agent", message=message) - return - - function_name = message.meta.get("function") - fn = getattr(agent, function_name, None) - - if not fn: - log.error( - "regenerate_message: Could not find agent function", message=message - ) - return - - arguments = message.meta.get("arguments", {}).copy() - - # if `character` is set and a string, convert it to a Character - if arguments.get("character") and isinstance(arguments.get("character"), str): - arguments["character"] = scene.get_character(arguments.get("character")) - - log.debug( - "regenerate_message: Calling agent function", - function=function_name, - arguments=arguments, - ) - - new_message = await fn(**arguments) - - if not new_message: - log.error("regenerate_message: No new message generated", message=message) - return - - if isinstance(new_message, str): - new_message = message.__class__(new_message) - new_message.meta = message.meta.copy() - - if isinstance(message, ContextInvestigationMessage): - new_message.sub_type = message.sub_type - - if not isinstance(new_message, (ReinforcementMessage)): - await scene.push_history(new_message) - emit(new_message.typ, message=new_message) - - messages = [new_message] + messages = new_messages + for new_message in messages: + if not isinstance(new_message, (ReinforcementMessage,)): + await scene.push_history(new_message) + emit(new_message.typ, message=new_message) for message in messages: await async_signals.get(f"regenerate.msg.{message.typ}").send( @@ -191,27 +288,125 @@ async def regenerate_message( return messages -def _emit_restored_message(message: SceneMessage, scene: "Scene"): - """Re-emit a message to the UI after restoring it to history.""" - if isinstance(message, CharacterMessage): - character = scene.get_character(message.character_name) - emit("character", message=message, character=character) - else: - emit(message.typ, message=message) - - -async def regenerate(scene: "Scene", idx: int = -1) -> list[SceneMessage]: +async def _regenerate_inplace( + message: SceneMessage, scene: "Scene" +) -> list[tuple[str, VersionSource]] | None: """ - Regenerate the most recent AI response, remove their previous message from the history, - and call talk() for the most recent AI Character. - """ - # Remove AI's last response and player's last message from the history - try: - message = scene.history[idx] - except IndexError: - return + Run the agent path for ``message`` and return the version(s) the + caller should append onto the existing message's revision stack. - regenerated_messages = [] + This does NOT push the new message to history nor emit any + ``character``/``narrator`` message — the caller is expected to + re-attach the original message to history and walk the returned list, + calling ``scene.append_message_version`` for each entry so the slot + keeps its id and grows its stack with the regen output. + + Returned shape, in append order: + - ``[(raw_regen, "regenerate")]`` — auto-revision did not rewrite the + regen output, so the canonical is the raw regenerate text. + - ``[(raw_regen, "regenerate"), (polished, "revision")]`` — + auto-revision rewrote the regen; both stay on the stack so the + user can navigate to the unrevised version. + + The ``raw_regen`` entry is dropped when it matches the prior + canonical (i.e. the active version on the message at regen time), + since that text is already at the active index — appending a + duplicate would just clutter the stack. + + Returns ``None`` on failure or empty result. + """ + # Snapshot the canonical text before regeneration so we can detect + # the "raw regen matches prior canonical" duplicate case. + prior_canonical = message.message + + new_messages = await _dispatch_agent_regeneration(message, scene) + if not new_messages: + return None + + # Use the first generated message as the new canonical. Multi-message + # results (rare; conversation can split) collapse to the first. + new_message = new_messages[0] + if not new_message.message: + return None + + raw_regen = new_message.message + + # Auto-revision normally runs at push_history time, but in-place + # regenerate skips push_history. Invoke the editor's in-place hook + # directly so the same gating + behavior applies. Scope the revision + # context to the original message slot (still in history) so the + # repetition range excludes it — new_message isn't in history yet. + editor = get_agent("editor") + with RevisionContext(message.id): + canonical_was_revised = await editor.maybe_revise_inplace(new_message) + + versions_to_append: list[tuple[str, VersionSource]] = [] + if raw_regen != prior_canonical: + versions_to_append.append((raw_regen, "regenerate")) + if canonical_was_revised: + versions_to_append.append((new_message.message, "revision")) + + # No-op: raw regen matched prior canonical and revision didn't fire. + if not versions_to_append: + return None + + # Fire the regenerate.msg.* signals for downstream consumers (e.g. + # avatar/asset hooks) — matches the previous push-emit flow. + for m in new_messages: + await async_signals.get(f"regenerate.msg.{m.typ}").send( + events.RegenerateGeneration( + scene=scene, + message=m, + character=scene.get_character(m.character_name) + if isinstance(m, CharacterMessage) + else None, + event_type=f"regenerate.msg.{m.typ}", + ) + ) + + return versions_to_append + + +async def _restore_history_after_failed_regenerate( + scene: "Scene", + message: SceneMessage, + popped_reinforcement_messages: list[ReinforcementMessage], +) -> None: + """ + Put history back the way it was before the regenerate attempt and tell + the frontend to drop the spinner on the message's slot. Shared by the + failure and the user-cancellation paths of ``regenerate``. + """ + scene.history.append(message) + emit( + "regenerate_failed", + "", + websocket_passthrough=True, + kwargs={"id": message.id}, + ) + for reinforcement_message in reversed(popped_reinforcement_messages): + await scene.push_history(reinforcement_message) + + +async def regenerate(scene: "Scene") -> list[SceneMessage]: + """ + In-place regenerate the most recent AI response (the tail of + ``scene.history``, skipping trailing reinforcement messages): keep + the original SceneMessage id and append the regen output(s) as new + versions on the message's revision stack via + ``scene.append_message_version``. When auto-revision rewrites the + regen, both the raw and revised versions land on the stack so the + user can navigate to either. + """ + regenerated_messages: list[SceneMessage] = [] + + # Guard via the shared predicate so the UI's button state and this + # flow can't drift apart. Checked before any history mutation, so a + # blocked regenerate is a clean no-op. + can_regen, reason = regeneration_status(scene) + if not can_regen: + log.warning("Cannot regenerate", reason=reason) + return regenerated_messages # while message type is ReinforcementMessage, keep going back in history # until we find a message that is not a ReinforcementMessage @@ -219,59 +414,71 @@ async def regenerate(scene: "Scene", idx: int = -1) -> list[SceneMessage]: # we need to pop the ReinforcementMessage from the history because # previous messages may have contributed to the answer that the AI gave # for the reinforcement message + popped_reinforcement_messages: list[ReinforcementMessage] = [] - popped_reinforcement_messages = [] - + message = scene.history[-1] while isinstance(message, (ReinforcementMessage,)): popped_reinforcement_messages.append(scene.history.pop()) - message = scene.history[idx] + message = scene.history[-1] + # `regeneration_status` already validated this target (non-static, + # regeneratable type, active character). log.debug(f"Regenerating message: {message} [{message.id}]") - if message.source == "player" and not message.from_choice: - log.warning("Cannot regenerate player's message", message=message) - # re-add the reinforcement messages - for message in reversed(popped_reinforcement_messages): - await scene.push_history(message) - return regenerated_messages - current_regeneration_context = regeneration_context.get() if current_regeneration_context: current_regeneration_context.message = message.message - if not isinstance( - message, (CharacterMessage, NarratorMessage, ContextInvestigationMessage) - ): - log.warning("Cannot regenerate message", message=message) - return regenerated_messages - + # Pop the target from history before invoking the agent — otherwise + # the LLM sees its own prior output in context and just rephrases it. + # After the reinforcement-message pop loop above, the target is at the + # tail, so a plain pop suffices. We re-append it afterward so the + # message id (and the frontend's revision stack tied to it) is + # preserved. scene.history.pop() - emit("remove_message", "", id=message.id) try: - new_messages = await regenerate_message(message, scene) + outcome = await _regenerate_inplace(message, scene) + except GenerationCancelled: + # User-initiated interrupt — not a failure. Restore history, report + # the cancellation as a normal (non-error) status, then re-raise so + # the task done-callback posts the regenerate_failed envelope and + # the scene's cancel flag is cleared. + log.warning("regenerate: Generation cancelled by user", message=message) + await _restore_history_after_failed_regenerate( + scene, message, popped_reinforcement_messages + ) + emit("status", message="Regeneration cancelled.", status="idle") + raise except Exception as e: log.error("regenerate: Exception during regeneration", message=message, error=e) - new_messages = None + outcome = None - if not new_messages: - log.error("No new messages generated, restoring original", message=message) - await scene.push_history(message) - _emit_restored_message(message, scene) + if not outcome: + log.error("No new message generated", message=message) + await _restore_history_after_failed_regenerate( + scene, message, popped_reinforcement_messages + ) emit( "status", - message="Could not regenerate message, original restored.", + message="Could not regenerate message.", status="error", ) - for reinforcement_message in reversed(popped_reinforcement_messages): - await scene.push_history(reinforcement_message) return regenerated_messages - if new_messages: - regenerated_messages.extend(new_messages) + versions_to_append = outcome - for message in popped_reinforcement_messages: - new_messages = await regenerate_message(message, scene) + # Re-append with the same id so the slot keeps its frontend + # revision stack; each call below pushes one entry onto the stack, + # with the last one becoming the active canonical. + scene.history.append(message) + for text, source in versions_to_append: + scene.append_message_version(message.id, text, source=source) + + regenerated_messages.append(message) + + for reinforcement_message in popped_reinforcement_messages: + new_messages = await regenerate_message(reinforcement_message, scene) if new_messages: regenerated_messages.extend(new_messages) diff --git a/src/talemate/save.py b/src/talemate/save.py index 5f659046..ce3cc3e8 100644 --- a/src/talemate/save.py +++ b/src/talemate/save.py @@ -28,7 +28,7 @@ def combine_paths(absolute, relative): class SceneEncoder(json.JSONEncoder): def default(self, obj): if isinstance(obj, SceneMessage): - return obj.__dict__() + return obj.to_dict() if obj is UNRESOLVED: return None return super().default(obj) diff --git a/src/talemate/scene/schema.py b/src/talemate/scene/schema.py index c2f08852..ec003390 100644 --- a/src/talemate/scene/schema.py +++ b/src/talemate/scene/schema.py @@ -1,10 +1,47 @@ +from typing import Literal + import pydantic from talemate.world_state import WorldState from talemate.game.state import GameState -__all__ = ["SceneType", "ScenePhase", "SceneIntent", "SceneState"] +__all__ = [ + "SceneType", + "ScenePhase", + "SceneIntent", + "SceneState", + "ScenePerspectives", + "PerspectiveRole", +] + + +PerspectiveRole = Literal["default", "player", "other", "narrator"] + + +class ScenePerspectives(pydantic.BaseModel): + """ + Narrative perspective / tense configuration for a scene. + + `default` is the scene-wide perspective shown in ambient prompt context. + The other three are speaker-specific overrides: when a prompt is generated + on behalf of the player character, an NPC, or the narrator, the matching + field is preferred over `default`. Empty overrides fall back to `default`. + """ + + default: str = "" + player: str = "" + other: str = "" + narrator: str = "" + + def for_role(self, role: PerspectiveRole | str | None) -> str: + default = (self.default or "").strip() + if not role or role == "default": + return default + value = getattr(self, role, None) + if value and value.strip(): + return value.strip() + return default def make_default_types() -> list["SceneType"]: diff --git a/src/talemate/scene_agent_settings.py b/src/talemate/scene_agent_settings.py new file mode 100644 index 00000000..a7e418d4 --- /dev/null +++ b/src/talemate/scene_agent_settings.py @@ -0,0 +1,404 @@ +"""Per-scene agent configuration overrides. + +Persisted as a JSON file inside an ``agent-settings/`` subdirectory of the +scene's ``save_dir`` (default name ``agent-settings.json``). Referenced by +filename from the scene's main JSON via ``agent_settings_file``. Storage is +*sparse* — only fields that have an active override appear in the file; +everything else falls through to the global agent config at access time via +``Agent.resolve_config`` / ``Agent.resolve_enabled``. + +Granularity is **project-level**, not save-file-level: ``save_dir`` is +derived from ``project_name``, so every save file (Save As copies, restore +points, etc.) inside the same project shares the same ``agent-settings/`` +folder. Scenes in different projects have independent overlays. The scene +ZIP export/import flow packages the whole ``agent-settings/`` folder so +overlays survive cross-system transfer (see ``talemate.export`` and +``talemate.load``). + +The on-disk layout looks like: + + { + "agents": { + "conversation": { + "actions": { + "generation_override": { + "enabled": false, + "config": { + "format": {"value": "narrative"}, + "length": {"value": 384} + } + } + } + } + } + } + +Any ``.json`` file in the ``agent-settings/`` subdirectory is treated as a +settings file — the dedicated directory is what identifies the role, so the +filename can be anything (the default just stays ``agent-settings.json``). +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import pydantic +import structlog + +if TYPE_CHECKING: + from talemate.tale_mate import Scene + +log = structlog.get_logger("talemate.scene_agent_settings") + +# `apply_scene_settings_link(filename=UNCHANGED)` means caller did not touch +# the agent-settings link at all (vs. explicit ``None`` which means "opt out"). +UNCHANGED: Any = object() + +# Subdirectory of ``save_dir`` that holds the agent-settings JSON files. +AGENT_SETTINGS_DIRNAME = "agent-settings" +SETTINGS_FILENAME_SUFFIX = ".json" + +# Sentinel meaning "no override is set for this field". Compare with ``is UNSET``. +UNSET: Any = object() + + +def agent_settings_dir(save_dir: Path | str) -> Path: + """Return the path to the ``agent-settings/`` subdirectory under ``save_dir``.""" + return Path(save_dir) / AGENT_SETTINGS_DIRNAME + + +class ConfigOverride(pydantic.BaseModel): + """A single overridden config value. Presence in the dict == override active.""" + + value: Any = None + + +class ActionOverride(pydantic.BaseModel): + """Overrides for one action. Sparse: missing fields fall through to global.""" + + # None means the container's `enabled` flag is NOT overridden (falls + # through to global). A real bool means it is. + enabled: bool | None = None + config: dict[str, ConfigOverride] = pydantic.Field(default_factory=dict) + + +class AgentOverride(pydantic.BaseModel): + """Overrides for one agent. Sparse.""" + + actions: dict[str, ActionOverride] = pydantic.Field(default_factory=dict) + + +class SceneAgentSettings(pydantic.BaseModel): + """A scene's agent-settings overlay. + + Loaded from / written to a JSON file in the scene's ``agent-settings/`` + subdirectory. + """ + + filepath: Path = pydantic.Field(exclude=True) + agents: dict[str, AgentOverride] = pydantic.Field(default_factory=dict) + + @property + def filename(self) -> str: + return self.filepath.name + + # ------------------------------------------------------------------ + # I/O + # ------------------------------------------------------------------ + + async def init_from_file(self) -> "SceneAgentSettings": + with open(self.filepath, "r") as f: + data = json.load(f) + + agents_raw = data.get("agents", {}) or {} + self.agents = { + name: AgentOverride(**override) for name, override in agents_raw.items() + } + return self + + async def write_to_file(self) -> None: + # Prune empty containers so the file stays clean even when callers + # clear the last override in an action / agent. + self._prune_empty() + + payload = { + "agents": { + name: override.model_dump(exclude_none=True) + for name, override in self.agents.items() + }, + } + # First write into a brand-new project creates the subdir. + self.filepath.parent.mkdir(parents=True, exist_ok=True) + with open(self.filepath, "w") as f: + json.dump(payload, f, indent=2) + + def _prune_empty(self) -> None: + """Drop action overrides with no enabled flag and no config overrides; + drop agent overrides with no remaining actions.""" + for agent_name in list(self.agents.keys()): + agent_override = self.agents[agent_name] + for action_key in list(agent_override.actions.keys()): + action = agent_override.actions[action_key] + if action.enabled is None and not action.config: + del agent_override.actions[action_key] + if not agent_override.actions: + del self.agents[agent_name] + + # ------------------------------------------------------------------ + # Read accessors (return UNSET when no override is set) + # ------------------------------------------------------------------ + + def get_value(self, agent_type: str, action_key: str, config_key: str): + agent = self.agents.get(agent_type) + if not agent: + return UNSET + action = agent.actions.get(action_key) + if not action: + return UNSET + override = action.config.get(config_key) + if override is None: + return UNSET + return override.value + + def get_enabled(self, agent_type: str, action_key: str): + agent = self.agents.get(agent_type) + if not agent: + return UNSET + action = agent.actions.get(action_key) + if not action or action.enabled is None: + return UNSET + return action.enabled + + # ------------------------------------------------------------------ + # Write accessors + # ------------------------------------------------------------------ + + def _ensure_action(self, agent_type: str, action_key: str) -> ActionOverride: + agent = self.agents.setdefault(agent_type, AgentOverride()) + return agent.actions.setdefault(action_key, ActionOverride()) + + def set_value( + self, agent_type: str, action_key: str, config_key: str, value: Any + ) -> None: + action = self._ensure_action(agent_type, action_key) + action.config[config_key] = ConfigOverride(value=value) + + def clear_value(self, agent_type: str, action_key: str, config_key: str) -> None: + agent = self.agents.get(agent_type) + if not agent: + return + action = agent.actions.get(action_key) + if not action: + return + action.config.pop(config_key, None) + + def set_enabled(self, agent_type: str, action_key: str, enabled: bool) -> None: + action = self._ensure_action(agent_type, action_key) + action.enabled = bool(enabled) + + def clear_enabled(self, agent_type: str, action_key: str) -> None: + agent = self.agents.get(agent_type) + if not agent: + return + action = agent.actions.get(action_key) + if not action: + return + action.enabled = None + + # ------------------------------------------------------------------ + # Bulk replacement (used by the AgentModal save flow) + # ------------------------------------------------------------------ + + def replace_agent_overrides(self, agent_type: str, override: dict) -> None: + """Replace ALL overrides for one agent with the supplied dict. + + Caller is expected to pass a sparse dict in the same shape as + ``AgentOverride.model_dump``. Pass an empty dict to remove all + overrides for the agent. + """ + if not override: + self.agents.pop(agent_type, None) + return + self.agents[agent_type] = AgentOverride(**override) + + +# ---------------------------------------------------------------------- +# Discovery helpers +# ---------------------------------------------------------------------- + + +def is_settings_filename(name: str) -> bool: + """Filename-only check — must be a non-empty ``.json`` name. + + The dedicated ``agent-settings/`` subdirectory carries the role; the + filename itself just needs to be a .json file. + """ + return ( + isinstance(name, str) + and name.endswith(SETTINGS_FILENAME_SUFFIX) + and len(name) > len(SETTINGS_FILENAME_SUFFIX) + ) + + +def is_safe_settings_filename(name: str | None) -> bool: + """True if ``name`` is safe to use as a settings file inside + ``/agent-settings/``.""" + from talemate.util.path import is_safe_relative_filename + + if not is_safe_relative_filename(name, suffix=SETTINGS_FILENAME_SUFFIX): + return False + return is_settings_filename(name) + + +def list_settings_files(save_dir: Path | str) -> list[str]: + """Return sorted filenames of all agent-settings JSON files in the + ``agent-settings/`` subdir under ``save_dir``.""" + settings_dir = agent_settings_dir(save_dir) + if not settings_dir.exists(): + return [] + return sorted( + entry.name + for entry in settings_dir.iterdir() + if entry.is_file() and is_settings_filename(entry.name) + ) + + +# ---------------------------------------------------------------------- +# Scene wiring helpers +# ---------------------------------------------------------------------- +# +# Both load-time auto-linking and user-driven explicit selection live +# here so the scene-side wiring lives in one place and doesn't get +# duplicated between ``load/__init__.py`` and ``world_state/manager.py``. + +DEFAULT_SETTINGS_FILENAME = "agent-settings.json" + + +async def _safe_init_overlay( + scene: "Scene", + path: Path, + filename: str, +) -> bool: + """Wire ``scene`` to the overlay at ``path`` and try to load it. + + On success populates ``scene.agent_overrides`` and returns True. On any + load error (unreadable file, malformed JSON, schema mismatch) logs a + warning, clears the link so the scene falls back to global config, and + returns False so callers can decide whether to surface the failure. + """ + scene.agent_settings_file = filename + scene.agent_overrides = SceneAgentSettings(filepath=path) + try: + await scene.agent_overrides.init_from_file() + except Exception as exc: + # Broad catch: a corrupt or hand-edited file can raise OSError + # (disk/perms), ValueError (bad JSON), pydantic.ValidationError + # (schema), TypeError / AttributeError (e.g. agents value is a + # string instead of a dict). Whatever the shape, the right fallback + # is to clear the link and let the scene read global config. + log.warning( + "agent settings file unreadable; clearing reference", + filename=filename, + error=str(exc), + ) + scene.agent_settings_file = None + scene.agent_overrides = None + return False + return True + + +async def _link_default_if_present(scene: "Scene") -> bool: + """If the default agent-settings file exists, link it. + + Returns True when the link was made. Used by both load-time auto-link + and the picker's "clear opt-out" path so they stay in sync. + """ + default_path = agent_settings_dir(scene.save_dir) / DEFAULT_SETTINGS_FILENAME + if not default_path.exists(): + return False + return await _safe_init_overlay(scene, default_path, DEFAULT_SETTINGS_FILENAME) + + +async def resolve_link_on_load(scene: "Scene", scene_data: dict) -> None: + """Apply the agent-settings link encoded in the loaded scene JSON. + + Rules: + - ``agent_settings_opted_out: true`` → never auto-link, scene has no overlay. + - ``agent_settings_file: "..."`` set → load it from ``agent-settings/``. + Missing file → log a warning and clear the reference; the cleared state + is persisted on the next scene save. + - Neither set → auto-link to ``agent-settings/agent-settings.json`` if + it exists. + """ + if scene_data.get("agent_settings_opted_out", False): + scene._agent_settings_opted_out = True + scene.agent_settings_file = None + scene.agent_overrides = None + return + + explicit_file = scene_data.get("agent_settings_file") + + if explicit_file: + path = agent_settings_dir(scene.save_dir) / explicit_file + if not path.exists(): + log.warning( + "agent settings file not found; clearing reference", + filename=explicit_file, + ) + scene.agent_settings_file = None + scene.agent_overrides = None + return + if await _safe_init_overlay(scene, path, explicit_file): + log.info("loaded scene agent overrides", filename=explicit_file) + return + + if await _link_default_if_present(scene): + log.info("auto-linked default scene agent overrides") + + +async def apply_scene_settings_link( + scene: "Scene", + filename: str | None | object = UNCHANGED, + opted_out_clear: bool = False, +) -> None: + """Apply a user-driven change to the scene's agent-settings link. + + Args: + filename: + ``UNCHANGED`` (default) — caller did not touch the link. + ``None`` — user opted out; no overlay, no auto-link on next load. + string — link to that file (must exist in + ``/agent-settings/``). + opted_out_clear: + When True (and filename is UNCHANGED), clears any prior opt-out + so the scene auto-links on next load. Drops any in-memory overlay. + """ + if filename is not UNCHANGED: + if filename is None: + scene._agent_settings_opted_out = True + scene.agent_settings_file = None + scene.agent_overrides = None + return + + if not is_safe_settings_filename(filename): + raise ValueError(f"Invalid agent settings filename: {filename!r}") + + path = agent_settings_dir(scene.save_dir) / filename + if not path.exists(): + raise ValueError(f"Agent settings file {filename} does not exist.") + scene._agent_settings_opted_out = False + if not await _safe_init_overlay(scene, path, filename): + raise ValueError( + f"Failed to load agent settings file {filename}; check the JSON is valid." + ) + return + + if opted_out_clear: + scene._agent_settings_opted_out = False + scene.agent_settings_file = None + scene.agent_overrides = None + # If a default file exists, re-link to it immediately so the user + # sees overrides take effect without reloading the scene. + await _link_default_if_present(scene) diff --git a/src/talemate/scene_assets.py b/src/talemate/scene_assets.py index b718aaa6..5f83b0c5 100644 --- a/src/talemate/scene_assets.py +++ b/src/talemate/scene_assets.py @@ -59,6 +59,7 @@ VIS_TYPE_TO_ASSET_TYPE = { VIS_TYPE.SCENE_CARD: "card", VIS_TYPE.SCENE_BACKGROUND: "scene_illustration", VIS_TYPE.SCENE_ILLUSTRATION: "scene_illustration", + VIS_TYPE.OBJECT_ILLUSTRATION: "card", VIS_TYPE.UNSPECIFIED: None, } diff --git a/src/talemate/scene_message.py b/src/talemate/scene_message.py index 1eacf374..2735e5fa 100644 --- a/src/talemate/scene_message.py +++ b/src/talemate/scene_message.py @@ -1,8 +1,9 @@ import enum import re import structlog -from dataclasses import dataclass, field -from typing import Literal +from typing import ClassVar, Literal + +import pydantic log = structlog.get_logger("talemate.scene_message") @@ -14,12 +15,32 @@ __all__ = [ "TimePassageMessage", "ReinforcementMessage", "ContextInvestigationMessage", + "MessageVersion", + "VersionSource", + "EMPTY_VERSIONS_PAYLOAD", + "versions_payload_for", "Flags", "MESSAGES", "DIRECTOR_INPUT_PREFIX", "DIRECTOR_INPUT_PREFIX_YIELD", ] +VersionSource = Literal["original", "revision", "regenerate", "continue", "custom"] + +# Default revision-stack shape for messages that don't (yet) have one — +# either no message_obj at all on a wire payload, or a message type that +# hasn't opted into versions. Keeps the wire schema homogeneous. +EMPTY_VERSIONS_PAYLOAD = {"versions": [], "active_version": 0} + + +def versions_payload_for(message_obj: "SceneMessage | None") -> dict: + """Wire-emit revision-stack shape for ``message_obj``, falling back + to :data:`EMPTY_VERSIONS_PAYLOAD` when no message is provided.""" + if message_obj is None: + return EMPTY_VERSIONS_PAYLOAD + return message_obj.versions_payload() + + # Prefixes the user can type in the main input box to route a message to the # director instead of having the player character speak/act. The yield variant # must be checked first since it shares the single-character prefix. @@ -49,17 +70,29 @@ class Flags(enum.IntFlag): HIDDEN = 0x1 -@dataclass -class SceneMessage: +class MessageVersion(pydantic.BaseModel): + """One self-describing entry in a SceneMessage's version stack.""" + + message: str + source: VersionSource = "original" + reason: str | None = None + + +class SceneMessage(pydantic.BaseModel): """ Base class for all messages that are sent to the scene. """ + model_config = pydantic.ConfigDict(extra="ignore") + + # Subclasses opt in to revision-stack behavior by setting this to True. + _supports_versions: ClassVar[bool] = False + # the mesage itself message: str # the id of the message - id: int = field(default_factory=get_message_id) + id: int = pydantic.Field(default_factory=get_message_id) # the source of the message (e.g. "ai", "progress_story", "director") source: str = "" @@ -68,10 +101,101 @@ class SceneMessage: flags: Flags = Flags.NONE - typ = "scene" + typ: str = "scene" rev: int = 0 + # Transient revision stack; seeded on opt-in subclasses, excluded + # from to_dict / persistence. + versions: list[MessageVersion] = pydantic.Field( + default_factory=list, exclude=True, repr=False + ) + + active_version: int = pydantic.Field(default=0, exclude=True, repr=False) + + @pydantic.model_validator(mode="after") + def _seed_initial_version(self): + # Only opt-in subclasses get a stack. Skip seeding when an + # explicit stack is supplied (model_validate / model_copy + # overrides). + if self._supports_versions and not self.versions: + object.__setattr__( + self, + "versions", + [MessageVersion(message=self.message, source="original")], + ) + object.__setattr__(self, "active_version", 0) + return self + + def __setattr__(self, name, value): + # Keep `versions[active]` in lockstep with bare writes to + # `.message` so streaming / in-flight cleanup don't drift the + # canonical away from its stack entry. + super().__setattr__(name, value) + if ( + name == "message" + and self._supports_versions + and self.versions + and 0 <= self.active_version < len(self.versions) + ): + self.versions[self.active_version].message = value + + def append_version( + self, + message: str, + source: VersionSource, + reason: str | None = None, + ) -> MessageVersion: + """ + Push a new version onto the stack, make it active, and update the + canonical text. The only correct way to grow the stack. + """ + if not self._supports_versions: + raise TypeError(f"{type(self).__name__} does not support version history") + version = MessageVersion(message=message, source=source, reason=reason) + self.versions.append(version) + # Use object.__setattr__ to bypass our own sync-back — we just + # wrote versions[-1].message and don't want to overwrite it. + object.__setattr__(self, "active_version", len(self.versions) - 1) + object.__setattr__(self, "message", message) + return version + + def set_active_version(self, index: int) -> None: + """Move the active pointer; the canonical text follows.""" + if not self._supports_versions: + raise TypeError(f"{type(self).__name__} does not support version history") + if not (0 <= index < len(self.versions)): + raise IndexError( + f"active_version index {index} out of range [0, {len(self.versions)})" + ) + object.__setattr__(self, "active_version", index) + object.__setattr__(self, "message", self.versions[index].message) + + def versions_payload(self) -> dict: + """ + Wire-emit shape for the revision stack. Returns + :data:`EMPTY_VERSIONS_PAYLOAD` for message types that don't opt + into versions so the wire payload stays homogeneous and the + frontend doesn't need conditional handling. + """ + if not self._supports_versions: + return EMPTY_VERSIONS_PAYLOAD + return { + "versions": [v.model_dump() for v in self.versions], + "active_version": self.active_version, + } + + def __init__(self, message: str | None = None, **data): + # Preserve the positional `message` construction style from the + # dataclass era — many call sites pass it as the first positional arg. + if message is not None: + if "message" in data: + raise TypeError( + f"{type(self).__name__}() got multiple values for 'message'" + ) + data["message"] = message + super().__init__(**data) + def __str__(self): return self.message @@ -87,7 +211,7 @@ class SceneMessage: def __contains__(self, other): return self.message in other - def __dict__(self) -> dict: + def to_dict(self) -> dict: rv = { "message": self.message, "id": self.id, @@ -175,9 +299,10 @@ class SceneMessage: self.meta.update(kwargs) -@dataclass class CharacterMessage(SceneMessage): - typ = "character" + _supports_versions: ClassVar[bool] = True + + typ: str = "character" source: str = "ai" from_choice: str | None = None asset_id: str | None = None @@ -186,6 +311,18 @@ class CharacterMessage(SceneMessage): def __str__(self): return self.message + @staticmethod + def with_name_prefix(name: str, body: str) -> str: + """ + Return ``body`` formatted in the canonical ``"{name}: body"`` + shape, idempotently — adds the prefix only if it isn't already + present. + """ + prefix = f"{name}: " + if body.startswith(prefix): + return body + return f"{prefix}{body}" + @property def character_name(self): return self.message.split(":", 1)[0] @@ -223,8 +360,8 @@ class CharacterMessage(SceneMessage): return f"\n{self.character_name.upper()}\n{message}\nEND-OF-LINE\n" - def __dict__(self) -> dict: - rv = super().__dict__() + def to_dict(self) -> dict: + rv = super().to_dict() if self.from_choice: rv["from_choice"] = self.from_choice @@ -245,10 +382,11 @@ class CharacterMessage(SceneMessage): return self.message -@dataclass class NarratorMessage(SceneMessage): + _supports_versions: ClassVar[bool] = True + source: str = "ai" - typ = "narrator" + typ: str = "narrator" asset_id: str | None = None asset_type: Literal["avatar", "card", "scene_illustration"] | None = None @@ -289,8 +427,8 @@ class NarratorMessage(SceneMessage): return self - def __dict__(self) -> dict: - rv = super().__dict__() + def to_dict(self) -> dict: + rv = super().to_dict() if self.asset_id: rv["asset_id"] = self.asset_id @@ -300,11 +438,10 @@ class NarratorMessage(SceneMessage): return rv -@dataclass class DirectorMessage(SceneMessage): - action: Literal["actor_instruction", "user_direction"] = "actor_instruction" + action: str = "actor_instruction" source: str = "ai" - typ = "director" + typ: str = "director" subtype: Literal["function_call", "user_direction"] | None = None @property @@ -368,8 +505,8 @@ class DirectorMessage(SceneMessage): return self - def __dict__(self) -> dict: - rv = super().__dict__() + def to_dict(self) -> dict: + rv = super().to_dict() if self.action: rv["action"] = self.action @@ -401,21 +538,19 @@ class DirectorMessage(SceneMessage): return f"# {self.as_story_progression}" -@dataclass class TimePassageMessage(SceneMessage): ts: str = "PT0S" source: str = "manual" - typ = "time" + typ: str = "time" - def __dict__(self) -> dict: - rv = super().__dict__() + def to_dict(self) -> dict: + rv = super().to_dict() rv["ts"] = self.ts return rv -@dataclass class ReinforcementMessage(SceneMessage): - typ = "reinforcement" + typ: str = "reinforcement" source: str = "ai" @property @@ -453,9 +588,10 @@ class ReinforcementMessage(SceneMessage): self.set_source("world_state", "update_reinforcement", **parameters) -@dataclass class ContextInvestigationMessage(SceneMessage): - typ = "context_investigation" + _supports_versions: ClassVar[bool] = True + + typ: str = "context_investigation" source: str = "ai" sub_type: str | None = None asset_id: str | None = None @@ -479,6 +615,7 @@ class ContextInvestigationMessage(SceneMessage): - visual-character - visual-scene - query + - examine A natural language title will be generated based on the sub_type """ @@ -489,13 +626,16 @@ class ContextInvestigationMessage(SceneMessage): return "Visual description of the current moment" elif self.sub_type == "query": return f"Query: {self.query}" + elif self.sub_type == "examine": + entity = self.source_arguments.get("entity_name", "entity") + return f"Added detail to {entity}" return "Internal note" def __str__(self): return f"# {self.title}: {self.message}" - def __dict__(self) -> dict: - rv = super().__dict__() + def to_dict(self) -> dict: + rv = super().to_dict() rv["sub_type"] = self.sub_type if self.asset_id: diff --git a/src/talemate/server/agent_config.py b/src/talemate/server/agent_config.py index fe84e3a0..2cc26192 100644 --- a/src/talemate/server/agent_config.py +++ b/src/talemate/server/agent_config.py @@ -14,6 +14,11 @@ import structlog from talemate.config import commit_config from talemate.instance import AGENTS +from talemate.scene_agent_settings import ( + SceneAgentSettings, + agent_settings_dir, + is_safe_settings_filename, +) from talemate.util import slugify from .websocket_plugin import Plugin @@ -147,3 +152,100 @@ class AgentConfigPlugin(Plugin): agent.rename_dynamic_child_label(action_key, slug, label) await self._persist_and_broadcast(agent) + + # ------------------------------------------------------------------ + # Per-scene agent overrides + # ------------------------------------------------------------------ + + async def handle_save_scene_overrides(self, data: dict): + """Persist a sparse per-agent override set into the scene's + agent-settings JSON file. + + Required: ``agent_type``, ``override`` (sparse AgentOverride dict). + Optional: ``filename`` (creates / re-links the file when supplied; + used by the modal's first-time naming flow). + """ + agent_type = data.get("agent_type") + override = data.get("override") + filename = data.get("filename") + + agent = AGENTS.get(agent_type) if agent_type else None + if not agent: + await self.signal_operation_failed(f"Agent '{agent_type}' not found") + return + + scene = getattr(agent, "scene", None) + if scene is None: + await self.signal_operation_failed( + f"Agent '{agent_type}' is not connected to a scene" + ) + return + + # Refuse for unsaved scenes — save_dir would collapse to scenes-root. + if not scene.filename or not scene.project_name: + log.warning( + "scene-overrides save refused: scene has no on-disk identity", + agent_type=agent_type, + scene_name=getattr(scene, "name", None), + project_name=getattr(scene, "project_name", None), + filename=getattr(scene, "filename", None), + ) + await self.signal_operation_failed( + "Save the scene first before adding per-scene agent overrides." + ) + return + + # Defense against path traversal — filename arrives over the wire + # and must stay inside ``save_dir``. + if filename is not None and not is_safe_settings_filename(filename): + await self.signal_operation_failed( + f"Invalid agent-settings filename: {filename!r}" + ) + return + + # Resolve target file. If the scene already has an overlay we write + # into it; otherwise the caller MUST supply a filename so we know + # what to create. + if scene.agent_overrides is None: + if not filename: + await self.signal_operation_failed( + "No agent-settings file linked; filename is required" + ) + return + target_path = agent_settings_dir(scene.save_dir) / filename + scene.agent_overrides = SceneAgentSettings(filepath=target_path) + scene.agent_settings_file = filename + scene._agent_settings_opted_out = False + elif filename and filename != scene.agent_overrides.filename: + # Caller wants to switch which file we write to. Move the in-memory + # overlay's filepath; existing file (if any) at the new name is + # treated as a fresh start — we overwrite below. + target_path = agent_settings_dir(scene.save_dir) / filename + scene.agent_overrides = SceneAgentSettings( + filepath=target_path, + agents=scene.agent_overrides.agents, + ) + scene.agent_settings_file = filename + + scene.agent_overrides.replace_agent_overrides(agent_type, override or {}) + try: + await scene.agent_overrides.write_to_file() + except OSError as exc: + log.warning( + "scene-overrides write failed", + agent_type=agent_type, + filepath=str(scene.agent_overrides.filepath), + error=str(exc), + ) + await self.signal_operation_failed( + f"Failed to write agent-settings file: {exc.strerror or exc}" + ) + return + + # Re-emit agent + scene status so the frontend re-renders with the + # newly-resolved values and the updated linked-file display. + await agent.emit_status() + # signal_operation_done triggers a scene auto-save when enabled, which + # persists the `agent_settings_file` reference in the scene JSON so + # the link survives reload. + await self.signal_operation_done() diff --git a/src/talemate/server/api.py b/src/talemate/server/api.py index 6c520765..38f0b307 100644 --- a/src/talemate/server/api.py +++ b/src/talemate/server/api.py @@ -199,8 +199,6 @@ async def websocket_endpoint(websocket): await instance.configure_agents() elif action_type == "request_client_status": await handler.request_client_status() - elif action_type == "delete_message": - handler.delete_message(data.get("id")) elif action_type == "request_scene_assets": log.debug("request_scene_assets", data=data) handler.request_scene_assets(data.get("asset_ids")) @@ -216,9 +214,6 @@ async def websocket_endpoint(websocket): elif action_type == "request_assets": log.info("request_assets") handler.request_assets(data.get("assets")) - elif action_type == "edit_message": - log.info("edit_message", data=data) - handler.edit_message(data.get("id"), data.get("text")) elif action_type == "generation_error_response": request_id = data.get("request_id") action = data.get("action") diff --git a/src/talemate/server/assistant.py b/src/talemate/server/assistant.py index dc989551..79ccb46b 100644 --- a/src/talemate/server/assistant.py +++ b/src/talemate/server/assistant.py @@ -11,7 +11,7 @@ from talemate.emit import emit from talemate.exceptions import GenerationCancelled from talemate.instance import get_agent from talemate.load.character_card import analyze_character_card -from talemate.regenerate import ensure_regenerate_allowed, regenerate +from talemate.regenerate import regenerate, regeneration_status from talemate.server.websocket_plugin import Plugin from talemate.status import set_loading @@ -123,7 +123,7 @@ class AssistantPlugin(Plugin): data = ContentGenerationContext(**data) try: creator = get_agent("creator") - context_type, context_name = data.computed_context + context_type, _ = data.computed_context if context_type == "dialogue": if not data.character: @@ -145,18 +145,17 @@ class AssistantPlugin(Plugin): await creator.autocomplete_narrative(data.partial, emit_signal=True) return - # force length to 35 - data.length = 35 + data.length = creator.autocomplete_contextual_length_for(context_type) log.info("Running autocomplete for contextual generation", args=data) completion = await creator.contextual_generate(data) log.info( "Autocomplete for contextual generation complete", completion=completion ) - completion = ( - completion.replace(f"{context_name}: {data.partial}", "") - .lstrip(".") - .strip() - ) + + # On the coerced path the completion is prefilled with the partial; strip + # it so only the continuation is appended in the UI. + if data.partial and completion.startswith(data.partial): + completion = completion[len(data.partial) :] emit("autocomplete_suggestion", completion) except GenerationCancelled: @@ -175,7 +174,7 @@ class AssistantPlugin(Plugin): Intended for UX-triggered regeneration (e.g., hotbuttons). """ payload = RegeneratePayload(**data) - allowed, err = ensure_regenerate_allowed(self.scene, idx=-1) + allowed, err = regeneration_status(self.scene) if not allowed: emit("status", message=err, status="error") self.websocket_handler.queue_put( @@ -184,7 +183,7 @@ class AssistantPlugin(Plugin): return try: with ClientContext(nuke_repetition=payload.nuke_repetition): - task = asyncio.create_task(regenerate(self.scene, -1)) + task = asyncio.create_task(regenerate(self.scene)) task.add_done_callback( self.create_task_done_callback( "regenerate_done", @@ -208,7 +207,7 @@ class AssistantPlugin(Plugin): `!regenerate_directed` (and without prompting via wait_for_input). """ payload = RegenerateDirectedPayload(**data) - allowed, err = ensure_regenerate_allowed(self.scene, idx=-1) + allowed, err = regeneration_status(self.scene) if not allowed: emit("status", message=err, status="error") self.websocket_handler.queue_put( @@ -226,7 +225,7 @@ class AssistantPlugin(Plugin): with ClientContext( direction=direction, nuke_repetition=payload.nuke_repetition ): - task = asyncio.create_task(regenerate(self.scene, -1)) + task = asyncio.create_task(regenerate(self.scene)) task.add_done_callback( self.create_task_done_callback( "regenerate_done", diff --git a/src/talemate/server/config.py b/src/talemate/server/config.py index 6bc079a3..86dee04a 100644 --- a/src/talemate/server/config.py +++ b/src/talemate/server/config.py @@ -14,7 +14,10 @@ from talemate.client.base import ( locked_model_template, ) from talemate.config import Config as AppConfigData -from talemate.config.schema import GamePlayerCharacter +from talemate.config.schema import ( + AgentActionOverride, + GamePlayerCharacter, +) from talemate.config import get_config, Config, update_config from talemate.emit import emit from talemate.instance import emit_clients_status, get_client @@ -95,6 +98,15 @@ class SystemCapabilitiesPayload(pydantic.BaseModel): torch_cuda: TorchCudaInfo = TorchCudaInfo() +class SetAgentActionOverridePayload(pydantic.BaseModel): + key: str + disable_reasoning: bool = False + + +class ClearAgentActionOverridePayload(pydantic.BaseModel): + key: str + + class ConfigPlugin(Plugin): router = "config" @@ -259,6 +271,15 @@ class ConfigPlugin(Plugin): "reason_response_pattern_default": ( spec.reasoning_pattern or DEFAULT_REASONING_PATTERN ), + "reason_response_pattern_from_template": bool( + spec.reasoning_pattern + ), + "reason_validation_pattern_default": ( + spec.reasoning_validation_pattern + ), + "reason_validation_pattern_from_template": bool( + spec.reasoning_validation_pattern + ), }, } ) @@ -486,6 +507,25 @@ class ConfigPlugin(Plugin): {"type": "app_config", "data": config.model_dump(), "version": VERSION} ) + async def handle_set_agent_action_override(self, data): + payload = SetAgentActionOverridePayload(**data["data"]) + + config: Config = get_config() + config.agent_actions.overrides[payload.key] = AgentActionOverride( + disable_reasoning=payload.disable_reasoning + ) + + await config.set_dirty() + + async def handle_clear_agent_action_override(self, data): + payload = ClearAgentActionOverridePayload(**data["data"]) + + config: Config = get_config() + if config.agent_actions.overrides.pop(payload.key, None) is None: + return + + await config.set_dirty() + async def handle_save_unified_api_key(self, data): """Save a unified API key to app config.""" payload = SaveUnifiedAPIKeyPayload(**data["data"]) diff --git a/src/talemate/server/scene_message.py b/src/talemate/server/scene_message.py new file mode 100644 index 00000000..e15da457 --- /dev/null +++ b/src/talemate/server/scene_message.py @@ -0,0 +1,145 @@ +""" +Websocket plugin for scene-level message edit actions. + +Routes frontend-initiated changes to a SceneMessage: + +- ``edit``: plain user edit — replaces the active version's text in + place. Runs through editor exposition cleanup when configured. +- ``append_version``: pushes a new version onto the revision stack and + makes it the active canonical. Used by the "continue" flow (and any + future flow that wants to grow the stack from the client side). +- ``swap_revision``: moves the active-version pointer to a different + index on the stack. The canonical text follows automatically. +- ``delete``: removes the message from scene history. +""" + +import pydantic +import structlog + +import talemate.instance as instance +from talemate.scene_message import VersionSource +from talemate.server.websocket_plugin import Plugin + +log = structlog.get_logger("talemate.server.scene_message") + +__all__ = ["SceneMessagePlugin"] + + +class EditPayload(pydantic.BaseModel): + id: int + text: str + + +class AppendVersionPayload(pydantic.BaseModel): + id: int + text: str + source: VersionSource = "custom" + reason: str | None = None + + +class SwapRevisionPayload(pydantic.BaseModel): + id: int + index: int + + +class DeletePayload(pydantic.BaseModel): + id: int + + +class SceneMessagePlugin(Plugin): + router = "scene_message" + + async def handle_delete(self, data: dict): + """ + Remove a scene message from history. + """ + payload = DeletePayload(**data) + self.scene.delete_message(payload.id) + await self.scene.attempt_auto_save() + + async def handle_edit(self, data: dict): + """ + Replace a scene message's active-version text with user-edited + text. When the editor agent's exposition cleanup is enabled and + the target is a character message, the text is cleaned up before + being committed. + + On success the per-router ``operation_done`` envelope is + emitted alongside the ``message_edited`` echo. The frontend's + revision UI consumes the echo (not the envelope) for its + spinner/state updates today; the envelope is still posted so + future generic-router consumers stay in sync. + """ + payload = EditPayload(**data) + message = self.scene.get_message(payload.id) + if message is None: + await self.signal_operation_failed(f"Message {payload.id} not found") + return + + new_text = payload.text + editor = instance.get_agent("editor") + + if ( + editor.enabled + and message.typ == "character" + and editor.fix_exposition_enabled + and editor.fix_exposition_user_input + ): + character = self.scene.get_character(message.character_name) + new_text = await editor.cleanup_character_message( + new_text, + character, + strip_partial=not editor.allow_incomplete_sentences, + ) + + self.scene.edit_message(payload.id, new_text) + await self.scene.attempt_auto_save() + await self.signal_operation_done(signal_only=True) + + async def handle_append_version(self, data: dict): + """ + Append a new version onto a scene message's revision stack and + make it the active canonical. Used by client-initiated flows + (today: continue) that want to grow the stack rather than + rewrite the active entry in place. + + Completion is signaled via the ``operation_done`` envelope plus + the authoritative ``message_edited`` echo (see ``handle_edit`` + for the rationale). + """ + payload = AppendVersionPayload(**data) + if self.scene.get_message(payload.id) is None: + await self.signal_operation_failed(f"Message {payload.id} not found") + return + self.scene.append_message_version( + payload.id, + payload.text, + source=payload.source, + reason=payload.reason, + ) + await self.scene.attempt_auto_save() + await self.signal_operation_done(signal_only=True) + + async def handle_swap_revision(self, data: dict): + """ + Set a scene message's active-version index. The canonical text + follows the pointer — no editor cleanup runs, the chosen version + was already a valid prior canonical. + + Completion is signaled via the ``operation_done`` envelope plus + the authoritative ``message_edited`` echo (see ``handle_edit`` + for the rationale). + """ + payload = SwapRevisionPayload(**data) + if self.scene.get_message(payload.id) is None: + await self.signal_operation_failed(f"Message {payload.id} not found") + return + try: + self.scene.set_message_active_version(payload.id, payload.index) + except IndexError: + await self.signal_operation_failed( + f"Revision index {payload.index} out of range" + ) + return + await self.scene.attempt_auto_save() + await self.signal_operation_done(signal_only=True) diff --git a/src/talemate/server/websocket_server.py b/src/talemate/server/websocket_server.py index 648f245b..cedc303e 100644 --- a/src/talemate/server/websocket_server.py +++ b/src/talemate/server/websocket_server.py @@ -17,6 +17,7 @@ import talemate.emit.async_signals as async_signals from talemate.files import list_scenes_directory from talemate.load import load_scene, SceneInitialization from talemate.scene_assets import Asset, get_media_type_from_file_path, VIS_TYPE +from talemate.scene_message import versions_payload_for from talemate.server import ( agent_config, assistant, @@ -24,6 +25,7 @@ from talemate.server import ( config, devtools, quick_settings, + scene_message as scene_message_plugin, time_passage, ux, world_state_manager, @@ -78,6 +80,9 @@ class WebsocketHandler(SceneAssetsBatchingMixin, Receiver): ), ux.UxPlugin.router: ux.UxPlugin(self), time_passage.TimePassagePlugin.router: time_passage.TimePassagePlugin(self), + scene_message_plugin.SceneMessagePlugin.router: scene_message_plugin.SceneMessagePlugin( + self + ), } # unconveniently named function, this `connect` method is called @@ -299,6 +304,7 @@ class WebsocketHandler(SceneAssetsBatchingMixin, Receiver): int(emission.message_object.flags) if emission.message_object else 0 ), "rev": (emission.message_object.rev if emission.message_object else 0), + **versions_payload_for(message_obj), } ) @@ -377,6 +383,7 @@ class WebsocketHandler(SceneAssetsBatchingMixin, Receiver): int(emission.message_object.flags) if emission.message_object else 0 ), "rev": (emission.message_object.rev if emission.message_object else 0), + **versions_payload_for(message_obj), } ) @@ -415,6 +422,7 @@ class WebsocketHandler(SceneAssetsBatchingMixin, Receiver): "asset_id": asset_id, "asset_type": asset_type, "flags": (int(message_obj.flags) if message_obj else 0), + **versions_payload_for(message_obj), } ) @@ -434,12 +442,14 @@ class WebsocketHandler(SceneAssetsBatchingMixin, Receiver): ) def handle_remove_message(self, emission: Emission): - self.queue_put( - { - "type": "remove_message", - "id": emission.id, - } - ) + reason = (emission.data or {}).get("reason") + payload = { + "type": "remove_message", + "id": emission.id, + } + if reason: + payload["reason"] = reason + self.queue_put(payload) def handle_scene_status(self, emission: Emission): self.queue_put( @@ -527,14 +537,19 @@ class WebsocketHandler(SceneAssetsBatchingMixin, Receiver): ) def handle_message_edited(self, emission: Emission): - self.queue_put( - { - "type": "message_edited", - "message": emission.message, - "id": emission.id, - "character": emission.character.name if emission.character else "", - } - ) + # All edit paths (plain edit, version append, active-version swap) + # carry the message's current state — the frontend just mirrors + # `versions` + `active_version` onto its local revision stack + # rather than reconstructing append/replace semantics from a + # per-edit reason. + payload = { + "type": "message_edited", + "message": emission.message, + "id": emission.id, + "character": emission.character.name if emission.character else "", + **versions_payload_for(emission.message_object), + } + self.queue_put(payload) def handle_autocomplete_suggestion(self, emission: Emission): self.queue_put( @@ -772,32 +787,6 @@ class WebsocketHandler(SceneAssetsBatchingMixin, Receiver): } ) - def delete_message(self, message_id): - self.scene.delete_message(message_id) - - def edit_message(self, message_id, new_text): - message = self.scene.get_message(message_id) - - editor = instance.get_agent("editor") - - if ( - editor.enabled - and message.typ == "character" - and editor.fix_exposition_enabled - and editor.fix_exposition_user_input - ): - character = self.scene.get_character(message.character_name) - loop = asyncio.get_event_loop() - new_text = loop.run_until_complete( - editor.cleanup_character_message( - new_text, - character, - strip_partial=not editor.allow_incomplete_sentences, - ) - ) - - self.scene.edit_message(message_id, new_text) - def handle_character_card_upload(self, image_data_url: str, filename: str) -> str: image_data = base64.b64decode(image_data_url.split(",")[1]) characters_path = os.path.join("./scenes", "characters") diff --git a/src/talemate/server/world_state_manager/__init__.py b/src/talemate/server/world_state_manager/__init__.py index d4dec627..ca89d3f3 100644 --- a/src/talemate/server/world_state_manager/__init__.py +++ b/src/talemate/server/world_state_manager/__init__.py @@ -11,6 +11,7 @@ from talemate.game.schema import ConditionGroup from talemate.export import ExportOptions, export from talemate.instance import get_agent from talemate.world_state.manager import WorldStateManager, Suggestion +from talemate.scene.schema import ScenePerspectives from talemate.status import background_task, set_loading import talemate.game.focal as focal from talemate.config import save_config @@ -173,7 +174,7 @@ class SceneOutlinePayload(pydantic.BaseModel): description: str | None = None intro: str | None = None context: str | None = None - perspective: str | None = None + perspectives: ScenePerspectives | None = None class SceneSettingsPayload(pydantic.BaseModel): @@ -183,6 +184,11 @@ class SceneSettingsPayload(pydantic.BaseModel): agent_persona_templates: dict[str, str | None] | None = None visual_style_template: str | None = None restore_from: str | None = None + # Presence-aware via `model_fields_set` — see handler. None = opt out; + # string = link to that file; unset (caller omits the key) = leave the + # link alone. + agent_settings_file: str | None = None + agent_settings_opted_out: bool | None = None class SaveScenePayload(pydantic.BaseModel): @@ -1102,7 +1108,13 @@ class WorldStateManagerPlugin( async def handle_update_scene_outline(self, data): payload = SceneOutlinePayload(**data) - await self.world_state_manager.update_scene_outline(**payload.model_dump()) + await self.world_state_manager.update_scene_outline( + title=payload.title, + description=payload.description, + intro=payload.intro, + context=payload.context, + perspectives=payload.perspectives, + ) self.websocket_handler.queue_put( { @@ -1119,7 +1131,21 @@ class WorldStateManagerPlugin( async def handle_update_scene_settings(self, data): payload = SceneSettingsPayload(**data) - await self.world_state_manager.update_scene_settings(**payload.model_dump()) + # `model_fields_set` distinguishes "caller omitted this field" from + # "caller explicitly sent None" — needed by the agent-settings link + # logic where None means opt-out and absent means "leave it alone". + kwargs = payload.model_dump() + if "agent_settings_file" not in payload.model_fields_set: + kwargs.pop("agent_settings_file", None) + if "agent_settings_opted_out" not in payload.model_fields_set: + kwargs.pop("agent_settings_opted_out", None) + + try: + await self.world_state_manager.update_scene_settings(**kwargs) + except ValueError as exc: + log.warning("update_scene_settings rejected", error=str(exc)) + await self.signal_operation_failed(str(exc)) + return self.websocket_handler.queue_put( { diff --git a/src/talemate/server/world_state_manager/character.py b/src/talemate/server/world_state_manager/character.py index 7d0266b7..b4cc6854 100644 --- a/src/talemate/server/world_state_manager/character.py +++ b/src/talemate/server/world_state_manager/character.py @@ -63,6 +63,13 @@ class RenameCharacterFolderPayload(pydantic.BaseModel): new_name: str +class SetCharacterIsPlayerPayload(pydantic.BaseModel): + """Payload for setting a character's player status.""" + + name: str + is_player: bool + + class CharacterMixin: """Mixin adding websocket handlers for character voice assignment.""" @@ -256,6 +263,39 @@ class CharacterMixin: await self.signal_operation_done() self.scene.emit_status() + async def handle_set_character_is_player(self, data: dict): + """Toggle a character's player status. + + Expected payload + ----------------- + { + "type": "world_state_manager", + "action": "set_character_is_player", + "name": "", + "is_player": true | false + } + """ + try: + payload = SetCharacterIsPlayerPayload(**data) + except pydantic.ValidationError as e: + log.error("Invalid payload for set_character_is_player", error=e) + await self.signal_operation_failed(str(e)) + return + + character = self.scene.get_character(payload.name) + if not character: + await self.signal_operation_failed("Character not found") + return + + await self.world_state_manager.set_character_is_player( + payload.name, payload.is_player + ) + + await self.handle_get_character_list({}) + await self.handle_get_character_details({"name": payload.name}) + await self.signal_operation_done() + self.scene.emit_status() + async def handle_rename_character_folder(self, data: dict): """Bulk-rename a folder across every character currently assigned to it. diff --git a/src/talemate/tale_mate.py b/src/talemate/tale_mate.py index 602116d9..429f58b0 100644 --- a/src/talemate/tale_mate.py +++ b/src/talemate/tale_mate.py @@ -36,6 +36,7 @@ from talemate.scene.episodes import EpisodesManager from talemate.scene_message import ( CharacterMessage, DirectorMessage, + VersionSource, ReinforcementMessage, SceneMessage, TimePassageMessage, @@ -50,16 +51,15 @@ from talemate.game.engine.nodes.core import GraphState from talemate.game.engine.nodes.layout import load_graph from talemate.game.engine.nodes.packaging import initialize_packages from talemate.scene.intent import SceneIntent +from talemate.scene.schema import ScenePerspectives from talemate.history import emit_archive_add, ArchiveEntry from talemate.character import Character -from talemate.game.engine.context_id.character import ( - CharacterContext, - CharacterContextItem, -) from talemate.agents.tts.schema import VoiceLibrary from talemate.instance import get_agent +from talemate.regenerate import regeneration_status from talemate.changelog import InMemoryChangelog from talemate.shared_context import SharedContext +from talemate.scene_agent_settings import SceneAgentSettings __all__ = [ "Character", @@ -141,6 +141,10 @@ class Scene(Emitter): # allowing websocket-triggered environment switches to restart the scene loop. self.restart_scene_loop_requested = False self.shared_context: SharedContext | None = None + # Per-scene agent overrides — see talemate.scene_agent_settings. + self.agent_settings_file: str | None = None + self._agent_settings_opted_out: bool = False + self.agent_overrides: SceneAgentSettings | None = None self.assets = SceneAssets(scene=self) self.voice_library: VoiceLibrary = VoiceLibrary() self.description = "" @@ -176,7 +180,7 @@ class Scene(Emitter): self.immutable_save = False self.context = "" - self.perspective = "" + self.perspectives = ScenePerspectives() self.commands = commands.Manager(self) self.environment = "scene" self.world_state = WorldState() @@ -389,6 +393,47 @@ class Scene(Emitter): def shared_context_dir(self): return os.path.join(self.save_dir, "shared-context") + @property + def agent_settings_link(self) -> str | None: + """Effective linked agent-settings filename, honoring opt-out.""" + if self._agent_settings_opted_out: + return None + return self.agent_overrides.filename if self.agent_overrides else None + + @property + def agent_settings_files(self) -> list[str]: + """Available agent-settings JSON files in this scene's + ``agent-settings/`` subdir. + + Cached by directory mtime — ``scene_status`` (which reads this) fires + often, and ``list_settings_files`` enumerates every .json in the + subdir. Re-reading on every emit is wasteful. + """ + from talemate.scene_agent_settings import ( + agent_settings_dir, + list_settings_files, + ) + + if not self.filename or not self._project_name: + return [] + save_dir = os.path.join(self.scenes_dir(), self.project_name) + settings_dir = agent_settings_dir(save_dir) + try: + mtime = os.stat(settings_dir).st_mtime_ns + except OSError: + # Subdir doesn't exist yet — nothing to list, and nothing to cache + # under (mtime would be undefined). Return empty without poisoning + # the cache so a later first-write is picked up immediately. + return [] + + cache = getattr(self, "_agent_settings_files_cache", None) + if cache is not None and cache[0] == mtime: + return cache[1] + + files = list_settings_files(save_dir) + self._agent_settings_files_cache = (mtime, files) + return files + @property def auto_save(self) -> bool: return self.config.game.general.auto_save @@ -639,6 +684,11 @@ class Scene(Emitter): await self.signals["push_history.after"].send(event) + # History changed — refresh scene status so UI affordances that + # depend on the tail (e.g. the regenerate buttons) stay in sync. + # emit_status is debounced, so bursts of pushes coalesce. + self.emit_status() + def pop_message(self, message: SceneMessage | int) -> bool: """ Removes the last message from the history that matches the given message @@ -648,11 +698,13 @@ class Scene(Emitter): self.history.remove(message) except ValueError: return False + self.emit_status() return True elif isinstance(message, int): message = self.find_message(message) if message: self.history.remove(message) + self.emit_status() return True return False else: @@ -714,6 +766,11 @@ class Scene(Emitter): for message in to_remove: self.history.remove(message) + # History changed — keep tail-dependent UI affordances (e.g. the + # regenerate buttons) in sync. emit_status is debounced. + if to_remove: + self.emit_status() + def find_message(self, typ: str, max_iterations: int = 100, **filters): """ Finds the last message in the history that matches the given typ and source @@ -744,13 +801,15 @@ class Scene(Emitter): return idx return -1 - def get_message(self, message_id: int) -> SceneMessage: + def get_message(self, message_id: int) -> SceneMessage | None: """ - Returns the message in the history with the given id + Returns the message in the history with the given id, or ``None`` + if no message in history matches. """ for idx in range(len(self.history) - 1, -1, -1): if self.history[idx].id == message_id: return self.history[idx] + return None def last_player_message(self) -> str: """ @@ -974,17 +1033,60 @@ class Scene(Emitter): }, ) - def edit_message(self, message_id: int, message: str): - """ - Finds the message in `history` by its id and will update its contents + def edit_message(self, message_id: int, message: str) -> None: """ + Replace a message's canonical text in place. Used for plain user + edits and any other in-place rewrite that should NOT grow a new + revision-stack entry. The active version's text is updated to + match. - for i, _message in enumerate(self.history): - if _message.id == message_id: - self.history[i].message = message - emit("message_edited", self.history[i], id=message_id) - self.log.info("Message edited", message=message, id=message_id) - return + For appending a new version (continue, manual revision, + regenerate, custom mutators), use ``append_message_version``. + For moving the active pointer, use ``set_message_active_version``. + """ + target = self.get_message(message_id) + if target is None: + return + target.message = message + emit("message_edited", target, id=message_id, data=None) + self.log.info("Message edited", message=message, id=message_id) + + def append_message_version( + self, + message_id: int, + text: str, + source: VersionSource, + reason: str | None = None, + ) -> None: + """ + Append a new version to a message's revision stack, make it + active, and emit ``message_edited`` so the frontend mirrors the + new state. + """ + target = self.get_message(message_id) + if target is None: + return + target.append_version(text, source=source, reason=reason) + emit("message_edited", target, id=message_id, data=None) + self.log.info( + "Message version appended", + text=text, + source=source, + reason=reason, + id=message_id, + ) + + def set_message_active_version(self, message_id: int, index: int) -> None: + """ + Move the active-version pointer on a message; the canonical text + follows. Used by the frontend's revision-navigator arrows. + """ + target = self.get_message(message_id) + if target is None: + return + target.set_active_version(index) + emit("message_edited", target, id=message_id, data=None) + self.log.info("Message active version swapped", index=index, id=message_id) async def add_actor(self, actor: Actor, commit_to_memory: bool = True): """ @@ -1165,6 +1267,26 @@ class Scene(Emitter): """ self.description = description + def perspective_for_role(self, role: str | None = None) -> str: + """ + Resolve the narrative perspective for a speaker role and substitute + the `{player_name}` placeholder against the current scene state. + + Returns "" when no perspective is configured for the role (after + falling back to the default) OR when the resolved value references + `{player_name}` but the scene has no explicit player character — a + perspective that depends on a missing anchor would render as broken + prose, so we suppress it instead. Used by content-classification and + dialogue templates. + """ + value = self.perspectives.for_role(role) + if not value or "{player_name}" not in value: + return value + character = self.get_explicit_player_character() + if character is None: + return "" + return value.replace("{player_name}", character.name) + def get_intro(self, intro: str = None) -> str: """ Returns the intro text of the scene @@ -1250,6 +1372,7 @@ class Scene(Emitter): return player_character = self.get_player_character() + can_regenerate, can_regenerate_reason = regeneration_status(self) emit( "scene_status", self.name, @@ -1271,7 +1394,7 @@ class Scene(Emitter): "explicit_player_character": self.player_character_exists, "inactive_characters": list(self.inactive_characters.keys()), "context": self.context, - "perspective": self.perspective, + "perspectives": self.perspectives.model_dump(), "assets": self.assets.dict(), "characters": [actor.character.model_dump() for actor in self.actors], "character_colors": { @@ -1287,6 +1410,8 @@ class Scene(Emitter): "auto_save": self.auto_save, "auto_progress": self.auto_progress, "can_auto_save": self.can_auto_save(), + "can_regenerate": can_regenerate, + "can_regenerate_reason": can_regenerate_reason, "game_state": self.game_state.model_dump(), "agent_state": self.agent_state, "active_pins": [pin.model_dump() for pin in self.active_pins], @@ -1311,6 +1436,9 @@ class Scene(Emitter): "shared_context": self.shared_context.filename if self.shared_context else None, + "agent_settings_file": self.agent_settings_link, + "agent_settings_opted_out": self._agent_settings_opted_out, + "agent_settings_files": self.agent_settings_files, "game_state_watch_paths": self.game_state_watch_paths, }, ) @@ -2026,6 +2154,8 @@ class Scene(Emitter): "shared_context": scene.shared_context.filename if scene.shared_context else None, + "agent_settings_file": scene.agent_settings_link, + "agent_settings_opted_out": scene._agent_settings_opted_out, "game_state_watch_paths": scene.game_state_watch_paths, } @@ -2040,8 +2170,3 @@ class Scene(Emitter): if self.cancel_requested: self.cancel_requested = False raise GenerationCancelled("action cancelled") - - -Character.model_rebuild() -CharacterContextItem.model_rebuild() -CharacterContext.model_rebuild() diff --git a/src/talemate/util/dialogue.py b/src/talemate/util/dialogue.py index d4cc7b4f..c29db12e 100644 --- a/src/talemate/util/dialogue.py +++ b/src/talemate/util/dialogue.py @@ -16,6 +16,7 @@ __all__ = [ "ensure_dialog_line_format", "clean_uneven_markers", "split_anchor_text", + "extract_autocomplete_hint", "separate_dialogue_from_exposition", "parse_tts_markup", "separate_sentences", @@ -25,6 +26,8 @@ __all__ = [ log = structlog.get_logger("talemate.util.dialogue") +AUTOCOMPLETE_HINT_RE = re.compile(r"\s*\{([^{}]+)\}\s*$") + class DialogueChunk(pydantic.BaseModel): text: str @@ -473,6 +476,27 @@ def split_anchor_text(text: str, anchor_length: int = 10) -> tuple[str, str]: return non_anchor, anchor +def extract_autocomplete_hint(text: str) -> tuple[str, str | None]: + """ + Strip a trailing ``{...}`` hint block from autocomplete input. + + Returns ``(cleaned_text, hint)`` where ``hint`` is the raw inner content of + the brace block (whitespace-stripped) or ``None`` if no trailing block + exists. The hint is meant to be passed to the LLM as free-form directional + guidance — not parsed. + """ + if not text: + return text, None + match = AUTOCOMPLETE_HINT_RE.search(text) + if not match: + return text, None + hint = match.group(1).strip() + if not hint: + return text, None + cleaned = text[: match.start()] + return cleaned, hint + + def separate_sentences(text: str) -> str: """ Separates a text into sentences and joins them with double newlines. diff --git a/src/talemate/util/gpu.py b/src/talemate/util/gpu.py new file mode 100644 index 00000000..93476c5a --- /dev/null +++ b/src/talemate/util/gpu.py @@ -0,0 +1,34 @@ +""" +GPU memory helpers. + +Kept framework-agnostic at the import boundary: torch is imported lazily so the +rest of the application keeps working on installs without a CUDA-enabled torch. +""" + +__all__ = ["release_cuda_cache"] + + +def release_cuda_cache() -> bool: + """ + Return reserved-but-unused CUDA memory held by PyTorch's caching allocator + back to the driver. + + PyTorch keeps the high-water-mark from the largest batch it has run (e.g. a + full scene re-embed, or a TTS generation) reserved for reuse; it is only + handed back to the driver on an explicit ``empty_cache()`` or process exit. + This releases the idle portion. Resident model weights are live allocations, + not idle cache, so they are unaffected. + + Returns True if a CUDA cache flush was performed, False otherwise (no torch, + or no CUDA device). + """ + try: + import torch + except ImportError: + return False + + if not torch.cuda.is_available(): + return False + + torch.cuda.empty_cache() + return True diff --git a/src/talemate/util/path.py b/src/talemate/util/path.py index ee9bfb61..d5bfd4d7 100644 --- a/src/talemate/util/path.py +++ b/src/talemate/util/path.py @@ -1,6 +1,28 @@ +from pathlib import Path from typing import Any -__all__ = ["split_state_path", "get_path_parent", "get_path_value"] +__all__ = [ + "split_state_path", + "get_path_parent", + "get_path_value", + "is_safe_relative_filename", +] + + +def is_safe_relative_filename(name: str | None, *, suffix: str | None = None) -> bool: + """True if ``name`` is safe to use as a single filename component. + + Rejects empty / non-str values, path separators (``/`` and ``\\``), NUL + bytes, leading-dot traversal, and (via ``Path(name).name == name``) + absolute paths. When ``suffix`` is given, the filename must end with it. + """ + if not name or not isinstance(name, str): + return False + if suffix is not None and not name.endswith(suffix): + return False + if "/" in name or "\\" in name or "\x00" in name: + return False + return Path(name).name == name and not name.startswith("..") def split_state_path(name: str) -> list[str]: diff --git a/src/talemate/version.py b/src/talemate/version.py index 847d7501..f5508f0b 100644 --- a/src/talemate/version.py +++ b/src/talemate/version.py @@ -1,3 +1,3 @@ __all__ = ["VERSION"] -VERSION = "0.37.1" +VERSION = "0.38.0" diff --git a/src/talemate/world_state/__init__.py b/src/talemate/world_state/__init__.py index 7f0e8481..baa78aa2 100644 --- a/src/talemate/world_state/__init__.py +++ b/src/talemate/world_state/__init__.py @@ -1,5 +1,4 @@ import re -import traceback from enum import Enum from typing import Any, Union @@ -9,22 +8,48 @@ from pydantic import BaseModel, Field import talemate.instance as instance from talemate.emit import emit from talemate.prompts import Prompt -from talemate.exceptions import GenerationCancelled import talemate.game.focal.schema as focal_schema from talemate.game.schema import ConditionGroup +from talemate.world_state.schema import ( + CharacterState, + ObjectState, + PlaceState, +) + +__all__ = [ + "CharacterState", + "ObjectState", + "PlaceState", + "WorldStateResponse", + "WorldState", + "Reinforcement", + "ContextPin", + "ManualContext", + "Suggestion", + "InsertionMode", + "ANY_CHARACTER", +] ANY_CHARACTER = "__any_character__" log = structlog.get_logger("talemate") -class CharacterState(BaseModel): - snapshot: Union[str, None] = None - emotion: Union[str, None] = None +class WorldStateResponse(BaseModel): + """ + Result of WorldStateAgent.request_world_state. + `world_state` is the raw parsed JSON payload from the LLM (or None if + the agent short-circuited because nothing in the scene could be + snapshotted). `anchor_message_ids` is the list of scene history message + ids the snapshot was generated against — every one of them is a valid + inline-highlight target. The list is empty when the snapshot was + generated against the scene intro (intro is not a real message and has + no id, so highlights don't render inline in that case). + """ -class ObjectState(BaseModel): - snapshot: Union[str, None] = None + world_state: Union[dict, None] = None + anchor_message_ids: list[int] = Field(default_factory=list) class InsertionMode(str, Enum): @@ -112,9 +137,16 @@ class WorldState(BaseModel): # objects in the scene by name items: dict[str, ObjectState] = {} + # places referenced in the scene by name (distinct from `location`) + places: dict[str, PlaceState] = {} + # location description location: Union[str, None] = None + # ids of the scene history messages the snapshot was generated against; + # every one of these can render inline entity highlights in the UI. + anchor_message_ids: list[int] = Field(default_factory=list) + # reinforcers reinforce: list[Reinforcement] = [] @@ -190,14 +222,16 @@ class WorldState(BaseModel): def reset(self): """ - Resets the WorldState instance to its initial state by clearing characters, items, and location. + Resets the WorldState instance to its initial state by clearing characters, items, places, location, and anchor. Arguments: - None """ self.characters = {} self.items = {} + self.places = {} self.location = None + self.anchor_message_ids = [] def emit(self, status="update"): """ @@ -210,10 +244,16 @@ class WorldState(BaseModel): async def request_update(self, initial_only: bool = False): """ - Requests an update of the world state from the WorldState agent. If initial_only is true, emits current state without requesting if characters exist. + Public entry point for refreshing the world-state snapshot. + + Owns only the ``initial_only`` short-circuits; the generate-and-merge + policy lives on the world_state agent + (``WorldStateSnapshotMixin.apply_snapshot_update``), which this method + delegates to. Arguments: - - initial_only: A boolean flag to determine if only the initial state should be emitted without requesting a new one. + - initial_only: When true, emit the current state without requesting a + new one if characters already exist or automatic updates are off. """ if initial_only and self.characters: @@ -221,119 +261,11 @@ class WorldState(BaseModel): return # if auto is true, we need to check if agent has automatic update enabled - if initial_only and not self.agent.actions["update_world_state"].enabled: + if initial_only and not self.agent.update_world_state_enabled: self.emit() return - self.emit(status="requested") - - try: - world_state = await self.agent.request_world_state() - except GenerationCancelled: - self.emit() - return - except Exception as e: - self.emit() - log.error( - "world_state.request_update", error=e, traceback=traceback.format_exc() - ) - return - - if world_state is None: - self.emit() - return - - previous_characters = self.characters - scene = self.agent.scene - character_names = scene.character_names - self.characters = {} - self.items = {} - - # if characters is not set or empty, make sure its at least a dict - if not world_state.get("characters"): - world_state["characters"] = {} - - for character_name, character in world_state.get("characters", {}).items(): - character_name = self.normalize_name(character_name) - # if character name is an alias, we need to convert it to the main name - # if it exists in the mappings - - for main_name, synonyms in self.character_name_mappings.items(): - if character_name.lower() in synonyms: - log.debug( - "world_state adjusting character name (via mapping)", - from_name=character_name, - to_name=main_name, - ) - character_name = main_name - break - - # character name may not always come back exactly as we have - # it defined in the scene. We assign the correct name by checking occurences - # of both names in each other. - - if character_name not in character_names: - for _character_name in character_names: - if ( - _character_name.lower() in character_name.lower() - or character_name.lower() in _character_name.lower() - ): - log.debug( - "world_state adjusting character name", - from_name=character_name, - to_name=_character_name, - ) - character_name = _character_name - break - - if not character: - continue - - # if emotion is not set, see if a previous state exists - # and use that emotion - - if "emotion" not in character: - log.debug( - "emotion not set", - character_name=character_name, - character=character, - characters=previous_characters, - ) - if character_name in previous_characters: - character["emotion"] = previous_characters[character_name].emotion - try: - self.characters[character_name] = CharacterState(**character) - except Exception as e: - log.error( - "world_state.request_update", - error=e, - traceback=traceback.format_exc(), - character=character, - ) - - log.debug("world_state", character=character) - - # if items is not set or empty, make sure its at least a dict - if not world_state.get("items"): - world_state["items"] = {} - - for item_name, item in world_state.get("items", {}).items(): - item_name = self.normalize_name(item_name) - if not item: - continue - try: - self.items[item_name] = ObjectState(**item) - except Exception as e: - log.error( - "world_state.request_update", - error=e, - traceback=traceback.format_exc(), - ) - log.debug("world_state", item=item) - - # deactivate persiting for now - # await self.persist() - self.emit() + await self.agent.apply_snapshot_update(self) async def persist(self): """ diff --git a/src/talemate/world_state/manager.py b/src/talemate/world_state/manager.py index c1ce0e0e..7ca2da2f 100644 --- a/src/talemate/world_state/manager.py +++ b/src/talemate/world_state/manager.py @@ -5,7 +5,16 @@ import structlog import talemate.world_state.templates as world_state_templates from talemate.agents.tts.util import get_voice -from talemate.character import activate_character, deactivate_character, set_voice +from talemate.scene_agent_settings import ( + UNCHANGED as AGENT_SETTINGS_UNCHANGED, + apply_scene_settings_link, +) +from talemate.character import ( + activate_character, + deactivate_character, + set_character_is_player, + set_voice, +) from talemate.instance import get_agent from talemate.emit import emit from talemate.world_state import ( @@ -18,6 +27,7 @@ from talemate.game.schema import ConditionGroup, condition_groups_match from talemate.game.engine.context_id.base import ContextIDItem from talemate.agents.tts.schema import Voice from talemate.game.engine.context_id import ContextID +from talemate.scene.schema import ScenePerspectives if TYPE_CHECKING: from talemate.tale_mate import Character, Scene @@ -991,6 +1001,15 @@ class WorldStateManager: """ await deactivate_character(self.scene, character_name) + async def set_character_is_player(self, character_name: str, is_player: bool): + """ + Sets or clears a character's player status. + + See :func:`talemate.character.set_character_is_player` for the full + contract. + """ + await set_character_is_player(self.scene, character_name, is_player) + async def create_character( self, generate: bool = True, @@ -1096,14 +1115,18 @@ class WorldStateManager: description: str | None = None, intro: str | None = None, context: str | None = None, - perspective: str | None = None, + perspectives: ScenePerspectives | None = None, ) -> "Scene": scene = self.scene scene.title = title scene.description = description scene.intro = intro scene.context = context - scene.perspective = perspective or "" + # `perspectives=None` preserves the existing nested object; clearing the + # four perspective fields is rarely what a partial outline update wants, + # and the frontend always sends a complete `ScenePerspectives` payload. + if perspectives is not None: + scene.perspectives = perspectives return scene @@ -1115,6 +1138,7 @@ class WorldStateManager: agent_persona_templates: dict[str, str] | None = None, visual_style_template: str | None = None, restore_from: str | None = None, + **agent_settings_kwargs, ) -> "Scene": scene = self.scene scene.immutable_save = immutable_save @@ -1131,6 +1155,20 @@ class WorldStateManager: scene.restore_from = restore_from + # Caller strips agent_settings_* keys from kwargs when the inbound + # payload didn't set them (see handle_update_scene_settings). Their + # presence here is what distinguishes "explicitly None" from "left + # alone" — the UNCHANGED sentinel substitutes for the latter. + link_filename = agent_settings_kwargs.get( + "agent_settings_file", AGENT_SETTINGS_UNCHANGED + ) + opted_out_clear = agent_settings_kwargs.get("agent_settings_opted_out") is False + await apply_scene_settings_link( + scene, + filename=link_filename, + opted_out_clear=opted_out_clear, + ) + return scene # suggestions diff --git a/src/talemate/world_state/merge.py b/src/talemate/world_state/merge.py new file mode 100644 index 00000000..db3f6138 --- /dev/null +++ b/src/talemate/world_state/merge.py @@ -0,0 +1,147 @@ +""" +Delta merge for world-state snapshots. + +The world-state agent's response is interpreted as a Python-style dict +merge applied on top of the current world state: include only entities or +fields that should change, set an entity's value to ``None`` to drop it, +and omit anything that should remain unchanged. + +Semantics +--------- +For each bucket (characters, items, places): + +- ``patch[key] is None`` → drop the entity. +- ``patch[key]`` is a dict → if the key is already in current state, + shallow-merge the fields into the existing entity (omitted fields + preserved); otherwise construct a fresh entity from the patch. +- Key omitted from the patch → entity is untouched. + +Auto-eviction +------------- +The model can't be trusted to reliably drop entities it has stopped +caring about, so omitted keys also age out automatically. Each entity +carries a ``misses`` counter: touching it (a dict value in the patch) +resets the counter to 0; leaving it out of the patch increments it. Once +an entity has been untouched for ``eviction_threshold`` consecutive +passes it is dropped. This is invisible to the model — it never sees the +counter and is never asked to manage it. ``eviction_threshold <= 0`` +disables aging (counters stay at 0), which is the default for callers +that don't opt in. + +A ``TimePassageMessage`` pushed since the last snapshot's anchors is a +hard scene-cut — callers should wipe the state before showing it to the +LLM so the patch is applied against an empty baseline. + +The ``location`` field (a bare string) is patched inline by the caller +in ``WorldStateSnapshotMixin.apply_snapshot_update`` — it has no bucket of +its own, so it does not flow through ``apply_bucket_patch``. +""" + +import structlog +from typing import Any, Callable, TypeVar + +from talemate.scene_message import SceneMessage, TimePassageMessage +from talemate.world_state.schema import CharacterState, ObjectState, PlaceState + +StateT = TypeVar("StateT", CharacterState, ObjectState, PlaceState) + +log = structlog.get_logger("talemate.world_state.merge") + + +def apply_bucket_patch( + prior: dict[str, StateT], + patch_data: Any, + factory: Callable[..., StateT], + eviction_threshold: int = 0, +) -> dict[str, StateT]: + """ + Apply a delta dict against a prior bucket. Returns a new bucket dict; + inputs are not mutated. + + Malformed entries (non-dict, non-None values; constructor failures) + are logged and skipped — the rest of the patch still applies. + + When ``eviction_threshold > 0``, entities left untouched by the patch + have their ``misses`` counter incremented and are dropped once it + reaches the threshold; touched entities reset to 0. The ``misses`` + field is controlled entirely here — any value supplied in the patch is + ignored. An entry whose patch raised a constructor error counts as + untouched (it wasn't actually updated), so it ages this pass. + """ + result: dict[str, StateT] = dict(prior) + if not isinstance(patch_data, dict): + patch_data = {} + touched: set[str] = set() + for key, value in patch_data.items(): + if value is None: + result.pop(key, None) + continue + if not isinstance(value, dict): + log.warning( + "apply_bucket_patch: skipping non-dict entity payload", + key=key, + value_type=type(value).__name__, + ) + continue + try: + if key in result: + merged_fields = {**result[key].model_dump(), **value} + else: + merged_fields = dict(value) + # ``misses`` is ours to manage — a touched entity is fresh. + merged_fields["misses"] = 0 + result[key] = factory(**merged_fields) + touched.add(key) + except Exception as e: + log.error( + "apply_bucket_patch: failed to apply entry", + key=key, + factory=factory.__name__, + value=value, + error=str(e), + ) + if eviction_threshold > 0: + for key in list(result.keys()): + if key in touched: + continue + misses = result[key].misses + 1 + if misses >= eviction_threshold: + result.pop(key) + else: + result[key] = result[key].model_copy(update={"misses": misses}) + return result + + +def cap_bucket(bucket: dict[str, StateT], max_items: int) -> dict[str, StateT]: + """ + Cap a bucket to at most ``max_items`` entries, dropping the stalest + entries first — those with the highest ``misses`` count (untouched + longest). Ties are broken by insertion order, so among equally-stale + entries the earliest-added is dropped first. Returns a new bucket dict + preserving the original insertion order of the kept entries. + + ``max_items <= 0`` disables the cap and returns the bucket unchanged. + """ + if max_items <= 0 or len(bucket) <= max_items: + return bucket + # Ascending by misses keeps the freshest entries; the stable sort + # preserves insertion order among ties so we keep the earliest-added. + kept = set(sorted(bucket, key=lambda k: bucket[k].misses)[:max_items]) + return {key: value for key, value in bucket.items() if key in kept} + + +def has_time_passage_boundary( + scene_history: list[SceneMessage], prior_anchor_ids: list[int] +) -> bool: + """ + Return True when a TimePassageMessage was pushed to ``scene_history`` + after the highest of ``prior_anchor_ids``. Crossing a TimePassage is a + hard scene-cut and invalidates the entire prior snapshot. + """ + if not prior_anchor_ids: + return False + max_prior = max(prior_anchor_ids) + for message in scene_history: + if isinstance(message, TimePassageMessage) and message.id > max_prior: + return True + return False diff --git a/src/talemate/world_state/schema.py b/src/talemate/world_state/schema.py new file mode 100644 index 00000000..8a62bdd7 --- /dev/null +++ b/src/talemate/world_state/schema.py @@ -0,0 +1,35 @@ +""" +Pydantic schemas for world-state snapshot entities. + +Lives in its own module so peers like ``talemate.world_state.merge`` can +import the state classes without cycling back through +``talemate.world_state.__init__`` (which itself depends on ``merge``). +""" + +from typing import Union + +from pydantic import BaseModel, Field + +# ``misses`` tracks how many consecutive durable-snapshot update passes an +# entity has gone without being touched by the agent's patch. It is internal +# bookkeeping for auto-eviction (see ``talemate.world_state.merge``) — it is +# never shown to the model and the model is never asked to manage it. + + +class CharacterState(BaseModel): + snapshot: Union[str, None] = None + emotion: Union[str, None] = None + mentions: list[str] = Field(default_factory=list) + misses: int = 0 + + +class ObjectState(BaseModel): + snapshot: Union[str, None] = None + mentions: list[str] = Field(default_factory=list) + misses: int = 0 + + +class PlaceState(BaseModel): + snapshot: Union[str, None] = None + mentions: list[str] = Field(default_factory=list) + misses: int = 0 diff --git a/start-frontend.bat b/start-frontend.bat index d25fedf0..9f46503d 100644 --- a/start-frontend.bat +++ b/start-frontend.bat @@ -1,2 +1,3 @@ if "%TALEMATE_FRONTEND_PORT%"=="" set TALEMATE_FRONTEND_PORT=8082 -start cmd /k "cd talemate_frontend && npm run serve -- --host 0.0.0.0 --port %TALEMATE_FRONTEND_PORT%" \ No newline at end of file +set COREPACK_ENABLE_DOWNLOAD_PROMPT=0 +start cmd /k "cd talemate_frontend && corepack pnpm run serve --host 0.0.0.0 --port %TALEMATE_FRONTEND_PORT%" \ No newline at end of file diff --git a/start-frontend.sh b/start-frontend.sh index 6e23b798..fa123b12 100755 --- a/start-frontend.sh +++ b/start-frontend.sh @@ -1,2 +1,9 @@ +#!/bin/bash + +# use the portable Node.js provisioned by install.sh (falls back to system Node) +source install-utils/node-env.sh +activate_embedded_node + cd talemate_frontend -npm run serve -- --host 0.0.0.0 --port "${TALEMATE_FRONTEND_PORT:-8082}" \ No newline at end of file +export COREPACK_ENABLE_DOWNLOAD_PROMPT=0 +corepack pnpm run serve --host 0.0.0.0 --port "${TALEMATE_FRONTEND_PORT:-8082}" \ No newline at end of file diff --git a/talemate_frontend/README.md b/talemate_frontend/README.md index 0467f1cd..5fb0736d 100644 --- a/talemate_frontend/README.md +++ b/talemate_frontend/README.md @@ -1,23 +1,26 @@ # talemate_frontend +This project uses [pnpm](https://pnpm.io/). You do not need to install it +yourself — it is provisioned automatically via +[Corepack](https://nodejs.org/api/corepack.html), which ships with Node.js. The +pinned pnpm version lives in the `packageManager` field of `package.json`. + +Run `corepack enable` once to put `pnpm` on your `PATH`, or prefix the commands +below with `corepack ` (e.g. `corepack pnpm install`). + ## Project setup ``` -npm install +pnpm install ``` ### Compiles and hot-reloads for development ``` -npm run serve +pnpm serve ``` ### Compiles and minifies for production ``` -npm run build -``` - -### Lints and fixes files -``` -npm run lint +pnpm build ``` ### Customize configuration diff --git a/talemate_frontend/package-lock.json b/talemate_frontend/package-lock.json deleted file mode 100644 index 5cd28ce0..00000000 --- a/talemate_frontend/package-lock.json +++ /dev/null @@ -1,2136 +0,0 @@ -{ - "name": "talemate_frontend", - "version": "0.37.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "talemate_frontend", - "version": "0.37.0", - "dependencies": { - "@codemirror/lang-json": "^6.0.1", - "@codemirror/lang-markdown": "^6.2.5", - "@codemirror/lang-yaml": "^6.1.2", - "@codemirror/language-data": "^6.5.1", - "@codemirror/state": "^6.5.2", - "@codemirror/theme-one-dark": "^6.1.2", - "@codemirror/view": "^6.37.1", - "@mdi/font": "7.4.47", - "core-js": "^3.37.1", - "dot-prop": "^9.0.0", - "litegraph.js": "^0.7.18", - "lodash": "^4.17.21", - "marked": "^15.0.12", - "roboto-fontface": "*", - "uuid": "^14.0.0", - "vue": "^3.5", - "vue-codemirror": "^6.1.1", - "vuetify": "^3.12", - "webfontloader": "^1.6.28" - }, - "devDependencies": { - "@vitejs/plugin-vue": "^6.0.0", - "postcss": "^8.5.3", - "vite": "^7.0.4", - "vite-plugin-runtime-env": "^1.0.0", - "vite-plugin-vuetify": "^2.1.1" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", - "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/types": { - "version": "7.28.2", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", - "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@codemirror/autocomplete": { - "version": "6.18.6", - "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.6.tgz", - "integrity": "sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg==", - "license": "MIT", - "dependencies": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.17.0", - "@lezer/common": "^1.0.0" - } - }, - "node_modules/@codemirror/commands": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.8.1.tgz", - "integrity": "sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==", - "license": "MIT", - "dependencies": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.4.0", - "@codemirror/view": "^6.27.0", - "@lezer/common": "^1.1.0" - } - }, - "node_modules/@codemirror/lang-angular": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@codemirror/lang-angular/-/lang-angular-0.1.4.tgz", - "integrity": "sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==", - "license": "MIT", - "dependencies": { - "@codemirror/lang-html": "^6.0.0", - "@codemirror/lang-javascript": "^6.1.2", - "@codemirror/language": "^6.0.0", - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.3.3" - } - }, - "node_modules/@codemirror/lang-cpp": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@codemirror/lang-cpp/-/lang-cpp-6.0.3.tgz", - "integrity": "sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==", - "license": "MIT", - "dependencies": { - "@codemirror/language": "^6.0.0", - "@lezer/cpp": "^1.0.0" - } - }, - "node_modules/@codemirror/lang-css": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", - "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", - "license": "MIT", - "dependencies": { - "@codemirror/autocomplete": "^6.0.0", - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@lezer/common": "^1.0.2", - "@lezer/css": "^1.1.7" - } - }, - "node_modules/@codemirror/lang-go": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@codemirror/lang-go/-/lang-go-6.0.1.tgz", - "integrity": "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==", - "license": "MIT", - "dependencies": { - "@codemirror/autocomplete": "^6.0.0", - "@codemirror/language": "^6.6.0", - "@codemirror/state": "^6.0.0", - "@lezer/common": "^1.0.0", - "@lezer/go": "^1.0.0" - } - }, - "node_modules/@codemirror/lang-html": { - "version": "6.4.9", - "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.9.tgz", - "integrity": "sha512-aQv37pIMSlueybId/2PVSP6NPnmurFDVmZwzc7jszd2KAF8qd4VBbvNYPXWQq90WIARjsdVkPbw29pszmHws3Q==", - "license": "MIT", - "dependencies": { - "@codemirror/autocomplete": "^6.0.0", - "@codemirror/lang-css": "^6.0.0", - "@codemirror/lang-javascript": "^6.0.0", - "@codemirror/language": "^6.4.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.17.0", - "@lezer/common": "^1.0.0", - "@lezer/css": "^1.1.0", - "@lezer/html": "^1.3.0" - } - }, - "node_modules/@codemirror/lang-java": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@codemirror/lang-java/-/lang-java-6.0.2.tgz", - "integrity": "sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==", - "license": "MIT", - "dependencies": { - "@codemirror/language": "^6.0.0", - "@lezer/java": "^1.0.0" - } - }, - "node_modules/@codemirror/lang-javascript": { - "version": "6.2.4", - "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz", - "integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==", - "license": "MIT", - "dependencies": { - "@codemirror/autocomplete": "^6.0.0", - "@codemirror/language": "^6.6.0", - "@codemirror/lint": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.17.0", - "@lezer/common": "^1.0.0", - "@lezer/javascript": "^1.0.0" - } - }, - "node_modules/@codemirror/lang-json": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", - "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", - "license": "MIT", - "dependencies": { - "@codemirror/language": "^6.0.0", - "@lezer/json": "^1.0.0" - } - }, - "node_modules/@codemirror/lang-less": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@codemirror/lang-less/-/lang-less-6.0.2.tgz", - "integrity": "sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==", - "license": "MIT", - "dependencies": { - "@codemirror/lang-css": "^6.2.0", - "@codemirror/language": "^6.0.0", - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" - } - }, - "node_modules/@codemirror/lang-liquid": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/@codemirror/lang-liquid/-/lang-liquid-6.2.3.tgz", - "integrity": "sha512-yeN+nMSrf/lNii3FJxVVEGQwFG0/2eDyH6gNOj+TGCa0hlNO4bhQnoO5ISnd7JOG+7zTEcI/GOoyraisFVY7jQ==", - "license": "MIT", - "dependencies": { - "@codemirror/autocomplete": "^6.0.0", - "@codemirror/lang-html": "^6.0.0", - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", - "@lezer/common": "^1.0.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.3.1" - } - }, - "node_modules/@codemirror/lang-markdown": { - "version": "6.3.4", - "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.3.4.tgz", - "integrity": "sha512-fBm0BO03azXnTAsxhONDYHi/qWSI+uSEIpzKM7h/bkIc9fHnFp9y7KTMXKON0teNT97pFhc1a9DQTtWBYEZ7ug==", - "license": "MIT", - "dependencies": { - "@codemirror/autocomplete": "^6.7.1", - "@codemirror/lang-html": "^6.0.0", - "@codemirror/language": "^6.3.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", - "@lezer/common": "^1.2.1", - "@lezer/markdown": "^1.0.0" - } - }, - "node_modules/@codemirror/lang-php": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@codemirror/lang-php/-/lang-php-6.0.2.tgz", - "integrity": "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==", - "license": "MIT", - "dependencies": { - "@codemirror/lang-html": "^6.0.0", - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@lezer/common": "^1.0.0", - "@lezer/php": "^1.0.0" - } - }, - "node_modules/@codemirror/lang-python": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz", - "integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==", - "license": "MIT", - "dependencies": { - "@codemirror/autocomplete": "^6.3.2", - "@codemirror/language": "^6.8.0", - "@codemirror/state": "^6.0.0", - "@lezer/common": "^1.2.1", - "@lezer/python": "^1.1.4" - } - }, - "node_modules/@codemirror/lang-rust": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@codemirror/lang-rust/-/lang-rust-6.0.2.tgz", - "integrity": "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==", - "license": "MIT", - "dependencies": { - "@codemirror/language": "^6.0.0", - "@lezer/rust": "^1.0.0" - } - }, - "node_modules/@codemirror/lang-sass": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@codemirror/lang-sass/-/lang-sass-6.0.2.tgz", - "integrity": "sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==", - "license": "MIT", - "dependencies": { - "@codemirror/lang-css": "^6.2.0", - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@lezer/common": "^1.0.2", - "@lezer/sass": "^1.0.0" - } - }, - "node_modules/@codemirror/lang-sql": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.9.1.tgz", - "integrity": "sha512-ecSk3gm/mlINcURMcvkCZmXgdzPSq8r/yfCtTB4vgqGGIbBC2IJIAy7GqYTy5pgBEooTVmHP2GZK6Z7h63CDGg==", - "license": "MIT", - "dependencies": { - "@codemirror/autocomplete": "^6.0.0", - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" - } - }, - "node_modules/@codemirror/lang-vue": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@codemirror/lang-vue/-/lang-vue-0.1.3.tgz", - "integrity": "sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==", - "license": "MIT", - "dependencies": { - "@codemirror/lang-html": "^6.0.0", - "@codemirror/lang-javascript": "^6.1.2", - "@codemirror/language": "^6.0.0", - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.3.1" - } - }, - "node_modules/@codemirror/lang-wast": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@codemirror/lang-wast/-/lang-wast-6.0.2.tgz", - "integrity": "sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==", - "license": "MIT", - "dependencies": { - "@codemirror/language": "^6.0.0", - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" - } - }, - "node_modules/@codemirror/lang-xml": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz", - "integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==", - "license": "MIT", - "dependencies": { - "@codemirror/autocomplete": "^6.0.0", - "@codemirror/language": "^6.4.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", - "@lezer/common": "^1.0.0", - "@lezer/xml": "^1.0.0" - } - }, - "node_modules/@codemirror/lang-yaml": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.2.tgz", - "integrity": "sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==", - "license": "MIT", - "dependencies": { - "@codemirror/autocomplete": "^6.0.0", - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.2.0", - "@lezer/lr": "^1.0.0", - "@lezer/yaml": "^1.0.0" - } - }, - "node_modules/@codemirror/language": { - "version": "6.11.2", - "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.2.tgz", - "integrity": "sha512-p44TsNArL4IVXDTbapUmEkAlvWs2CFQbcfc0ymDsis1kH2wh0gcY96AS29c/vp2d0y2Tquk1EDSaawpzilUiAw==", - "license": "MIT", - "dependencies": { - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.23.0", - "@lezer/common": "^1.1.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0", - "style-mod": "^4.0.0" - } - }, - "node_modules/@codemirror/language-data": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/@codemirror/language-data/-/language-data-6.5.1.tgz", - "integrity": "sha512-0sWxeUSNlBr6OmkqybUTImADFUP0M3P0IiSde4nc24bz/6jIYzqYSgkOSLS+CBIoW1vU8Q9KUWXscBXeoMVC9w==", - "license": "MIT", - "dependencies": { - "@codemirror/lang-angular": "^0.1.0", - "@codemirror/lang-cpp": "^6.0.0", - "@codemirror/lang-css": "^6.0.0", - "@codemirror/lang-go": "^6.0.0", - "@codemirror/lang-html": "^6.0.0", - "@codemirror/lang-java": "^6.0.0", - "@codemirror/lang-javascript": "^6.0.0", - "@codemirror/lang-json": "^6.0.0", - "@codemirror/lang-less": "^6.0.0", - "@codemirror/lang-liquid": "^6.0.0", - "@codemirror/lang-markdown": "^6.0.0", - "@codemirror/lang-php": "^6.0.0", - "@codemirror/lang-python": "^6.0.0", - "@codemirror/lang-rust": "^6.0.0", - "@codemirror/lang-sass": "^6.0.0", - "@codemirror/lang-sql": "^6.0.0", - "@codemirror/lang-vue": "^0.1.1", - "@codemirror/lang-wast": "^6.0.0", - "@codemirror/lang-xml": "^6.0.0", - "@codemirror/lang-yaml": "^6.0.0", - "@codemirror/language": "^6.0.0", - "@codemirror/legacy-modes": "^6.4.0" - } - }, - "node_modules/@codemirror/legacy-modes": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.1.tgz", - "integrity": "sha512-DJYQQ00N1/KdESpZV7jg9hafof/iBNp9h7TYo1SLMk86TWl9uDsVdho2dzd81K+v4retmK6mdC7WpuOQDytQqw==", - "license": "MIT", - "dependencies": { - "@codemirror/language": "^6.0.0" - } - }, - "node_modules/@codemirror/lint": { - "version": "6.8.5", - "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.5.tgz", - "integrity": "sha512-s3n3KisH7dx3vsoeGMxsbRAgKe4O1vbrnKBClm99PU0fWxmxsx5rR2PfqQgIt+2MMJBHbiJ5rfIdLYfB9NNvsA==", - "license": "MIT", - "dependencies": { - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.35.0", - "crelt": "^1.0.5" - } - }, - "node_modules/@codemirror/search": { - "version": "6.5.11", - "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz", - "integrity": "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", - "crelt": "^1.0.5" - } - }, - "node_modules/@codemirror/state": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz", - "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", - "license": "MIT", - "dependencies": { - "@marijn/find-cluster-break": "^1.0.0" - } - }, - "node_modules/@codemirror/theme-one-dark": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", - "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", - "license": "MIT", - "dependencies": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", - "@lezer/highlight": "^1.0.0" - } - }, - "node_modules/@codemirror/view": { - "version": "6.38.1", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.1.tgz", - "integrity": "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ==", - "license": "MIT", - "dependencies": { - "@codemirror/state": "^6.5.0", - "crelt": "^1.0.6", - "style-mod": "^4.1.0", - "w3c-keyname": "^2.2.4" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", - "cpu": [ - "mips64el" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "license": "MIT" - }, - "node_modules/@lezer/common": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.3.tgz", - "integrity": "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==", - "license": "MIT" - }, - "node_modules/@lezer/cpp": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@lezer/cpp/-/cpp-1.1.3.tgz", - "integrity": "sha512-ykYvuFQKGsRi6IcE+/hCSGUhb/I4WPjd3ELhEblm2wS2cOznDFzO+ubK2c+ioysOnlZ3EduV+MVQFCPzAIoY3w==", - "license": "MIT", - "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" - } - }, - "node_modules/@lezer/css": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.0.tgz", - "integrity": "sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==", - "license": "MIT", - "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.3.0" - } - }, - "node_modules/@lezer/go": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@lezer/go/-/go-1.0.1.tgz", - "integrity": "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==", - "license": "MIT", - "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.3.0" - } - }, - "node_modules/@lezer/highlight": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz", - "integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==", - "license": "MIT", - "dependencies": { - "@lezer/common": "^1.0.0" - } - }, - "node_modules/@lezer/html": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.10.tgz", - "integrity": "sha512-dqpT8nISx/p9Do3AchvYGV3qYc4/rKr3IBZxlHmpIKam56P47RSHkSF5f13Vu9hebS1jM0HmtJIwLbWz1VIY6w==", - "license": "MIT", - "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" - } - }, - "node_modules/@lezer/java": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.3.tgz", - "integrity": "sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==", - "license": "MIT", - "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" - } - }, - "node_modules/@lezer/javascript": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.1.tgz", - "integrity": "sha512-ATOImjeVJuvgm3JQ/bpo2Tmv55HSScE2MTPnKRMRIPx2cLhHGyX2VnqpHhtIV1tVzIjZDbcWQm+NCTF40ggZVw==", - "license": "MIT", - "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.1.3", - "@lezer/lr": "^1.3.0" - } - }, - "node_modules/@lezer/json": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", - "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", - "license": "MIT", - "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" - } - }, - "node_modules/@lezer/lr": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz", - "integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==", - "license": "MIT", - "dependencies": { - "@lezer/common": "^1.0.0" - } - }, - "node_modules/@lezer/markdown": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.4.3.tgz", - "integrity": "sha512-kfw+2uMrQ/wy/+ONfrH83OkdFNM0ye5Xq96cLlaCy7h5UT9FO54DU4oRoIc0CSBh5NWmWuiIJA7NGLMJbQ+Oxg==", - "license": "MIT", - "dependencies": { - "@lezer/common": "^1.0.0", - "@lezer/highlight": "^1.0.0" - } - }, - "node_modules/@lezer/php": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.4.tgz", - "integrity": "sha512-D2dJ0t8Z28/G1guztRczMFvPDUqzeMLSQbdWQmaiHV7urc8NlEOnjYk9UrZ531OcLiRxD4Ihcbv7AsDpNKDRaQ==", - "license": "MIT", - "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.1.0" - } - }, - "node_modules/@lezer/python": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.18.tgz", - "integrity": "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==", - "license": "MIT", - "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" - } - }, - "node_modules/@lezer/rust": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@lezer/rust/-/rust-1.0.2.tgz", - "integrity": "sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==", - "license": "MIT", - "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" - } - }, - "node_modules/@lezer/sass": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@lezer/sass/-/sass-1.1.0.tgz", - "integrity": "sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==", - "license": "MIT", - "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" - } - }, - "node_modules/@lezer/xml": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz", - "integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==", - "license": "MIT", - "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" - } - }, - "node_modules/@lezer/yaml": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.3.tgz", - "integrity": "sha512-GuBLekbw9jDBDhGur82nuwkxKQ+a3W5H0GfaAthDXcAu+XdpS43VlnxA9E9hllkpSP5ellRDKjLLj7Lu9Wr6xA==", - "license": "MIT", - "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.4.0" - } - }, - "node_modules/@marijn/find-cluster-break": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", - "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", - "license": "MIT" - }, - "node_modules/@mdi/font": { - "version": "7.4.47", - "resolved": "https://registry.npmjs.org/@mdi/font/-/font-7.4.47.tgz", - "integrity": "sha512-43MtGpd585SNzHZPcYowu/84Vz2a2g31TvPMTm9uTiCSWzaheQySUcSyUH/46fPnuPQWof2yd0pGBtzee/IQWw==", - "license": "Apache-2.0" - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.29", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.29.tgz", - "integrity": "sha512-NIJgOsMjbxAXvoGq/X0gD7VPMQ8j9g0BiDaNjVNVjvl+iKXxL3Jre0v31RmBYeLEmkbj2s02v8vFTbUXi5XS2Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@vitejs/plugin-vue": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.1.tgz", - "integrity": "sha512-+MaE752hU0wfPFJEUAIxqw18+20euHHdxVtMvbFcOEpjEyfqXH/5DCoTHiVJ0J29EhTJdoTkjEv5YBKU9dnoTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rolldown/pluginutils": "1.0.0-beta.29" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0", - "vue": "^3.2.25" - } - }, - "node_modules/@vue/compiler-core": { - "version": "3.5.18", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.18.tgz", - "integrity": "sha512-3slwjQrrV1TO8MoXgy3aynDQ7lslj5UqDxuHnrzHtpON5CBinhWjJETciPngpin/T3OuW3tXUf86tEurusnztw==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.28.0", - "@vue/shared": "3.5.18", - "entities": "^4.5.0", - "estree-walker": "^2.0.2", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-dom": { - "version": "3.5.18", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.18.tgz", - "integrity": "sha512-RMbU6NTU70++B1JyVJbNbeFkK+A+Q7y9XKE2EM4NLGm2WFR8x9MbAtWxPPLdm0wUkuZv9trpwfSlL6tjdIa1+A==", - "license": "MIT", - "dependencies": { - "@vue/compiler-core": "3.5.18", - "@vue/shared": "3.5.18" - } - }, - "node_modules/@vue/compiler-sfc": { - "version": "3.5.18", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.18.tgz", - "integrity": "sha512-5aBjvGqsWs+MoxswZPoTB9nSDb3dhd1x30xrrltKujlCxo48j8HGDNj3QPhF4VIS0VQDUrA1xUfp2hEa+FNyXA==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.28.0", - "@vue/compiler-core": "3.5.18", - "@vue/compiler-dom": "3.5.18", - "@vue/compiler-ssr": "3.5.18", - "@vue/shared": "3.5.18", - "estree-walker": "^2.0.2", - "magic-string": "^0.30.17", - "postcss": "^8.5.6", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-ssr": { - "version": "3.5.18", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.18.tgz", - "integrity": "sha512-xM16Ak7rSWHkM3m22NlmcdIM+K4BMyFARAfV9hYFl+SFuRzrZ3uGMNW05kA5pmeMa0X9X963Kgou7ufdbpOP9g==", - "license": "MIT", - "dependencies": { - "@vue/compiler-dom": "3.5.18", - "@vue/shared": "3.5.18" - } - }, - "node_modules/@vue/reactivity": { - "version": "3.5.18", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.18.tgz", - "integrity": "sha512-x0vPO5Imw+3sChLM5Y+B6G1zPjwdOri9e8V21NnTnlEvkxatHEH5B5KEAJcjuzQ7BsjGrKtfzuQ5eQwXh8HXBg==", - "license": "MIT", - "dependencies": { - "@vue/shared": "3.5.18" - } - }, - "node_modules/@vue/runtime-core": { - "version": "3.5.18", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.18.tgz", - "integrity": "sha512-DUpHa1HpeOQEt6+3nheUfqVXRog2kivkXHUhoqJiKR33SO4x+a5uNOMkV487WPerQkL0vUuRvq/7JhRgLW3S+w==", - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.18", - "@vue/shared": "3.5.18" - } - }, - "node_modules/@vue/runtime-dom": { - "version": "3.5.18", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.18.tgz", - "integrity": "sha512-YwDj71iV05j4RnzZnZtGaXwPoUWeRsqinblgVJwR8XTXYZ9D5PbahHQgsbmzUvCWNF6x7siQ89HgnX5eWkr3mw==", - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.18", - "@vue/runtime-core": "3.5.18", - "@vue/shared": "3.5.18", - "csstype": "^3.1.3" - } - }, - "node_modules/@vue/server-renderer": { - "version": "3.5.18", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.18.tgz", - "integrity": "sha512-PvIHLUoWgSbDG7zLHqSqaCoZvHi6NNmfVFOqO+OnwvqMz/tqQr3FuGWS8ufluNddk7ZLBJYMrjcw1c6XzR12mA==", - "license": "MIT", - "dependencies": { - "@vue/compiler-ssr": "3.5.18", - "@vue/shared": "3.5.18" - }, - "peerDependencies": { - "vue": "3.5.18" - } - }, - "node_modules/@vue/shared": { - "version": "3.5.18", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.18.tgz", - "integrity": "sha512-cZy8Dq+uuIXbxCZpuLd2GJdeSO/lIzIspC2WtkqIpje5QyFbvLaI5wZtdUjLHjGZrlVX6GilejatWwVYYRc8tA==", - "license": "MIT" - }, - "node_modules/@vuetify/loader-shared": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@vuetify/loader-shared/-/loader-shared-2.1.1.tgz", - "integrity": "sha512-jSZTzTYaoiv8iwonFCVZQ0YYX/M+Uyl4ng+C4egMJT0Hcmh9gIxJL89qfZICDeo3g0IhqrvipW2FFKKRDMtVcA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "upath": "^2.0.1" - }, - "peerDependencies": { - "vue": "^3.0.0", - "vuetify": "^3.0.0" - } - }, - "node_modules/codemirror": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", - "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@codemirror/autocomplete": "^6.0.0", - "@codemirror/commands": "^6.0.0", - "@codemirror/language": "^6.0.0", - "@codemirror/lint": "^6.0.0", - "@codemirror/search": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0" - } - }, - "node_modules/core-js": { - "version": "3.45.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.45.0.tgz", - "integrity": "sha512-c2KZL9lP4DjkN3hk/an4pWn5b5ZefhRJnAc42n6LJ19kSnbeRbdQZE5dSeE2LBol1OwJD3X1BQvFTAsa8ReeDA==", - "hasInstallScript": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, - "node_modules/crelt": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", - "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", - "license": "MIT" - }, - "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/dot-prop": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", - "integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==", - "license": "MIT", - "dependencies": { - "type-fest": "^4.18.2" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", - "devOptional": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" - } - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "license": "MIT" - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/litegraph.js": { - "version": "0.7.18", - "resolved": "https://registry.npmjs.org/litegraph.js/-/litegraph.js-0.7.18.tgz", - "integrity": "sha512-1WEwjOO58j4FcLX8DvsuMXM371MEq4Y+8pBr3q2pBhJ9nDkwBtBd9Gj6bxArBKhW6i42bSOyv9ybeuez6NAxoQ==", - "license": "MIT" - }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "license": "MIT" - }, - "node_modules/magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" - } - }, - "node_modules/marked": { - "version": "15.0.12", - "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", - "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", - "license": "MIT", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.10", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", - "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/roboto-fontface": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/roboto-fontface/-/roboto-fontface-0.10.0.tgz", - "integrity": "sha512-OlwfYEgA2RdboZohpldlvJ1xngOins5d7ejqnIBWr9KaMxsnBqotpptRXTyfNRLnFpqzX6sTDt+X+a+6udnU8g==", - "license": "Apache-2.0" - }, - "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/style-mod": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz", - "integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==", - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/upath": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz", - "integrity": "sha512-1uEe95xksV1O0CYKXo8vQvN1JEbtJp7lb7C5U9HMsIp6IVwntkH/oNUzyVNQSd4S1sYk2FpSSW44FqMc8qee5w==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=4", - "yarn": "*" - } - }, - "node_modules/uuid": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", - "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist-node/bin/uuid" - } - }, - "node_modules/vite": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", - "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite-plugin-runtime-env": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/vite-plugin-runtime-env/-/vite-plugin-runtime-env-1.0.0.tgz", - "integrity": "sha512-0fV4NGeIubM632bQLQ4WijAyfFrrWw2zbar8b/HUGj6hNmKXQ6HUdWAcBPGILInOIgY3qm5hSOhWDJEvhFF64g==", - "dev": true, - "license": "MIT", - "dependencies": { - "magic-string": "^0.30.0" - }, - "peerDependencies": { - "vite": "*" - } - }, - "node_modules/vite-plugin-vuetify": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/vite-plugin-vuetify/-/vite-plugin-vuetify-2.1.2.tgz", - "integrity": "sha512-I/wd6QS+DO6lHmuGoi1UTyvvBTQ2KDzQZ9oowJQEJ6OcjWfJnscYXx2ptm6S7fJSASuZT8jGRBL3LV4oS3LpaA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@vuetify/loader-shared": "^2.1.1", - "debug": "^4.3.3", - "upath": "^2.0.1" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "peerDependencies": { - "vite": ">=5", - "vue": "^3.0.0", - "vuetify": "^3.0.0" - } - }, - "node_modules/vue": { - "version": "3.5.18", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.18.tgz", - "integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==", - "license": "MIT", - "dependencies": { - "@vue/compiler-dom": "3.5.18", - "@vue/compiler-sfc": "3.5.18", - "@vue/runtime-dom": "3.5.18", - "@vue/server-renderer": "3.5.18", - "@vue/shared": "3.5.18" - }, - "peerDependencies": { - "typescript": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/vue-codemirror": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/vue-codemirror/-/vue-codemirror-6.1.1.tgz", - "integrity": "sha512-rTAYo44owd282yVxKtJtnOi7ERAcXTeviwoPXjIc6K/IQYUsoDkzPvw/JDFtSP6T7Cz/2g3EHaEyeyaQCKoDMg==", - "license": "MIT", - "dependencies": { - "@codemirror/commands": "6.x", - "@codemirror/language": "6.x", - "@codemirror/state": "6.x", - "@codemirror/view": "6.x" - }, - "peerDependencies": { - "codemirror": "6.x", - "vue": "3.x" - } - }, - "node_modules/vuetify": { - "version": "3.12.5", - "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.12.5.tgz", - "integrity": "sha512-Gk3eDIiBXpv+QjMTYmXXk/uvpqaSNAgprcV/Og0btBUx4saLN7AUmqUi34hRmBqu2tpype2GVvg2yxJUqP2mdg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/johnleider" - }, - "peerDependencies": { - "typescript": ">=4.7", - "vite-plugin-vuetify": ">=2.1.0", - "vue": "^3.5.0", - "webpack-plugin-vuetify": ">=3.1.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - }, - "vite-plugin-vuetify": { - "optional": true - }, - "webpack-plugin-vuetify": { - "optional": true - } - } - }, - "node_modules/w3c-keyname": { - "version": "2.2.8", - "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", - "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", - "license": "MIT" - }, - "node_modules/webfontloader": { - "version": "1.6.28", - "resolved": "https://registry.npmjs.org/webfontloader/-/webfontloader-1.6.28.tgz", - "integrity": "sha512-Egb0oFEga6f+nSgasH3E0M405Pzn6y3/9tOVanv/DLfa1YBIgcv90L18YyWnvXkRbIM17v5Kv6IT2N6g1x5tvQ==", - "license": "Apache-2.0" - } - } -} diff --git a/talemate_frontend/package.json b/talemate_frontend/package.json index 54bd5342..7a6e7aa5 100644 --- a/talemate_frontend/package.json +++ b/talemate_frontend/package.json @@ -1,7 +1,8 @@ { "name": "talemate_frontend", - "version": "0.37.1", + "version": "0.38.0", "private": true, + "packageManager": "pnpm@11.4.0+sha512.f0febc7e37552ab485494a914241b338e0b3580b93d54ce31f00933015880863129038a1b4ae4e414a0ee63ac35bf21197e990172c4a68256450b5636310968f", "type": "module", "scripts": { "serve": "vite", @@ -12,6 +13,7 @@ "@codemirror/lang-markdown": "^6.2.5", "@codemirror/lang-yaml": "^6.1.2", "@codemirror/language-data": "^6.5.1", + "@codemirror/lint": "^6.9.6", "@codemirror/state": "^6.5.2", "@codemirror/theme-one-dark": "^6.1.2", "@codemirror/view": "^6.37.1", @@ -31,7 +33,7 @@ "devDependencies": { "@vitejs/plugin-vue": "^6.0.0", "postcss": "^8.5.3", - "vite": "^7.0.4", + "vite": "^7.3.5", "vite-plugin-runtime-env": "^1.0.0", "vite-plugin-vuetify": "^2.1.1" }, diff --git a/talemate_frontend/pnpm-lock.yaml b/talemate_frontend/pnpm-lock.yaml new file mode 100644 index 00000000..3f432314 --- /dev/null +++ b/talemate_frontend/pnpm-lock.yaml @@ -0,0 +1,1620 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + esbuild: '>=0.28.1' + +importers: + + .: + dependencies: + '@codemirror/lang-json': + specifier: ^6.0.1 + version: 6.0.2 + '@codemirror/lang-markdown': + specifier: ^6.2.5 + version: 6.5.0 + '@codemirror/lang-yaml': + specifier: ^6.1.2 + version: 6.1.3 + '@codemirror/language-data': + specifier: ^6.5.1 + version: 6.5.2 + '@codemirror/lint': + specifier: ^6.9.6 + version: 6.9.6 + '@codemirror/state': + specifier: ^6.5.2 + version: 6.6.0 + '@codemirror/theme-one-dark': + specifier: ^6.1.2 + version: 6.1.3 + '@codemirror/view': + specifier: ^6.37.1 + version: 6.42.1 + '@mdi/font': + specifier: 7.4.47 + version: 7.4.47 + core-js: + specifier: ^3.37.1 + version: 3.49.0 + dot-prop: + specifier: ^9.0.0 + version: 9.0.0 + litegraph.js: + specifier: ^0.7.18 + version: 0.7.18 + lodash: + specifier: ^4.17.21 + version: 4.18.1 + marked: + specifier: ^15.0.12 + version: 15.0.12 + roboto-fontface: + specifier: '*' + version: 0.10.0 + uuid: + specifier: ^14.0.0 + version: 14.0.0 + vue: + specifier: ^3.5 + version: 3.5.34 + vue-codemirror: + specifier: ^6.1.1 + version: 6.1.1(codemirror@6.0.2)(vue@3.5.34) + vuetify: + specifier: ^3.12 + version: 3.12.6(vite-plugin-vuetify@2.1.3)(vue@3.5.34) + webfontloader: + specifier: ^1.6.28 + version: 1.6.28 + devDependencies: + '@vitejs/plugin-vue': + specifier: ^6.0.0 + version: 6.0.6(vite@7.3.5)(vue@3.5.34) + postcss: + specifier: ^8.5.3 + version: 8.5.14 + vite: + specifier: ^7.3.5 + version: 7.3.5 + vite-plugin-runtime-env: + specifier: ^1.0.0 + version: 1.0.0(vite@7.3.5) + vite-plugin-vuetify: + specifier: ^2.1.1 + version: 2.1.3(vite@7.3.5)(vue@3.5.34)(vuetify@3.12.6) + +packages: + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@codemirror/autocomplete@6.20.2': + resolution: {integrity: sha512-G5FPkgIiLjOgZMjqVjvuKQ1rGPtHogLldJr33eFJdVLtmwY+giGrlv/ewljLz6b9BSQLkjxuwBc6g6omDM+YxQ==} + + '@codemirror/autocomplete@6.20.3': + resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==} + + '@codemirror/commands@6.10.3': + resolution: {integrity: sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==} + + '@codemirror/lang-angular@0.1.4': + resolution: {integrity: sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==} + + '@codemirror/lang-cpp@6.0.3': + resolution: {integrity: sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==} + + '@codemirror/lang-css@6.3.1': + resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==} + + '@codemirror/lang-go@6.0.1': + resolution: {integrity: sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==} + + '@codemirror/lang-html@6.4.11': + resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==} + + '@codemirror/lang-java@6.0.2': + resolution: {integrity: sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==} + + '@codemirror/lang-javascript@6.2.5': + resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==} + + '@codemirror/lang-jinja@6.0.1': + resolution: {integrity: sha512-P5kyHLObzjtbGj16h+hyvZTxJhSjBEeSx4wMjbnAf3b0uwTy2+F0zGjMZL4PQOm/mh2eGZ5xUDVZXgwP783Nsw==} + + '@codemirror/lang-json@6.0.2': + resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==} + + '@codemirror/lang-less@6.0.2': + resolution: {integrity: sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==} + + '@codemirror/lang-liquid@6.3.2': + resolution: {integrity: sha512-6PDVU3ZnfeYyz1at1E/ttorErZvZFXXt1OPhtfe1EZJ2V2iDFa0CwPqPgG5F7NXN0yONGoBogKmFAafKTqlwIw==} + + '@codemirror/lang-markdown@6.5.0': + resolution: {integrity: sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==} + + '@codemirror/lang-php@6.0.2': + resolution: {integrity: sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==} + + '@codemirror/lang-python@6.2.1': + resolution: {integrity: sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==} + + '@codemirror/lang-rust@6.0.2': + resolution: {integrity: sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==} + + '@codemirror/lang-sass@6.0.2': + resolution: {integrity: sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==} + + '@codemirror/lang-sql@6.10.0': + resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==} + + '@codemirror/lang-vue@0.1.3': + resolution: {integrity: sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==} + + '@codemirror/lang-wast@6.0.2': + resolution: {integrity: sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==} + + '@codemirror/lang-xml@6.1.0': + resolution: {integrity: sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==} + + '@codemirror/lang-yaml@6.1.3': + resolution: {integrity: sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==} + + '@codemirror/language-data@6.5.2': + resolution: {integrity: sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg==} + + '@codemirror/language@6.12.3': + resolution: {integrity: sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==} + + '@codemirror/legacy-modes@6.5.2': + resolution: {integrity: sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==} + + '@codemirror/lint@6.9.6': + resolution: {integrity: sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A==} + + '@codemirror/lint@6.9.7': + resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==} + + '@codemirror/search@6.7.1': + resolution: {integrity: sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==} + + '@codemirror/state@6.6.0': + resolution: {integrity: sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==} + + '@codemirror/theme-one-dark@6.1.3': + resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==} + + '@codemirror/view@6.42.1': + resolution: {integrity: sha512-ToN3oFc0nsxNUYVF5P0ztLgbC4UPPjPtA9aKYhkOKQaZASpOUo6ISXyQLP66ctVwlDc+j6Jv0uK5IFALkiXztg==} + + '@codemirror/view@6.43.1': + resolution: {integrity: sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw==} + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@lezer/common@1.5.2': + resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==} + + '@lezer/cpp@1.1.5': + resolution: {integrity: sha512-DIhSXmYtJKLehrjzDFN+2cPt547ySQ41nA8yqcDf/GxMc+YM736xqltFkvADL2M0VebU5I+3+4ks2Vv+Kyq3Aw==} + + '@lezer/css@1.3.3': + resolution: {integrity: sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==} + + '@lezer/go@1.0.1': + resolution: {integrity: sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==} + + '@lezer/highlight@1.2.3': + resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + + '@lezer/html@1.3.13': + resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==} + + '@lezer/java@1.1.3': + resolution: {integrity: sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==} + + '@lezer/javascript@1.5.4': + resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==} + + '@lezer/json@1.0.3': + resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==} + + '@lezer/lr@1.4.10': + resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==} + + '@lezer/markdown@1.6.3': + resolution: {integrity: sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==} + + '@lezer/php@1.0.5': + resolution: {integrity: sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==} + + '@lezer/python@1.1.18': + resolution: {integrity: sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==} + + '@lezer/rust@1.0.2': + resolution: {integrity: sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==} + + '@lezer/sass@1.1.0': + resolution: {integrity: sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==} + + '@lezer/xml@1.0.6': + resolution: {integrity: sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==} + + '@lezer/yaml@1.0.4': + resolution: {integrity: sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==} + + '@marijn/find-cluster-break@1.0.2': + resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} + + '@mdi/font@7.4.47': + resolution: {integrity: sha512-43MtGpd585SNzHZPcYowu/84Vz2a2g31TvPMTm9uTiCSWzaheQySUcSyUH/46fPnuPQWof2yd0pGBtzee/IQWw==} + + '@rolldown/pluginutils@1.0.0-rc.13': + resolution: {integrity: sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==} + + '@rollup/rollup-android-arm-eabi@4.60.3': + resolution: {integrity: sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.3': + resolution: {integrity: sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.3': + resolution: {integrity: sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.3': + resolution: {integrity: sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.3': + resolution: {integrity: sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.3': + resolution: {integrity: sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.3': + resolution: {integrity: sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.3': + resolution: {integrity: sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.3': + resolution: {integrity: sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.3': + resolution: {integrity: sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.3': + resolution: {integrity: sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.3': + resolution: {integrity: sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.3': + resolution: {integrity: sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.3': + resolution: {integrity: sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.3': + resolution: {integrity: sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.3': + resolution: {integrity: sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.3': + resolution: {integrity: sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.3': + resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.3': + resolution: {integrity: sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.3': + resolution: {integrity: sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.3': + resolution: {integrity: sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.3': + resolution: {integrity: sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.3': + resolution: {integrity: sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.3': + resolution: {integrity: sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.3': + resolution: {integrity: sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==} + cpu: [x64] + os: [win32] + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@vitejs/plugin-vue@6.0.6': + resolution: {integrity: sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + vue: ^3.2.25 + + '@vue/compiler-core@3.5.34': + resolution: {integrity: sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==} + + '@vue/compiler-dom@3.5.34': + resolution: {integrity: sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==} + + '@vue/compiler-sfc@3.5.34': + resolution: {integrity: sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==} + + '@vue/compiler-ssr@3.5.34': + resolution: {integrity: sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==} + + '@vue/reactivity@3.5.34': + resolution: {integrity: sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==} + + '@vue/runtime-core@3.5.34': + resolution: {integrity: sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==} + + '@vue/runtime-dom@3.5.34': + resolution: {integrity: sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==} + + '@vue/server-renderer@3.5.34': + resolution: {integrity: sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==} + peerDependencies: + vue: 3.5.34 + + '@vue/shared@3.5.34': + resolution: {integrity: sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==} + + '@vuetify/loader-shared@2.1.2': + resolution: {integrity: sha512-X+1jBLmXHkpQEnC0vyOb4rtX2QSkBiFhaFXz8yhQqN2A4vQ6k2nChxN4Ol7VAY5KoqMdFoRMnmNdp/1qYXDQig==} + peerDependencies: + vue: ^3.0.0 + vuetify: '>=3' + + codemirror@6.0.2: + resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} + + core-js@3.49.0: + resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} + + crelt@1.0.6: + resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + dot-prop@9.0.0: + resolution: {integrity: sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==} + engines: {node: '>=18'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + litegraph.js@0.7.18: + resolution: {integrity: sha512-1WEwjOO58j4FcLX8DvsuMXM371MEq4Y+8pBr3q2pBhJ9nDkwBtBd9Gj6bxArBKhW6i42bSOyv9ybeuez6NAxoQ==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + marked@15.0.12: + resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} + engines: {node: '>= 18'} + hasBin: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + engines: {node: ^10 || ^12 || >=14} + + roboto-fontface@0.10.0: + resolution: {integrity: sha512-OlwfYEgA2RdboZohpldlvJ1xngOins5d7ejqnIBWr9KaMxsnBqotpptRXTyfNRLnFpqzX6sTDt+X+a+6udnU8g==} + + rollup@4.60.3: + resolution: {integrity: sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + style-mod@4.1.3: + resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + upath@2.0.1: + resolution: {integrity: sha512-1uEe95xksV1O0CYKXo8vQvN1JEbtJp7lb7C5U9HMsIp6IVwntkH/oNUzyVNQSd4S1sYk2FpSSW44FqMc8qee5w==} + engines: {node: '>=4'} + + uuid@14.0.0: + resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} + hasBin: true + + vite-plugin-runtime-env@1.0.0: + resolution: {integrity: sha512-0fV4NGeIubM632bQLQ4WijAyfFrrWw2zbar8b/HUGj6hNmKXQ6HUdWAcBPGILInOIgY3qm5hSOhWDJEvhFF64g==} + peerDependencies: + vite: '*' + + vite-plugin-vuetify@2.1.3: + resolution: {integrity: sha512-Q4SC/4TqbNvaZIFb9YsfBqkGlYHbJJJ6uU3CnRBZqLUF3s5eCMVZAaV4GkTbehIH/bhSj42lMXztOwc71u6rVw==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: '>=5' + vue: ^3.0.0 + vuetify: '>=3' + + vite@7.3.5: + resolution: {integrity: sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vue-codemirror@6.1.1: + resolution: {integrity: sha512-rTAYo44owd282yVxKtJtnOi7ERAcXTeviwoPXjIc6K/IQYUsoDkzPvw/JDFtSP6T7Cz/2g3EHaEyeyaQCKoDMg==} + peerDependencies: + codemirror: 6.x + vue: 3.x + + vue@3.5.34: + resolution: {integrity: sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + vuetify@3.12.6: + resolution: {integrity: sha512-ARfflEJu+pKk+vya059qsUT/xUnYf0K6sIZdJwVh5w+kR6CJCXMQN1MONABb3Jm2kmhN/AXB4W5/MjYiKnVAFg==} + peerDependencies: + typescript: '>=4.7' + vite-plugin-vuetify: '>=2.1.0' + vue: ^3.5.0 + webpack-plugin-vuetify: '>=3.1.0' + peerDependenciesMeta: + typescript: + optional: true + vite-plugin-vuetify: + optional: true + webpack-plugin-vuetify: + optional: true + + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + + webfontloader@1.6.28: + resolution: {integrity: sha512-Egb0oFEga6f+nSgasH3E0M405Pzn6y3/9tOVanv/DLfa1YBIgcv90L18YyWnvXkRbIM17v5Kv6IT2N6g1x5tvQ==} + +snapshots: + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@codemirror/autocomplete@6.20.2': + dependencies: + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.42.1 + '@lezer/common': 1.5.2 + + '@codemirror/autocomplete@6.20.3': + dependencies: + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.1 + '@lezer/common': 1.5.2 + + '@codemirror/commands@6.10.3': + dependencies: + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.42.1 + '@lezer/common': 1.5.2 + + '@codemirror/lang-angular@0.1.4': + dependencies: + '@codemirror/lang-html': 6.4.11 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/language': 6.12.3 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-cpp@6.0.3': + dependencies: + '@codemirror/language': 6.12.3 + '@lezer/cpp': 1.1.5 + + '@codemirror/lang-css@6.3.1': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.2 + '@lezer/css': 1.3.3 + + '@codemirror/lang-go@6.0.1': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.2 + '@lezer/go': 1.0.1 + + '@codemirror/lang-html@6.4.11': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/lang-css': 6.3.1 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.42.1 + '@lezer/common': 1.5.2 + '@lezer/css': 1.3.3 + '@lezer/html': 1.3.13 + + '@codemirror/lang-java@6.0.2': + dependencies: + '@codemirror/language': 6.12.3 + '@lezer/java': 1.1.3 + + '@codemirror/lang-javascript@6.2.5': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/language': 6.12.3 + '@codemirror/lint': 6.9.6 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.42.1 + '@lezer/common': 1.5.2 + '@lezer/javascript': 1.5.4 + + '@codemirror/lang-jinja@6.0.1': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.42.1 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-json@6.0.2': + dependencies: + '@codemirror/language': 6.12.3 + '@lezer/json': 1.0.3 + + '@codemirror/lang-less@6.0.2': + dependencies: + '@codemirror/lang-css': 6.3.1 + '@codemirror/language': 6.12.3 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-liquid@6.3.2': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.42.1 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-markdown@6.5.0': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.42.1 + '@lezer/common': 1.5.2 + '@lezer/markdown': 1.6.3 + + '@codemirror/lang-php@6.0.2': + dependencies: + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.2 + '@lezer/php': 1.0.5 + + '@codemirror/lang-python@6.2.1': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.2 + '@lezer/python': 1.1.18 + + '@codemirror/lang-rust@6.0.2': + dependencies: + '@codemirror/language': 6.12.3 + '@lezer/rust': 1.0.2 + + '@codemirror/lang-sass@6.0.2': + dependencies: + '@codemirror/lang-css': 6.3.1 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.2 + '@lezer/sass': 1.1.0 + + '@codemirror/lang-sql@6.10.0': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-vue@0.1.3': + dependencies: + '@codemirror/lang-html': 6.4.11 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/language': 6.12.3 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-wast@6.0.2': + dependencies: + '@codemirror/language': 6.12.3 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-xml@6.1.0': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.42.1 + '@lezer/common': 1.5.2 + '@lezer/xml': 1.0.6 + + '@codemirror/lang-yaml@6.1.3': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + '@lezer/yaml': 1.0.4 + + '@codemirror/language-data@6.5.2': + dependencies: + '@codemirror/lang-angular': 0.1.4 + '@codemirror/lang-cpp': 6.0.3 + '@codemirror/lang-css': 6.3.1 + '@codemirror/lang-go': 6.0.1 + '@codemirror/lang-html': 6.4.11 + '@codemirror/lang-java': 6.0.2 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/lang-jinja': 6.0.1 + '@codemirror/lang-json': 6.0.2 + '@codemirror/lang-less': 6.0.2 + '@codemirror/lang-liquid': 6.3.2 + '@codemirror/lang-markdown': 6.5.0 + '@codemirror/lang-php': 6.0.2 + '@codemirror/lang-python': 6.2.1 + '@codemirror/lang-rust': 6.0.2 + '@codemirror/lang-sass': 6.0.2 + '@codemirror/lang-sql': 6.10.0 + '@codemirror/lang-vue': 0.1.3 + '@codemirror/lang-wast': 6.0.2 + '@codemirror/lang-xml': 6.1.0 + '@codemirror/lang-yaml': 6.1.3 + '@codemirror/language': 6.12.3 + '@codemirror/legacy-modes': 6.5.2 + + '@codemirror/language@6.12.3': + dependencies: + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.42.1 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + style-mod: 4.1.3 + + '@codemirror/legacy-modes@6.5.2': + dependencies: + '@codemirror/language': 6.12.3 + + '@codemirror/lint@6.9.6': + dependencies: + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.42.1 + crelt: 1.0.6 + + '@codemirror/lint@6.9.7': + dependencies: + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.1 + crelt: 1.0.6 + + '@codemirror/search@6.7.1': + dependencies: + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.1 + crelt: 1.0.6 + + '@codemirror/state@6.6.0': + dependencies: + '@marijn/find-cluster-break': 1.0.2 + + '@codemirror/theme-one-dark@6.1.3': + dependencies: + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.42.1 + '@lezer/highlight': 1.2.3 + + '@codemirror/view@6.42.1': + dependencies: + '@codemirror/state': 6.6.0 + crelt: 1.0.6 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + + '@codemirror/view@6.43.1': + dependencies: + '@codemirror/state': 6.6.0 + crelt: 1.0.6 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@lezer/common@1.5.2': {} + + '@lezer/cpp@1.1.5': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/css@1.3.3': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/go@1.0.1': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/highlight@1.2.3': + dependencies: + '@lezer/common': 1.5.2 + + '@lezer/html@1.3.13': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/java@1.1.3': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/javascript@1.5.4': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/json@1.0.3': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/lr@1.4.10': + dependencies: + '@lezer/common': 1.5.2 + + '@lezer/markdown@1.6.3': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + + '@lezer/php@1.0.5': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/python@1.1.18': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/rust@1.0.2': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/sass@1.1.0': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/xml@1.0.6': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/yaml@1.0.4': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@marijn/find-cluster-break@1.0.2': {} + + '@mdi/font@7.4.47': {} + + '@rolldown/pluginutils@1.0.0-rc.13': {} + + '@rollup/rollup-android-arm-eabi@4.60.3': + optional: true + + '@rollup/rollup-android-arm64@4.60.3': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.3': + optional: true + + '@rollup/rollup-darwin-x64@4.60.3': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.3': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.3': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.3': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.3': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.3': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.3': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.3': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.3': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.3': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.3': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.3': + optional: true + + '@types/estree@1.0.8': {} + + '@vitejs/plugin-vue@6.0.6(vite@7.3.5)(vue@3.5.34)': + dependencies: + '@rolldown/pluginutils': 1.0.0-rc.13 + vite: 7.3.5 + vue: 3.5.34 + + '@vue/compiler-core@3.5.34': + dependencies: + '@babel/parser': 7.29.3 + '@vue/shared': 3.5.34 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.34': + dependencies: + '@vue/compiler-core': 3.5.34 + '@vue/shared': 3.5.34 + + '@vue/compiler-sfc@3.5.34': + dependencies: + '@babel/parser': 7.29.3 + '@vue/compiler-core': 3.5.34 + '@vue/compiler-dom': 3.5.34 + '@vue/compiler-ssr': 3.5.34 + '@vue/shared': 3.5.34 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.14 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.34': + dependencies: + '@vue/compiler-dom': 3.5.34 + '@vue/shared': 3.5.34 + + '@vue/reactivity@3.5.34': + dependencies: + '@vue/shared': 3.5.34 + + '@vue/runtime-core@3.5.34': + dependencies: + '@vue/reactivity': 3.5.34 + '@vue/shared': 3.5.34 + + '@vue/runtime-dom@3.5.34': + dependencies: + '@vue/reactivity': 3.5.34 + '@vue/runtime-core': 3.5.34 + '@vue/shared': 3.5.34 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.34(vue@3.5.34)': + dependencies: + '@vue/compiler-ssr': 3.5.34 + '@vue/shared': 3.5.34 + vue: 3.5.34 + + '@vue/shared@3.5.34': {} + + '@vuetify/loader-shared@2.1.2(vue@3.5.34)(vuetify@3.12.6)': + dependencies: + upath: 2.0.1 + vue: 3.5.34 + vuetify: 3.12.6(vite-plugin-vuetify@2.1.3)(vue@3.5.34) + + codemirror@6.0.2: + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/commands': 6.10.3 + '@codemirror/language': 6.12.3 + '@codemirror/lint': 6.9.7 + '@codemirror/search': 6.7.1 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.1 + + core-js@3.49.0: {} + + crelt@1.0.6: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + dot-prop@9.0.0: + dependencies: + type-fest: 4.41.0 + + entities@7.0.1: {} + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + estree-walker@2.0.2: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fsevents@2.3.3: + optional: true + + litegraph.js@0.7.18: {} + + lodash@4.18.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + marked@15.0.12: {} + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.14: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + roboto-fontface@0.10.0: {} + + rollup@4.60.3: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.3 + '@rollup/rollup-android-arm64': 4.60.3 + '@rollup/rollup-darwin-arm64': 4.60.3 + '@rollup/rollup-darwin-x64': 4.60.3 + '@rollup/rollup-freebsd-arm64': 4.60.3 + '@rollup/rollup-freebsd-x64': 4.60.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.3 + '@rollup/rollup-linux-arm-musleabihf': 4.60.3 + '@rollup/rollup-linux-arm64-gnu': 4.60.3 + '@rollup/rollup-linux-arm64-musl': 4.60.3 + '@rollup/rollup-linux-loong64-gnu': 4.60.3 + '@rollup/rollup-linux-loong64-musl': 4.60.3 + '@rollup/rollup-linux-ppc64-gnu': 4.60.3 + '@rollup/rollup-linux-ppc64-musl': 4.60.3 + '@rollup/rollup-linux-riscv64-gnu': 4.60.3 + '@rollup/rollup-linux-riscv64-musl': 4.60.3 + '@rollup/rollup-linux-s390x-gnu': 4.60.3 + '@rollup/rollup-linux-x64-gnu': 4.60.3 + '@rollup/rollup-linux-x64-musl': 4.60.3 + '@rollup/rollup-openbsd-x64': 4.60.3 + '@rollup/rollup-openharmony-arm64': 4.60.3 + '@rollup/rollup-win32-arm64-msvc': 4.60.3 + '@rollup/rollup-win32-ia32-msvc': 4.60.3 + '@rollup/rollup-win32-x64-gnu': 4.60.3 + '@rollup/rollup-win32-x64-msvc': 4.60.3 + fsevents: 2.3.3 + + source-map-js@1.2.1: {} + + style-mod@4.1.3: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + type-fest@4.41.0: {} + + upath@2.0.1: {} + + uuid@14.0.0: {} + + vite-plugin-runtime-env@1.0.0(vite@7.3.5): + dependencies: + magic-string: 0.30.21 + vite: 7.3.5 + + vite-plugin-vuetify@2.1.3(vite@7.3.5)(vue@3.5.34)(vuetify@3.12.6): + dependencies: + '@vuetify/loader-shared': 2.1.2(vue@3.5.34)(vuetify@3.12.6) + debug: 4.4.3 + upath: 2.0.1 + vite: 7.3.5 + vue: 3.5.34 + vuetify: 3.12.6(vite-plugin-vuetify@2.1.3)(vue@3.5.34) + transitivePeerDependencies: + - supports-color + + vite@7.3.5: + dependencies: + esbuild: 0.28.1 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.14 + rollup: 4.60.3 + tinyglobby: 0.2.16 + optionalDependencies: + fsevents: 2.3.3 + + vue-codemirror@6.1.1(codemirror@6.0.2)(vue@3.5.34): + dependencies: + '@codemirror/commands': 6.10.3 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.42.1 + codemirror: 6.0.2 + vue: 3.5.34 + + vue@3.5.34: + dependencies: + '@vue/compiler-dom': 3.5.34 + '@vue/compiler-sfc': 3.5.34 + '@vue/runtime-dom': 3.5.34 + '@vue/server-renderer': 3.5.34(vue@3.5.34) + '@vue/shared': 3.5.34 + + vuetify@3.12.6(vite-plugin-vuetify@2.1.3)(vue@3.5.34): + dependencies: + vue: 3.5.34 + optionalDependencies: + vite-plugin-vuetify: 2.1.3(vite@7.3.5)(vue@3.5.34)(vuetify@3.12.6) + + w3c-keyname@2.2.8: {} + + webfontloader@1.6.28: {} diff --git a/talemate_frontend/pnpm-workspace.yaml b/talemate_frontend/pnpm-workspace.yaml new file mode 100644 index 00000000..129bdd1b --- /dev/null +++ b/talemate_frontend/pnpm-workspace.yaml @@ -0,0 +1,30 @@ +# pnpm settings. pnpm reads this file for configuration even though +# talemate_frontend is a single package, not a workspace. + +# Supply-chain hardening: refuse to resolve any dependency version (including +# transitive ones) until it has been public for at least this many minutes. +# Fast-moving npm supply-chain worms are typically detected and yanked within +# hours, so this keeps us out of the blast radius during that window. +# 10080 minutes = 7 days. Raise/lower to trade freshness for safety; +# set to 0 to disable. Use `minimumReleaseAgeExclude` to exempt packages. +minimumReleaseAge: 10080 + +# Dependency lifecycle scripts (postinstall etc.) are blocked by default in +# pnpm 11 — another supply-chain safeguard, since these worms typically run +# their payload from an install script. `allowBuilds` is the explicit decision +# for every dependency that ships a build script: true = may run it, +# false = must not (and is acknowledged, so installs don't error). +# - esbuild: needs its script to set up its platform binary +# - core-js: denied; its install script only prints a funding message +# If pnpm reports a new ignored build, add it here as true or false. +allowBuilds: + esbuild: true + core-js: false + +# Pin transitive packages to patched versions for security advisories. +# - esbuild: vite 7.3.5 pins esbuild ^0.27.0, but GHSA-g7r4-m6w7-qqqr +# (dev-server arbitrary file read) is only fixed in >=0.28.1. esbuild's +# API is stable across 0.27->0.28 (vite 8 itself moved to 0.28), so the +# override is safe. +overrides: + esbuild: ">=0.28.1" diff --git a/talemate_frontend/src/components/AIAgent.vue b/talemate_frontend/src/components/AIAgent.vue index d8e070d0..776792c2 100644 --- a/talemate_frontend/src/components/AIAgent.vue +++ b/talemate_frontend/src/components/AIAgent.vue @@ -1,4 +1,21 @@ + + + @@ -100,7 +122,7 @@ - + @@ -108,12 +130,14 @@ import AgentModal from './AgentModal.vue'; import { isPrimaryModifier } from '@/utils/keyboardModifiers'; import AgentMessages from './AgentMessages.vue'; +import { countSceneOverrides } from '@/constants/sceneAgentSettings'; export default { components: { AgentModal, AgentMessages }, + emits: ['agents-updated'], data() { return { @@ -143,8 +167,12 @@ export default { }, templates: Object, appConfig: Object, + scene: Object, }, computed: { + overrideCount() { + return Object.keys(this.appConfig?.agent_actions?.overrides || {}).length; + }, agentStateNotifications() { // if key begins with 'notify__' and value is a string, return the key and value // return the notify__(.+) part as the key, and the value as the value @@ -170,6 +198,7 @@ export default { 'registerMessageHandler', 'isConnected', 'getClients', + 'openAgentActionOverrides', ], provide() { return { @@ -177,6 +206,15 @@ export default { }; }, methods: { + sceneOverrideCount(agent) { + return countSceneOverrides(agent?.data?.scene_overrides); + }, + sceneOverrideTooltip(agent) { + const n = this.sceneOverrideCount(agent); + const file = this.scene?.data?.agent_settings_file || 'agent-settings.json'; + return `${n} scene override${n === 1 ? '' : 's'} active (from ${file})`; + }, + setAgentMessageRef(el, agentName) { if (el) { this.agentMessagesRefs[agentName] = el; diff --git a/talemate_frontend/src/components/AIClient.vue b/talemate_frontend/src/components/AIClient.vue index 1d0b779c..c9fb0771 100644 --- a/talemate_frontend/src/components/AIClient.vue +++ b/talemate_frontend/src/components/AIClient.vue @@ -231,7 +231,7 @@ - + @@ -313,7 +313,7 @@ export default { rate_limit: null, data_format: null, section_format: null, - enforce_response_length: 'cap_tokens_and_instructions', + enforce_response_length: 'adaptive', data: { has_prompt_template: false, } @@ -403,7 +403,7 @@ export default { api_url: 'http://localhost:8080', model_name: '', max_token_length: 8192, - enforce_response_length: 'cap_tokens_and_instructions', + enforce_response_length: 'adaptive', data: { has_prompt_template: false, }, @@ -554,6 +554,7 @@ export default { client.reason_tokens = data.data.reason_tokens; client.min_reason_tokens = data.data.min_reason_tokens; client.reason_response_pattern = data.data.reason_response_pattern; + client.reason_validation_pattern = data.data.reason_validation_pattern; client.reason_prefill = data.data.reason_prefill; client.reason_failure_behavior = data.data.reason_failure_behavior; client.reason_enabled = data.data.reason_enabled; @@ -615,6 +616,7 @@ export default { reason_tokens: data.data.reason_tokens, min_reason_tokens: data.data.min_reason_tokens, reason_response_pattern: data.data.reason_response_pattern, + reason_validation_pattern: data.data.reason_validation_pattern, reason_prefill: data.data.reason_prefill, reason_failure_behavior: data.data.reason_failure_behavior, reason_enabled: data.data.reason_enabled, diff --git a/talemate_frontend/src/components/AgentActionOverrides.vue b/talemate_frontend/src/components/AgentActionOverrides.vue new file mode 100644 index 00000000..4155efe7 --- /dev/null +++ b/talemate_frontend/src/components/AgentActionOverrides.vue @@ -0,0 +1,121 @@ + + + diff --git a/talemate_frontend/src/components/AgentGlobalSettings.vue b/talemate_frontend/src/components/AgentGlobalSettings.vue new file mode 100644 index 00000000..6ddddff1 --- /dev/null +++ b/talemate_frontend/src/components/AgentGlobalSettings.vue @@ -0,0 +1,124 @@ + + + diff --git a/talemate_frontend/src/components/AgentModal.vue b/talemate_frontend/src/components/AgentModal.vue index eafdd171..aef3ff01 100644 --- a/talemate_frontend/src/components/AgentModal.vue +++ b/talemate_frontend/src/components/AgentModal.vue @@ -1,23 +1,48 @@ - + @@ -817,8 +620,16 @@ export default { padding-right: 16px; } +/* Vuetify's v-window has overflow: hidden for slide transitions, which + clips v-slider's `thumb-label="always"` bubble when a slider is the + first child in a tab. Give the window-item internal top padding so the + slider has clearance below the v-window's clip edge. */ +.scrollable-content .v-window-item { + padding-top: 24px; +} + .checkbox-right { display: flex; justify-content: flex-end; } - \ No newline at end of file + diff --git a/talemate_frontend/src/components/AgentSceneSettings.vue b/talemate_frontend/src/components/AgentSceneSettings.vue new file mode 100644 index 00000000..085b55fa --- /dev/null +++ b/talemate_frontend/src/components/AgentSceneSettings.vue @@ -0,0 +1,186 @@ + + + + + diff --git a/talemate_frontend/src/components/AgentSettingField.vue b/talemate_frontend/src/components/AgentSettingField.vue new file mode 100644 index 00000000..8ab34753 --- /dev/null +++ b/talemate_frontend/src/components/AgentSettingField.vue @@ -0,0 +1,412 @@ + + + + + diff --git a/talemate_frontend/src/components/AppConfig.vue b/talemate_frontend/src/components/AppConfig.vue index 35ae2039..422473d0 100644 --- a/talemate_frontend/src/components/AppConfig.vue +++ b/talemate_frontend/src/components/AppConfig.vue @@ -70,7 +70,12 @@ - + + + + + +
@@ -328,6 +333,24 @@
+ +
+ + HuggingFace +
+

Access token used to download gated model weights (e.g. for Pocket TTS voice cloning).

+ Create a token at https://huggingface.co/settings/tokens. A read token is sufficient. +
+
+ + + + + + +
+ @@ -367,11 +390,10 @@
- - Content context + Content Classification
- Available content-context choices when generating characters or scenarios. This can strongly influence the content that is generated. + Available content classification choices when generating characters or scenarios. This can strongly influence the content that is generated.
@@ -383,12 +405,31 @@ - - - +
+
+ + Perspective Presets +
+ Reusable narrative perspective / tense strings offered in the scene outline. Use {player_name} as a placeholder for the player character — it will be substituted at prompt render time. +
+
+ + + + + + mdi-close-box-outline{{ value }} + + + + + +
@@ -438,6 +479,7 @@ export default { dialog: false, app_config: null, content_context_input: '', + perspective_preset_input: '', navigation: { game: [ {title: 'General', icon: 'mdi-cog', value: 'general'}, @@ -453,12 +495,14 @@ export default { {title: 'ElevenLabs', icon: 'mdi-api', value: 'elevenlabs_api'}, {title: 'Google', icon: 'mdi-api', value: 'google_api'}, {title: 'groq', icon: 'mdi-api', value: 'groq_api'}, + {title: 'HuggingFace', icon: 'mdi-api', value: 'huggingface_api'}, {title: 'mistral.ai', icon: 'mdi-api', value: 'mistralai_api'}, {title: 'OpenAI', icon: 'mdi-api', value: 'openai_api'}, {title: 'OpenRouter', icon: 'mdi-api', value: 'openrouter_api'}, ], creator: [ - {title: 'Content Context', icon: 'mdi-cube-scan', value: 'content_context'}, + {title: 'Content Classification', icon: 'mdi-cube-scan', value: 'content_context'}, + {title: 'Perspective Presets', icon: 'mdi-eye-outline', value: 'perspective_presets'}, ] }, gamePageSelected: 'general', @@ -552,6 +596,20 @@ export default { this.app_config.creator.content_context.splice(index, 1); }, + perspectivePresetRemove(index) { + this.app_config.creator.perspective_presets.splice(index, 1); + }, + + perspectivePresetAdd() { + const value = (this.perspective_preset_input || '').trim(); + if (!value) return; + if (!Array.isArray(this.app_config.creator.perspective_presets)) { + this.app_config.creator.perspective_presets = []; + } + this.app_config.creator.perspective_presets.push(value); + this.perspective_preset_input = ''; + }, + handleMessage(message) { if (message.type == "app_config") { this.app_config = message.data; diff --git a/talemate_frontend/src/components/AppConfigAppearanceScene.vue b/talemate_frontend/src/components/AppConfigAppearanceScene.vue index 85e74ed1..9facb280 100644 --- a/talemate_frontend/src/components/AppConfigAppearanceScene.vue +++ b/talemate_frontend/src/components/AppConfigAppearanceScene.vue @@ -13,7 +13,7 @@ + + diff --git a/talemate_frontend/src/components/CharacterMessage.vue b/talemate_frontend/src/components/CharacterMessage.vue index 89b6873f..50b5b35d 100644 --- a/talemate_frontend/src/components/CharacterMessage.vue +++ b/talemate_frontend/src/components/CharacterMessage.vue @@ -1,12 +1,13 @@