diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 964cbe7..1c43b86 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -125,6 +125,9 @@ jobs: - name: Run frontend linting run: cd frontend && npm run lint + - name: Check source file sizes + run: cd frontend && npm run check:file-sizes + - name: Run Rust clippy run: cd frontend/src-tauri && cargo clippy --all-targets -- -D warnings continue-on-error: true # TODO: fix 26 pre-existing warnings, then remove this diff --git a/CLAUDE.md b/CLAUDE.md index 7c59d1c..25ee994 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,14 +90,14 @@ Grafyn is a **desktop-only** app — a single Tauri binary with a Vue frontend a ### Tauri IPC Commands -16 modules in `frontend/src-tauri/src/commands/`. Enumerate exact command names with `grep -rn "#\[tauri::command\]" -A1 src/commands/` — purposes only below, to avoid drift. +16 modules in `frontend/src-tauri/src/commands/`. `canvas` is a directory module (split — see below); every other module is a single file. Enumerate exact command names with `grep -rn "#\[tauri::command\]" -A1 src/commands/` — purposes only below, to avoid drift. | Module | Purpose | |--------|---------| | `notes.rs` | Note CRUD | | `search.rs` | Full-text search, find-similar, reindex | | `graph.rs` | Link graph: backlinks, outgoing, neighbors, unlinked, full graph, rebuild | -| `canvas.rs` | Multi-LLM canvas (18 commands) with note context; streaming via `canvas-stream` Tauri events | +| `canvas/` | Multi-LLM canvas (18 commands) with note context; streaming via `canvas-stream` Tauri events. Split across `session.rs` (session/tile CRUD), `streaming.rs` (`send_prompt`/`add_models_to_tile`/`regenerate_response`), `debate.rs` (`start_debate`/`continue_debate`), `context.rs` (retrieval + twin-context prompt assembly, incl. `build_twin_context_prompt()`), `shared.rs` (common helpers), and `mod.rs` (re-exports) | | `distill.rs` | LLM + rules-based distillation, tag normalization | | `settings.rs` | Settings, first-run setup, OpenRouter key validation, Ollama status/models | | `feedback.rs` | Feedback with offline queue | @@ -114,8 +114,9 @@ Grafyn is a **desktop-only** app — a single Tauri binary with a Vue frontend a ### Frontend - `src/api/client.js` — all backend calls go through Tauri `invoke()`; exports one namespace per command module plus `optimizer` and `isDesktopApp` -- **Pinia stores (3):** `canvas.js`, `theme.js`, `boot.js` — there is no twin store; twin UI state lives in `TwinReviewView.vue` and `stores/canvas.js` -- **Routes:** `/` (notes), `/canvas`, `/canvas/:id`, `/import`, `/twin` (component: `TwinReviewView.vue`), plus catch-all → `NotFoundView.vue` +- **Pinia stores (4):** `canvas.js`, `theme.js`, `boot.js`, `twin.js` — twin state (records, review, Constitution, action gaps, decisions, Decision Mirror config, setup) lives in the store; components read/act through it rather than holding local copies +- `components/twin/` — the Twin Workspace's tab components (`TwinOverviewTab.vue`, `TwinSetupTab.vue`, `TwinConfigTab.vue`, `TwinConstitutionTab.vue`, `TwinActionGapsTab.vue`, `TwinDecisionsTab.vue`, `TwinMemoryTab.vue`, `TwinGuideTab.vue`) plus shared pieces (`ReviewActions.vue`, `SetupField.vue`, `ActionGapRow.vue`, `DecisionRow.vue`, `EvidenceDrawer.vue`) and `twin-workspace.css`. `views/TwinReviewView.vue` is now a ~150-line shell that wires the store to these tabs — do not add Twin Workspace logic directly to the view. +- **Routes:** `/` (notes), `/canvas`, `/canvas/:id`, `/import`, `/twin` (component: `TwinReviewView.vue`, thin shell over `components/twin/`), plus catch-all → `NotFoundView.vue` - **Tests:** `src/__tests__/{unit,integration,fixtures}/` + `setup.js` (Vitest) ## Key Concepts @@ -186,7 +187,9 @@ Streaming commands: `send_prompt`, `start_debate`, `continue_debate`, `add_model ### Twin Identity, Constitution, And Decision Mirror -Twin context mode is a native RAG path, not model-weight training. `frontend/src-tauri/src/commands/canvas.rs` assembles the model-facing prompt through `build_twin_context_prompt()`. +Twin context mode is a native RAG path, not model-weight training. `frontend/src-tauri/src/commands/canvas/context.rs` assembles the model-facing prompt through `build_twin_context_prompt()`. + +`TwinStore` itself is defined in `services/twin/mod.rs` (struct + shared state) with its methods split by concern across sibling files in `services/twin/`: `records.rs` (user records, inference, promotion), `constitution.rs` (Constitution items + review), `decisions.rs` (decision episodes, outcomes, Decision Mirror), `digest.rs` (memory digest), `traces.rs` (session traces, reflection cards), `export.rs` (JSONL export bundles), and `shared.rs` (corrupt-file quarantine + common helpers). There is no single `twin_store.rs` file — treat `services/twin/` as one logical module split across files, the same pattern as `commands/canvas/`. The prompt order is: @@ -231,13 +234,21 @@ Configurable via `RetrievalConfig` (persisted in `data/retrieval_config.json`): Hub clustering rules: label-propagation over linked note groups; noise filtering suppresses model names, provider names, and transcript artifacts from becoming hubs; minor themes are grouped under a parent hub's `Subtopics` section rather than creating new hubs. +### Note Write & Delete Chokepoints + +Any note write or delete that must stay consistent with the search index, chunk index, topic hubs, and vault-optimizer queue goes through two chokepoints in `commands/mod.rs`: **`commit_note_write`/`commit_note_writes`** (single/batch write — indexes the note, syncs the chunk index, runs `sync_topic_hubs`, enqueues the note in the vault optimizer) and **`commit_note_delete`** (the delete-side equivalent). **New cross-cutting write paths must call these** rather than writing a note and re-indexing ad hoc. + +Underlying atomic file I/O is `services/atomic_io.rs::write_atomic` (temp-file-then-rename, so a crash mid-write never leaves a half-written note on disk). `services/index_commit.rs` holds the shared search-index-write helpers (`index_note_for_search`, `commit_search`) used by both chokepoints and any other direct index writer. + +`commands/mod.rs`'s module-level doc comment documents the **canonical lock order**: `knowledge_store` must always be acquired before `vault_optimizer` (order doesn't matter between read/write, but `vault_optimizer` must come second) — reversing it risks an ABBA deadlock between the background vault-optimizer worker and any command that touches both locks. + ### Background Services These services run automatically in the background and have dedicated inbox/decision/rollback APIs. Do not re-implement any of these — they already exist. **`services/link_discovery.rs`** — background link-discovery worker. Distinct from on-demand zettelkasten discovery (`discover_links` command). Uses YAKE keyword extraction (`services/yake.rs`) and TF-IDF cosine similarity (`services/similarity.rs`) to find wikilink candidates without LLM calls. Optional LLM pass controlled by `background_link_discovery_llm_enabled`. Results surface via `list_link_suggestion_queue` / `dismiss_link_suggestion` commands. -**`services/vault_optimizer.rs`** — background vault optimizer. Queued via `enqueue_vault_optimizer_note()` whenever notes are created or migrated. Processes the queue and applies structural improvements in two modes: `sidecar_first` (overlay metadata) or `full_rewrite`. Budget caps and daily write limits prevent runaway LLM spend. Decisions are auditable via `list_vault_optimizer_decisions`; rollbacks are per-change via `rollback_vault_optimizer_change`. +**`services/vault_optimizer.rs`** — background vault optimizer. Queued via `enqueue_vault_optimizer_note()` whenever notes are created or migrated. Processes the queue and applies structural improvements in two modes: `sidecar_first` (overlay metadata) or `full_rewrite`. **The proposals are rule-based, not LLM-based** — title-alias normalization and topic tagging computed directly from vault content; there is no `OpenRouterService`/network call anywhere in this file. `background_vault_optimizer_llm_enabled` is a reserved no-op today (a characterization test, `run_next_ignores_llm_enabled_because_no_llm_path_exists`, locks in that toggling it changes nothing); it exists as the gate a future LLM-backed enrichment step must check before making any network call. The budget/daily-write settings (`background_vault_optimizer_max_daily_writes`, etc.) cap **disk writes to the vault** (`daily_write_count`), not LLM spend. Decisions are auditable via `list_vault_optimizer_decisions`; rollbacks are per-change via `rollback_vault_optimizer_change`. **`services/markdown_migration.rs`** — one-shot structured vault migration. Preview → apply → rollback workflow. `apply_markdown_migration` runs `sync_topic_hubs` and rebuilds all indexes after applying, then enqueues touched notes in the vault optimizer. Rollback restores pre-migration state and rebuilds. @@ -256,8 +267,8 @@ First-run setup wizard and persistent settings. Manages vault path, OpenRouter A - **`twin_llm_provider`** — selects the LLM runtime for twin context answers: `"openrouter"` (default) or `"ollama"`. Gates whether `OllamaService` or `OpenRouterService` is used in `build_twin_context_prompt()`. - **`ollama_base_url`** / **`ollama_model`** — endpoint and model for local inference. `get_ollama_status` probes the Ollama daemon; `list_ollama_models` enumerates pulled models. Synced via `ollama.set_base_url()` on settings change. - **`background_link_discovery_enabled`** / **`background_link_discovery_llm_enabled`** — controls the background link-discovery worker. When enabled, `LinkDiscoveryService` processes notes in the background using YAKE keyword extraction and TF-IDF similarity. -- **`background_vault_optimizer_enabled`** — controls the `VaultOptimizerService`. When enabled, optimizer processes queued notes and applies structural improvements (sidecar overlay or full rewrite mode). Budget and max daily writes cap LLM costs. -- **Vault optimizer sub-settings** (all on `UserSettings`): `background_vault_optimizer_llm_enabled`, `_budget_monthly`, `_max_daily_writes`, `_edit_mode`, `_program_enabled`, and `vault_optimizer_program_path` — the last two enable a **vault-local `program.md` policy file** that steers optimizer behavior per-vault. +- **`background_vault_optimizer_enabled`** — controls the `VaultOptimizerService`. When enabled, optimizer processes queued notes and applies structural improvements (sidecar overlay or full rewrite mode) using rule-based proposals only (no LLM call today). Max daily writes cap disk writes to the vault, not LLM spend — see Background Services above. +- **Vault optimizer sub-settings** (all on `UserSettings`): `background_vault_optimizer_llm_enabled` (reserved no-op — no LLM path exists yet), `_budget_monthly` (unused until an LLM path exists), `_max_daily_writes` (real — caps disk writes), `_edit_mode`, `_program_enabled`, and `vault_optimizer_program_path` — the last two enable a **vault-local `program.md` policy file** that steers optimizer behavior per-vault. - **`canvas_model_presets`** — saved canvas model combinations (`CanvasModelPreset` struct in `models/settings.rs`). **Runtime sync pattern:** When settings change via `update_settings`, dependent services are updated in-place — no restart required. The pattern (in `commands/settings.rs`): capture changed fields before moving the update, apply settings, then sync each affected service: @@ -307,7 +318,7 @@ cargo build --release --bin grafyn-mcp --no-default-features --features mcp ### Test Pipeline -`.github/workflows/test.yml` — runs on push to main and PRs. Jobs: `release-preflight` (version + Cargo.lock alignment), `rust-tests` (ubuntu-22.04), `frontend-tests` (Vitest), `lint` (eslint + `cargo clippy -D warnings`), `security` (`npm audit --audit-level=high`), `build` (Vite), `test-summary`. Clippy and npm audit **block** PRs. +`.github/workflows/test.yml` — runs on push to main and PRs. Jobs: `release-preflight` (version + Cargo.lock alignment), `rust-tests` (ubuntu-22.04), `frontend-tests` (Vitest), `lint` (eslint + file-size tripwire + `cargo clippy -D warnings`), `security` (`npm audit --audit-level=high`), `build` (Vite), `test-summary`. npm audit **blocks** PRs; clippy currently runs with `continue-on-error: true` pending cleanup of pre-existing warnings (see the TODO in the `lint` job). The file-size tripwire (`npm run check:file-sizes`, `scripts/check-file-sizes.cjs`) blocks PRs when any non-test `.rs`/`.vue`/`.js` source file exceeds 2,500 lines. ### Release Pipeline @@ -412,3 +423,4 @@ From `frontend/`: - **Read before editing.** Always Read a file in-session before Edit/Write; "File has not been read yet" failures were the most repeated tool error in this repo's sessions. - **Discussion-first.** When the user is exploring a design ("lets chat more", strategic questions), discuss — do not start implementing until explicitly told to build. Tool-use rejections are usually redirects back to discussion, not vetoes. - **Local models:** before benchmarking a new Ollama tag, smoke-test it first (prompt-echo check, choice-extraction check, max-token truncation check) — these three failure modes consumed entire past sessions. +- **Source file size.** Keep new/edited source files under ~1,500 lines as an authoring target; CI hard-fails the `lint` job at 2,500 lines (`npm run check:file-sizes`, excludes `__tests__/`, `*.spec.js`, and Rust test-only files by path convention). New cross-cutting write paths must go through `commit_note_write`/`commit_note_delete` (`commands/mod.rs`) and `services/atomic_io.rs::write_atomic` — see "Note Write & Delete Chokepoints" above — not ad hoc file writes. When a file needs splitting, follow the mod-facade pattern already established by `commands/canvas/` (and `services/twin/`): one directory module, a `mod.rs` that re-exports, and sibling files split by concern (not by arbitrary line count). diff --git a/docs/superpowers/plans/2026-07-09-production-readiness-refactor.md b/docs/superpowers/plans/2026-07-09-production-readiness-refactor.md index 99a7382..56674fb 100644 --- a/docs/superpowers/plans/2026-07-09-production-readiness-refactor.md +++ b/docs/superpowers/plans/2026-07-09-production-readiness-refactor.md @@ -260,7 +260,7 @@ - [ ] Add convention to CLAUDE.md: source files should stay under ~1,500 lines; new cross-cutting write paths must go through `commit_note_write`/`write_atomic`. - [ ] Optional CI tripwire: a 10-line script failing the lint job when any `src-tauri/src/**/*.rs` or `src/**/*.{vue,js}` exceeds 2,000 lines (excluding tests). Decide with owner at PR time. -**Phase 4 acceptance:** no public behavior change (full Rust + Vitest + e2e suites green before/after); largest source file < 2,000 lines; twin state has a real store. +**Phase 4 acceptance:** no public behavior change (full Rust + Vitest + e2e suites green before/after); source files target ~1,500 lines with a 2,500-line CI hard-fail (owner decision 2026-07-10: `commands/canvas/context.rs` at 2,279 accepted — keeping the twin Simulation gate and its 21 tests together outweighs the original < 2,000 line goal); twin state has a real store. --- diff --git a/frontend/package.json b/frontend/package.json index f6a8b76..fe5693a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "test:run": "vitest run", "test:coverage": "vitest run --coverage", "prepare:sidecar": "node scripts/prepare-sidecar.cjs", + "check:file-sizes": "node scripts/check-file-sizes.cjs", "tauri": "tauri", "tauri:dev": "node scripts/run-tauri.cjs dev", "tauri:build": "node scripts/run-tauri.cjs build", diff --git a/frontend/scripts/check-file-sizes.cjs b/frontend/scripts/check-file-sizes.cjs new file mode 100644 index 0000000..9f4de33 --- /dev/null +++ b/frontend/scripts/check-file-sizes.cjs @@ -0,0 +1,120 @@ +#!/usr/bin/env node + +// Size tripwire: fails when a source file grows past a line-count threshold. +// Catches the "one file does everything" drift this refactor split apart +// (the old single-file commands/canvas.rs and services/twin_store.rs, now +// the commands/canvas/ and services/twin/ module directories) before it recurs. See +// CLAUDE.md "Working conventions" for the ~1,500-line authoring target — +// this threshold (2,500) is a hard CI failure with headroom above that. + +const fs = require('node:fs') +const path = require('node:path') + +const projectRoot = path.resolve(__dirname, '..') + +const DEFAULT_MAX_LINES = 2500 + +const SCAN_ROOTS = [ + { dir: path.join(projectRoot, 'src-tauri', 'src'), extensions: ['.rs'] }, + { dir: path.join(projectRoot, 'src'), extensions: ['.vue', '.js'] }, +] + +// Path-convention exclusions only — we do not parse Rust to find +// `#[cfg(test)]`-only files, and we do not parse JS to find describe/it +// blocks. A file is excluded solely by where it lives or how it's named. +function isExcluded(relativePath) { + const segments = relativePath.split('/') + + if (segments.includes('__tests__')) { + return true + } + + const basename = path.basename(relativePath) + + if (/\.(spec|test)\.js$/.test(basename)) { + return true + } + + // Rust test-only files by naming convention (e.g. test_support.rs, + // foo_test.rs) — not files that merely contain an inline `mod tests {}`. + if (relativePath.endsWith('.rs') && /(^|_)test(s)?(_|\.rs$)/.test(basename)) { + return true + } + + return false +} + +function walk(dir, extensions, results) { + const entries = fs.readdirSync(dir, { withFileTypes: true }) + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name) + + if (entry.isDirectory()) { + walk(fullPath, extensions, results) + continue + } + + if (extensions.includes(path.extname(entry.name))) { + results.push(fullPath) + } + } +} + +function countLines(filePath) { + const contents = fs.readFileSync(filePath, 'utf8') + if (contents.length === 0) { + return 0 + } + return contents.split('\n').length +} + +function main() { + const maxLinesArg = process.argv.find((arg) => arg.startsWith('--max-lines=')) + const maxLines = maxLinesArg ? Number(maxLinesArg.split('=')[1]) : DEFAULT_MAX_LINES + + if (!Number.isFinite(maxLines) || maxLines <= 0) { + console.error(`check-file-sizes: invalid --max-lines value: ${maxLinesArg}`) + process.exit(1) + } + + const files = [] + for (const { dir, extensions } of SCAN_ROOTS) { + if (fs.existsSync(dir)) { + walk(dir, extensions, files) + } + } + + const offenders = [] + + for (const filePath of files) { + const relativePath = path.relative(projectRoot, filePath).replace(/\\/g, '/') + if (isExcluded(relativePath)) { + continue + } + + const lines = countLines(filePath) + if (lines > maxLines) { + offenders.push({ relativePath, lines }) + } + } + + offenders.sort((a, b) => b.lines - a.lines) + + if (offenders.length > 0) { + console.error(`check-file-sizes: ${offenders.length} file(s) exceed ${maxLines} lines:`) + for (const { relativePath, lines } of offenders) { + console.error(` ${lines.toString().padStart(6)} ${relativePath}`) + } + console.error('') + console.error( + 'Split oversized files using the mod-facade pattern (see commands/canvas/ for the exemplar) ' + + 'instead of raising this threshold.', + ) + process.exit(1) + } + + console.log(`check-file-sizes: OK — no source file exceeds ${maxLines} lines.`) +} + +main() diff --git a/frontend/src-tauri/src/commands/canvas.rs b/frontend/src-tauri/src/commands/canvas.rs deleted file mode 100644 index a9fa36e..0000000 --- a/frontend/src-tauri/src/commands/canvas.rs +++ /dev/null @@ -1,4797 +0,0 @@ -use crate::commands::{commit_note_write, run_retrieval}; -use crate::models::canvas::{ - AddModelsRequest, AvailableModel, CanvasSession, CanvasStreamEvent, CanvasViewport, - ContextMode, Debate, DebateContinueRequest, DebateResponse, DebateRound, DebateStartRequest, - DecisionPromptMetadata, LLMNodePositionUpdate, ModelResponse, PromptRequest, PromptTile, - PromptType, ResponseStatus, SessionCreate, SessionMeta, SessionUpdate, TileContextNote, - TilePosition, TilePositionUpdate, TwinAnswerMode, -}; -use crate::models::note::{ChunkResult, NoteCreate, NoteStatus}; -use crate::models::settings::UserSettings; -use crate::models::twin::{ - ActionGap, ConstitutionItem, ConstitutionSetup, DecisionEpisode, DecisionEpisodeCreate, - PrimitiveDecisionAssessment, ReflectionCardCreate, TraceEventType, TwinContextRecord, -}; -use crate::services::ollama::OllamaService; -use crate::services::openrouter::{ChatMessage, OpenRouterService}; -use crate::services::retrieval::RetrievalResult; -use crate::services::twin_store::{parse_twin_prediction, TwinStore}; -use crate::AppState; -use chrono::Utc; -use futures::StreamExt; -use serde_json::json; -use std::collections::{HashMap, HashSet}; -use std::sync::Arc; -use std::time::Duration; -use tauri::State; -use tokio::sync::RwLock; - -const COMPACT_HISTORY_RECENT_TURNS: usize = 2; -const COMPACT_HISTORY_EXCERPT_CHARS: usize = 240; -const EMPTY_MODEL_RESPONSE_ERROR: &str = "No response returned from model"; -const MIN_RETRIEVAL_SCORE_FOR_NOTES: f32 = 5.0; -const MIN_CANVAS_QUERY_TOKEN_LEN: usize = 3; -const CANVAS_RETRIEVAL_STOPWORDS: &[&str] = &[ - "a", "an", "and", "are", "as", "at", "be", "but", "by", "can", "do", "for", "from", "how", "i", - "if", "in", "into", "is", "it", "its", "like", "make", "more", "my", "not", "of", "on", "or", - "our", "real", "so", "that", "the", "their", "them", "there", "these", "they", "this", "to", - "up", "use", "want", "was", "we", "what", "when", "where", "which", "who", "why", "with", - "works", "would", "you", "your", -]; - -// LLM node layout constants -const LLM_NODE_WIDTH: f64 = 280.0; -const LLM_NODE_HEIGHT: f64 = 200.0; -const LLM_NODE_Y_STEP: f64 = 300.0; // height(200) + 100px gap for content overflow -const LLM_NODE_X_GAP: f64 = 80.0; - -// Twin context assembly -const TWIN_CONTEXT_VERSION: &str = "ctx-v2-cases-lexical"; -const TWIN_CONTEXT_TOKEN_BUDGET: usize = 4000; -const MAX_TWIN_CASE_CONTEXT: usize = 5; -const TWIN_CASE_FIELD_MAX_CHARS: usize = 800; -const TWIN_CASE_CORRECTION_MAX_CHARS: usize = 500; - -#[derive(Debug, Clone)] -struct ConversationTurn { - prompt: String, - response: String, - model_id: String, -} - -#[derive(Debug, Clone)] -struct ResolvedPromptContext { - messages: Vec, - context_notes: Vec, - approved_twin_records: Vec, - candidate_twin_records: Vec, - constitution_items: Vec, - action_gaps: Vec, - system_prompt: Option, - /// Raw twin system prompt before any user system prompt is merged in; - /// reused verbatim by the sealed-prediction call. - twin_context_prompt: Option, - context_version: Option, - decision_case_ids: Vec, -} - -type StreamedResponseUpdate = (String, String, ResponseStatus, Option); - -#[derive(Debug, Clone, PartialEq, Eq)] -struct ModelRoute { - provider: ModelProviderRoute, - model_ids: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum ModelProviderRoute { - OpenRouter, - Ollama, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RetrievalDecisionReason { - NoResults, - WeakTopScore, - NoKeywordMatch, - NoSnippet, - NoLexicalOverlap, - UseRetrievedNotes, -} - -/// List all canvas sessions -#[tauri::command] -pub async fn list_sessions(state: State<'_, AppState>) -> Result, String> { - let mut store = state.canvas_store.write().await; - store.list_sessions().map_err(|e| e.to_string()) -} - -/// Get a single session by ID -#[tauri::command] -pub async fn get_session(id: String, state: State<'_, AppState>) -> Result { - let mut store = state.canvas_store.write().await; - store.get_session(&id).map_err(|e| e.to_string()) -} - -/// Create a new canvas session -#[tauri::command] -pub async fn create_session( - session: SessionCreate, - state: State<'_, AppState>, -) -> Result { - let mut store = state.canvas_store.write().await; - let created = store.create_session(session).map_err(|e| e.to_string())?; - drop(store); - - append_canvas_trace( - state.twin_store.clone(), - &created.id, - TraceEventType::SessionCreated, - json!({ - "title": created.title.clone(), - "description": created.description.clone(), - "tags": created.tags.clone(), - "status": created.status.clone(), - }), - ) - .await; - - Ok(created) -} - -/// Update a canvas session -#[tauri::command] -pub async fn update_session( - id: String, - update: SessionUpdate, - state: State<'_, AppState>, -) -> Result { - let mut store = state.canvas_store.write().await; - let updated = store - .update_session(&id, update) - .map_err(|e| e.to_string())?; - drop(store); - - append_canvas_trace( - state.twin_store.clone(), - &updated.id, - TraceEventType::SessionUpdated, - json!({ - "title": updated.title.clone(), - "description": updated.description.clone(), - "tags": updated.tags.clone(), - "status": updated.status.clone(), - "viewport": updated.viewport.clone(), - "pinned_note_ids": updated.pinned_note_ids.clone(), - }), - ) - .await; - - Ok(updated) -} - -/// Delete a canvas session -#[tauri::command] -pub async fn delete_session(id: String, state: State<'_, AppState>) -> Result<(), String> { - let mut store = state.canvas_store.write().await; - store.delete_session(&id).map_err(|e| e.to_string())?; - drop(store); - - append_canvas_trace( - state.twin_store.clone(), - &id, - TraceEventType::SessionDeleted, - json!({ - "session_id": id, - }), - ) - .await; - - Ok(()) -} - -/// Get list of available LLM models -#[tauri::command] -pub async fn get_available_models( - state: State<'_, AppState>, -) -> Result, String> { - let openrouter = state.openrouter.read().await; - openrouter - .get_available_models() - .await - .map_err(|e| e.to_string()) -} - -/// Send a prompt to multiple models with streaming responses via Tauri events. -/// Returns the tile_id immediately; actual responses stream via "canvas-stream" events. -#[tauri::command] -pub async fn send_prompt( - window: tauri::Window, - session_id: String, - mut request: PromptRequest, - state: State<'_, AppState>, -) -> Result { - let tile_id = uuid::Uuid::new_v4().to_string(); - let now = Utc::now(); - let model_route = { - let settings = state.settings_service.read().await; - resolve_model_route( - &request.prompt_type, - &request.context_mode, - request.twin_llm_provider.as_deref(), - settings.get(), - )? - }; - request.models = effective_model_ids(&model_route, &request.models); - let session = { - let mut store = state.canvas_store.write().await; - store.get_session(&session_id).map_err(|e| e.to_string())? - }; - let resolved_context = resolve_prompt_context(state.inner(), &session, &request).await?; - let decision_episode_id = if request.prompt_type == PromptType::Decision { - Some(uuid::Uuid::new_v4().to_string()) - } else { - None - }; - - // Compute LLM node positions (offset from prompt tile) - let prompt_pos = request.position.clone().unwrap_or_default(); - let llm_start_x = prompt_pos.x + prompt_pos.width + LLM_NODE_X_GAP; - - // Create initial responses map with positions - let mut responses: HashMap = HashMap::new(); - for (i, model_id) in request.models.iter().enumerate() { - let model_name = model_id.split('/').last().unwrap_or(model_id).to_string(); - let llm_y = prompt_pos.y + (i as f64) * LLM_NODE_Y_STEP; - responses.insert( - model_id.clone(), - ModelResponse { - id: uuid::Uuid::new_v4().to_string(), - model_id: model_id.clone(), - model_name, - content: String::new(), - status: ResponseStatus::Pending, - error: None, - tokens_used: None, - created_at: now, - position: TilePosition { - x: llm_start_x, - y: llm_y, - width: LLM_NODE_WIDTH, - height: LLM_NODE_HEIGHT, - }, - }, - ); - } - - // Create the tile (with context notes attached) - let tile = PromptTile { - id: tile_id.clone(), - prompt_type: request.prompt_type.clone(), - prompt: request.prompt.clone(), - system_prompt: request.system_prompt.clone(), - models: request.models.clone(), - responses: responses.clone(), - position: prompt_pos, - created_at: now, - context_mode: request.context_mode, - parent_tile_id: request.parent_tile_id, - parent_model_id: request.parent_model_id, - context_notes: resolved_context.context_notes.clone(), - approved_twin_records: resolved_context.approved_twin_records.clone(), - candidate_twin_records: resolved_context.candidate_twin_records.clone(), - twin_answer_mode: request.twin_answer_mode.clone(), - twin_context_policy: request.twin_context_policy.clone(), - twin_llm_provider: request.twin_llm_provider.clone(), - decision_metadata: request.decision_metadata.clone(), - decision_episode_id: decision_episode_id.clone(), - web_search: request.web_search, - web_search_max_results: request.web_search_max_results, - reasoning_effort: request.reasoning_effort.clone(), - }; - - // Save tile to session - { - let mut store = state.canvas_store.write().await; - store - .add_tile(&session_id, tile.clone()) - .map_err(|e| e.to_string())?; - } - - if let Some(decision_episode_id) = decision_episode_id.clone() { - let decision_metadata = request.decision_metadata.clone().unwrap_or_default(); - let decision = if decision_metadata.decision.trim().is_empty() { - request.prompt.clone() - } else { - decision_metadata.decision - }; - let mut twin_store = state.twin_store.write().await; - twin_store - .record_decision_episode(DecisionEpisodeCreate { - id: decision_episode_id, - session_id: session_id.clone(), - tile_id: tile_id.clone(), - decision, - options: decision_metadata.options, - stakes: decision_metadata.stakes, - initial_leaning: decision_metadata.initial_leaning, - review_date: decision_metadata.review_date, - primitive_assessment: PrimitiveDecisionAssessment::default(), - // Stamped on every decision episode — including non-Twin - // context tiles — so attribution survives a failed or absent - // hidden prediction call. - context_version: Some( - resolved_context - .context_version - .clone() - .unwrap_or_else(|| TWIN_CONTEXT_VERSION.to_string()), - ), - }) - .map_err(|error| error.to_string())?; - } - - append_canvas_trace( - state.twin_store.clone(), - &session_id, - TraceEventType::PromptSubmitted, - json!({ - "tile_id": tile.id.clone(), - "prompt_type": tile.prompt_type.clone(), - "decision_episode_id": tile.decision_episode_id.clone(), - "decision_metadata": tile.decision_metadata.clone(), - "prompt": tile.prompt.clone(), - "models": tile.models.clone(), - "context_mode": tile.context_mode.clone(), - "parent_tile_id": tile.parent_tile_id.clone(), - "parent_model_id": tile.parent_model_id.clone(), - "context_note_ids": tile.context_notes.iter().map(|note| note.id.clone()).collect::>(), - "twin_answer_mode": tile.twin_answer_mode.clone(), - "approved_twin_record_ids": tile.approved_twin_records.iter().map(|record| record.id.clone()).collect::>(), - "candidate_twin_record_ids": tile.candidate_twin_records.iter().map(|record| record.id.clone()).collect::>(), - "constitution_item_ids": resolved_context.constitution_items.iter().map(|item| item.id.clone()).collect::>(), - "action_gap_ids": resolved_context.action_gaps.iter().map(|gap| gap.id.clone()).collect::>(), - "context_version": resolved_context.context_version.clone(), - "decision_case_ids": resolved_context.decision_case_ids.clone(), - "web_search": tile.web_search, - "web_search_max_results": tile.web_search_max_results, - }), - ) - .await; - - // Emit TileCreated event - let _ = window.emit( - "canvas-stream", - CanvasStreamEvent::TileCreated { - session_id: session_id.clone(), - tile: tile.clone(), - }, - ); - - // Emit ContextNotes event (so frontend can display which notes were used) - if !resolved_context.context_notes.is_empty() { - let _ = window.emit( - "canvas-stream", - CanvasStreamEvent::ContextNotes { - session_id: session_id.clone(), - tile_id: tile_id.clone(), - notes: resolved_context.context_notes.clone(), - }, - ); - } - - // Clone what we need for the spawned task - let openrouter_arc = state.openrouter.clone(); - let ollama_arc = state.ollama.clone(); - let canvas_store_arc = state.canvas_store.clone(); - let twin_store_arc = state.twin_store.clone(); - let models = request.models.clone(); - let messages = resolved_context.messages.clone(); - let system_prompt = resolved_context.system_prompt.clone(); - let temperature = request.temperature; - let web_search = request.web_search; - let web_search_max_results = request.web_search_max_results; - let reasoning_effort = request.reasoning_effort.clone(); - let provider_route = model_route.provider.clone(); - let tile_id_clone = tile_id.clone(); - let session_id_clone = session_id.clone(); - let decision_episode_id_for_reflection = tile.decision_episode_id.clone(); - let reflection_note_ids = tile - .context_notes - .iter() - .map(|note| note.id.clone()) - .collect::>(); - let reflection_user_record_ids = tile - .approved_twin_records - .iter() - .chain(tile.candidate_twin_records.iter()) - .map(|record| record.id.clone()) - .collect::>(); - let reflection_constitution_item_ids = resolved_context - .constitution_items - .iter() - .map(|item| item.id.clone()) - .collect::>(); - let reflection_action_gap_ids = resolved_context - .action_gaps - .iter() - .map(|gap| gap.id.clone()) - .collect::>(); - - // Spawn async task for streaming (doesn't block the IPC response) - tauri::async_runtime::spawn(async move { - // Stream all models concurrently using JoinSet - let mut join_set = tokio::task::JoinSet::new(); - - for model_id in models { - let messages = messages.clone(); - let system_prompt = system_prompt.clone(); - let reasoning_effort = reasoning_effort.clone(); - let provider_route = provider_route.clone(); - let openrouter_arc = openrouter_arc.clone(); - let ollama_arc = ollama_arc.clone(); - let window = window.clone(); - let session_id = session_id_clone.clone(); - let tile_id = tile_id_clone.clone(); - - join_set.spawn(async move { - let stream_result = match provider_route { - ModelProviderRoute::Ollama => { - let ollama = ollama_arc.read().await; - let result = ollama - .chat_stream( - &model_id, - messages, - system_prompt.as_deref(), - Some(temperature), - ) - .await; - drop(ollama); - result.map(|stream| { - Box::pin(stream) - as std::pin::Pin< - Box> + Send>, - > - }) - } - ModelProviderRoute::OpenRouter => { - let openrouter = openrouter_arc.read().await; - let result = openrouter - .chat_stream( - &model_id, - messages, - system_prompt.as_deref(), - Some(temperature), - None, - Some(reasoning_effort.as_str()), - web_search, - web_search_max_results, - ) - .await; - drop(openrouter); - result.map(|stream| { - Box::pin(stream) - as std::pin::Pin< - Box> + Send>, - > - }) - } - }; - - match stream_result { - Ok(stream) => { - let mut stream = stream; - let mut full_content = String::new(); - - loop { - match tokio::time::timeout(Duration::from_secs(60), stream.next()).await - { - Ok(Some(Ok(chunk))) => { - if !chunk.is_empty() { - full_content.push_str(&chunk); - let _ = window.emit( - "canvas-stream", - CanvasStreamEvent::Chunk { - session_id: session_id.clone(), - tile_id: tile_id.clone(), - model_id: model_id.clone(), - chunk, - }, - ); - } - } - Ok(Some(Err(e))) => { - let error = e.to_string(); - emit_canvas_error( - &window, - &session_id, - &tile_id, - &model_id, - &error, - ); - return ( - model_id, - String::new(), - ResponseStatus::Error, - Some(error), - ); - } - Ok(None) => break, // Stream ended naturally - Err(_) => { - let error = "Stream idle timeout (60s)".to_string(); - emit_canvas_error( - &window, - &session_id, - &tile_id, - &model_id, - &error, - ); - return ( - model_id, - full_content, - ResponseStatus::Error, - Some(error), - ); - } - } - } - - finalize_streamed_model_response( - &window, - &session_id, - &tile_id, - model_id, - full_content, - ) - } - Err(e) => { - let error = e.to_string(); - emit_canvas_error(&window, &session_id, &tile_id, &model_id, &error); - (model_id, String::new(), ResponseStatus::Error, Some(error)) - } - } - }); - } - - // Wait for all models and collect results - let mut results = Vec::new(); - while let Some(result) = join_set.join_next().await { - if let Ok(r) = result { - results.push(r); - } - } - - // Batch update store with all results in a single write - let persistence_ok = { - let mut store = canvas_store_arc.write().await; - match store.batch_update_tile_responses(&session_id_clone, &tile_id_clone, &results) { - Ok(()) => true, - Err(error) => { - let model_ids: Vec = results - .iter() - .map(|(model_id, _, _, _)| model_id.clone()) - .collect(); - emit_persistence_error( - &window, - &session_id_clone, - &tile_id_clone, - &model_ids, - &error, - ); - false - } - } - }; - - append_model_result_traces( - twin_store_arc.clone(), - &session_id_clone, - &tile_id_clone, - "send_prompt", - &results, - ) - .await; - - if let Some(decision_episode_id) = decision_episode_id_for_reflection { - let mut twin_store = twin_store_arc.write().await; - for (model_id, content, status, _) in &results { - if *status != ResponseStatus::Completed || content.trim().is_empty() { - continue; - } - - let _ = twin_store.record_reflection_card(ReflectionCardCreate { - decision_episode_id: decision_episode_id.clone(), - session_id: session_id_clone.clone(), - tile_id: tile_id_clone.clone(), - model_id: model_id.clone(), - content: content.clone(), - cited_note_ids: reflection_note_ids.clone(), - cited_user_record_ids: reflection_user_record_ids.clone(), - cited_constitution_item_ids: reflection_constitution_item_ids.clone(), - cited_action_gap_ids: reflection_action_gap_ids.clone(), - evidence_packet: None, - }); - } - } - - // Emit session saved after all models complete — but only if the - // batch persist above actually succeeded. If it failed, per-model - // Error events were already emitted and the frontend must not - // believe the (in-memory only) responses were saved to disk. - if persistence_ok { - let _ = window.emit( - "canvas-stream", - CanvasStreamEvent::SessionSaved { - session_id: session_id_clone, - }, - ); - } - }); - - // Sealed twin prediction: one hidden, non-streaming call per decision - // episode with at least two options. Fire-and-forget in its own task — - // it never emits canvas-stream events and cannot block or fail the - // visible flow above. - if let Some(episode_id) = tile.decision_episode_id.clone() { - let metadata = tile.decision_metadata.clone().unwrap_or_default(); - if metadata.options.len() >= 2 { - let prediction_model = match model_route.provider { - ModelProviderRoute::Ollama => tile.models.first().cloned().unwrap_or_default(), - ModelProviderRoute::OpenRouter => { - let settings = state.settings_service.read().await; - settings.get().llm_model.clone() - } - }; - let decision = if metadata.decision.trim().is_empty() { - tile.prompt.clone() - } else { - metadata.decision.clone() - }; - let context_version = resolved_context - .context_version - .clone() - .unwrap_or_else(|| TWIN_CONTEXT_VERSION.to_string()); - tauri::async_runtime::spawn(run_sealed_twin_prediction( - state.twin_store.clone(), - state.openrouter.clone(), - state.ollama.clone(), - model_route.provider.clone(), - prediction_model, - episode_id, - tile.prompt.clone(), - decision, - metadata.options.clone(), - metadata.stakes.clone(), - resolved_context.twin_context_prompt.clone(), - context_version, - tile.decision_metadata.clone(), - )); - } - } - - Ok(tile_id) -} - -/// Update a tile's position -#[tauri::command] -pub async fn update_tile_position( - session_id: String, - tile_id: String, - position: TilePositionUpdate, - state: State<'_, AppState>, -) -> Result<(), String> { - let mut store = state.canvas_store.write().await; - store - .update_tile_position(&session_id, &tile_id, position) - .map_err(|e| e.to_string())?; - Ok(()) -} - -/// Delete a prompt tile and its children -#[tauri::command] -pub async fn delete_tile( - session_id: String, - tile_id: String, - state: State<'_, AppState>, -) -> Result<(), String> { - let mut store = state.canvas_store.write().await; - store - .delete_tile(&session_id, &tile_id) - .map_err(|e| e.to_string())?; - drop(store); - - append_canvas_trace( - state.twin_store.clone(), - &session_id, - TraceEventType::TileDeleted, - json!({ - "tile_id": tile_id, - }), - ) - .await; - - Ok(()) -} - -/// Delete a single model response from a tile -#[tauri::command] -pub async fn delete_response( - session_id: String, - tile_id: String, - model_id: String, - state: State<'_, AppState>, -) -> Result<(), String> { - let mut store = state.canvas_store.write().await; - store - .delete_response(&session_id, &tile_id, &model_id) - .map_err(|e| e.to_string())?; - drop(store); - - append_canvas_trace( - state.twin_store.clone(), - &session_id, - TraceEventType::ResponseDeleted, - json!({ - "tile_id": tile_id, - "model_id": model_id, - }), - ) - .await; - - Ok(()) -} - -/// Update viewport zoom/pan state -#[tauri::command] -pub async fn update_viewport( - session_id: String, - viewport: CanvasViewport, - state: State<'_, AppState>, -) -> Result<(), String> { - let mut store = state.canvas_store.write().await; - store - .update_viewport(&session_id, viewport) - .map_err(|e| e.to_string()) -} - -/// Update an LLM response node's position -#[tauri::command] -pub async fn update_llm_node_position( - session_id: String, - tile_id: String, - model_id: String, - position: LLMNodePositionUpdate, - state: State<'_, AppState>, -) -> Result<(), String> { - let mut store = state.canvas_store.write().await; - store - .update_llm_node_position(&session_id, &tile_id, &model_id, position) - .map_err(|e| e.to_string()) -} - -/// Auto-arrange all nodes (batch position update) -#[tauri::command] -pub async fn auto_arrange( - session_id: String, - positions: HashMap, - state: State<'_, AppState>, -) -> Result<(), String> { - let mut store = state.canvas_store.write().await; - store - .batch_update_positions(&session_id, positions) - .map_err(|e| e.to_string()) -} - -/// Export canvas session to a note (returns note info) -#[tauri::command] -pub async fn export_to_note( - session_id: String, - state: State<'_, AppState>, -) -> Result { - let mut store = state.canvas_store.write().await; - let session = store.get_session(&session_id).map_err(|e| e.to_string())?; - drop(store); - - // Build markdown content from session - let mut content = format!("# {}\n\n", session.title); - - if let Some(desc) = &session.description { - content.push_str(&format!("{}\n\n", desc)); - } - - for tile in &session.prompt_tiles { - content.push_str(&format!("## Prompt\n\n{}\n\n", tile.prompt)); - - for (model_id, response) in &tile.responses { - if response.status == ResponseStatus::Completed { - content.push_str(&format!( - "### {} ({})\n\n{}\n\n", - response.model_name, model_id, response.content - )); - } - } - } - - for debate in &session.debates { - content.push_str("## Debate\n\n"); - for round in &debate.rounds { - content.push_str(&format!( - "### Round {} - {}\n\n", - round.round_number, round.topic - )); - for resp in &round.responses { - content.push_str(&format!( - "**{} ({}):**\n\n{}\n\n", - resp.model_name, resp.model_id, resp.content - )); - } - } - } - - // Create note via knowledge store - let mut ks = state.knowledge_store.write().await; - - let note = ks - .create_note(NoteCreate { - title: session.title.clone(), - content, - relative_path: None, - aliases: Vec::new(), - status: NoteStatus::Evidence, - tags: session.tags.clone(), - schema_version: crate::models::note::CURRENT_NOTE_SCHEMA_VERSION, - migration_source: None, - optimizer_managed: false, - properties: HashMap::new(), - }) - .map_err(|e| e.to_string())?; - - drop(ks); - - commit_note_write(state.inner(), ¬e.id, "note_exported").await?; - - append_canvas_trace( - state.twin_store.clone(), - &session_id, - TraceEventType::NoteExported, - json!({ - "note_id": note.id.clone(), - "title": note.title.clone(), - }), - ) - .await; - - Ok(serde_json::json!({ - "note_id": note.id, - "title": note.title, - "updated": false, - })) -} - -/// Start a debate between models with streaming via Tauri events -#[tauri::command] -pub async fn start_debate( - window: tauri::Window, - session_id: String, - mut request: DebateStartRequest, - state: State<'_, AppState>, -) -> Result { - let debate_id = uuid::Uuid::new_v4().to_string(); - let now = Utc::now(); - - // Collect source content from tiles - let mut store = state.canvas_store.write().await; - let session = store.get_session(&session_id).map_err(|e| e.to_string())?; - drop(store); - - let mut source_content = String::new(); - let (source_has_vault_context, source_twin_provider) = - source_tile_context_provider(&session, &request.source_tile_ids); - let model_route = { - let settings = state.settings_service.read().await; - if source_has_vault_context { - resolve_model_route( - &PromptType::Decision, - &ContextMode::Twin, - source_twin_provider.as_deref(), - settings.get(), - )? - } else { - resolve_model_route( - &PromptType::Standard, - &ContextMode::KnowledgeSearch, - None, - settings.get(), - )? - } - }; - request.participating_models = effective_model_ids(&model_route, &request.participating_models); - for tile_id in &request.source_tile_ids { - if let Some(tile) = session.prompt_tiles.iter().find(|t| &t.id == tile_id) { - source_content.push_str(&format!("Prompt: {}\n", tile.prompt)); - for model_id in &request.participating_models { - if let Some(resp) = tile.responses.get(model_id) { - source_content.push_str(&format!( - "{} responded: {}\n", - resp.model_name, resp.content - )); - } - } - } - } - - // Calculate position (to the right of source tiles) - let max_x = session - .prompt_tiles - .iter() - .flat_map(|t| { - t.responses - .values() - .map(|r| r.position.x + r.position.width) - }) - .fold(0.0_f64, f64::max); - - let debate = Debate { - id: debate_id.clone(), - participating_models: request.participating_models.clone(), - source_tile_ids: request.source_tile_ids.clone(), - rounds: Vec::new(), - status: "active".to_string(), - position: TilePosition { - x: max_x + 100.0, - y: 100.0, - width: 400.0, - height: 300.0, - }, - debate_mode: request.debate_mode.clone(), - reasoning_effort: request.reasoning_effort.clone(), - created_at: now, - }; - - // Save debate to session - { - let mut store = state.canvas_store.write().await; - store - .add_debate(&session_id, debate.clone()) - .map_err(|e| e.to_string())?; - } - - append_canvas_trace( - state.twin_store.clone(), - &session_id, - TraceEventType::DebateStarted, - json!({ - "debate_id": debate.id.clone(), - "source_tile_ids": debate.source_tile_ids.clone(), - "participating_models": debate.participating_models.clone(), - "debate_mode": debate.debate_mode.clone(), - "max_rounds": request.max_rounds, - }), - ) - .await; - - // Emit debate created - let _ = window.emit( - "canvas-stream", - CanvasStreamEvent::DebateCreated { - session_id: session_id.clone(), - debate: debate.clone(), - }, - ); - - // Spawn async task for debate streaming - let openrouter_arc = state.openrouter.clone(); - let ollama_arc = state.ollama.clone(); - let canvas_store_arc = state.canvas_store.clone(); - let models = request.participating_models.clone(); - let max_rounds = request.max_rounds; - let reasoning_effort = request.reasoning_effort.clone(); - let provider_route = model_route.provider.clone(); - let debate_id_clone = debate_id.clone(); - let session_id_clone = session_id.clone(); - - tauri::async_runtime::spawn(async move { - let mut debate_state = debate; - - for round_num in 1..=max_rounds { - let _ = window.emit( - "canvas-stream", - CanvasStreamEvent::RoundStart { - session_id: session_id_clone.clone(), - debate_id: debate_id_clone.clone(), - round_number: round_num, - }, - ); - - // Build debate context - let mut context = format!( - "You are participating in a structured debate.\n\nOriginal context:\n{}\n\n", - source_content - ); - - // Add previous rounds for context - for prev_round in &debate_state.rounds { - context.push_str(&format!("Round {}:\n", prev_round.round_number)); - for resp in &prev_round.responses { - context.push_str(&format!("{}: {}\n", resp.model_name, resp.content)); - } - context.push('\n'); - } - - context.push_str(&format!( - "Round {} - Present your analysis. Be concise and insightful. If other models have responded before you, engage with their points.", - round_num - )); - - // Stream all models concurrently within this round using JoinSet - let mut join_set = tokio::task::JoinSet::new(); - - for model_id in models.clone() { - let model_name = model_id.split('/').last().unwrap_or(&model_id).to_string(); - let messages = vec![ChatMessage { - role: "user".to_string(), - content: context.clone(), - }]; - let openrouter_arc = openrouter_arc.clone(); - let ollama_arc = ollama_arc.clone(); - let window = window.clone(); - let session_id = session_id_clone.clone(); - let debate_id = debate_id_clone.clone(); - let reasoning_effort = reasoning_effort.clone(); - let provider_route = provider_route.clone(); - - join_set.spawn(async move { - let stream_result = match provider_route { - ModelProviderRoute::Ollama => { - let ollama = ollama_arc.read().await; - let result = ollama - .chat_stream(&model_id, messages, None, Some(0.7)) - .await; - drop(ollama); - result.map(|stream| { - Box::pin(stream) - as std::pin::Pin< - Box< - dyn futures::Stream> - + Send, - >, - > - }) - } - ModelProviderRoute::OpenRouter => { - let openrouter = openrouter_arc.read().await; - let result = openrouter - .chat_stream( - &model_id, - messages, - None, - Some(0.7), - None, - Some(reasoning_effort.as_str()), - false, - 5, - ) - .await; - drop(openrouter); - result.map(|stream| { - Box::pin(stream) - as std::pin::Pin< - Box< - dyn futures::Stream> - + Send, - >, - > - }) - } - }; - - match stream_result { - Ok(stream) => { - let mut stream = stream; - let mut full_content = String::new(); - - loop { - match tokio::time::timeout(Duration::from_secs(60), stream.next()) - .await - { - Ok(Some(Ok(chunk))) => { - if !chunk.is_empty() { - full_content.push_str(&chunk); - let _ = window.emit( - "canvas-stream", - CanvasStreamEvent::DebateChunk { - session_id: session_id.clone(), - debate_id: debate_id.clone(), - model_id: model_id.clone(), - chunk, - round_number: round_num, - }, - ); - } - } - Ok(Some(Err(e))) => { - let _ = window.emit( - "canvas-stream", - CanvasStreamEvent::DebateError { - session_id: session_id.clone(), - debate_id: debate_id.clone(), - model_id: model_id.clone(), - error: e.to_string(), - round_number: round_num, - }, - ); - return DebateResponse { - model_id, - model_name, - content: full_content, - stance: None, - }; - } - Ok(None) => break, // Stream ended naturally - Err(_) => { - let _ = window.emit( - "canvas-stream", - CanvasStreamEvent::DebateError { - session_id: session_id.clone(), - debate_id: debate_id.clone(), - model_id: model_id.clone(), - error: "Stream idle timeout (60s)".to_string(), - round_number: round_num, - }, - ); - return DebateResponse { - model_id, - model_name, - content: full_content, - stance: None, - }; - } - } - } - - let _ = window.emit( - "canvas-stream", - CanvasStreamEvent::ModelComplete { - session_id: session_id.clone(), - debate_id: debate_id.clone(), - model_id: model_id.clone(), - round_number: round_num, - }, - ); - - DebateResponse { - model_id, - model_name, - content: full_content, - stance: None, - } - } - Err(e) => { - let _ = window.emit( - "canvas-stream", - CanvasStreamEvent::DebateError { - session_id: session_id.clone(), - debate_id: debate_id.clone(), - model_id: model_id.clone(), - error: e.to_string(), - round_number: round_num, - }, - ); - let _ = window.emit( - "canvas-stream", - CanvasStreamEvent::ModelComplete { - session_id: session_id.clone(), - debate_id: debate_id.clone(), - model_id: model_id.clone(), - round_number: round_num, - }, - ); - DebateResponse { - model_id, - model_name, - content: e.to_string(), - stance: None, - } - } - } - }); - } - - // Collect all model responses from this round - let mut round_responses = Vec::new(); - while let Some(result) = join_set.join_next().await { - if let Ok(response) = result { - round_responses.push(response); - } - } - - // Save round - let round = DebateRound { - round_number: round_num, - topic: format!("Round {}", round_num), - responses: round_responses, - created_at: Utc::now(), - }; - debate_state.rounds.push(round); - - // Persist after each round. If this fails, surface it instead of - // silently continuing to stream rounds that will never survive a - // session reopen. - let round_persisted = { - let mut store = canvas_store_arc.write().await; - match store.update_debate(&session_id_clone, &debate_state) { - Ok(()) => true, - Err(error) => { - emit_debate_persist_error( - &window, - &session_id_clone, - &debate_id_clone, - round_num, - &debate_state, - &error, - ); - false - } - } - }; - - if !round_persisted { - return; - } - } - - // Mark debate as complete - debate_state.status = "completed".to_string(); - let final_round_number = debate_state - .rounds - .last() - .map(|round| round.round_number) - .unwrap_or(max_rounds); - let completion_persisted = { - let mut store = canvas_store_arc.write().await; - match store.update_debate(&session_id_clone, &debate_state) { - Ok(()) => true, - Err(error) => { - emit_debate_persist_error( - &window, - &session_id_clone, - &debate_id_clone, - final_round_number, - &debate_state, - &error, - ); - false - } - } - }; - - if completion_persisted { - let _ = window.emit( - "canvas-stream", - CanvasStreamEvent::DebateComplete { - session_id: session_id_clone, - debate_id: debate_id_clone, - }, - ); - } - }); - - Ok(debate_id) -} - -/// Continue a debate with a new round -#[tauri::command] -pub async fn continue_debate( - window: tauri::Window, - session_id: String, - debate_id: String, - request: DebateContinueRequest, - state: State<'_, AppState>, -) -> Result<(), String> { - let mut store = state.canvas_store.write().await; - let session = store.get_session(&session_id).map_err(|e| e.to_string())?; - drop(store); - - let debate = session - .debates - .iter() - .find(|d| d.id == debate_id) - .ok_or_else(|| "Debate not found".to_string())? - .clone(); - let (source_has_vault_context, source_twin_provider) = - source_tile_context_provider(&session, &debate.source_tile_ids); - let model_route = { - let settings = state.settings_service.read().await; - if source_has_vault_context { - resolve_model_route( - &PromptType::Decision, - &ContextMode::Twin, - source_twin_provider.as_deref(), - settings.get(), - )? - } else { - resolve_model_route( - &PromptType::Standard, - &ContextMode::KnowledgeSearch, - None, - settings.get(), - )? - } - }; - - append_canvas_trace( - state.twin_store.clone(), - &session_id, - TraceEventType::DebateContinued, - json!({ - "debate_id": debate_id.clone(), - "prompt": request.prompt.clone(), - "participating_models": debate.participating_models.clone(), - }), - ) - .await; - - let openrouter_arc = state.openrouter.clone(); - let ollama_arc = state.ollama.clone(); - let canvas_store_arc = state.canvas_store.clone(); - let models = effective_model_ids(&model_route, &debate.participating_models); - let reasoning_effort = request.reasoning_effort.clone(); - let provider_route = model_route.provider.clone(); - - tauri::async_runtime::spawn(async move { - let mut debate_state = debate; - let round_num = debate_state.rounds.len() as u32 + 1; - - let _ = window.emit( - "canvas-stream", - CanvasStreamEvent::RoundStart { - session_id: session_id.clone(), - debate_id: debate_id.clone(), - round_number: round_num, - }, - ); - - // Build context from previous rounds + new prompt - let mut context = String::from("Previous debate rounds:\n\n"); - for round in &debate_state.rounds { - context.push_str(&format!("Round {}:\n", round.round_number)); - for resp in &round.responses { - context.push_str(&format!("{}: {}\n", resp.model_name, resp.content)); - } - context.push('\n'); - } - context.push_str(&format!( - "New prompt: {}\n\nRespond to this new direction.", - request.prompt - )); - - // Stream all models concurrently using JoinSet - let mut join_set = tokio::task::JoinSet::new(); - - for model_id in models { - let model_name = model_id.split('/').last().unwrap_or(&model_id).to_string(); - let messages = vec![ChatMessage { - role: "user".to_string(), - content: context.clone(), - }]; - let openrouter_arc = openrouter_arc.clone(); - let ollama_arc = ollama_arc.clone(); - let window = window.clone(); - let session_id = session_id.clone(); - let debate_id = debate_id.clone(); - let reasoning_effort = reasoning_effort.clone(); - let provider_route = provider_route.clone(); - - join_set.spawn(async move { - let stream_result = match provider_route { - ModelProviderRoute::Ollama => { - let ollama = ollama_arc.read().await; - let result = ollama - .chat_stream(&model_id, messages, None, Some(0.7)) - .await; - drop(ollama); - result.map(|stream| { - Box::pin(stream) - as std::pin::Pin< - Box> + Send>, - > - }) - } - ModelProviderRoute::OpenRouter => { - let openrouter = openrouter_arc.read().await; - let result = openrouter - .chat_stream( - &model_id, - messages, - None, - Some(0.7), - None, - Some(reasoning_effort.as_str()), - false, - 5, - ) - .await; - drop(openrouter); - result.map(|stream| { - Box::pin(stream) - as std::pin::Pin< - Box> + Send>, - > - }) - } - }; - - match stream_result { - Ok(stream) => { - let mut stream = stream; - let mut full_content = String::new(); - - loop { - match tokio::time::timeout(Duration::from_secs(60), stream.next()).await - { - Ok(Some(Ok(chunk))) => { - if !chunk.is_empty() { - full_content.push_str(&chunk); - let _ = window.emit( - "canvas-stream", - CanvasStreamEvent::DebateChunk { - session_id: session_id.clone(), - debate_id: debate_id.clone(), - model_id: model_id.clone(), - chunk, - round_number: round_num, - }, - ); - } - } - Ok(Some(Err(e))) => { - let _ = window.emit( - "canvas-stream", - CanvasStreamEvent::DebateError { - session_id: session_id.clone(), - debate_id: debate_id.clone(), - model_id: model_id.clone(), - error: e.to_string(), - round_number: round_num, - }, - ); - return DebateResponse { - model_id, - model_name, - content: full_content, - stance: None, - }; - } - Ok(None) => break, // Stream ended naturally - Err(_) => { - let _ = window.emit( - "canvas-stream", - CanvasStreamEvent::DebateError { - session_id: session_id.clone(), - debate_id: debate_id.clone(), - model_id: model_id.clone(), - error: "Stream idle timeout (60s)".to_string(), - round_number: round_num, - }, - ); - return DebateResponse { - model_id, - model_name, - content: full_content, - stance: None, - }; - } - } - } - - let _ = window.emit( - "canvas-stream", - CanvasStreamEvent::ModelComplete { - session_id: session_id.clone(), - debate_id: debate_id.clone(), - model_id: model_id.clone(), - round_number: round_num, - }, - ); - - DebateResponse { - model_id, - model_name, - content: full_content, - stance: None, - } - } - Err(e) => { - let _ = window.emit( - "canvas-stream", - CanvasStreamEvent::DebateError { - session_id: session_id.clone(), - debate_id: debate_id.clone(), - model_id: model_id.clone(), - error: e.to_string(), - round_number: round_num, - }, - ); - let _ = window.emit( - "canvas-stream", - CanvasStreamEvent::ModelComplete { - session_id: session_id.clone(), - debate_id: debate_id.clone(), - model_id: model_id.clone(), - round_number: round_num, - }, - ); - DebateResponse { - model_id, - model_name, - content: e.to_string(), - stance: None, - } - } - } - }); - } - - // Collect all model responses from this round - let mut round_responses = Vec::new(); - while let Some(result) = join_set.join_next().await { - if let Ok(response) = result { - round_responses.push(response); - } - } - - // Save round - let round = DebateRound { - round_number: round_num, - topic: request.prompt, - responses: round_responses, - created_at: Utc::now(), - }; - debate_state.rounds.push(round); - - let persisted = { - let mut store = canvas_store_arc.write().await; - match store.update_debate(&session_id, &debate_state) { - Ok(()) => true, - Err(error) => { - emit_debate_persist_error( - &window, - &session_id, - &debate_id, - round_num, - &debate_state, - &error, - ); - false - } - } - }; - - if persisted { - let _ = window.emit( - "canvas-stream", - CanvasStreamEvent::DebateComplete { - session_id, - debate_id, - }, - ); - } - }); - - Ok(()) -} - -/// Add new models to an existing tile (same prompt, new model responses) -#[tauri::command] -pub async fn add_models_to_tile( - window: tauri::Window, - session_id: String, - tile_id: String, - request: AddModelsRequest, - state: State<'_, AppState>, -) -> Result<(), String> { - // Get the tile's prompt - let mut store = state.canvas_store.write().await; - let session = store.get_session(&session_id).map_err(|e| e.to_string())?; - drop(store); - - let tile = session - .prompt_tiles - .iter() - .find(|t| t.id == tile_id) - .ok_or_else(|| "Tile not found".to_string())? - .clone(); - let model_route = { - let settings = state.settings_service.read().await; - resolve_model_route( - &tile.prompt_type, - &tile.context_mode, - tile.twin_llm_provider.as_deref(), - settings.get(), - )? - }; - let model_ids = effective_model_ids(&model_route, &request.model_ids); - let prompt_request = prompt_request_from_tile(&tile, model_ids.clone(), 0.7); - let resolved_context = resolve_prompt_context(state.inner(), &session, &prompt_request).await?; - - let now = Utc::now(); - - // Calculate positions for new models - let existing_count = tile.responses.len(); - let prompt_pos = &tile.position; - let llm_start_x = prompt_pos.x + prompt_pos.width + LLM_NODE_X_GAP; - - // Add initial pending responses and notify frontend before streaming - let mut new_responses: HashMap = HashMap::new(); - { - let mut store = state.canvas_store.write().await; - let mut session = store.get_session(&session_id).map_err(|e| e.to_string())?; - if let Some(t) = session.prompt_tiles.iter_mut().find(|t| t.id == tile_id) { - for (i, model_id) in model_ids.iter().enumerate() { - let model_name = model_id.split('/').last().unwrap_or(model_id).to_string(); - let llm_y = prompt_pos.y + ((existing_count + i) as f64) * LLM_NODE_Y_STEP; - let response = ModelResponse { - id: uuid::Uuid::new_v4().to_string(), - model_id: model_id.clone(), - model_name, - content: String::new(), - status: ResponseStatus::Pending, - error: None, - tokens_used: None, - created_at: now, - position: TilePosition { - x: llm_start_x, - y: llm_y, - width: LLM_NODE_WIDTH, - height: LLM_NODE_HEIGHT, - }, - }; - new_responses.insert(model_id.clone(), response.clone()); - t.models.push(model_id.clone()); - t.responses.insert(model_id.clone(), response); - } - session.updated_at = Utc::now(); - store.save_session(&session).map_err(|e| e.to_string())?; - } - } - - // Notify frontend about new responses before streaming starts - let _ = window.emit( - "canvas-stream", - CanvasStreamEvent::ModelsAdded { - session_id: session_id.clone(), - tile_id: tile_id.clone(), - responses: new_responses, - }, - ); - - // Spawn streaming task - let openrouter_arc = state.openrouter.clone(); - let ollama_arc = state.ollama.clone(); - let canvas_store_arc = state.canvas_store.clone(); - let twin_store_arc = state.twin_store.clone(); - let messages = resolved_context.messages.clone(); - let system_prompt = resolved_context.system_prompt.clone(); - let web_search = prompt_request.web_search; - let web_search_max_results = prompt_request.web_search_max_results; - let reasoning_effort = prompt_request.reasoning_effort.clone(); - let provider_route = model_route.provider.clone(); - - tauri::async_runtime::spawn(async move { - // Stream all new models concurrently using JoinSet - let mut join_set = tokio::task::JoinSet::new(); - - for model_id in model_ids { - let messages = messages.clone(); - let system_prompt = system_prompt.clone(); - let openrouter_arc = openrouter_arc.clone(); - let window = window.clone(); - let session_id = session_id.clone(); - let tile_id = tile_id.clone(); - let reasoning_effort = reasoning_effort.clone(); - let provider_route = provider_route.clone(); - let ollama_arc = ollama_arc.clone(); - - join_set.spawn(async move { - let stream_result = match provider_route { - ModelProviderRoute::Ollama => { - let ollama = ollama_arc.read().await; - let result = ollama - .chat_stream(&model_id, messages, system_prompt.as_deref(), Some(0.7)) - .await; - drop(ollama); - result.map(|stream| { - Box::pin(stream) - as std::pin::Pin< - Box> + Send>, - > - }) - } - ModelProviderRoute::OpenRouter => { - let openrouter = openrouter_arc.read().await; - let result = openrouter - .chat_stream( - &model_id, - messages, - system_prompt.as_deref(), - Some(0.7), - None, - Some(reasoning_effort.as_str()), - web_search, - web_search_max_results, - ) - .await; - drop(openrouter); - result.map(|stream| { - Box::pin(stream) - as std::pin::Pin< - Box> + Send>, - > - }) - } - }; - - match stream_result { - Ok(stream) => { - let mut stream = stream; - let mut full_content = String::new(); - - loop { - match tokio::time::timeout(Duration::from_secs(60), stream.next()).await - { - Ok(Some(Ok(chunk))) => { - if !chunk.is_empty() { - full_content.push_str(&chunk); - let _ = window.emit( - "canvas-stream", - CanvasStreamEvent::Chunk { - session_id: session_id.clone(), - tile_id: tile_id.clone(), - model_id: model_id.clone(), - chunk, - }, - ); - } - } - Ok(Some(Err(e))) => { - let error = e.to_string(); - emit_canvas_error( - &window, - &session_id, - &tile_id, - &model_id, - &error, - ); - return ( - model_id, - String::new(), - ResponseStatus::Error, - Some(error), - ); - } - Ok(None) => break, - Err(_) => { - let error = "Stream idle timeout (60s)".to_string(); - emit_canvas_error( - &window, - &session_id, - &tile_id, - &model_id, - &error, - ); - return ( - model_id, - full_content, - ResponseStatus::Error, - Some(error), - ); - } - } - } - - finalize_streamed_model_response( - &window, - &session_id, - &tile_id, - model_id, - full_content, - ) - } - Err(e) => { - let error = e.to_string(); - emit_canvas_error(&window, &session_id, &tile_id, &model_id, &error); - (model_id, String::new(), ResponseStatus::Error, Some(error)) - } - } - }); - } - - // Wait for all models and collect results - let mut results = Vec::new(); - while let Some(result) = join_set.join_next().await { - if let Ok(r) = result { - results.push(r); - } - } - - // Batch update store - let persistence_ok = { - let mut store = canvas_store_arc.write().await; - match store.batch_update_tile_responses(&session_id, &tile_id, &results) { - Ok(()) => true, - Err(error) => { - let model_ids: Vec = results - .iter() - .map(|(model_id, _, _, _)| model_id.clone()) - .collect(); - emit_persistence_error(&window, &session_id, &tile_id, &model_ids, &error); - false - } - } - }; - - append_canvas_trace( - twin_store_arc.clone(), - &session_id, - TraceEventType::ModelsAdded, - json!({ - "tile_id": tile_id.clone(), - "model_ids": results.iter().map(|(model_id, _, _, _)| model_id.clone()).collect::>(), - }), - ) - .await; - append_model_result_traces( - twin_store_arc.clone(), - &session_id, - &tile_id, - "add_models_to_tile", - &results, - ) - .await; - - if persistence_ok { - let _ = window.emit( - "canvas-stream", - CanvasStreamEvent::SessionSaved { session_id }, - ); - } - }); - - Ok(()) -} - -/// Regenerate a single model's response -#[tauri::command] -pub async fn regenerate_response( - window: tauri::Window, - session_id: String, - tile_id: String, - model_id: String, - state: State<'_, AppState>, -) -> Result<(), String> { - // Get the tile's prompt - let mut store = state.canvas_store.write().await; - let session = store.get_session(&session_id).map_err(|e| e.to_string())?; - drop(store); - - let tile = session - .prompt_tiles - .iter() - .find(|t| t.id == tile_id) - .ok_or_else(|| "Tile not found".to_string())?; - - let model_route = { - let settings = state.settings_service.read().await; - resolve_model_route( - &tile.prompt_type, - &tile.context_mode, - tile.twin_llm_provider.as_deref(), - settings.get(), - )? - }; - let effective_model_id = effective_model_ids(&model_route, std::slice::from_ref(&model_id)) - .into_iter() - .next() - .unwrap_or_else(|| model_id.clone()); - - if !tile.responses.contains_key(&effective_model_id) { - return Err("Response not found".to_string()); - } - - let request = prompt_request_from_tile(tile, vec![effective_model_id.clone()], 0.7); - let resolved_context = resolve_prompt_context(state.inner(), &session, &request).await?; - - // Reset response to streaming - { - let mut store = state.canvas_store.write().await; - let _ = store.update_tile_response( - &session_id, - &tile_id, - &effective_model_id, - "", - ResponseStatus::Streaming, - None, - ); - } - - let openrouter_arc = state.openrouter.clone(); - let ollama_arc = state.ollama.clone(); - let canvas_store_arc = state.canvas_store.clone(); - let twin_store_arc = state.twin_store.clone(); - let messages = resolved_context.messages.clone(); - let system_prompt = resolved_context.system_prompt.clone(); - let web_search = request.web_search; - let web_search_max_results = request.web_search_max_results; - let reasoning_effort = request.reasoning_effort.clone(); - let provider_route = model_route.provider.clone(); - - tauri::async_runtime::spawn(async move { - let model_id = effective_model_id; - let stream_result = match provider_route { - ModelProviderRoute::Ollama => { - let ollama = ollama_arc.read().await; - let result = ollama - .chat_stream(&model_id, messages, system_prompt.as_deref(), Some(0.7)) - .await; - drop(ollama); - result.map(|stream| { - Box::pin(stream) - as std::pin::Pin< - Box> + Send>, - > - }) - } - ModelProviderRoute::OpenRouter => { - let openrouter = openrouter_arc.read().await; - let result = openrouter - .chat_stream( - &model_id, - messages, - system_prompt.as_deref(), - Some(0.7), - None, - Some(reasoning_effort.as_str()), - web_search, - web_search_max_results, - ) - .await; - drop(openrouter); - result.map(|stream| { - Box::pin(stream) - as std::pin::Pin< - Box> + Send>, - > - }) - } - }; - - let mut persistence_ok = true; - - match stream_result { - Ok(stream) => { - let mut stream = stream; - let mut full_content = String::new(); - let mut final_error: Option = None; - - loop { - match tokio::time::timeout(Duration::from_secs(60), stream.next()).await { - Ok(Some(Ok(chunk))) => { - if !chunk.is_empty() { - full_content.push_str(&chunk); - let _ = window.emit( - "canvas-stream", - CanvasStreamEvent::Chunk { - session_id: session_id.clone(), - tile_id: tile_id.clone(), - model_id: model_id.clone(), - chunk, - }, - ); - } - } - Ok(Some(Err(e))) => { - let error = e.to_string(); - emit_canvas_error(&window, &session_id, &tile_id, &model_id, &error); - final_error = Some(error); - break; - } - Ok(None) => break, // Stream ended naturally - Err(_) => { - let error = "Stream idle timeout (60s)".to_string(); - emit_canvas_error(&window, &session_id, &tile_id, &model_id, &error); - final_error = Some(error); - break; - } - } - } - - let (final_content, final_status) = if final_error.is_some() { - (full_content, ResponseStatus::Error) - } else { - let update = - classify_streamed_model_response(model_id.clone(), full_content.clone()); - final_error = update.3.clone(); - if let Some(error) = final_error.as_deref() { - emit_canvas_error(&window, &session_id, &tile_id, &model_id, error); - } else { - emit_canvas_complete(&window, &session_id, &tile_id, &model_id); - } - (update.1, update.2) - }; - - { - let mut store = canvas_store_arc.write().await; - if let Err(persist_error) = store.update_tile_response( - &session_id, - &tile_id, - &model_id, - &final_content, - final_status.clone(), - final_error.as_deref(), - ) { - emit_persistence_error( - &window, - &session_id, - &tile_id, - std::slice::from_ref(&model_id), - &persist_error, - ); - persistence_ok = false; - } - } - - append_canvas_trace( - twin_store_arc.clone(), - &session_id, - TraceEventType::ResponseRegenerated, - json!({ - "tile_id": tile_id.clone(), - "model_id": model_id.clone(), - "status": final_status, - "error": final_error.clone(), - "content": final_content.clone(), - }), - ) - .await; - } - Err(e) => { - let error = e.to_string(); - { - let mut store = canvas_store_arc.write().await; - if let Err(persist_error) = store.update_tile_response( - &session_id, - &tile_id, - &model_id, - "", - ResponseStatus::Error, - Some(error.as_str()), - ) { - emit_persistence_error( - &window, - &session_id, - &tile_id, - std::slice::from_ref(&model_id), - &persist_error, - ); - persistence_ok = false; - } - } - emit_canvas_error(&window, &session_id, &tile_id, &model_id, &error); - append_canvas_trace( - twin_store_arc.clone(), - &session_id, - TraceEventType::ResponseRegenerated, - json!({ - "tile_id": tile_id.clone(), - "model_id": model_id.clone(), - "status": ResponseStatus::Error, - "error": error, - "content": "", - }), - ) - .await; - } - } - - if persistence_ok { - let _ = window.emit( - "canvas-stream", - CanvasStreamEvent::SessionSaved { session_id }, - ); - } - }); - - Ok(()) -} - -fn prompt_request_from_tile( - tile: &PromptTile, - models: Vec, - temperature: f64, -) -> PromptRequest { - PromptRequest { - prompt: tile.prompt.clone(), - prompt_type: tile.prompt_type.clone(), - system_prompt: tile.system_prompt.clone(), - models, - position: None, - context_mode: tile.context_mode.clone(), - twin_answer_mode: tile.twin_answer_mode.clone(), - twin_context_policy: tile.twin_context_policy.clone(), - twin_llm_provider: tile.twin_llm_provider.clone(), - decision_metadata: tile.decision_metadata.clone(), - parent_tile_id: tile.parent_tile_id.clone(), - parent_model_id: tile.parent_model_id.clone(), - temperature, - max_tokens: None, - web_search: tile.web_search, - web_search_max_results: tile.web_search_max_results, - reasoning_effort: tile.reasoning_effort.clone(), - } -} - -fn resolve_model_route( - prompt_type: &PromptType, - context_mode: &ContextMode, - twin_provider_override: Option<&str>, - settings: &UserSettings, -) -> Result { - let twin_provider = twin_provider_override - .map(|provider| provider.trim().to_ascii_lowercase()) - .filter(|provider| provider == "ollama" || provider == "openrouter") - .unwrap_or_else(|| settings.twin_llm_provider.to_ascii_lowercase()); - - if is_vault_context_prompt(prompt_type, context_mode) && twin_provider == "ollama" { - let model = settings.ollama_model.trim(); - if model.is_empty() { - return Err( - "Select an Ollama model for local vault/twin responses before sending vault context" - .to_string(), - ); - } - - return Ok(ModelRoute { - provider: ModelProviderRoute::Ollama, - model_ids: vec![model.to_string()], - }); - } - - Ok(ModelRoute { - provider: ModelProviderRoute::OpenRouter, - model_ids: Vec::new(), - }) -} - -fn source_tile_context_provider( - session: &CanvasSession, - source_tile_ids: &[String], -) -> (bool, Option) { - for tile_id in source_tile_ids { - if let Some(tile) = session.prompt_tiles.iter().find(|tile| &tile.id == tile_id) { - if is_vault_context_prompt(&tile.prompt_type, &tile.context_mode) { - return (true, tile.twin_llm_provider.clone()); - } - } - } - - (false, None) -} - -fn is_vault_context_prompt(prompt_type: &PromptType, context_mode: &ContextMode) -> bool { - prompt_type == &PromptType::Decision - || matches!( - context_mode, - ContextMode::KnowledgeSearch - | ContextMode::Semantic - | ContextMode::Twin - | ContextMode::FullHistory - | ContextMode::Compact - ) -} - -fn effective_model_ids(route: &ModelRoute, requested_model_ids: &[String]) -> Vec { - match route.provider { - ModelProviderRoute::Ollama => { - if requested_model_ids.is_empty() { - route.model_ids.clone() - } else { - requested_model_ids.to_vec() - } - } - ModelProviderRoute::OpenRouter => requested_model_ids.to_vec(), - } -} - -async fn append_canvas_trace( - twin_store_arc: Arc>, - session_id: &str, - event_type: TraceEventType, - payload: serde_json::Value, -) { - let mut twin_store = twin_store_arc.write().await; - if let Err(error) = twin_store.append_trace_event(session_id, event_type, payload) { - log::error!( - "Failed to append twin trace for session '{}': {}", - session_id, - error - ); - } -} - -async fn append_model_result_traces( - twin_store_arc: Arc>, - session_id: &str, - tile_id: &str, - trigger: &str, - results: &[StreamedResponseUpdate], -) { - for (model_id, content, status, error) in results { - let event_type = if *status == ResponseStatus::Error { - TraceEventType::ResponseErrored - } else { - TraceEventType::ResponseCompleted - }; - - append_canvas_trace( - twin_store_arc.clone(), - session_id, - event_type, - json!({ - "tile_id": tile_id, - "model_id": model_id, - "trigger": trigger, - "status": status, - "content": content, - "error": error, - }), - ) - .await; - } -} - -fn emit_canvas_error( - window: &tauri::Window, - session_id: &str, - tile_id: &str, - model_id: &str, - error: &str, -) { - let _ = window.emit( - "canvas-stream", - CanvasStreamEvent::Error { - session_id: session_id.to_string(), - tile_id: tile_id.to_string(), - model_id: model_id.to_string(), - error: error.to_string(), - }, - ); -} - -/// Emit an Error event for every model whose response was streamed -/// successfully but then failed to persist to the session file (disk full, -/// file locked, etc.). Without this, the frontend has already rendered a -/// complete response, `SessionSaved` would otherwise still fire, and the -/// content silently reverts to an empty Pending stub the next time the -/// session is reopened — with no error anywhere in the UI. -fn emit_persistence_error( - window: &tauri::Window, - session_id: &str, - tile_id: &str, - model_ids: &[String], - error: &anyhow::Error, -) { - log::error!( - "Failed to persist canvas tile '{}' responses for session '{}': {}", - tile_id, - session_id, - error - ); - - let message = format!("Failed to save response: {}", error); - for model_id in model_ids { - emit_canvas_error(window, session_id, tile_id, model_id, &message); - } -} - -/// Same contract as `emit_persistence_error` but for debate rounds, which -/// don't have a single tile/model — a round can involve several models at -/// once. Emits a `DebateError` per participating model in the round that -/// failed to persist so the frontend has something concrete to render. -fn emit_debate_persist_error( - window: &tauri::Window, - session_id: &str, - debate_id: &str, - round_number: u32, - debate_state: &Debate, - error: &anyhow::Error, -) { - log::error!( - "Failed to persist debate '{}' round {} for session '{}': {}", - debate_id, - round_number, - session_id, - error - ); - - let model_ids: Vec = debate_state - .rounds - .last() - .map(|round| round.responses.iter().map(|r| r.model_id.clone()).collect()) - .unwrap_or_default(); - - let message = format!("Failed to save debate round: {}", error); - - if model_ids.is_empty() { - let _ = window.emit( - "canvas-stream", - CanvasStreamEvent::DebateError { - session_id: session_id.to_string(), - debate_id: debate_id.to_string(), - model_id: "system".to_string(), - error: message, - round_number, - }, - ); - return; - } - - for model_id in model_ids { - let _ = window.emit( - "canvas-stream", - CanvasStreamEvent::DebateError { - session_id: session_id.to_string(), - debate_id: debate_id.to_string(), - model_id, - error: message.clone(), - round_number, - }, - ); - } -} - -fn emit_canvas_complete(window: &tauri::Window, session_id: &str, tile_id: &str, model_id: &str) { - let _ = window.emit( - "canvas-stream", - CanvasStreamEvent::Complete { - session_id: session_id.to_string(), - tile_id: tile_id.to_string(), - model_id: model_id.to_string(), - tokens_used: None, - }, - ); -} - -fn finalize_streamed_model_response( - window: &tauri::Window, - session_id: &str, - tile_id: &str, - model_id: String, - full_content: String, -) -> StreamedResponseUpdate { - let update = classify_streamed_model_response(model_id, full_content); - - if let Some(error) = &update.3 { - emit_canvas_error(window, session_id, tile_id, &update.0, error); - } else { - emit_canvas_complete(window, session_id, tile_id, &update.0); - } - - update -} - -fn classify_streamed_model_response( - model_id: String, - full_content: String, -) -> StreamedResponseUpdate { - if full_content.trim().is_empty() { - ( - model_id, - String::new(), - ResponseStatus::Error, - Some(EMPTY_MODEL_RESPONSE_ERROR.to_string()), - ) - } else { - (model_id, full_content, ResponseStatus::Completed, None) - } -} - -fn truncate_note_context_content(content: &str, max_chars: usize) -> String { - let mut truncated = String::new(); - let mut chars = content.chars(); - for _ in 0..max_chars { - match chars.next() { - Some(ch) => truncated.push(ch), - None => return truncated, - } - } - - if chars.next().is_some() { - truncated.push_str("..."); - } - - truncated -} - -fn normalize_canvas_query_tokens(text: &str) -> Vec { - let normalized: String = text - .chars() - .map(|ch| { - if ch.is_alphanumeric() { - ch.to_ascii_lowercase() - } else { - ' ' - } - }) - .collect(); - - normalized - .split_whitespace() - .filter(|token| token.len() >= MIN_CANVAS_QUERY_TOKEN_LEN) - .filter(|token| !CANVAS_RETRIEVAL_STOPWORDS.contains(token)) - .map(str::to_string) - .collect() -} - -fn matching_canvas_query_tokens( - query_tokens: &[String], - title: &str, - snippet: &str, -) -> HashSet { - let candidate_tokens: HashSet = - normalize_canvas_query_tokens(&format!("{} {}", title, snippet)) - .into_iter() - .collect(); - - query_tokens - .iter() - .filter(|token| candidate_tokens.contains(token.as_str())) - .cloned() - .collect() -} - -fn should_use_retrieved_notes( - prompt: &str, - retrieval_results: &[RetrievalResult], -) -> RetrievalDecisionReason { - let Some(top_result) = retrieval_results.first() else { - return RetrievalDecisionReason::NoResults; - }; - - if top_result.score < MIN_RETRIEVAL_SCORE_FOR_NOTES { - return RetrievalDecisionReason::WeakTopScore; - } - - if !top_result - .relevance_reasons - .iter() - .any(|reason| reason == "keyword match") - { - return RetrievalDecisionReason::NoKeywordMatch; - } - - let query_tokens = normalize_canvas_query_tokens(prompt); - - let mut saw_snippet = false; - for result in retrieval_results { - if result.snippet.trim().is_empty() { - continue; - } - - saw_snippet = true; - let matched_tokens = - matching_canvas_query_tokens(&query_tokens, &result.note.title, &result.snippet); - let has_long_match = matched_tokens.iter().any(|token| token.len() >= 6); - - if matched_tokens.len() >= 2 || (has_long_match && !matched_tokens.is_empty()) { - return RetrievalDecisionReason::UseRetrievedNotes; - } - } - - if !saw_snippet { - RetrievalDecisionReason::NoSnippet - } else { - RetrievalDecisionReason::NoLexicalOverlap - } -} - -async fn resolve_prompt_context( - state: &AppState, - session: &CanvasSession, - request: &PromptRequest, -) -> Result { - let messages = build_canvas_messages(session, request)?; - - if request.context_mode == ContextMode::Twin { - return resolve_twin_prompt_context(state, messages, session, request).await; - } - - if matches!( - request.context_mode, - ContextMode::KnowledgeSearch | ContextMode::Semantic - ) { - let pinned_ids = session.pinned_note_ids.clone(); - - // Quality gate: note-level retrieval to check if vault has relevant content - let retrieval_results = run_retrieval(state, &request.prompt, 5, &pinned_ids) - .await - .unwrap_or_default(); - - let retrieval_decision = should_use_retrieved_notes(&request.prompt, &retrieval_results); - if retrieval_decision != RetrievalDecisionReason::UseRetrievedNotes { - log::info!( - "Canvas knowledge search fallback for prompt {:?}: {:?}", - request.prompt, - retrieval_decision - ); - return Ok(ResolvedPromptContext { - messages, - context_notes: Vec::new(), - approved_twin_records: Vec::new(), - candidate_twin_records: Vec::new(), - constitution_items: Vec::new(), - action_gaps: Vec::new(), - system_prompt: request.system_prompt.clone(), - twin_context_prompt: None, - context_version: None, - decision_case_ids: Vec::new(), - }); - } - - // Check if chunk-level retrieval is enabled - let chunk_enabled = { - let retrieval = state.retrieval_service.read().await; - retrieval.get_config().chunk_retrieval_enabled - }; - - if chunk_enabled { - // Chunk-level context: retrieve relevant paragraphs within token budget - let chunks = { - let retrieval = state.retrieval_service.read().await; - let chunk_index = state.chunk_index.read().await; - let graph = state.graph_index.read().await; - let priority = state.priority_service.read().await; - let token_budget = retrieval.get_config().default_token_budget; - retrieval - .retrieve_chunks( - &chunk_index, - &graph, - &priority, - &request.prompt, - token_budget, - &pinned_ids, - ) - .unwrap_or_default() - }; - - if chunks.is_empty() { - log::info!("Chunk retrieval returned no results, falling back to note-level"); - return resolve_note_level_context( - state, - messages, - &retrieval_results, - &pinned_ids, - &request.system_prompt, - ) - .await; - } - - let total_tokens: usize = chunks.iter().map(|c| c.token_estimate).sum(); - let parent_count = chunks - .iter() - .map(|c| &c.parent_note_id) - .collect::>() - .len(); - log::info!( - "Canvas using chunk retrieval: {} chunks from {} notes (~{} tokens)", - chunks.len(), - parent_count, - total_tokens - ); - - // Build context notes from chunk parent notes (deduped) - let mut seen_notes: HashSet = HashSet::new(); - let context_notes: Vec = chunks - .iter() - .filter_map(|c| { - if seen_notes.insert(c.parent_note_id.clone()) { - Some(TileContextNote { - id: c.parent_note_id.clone(), - title: c.parent_title.clone(), - snippet: truncate_note_context_content(&c.text, 200), - score: c.search_score, - pinned: pinned_ids.contains(&c.parent_note_id), - }) - } else { - None - } - }) - .collect(); - - let note_prompt = build_chunk_context_prompt(&chunks); - let system_prompt = match &request.system_prompt { - Some(user_sp) if !user_sp.is_empty() => format!("{}\n\n{}", note_prompt, user_sp), - _ => note_prompt, - }; - - Ok(ResolvedPromptContext { - messages, - context_notes, - approved_twin_records: Vec::new(), - candidate_twin_records: Vec::new(), - constitution_items: Vec::new(), - action_gaps: Vec::new(), - system_prompt: Some(system_prompt), - twin_context_prompt: None, - context_version: None, - decision_case_ids: Vec::new(), - }) - } else { - log::info!("Canvas using note-level context (chunk retrieval disabled)"); - resolve_note_level_context( - state, - messages, - &retrieval_results, - &pinned_ids, - &request.system_prompt, - ) - .await - } - } else { - Ok(ResolvedPromptContext { - messages, - context_notes: Vec::new(), - approved_twin_records: Vec::new(), - candidate_twin_records: Vec::new(), - constitution_items: Vec::new(), - action_gaps: Vec::new(), - system_prompt: request.system_prompt.clone(), - twin_context_prompt: None, - context_version: None, - decision_case_ids: Vec::new(), - }) - } -} - -async fn resolve_twin_prompt_context( - state: &AppState, - messages: Vec, - session: &CanvasSession, - request: &PromptRequest, -) -> Result { - let pinned_ids = session.pinned_note_ids.clone(); - let retrieval_results = run_retrieval(state, &request.prompt, 5, &pinned_ids) - .await - .unwrap_or_default(); - - let should_use_notes = should_use_retrieved_notes(&request.prompt, &retrieval_results) - == RetrievalDecisionReason::UseRetrievedNotes; - let mut context_notes = Vec::new(); - let mut note_contexts = Vec::new(); - - if should_use_notes { - let chunk_enabled = { - let retrieval = state.retrieval_service.read().await; - retrieval.get_config().chunk_retrieval_enabled - }; - - if chunk_enabled { - let chunks = { - let retrieval = state.retrieval_service.read().await; - let chunk_index = state.chunk_index.read().await; - let graph = state.graph_index.read().await; - let priority = state.priority_service.read().await; - let token_budget = retrieval.get_config().default_token_budget; - retrieval - .retrieve_chunks( - &chunk_index, - &graph, - &priority, - &request.prompt, - token_budget, - &pinned_ids, - ) - .unwrap_or_default() - }; - - if !chunks.is_empty() { - let mut seen_notes: HashSet = HashSet::new(); - for chunk in &chunks { - note_contexts.push(( - chunk.parent_note_id.clone(), - chunk.parent_title.clone(), - chunk.text.clone(), - )); - if seen_notes.insert(chunk.parent_note_id.clone()) { - context_notes.push(TileContextNote { - id: chunk.parent_note_id.clone(), - title: chunk.parent_title.clone(), - snippet: truncate_note_context_content(&chunk.text, 200), - score: chunk.search_score, - pinned: pinned_ids.contains(&chunk.parent_note_id), - }); - } - } - } - } - - if note_contexts.is_empty() { - note_contexts = fetch_note_contexts(state, &retrieval_results).await; - let found_ids: HashSet<&str> = - note_contexts.iter().map(|(id, _, _)| id.as_str()).collect(); - for r in &retrieval_results { - if found_ids.contains(r.note.id.as_str()) { - context_notes.push(TileContextNote { - id: r.note.id.clone(), - title: r.note.title.clone(), - snippet: r.snippet.clone(), - score: r.score, - pinned: pinned_ids.contains(&r.note.id), - }); - } - } - } - } - - let constitution_query = - decision_context_query(&request.prompt, request.decision_metadata.as_ref()); - let ( - setup, - approved_twin_records, - candidate_twin_records, - constitution_items, - action_gaps, - decision_cases, - ) = { - let mut twin_store = state.twin_store.write().await; - let setup = twin_store - .get_constitution_setup() - .map_err(|error| error.to_string())?; - validate_twin_identity_for_answer_mode(&setup, &request.twin_answer_mode)?; - let (approved, candidate) = twin_store - .select_context_records(&request.prompt) - .map_err(|error| error.to_string())?; - let (constitution_items, action_gaps) = twin_store - .select_constitution_context(&constitution_query) - .map_err(|error| error.to_string())?; - let decision_cases = twin_store - .select_decision_cases(&constitution_query, None, MAX_TWIN_CASE_CONTEXT) - .map_err(|error| error.to_string())?; - ( - setup, - approved, - candidate, - constitution_items, - action_gaps, - decision_cases, - ) - }; - - let selection = apply_twin_context_budget( - decision_cases, - constitution_items, - approved_twin_records, - candidate_twin_records, - action_gaps, - note_contexts, - TWIN_CONTEXT_TOKEN_BUDGET, - ); - let decision_case_ids = selection - .cases - .iter() - .map(|episode| episode.id.clone()) - .collect::>(); - - let twin_prompt = build_twin_context_prompt( - &setup, - &selection.cases, - &selection.notes, - &selection.approved, - &selection.candidates, - &selection.constitution_items, - &selection.action_gaps, - &request.twin_answer_mode, - &request.prompt_type, - request.decision_metadata.as_ref(), - ); - let system_prompt = match &request.system_prompt { - Some(user_sp) if !user_sp.is_empty() => format!("{}\n\n{}", twin_prompt, user_sp), - _ => twin_prompt.clone(), - }; - - Ok(ResolvedPromptContext { - messages, - context_notes, - approved_twin_records: selection.approved, - candidate_twin_records: selection.candidates, - constitution_items: selection.constitution_items, - action_gaps: selection.action_gaps, - system_prompt: Some(system_prompt), - twin_context_prompt: Some(twin_prompt), - context_version: Some(TWIN_CONTEXT_VERSION.to_string()), - decision_case_ids, - }) -} - -/// Fetch full note content for retrieval results, returning (id, title, truncated_content) tuples. -/// Skips notes that can't be read from the store. Called by both the semantic and twin note-level paths. -async fn fetch_note_contexts( - state: &AppState, - results: &[RetrievalResult], -) -> Vec<(String, String, String)> { - let store = state.knowledge_store.read().await; - results - .iter() - .filter_map(|r| { - store.get_note(&r.note.id).ok().map(|note| { - let truncated = truncate_note_context_content(¬e.content, 1500); - (note.id.clone(), note.title.clone(), truncated) - }) - }) - .collect() -} - -/// Fall back to note-level context when chunk retrieval is disabled or returns nothing. -async fn resolve_note_level_context( - state: &AppState, - messages: Vec, - retrieval_results: &[RetrievalResult], - pinned_ids: &[String], - user_system_prompt: &Option, -) -> Result { - let note_contexts = fetch_note_contexts(state, retrieval_results).await; - - let context_notes: Vec = retrieval_results - .iter() - .map(|r| TileContextNote { - id: r.note.id.clone(), - title: r.note.title.clone(), - snippet: r.snippet.clone(), - score: r.score, - pinned: pinned_ids.contains(&r.note.id), - }) - .collect(); - - let note_prompt = build_note_context_prompt(¬e_contexts); - let system_prompt = match user_system_prompt { - Some(user_sp) if !user_sp.is_empty() => format!("{}\n\n{}", note_prompt, user_sp), - _ => note_prompt, - }; - - Ok(ResolvedPromptContext { - messages, - context_notes, - approved_twin_records: Vec::new(), - candidate_twin_records: Vec::new(), - constitution_items: Vec::new(), - action_gaps: Vec::new(), - system_prompt: Some(system_prompt), - twin_context_prompt: None, - context_version: None, - decision_case_ids: Vec::new(), - }) -} - -fn build_canvas_messages( - session: &CanvasSession, - request: &PromptRequest, -) -> Result, String> { - match request.context_mode { - ContextMode::FullHistory => build_full_history_messages(session, request), - ContextMode::Compact => build_compact_history_messages(session, request), - _ => Ok(vec![ChatMessage { - role: "user".to_string(), - content: request.prompt.clone(), - }]), - } -} - -fn build_full_history_messages( - session: &CanvasSession, - request: &PromptRequest, -) -> Result, String> { - let turns = build_selected_parent_chain(session, request)?; - let mut messages = Vec::with_capacity((turns.len() * 2) + 1); - - for turn in turns { - messages.push(ChatMessage { - role: "user".to_string(), - content: turn.prompt, - }); - messages.push(ChatMessage { - role: "assistant".to_string(), - content: turn.response, - }); - } - - messages.push(ChatMessage { - role: "user".to_string(), - content: request.prompt.clone(), - }); - - Ok(messages) -} - -fn build_compact_history_messages( - session: &CanvasSession, - request: &PromptRequest, -) -> Result, String> { - let turns = build_selected_parent_chain(session, request)?; - let mut messages = Vec::new(); - - if turns.len() > COMPACT_HISTORY_RECENT_TURNS { - let split_at = turns.len() - COMPACT_HISTORY_RECENT_TURNS; - let summary = build_compact_history_summary(&turns[..split_at]); - messages.push(ChatMessage { - role: "user".to_string(), - content: summary, - }); - - for turn in &turns[split_at..] { - messages.push(ChatMessage { - role: "user".to_string(), - content: turn.prompt.clone(), - }); - messages.push(ChatMessage { - role: "assistant".to_string(), - content: turn.response.clone(), - }); - } - } else { - for turn in turns { - messages.push(ChatMessage { - role: "user".to_string(), - content: turn.prompt, - }); - messages.push(ChatMessage { - role: "assistant".to_string(), - content: turn.response, - }); - } - } - - messages.push(ChatMessage { - role: "user".to_string(), - content: request.prompt.clone(), - }); - - Ok(messages) -} - -fn build_selected_parent_chain( - session: &CanvasSession, - request: &PromptRequest, -) -> Result, String> { - let mut tile_id = request - .parent_tile_id - .clone() - .ok_or_else(|| "Context mode requires a parent tile".to_string())?; - let mut model_id = request - .parent_model_id - .clone() - .ok_or_else(|| "Context mode requires a parent model".to_string())?; - let mut visited = HashSet::new(); - let mut turns = Vec::new(); - - loop { - let visit_key = format!("{}::{}", tile_id, model_id); - if !visited.insert(visit_key) { - return Err("Detected a cycle while reconstructing canvas history".to_string()); - } - - let tile = session - .prompt_tiles - .iter() - .find(|t| t.id == tile_id) - .ok_or_else(|| format!("Parent tile not found in session: {}", tile_id))?; - let response = tile.responses.get(&model_id).ok_or_else(|| { - format!( - "Parent response not found for tile {} and model {}", - tile_id, model_id - ) - })?; - - turns.push(ConversationTurn { - prompt: tile.prompt.clone(), - response: response.content.clone(), - model_id: model_id.clone(), - }); - - match (&tile.parent_tile_id, &tile.parent_model_id) { - (Some(next_tile_id), Some(next_model_id)) => { - tile_id = next_tile_id.clone(); - model_id = next_model_id.clone(); - } - (None, None) => break, - _ => { - return Err(format!( - "Incomplete parent linkage for tile {} while reconstructing history", - tile.id - )) - } - } - } - - turns.reverse(); - Ok(turns) -} - -fn build_compact_history_summary(turns: &[ConversationTurn]) -> String { - let mut summary = String::from("Conversation summary before the most recent turns:\n"); - - for (index, turn) in turns.iter().enumerate() { - summary.push_str(&format!( - "\nTurn {}:\nUser: {}\nAssistant ({}): {}\n", - index + 1, - truncate_for_compact_history(&turn.prompt), - turn.model_id, - truncate_for_compact_history(&turn.response), - )); - } - - summary -} - -fn truncate_for_compact_history(content: &str) -> String { - if content.chars().count() <= COMPACT_HISTORY_EXCERPT_CHARS { - return content.to_string(); - } - - let mut truncated = content - .chars() - .take(COMPACT_HISTORY_EXCERPT_CHARS) - .collect::(); - truncated.push_str("..."); - truncated -} - -/// Build a system prompt that includes retrieved note context. -/// Used by send_prompt when context_mode is Semantic. -fn build_chunk_context_prompt(chunks: &[ChunkResult]) -> String { - let mut prompt = String::from( - "You are a helpful knowledge assistant for the user's personal note-taking system (Grafyn). \ - Answer questions using the context from the user's notes below. \ - Reference specific notes by title when citing information. \ - If the notes don't contain relevant information, say so honestly.\n\n", - ); - - if chunks.is_empty() { - prompt.push_str("No relevant notes were found for this query.\n"); - return prompt; - } - - // Group chunks by parent note, preserving insertion order - let mut note_order: Vec = Vec::new(); - let mut note_map: HashMap)> = HashMap::new(); - - for chunk in chunks { - let entry = note_map - .entry(chunk.parent_note_id.clone()) - .or_insert_with(|| { - note_order.push(chunk.parent_note_id.clone()); - (chunk.parent_title.clone(), Vec::new()) - }); - entry.1.push(&chunk.text); - } - - prompt.push_str("## Relevant Notes\n\n"); - for note_id in ¬e_order { - if let Some((title, texts)) = note_map.get(note_id) { - prompt.push_str(&format!("### {} (id: {})\n", title, note_id)); - for text in texts { - prompt.push_str(text); - prompt.push_str("\n\n"); - } - } - } - - prompt -} - -fn build_note_context_prompt(notes: &[(String, String, String)]) -> String { - let mut prompt = String::from( - "You are a helpful knowledge assistant for the user's personal note-taking system (Grafyn). \ - Answer questions using the context from the user's notes below. \ - Reference specific notes by title when citing information. \ - If the notes don't contain relevant information, say so honestly.\n\n", - ); - - if notes.is_empty() { - prompt.push_str("No relevant notes were found for this query.\n"); - } else { - prompt.push_str("## Relevant Notes\n\n"); - for (id, title, content) in notes { - prompt.push_str(&format!("### {} (id: {})\n{}\n\n", title, id, content)); - } - } - - prompt -} - -/// Rough token estimate matching the chunk-index convention (words * 4/3). -fn estimate_tokens(text: &str) -> usize { - text.split_whitespace().count() * 4 / 3 + 1 -} - -/// Render one past decision episode as a verbatim behavioral case. -fn format_decision_case(episode: &DecisionEpisode) -> String { - let mut case = format!("### Past decision: {}\n", episode.decision.trim()); - if !episode.options.is_empty() { - case.push_str(&format!("- Options: {}\n", episode.options.join(" | "))); - } - if let Some(chosen) = episode.chosen_option.as_deref() { - case.push_str(&format!("- Chose: {}\n", chosen.trim())); - } - if let Some(leaning) = episode.initial_leaning.as_deref() { - if !leaning.trim().is_empty() { - case.push_str(&format!("- Initial leaning: {}\n", leaning.trim())); - } - } - if let Some(outcome) = episode.outcome.as_deref() { - if !outcome.trim().is_empty() { - case.push_str(&format!( - "- Outcome: {}\n", - truncate_note_context_content(outcome.trim(), TWIN_CASE_FIELD_MAX_CHARS) - )); - } - } - if let Some(lesson) = episode.lesson.as_deref() { - if !lesson.trim().is_empty() { - case.push_str(&format!( - "- Lesson (verbatim): {}\n", - truncate_note_context_content(lesson.trim(), TWIN_CASE_FIELD_MAX_CHARS) - )); - } - } - if let Some(note) = episode.correction_note.as_deref() { - if !note.trim().is_empty() { - case.push_str(&format!( - "- Correction note (recorded when an earlier sealed twin guess missed): {}\n", - truncate_note_context_content(note.trim(), TWIN_CASE_CORRECTION_MAX_CHARS) - )); - } - } - case.push('\n'); - case -} - -struct TwinContextSelection { - cases: Vec, - constitution_items: Vec, - approved: Vec, - candidates: Vec, - action_gaps: Vec, - notes: Vec<(String, String, String)>, -} - -/// Greedy-fill the variable twin context sections into a hard token budget, -/// in priority order: cases > constitution > approved records > candidate -/// records > action gaps > evidence notes. Fixed scaffolding (operating -/// contract, identity, answer instructions, decision metadata) sits outside -/// the budget. -#[allow(clippy::too_many_arguments)] -fn apply_twin_context_budget( - cases: Vec, - constitution_items: Vec, - approved: Vec, - candidates: Vec, - action_gaps: Vec, - notes: Vec<(String, String, String)>, - budget: usize, -) -> TwinContextSelection { - let mut remaining = budget as isize; - let mut take_within_budget = move |cost: usize| -> bool { - if remaining - cost as isize >= 0 { - remaining -= cost as isize; - true - } else { - false - } - }; - - let cases = cases - .into_iter() - .filter(|episode| take_within_budget(estimate_tokens(&format_decision_case(episode)))) - .collect(); - let constitution_items = constitution_items - .into_iter() - .filter(|item| take_within_budget(estimate_tokens(&format_constitution_item(item)))) - .collect(); - let approved = approved - .into_iter() - .filter(|record| take_within_budget(estimate_tokens(&format_twin_record(record)))) - .collect(); - let candidates = candidates - .into_iter() - .filter(|record| take_within_budget(estimate_tokens(&format_twin_record(record)))) - .collect(); - let action_gaps = action_gaps - .into_iter() - .filter(|gap| take_within_budget(estimate_tokens(&format_action_gap(gap)))) - .collect(); - let notes = notes - .into_iter() - .filter(|(_, title, content)| { - take_within_budget(estimate_tokens(title) + estimate_tokens(content)) - }) - .collect(); - - TwinContextSelection { - cases, - constitution_items, - approved, - candidates, - action_gaps, - notes, - } -} - -/// User message for the hidden sealed-prediction call. With a configured -/// Twin Identity the framing is immersed first person; the fallback is a -/// neutral decision-support instruction. Disclosure language lives in the -/// app UI, never in model-facing prompts. -fn build_twin_prediction_user_message( - setup: &ConstitutionSetup, - decision: &str, - options: &[String], - stakes: Option<&str>, -) -> String { - let immersed = has_twin_identity(setup); - let mut message = String::new(); - if immersed { - let name = setup - .twin_name - .as_deref() - .unwrap_or_default() - .trim() - .to_string(); - message.push_str(&format!( - "I am {}. The decision in front of me: {}\n", - name, - decision.trim() - )); - } else { - message.push_str(&format!( - "Decision under consideration: {}\n", - decision.trim() - )); - } - if let Some(stakes) = stakes { - if !stakes.trim().is_empty() { - message.push_str(&format!("Stakes: {}\n", stakes.trim())); - } - } - message.push_str("My options:\n"); - for (index, option) in options.iter().enumerate() { - message.push_str(&format!("{}. {}\n", index + 1, option)); - } - if immersed { - message.push_str( - "\nWhich option do I choose? I answer with only this JSON object and nothing else:\n", - ); - } else { - message.push_str( - "\nGiven the context above, determine which option best fits this decision-maker's \ - documented values, constitution, and past decisions. Respond with only this JSON \ - object and nothing else:\n", - ); - } - message.push_str( - "{\"predicted_option\": \"