diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..d7f90c58 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +target-dir = "C:/Users/julie/AppData/Local/Temp/sinew-cargo-target" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 936ba43a..d86e0934 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -132,6 +132,9 @@ jobs: - name: Install npm dependencies run: npm ci + - name: Prepare Tauri sidecars + run: npm run prepare-sidecars + # Workaround for the well-known `bundle_dmg.sh` hang on GitHub macOS # runners: Finder + DiskImageMounter aren't warmed up in the headless # session, so the first AppleScript call inside bundle_dmg.sh races and @@ -238,6 +241,16 @@ jobs: echo "No .deb produced - skipping .deb alias." fi + - name: Build & upload Linux Daemon (for Super SSH) + if: runner.os == 'Linux' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -e + cargo build --release -p sinew-agent-daemon + cp target/release/sinew-agent-daemon sinew-agent-daemon-linux + gh release upload "${{ needs.create-release.outputs.tag_name }}" sinew-agent-daemon-linux --clobber + finalize-release: name: Finalize release needs: [create-release, build-tauri] diff --git a/.gitignore b/.gitignore index 6b113801..98159dd8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ /target /dist /node_modules +sinew-chrome-bridge/node_modules/ +scripts/agent-bridge/node_modules/ /FEATURES.md /EDIT_FILE_HARNESS_COMPARISON.md /GLOB_HARNESS_COMPARISON.md @@ -18,3 +20,17 @@ # Tauri updater signing keys — NEVER commit private keys /.tauri-keys/ *.key + +/installers +.fastembed_cache/ +desktop-state.sqlite3 +desktop-state.sqlite3-wal +desktop-state.sqlite3-shm + +/build + + + +rust-live-*.txt +scripts/agent-bridge/_node_*.txt +scripts/agent-bridge/_payload*.json diff --git a/AGENTS.md b/AGENTS.md index d772c748..c1db6dc8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,282 +1,430 @@ -Code map: -- L'agent doit garder à jour cette carte simple des fichiers à chaque création, suppression, renommage, déplacement ou modification. - -. -├── .gitignore -├── AGENTS.md -├── Cargo.lock -├── Cargo.toml -├── EDIT_FILE_HARNESS_COMPARISON.md -├── FEATURES.md -├── GLOB_HARNESS_COMPARISON.md -├── GREP_HARNESS_COMPARISON.md -├── index.html -├── LICENSE -├── package-lock.json -├── package.json -├── README.md -├── test-stop.md -├── scripts -│ └── prepare-sidecars.mjs -├── tsconfig.json -├── tsconfig.node.json -├── vite.config.ts -├── .github -│ ├── assets -│ │ ├── architecture.png -│ │ ├── harness.png -│ │ ├── hero.png -│ │ ├── modes.png -│ │ ├── screenshot.png -│ │ └── swarm.png -│ └── workflows -│ └── release.yml -├── crates -│ ├── sinew-anthropic -│ │ ├── Cargo.toml -│ │ └── src -│ │ ├── auth.rs -│ │ ├── client.rs -│ │ ├── lib.rs -│ │ ├── model_info.rs -│ │ ├── stream.rs -│ │ └── wire.rs -│ ├── sinew-app -│ │ ├── Cargo.toml -│ │ └── src -│ │ ├── agent.rs -│ │ ├── agent -│ │ │ ├── assistant_message.rs -│ │ │ ├── cancel.rs -│ │ │ ├── clean_context.rs -│ │ │ ├── compaction.rs -│ │ │ ├── context.rs -│ │ │ ├── events.rs -│ │ │ ├── history.rs -│ │ │ ├── mode.rs -│ │ │ ├── tests.rs -│ │ │ ├── tool_dispatch.rs -│ │ │ ├── tool_summary.rs -│ │ │ └── turn.rs -│ │ ├── bash.rs -│ │ ├── compact.rs -│ │ ├── edit.rs -│ │ ├── glob.rs -│ │ ├── grep.rs -│ │ ├── image.rs -│ │ ├── lib.rs -│ │ ├── mcp.rs -│ │ ├── question.rs -│ │ ├── read.rs -│ │ ├── skill.rs -│ │ ├── store.rs -│ │ ├── subagent.rs -│ │ ├── team.rs -│ │ ├── team -│ │ │ ├── agent_turns.rs -│ │ │ ├── context.rs -│ │ │ ├── descriptors.rs -│ │ │ ├── launch.rs -│ │ │ ├── live.rs -│ │ │ ├── messaging.rs -│ │ │ ├── model.rs -│ │ │ ├── render.rs -│ │ │ ├── session.rs -│ │ │ ├── status_stop.rs -│ │ │ ├── task_board.rs -│ │ │ └── tests.rs -│ │ ├── text.rs -│ │ ├── todo.rs -│ │ ├── tool_names.rs -│ │ ├── tool_run.rs -│ │ ├── web.rs -│ │ ├── write.rs -│ │ └── workspace.rs -│ ├── sinew-core -│ │ ├── Cargo.toml -│ │ └── src -│ │ ├── error.rs -│ │ ├── lib.rs -│ │ ├── message.rs -│ │ ├── model.rs -│ │ ├── provider.rs -│ │ ├── stream.rs -│ │ └── tool.rs -│ ├── sinew-google -│ │ ├── Cargo.toml -│ │ └── src -│ │ ├── auth.rs -│ │ ├── client.rs -│ │ ├── lib.rs -│ │ ├── model_info.rs -│ │ ├── stream.rs -│ │ └── wire.rs -│ ├── sinew-kimi -│ │ ├── Cargo.toml -│ │ └── src -│ │ ├── auth.rs -│ │ ├── client.rs -│ │ ├── lib.rs -│ │ ├── model_info.rs -│ │ ├── stream.rs -│ │ └── wire.rs -│ ├── sinew-openai -│ │ ├── Cargo.toml -│ │ └── src -│ │ ├── auth.rs -│ │ ├── client.rs -│ │ ├── lib.rs -│ │ ├── model_info.rs -│ │ ├── responses_stream.rs -│ │ ├── stream.rs -│ │ ├── websocket.rs -│ │ └── wire.rs -│ └── sinew-openrouter -│ ├── Cargo.toml -│ └── src -│ ├── auth.rs -│ ├── client.rs -│ ├── lib.rs -│ ├── model_info.rs -│ ├── stream.rs -│ └── wire.rs -├── src-tauri -│ ├── Cargo.toml -│ ├── binaries -│ │ └── .gitkeep -│ ├── build.rs -│ ├── tauri.sidecars.conf.json -│ ├── tauri.conf.json -│ ├── tauri.windows.conf.json -│ ├── capabilities -│ │ └── default.json -│ ├── gen -│ │ └── schemas -│ │ ├── acl-manifests.json -│ │ ├── capabilities.json -│ │ ├── desktop-schema.json -│ │ └── macOS-schema.json -│ ├── icons -│ │ ├── 128x128.png -│ │ ├── 128x128@2x.png -│ │ ├── 32x32.png -│ │ ├── 64x64.png -│ │ ├── Square107x107Logo.png -│ │ ├── Square142x142Logo.png -│ │ ├── Square150x150Logo.png -│ │ ├── Square284x284Logo.png -│ │ ├── Square30x30Logo.png -│ │ ├── Square310x310Logo.png -│ │ ├── Square44x44Logo.png -│ │ ├── Square71x71Logo.png -│ │ ├── Square89x89Logo.png -│ │ ├── StoreLogo.png -│ │ ├── icon.icns -│ │ ├── icon.ico -│ │ ├── icon.png -│ │ ├── nsis-sidebar.bmp -│ │ ├── source.svg -│ │ ├── android -│ │ │ ├── mipmap-anydpi-v26 -│ │ │ │ └── ic_launcher.xml -│ │ │ ├── mipmap-hdpi -│ │ │ │ ├── ic_launcher.png -│ │ │ │ ├── ic_launcher_foreground.png -│ │ │ │ └── ic_launcher_round.png -│ │ │ ├── mipmap-mdpi -│ │ │ │ ├── ic_launcher.png -│ │ │ │ ├── ic_launcher_foreground.png -│ │ │ │ └── ic_launcher_round.png -│ │ │ ├── mipmap-xhdpi -│ │ │ │ ├── ic_launcher.png -│ │ │ │ ├── ic_launcher_foreground.png -│ │ │ │ └── ic_launcher_round.png -│ │ │ ├── mipmap-xxhdpi -│ │ │ │ ├── ic_launcher.png -│ │ │ │ ├── ic_launcher_foreground.png -│ │ │ │ └── ic_launcher_round.png -│ │ │ ├── mipmap-xxxhdpi -│ │ │ │ ├── ic_launcher.png -│ │ │ │ ├── ic_launcher_foreground.png -│ │ │ │ └── ic_launcher_round.png -│ │ │ └── values -│ │ │ └── ic_launcher_background.xml -│ │ └── ios -│ │ ├── AppIcon-20x20@1x.png -│ │ ├── AppIcon-20x20@2x-1.png -│ │ ├── AppIcon-20x20@2x.png -│ │ ├── AppIcon-20x20@3x.png -│ │ ├── AppIcon-29x29@1x.png -│ │ ├── AppIcon-29x29@2x-1.png -│ │ ├── AppIcon-29x29@2x.png -│ │ ├── AppIcon-29x29@3x.png -│ │ ├── AppIcon-40x40@1x.png -│ │ ├── AppIcon-40x40@2x-1.png -│ │ ├── AppIcon-40x40@2x.png -│ │ ├── AppIcon-40x40@3x.png -│ │ ├── AppIcon-512@2x.png -│ │ ├── AppIcon-60x60@2x.png -│ │ ├── AppIcon-60x60@3x.png -│ │ ├── AppIcon-76x76@1x.png -│ │ ├── AppIcon-76x76@2x.png -│ │ └── AppIcon-83.5x83.5@2x.png -│ └── src -│ ├── context.rs -│ ├── conversations.rs -│ ├── git.rs -│ ├── lib.rs -│ ├── main.rs -│ ├── models.rs -│ ├── platform.rs -│ ├── providers.rs -│ ├── state.rs -│ ├── swarm.rs -│ ├── terminal.rs -│ ├── tests.rs -│ ├── turns.rs -│ ├── updater.rs -│ ├── workflow.rs -│ └── workspace.rs -└── src - ├── App.tsx - ├── main.tsx - ├── styles.css - ├── types.ts - ├── vite-env.d.ts - ├── components - │ ├── ConversationList.tsx - │ ├── EditorPane.tsx - │ ├── FileTree.tsx - │ ├── GitPanel.tsx - │ ├── SearchPane.tsx - │ ├── SettingsPane.tsx - │ ├── SinewMark.tsx - │ ├── Splitter.tsx - │ ├── TerminalPanel.tsx - │ ├── UpdateBadge.tsx - │ ├── UpdaterLockScreen.tsx - │ ├── Welcome.tsx - │ ├── WindowControls.tsx - │ ├── Workspace.tsx - │ └── chat - │ ├── AIThinkingBlock.tsx - │ ├── ChatPane.tsx - │ ├── DotmSquare2.tsx - │ ├── DotmSquare5.tsx - │ ├── FileChangeBlock.tsx - │ ├── Markdown.tsx - │ ├── MermaidDiagram.tsx - │ ├── PlanningNextMoveBlock.tsx - │ ├── Questionnaire.tsx - │ ├── TodoStrip.tsx - │ ├── ToolCard.tsx - │ ├── dotmatrix-core.tsx - │ ├── dotmatrix-hooks.ts - │ └── stream.ts - ├── lib - │ ├── fileIcon.ts - │ ├── ipc.ts - │ ├── language.ts - │ ├── models.ts - │ ├── recents.ts - │ └── tools.ts +# R�gles anti-boucle locales + +- **Dossier de travail des commandes** : **ATTENTION EXCEPTION** : Contrairement aux outils `read`/`write` qui exigent des chemins absolus, l'outil terminal (bash) ne supporte **PAS** les chemins absolus ni `cwd: "."`. Vous devez obligatoirement utiliser `cwd: ""` pour rester � la racine sous peine d'erreur de snapshot `path escapes workspace`. +- **CHANGELOG avant modification** : juste avant toute modification de `CHANGELOG.md`, toujours relire `C:\dev\sinew\CHANGELOG.md` avec l'outil `read`, puis modifier `CHANGELOG.md` dans le m�me lot que les autres fichiers touch�s. +- **Chemins de fichiers** : pour `read`, `edit_file` et `write_file`, utiliser les chemins absolus Windows du workspace, par exemple `C:\dev\sinew\...`. +- **Grep Limit** : le param�tre `limit` est strictement obligatoire pour les outils `grep` et `glob`. Ne jamais l'omettre. +- **Fichiers fant�mes** : avant d'appeler `grep` ou `read` sur un chemin sp�cifique, s'assurer que le fichier existe r�ellement sur le disque (ex: via `bash` avec `Test-Path`). +# Code map +- L'agent doit garder à jour cette carte simple des fichiers à chaque création, suppression, renommage, déplacement ou modification. + +. +�S���� .gitignore +�S���� AGENTS.md +�S���� CHANGELOG.md +�S���� Cargo.lock +�S���� Cargo.toml +�S���� index.html +�S���� launch-sinew-dev.bat +�S���� LICENSE +�S���� package-lock.json +�S���� package.json +�S���� README.md +�S���� .sinew +�S���� scripts +� �S���� check.ps1 +� �S���� compil.ps1 +� �S���� export-agent-descriptor.mjs +� �S���� prepare-agent-bridge.mjs +� �S���� prepare-sidecars.mjs +� �S���� agent-bridge +� � �S���� exec-handlers.mjs +� � �S���� export-agent-fds-prost.mjs +� � �S���� h2-bridge.mjs +� � �S���� install-proto.mjs +� � �S���� package-lock.json +� � �S���� package.json +� � �S���� run-stream.mjs +� � �S���� test-live-rust.ps1 +� � �S���� test-live.ps1 +� � ����� vendor +� � ����� agent_pb.ts +� ����� mitm +� �S���� check-mitm.ps1 +� �S���� install-mitmproxy.ps1 +� �S���� README.md +� ����� start-mitmweb.ps1 +�S���� tsconfig.json +�S���� tsconfig.node.json +�S���� vite.config.ts +�S���� .github +� �S���� assets +� � �S���� architecture.png +� � �S���� harness.png +� � �S���� hero.png +� � �S���� modes.png +� � �S���� screenshot.png +� � ����� swarm.png +� ����� workflows +� �S���� release.yml +� ����� security.yml +�S���� crates +� �S���� sinew-anthropic +� � �S���� Cargo.toml +� � ����� src +� � �S���� auth.rs +� � �S���� client.rs +� � �S���� lib.rs +� � �S���� model_info.rs +� � �S���� stream.rs +� � ����� wire.rs +� �S���� sinew-app +� � �S���� Cargo.toml +� � ����� src +� � �S���� agent.rs +� � �S���� agent +� � � �S���� assistant_message.rs +� � � �S���� cancel.rs +� � � �S���� clean_context.rs +� � � �S���� compaction.rs +� � � �S���� context.rs +� � � �S���� events.rs +� � � �S���� history.rs +� � � �S���� mode.rs +� � � �S���� tests.rs +� � � �S���� tool_dispatch.rs +� � � �S���� tool_summary.rs +� � � ����� turn.rs +� � �S���� bash.rs +� � �S���� check_sota.rs +� � �S���� codebase_search.rs +� � �S���� compact.rs +� � �S���� computer_use.rs +� � �S���� delete_file.rs +� � �S���� edit.rs +� � �S���� editor_diagnostics.rs +� � �S���� glob.rs +� � �S���� grep.rs +� � �S���� image.rs +� � �S���� lib.rs +� � �S���� list_dir.rs +� � �S���� mcp.rs +� � �S���� question.rs +� � �S���� read.rs +� � �S���� read_lints.rs +� � �S���� ripgrep.rs +� � �S���� skill.rs +� � �S���� store.rs +� � �S���� subagent.rs +� � �S���� team.rs +� � �S���� team +� � � �S���� agent_turns.rs +� � � �S���� context.rs +� � � �S���� descriptors.rs +� � � �S���� launch.rs +� � � �S���� live.rs +� � � �S���� messaging.rs +� � � �S���� model.rs +� � � �S���� render.rs +� � � �S���� session.rs +� � � �S���� status_stop.rs +� � � �S���� task_board.rs +� � � ����� tests.rs +� � �S���� text.rs +� � �S���� todo.rs +� � �S���� tool_names.rs +� � �S���� tool_run.rs +� � �S���� web.rs +� � �S���� workspace.rs +� � ����� write.rs +� �S���� sinew-core +� � �S���� Cargo.toml +� � ����� src +� � �S���� error.rs +� � �S���� lib.rs +� � �S���� message.rs +� � �S���� model.rs +� � �S���� provider.rs +� � �S���� stream.rs +� � ����� tool.rs +� �S���� sinew-deepseek +� � �S���� Cargo.toml +� � ����� src +� � �S���� auth.rs +� � �S���� client.rs +� � �S���� lib.rs +� � �S���� model_info.rs +� � �S���� stream.rs +� � ����� wire.rs +� �S���� sinew-google +� � �S���� Cargo.toml +� � ����� src +� � �S���� auth.rs +� � �S���� client.rs +� � �S���� lib.rs +� � �S���� model_info.rs +� � �S���� stream.rs +� � ����� wire.rs +� �S���� sinew-index +� � �S���� Cargo.toml +� � ����� src +� � �S���� background.rs +� � �S���� chunk.rs +� � �S���� embeddings.rs +� � �S���� indexer.rs +� � �S���� lib.rs +� � �S���� process.rs +� � �S���� search.rs +� � ����� store.rs +� �S���� sinew-kimi +� � �S���� Cargo.toml +� � ����� src +� � �S���� auth.rs +� � �S���� client.rs +� � �S���� lib.rs +� � �S���� model_info.rs +� � �S���� stream.rs +� � ����� wire.rs +� �S���� sinew-openai +� � �S���� Cargo.toml +� � ����� src +� � �S���� auth.rs +� � �S���� client.rs +� � �S���� lib.rs +� � �S���� model_info.rs +� � �S���� responses_stream.rs +� � �S���� stream.rs +� � �S���� websocket.rs +� � ����� wire.rs +� ����� sinew-openrouter +� �S���� Cargo.toml +� ����� src +� �S���� auth.rs +� �S���� client.rs +� �S���� lib.rs +� �S���� model_info.rs +� �S���� stream.rs +� ����� wire.rs +� ����� sinew-cursor +� ����� sinew-ollama +� �S���� Cargo.toml +� ����� src +� �S���� auth.rs +� �S���� client.rs +� �S���� lib.rs +� �S���� model_info.rs +� �S���� stream.rs +� ����� wire.rs +� ����� sinew-cursor +� �S���� Cargo.toml +� ����� src +� �S���� agent +� � �S���� bridge.rs +� � �S���� client_proto.rs +� � �S���� connect_proto.rs +� � �S���� conversation_id.rs +� � �S���� exec_handler.rs +� � �S���� h2_client.rs +� � �S���� mod.rs +� � �S���� models.rs +� � �S���� proto_dynamic.rs +� � �S���� proto_pool.rs +� � �S���� retry.rs +� � �S���� run_h2.rs +� � �S���� run_request.rs +� � �S���� rust_bridge.rs +� � �S���� server_decode.rs +� � �S���� setup.rs +� � �S���� state.rs +� � �S���� tools.rs +� � �S���� transcript.rs +� � ����� transport.rs +� �S���� auth +� � �S���� composer.rs +� � �S���� mod.rs +� � ����� oauth.rs +� �S���� proto +� � �S���� agent.fds +� � �S���� agent.pb +� � ����� README.md +� �S���� client.rs +� �S���� connect.rs +� �S���� context_injection.rs +� �S���� conversation.rs +� �S���� encryption.rs +� �S���� identity.rs +� �S���� images.rs +� �S���� lib.rs +� �S���� model_info.rs +� �S���� sanitize.rs +� �S���� stream_state.rs +� �S���� tests.rs +� �S���� tools.rs +� �S���� usage.rs +� ����� workspace.rs +�S���� src-tauri +� �S���� Cargo.toml +� �S���� PROVIDERS.md +� �S���� binaries +� � ����� .gitkeep +� �S���� build.rs +� �S���� tauri.sidecars.conf.json +� �S���� tauri.conf.json +� �S���� tauri.windows.conf.json +� �S���� capabilities +� � ����� default.json +� �S���� gen +� � ����� schemas +� � �S���� acl-manifests.json +� � �S���� capabilities.json +� � �S���� desktop-schema.json +� � ����� macOS-schema.json +� �S���� icons +� � �S���� 128x128.png +� � �S���� 128x128@2x.png +� � �S���� 32x32.png +� � �S���� 64x64.png +� � �S���� Square107x107Logo.png +� � �S���� Square142x142Logo.png +� � �S���� Square150x150Logo.png +� � �S���� Square284x284Logo.png +� � �S���� Square30x30Logo.png +� � �S���� Square310x310Logo.png +� � �S���� Square44x44Logo.png +� � �S���� Square71x71Logo.png +� � �S���� Square89x89Logo.png +� � �S���� StoreLogo.png +� � �S���� icon.icns +� � �S���� icon.ico +� � �S���� icon.png +� � �S���� nsis-sidebar.bmp +� � �S���� source.svg +� � �S���� android +� � � �S���� mipmap-anydpi-v26 +� � � � ����� ic_launcher.xml +� � � �S���� mipmap-hdpi +� � � � �S���� ic_launcher.png +� � � � �S���� ic_launcher_foreground.png +� � � � ����� ic_launcher_round.png +� � � �S���� mipmap-mdpi +� � � � �S���� ic_launcher.png +� � � � �S���� ic_launcher_foreground.png +� � � � ����� ic_launcher_round.png +� � � �S���� mipmap-xhdpi +� � � � �S���� ic_launcher.png +� � � � �S���� ic_launcher_foreground.png +� � � � ����� ic_launcher_round.png +� � � �S���� mipmap-xxhdpi +� � � � �S���� ic_launcher.png +� � � � �S���� ic_launcher_foreground.png +� � � � ����� ic_launcher_round.png +� � � �S���� mipmap-xxxhdpi +� � � � �S���� ic_launcher.png +� � � � �S���� ic_launcher_foreground.png +� � � � ����� ic_launcher_round.png +� � � ����� values +� � � ����� ic_launcher_background.xml +� � ����� ios +� � �S���� AppIcon-20x20@1x.png +� � �S���� AppIcon-20x20@2x-1.png +� � �S���� AppIcon-20x20@2x.png +� � �S���� AppIcon-20x20@3x.png +� � �S���� AppIcon-29x29@1x.png +� � �S���� AppIcon-29x29@2x-1.png +� � �S���� AppIcon-29x29@2x.png +� � �S���� AppIcon-29x29@3x.png +� � �S���� AppIcon-40x40@1x.png +� � �S���� AppIcon-40x40@2x-1.png +� � �S���� AppIcon-40x40@2x.png +� � �S���� AppIcon-40x40@3x.png +� � �S���� AppIcon-512@2x.png +� � �S���� AppIcon-60x60@2x.png +� � �S���� AppIcon-60x60@3x.png +� � �S���� AppIcon-76x76@1x.png +� � �S���� AppIcon-76x76@2x.png +� � ����� AppIcon-83.5x83.5@2x.png +� ����� src +� �S���� context.rs +� �S���� conversations.rs +� �S���� git.rs +� �S���� lib.rs +� �S���� main.rs +� �S���� models.rs +� �S���� platform.rs +� �S���� providers.rs +� �S���� state.rs +� �S���� swarm.rs +� �S���� terminal.rs +� �S���� tests.rs +� �S���� turns.rs +� �S���� updater.rs +� �S���� workflow.rs +� ����� workspace.rs +����� src + �S���� App.tsx + �S���� main.tsx + �S���� styles.css + �S���� types.ts + �S���� vite-env.d.ts + �S���� components + � �S���� ConversationList.tsx + � �S���� EditorPane.tsx + � �S���� FileTree.tsx + � �S���� GitPanel.tsx + � �S���� ImageContextMenu.tsx + � �S���� SearchPane.tsx + � �S���� SettingsPane.tsx + � �S���� SinewMark.tsx + � �S���� Splitter.tsx + � �S���� TerminalPanel.tsx + � �S���� UpdateBadge.tsx + � �S���� UpdaterLockScreen.tsx + � �S���� Welcome.tsx + � �S���� WindowControls.tsx + � �S���� Workspace.tsx + � ����� chat + � �S���� AIThinkingBlock.tsx + � �S���� ChatPane.tsx + � �S���� DotmSquare2.tsx + � �S���� DotmSquare5.tsx + � �S���� FileChangeBlock.tsx + � �S���� Markdown.tsx + � �S���� MermaidDiagram.tsx + � �S���� PlanningNextMoveBlock.tsx + � �S���� Questionnaire.tsx + � �S���� TodoStrip.tsx + � �S���� ToolCard.tsx + � �S���� dotmatrix-core.tsx + � �S���� dotmatrix-hooks.ts + � ����� stream.ts + �S���� lib + � �S���� customIcons.ts + � �S���� fileIcon.ts + � �S���� frRuntime.ts + � �S���� ipc.ts + � �S���� language.ts + � �S���� locale.ts + � �S���� models.ts + � �S���� quotas.ts + � �S���� recents.ts + � ����� tools.ts +����� sinew-chrome-bridge + �S���� add_to_sinew.py + �S���� background.js + �S���� com.sinew.chrome_bridge.json + �S���� e2e-local.mjs + �S���� e2e-structured.mjs + �S���� icon-128.png + �S���� icon-32.png + �S���� icon-64.png + �S���� icon.jpg + �S���� interact_chrome.js + �S���� launch_chrome_silent.bat + �S���� manifest.json + �S���� mcp_server.js + �S���� native-host-wrapper.exe + �S���� native_host.bat + �S���� package-lock.json + �S���� package.json + �S���� popup.html + �S���� popup.js + �S���� register.ps1 + �S���� run_bridge.bat + �S���� run_sinew_bridge.bat + �S���� server.js + �S���� sinew_cursor.js + ����� native-host-wrapper + �S���� Cargo.toml + ����� src + ����� main.rs + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..3cc8e76b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,647 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [2026-05-30 22:25:25] +- `src/styles.css` : Harmonisation des barres de séparation (diviseurs et bordures) entre le lecteur de document (éditeur/preview) et le panneau de chat. Uniformisation de la hauteur de l'en-tête du chat (`.chat-head`) à 40px pour l'aligner parfaitement avec les onglets de l'éditeur (`.tabs`), et ajout d'une bordure inférieure de séparation nette pour ces deux en-têtes. Mise à jour de la couleur de fond des séparateurs (`.gutter` et `.gutter-h`) avec la couleur standard de bordure (`var(--line)`) et ajout d'un effet de surbrillance réactif lors du survol ou du glissement. Simplification et correction des bordures d'arrière-plan du thème IA en ciblant uniquement les colonnes principales et en supprimant les bordures internes redondantes des sous-composants. + +## [2026-05-30 22:27:43] +- `README.md` : Réintégration du contenu officiel en anglais (les modes, les outils, le swarm, etc.) et retrait de toutes les instructions et sections relatives à l'installation et à la compilation. + +## [2026-05-30 22:24:17] +- `README.md` : Condensation du fichier et intégration de la présentation des fonctionnalités majeures du fork premium. + +## [2026-05-30 22:19:45] +- `crates/sinew-app/src/store.rs` : Ajout d'une table SQLite `provider_states` pour gérer l'archivage/restauration des fournisseurs avec les méthodes correspondantes `set_provider_status`, `get_provider_status` et `list_archived_providers`. Passage à la version 12 du schéma. Ajout du test unitaire `provider_archiving_and_restoring` pour valider le bon fonctionnement de l'archivage/restauration en base SQLite. +- `src-tauri/src/providers.rs` : Enregistrement des commandes Tauri `archive_provider`, `restore_provider`, `list_archived_providers` et filtrage des fournisseurs archivés dans `list_configured_model_providers`. +- `src-tauri/src/lib.rs` : Exposition des nouvelles commandes d'archivage et de restauration des fournisseurs. +- `src/lib/ipc.ts` : Ajout des liaisons IPC pour les méthodes `archiveProvider`, `restoreProvider` et `listArchivedProviders`. +- `src/components/SettingsPane.tsx` : Intégration de la gestion de l'archivage et de la restauration dans les cartes de fournisseurs, masquage des éléments archivés et ajout d'un filtre d'affichage (Actifs / Archivés). Ajout des boutons d'archivage/restauration dans les en-têtes de toutes les cartes de fournisseurs (DeepSeek, OpenRouter, Ollama, ProviderCard). + +## [2026-05-30 22:17:59] +- `src-tauri/src/turns.rs` : Prise en charge du niveau de réflexion (thinking/effort) dans les paramètres d'optimisation de prompt en utilisant `model_with_optional_selection`. +- `src/lib/ipc.ts` : Ajout de l'état `autoOptimizeThinking` dans la configuration active des options d'optimisation magique. +- `src/components/SettingsPane.tsx` : Intégration d'un sélecteur de niveau de réflexion (dropdown) dans l'interface d'Optimisation Magique Auto, avec alignement automatique du niveau lors du changement de modèle. +- `src/components/chat/ChatPane.tsx` : Transmission du niveau de réflexion sélectionné lors des appels à l'API d'optimisation magique automatique et manuelle. + +## [2026-05-30 22:15:36] +- `src/lib/ipc.ts` : Ajout de la clé de configuration `autoOptimizeMode` avec prise en charge des trois options d'optimisation ("auto", "manual", "disabled"). +- `src/components/SettingsPane.tsx` : Remplacement du commutateur activé/désactivé par un choix explicite à trois options : "Automatique", "Actif à la demande", "Désactivé". +- `src/components/chat/ChatPane.tsx` : Affichage permanent du bouton d'optimisation (baguette magique) en bas dans la barre de saisie, avec une désactivation et un titre adaptés au mode choisi. +- `src/styles.css` : Ajustement de l'opacité et du curseur du bouton d'optimisation lorsqu'il est rendu inactif. + +## [2026-05-30 22:09:30] +- `src/components/chat/ChatPane.tsx` : Intégration de l'effet visuel et du blocage de l'interface lors de l'Optimisation Magique. Toutes les commandes de saisie et de validation sont désactivées durant l'optimisation, et un scroll automatique amène le squelette d'optimisation en vue. +- `src/styles.css` : Ajout des animations de reflets mouvants (effet shimmer) et du style de squelette de chargement pour la carte d'optimisation, ainsi que le style semi-opaque inactif pour le conteneur du composeur. + +## [2026-05-30 22:08:04] +- `src/components/SettingsPane.tsx` : Correction d'une balise div non fermée dans la section "Power User". Le manque de fermeture pour le conteneur grid (Ligne 4) imbriquait indûment la Ligne 5 et le choix du mode d'affichage, provoquant un alignement incorrect dans la colonne gauche et un vide dans la colonne droite. Le positionnement et l'alignement des options sont désormais rétablis. + +## [2026-05-30 22:06:13] +- `src/styles.css` : Restriction des halos lumineux d'arrière-plan du chat et de leurs animations au seul mode IA (`data-theme="ai"`). En modes normaux (jour, nuit et système), ces animations et halos sont désormais désactivés pour conserver une interface sobre et sans distractions. + +## [2026-05-30 21:45:24] +- `C:\Users\julie\OneDrive\Bureau\Sinew_0.1.27_x64-setup.exe` : Recompilation de l'installateur Windows et copie sur le Bureau pour fournir une version installable fraîche. + +## [2026-05-30 21:38:12] +- `src/lib/quotas.ts` : Ajustement de la pastille de quota OpenAI/Codex. La pastille suit maintenant la fenêtre courte 5h tant que la semaine n'est pas totalement vide, et ne passe rouge à cause de la semaine que lorsqu'il ne reste vraiment plus rien. + +## [2026-05-30 21:15:44] +- `.gitignore` : Exclusion des fichiers temporaires `_node_*.txt` et `_payload*.json` produits par les tests locaux du pont agent, afin d'éviter d'envoyer des journaux ou jetons de session par erreur. +- `scripts/agent-bridge/_node_err.txt`, `scripts/agent-bridge/_node_out.txt`, `scripts/agent-bridge/_payload25.json` : Suppression des traces de test locales non nécessaires au produit. + +## [2026-05-30 21:09:14] +- `crates/sinew-cursor/src/agent/proto_dynamic.rs` : Correction de la lecture des messages Cursor pour ignorer les enveloppes vides. Composer 2.5 Fast n'était pas refusé : Sinew ouvrait la mauvaise enveloppe, comme un courrier trié dans la mauvaise boîte, et ne répondait donc pas aux demandes de blobs de Cursor. +- `crates/sinew-cursor/src/agent/run_h2.rs` : Conservation de la correction du lecteur HTTP/2 qui empêchait la boucle de rester bloquée après le premier battement de vie du serveur, lecture correcte des blobs enregistrés par Cursor, et retrait des traces de diagnostic temporaires. +- `crates/sinew-cursor/src/tests.rs` : Ajout d'un choix de modèle par variable de test et remise en forme du test live pour vérifier explicitement `composer-2.5-fast`. +- `crates/sinew-cursor/src/identity.rs` : Conservation de l'option de version Cursor configurable pour comparer facilement le comportement avec le client officiel sans retoucher le code. + +## [2026-05-30 21:09:49] +- `src-tauri/src/state.rs` : Ajout du chemin de mémoire persistante `.sinew/memory.md` pour conserver un carnet de projet entre sessions. +- `src-tauri/src/turns.rs` : Injection automatique de `.sinew/memory.md` dans le prompt système quand le fichier existe, afin que l'agent retrouve les décisions, l'état du projet et les pièges connus dès le début d'une session. +- `.sinew/memory.md` : Création du carnet local initial avec les décisions sur le rôle réel du GPU, la recherche hybride et la mémoire persistante. +## [2026-05-30 21:05:00] +- `src-tauri/src/tests.rs` : Ajout d'un test de latence réel (ignoré par défaut, `flash_optimizer_race`) qui met en course DeepSeek V4 Flash et Gemini 3.5 Flash sur la tâche d'optimisation de prompt et mesure le temps jusqu'au 1er mot et le temps de réponse complète. Résultat sur 4 runs : 1er mot quasi à égalité (DeepSeek légèrement devant), mais Gemini 3.5 Flash termine la réponse complète plus vite et de manière plus régulière — meilleur choix pour l'optimiseur. + +## [2026-05-30 20:50:00] +- `src-tauri/src/turns.rs` : Correction de l'Optimisation Magique Auto qui ne réécrivait jamais le prompt. Cause racine : on demandait au modèle un JSON strict, mais un prompt réécrit est souvent multi-lignes, ce qui produit un JSON invalide (sauts de ligne bruts) et faisait échouer l'analyse en silence — le brouillon d'origine était alors envoyé tel quel. Nouveau format texte délimité robuste (`MODE:` + `===PROMPT===`), avec repli sur l'ancien JSON puis sur le texte brut, et normalisation du mode (act/plan/goal). L'option reste en envoi automatique invisible à l'Entrée (choix utilisateur). + +## [2026-05-30 20:41:00] +- `crates/sinew-app/src/agent/boost_distill.rs` (nouveau) : Auto-distillation des grosses sorties d'outils dans la boucle de l'agent. Quand le Boost Local est actif (drapeau `SINEW_BOOST_DISTILLER`), les sorties volumineuses de `bash`/`bash_input`/`web_fetch` (> ~6 000 jetons) sont résumées par le modèle local avant d'entrer dans le contexte du modèle principal. Garde-fous SOTA : jamais sur `read`/`grep`/`codebase_search` (texte exact requis pour les éditions), jamais sur une sortie en erreur, fin de sortie conservée brute (codes d'erreur), repli sur la sortie brute si le distillateur échoue. +- `crates/sinew-app/src/agent.rs` : Déclaration du module `boost_distill`. +- `crates/sinew-app/src/agent/turn.rs` : Branchement de la distillation sur le contenu envoyé au modèle (l'interface continue d'afficher la sortie complète). +- `src-tauri/src/boost.rs` : Pose/retrait du drapeau `SINEW_BOOST_DISTILLER` au démarrage/arrêt du Boost Local (même mécanisme que la sémantique). + +## [2026-05-30 20:24:01] +- `src-tauri/src/boost.rs` (nouveau) : Ajout de la fonctionnalité « Boost Local ». Un seul interrupteur démarre le serveur Ollama si besoin, charge un petit modèle distillateur (`qwen2.5:3b` par défaut) et le garde en mémoire toute la session (`keep_alive = -1`), et active la recherche sémantique vectorielle. Expose 4 commandes : `boost_local_status`, `boost_local_start`, `boost_local_stop`, `boost_local_distill` (cette dernière compresse un gros texte — log, fichier, sortie d'outil — en faits utiles pour économiser les jetons des IA). +- `src-tauri/src/lib.rs` : Déclaration du module `boost` et enregistrement des 4 commandes dans le handler Tauri. +- `src/lib/ipc.ts` : Ajout du type `BoostStatus` et des appels IPC `boostLocalStatus`, `boostLocalStart`, `boostLocalStop`, `boostLocalDistill`. +- `src/components/SettingsPane.tsx` : Ajout de la carte/bouton « Boost Local » (toggle Activé/Désactivé) avec voyants d'état (Ollama, distillateur chargé, sémantique) ; l'activation aligne aussi le toggle de recherche sémantique existant. +- `scripts/boost-proof.ps1` (nouveau) : Script de preuve mesurée — démontre l'économie de jetons (recherche ciblée + distillation locale) sur un vrai fichier du projet (99,3% de jetons en moins, réponse correcte). +## [2026-05-30 19:41:23] +- `crates/sinew-ollama/*` (nouveau crate) : Ajout d'un fournisseur Ollama complet pour utiliser les modèles installés localement. Détecte automatiquement tous les modèles via l'API locale d'Ollama (`/api/tags` + `/api/show` pour le contexte et les capacités outils/vision/raisonnement), discute en streaming via l'endpoint compatible OpenAI, et stocke l'adresse du serveur (par défaut `http://localhost:11434`) au lieu d'une clé. +- `Cargo.toml`, `src-tauri/Cargo.toml` : Déclaration et branchement du nouveau crate `sinew-ollama`. +- `crates/sinew-app/src/store.rs` : Ajout du stockage de la liste des modèles Ollama (`load_ollama_models` / `save_ollama_models`). +- `src-tauri/src/models.rs` : Ajout des structures `OllamaProviderStatus` et `ConnectOllamaInput`. +- `src-tauri/src/providers.rs` : Ajout des fonctions d'installation/retrait du fournisseur et des commandes `get_ollama_provider_status`, `connect_ollama_provider`, `refresh_ollama_models`, `list_ollama_models`, `disconnect_ollama_provider`. +- `src-tauri/src/lib.rs` : Enregistrement du fournisseur Ollama au démarrage, dans le modèle par défaut et dans la liste des commandes exposées au frontend. +- `src/types.ts`, `src/lib/ipc.ts`, `src/lib/models.ts` : Ajout du type/carte Ollama, des appels IPC et de l'intégration des modèles Ollama dans les listes de choix de modèle. +- `src/components/SettingsPane.tsx`, `src/styles.css`, `src/lib/frRuntime.ts` : Ajout de la carte Ollama dans les paramètres (connexion par adresse, détection auto, actualisation, déconnexion) avec son style et ses traductions françaises. +- `src/components/chat/ChatPane.tsx` : Chargement des modèles Ollama et affichage dans le sélecteur de modèle. + +## [2026-05-30 19:42:00] +- `src-tauri/src/rules.rs` : Ajout d'un filet de sécurité à la consolidation par IA. Avant d'écraser `instructions_consolidated.md`, l'ancienne version est sauvegardée (`instructions_consolidated.bak.md`), et la réécriture est refusée si le résultat de l'IA est anormalement court (moins de la moitié de l'actuel) afin d'éviter toute perte silencieuse de règles en cas de réponse tronquée du modèle. + +## [2026-05-30 19:17:08] +- `crates/sinew-app/src/agent/repeat_guard.rs` (nouveau) : Ajout du maillon « Capture » manquant du système d'auto-amélioration. Détecte quand l'agent rejoue la même commande shell ou le même appel d'outil en erreur sans progresser : injecte un rappel fort à 3 répétitions, puis enregistre l'incident dans `errors_raw.json` et coupe le tour à 4. Ces incidents alimentent désormais automatiquement la consolidation en règles globales (avant, `errors_raw.json` n'était jamais rempli par le code). +- `crates/sinew-app/src/agent/turn.rs` : Branchement du détecteur de boucle dans la boucle d'outils (observation de chaque résultat, injection du rappel dans le prompt système, enregistrement + arrêt propre du tour avec événement d'erreur quand une boucle est avérée). +- `crates/sinew-app/src/agent.rs` : Déclaration du module `repeat_guard`. +- `crates/sinew-app/Cargo.toml` : Ajout de la dépendance `chrono` (horodatage des incidents). +## [2026-05-30 18:22:51] +- `src/components/SettingsPane.tsx` : Déplacement de "Optimisation Magique Auto" en tête de l'onglet Power User sous forme de carte pleine largeur responsive. Rétablissement de l'accès au menu de choix de modèle d'analyse indépendamment de l'état d'activation de l'optimisation. Transfert de l'option "Recherche de mise à jour automatique" vers l'onglet Système (Diagnostics) pour une cohérence thématique parfaite. +- `src/styles.css` : Ajout de la classe `.settings-pane__select` pour habiller proprement les listes déroulantes de paramètres. + +## [2026-05-30 18:21:34] +- `crates/sinew-anthropic/src/client.rs` : Ajout de la méthode `get_usage` pour récupérer les limites de quota Anthropic via l'API OAuth `/api/oauth/usage` avec gestion du rafraîchissement des jetons. +- `src-tauri/src/providers.rs` : Ajout de la commande Tauri `get_anthropic_usage` et définition du cache associé pour récupérer l'utilisation de l'abonnement Anthropic. +- `src-tauri/src/lib.rs` : Exposition de la commande Tauri `get_anthropic_usage` au processus frontal. +- `src/lib/ipc.ts` : Déclaration de la méthode IPC `getAnthropicUsage`. +- `src/lib/quotas.ts` : Implémentation de la récupération et du parsing du quota pour le fournisseur Anthropic en utilisant les données de l'API d'abonnement Anthropic. + +## [2026-05-30 18:18:38] +- `src/components/SettingsPane.tsx` : Correction des problèmes d'encodage pour le bouton "Jour" et "Système" dans l'onglet Apparence, remplacement par des emojis corrects. Remplacement des réglages de taille numérique par une structure de classe CSS unifiée et correction de l'icône de titre pour DeepSeek. +- `src/styles.css` : Ajout de la classe `.settings-pane__number-adjuster` pour uniformiser la disposition des ajusteurs numériques. Conversion de la règle responsive `@media` en `@container` query sur les lignes à deux colonnes pour qu'elles se comportent correctement suivant la taille du panneau de réglages, et élargissement de la bascule adaptative des cartes de 500px à 600px pour une meilleure lisibilité mobile. + +## [2026-05-30 17:51:00] +- `src/components/chat/chatUtils.ts` : Ajout de la fonction `playNotificationSound` utilisant l'API Web Audio pour jouer un carillon agréable à la fin d'un chat. +- `src/components/chat/ChatPane.tsx` : Importation et déclenchement de la sonnerie de notification lors de la réception de l'événement `turn_finished`. +- `src/components/SettingsPane.tsx` : Ajout de l'option de configuration pour activer/désactiver la sonnerie de fin de chat sous l'onglet Apparence, avec synchronisation de l'état dans le stockage local. + +## [2026-05-30 16:58:12] +- `src/components/Workspace.tsx` : Correction du comportement des menus de fermeture d'onglets (Fermer les autres, Fermer à droite, Fermer tous) pour qu'ils ferment également l'onglet "Paramètres" s'il est ouvert et actif, évitant ainsi le rendu d'un écran noir vide. +- `src/components/EditorPane.tsx` : Mise à jour du composant `EditorTabContextMenu` pour accepter la propriété `settingsOpen`. Le bouton "Fermer les onglets à droite" reste désormais cliquable même sur le dernier onglet fichier si l'onglet Paramètres est ouvert (puisqu'il est situé à sa droite). + +## [2026-05-30 16:55:47] +- `REFACTORING_PLAN.md` : Création du rapport d'audit global. Identification et priorisation des 3 meilleures cibles de rationalisation : unification des providers LLM (Rust), découpage des composants monolithiques SettingsPane/ChatPane (React), et standardisation du pipeline d'outils (Rust). + +## [2026-05-30 16:17:12] +- `src-tauri/src/workspace.rs` : Automatisation du déploiement du daemon Linux "Super SSH". Si le binaire local est absent, la commande de démarrage télécharge automatiquement la dernière release stable du daemon `sinew-agent-daemon-linux` via `curl` sur le serveur distant depuis GitHub, garantissant une utilisation sans configuration pour les utilisateurs. +- `.github/workflows/release.yml` : Ajout d'une étape de compilation native du daemon (`cargo build --release -p sinew-agent-daemon`) sur les runners Linux de l'intégration continue. Le binaire est ensuite publié en tant qu'asset additionnel (`sinew-agent-daemon-linux`) sur les releases GitHub. + + + +## [2026-05-30 16:01:00] +- `src-tauri/src/workspace.rs` : Création de la commande Tauri `mount_super_ssh_workspace` qui orchestre la connexion "Super SSH". Cette commande déploie dynamiquement le daemon natif compilé sur le serveur distant via SCP, le démarre en arrière-plan, puis met en place un port forwarding SSH local (47990 -> 127.0.0.1:47990) sans bloquer l'interface. +- `src-tauri/src/workspace.rs` : Ajout de la méthode `proxy_to_daemon` et interception des requêtes de fichiers (`list_workspace_entries_command`, `list_workspace_files_command`, `read_workspace_file_command`, `write_workspace_file_command`, `search_workspace_files_command`). Si l'espace de travail est préfixé par `super-ssh://`, les commandes ne lisent pas le disque local mais encapsulent l'appel en JSON et le transmettent au daemon distant sur le port 47990 pour une latence nulle. +- `src-tauri/src/turns.rs` : Modification du routeur d'agents (`run_turn_via_daemon`). En mode Super SSH, la communication ne s'établit plus sur le pipe nommé Windows `\\.\pipe\sinew-agent-ipc` mais via une connexion TCP directe (`127.0.0.1:47990`) vers le daemon Linux du serveur. +- `crates/sinew-app/src/workspace.rs` : Dérivation et exposition complète des traits de désérialisation (`Deserialize`) sur toutes les structures liées aux résultats de requêtes de l'espace de travail (`WorkspaceEntry`, `FileDocument`, `WorkspaceSearchResult`, etc.) pour supporter le parsing des données distantes renvoyées par le proxy. +- `src-tauri/src/lib.rs` : Exposition de la commande système `mount_super_ssh_workspace` au processus IPC Tauri pour permettre au client React de l'invoquer depuis `Welcome.tsx`. + +## [2026-05-30 16:09:40] +- `src-tauri/src/turns.rs` : Assouplissement du prompt système de la règle "Maquettes Visuelles Automatiques" suite aux retours utilisateurs. L'agent est désormais encouragé à générer proactivement des diagrammes Mermaid pour illustrer ses explications ou son architecture, au lieu de s'en priver par peur de bloquer l'édition de fichiers. +- `src/components/SettingsPane.tsx` : Mise à jour de la description UI de la règle des maquettes visuelles pour refléter le changement (passage d'un comportement passif "uniquement si demandé" à un comportement proactif "Génère spontanément des schémas"). + +## [2026-05-30 16:05:00] +- `crates/sinew-agent-daemon/src/protocol.rs` & `crates/sinew-agent-daemon/src/main.rs` : Ajout des requêtes `ListEntries`, `ListAllFiles`, `ReadFile` et `WriteFile` au protocole IPC du démon. Cela permet au frontend de lire et écrire des fichiers distants via le proxy TCP en mode Super SSH, sans nécessiter de point de montage SSHFS, garantissant une latence nulle. +- `src/components/Welcome.tsx` : Ajout de l'option de sélection "Super SSH (Native Agent)" dans le formulaire de connexion SSH pour utiliser le nouveau mode de connexion proxy distant SOTA. +- `src/lib/ipc.ts` : Ajout de la méthode `mountSuperSshWorkspace` qui appelle la commande Tauri `connect_super_ssh` de `@backend_ssh` pour gérer la connexion "Super SSH". + +## [2026-05-30 16:01:00] +- `crates/sinew-agent-daemon/src/main.rs` : Adaptation du démon persistant pour écouter sur le port TCP `127.0.0.1:47990` sur les environnements Linux (en plus de l'écoute sur le Named Pipe sous Windows), permettant la création de l'architecture "Super SSH" et la connexion proxy du frontend vers le serveur distant. + + +- `src/components/SettingsPane.tsx` : Amélioration de "Optimisation Magique Auto" qui s'active de manière totalement invisible lors de l'appui sur "Entrée" dans le panneau de chat. +- `src/components/chat/ChatPane.tsx` : Interception de l'envoi de message pour appeler l'API d'optimisation, basculer le mode (Action, Plan, Objectif) en temps réel, puis relayer automatiquement le prompt structuré à l'agent sans aucune friction. +- `src-tauri/src/turns.rs` : Remplacement complet de la méthode d'extraction du JSON par une recherche textuelle du bloc JSON au lieu du pattern `trim` pour garantir que le prompt de retour soit correctement identifié même s'il est noyé dans le texte. + +## [2026-05-30 16:02:15] +- `src/components/SettingsPane.tsx` : Ajout de deux nouvelles options avancées (Power User) dans l'interface : "Résolution Stricte des Problèmes" (pour interdire à l'agent de contourner les erreurs) et "Implémentation Complète" (pour interdire les faux blocs de code ou les commentaires TODOs). +- `src/lib/ipc.ts` : Transmission des nouvelles préférences `strictProblemSolving` et `fullImplementation` via les payloads IPC d'estimation et d'envoi de messages. +- `src-tauri/src/state.rs` : Création des constantes `DEFAULT_STRICT_PROBLEM_SOLVING_PROMPT` et `DEFAULT_FULL_IMPLEMENTATION_PROMPT` définissant les consignes à injecter dans le contexte de l'IA. +- `src-tauri/src/models.rs` & `src-tauri/src/turns.rs` & `src-tauri/src/context.rs` : Injection de ces nouvelles instructions système aux prompts racines du moteur Rust. + +## [2026-05-30 16:01:20] +- `src-tauri/src/state.rs` & `src-tauri/src/turns.rs` : Ajout d'une consigne système permanente (`DEFAULT_SSH_OPTIMIZATION_PROMPT`) pour les agents. Lorsqu'ils détectent être sur un espace de travail distant monté en SSHFS, les agents utiliseront désormais leurs outils MCP natifs (`ssh_exec`) pour installer des utilitaires (SOTA) et exécuter des scripts directement sur le serveur, afin de contourner la latence réseau des outils de recherche de fichiers en local. + + + +## [2026-05-30 15:44:34] +- `src/components/Workspace.tsx` : Suppression du composant de connexion SSH dans la barre latérale inférieure de l'espace de travail. Le bouton "Se connecter" et ses états associés ont été retirés pour éviter toute confusion avec le changement de projet, réservant ainsi l'accès SSH à l'écran de bienvenue. + + + +## [2026-05-30 15:44:10] +- `src-tauri/src/turns.rs` : Masquage de l'avertissement répétitif (`WARN`) signalant l'absence du démon d'agent (qui bascule silencieusement sur le moteur local) en abaissant le niveau de log à `DEBUG` pour ne plus polluer la console. Arrêt propre de la tentative de création de processus si le binaire `sinew-agent-daemon.exe` n'est pas compilé. + +## [2026-05-30 15:43:00] +- `src-tauri/src/turns.rs` & `src/lib/ipc.ts` : Ajout d'une commande Tauri `optimize_prompt` qui fait appel au modèle sélectionné pour agir comme "Prompt Engineer". Le modèle analyse le brouillon de l'utilisateur, détermine le mode optimal (`act`, `plan`, `goal`), et retourne une consigne réécrite de qualité professionnelle. +- `src/components/chat/ChatPane.tsx` : Intégration d'un bouton "Baguette magique" (Optimiser) directement dans le panneau de saisie. Un clic envoie le brouillon à l'IA d'optimisation, remplace le texte par la consigne SOTA structurée, et bascule le menu déroulant sur le mode recommandé automatiquement. + +## [2026-05-30 15:35:10] +- `src-tauri/src/models.rs` : Neutralisation de warnings du compilateur Rust (`dead_code`) sur les structures `OptimizePromptInput` et `OptimizePromptOutput`. + +## [2026-05-30 15:27:26] +- `src-tauri/src/tray.rs` : Nettoyage du code Rust. Suppression des imports de modules inutilisés (`DesktopState`, `Runtime`, `Manager`) et des variables mortes (`handle`, `tray`) signalés par le compilateur pour maintenir une base de code propre et sans avertissements. + +## [2026-05-30 15:25:37] +- `Cargo.lock`, `Cargo.toml`, `package.json`, `src-tauri/tauri.conf.json` : Mise à jour de la version vers la `0.1.27` via la fusion avec le dépôt upstream. +- `crates/sinew-app/src/store.rs` : Amélioration SOTA du mode Plan. L'IA a interdiction de détailler l'implémentation (code, shell), mais doit impérativement conserver les choix de design, les technologies et les paramètres décidés lors de la discussion pour un plan plus précis et actionnable. +- `src-tauri/src/state.rs` : Simplification du prompt système pour supprimer l'obligation fastidieuse des mises à jour utilisateur toutes les 30 secondes en mode exploration, rendant l'IA plus silencieuse et efficace. + +## [2026-05-30 15:21:45] +- `src/components/SettingsPane.tsx` : Rétablissement intégral des emojis et des caractères accentués suite à une corruption d'encodage (passage de UTF-8 à ISO-8859-1). Remplacement par regex de toutes les entités corrompues (ex: soleil, lune, ordinateur, etc.) pour garantir un affichage propre du panneau de paramètres. + +## [2026-05-30 15:14:38] +- `src/components/SettingsPane.tsx` : Correction des erreurs d'encodage (mojibake) où les caractères accentués français apparaissaient sous la forme de caractères corrompus à cause d'une précédente écriture dans un format de texte incorrect. Le fichier a été restauré en UTF-8 pur. +- `AGENTS.md` : Correction identique de l'encodage pour restaurer les caractères français. + +## [2026-05-30 15:10:00] +- `src-tauri/Cargo.toml` & `Cargo.toml` : Activation du plugin natif `tray-icon` pour afficher l'icône de Sinew dans la zone de notification Windows. +- `src-tauri/src/tray.rs` : Création du module de gestion de l'icône système (Tray Icon) pour proposer le menu des projets récents lors d'un clic droit sur l'icône Sinew (en bas à droite). +- `src-tauri/src/lib.rs` : Intégration et exposition des commandes Tauri (`get_recent_workspaces_command`, `record_recent_workspace_command`, `clear_recent_workspaces_command`) pour que les projets récents soient sauvegardés directement sur le disque (en Rust) plutôt que dans le `localStorage` volatile du navigateur. +- `src/lib/recents.ts` : Réécriture de la gestion des projets récents pour s'interfacer avec le backend Rust de manière asynchrone, garantissant la persistance des projets même si les données du navigateur sont effacées. +- `src/App.tsx` : Modification du processus de démarrage de l'application pour utiliser le dernier projet enregistré via le backend Rust, garantissant que Sinew s'ouvre toujours sur le dernier espace de travail de façon très fiable et ne demande plus le dossier à chaque ouverture. + +## [2026-05-30 15:07:15] +- `src/lib/recents.ts` & `src/components/Welcome.tsx` : Amélioration SOTA pour le "Sans dossier" (Sandbox). Au lieu de le cacher ou de l'afficher comme un dossier système brut, il est désormais intégré à l'historique avec une interface dédiée. +- src/components/chat/TodoStrip.tsx : Implémentation d'une vue Kanban temps réel pour le mode Swarm (Essaim d'agents), remplaçant la liste plate par des colonnes 'À faire', 'En cours', 'Bloqué' et 'Terminé'. +- crates/sinew-app/src/agent/tool_dispatch.rs : Implémentation du mode Auto-Lint Ghost-Loop ! Les appels à 'edit_file' et 'write_file' incluent désormais instantanément les retours des linteurs (cargo, eslint, etc.) s'ils ont échoué, forçant l'agent à s'auto-corriger dans la foulée. +- src/components/chat/ChatPane.tsx : Extraction chirurgicale des fonctions utilitaires pures (formatBytes, formatTurnDuration, etc.) vers 'chatUtils.ts' pour alléger le composant monolithique sans risque de conflits.: icône de boîte distinctive (`solar:box-bold-duotone`), nom "Brouillon actif (Sandbox)", et un sous-titre clair ("Dernier espace de travail temporaire") masquant le chemin technique. + +## [2026-05-30 15:04:00] +- src/components/SettingsPane.tsx : Déplacement des barres de quotas sous les boutons d'action (Se déconnecter/Se connecter) afin de gagner de l'espace horizontal. +- src/components/SettingsPane.tsx : Découpage de l'onglet massif 'Options' en 3 nouveaux onglets dédiés dans la navigation principale ('Apparence', 'Power User', et 'Système') pour aérer l'interface. +- src/components/SettingsPane.tsx : Déplacement de la 'Synchronisation Multi-PC', 'Recherche Sémantique', et 'Apprentissage Automatique IA' vers le nouvel onglet 'Système'. Harmonisation de leur apparence dans la grille dédiée. +- src/components/chat/TodoStrip.tsx : Implémentation d'une vue Kanban temps réel pour le mode Swarm (Essaim d'agents), remplaçant la liste plate par des colonnes 'À faire', 'En cours', 'Bloqué' et 'Terminé'. +- crates/sinew-app/src/agent/tool_dispatch.rs : Implémentation du mode Auto-Lint Ghost-Loop ! Les appels à 'edit_file' et 'write_file' incluent désormais instantanément les retours des linteurs (cargo, eslint, etc.) s'ils ont échoué, forçant l'agent à s'auto-corriger dans la foulée. +- src/components/chat/ChatPane.tsx : Extraction chirurgicale des fonctions utilitaires pures (formatBytes, formatTurnDuration, etc.) vers 'chatUtils.ts' pour alléger le composant monolithique sans risque de conflits.sur les cartes fournisseurs et éviter qu'elles ne soient écrasées sur de petites résolutions. +- src/components/SettingsPane.tsx : Retrait des mentions explicites de numéros de version (ex: V3 & R1) pour la description de DeepSeek, car ces informations évoluent vite. +- src/lib/frRuntime.ts : Raccourcissement de "Limite atteinte" en "Limite" pour optimiser l'affichage. +- src/components/SettingsPane.tsx : Masquage automatique de la description du fournisseur une fois connecté pour gagner de la place verticalement. +- src/components/SettingsPane.tsx : Correction de l'affichage interne des sous-cartes de fournisseurs pour que les barres de quotas ne détruisent plus l'alignement des emails. +- src/components/SettingsPane.tsx : Optimisation extrême des sous-cartes : les quotas sont maintenant empilés proprement sur toute la largeur, et les détails (emails, plan, etc.) sont fusionnés sur une seule ligne condensée. +- src/components/SettingsPane.tsx : Transformation des quotas des sous-cartes en blocs pleine largeur avec l'info au-dessus de la barre pour exploiter tout l'espace horizontal. +- src/components/SettingsPane.tsx : Découpage de l'onglet massif 'Options' en 3 nouveaux onglets dédiés dans la navigation principale ('Apparence', 'Power User', et 'Système') pour aérer l'interface. +- src/components/SettingsPane.tsx : Déplacement de la 'Synchronisation Multi-PC', 'Recherche Sémantique', et 'Apprentissage Automatique IA' vers le nouvel onglet 'Système'. Harmonisation de leur apparence dans la grille dédiée. +- src/components/chat/TodoStrip.tsx : Implémentation d'une vue Kanban temps réel pour le mode Swarm (Essaim d'agents), remplaçant la liste plate par des colonnes 'À faire', 'En cours', 'Bloqué' et 'Terminé'. +- crates/sinew-app/src/agent/tool_dispatch.rs : Implémentation du mode Auto-Lint Ghost-Loop ! Les appels à 'edit_file' et 'write_file' incluent désormais instantanément les retours des linteurs (cargo, eslint, etc.) s'ils ont échoué, forçant l'agent à s'auto-corriger dans la foulée. +- src/components/chat/ChatPane.tsx : Extraction chirurgicale des fonctions utilitaires pures (formatBytes, formatTurnDuration, etc.) vers 'chatUtils.ts' pour alléger le composant monolithique sans risque de conflits. +## [2026-05-30 15:02:20] +- `src/lib/recents.ts` : Exclusion automatique du dossier temporaire (`.sinew-sandbox` ou "Sans dossier") de la liste des projets récents affichés sur la page d'accueil pour éviter de polluer l'historique de l'utilisateur, tout en conservant la possibilité de le rouvrir automatiquement au prochain lancement si c'était le dernier projet actif. + +## [2026-05-30 14:57:30] +- `src/components/SettingsPane.tsx` : Ajout d'une fonctionnalité permettant de masquer les fournisseurs de modèles non utilisés (bouton œil barré sur les fournisseurs non connectés) et de les réafficher depuis une nouvelle section "Ajouter un fournisseur" en bas de page. Seuls OpenAI, Google et DeepSeek sont affichés par défaut (ainsi que les fournisseurs ayant déjà des identifiants/comptes). + + +## [2026-05-30 14:56:52] +- `src/components/SettingsPane.tsx` : Correction d'un problème d'affichage où le composant `QuotaBar` en mode inline dépassait du bord de la carte pour les fournisseurs ayant de longs libellés de quotas (ex: "Claude & GPT-OSS"). Ajout de `maxWidth: "100%"`, `overflow: "hidden"` et `textOverflow: "ellipsis"` pour tronquer proprement le texte à l'intérieur. + +## [2026-05-30 14:55:00] +- `src/components/chat/ToolCard.tsx` : Suppression complète du bouton "Auto" / "Auto-fix", car les relances et correctifs automatiques doivent être gérés par l'agent de manière autonome sans nécessiter d'intervention manuelle. + +## [2026-05-30 14:54:12] +- `src/components/SettingsPane.tsx` : Ajout de paliers de couleurs supplémentaires pour le solde DeepSeek : Rouge (<10$), Orange (<20$), Jaune (<40$), Vert clair (<60$), Émeraude (<80$), Cyan (<100$) et Bleu Tech (>=100$). + +## [2026-05-30 14:51:15] +- `src/components/chat/ToolCard.tsx` : Modification du bouton "Auto" en "Auto-fix" avec une nouvelle icône baguette magique, car l'ancienne icône de sliders ressemblait à un caractère chinois et son utilité n'était pas claire. + +## [2026-05-30 14:52:10] +- `src/components/SettingsPane.tsx` : Ajout d'un code couleur dynamique pour l'affichage du solde (ex: DeepSeek) : rouge si le solde est inférieur à 10$, orange s'il est entre 10$ et 20$, et vert au-delà. + +## [2026-05-30 14:48:45] +- `src/components/SettingsPane.tsx` : Agrandissement de la police (de 11px à 13px) et changement de la couleur en vert émeraude (`#10b981`) pour le texte du solde restant DeepSeek lorsque le pourcentage est nul, afin qu'il soit bien plus visible et lisible ("plus gros"). + +## [2026-05-30 14:46:07] +- `src/lib/quotas.ts` : Suppression du calcul inutile du pourcentage pour DeepSeek car le point de terminaison de l'API rendait toujours `100%` (le dénominateur est souvent égal au reste), renvoyant `null` à la place. +- `src/components/SettingsPane.tsx` : Modification de l'affichage en ligne du composant `QuotaBar` pour masquer la barre de progression complète et afficher uniquement le texte centré et agrandi lorsque le pourcentage restant est nul (`null`), ce qui permet d'afficher la ligne de crédit DeepSeek ("Restant $17.08") plus clairement sans une barre `100%` trompeuse. + + +- `src-tauri/src/turns.rs` : Implémentation de la lecture "Hot-Reload" en temps réel des règles d'IA (Cerveau Cloud via OneDrive). L'application lit désormais dynamiquement `instructions_consolidated.md` directement depuis OneDrive (avant de basculer sur LocalAppData) à chaque nouveau message, permettant des améliorations SOTA immédiates sans recharger ni recompiler l'application. + +## [2026-05-30 14:21:14] +- `AGENTS.md` : Clarification de la règle sur `cwd` dans le terminal pour éviter la confusion avec la règle d'utilisation des chemins absolus (les chemins absolus sont interdits pour l'outil bash mais requis pour read/write). + +## [2026-05-30 14:14:00] +- `crates/sinew-cursor/src/agent/run_h2.rs` : Remplacement de l'appel `hyper::body::to_bytes(resp.into_body()).await` par `resp.into_body().collect().await.map(|c| c.to_bytes())` pour s'adapter à l'API de Hyper 1.x et corriger l'erreur de compilation `E0425: cannot find function to_bytes in module hyper::body` remontée dans `build-error.txt`. + +## [2026-05-30 14:10:18] +- `AGENTS.md` : Ajout d'une règle d'auto-amélioration globale pour documenter l'obligation de fournir le paramètre `limit` avec `grep`/`glob` et de vérifier l'existence des fichiers avant l'appel (via `Test-Path`), suite aux erreurs de l'agent. + +## [2026-05-30 14:06:32] +- `src-tauri/capabilities/default.json` : Extension des fenêtres autorisées à toutes (`*` au lieu de `main, sinew-window-*`) et ajout explicite de la permission `dialog:allow-message` pour s'assurer que le frontend puisse exécuter `window.confirm` sur n'importe quelle fenêtre Tauri sans blocage ACL. + +## [2026-05-30 14:04:09] +- `src-tauri/capabilities/default.json` : Ajout des permissions `dialog:allow-confirm` et `dialog:allow-ask` pour autoriser le frontend à utiliser les boîtes de dialogue de confirmation natives via `window.confirm`, résolvant l'erreur d'autorisation ACL `Command plugin:dialog|confirm not allowed by ACL` identifiée dans le fichier de log `frontend-error.log`. + +## [2026-05-30 11:26:12] +- `src/components/chat/ToolCard.tsx` : Remplacement du libellé du bouton de dépannage de "Auto-réparer" par "Auto" pour plus de clarté. + +## [2026-05-30 11:17:33] +- `src/styles.css` : Ajustement des styles de l'encart SSH pour forcer l'affichage de l'état de connexion et du bouton sur une seule ligne (sans retour à la ligne) avec troncature automatique du texte en cas de manque d'espace. + +## [2026-05-30 11:15:47] +- src/components/SettingsPane.tsx : Ajout de la sérialisation du champ id dans settingsToJson pour préserver les identifiants uniques des serveurs MCP (tels que sinew-chrome ou mcp_ssh_mcp) lors de leur affichage et de leur édition dans la configuration avancée du frontend, résolvant le problème d'outils manquants dû à des ID non concordants entre le backend et le frontend. +- crates/sinew-app/src/edit.rs : Normalisation en minuscules des chemins relatifs sous Windows lors de la modification de fichiers pour assurer la cohérence avec l'outil de lecture et éviter le blocage de sécurité (read-before-write) dû aux différences de casse. + +## [2026-05-30 11:14:58] +- `src-tauri/src/rules.rs` : Suppression automatique du marqueur de début de fichier UTF-8 (BOM `\u{FEFF}`) lors de la lecture de `errors_raw.json` pour éviter l'erreur d'analyse JSON `Format errors_raw.json invalide: expected value at line 1 column 1` qui bloquait la consolidation des règles par l'IA. + +## [2026-05-30 04:01:40] +- `src/components/Welcome.tsx` : Enregistrement de l'hôte SSH connecté dans le stockage local du navigateur (`localStorage`) pour pouvoir l'identifier plus tard. +- `src/components/Workspace.tsx` : Ajout d'un encart de connexion SSH au bas de la colonne de gauche (barre latérale). Il affiche un indicateur vert avec le nom du serveur si connecté, ainsi qu'un bouton de déconnexion. Sinon, il propose un bouton "Se connecter" qui ouvre un petit formulaire intégré avec gestion des connexions rapides. +- `src/styles.css` : Ajout des styles graphiques pour le nouvel encart de connexion SSH en bas à gauche de l'interface. + +## [2026-05-30 03:57:31] +- `src/lib/quotas.ts` : Correction du calcul du pourcentage DeepSeek — la barre incluait seulement le solde rechargé (`toppedUpBalance`) comme dénominateur, ce qui donnait toujours ≥100% tant que des crédits gratuits (`grantedBalance`) étaient disponibles. Le dénominateur devient `toppedUpBalance + grantedBalance` pour refléter le total réel. + +## [2026-05-30 03:55:30] +- **Icônes globales — 12 fichiers, ~50 icônes modernisées** : + - `circle` → `square` : `close` (20 occurrences), `add` (8), `minus` (3) — style carré plus net et cohérent. + - `trash-bin-trash` → `trash-bin-minimalistic` : (6 occurrences) icône poubelle plus moderne. + - `linear` → `bold` : toutes les flèches (`alt-arrow-right/down/up`, `square-alt-arrow-up/down`) pour une meilleure visibilité. + - Spécifiques : `wrench` → `tuning`, `download-linear` → `download-square`, `play-linear` → `play-circle`, `rewind-back` → `undo-left`, `clock-circle` → `history`. + +## [2026-05-30 03:53:56] +- `src/components/chat/ChatPane.tsx` : 12 icônes modernisées — zoom lightbox (`magnifer-zoom` → `minimize/maximize-square`), pièce jointe (`paperclip-bold` → `paperclip-rounded-bold`), retour (`alt-arrow-left` → `arrow-left`), scroll question (`arrow-up` → `arrow-to-top-left`), téléchargement (`download-linear` → `download-square`), retour arrière (`rewind-back` → `undo-left`), archives `linear` → `bold` (3 occurrences), historique (`clock-circle` → `history`). +- `src/components/chat/ToolCard.tsx` : Icône clé à molette (`wrench` → `tuning`) plus moderne, stop (`stop-circle-linear` → `stop-bold`). +- `src/components/chat/MermaidDiagram.tsx` : Zoom (`minus-circle`/`add-circle` → `minimize/maximize-square`) plus cohérent avec la lightbox. +- `src/components/chat/TodoStrip.tsx` : Flèches expand/collapse (`alt-arrow-down/up-linear` → `bold`) plus visibles. + +## [2026-05-30 03:53:45] +- `crates/sinew-app/src/agent/turn.rs` : Passage de `info!` à `debug!` pour compaction et turn_finished (uniformité totale). + +## [2026-05-30 03:51:25] +- `src-tauri/src/main.rs` : Ajout d'un panic hook global qui capture toutes les panics Rust dans `logs/panic.log` avant le crash. +- `src-tauri/src/lib.rs` : Ajout de la commande `log_frontend_error` qui écrit les erreurs du frontend dans `logs/frontend-error.log`. +- `src/main.tsx` : Ajout de `window.onerror` and `window.onunhandledrejection` qui capturent toutes les erreurs JS/React et les envoient au backend. +- `src/lib/ipc.ts` : Ajout de la méthode `logFrontendError`. +- **Couverture erreurs totale** : panics Rust + erreurs React/JS + erreurs bridge = tout dans `logs/`. + +## [2026-05-30 03:48:15] +- `scripts/agent-bridge/run-stream.mjs` : Ajout logger JSON fichier vers `logs/agent-bridge.log` + timers (bridge_start, h2_connected, mcp_tool_exec, bridge_end). +- `scripts/agent-bridge/h2-bridge.mjs` : Ajout logger JSON fichier vers `logs/h2-bridge.log` + timers (bridge_start, h2_stream_end/error). +- `%LOCALAPPDATA%/sinew/ChromeBridge/server.js` : Mirror des logs vers `logs/chrome-bridge.log`. +- `%LOCALAPPDATA%/sinew/ChromeBridge/mcp_server.js` : Ajout log fichier vers `logs/chrome-mcp.log`. +- **Centralisation totale** : tous les logs (Rust + Node.js bridges) convergent maintenant dans `%LOCALAPPDATA%/dev/hyrak/sinew/data/logs/`. + +## [2026-05-30 03:41:07] +- `crates/sinew-app/src/web.rs` : Ajout timers web_search + web_fetch. +- `crates/sinew-app/src/store.rs` : Ajout timer load_conversation. +- `crates/sinew-cursor/src/agent/run_h2.rs` : Ajout timer Cursor bridge h2 (durée totale + output tokens). +- `crates/sinew-index/src/search.rs` : Ajout timer workspace search. +- Couverture complète atteinte : tous les points d'entrée/sortie majeurs sont maintenant tracés avec durée. + +## [2026-05-30 03:35:24] +- `src-tauri/src/rules.rs` : Refonte du prompt système de `ai_consolidate_rules()` avec un système de confiance à 3 niveaux (🟢 ACTIVE / 🟡 CANDIDATE / 🔴 OBSOLÈTE), traçabilité complète (origine des erreurs, dates, règles remplacées), et dégradation automatique des règles obsolètes (2+ mois sans mise à jour ou contredites par une règle plus récente). + +## [2026-05-30 03:24:31] +- `src-tauri/src/lib.rs` : Changement du filtre de log par défaut de `info` à `trace` pour tous les crates Sinew (`sinew_app`, `sinew_cursor`, `sinew_openai`, `sinew_anthropic`, `sinew_google`, `sinew_kimi`, `sinew_deepseek`, `sinew_openrouter`, `sinew_index`, `sinew_core`). Les libs externes restent à `warn`/`debug` pour éviter le bruit. Le fichier de log passe de `desktop-app.log` à `logs/sinew.log` avec rotation à 64 Mo. +- `crates/sinew-app/src/agent/turn.rs` : Ajout de timers de précision (stream setup, premier token, exécution de chaque outil, compaction automatique, durée totale du tour) avec `tracing::debug!` et `tracing::info!`. +- `crates/sinew-app/src/store.rs` : Ajout d'un timer sur `save_conversation` (temps SQLite + sérialisation). +- `crates/sinew-anthropic/src/client.rs` : Ajout d'un timer HTTP round-trip sur le stream Anthropic. +- `crates/sinew-openai/src/client.rs` : Ajout d'un timer HTTP round-trip sur le stream SSE OpenAI. +- `crates/sinew-google/src/client.rs` : Ajout d'un timer HTTP round-trip sur le stream Google Antigravity. +- `crates/sinew-deepseek/src/client.rs` : Ajout d'un timer HTTP round-trip dans `send_json`. +- `crates/sinew-kimi/src/client.rs` : Ajout d'un timer HTTP round-trip dans `send_json` (inclut les retries 401). +- `crates/sinew-openrouter/src/client.rs` : Ajout d'un timer HTTP round-trip sur le stream OpenRouter. +- **Centralisation logs** : Tous les logs sont maintenant dans `%LOCALAPPDATA%/dev/hyrak/sinew/data/logs/sinew.log`. + +## [2026-05-30 03:33:19] +- `src/components/Workspace.tsx` : Ajout d'un timer automatique (toutes les 5 minutes) qui vérifie si l'apprentissage IA est activé et, si oui, déclenche la consolidation IA des erreurs. Premier déclenchement après 30 secondes au démarrage. + +## [2026-05-30 03:20:13] +- `src-tauri/src/rules.rs` : Ajout de la fonction `ai_consolidate_rules()` qui lit les erreurs brutes (`errors_raw.json`) et les règles existantes (`instructions_consolidated.md`), les envoie à un fournisseur IA (DeepSeek par défaut) pour analyse, dédoublonnage et fusion intelligente des règles similaires, puis écrit le fichier optimisé. +- `src-tauri/src/lib.rs` : Ajout de la commande Tauri `trigger_ai_rule_consolidation` pour déclencher manuellement l'analyse IA depuis l'interface. +- `src/lib/ipc.ts` : Ajout de la méthode `triggerAiRuleConsolidation(providerId)` au bridge IPC. +- `src/components/SettingsPane.tsx` : Ajout d'une carte "Apprentissage Automatique IA" dans la section Diagnostics, avec bouton ON/OFF, sélecteur de fournisseur IA, bouton d'analyse manuelle, et affichage du statut. Cette IA remplace le script de consolidation simple pour fusionner les règles redondantes. + +## [2026-05-30 03:22:45] +- `src/components/SettingsPane.tsx` : Ajout d'un bouton "Refresh" global dans l'en-tête de la section MCP pour reconnecter et rafraîchir tous les serveurs MCP en un clic. + +## [2026-05-30 03:21:11] +- `.sinew/skills/browser/` & `.sinew/skills/computer_use/` : Déplacement des compétences `browser` et `computer_use` du workspace vers le dossier global utilisateur `~/.agents/skills/`, afin qu'elles soient disponibles pour tous les workspaces et non uniquement pour celui de Sinew. + +## [2026-05-30 03:13:06] +- `src-tauri/src/workspace.rs` : Création de la commande `list_ssh_hosts` pour extraire automatiquement les serveurs/alias configurés dans le fichier `~/.ssh/config` de l'utilisateur. +- `src-tauri/src/lib.rs` : Enregistrement de la commande `list_ssh_hosts` dans le gestionnaire Tauri. +- `src/lib/ipc.ts` : Exposition de la méthode API `listSshHosts`. +- `src/components/Welcome.tsx` : Intégration des boutons de connexion rapide ("Quick Connect") basés sur la liste des serveurs configurés pour une connexion instantanée en un clic. + +## [2026-05-30 03:06:42] +- `src-tauri/src/turns.rs` : Correction d'une erreur d'emprunt de valeur déplacée (borrow of moved value) en clonant les paramètres de configuration dans la fonction d'orchestration des turns. + +## [2026-05-30 03:03:29] +- `crates/sinew-app/src/lib.rs` : Re-exportation de la structure `TurnOutput` pour la rendre accessible par l'application Tauri. +- `crates/sinew-app/src/agent/events.rs` : Dérivation du trait `Deserialize` pour la structure `AgentEvent`, permettant au client de désérialiser les évènements de l'agent. +- `src-tauri/src/turns.rs` : Implémentation du pont Named Pipe IPC client (`run_turn_via_daemon`) qui envoie la commande `StartTurn` au démon Windows, écoute les réponses en temps réel, redirige les évènements vers le moteur principal de l'UI, et démarre automatiquement le binaire detached (`spawn_daemon`) en cas d'absence. + + +## [2026-05-30 03:06:42] +- `src-tauri/src/turns.rs` : Utilisation explicite des types ré-exportés par `sinew_app` dans le proxy du démon de fond (AgentEvent, TurnOutput, McpSettings, etc.) pour résoudre les conflits de types et d'importations. +- `src-tauri/src/workspace.rs` : Ajout de l'installation automatique et silencieuse en tâche de fond de WinFsp et SSHFS-Win via Winget s'ils sont absents lors de la connexion. +- `sinew-chrome-bridge/native-host-wrapper/src/main.rs` : Correction d'une erreur de syntaxe (accolades fermantes superflues) empêchant la compilation du binaire natif du pont Chrome. + +## [2026-05-30 03:04:09] +- `src-tauri/src/lib.rs` : Restauration des imports accidentellement supprimés par la session précédente (`DeleteFileTool`, `EditFileTool`, `GlobTool`, `GoalWorkflowState`, `GrepTool`), résolvant les erreurs de compilation du projet principal. +- `sinew-chrome-bridge/native-host-wrapper/Cargo.toml` : Ajout de la dépendance `base64` pour permettre la compilation du binaire natif du pont Chrome. + +## [2026-05-30 03:06:42] +- `crates/sinew-agent-daemon/src/main.rs` : Remplacement du chargement incorrect de `all_auth_files` par le constructeur standard `from_default_sources` pour le fournisseur Google, résolvant les erreurs de compilation du daemon. + +## [2026-05-30 03:04:09] +- `sinew-chrome-bridge/native-host-wrapper/Cargo.toml` : Ajout de la dépendance `base64` pour permettre la compilation du binaire natif du pont Chrome. + +## [2026-05-30 03:06:42] +- `src-tauri/src/workspace.rs` : Ajout de l'installation automatique et silencieuse en tâche de fond de WinFsp et SSHFS-Win via Winget s'ils sont absents lors de la connexion. +- `sinew-chrome-bridge/native-host-wrapper/src/main.rs` : Correction d'une erreur de syntaxe (accolades fermantes superflues) empêchant la compilation du binaire natif du pont Chrome. + +## [2026-05-30 03:00:17] +- `AGENTS.md` : Mise à jour de la carte des fichiers (code map) avec `computer_use.rs` et la nouvelle Skill. +- `.sinew/skills/computer_use/SKILL.md` : Création de la compétence (Skill) documentant le pilotage Windows pour l'agent. +- `sinew-chrome-bridge/native-host-wrapper/src/main.rs` : Exposition de la commande MCP `computer_use` et implémentation Windows native correspondante. +- `sinew-chrome-bridge/native-host-wrapper/Cargo.toml` : Ajout de la dépendance `image` pour le wrapper MCP. +- `crates/sinew-app/src/subagent.rs`, `crates/sinew-app/src/team.rs`, `crates/sinew-app/src/team/agent_turns.rs` : Instanciation de l'outil `ComputerUseTool` pour les sous-agents et les agents d'équipe. +- `src-tauri/src/lib.rs` : Import de `ComputerUseTool` dans le binaire principal de Tauri. +- `src-tauri/src/turns.rs`, `src-tauri/src/swarm.rs` : Instanciation de l'outil `ComputerUseTool` dans les contextes de turn. +- `crates/sinew-app/src/agent/turn.rs` : Intégration de l'appel et des descripteurs de `ComputerUseTool` dans le flux principal de discussion de l'agent. +- `crates/sinew-app/src/agent/tool_dispatch.rs` : Routage dynamique de la commande `computer_use` vers la simulation système correspondante. +- `crates/sinew-app/src/agent/context.rs` : Intégration de `ComputerUseTool` dans le contexte de discussion de l'agent. +- `crates/sinew-app/src/lib.rs` : Exportation du nouvel outil `ComputerUseTool`. +- `crates/sinew-app/src/tool_names.rs` : Définition de la constante d'outil `computer_use` et prise en charge de sa résolution canonique. +- `crates/sinew-app/Cargo.toml` : Ajout de la dépendance `image` pour compresser les captures d'écran du Computer Use. +- `crates/sinew-app/src/computer_use.rs` : Création du module d'automatisation et de pilotage d'ordinateur (Computer Use) natif pour Windows (GDI screenshots, simulation clavier/souris). + + + + + + + + + + +## [2026-05-30 03:01:48] +- `CHANGELOG.md` : Enregistrement de la suppression des fichiers temporaires et rapports d'analyse obsolètes. +- `AGENTS.md` : Mise à jour de la carte des fichiers (code map) suite au retrait des fichiers inutiles du projet. +- `afaire.md`, `AMELIORATION_SSH.md`, `COMPARAISON_ARCHITECTURE.md`, `Rapport_Analyse_Composer_2.5.md`, `RAPPORT_ANTIGRAVITY.md`, `Rapport_Codex_Analyse.md`, `RAPPORT_DAEMON_PERSISTANT.md`, `RAPPORT_DECOMPILE_CURSOR.md`, `Rapport_SSH_Analyse.md`, `untitled.txt` : Suppression des fichiers de rapports temporaires et documents d'analyse obsolètes pour nettoyer le projet. +- `sinew-chrome-bridge/bridge.log`, `sinew-chrome-bridge/bridge_err.log` : Nettoyage des journaux de logs locaux inutiles. + + +## [2026-05-30 03:02:04] +- `crates/sinew-agent-daemon/src/protocol.rs` : Création de la structure du protocole d'échange JSON IPC (Requêtes de turn, d'annulation, de statut et Réponses d'événements et d'erreurs). +- `crates/sinew-agent-daemon/src/main.rs` : Implémentation du serveur d'écoute asynchrone multithread gérant les connexions entrantes sur le Named Pipe et le traitement des messages JSON-RPC de limités par des retours à la ligne (`\n`). + + +## [2026-05-30 02:57:00] +- `src-tauri/src/workspace.rs` : Création de la commande `mount_ssh_workspace` pour automatiser la détection de lettre libre, le lancement d'SSHFS-Win et l'ouverture automatique du lecteur. +- `src-tauri/src/lib.rs` : Enregistrement du gestionnaire Tauri `mount_ssh_workspace`. +- `src/lib/ipc.ts` : Exposition de la méthode API `mountSshWorkspace`. +- `src/components/Welcome.tsx` : Intégration d'un formulaire et bouton de connexion SSH directe dans l'interface d'accueil (Switch) permettant de connecter n'importe quelle VM à la volée. + +## [2026-05-30 03:00:40] +- `Cargo.toml` : Ajout du sous-projet `crates/sinew-agent-daemon` à la liste des membres du workspace Cargo. +- `crates/sinew-agent-daemon/Cargo.toml` : Création du fichier de configuration Cargo avec ses dépendances (tokio, anyhow, serde, etc.). +- `crates/sinew-agent-daemon/src/main.rs` : Implémentation du squelette du démon de fond persistant Windows (écriture de PID, configuration de serveur Named Pipe). + + +## [2026-05-30 02:57:59] +- `RAPPORT_DAEMON_PERSISTANT.md` : Création du rapport de conception SOTA détaillant le découplage du moteur de discussion en démon d'arrière-plan Windows persistant (Named Pipes, cycle de vie detached, persistance SQLite, stream de reconnexion). + + +## [2026-05-30 02:52:05] +- `sinew-chrome-bridge/mcp_server.js`, `sinew-chrome-bridge/server.js` : Suppression définitive des anciens serveurs Node.js obsolètes après la réécriture totale du pont Chrome en Rust. +- `sinew-chrome-bridge/register.ps1` : Nettoyage et suppression complète des dépendances et lanceurs Node.js (`ws`, `npm install`, fichiers `.bat`) pour un déploiement 100% natif. + +## [2026-05-30 02:54:37] +- `src/components/Welcome.tsx` : Retrait du bouton d'accès SSH/Sandbox de la page d'accueil pour respecter la préférence de l'utilisateur de travailler exclusivement dans un dossier projet monté. + +## [2026-05-30 02:51:02] +- `Cargo.toml` : Ajout de la dépendance `ignore` au niveau de l'espace de travail. +- `crates/sinew-index/Cargo.toml` : Ajout de la dépendance `ignore`. +- `crates/sinew-index/src/indexer.rs` : Intégration de la gestion dynamique des fichiers `.gitignore`, `.cursorignore` et `.sinewignore` dans l'indexeur de base de code. +- `crates/sinew-app/src/workspace.rs` : Ajout de `.sinew` dans la liste des répertoires exclus de l'exploration de l'espace de travail, masquant ainsi `.sinew/worktrees`. +- `sinew-chrome-bridge/sinew_cursor.js` : Implémentation du système d'étiquetage d'interface (injection visuelle des badges `@ref1`, `@ref2` etc.) et résolution automatique des sélecteurs de référence par l'assistant. +- `crates/sinew-app/src/agent/turn.rs` : Ajout d'une boucle d'auto-correction (Forced Reflection system reminder) en cas de tours d'outils répétés pour éviter les boucles infinies de l'IA. + + +- `sinew-chrome-bridge/native-host-wrapper/Cargo.toml` : Ajout de la dépendance chrono pour l'analyse de performance. +- `sinew-chrome-bridge/native-host-wrapper/src/main.rs` : Ajout des outils de diagnostic et d'émulation Chrome restants (emulate_experience, lighthouse_audit et analyze_memory_leaks) en Rust natif pour atteindre 100% de parité fonctionnelle et supprimer la dépendance à Node.js. + +## [2026-05-30 02:48:23] +- `src-tauri/src/cli.rs` : Enregistrement du serveur MCP natif Rust (native-host-wrapper.exe) s'il existe, avec repli automatique sur Node.js (mcp_server.js). +- `crates/sinew-openai/src/stream.rs` : Correction d'avertissement clippy sur un bloc match pliable. +- `crates/sinew-app/src/edit.rs` : Correction d'avertissements clippy sur l'indexation de boucles et les tris personnalisés. +- `crates/sinew-app/src/agent/cancel.rs` : Correction d'avertissement clippy sur le retour d'un type d'erreur unitaire. +- `sinew-chrome-bridge/native-host-wrapper/src/main.rs` : Suppression des avertissements clippy de conversions redondantes dans l'affichage JSON. +- `sinew-chrome-bridge/native-host-wrapper.exe` : Recompilation en mode release sans avertissements clippy. + +## [2026-05-30 02:45:07] +- `AMELIORATION_SSH.md` : Création du plan d'action d'amélioration SSH surpassant le SOTA (filtrage des clés, persistance des connexions en tâche de fond et découplage des configurations). + +## [2026-05-30 02:44:34] +- `Cargo.toml` : Ajout de la dépendance chrono dans le workspace pour la consolidation des règles en Rust. +- `src-tauri/Cargo.toml` : Ajout des dépendances regex, chrono et futures. +- `src-tauri/src/rules.rs` : Création de l'implémentation native en Rust de la consolidation des règles d'apprentissage. +- `src-tauri/src/cli.rs` : Création du CLI natif en Rust pour synchroniser le projet et enregistrer les extensions MCP sans dépendance Python (et nettoyage des imports inutilisés), ajout de l'outil de diagnostic de connexion (--probe) avec gestion des événements de flux. +- `src-tauri/src/main.rs` : Interception des paramètres en ligne de commande pour le CLI de synchronisation et de configuration. +- `src-tauri/src/lib.rs` : Exposition des fonctions internes de base de données et de copie de fichiers pour le CLI. +- `consolidate_rules.py`, `sync_now.py`, `sinew-chrome-bridge/add_to_sinew.py`, `scripts/probe_*.py` : Suppression de tous les scripts Python obsolètes suite à leur réécriture native en Rust. +- `sinew-chrome-bridge/register.ps1` : Utilisation de la commande native Rust `Sinew.exe --register-chrome` au lieu du script Python. +- `crates/sinew-cursor`, `crates/sinew-app` : Application de corrections automatiques Clippy et résolution manuelle de warnings de syntaxe. + +## [2026-05-30 02:44:22] +- `src/components/Welcome.tsx` : Ajout d'un bouton d'accès direct SSH/Sandbox sur la page d'accueil (Switch) pour utiliser le serveur MCP SSH. + +## [2026-05-30 02:43:27] +- `COMPARAISON_ARCHITECTURE.md` : Création du document d'analyse comparative entre l'architecture de Cursor et les fonctionnalités actuelles de Sinew, évaluant le niveau d'opportunité d'intégration (Shadow Workspace, Indexation/Ignore, MCP Navigateur, Boucle d'agent, Commits). + + +## [2026-05-30 02:36:45] +- `RAPPORT_DECOMPILE_CURSOR.md` : Mise à jour et enrichissement en profondeur du rapport d'analyse de l'architecture de Cursor (gRPC, sockets locaux, indexation Merkle native, daemon autonome d'agent, plomberie Git temporaire, automatisation de navigateur par WebView injectée et réduction de contexte). + + +## [2026-05-30 02:33:05] +- `sinew-chrome-bridge/native-host-wrapper/Cargo.toml` : Ajout des dépendances tokio, tokio-tungstenite, serde, serde_json, anyhow, futures-util, directories, uuid et reqwest pour réécrire le pont Chrome natif en Rust. +- `sinew-chrome-bridge/native-host-wrapper/src/main.rs` : Réécriture complète du pont Chrome et du serveur MCP en Rust (SOTA zero-install) permettant de supprimer la dépendance à Node.js. +- `sinew-chrome-bridge/add_to_sinew.py` : Enregistrement du nouveau binaire natif Rust MCP dans la base de données SQLite de Sinew à la place de l'ancien script Node.js. + + +## [2026-05-30 02:39:37] +- `mcp_settings` : Intégration du serveur MCP SOTA `slepp-ssh-mcp` dans la base SQLite locale pour donner aux agents un accès SSH complet aux machines distantes. + +## [2026-05-30 02:38:31] +- `src/components/chat/ToolCard.tsx` : Ajout du bouton "Auto-réparer" sur les cartes de commande bash en cas d'erreur. +- `src/components/chat/ChatPane.tsx` : Implémentation du callback de réparation `handleFixCommand` et passage du prop à `ToolCard`. +- `src/styles.css` : Ajout des styles pour le bouton d'auto-réparation `.tool-card__fix-action`. + +## [2026-05-30 02:36:45] +- `search_decompiled.py` : Créé puis supprimé après avoir servi à analyser en profondeur les extensions décompilées de Cursor. +- `RAPPORT_DECOMPILE_CURSOR.md` : Rapport complet d'analyse de l'architecture de Cursor (Shadow Workspace, Retrieval, MCP Navigateur, Boucle d'agent, Commits) enrichi avec les détails bas niveau (Délégation CDP, sockets gRPC, synchronisations Merkle, simhash, correctifs OAuth MCP SDK) rédigé en français simple. + +## [2026-05-30 02:37:33] +- `Rapport_Codex_Analyse.md` : Détail complet du fonctionnement SOTA du bouton "Auto-réparer" (boucle d'auto-correction via sous-agents et vérification de build). + +## [2026-05-30 02:35:40] +- `Rapport_Codex_Analyse.md` : Ajout des sections d'analyse sur le pilotage d'ordinateur (Computer Use) et la télécommande par téléphone (Remote Control). +- Confirmé la présence native du rendu de diagrammes Mermaid dans Sinew. + +## [2026-05-30 02:26:13] +- `crates/sinew-cursor/src/agent/run_h2.rs` : Redirection du point de contact de l'agent NAL vers le serveur de production express de Cursor (`agent.api5.cursor.sh` au lieu de `api2.cursor.sh`). +- `scripts/agent-bridge/run-stream.mjs` : Alignement de l'endpoint du pont Node pour utiliser le serveur express `agent.api5.cursor.sh`. +- `scripts/agent-bridge/h2-bridge.mjs` : Alignement de l'endpoint par défaut du pont HTTP/2 Node pour utiliser `agent.api5.cursor.sh`. + +## [2026-05-30 02:26:13] +- `Rapport_Analyse_Composer_2.5.md` : Ajout du rapport d'analyse synthétique sur le support de Composer 2.5 standalone, les clés de sécurité et la migration vers la ligne express agent.api5. + +## [2026-05-30 02:31:00] +- `Rapport_Codex_Analyse.md` : Enrichissement du rapport avec les analyses d'interface utilisateur et de fonctionnalités frontend (Mini-apps MCP, planificateur d'automatisations RRule, auto-réparation des espaces temporaires Git et régulateur de débit d'affichage). + +## [2026-05-30 02:26:01] +- `Rapport_Codex_Analyse.md` : Ajout des analyses détaillées sur la sécurité de Codex (relocalisation de binaires hors WindowsApps, filtres réseau WFP persistants pour Windows Sandbox et jetons AppContainer/Capability SIDs pour le Command Runner). + +## [2026-05-30 02:29:00] +- `Rapport_Codex_Analyse.md` : Création du rapport de synthèse de Codex analysant son architecture, son intégration avec le clavier Work Louder, son isolation d'exécutables (staging) et ses politiques de bac à sable (sandbox). + + +## [2026-05-30 02:26:42] +- `src/components/SettingsPane.tsx` : Ajout d'une option de configuration pour agrandir la taille de la boîte de saisie (boîte de chat) en mode normal ou agrandi. +- `src/App.tsx` : Initialisation au démarrage de l'attribut `data-large-chat-box` sur le document HTML à partir des paramètres persistés de l'utilisateur. +- `src/styles.css` : Utilisation de variables CSS pour la hauteur minimale/maximale du composer de messages et doublement automatique de ces dimensions en mode agrandi. + + +## [2026-05-30 02:23:29] +- `RAPPORT_ANTIGRAVITY.md` : Création et simplification complète du rapport d'analyse pour supprimer le jargon technique et utiliser des métaphores faciles à comprendre (Téléviseur et Décodeur). + +## [2026-05-30 02:20:52] +- `Rapport_SSH_Analyse.md` : Création du rapport détaillé d'analyse de l'implémentation SSH dans Antigravity, Codexx et Cursor en utilisant les perspectives des 4 sous-agents. + +## [2026-05-30 02:18:39] +- `crates/sinew-app/src/write.rs` : Résolution d'un bug critique bloquant l'écriture de nouveaux fichiers sur Windows en harmonisant la comparaison insensible à la casse et la suppression des préfixes UNC (`\\?\`). +- `crates/sinew-app/src/read.rs` : Harmonisation de la fonction `relative_from_root` pour nettoyer correctement les préfixes UNC sous Windows et éviter les fausses alertes d'accès hors espace de travail. + +## [2026-05-30 02:16:06] +- `consolidate_rules.py` : Correction d'un bug cosmétique de double point final lors de la génération de règles d'auto-apprentissage si la description d'erreur contenait déjà un point. +- `test_consolidation.py` : Ajout puis suppression du script temporaire de test de validation du système d'auto-apprentissage des erreurs. + +## [2026-05-30 02:13:43] +- `C:\Users\julie\.agents\skills` : Restauration de la compétence de recherche globale `find-skills` pour permettre la découverte et l'installation de compétences à la demande. + +## [2026-05-30 02:15:11] +- `crates/sinew-cursor/src/identity.rs` : Cache de la détection du fuseau horaire via OnceLock pour éviter le spawn répétitif de PowerShell sur chaque requête. +- `crates/sinew-index/src/store.rs` : Optimisation majeure des performances SQLite. Mise en cache du profil de puissance machine (OnceLock), détection SSD/NVMe Windows améliorée via le PNPDeviceID et Caption, augmentation dynamique de la taille du cache SQLite (limité à ~3.1% de la mémoire vive pour rester bien en dessous du plafond de 40% demandé par l'utilisateur, max 1 Go) et de la taille de mmap (max 4 Go), et activation de PRAGMA threads multi-cœurs. + +## [2026-05-30 02:12:16] +- `crates/sinew-index/src/process.rs` : Limitation de la mémoire des sous-processus de l'indexeur (recherche codebase et watch) à 12 Go maximum sur Windows via les API de Job Object, afin d'éviter tout blocage ou fuite de mémoire excessive. + +## [2026-05-30 02:10:30] +- `C:\Users\julie\.agents\skills` : Suppression des dossiers de compétences globales pré-installés superflus pour ne conserver que la compétence Chrome locale (`browser`) de l'espace de travail. + +## [2026-05-30 02:08:33] +- `src/components/SettingsPane.tsx` : Suppression du bouton de synchronisation manuelle ("Synchroniser maintenant") et de la section de détection/liaison des conversations d'autres projets ("Détection de conversations d'autres projets / PC") pour simplifier l'interface utilisateur. + + +## 🚀 Présentation des Fonctionnalités Majeures (Fork Premium julienpiron.fr) + +Cette version a été optimisée en profondeur pour offrir une expérience utilisateur haut de gamme (SOTA), une autonomie maximale en arrière-plan, et des intégrations d'intelligence artificielle inégalées. + +### 🎨 Interface, Confort & Ergonomie (Premium UI) +* **Animation de démarrage premium :** Une animation de boot moderne, fluide et élégante à l'ouverture de l'application. +* **3 niveaux de réflexion :** Choix entre Détaillé, Compact ou Très compact pour configurer précisément la verbosité de l'IA et le masquage des détails techniques dans le chat. +* **Question collante (Sticky Question) :** La question en cours de traitement reste épinglée en haut de l'écran pendant que vous faites défiler le fil de discussion. +* **Menu clic droit interactif sur les onglets de l'éditeur :** Clic droit (ou `F10`) sur les onglets pour fermer l'onglet (raccourci `Ctrl+F4`), les autres, à sa droite ou tous, copier le chemin (absolu ou relatif) et révéler dans le Finder/Explorateur. +* **Menu clic droit d'exécution :** Clic droit sur les fichiers dans le chat et l'arbre des fichiers pour les ouvrir, les révéler ou les exécuter directement. +* **Polices dynamiques ajustables :** Boutons tactiles réactifs (`+` et `-`) dans les options pour ajuster instantanément à chaud la taille du texte de l'éditeur Monaco et du chat. +* **Version française complète :** L'interface entière et toutes les actions de l'application s'adaptent automatiquement en français ou en anglais. +* **Sélection et copie libre :** Déblocage de la sélection et copie de texte directement dans le fil de discussion du chat. +* **Démarcation visuelle :** Ligne de séparation verticale élégante à gauche du panneau de configuration des paramètres. +* **Découpage du bundle Vite (-80% de taille) :** Monaco Editor et xterm.js sont isolés dans des sous-lots séparés pour un chargement instantanél'interface utilisateur. +* **Visualisation du plan d'action (Planning Board) :** Intégration d'un bloc dynamique interactif (`PlanningNextMoveBlock`) montrant en temps réel les prochaines étapes planifiées by le Swarm d'agents. +* **Aperçu d'image immersif (Lightbox) :** Visionneuse d'images de discussion immersive avec zoom à la molette de souris, déplacement panoramique, rotation, téléchargement et fermeture par clic extérieur. + +### 💾 Autonomie, Sauvegarde & Robustesse Système +* **Sauvegarde automatique (Auto-Save SOTA) :** Enregistrement automatique et transparent en arrière-plan 1,5 seconde après l'arrêt de la frappe. Activable ou désactivable d'un clic dans vos options. +* **Mode Sandbox :** Lancement de l'application en un clic sans aucun projet ouvert pour tester l'IA ou utiliser les outils MCP de manière isolée. +* **Synchro OneDrive & SQLite automatique :** Synchronisation transparente de vos conversations, configurations de projets, jetons de connexion/clés d'authentification (`*-auth.json`, `*-device.json`, `*-stream-state.json`), fichiers d'apprentissage globaux (`errors_raw.json` et `instructions_consolidated.md`), et bases de données SQLite entre vos différents ordinateurs. +* **Zéro popup console Windows :** Lancement asynchrone et silencieux de tous les outils, serveurs MCP, commandes Git et diagnostics SOTA en arrière-plan sans aucune ouverture de fenêtres d'invite de commandes. +* **Préfixe PC réel automatique :** Identification automatique du nom de la machine physique pour typer et sécuriser les configurations de conversation multi-PC. +* **Diagnostic Windows OAuth résilient :** Capture robuste de l'erreur réseau typique sous Windows (code 10013) et conseils clairs pour débloquer la connexion (WinNAT/HNS). +* **Diagnostic SOTA :** Vérification en un clic de l'état de santé, du PATH et des versions de tous vos outils de développement (Git, Python, Node, Cargo, etc.). +* **Écran de mises à jour sécurisé (`UpdaterLockScreen`) :** Verrouillage de l'interface pendant l'application des correctifs système pour éviter tout conflit de fichiers ou corruption de base de données. +* **Script de compilation OneDrive (`compil.ps1`) :** Automatisation de la génération de l'application et copie immédiate sur OneDrive pour un déploiement instantané sur vos PC. +* **Active Turn Registry :** Moteur intelligent Rust qui suit les turns de l'agent en cours et assure une reprise instantanée du streaming. +* **Fiche de transmission structurée (Compaction d'IA) :** Compactage automatique du contexte lors du changement de fournisseur d'IA dans une fiche structurée reprenant le statut des fichiers modifiés, le relais des tâches et les diagnostics du linter. +* **Mode plein gaz adaptatif (`crates/sinew-index/src/store.rs`) :** Optimisation dynamique des performances de l'indexeur augmentant le cache et la lecture en mémoire lorsque la machine dispose d'un stockage SSD/NVMe. +* **Indexation locale parallèle SOTA :** Préparation et analyse des fichiers en parallèle répartie sur tous les coeurs de CPU disponibles via Rayon, avec détection immédiate et saut des fichiers inchangés grâce à leurs empreintes de taille et date. +* **Identification de projet universelle :** Association automatique des conversations au dépôt Git distant (remote origin URL) ou via un fichier d'identifiant unique `.sinew/project_id.txt` pour lier instantanément vos conversations d'un PC à un autre sans aucune action manuelle, avec détection, liaison et rafraîchissement dynamique des conversations provenant de PC alternatifs depuis les paramètres. +* **Gestion des mises à jour configurables :** Option à 3 choix (Bloquant, Notification, Désactivé) pour décider précisément du niveau de vérification des nouvelles versions de Sinew et empêcher l'écrasement de vos modifications. +* **Script de contrôle qualité unifié (`scripts/check.ps1`) :** Commande unique `npm run check` exécutant le build frontend, `cargo check`, les tests, `clippy` et les audits de dépendances en une seule opération. +* **Système d'apprentissage global transparent :** Chargement et injection automatique de la base d'instructions centralisées de l'utilisateur (`%LOCALAPPDATA%\Sinew\instructions_consolidated.md`) dans le prompt système de tous les agents pour l'ensemble des projets ouverts sur la machine. +* **Consolidation automatique de la mémoire :** Mécanisme au démarrage transformant automatiquement les erreurs répétées enregistrées dans `errors_raw.json` en règles d'apprentissage globales permanentes dans `instructions_consolidated.md` avec nettoyage du compteur d'erreurs. +* **Bouton de synchronisation forcée :** Ajout d'un bouton de synchronisation immédiate à la demande dans les paramètres pour déclencher manuellement la synchronisation du dossier OneDrive local. + +### 🤖 Modèles d'IA, Comptes & Furtivité (AI Engine) +* **Gestion Multi-comptes OpenAI & Google Gemini :** Connexion simultanée de plusieurs profils OpenAI et Google Gemini secondaires avec bascule instantanée entre vos différentes clés, comptes et abonnements. +* **Quotas en temps réel :** Visualisation dynamique de votre consommation (crédits / balance restante) sous forme de barres de progression colorées adaptatives dans les options, et pastille live dans le chat. +* **Routage & Résilience Google Antigravity SOTA :** Réparation, de-surcharge réseau (erreur 503), routeurs de secours et transition transparente entre modèles avec résolution dynamique des identifiants d'appels d'outils (tool_call_id). +* **Optimisation de vitesse Gemini :** Streaming et requêtes ultra-rapides pour les modèles Gemini. +* **Incorporation de Claude Opus 4.8 & 4.6 :** Intégration complète de Claude Opus 4.8 (contexte 1M natif) et Claude Opus 4.6 via les abonnements professionnels Google. +* **Système Pending/Steering pour Influencer :** Un vrai système d'interception et de guidage pour orienter, corriger ou ajouter des instructions en temps réel sans blocage du flux de l'IA. +* **Indexation sémantique locale vectorielle :** Indexation et recherche vectorielle haute-performance effectuée localement sur votre machine avec commutateur d'activation directe (BETA) dans le panneau d'options. +* **Intégration de DeepSeek R1 & V3 :** Support complet de **DeepSeek V3** et de **DeepSeek R1** avec capture et rendu en temps réel du bloc de réflexion (*reasoning*) grâce à l'extraction du champ `reasoning_content` dans le chat. +* **Pont Cursor Composer 2.5 (agent.v1) :** Moteur haute-performance autonome sur connexions HTTP/2 persistantes gérant toutes les modifications chirurgicales de fichiers, avec installation automatique et invisible en arrière-plan, et masquage du sélecteur d'intelligence inutile. +* **Sécurité & Furtivité WebSocket :** Spoofing d'empreinte réseau avancé pour éliminer tout risque de détection ou de blocage sur les flux de ChatGPT. +* **WebSocket OpenAI :** Transport temps-réel haute performance basé sur WebSocket pour des réponses fluides et à latence minimale avec OpenAI. + +### 🔌 Extensions & Ponts locaux (MCP & Bridge) +* **Extension Chrome nouvelle génération :** Pilotage d'actions de navigation ultra-stables en natif Rust avec mouvements et clics à vitesse humaine (mouvements Beziers, physique fluide) et mode silencieux. +* **Réparation Chrome en un clic :** Bouton bleu de configuration automatique si le pont Chrome ne répond pas sur un nouveau PC. +* **Empaquetage des ressources Tauri :** Le pont local et l'extension Chrome sont intégrés directement au sein de l'installateur compilé (MSI/EXE). +* **Outils Rust & ripgrep Sidecar :** Intégration de Ripgrep en binaire natif sidecar et de nouveaux outils (`list_dir`, `delete_file`) pour accélérer la recherche et la gestion des fichiers par 10x. +* **Diagnostics Monaco en temps réel :** Remontée automatique des lints et erreurs de compilation de l'éditeur de code à l'IA en temps réel. +* **Logs ultra-compacts :** Nettoyage automatique du contexte de discussion pour éliminer le bruit et optimiser la consommation de jetons. +* **Laboratoire réseau MITM :** Outils de débogage et d'ingénierie inverse intégrés pour inspecter le trafic chiffrés des outils IA. +* **Moteur de remplacement intelligent (Search/Replace) :** Système d'auto-correction à 8 couches (Unicode, indentations, etc.) garantissant que les modifications de l'IA s'insèrent correctement dans vos fichiers même en cas de légères erreurs d'espaces. +* **Outils MCP de diagnostics Chrome avancés :** Intégration de nouveaux outils d'audit (`emulate_experience`, `lighthouse_audit`, `analyze_memory_leaks`) basés sur l'API CDP pour tester les performances, diagnostics Lighthouse et fuites mémoire en local. + +--- + diff --git a/Cargo.lock b/Cargo.lock index e2cb00a0..a6558e8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,19 +8,28 @@ version = "0.1.27" dependencies = [ "anyhow", "base64 0.22.1", + "chrono", + "directories", + "futures", "notify", "objc2", "objc2-app-kit", "objc2-foundation", "portable-pty", + "regex", "reqwest 0.12.28", + "rusqlite", "serde", "serde_json", "sinew-anthropic", "sinew-app", "sinew-core", + "sinew-cursor", + "sinew-deepseek", "sinew-google", + "sinew-index", "sinew-kimi", + "sinew-ollama", "sinew-openai", "sinew-openrouter", "tauri", @@ -31,6 +40,7 @@ dependencies = [ "tracing", "tracing-subscriber", "url", + "uuid", ] [[package]] @@ -46,7 +56,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -60,6 +72,24 @@ dependencies = [ "memchr", ] +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + [[package]] name = "alloc-no-stdlib" version = "2.0.4" @@ -99,6 +129,54 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -145,6 +223,77 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.18", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom 8.0.0", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.21.7" @@ -172,6 +321,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -187,6 +342,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -226,6 +390,22 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + [[package]] name = "bumpalo" version = "3.20.2" @@ -244,6 +424,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -320,6 +506,15 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.60" @@ -327,6 +522,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -393,11 +590,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.1", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "combine" version = "4.6.7" @@ -408,6 +622,34 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "serde", + "static_assertions", +] + +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -424,6 +666,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -447,9 +699,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.11.1", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -460,7 +712,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.11.1", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -500,12 +752,37 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -566,14 +843,38 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", ] [[package]] @@ -589,17 +890,37 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core", + "darling_core 0.23.0", "quote", "syn 2.0.117", ] +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" +dependencies = [ + "serde", +] + [[package]] name = "data-encoding" version = "2.11.0" @@ -627,6 +948,37 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -816,6 +1168,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "embed-resource" version = "3.0.8" @@ -836,6 +1194,41 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -863,6 +1256,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "esaxx-rs" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" + [[package]] name = "eventsource-stream" version = "0.2.3" @@ -870,10 +1269,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" dependencies = [ "futures-core", - "nom", + "nom 7.1.3", "pin-project-lite", ] +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -886,12 +1300,35 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fastembed" +version = "4.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04c269a76bfc6cea69553b7d040acb16c793119cebd97c756d21e08d0f075ff8" +dependencies = [ + "anyhow", + "hf-hub", + "image", + "ndarray", + "ort", + "ort-sys", + "rayon", + "serde_json", + "tokenizers", +] + [[package]] name = "fastrand" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -967,6 +1404,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -974,7 +1420,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -988,6 +1434,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1003,6 +1455,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -1280,6 +1738,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gio" version = "0.18.4" @@ -1365,6 +1833,19 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -1428,6 +1909,36 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1485,6 +1996,27 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hf-hub" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "629d8f3bbeda9d148036d6b0de0a3ab947abd08ce90626327fc3547a49d59d97" +dependencies = [ + "dirs", + "http", + "indicatif", + "libc", + "log", + "native-tls", + "rand 0.9.4", + "reqwest 0.12.28", + "serde", + "serde_json", + "thiserror 2.0.18", + "ureq", + "windows-sys 0.60.2", +] + [[package]] name = "html5ever" version = "0.29.1" @@ -1538,6 +2070,7 @@ dependencies = [ "http", "http-body", "pin-project-lite", + "tokio", ] [[package]] @@ -1562,6 +2095,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1581,13 +2115,31 @@ dependencies = [ "http", "hyper", "hyper-util", + "log", "rustls", + "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", "webpki-roots 1.0.7", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1606,9 +2158,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1642,7 +2196,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" dependencies = [ "byteorder", - "png", + "png 0.17.16", ] [[package]] @@ -1760,6 +2314,62 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png 0.18.1", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40fac9d56ed6437b198fddba683305e8e2d651aa42647f00f5ae542e7f5c94a2" + [[package]] name = "indexmap" version = "1.9.3" @@ -1783,6 +2393,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width", + "web-time", +] + [[package]] name = "infer" version = "0.19.0" @@ -1812,6 +2435,17 @@ dependencies = [ "libc", ] +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1828,6 +2462,15 @@ dependencies = [ "serde", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1901,6 +2544,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.95" @@ -1990,6 +2643,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "libappindicator" version = "0.9.0" @@ -2020,6 +2679,16 @@ version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +[[package]] +name = "libfuzzer-sys" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +dependencies = [ + "arbitrary", + "cc", +] + [[package]] name = "libloading" version = "0.7.4" @@ -2080,6 +2749,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -2092,6 +2770,22 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "macro_rules_attribute" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65049d7923698040cd0b1ddcced9b0eb14dd22c5f86ae59c3740eab64a676520" +dependencies = [ + "macro_rules_attribute-proc_macro", + "paste", +] + +[[package]] +name = "macro_rules_attribute-proc_macro" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" + [[package]] name = "markup5ever" version = "0.14.1" @@ -2143,6 +2837,26 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + [[package]] name = "memchr" version = "2.8.0" @@ -2210,24 +2924,106 @@ dependencies = [ ] [[package]] -name = "muda" -version = "0.17.2" +name = "monostate" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67" +dependencies = [ + "monostate-impl", + "serde", + "serde_core", +] + +[[package]] +name = "monostate-impl" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "muda" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c9fec5a4e89860383d778d10563a605838f8f0b2f9303868937e5ff32e86177" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.17.16", + "serde", + "thiserror 2.0.18", + "windows-sys 0.60.2", +] + +[[package]] +name = "native-host-wrapper" +version = "0.1.27" +dependencies = [ + "anyhow", + "base64 0.22.1", + "chrono", + "directories", + "futures-util", + "image", + "reqwest 0.12.28", + "serde", + "serde_json", + "tokio", + "tokio-tungstenite", + "uuid", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndarray" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c9fec5a4e89860383d778d10563a605838f8f0b2f9303868937e5ff32e86177" +checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841" dependencies = [ - "crossbeam-channel", - "dpi", - "gtk", - "keyboard-types", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "once_cell", - "png", - "serde", - "thiserror 2.0.18", - "windows-sys 0.60.2", + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", ] [[package]] @@ -2278,6 +3074,15 @@ dependencies = [ "libc", ] +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -2294,6 +3099,21 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + [[package]] name = "notify" version = "6.1.1" @@ -2322,12 +3142,62 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2359,6 +3229,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + [[package]] name = "objc2" version = "0.6.4" @@ -2501,18 +3377,101 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "onig" +version = "6.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" +dependencies = [ + "bitflags 2.11.1", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "openssl" +version = "0.10.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-sys" +version = "0.9.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ort" +version = "2.0.0-rc.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52afb44b6b0cffa9bf45e4d37e5a4935b0334a51570658e279e9e3e6cf324aa5" +dependencies = [ + "ndarray", + "ort-sys", + "tracing", +] + +[[package]] +name = "ort-sys" +version = "2.0.0-rc.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41d7757331aef2d04b9cb09b45583a59217628beaf91895b7e76187b6e8c088" +dependencies = [ + "flate2", + "pkg-config", + "sha2", + "tar", + "ureq", +] + [[package]] name = "osakit" version = "0.3.1" @@ -2575,6 +3534,18 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2812,6 +3783,34 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "portable-pty" version = "0.9.0" @@ -2941,6 +3940,89 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "prost-reflect" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5edd582b62f5cde844716e66d92565d7faf7ab1445c8cebce6e00fba83ddb2" +dependencies = [ + "once_cell", + "prost", + "prost-types", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost", +] + +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.38.4" @@ -3153,11 +4235,98 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.4", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.18", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + [[package]] name = "raw-window-handle" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-cond" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" +dependencies = [ + "either", + "itertools", + "rayon", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] [[package]] name = "redox_syscall" @@ -3256,16 +4425,21 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", + "mime", + "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -3276,6 +4450,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-rustls", "tokio-util", "tower", @@ -3352,6 +4527,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + [[package]] name = "ring" version = "0.17.14" @@ -3414,6 +4595,8 @@ version = "0.23.38" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" dependencies = [ + "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", @@ -3450,7 +4633,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" dependencies = [ - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "jni", "log", @@ -3477,6 +4660,7 @@ version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -3576,7 +4760,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.11.1", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -3771,7 +4955,7 @@ version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" dependencies = [ - "darling", + "darling 0.23.0", "proc-macro2", "quote", "syn 2.0.117", @@ -3898,12 +5082,43 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + [[package]] name = "similar" version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "sinew-agent-daemon" +version = "0.1.27" +dependencies = [ + "anyhow", + "serde", + "serde_json", + "sinew-anthropic", + "sinew-app", + "sinew-core", + "sinew-cursor", + "sinew-deepseek", + "sinew-google", + "sinew-kimi", + "sinew-openai", + "sinew-openrouter", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + [[package]] name = "sinew-anthropic" version = "0.1.27" @@ -3932,12 +5147,15 @@ version = "0.1.27" dependencies = [ "anyhow", "base64 0.22.1", + "chrono", "directories", "eventsource-stream", "futures-util", + "image", "kuchikiki", "portable-pty", "rand 0.9.4", + "rayon", "regex", "reqwest 0.12.28", "rusqlite", @@ -3946,6 +5164,8 @@ dependencies = [ "sha2", "similar", "sinew-core", + "sinew-google", + "sinew-index", "sinew-openai", "tokio", "tracing", @@ -3964,6 +5184,59 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "sinew-cursor" +version = "0.1.27" +dependencies = [ + "anyhow", + "async-stream", + "async-trait", + "base64 0.22.1", + "bytes", + "directories", + "futures", + "hex", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "prost", + "prost-reflect", + "prost-types", + "rand 0.9.4", + "reqwest 0.12.28", + "rustls", + "serde", + "serde_json", + "sha2", + "sinew-core", + "sinew-index", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "tracing-subscriber", + "url", + "uuid", +] + +[[package]] +name = "sinew-deepseek" +version = "0.1.27" +dependencies = [ + "async-trait", + "bytes", + "directories", + "eventsource-stream", + "futures", + "reqwest 0.12.28", + "serde", + "serde_json", + "sinew-core", + "tokio", + "tracing", +] + [[package]] name = "sinew-google" version = "0.1.27" @@ -3986,6 +5259,25 @@ dependencies = [ "url", ] +[[package]] +name = "sinew-index" +version = "0.1.27" +dependencies = [ + "anyhow", + "directories", + "fastembed", + "ignore", + "notify", + "rayon", + "regex", + "rusqlite", + "serde", + "serde_json", + "sha2", + "tracing", + "walkdir", +] + [[package]] name = "sinew-kimi" version = "0.1.27" @@ -4005,6 +5297,23 @@ dependencies = [ "tracing", ] +[[package]] +name = "sinew-ollama" +version = "0.1.27" +dependencies = [ + "async-trait", + "bytes", + "directories", + "eventsource-stream", + "futures", + "reqwest 0.12.28", + "serde", + "serde_json", + "sinew-core", + "tokio", + "tracing", +] + [[package]] name = "sinew-openai" version = "0.1.27" @@ -4078,6 +5387,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + [[package]] name = "softbuffer" version = "0.4.8" @@ -4126,12 +5446,30 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spm_precompiled" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" +dependencies = [ + "base64 0.13.1", + "nom 7.1.3", + "serde", + "unicode-segmentation", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "string_cache" version = "0.8.9" @@ -4246,6 +5584,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -4267,7 +5626,7 @@ checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20" dependencies = [ "bitflags 2.11.1", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dispatch2", @@ -4410,7 +5769,7 @@ dependencies = [ "ico", "json-patch", "plist", - "png", + "png 0.17.16", "proc-macro2", "quote", "semver", @@ -4715,6 +6074,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.47" @@ -4771,6 +6144,39 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tokenizers" +version = "0.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a620b996116a59e184c2fa2dfd8251ea34a36d0a514758c6f966386bd2e03476" +dependencies = [ + "ahash", + "aho-corasick", + "compact_str", + "dary_heap", + "derive_builder", + "esaxx-rs", + "getrandom 0.3.4", + "itertools", + "log", + "macro_rules_attribute", + "monostate", + "onig", + "paste", + "rand 0.9.4", + "rayon", + "rayon-cond", + "regex", + "regex-syntax", + "serde", + "serde_json", + "spm_precompiled", + "thiserror 2.0.18", + "unicode-normalization-alignments", + "unicode-segmentation", + "unicode_categories", +] + [[package]] name = "tokio" version = "1.52.1" @@ -4799,6 +6205,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -4809,6 +6225,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-tungstenite" version = "0.26.2" @@ -5065,7 +6492,7 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation", "once_cell", - "png", + "png 0.17.16", "serde", "thiserror 2.0.18", "windows-sys 0.60.2", @@ -5155,24 +6582,65 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization-alignments" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de" +dependencies = [ + "smallvec", +] + [[package]] name = "unicode-segmentation" version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + [[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "native-tls", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "socks", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.8" @@ -5223,6 +6691,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -5570,6 +7049,12 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "winapi" version = "0.3.9" @@ -5719,6 +7204,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -6282,6 +7778,12 @@ dependencies = [ "rustix", ] +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + [[package]] name = "yoke" version = "0.8.2" @@ -6402,3 +7904,27 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index a662a3f8..d6cbd037 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,8 +7,13 @@ members = [ "crates/sinew-kimi", "crates/sinew-openai", "crates/sinew-openrouter", + "crates/sinew-deepseek", + "crates/sinew-ollama", + "crates/sinew-cursor", + "crates/sinew-index", "crates/sinew-app", - "src-tauri", + "crates/sinew-agent-daemon", + "src-tauri", "sinew-chrome-bridge/native-host-wrapper", ] [workspace.package] @@ -27,6 +32,10 @@ sinew-google = { path = "crates/sinew-google" } sinew-kimi = { path = "crates/sinew-kimi" } sinew-openai = { path = "crates/sinew-openai" } sinew-openrouter = { path = "crates/sinew-openrouter" } +sinew-deepseek = { path = "crates/sinew-deepseek" } +sinew-ollama = { path = "crates/sinew-ollama" } +sinew-cursor = { path = "crates/sinew-cursor" } +sinew-index = { path = "crates/sinew-index" } sinew-app = { path = "crates/sinew-app" } tokio = { version = "1.40", features = ["full"] } @@ -40,12 +49,15 @@ eventsource-stream = "0.2" serde = { version = "1", features = ["derive"] } serde_json = "1" regex = "1" +chrono = { version = "0.4.44", features = ["serde"] } +rayon = "1.12" -reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "http2"] } rusqlite = { version = "0.32", features = ["bundled"] } directories = "5" uuid = { version = "1", features = ["v4", "fast-rng"] } notify = "6.1" +ignore = "0.4" rand = "0.9" sha2 = "0.10" url = "2.5" @@ -56,8 +68,16 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } similar = "2.6" base64 = "0.22" -tauri = { version = "2.1", features = ["protocol-asset"] } +tauri = { version = "2.1", features = ["protocol-asset", "tray-icon"] } tauri-build = { version = "2.1" } tauri-plugin-dialog = "2.0" tauri-plugin-updater = "2.0" walkdir = "2.5" + +[profile.release] +opt-level = 3 # Optimisation maximale du code +lto = "thin" # LTO "thin" pour compiler 10 fois plus vite tout en optimisant +codegen-units = 16 # Utilise plusieurs processeurs en parallèle pour la génération de code +panic = "abort" # Supprime la surcharge du déroulement de pile +strip = true # Retire les symboles de débogage (gains de 10 à 30 Mo sur le binaire) + diff --git a/README.md b/README.md index 4c26caa7..d06ea7a7 100644 --- a/README.md +++ b/README.md @@ -36,11 +36,77 @@ --- +## 🚀 Présentation des Fonctionnalités Majeures (Fork Premium julienpiron.fr) + +Cette version a été optimisée en profondeur pour offrir une expérience utilisateur haut de gamme (SOTA), une autonomie maximale en arrière-plan, et des intégrations d'intelligence artificielle inégalées. + +### 🎨 Interface, Confort & Ergonomie (Premium UI) +* **Animation de démarrage premium :** Une animation de boot moderne, fluide et élégante à l'ouverture de l'application. +* **3 niveaux de réflexion :** Choix entre Détaillé, Compact ou Très compact pour configurer précisément la verbosité de l'IA et le masquage des détails techniques dans le chat. +* **Question collante (Sticky Question) :** La question en cours de traitement reste épinglée en haut de l'écran pendant que vous faites défiler le fil de discussion. +* **Menu clic droit interactif sur les onglets de l'éditeur :** Clic droit (ou `F10`) sur les onglets pour fermer l'onglet (raccourci `Ctrl+F4`), les autres, à sa droite ou tous, copier le chemin (absolu ou relatif) et révéler dans le Finder/Explorateur. +* **Menu clic droit d'exécution :** Clic droit sur les fichiers dans le chat et l'arbre des fichiers pour les ouvrir, les révéler ou les exécuter directement. +* **Polices dynamiques ajustables :** Boutons tactiles réactifs (`+` et `-`) dans les options pour ajuster instantanément à chaud la taille du texte de l'éditeur Monaco et du chat. +* **Version française complète :** L'interface entière et toutes les actions de l'application s'adaptent automatiquement en français ou en anglais. +* **Sélection et copie libre :** Déblocage de la sélection et copie de texte directement dans le fil de discussion du chat. +* **Démarcation visuelle :** Ligne de séparation verticale élégante à gauche du panneau de configuration des paramètres. +* **Découpage du bundle Vite (-80% de taille) :** Monaco Editor et xterm.js sont isolés dans des sous-lots séparés pour un chargement instantané de l'interface utilisateur. +* **Visualisation du plan d'action (Planning Board) :** Intégration d'un bloc dynamique interactif (`PlanningNextMoveBlock`) montrant en temps réel les prochaines étapes planifiées par le Swarm d'agents. +* **Aperçu d'image immersif (Lightbox) :** Visionneuse d'images de discussion immersive avec zoom à la molette de souris, déplacement panoramique, rotation, téléchargement et fermeture par clic extérieur. + +### 💾 Autonomie, Sauvegarde & Robustesse Système +* **Sauvegarde automatique (Auto-Save SOTA) :** Enregistrement automatique et transparent en arrière-plan 1,5 seconde après l'arrêt de la frappe. Activable ou désactivable d'un clic dans vos options. +* **Mode Sandbox :** Lancement de l'application en un clic sans aucun projet ouvert pour tester l'IA ou utiliser les outils MCP de manière isolée. +* **Synchro OneDrive & SQLite automatique :** Synchronisation transparente de vos conversations, configurations de projets, jetons de connexion/clés d'authentification (`*-auth.json`, `*-device.json`, `*-stream-state.json`), fichiers d'apprentissage globaux (`errors_raw.json` et `instructions_consolidated.md`), et bases de données SQLite entre vos différents ordinateurs. +* **Zéro popup console Windows :** Lancement asynchrone et silencieux de tous les outils, serveurs MCP, commandes Git et diagnostics SOTA en arrière-plan sans aucune ouverture de fenêtres d'invite de commandes. +* **Préfixe PC réel automatique :** Identification automatique du nom de la machine physique pour typer et sécuriser les configurations de conversation multi-PC. +* **Diagnostic Windows OAuth résilient :** Capture robuste de l'erreur réseau typique sous Windows (code 10013) et conseils clairs pour débloquer la connexion (WinNAT/HNS). +* **Diagnostic SOTA :** Vérification en un clic de l'état de santé, du PATH et des versions de tous vos outils de développement (Git, Python, Node, Cargo, etc.). +* **Écran de mises à jour sécurisé (`UpdaterLockScreen`) :** Verrouillage de l'interface pendant l'application des correctifs système pour éviter tout conflit de fichiers ou corruption de base de données. +* **Script de compilation OneDrive (`compil.ps1`) :** Automatisation de la génération de l'application et copie immédiate sur OneDrive pour un déploiement instantané sur vos PC. +* **Active Turn Registry :** Moteur intelligent Rust qui suit les turns de l'agent en cours et assure une reprise instantanée du streaming. +* **Fiche de transmission structurée (Compaction d'IA) :** Compactage automatique du contexte lors du changement de fournisseur d'IA dans une fiche structurée reprenant le statut des fichiers modifiés, le relais des tâches et les diagnostics du linter. +* **Mode plein gaz adaptatif (`crates/sinew-index/src/store.rs`) :** Optimisation dynamique des performances de l'indexeur augmentant le cache et la lecture en mémoire lorsque la machine dispose d'un stockage SSD/NVMe. +* **Indexation locale parallèle SOTA :** Préparation et analyse des fichiers en parallèle répartie sur tous les coeurs de CPU disponibles via Rayon, avec détection immédiate et saut des fichiers inchangés grâce à leurs empreintes de taille et date. +* **Identification de projet universelle :** Association automatique des conversations au dépôt Git distant (remote origin URL) ou via un fichier d'identifiant unique `.sinew/project_id.txt` pour lier instantanément vos conversations d'un PC à un autre sans aucune action manuelle, avec détection, liaison et rafraîchissement dynamique des conversations provenant de PC alternatifs depuis les paramètres. +* **Gestion des mises à jour configurables :** Option à 3 choix (Bloquant, Notification, Désactivé) pour décider précisément du niveau de vérification des nouvelles versions de Sinew et empêcher l'écrasement de vos modifications. +* **Script de contrôle qualité unifié (`scripts/check.ps1`) :** Commande unique `npm run check` exécutant le build frontend, `cargo check`, les tests, `clippy` et les audits de dépendances en une seule opération. +* **Système d'apprentissage global transparent :** Chargement et injection automatique de la base d'instructions centralisées de l'utilisateur (`%LOCALAPPDATA%\Sinew\instructions_consolidated.md`) dans le prompt système de tous les agents pour l'ensemble des projets ouverts sur la machine. +* **Consolidation automatique de la mémoire :** Mécanisme au démarrage transformant automatiquement les erreurs répétées enregistrées dans `errors_raw.json` en règles d'apprentissage globales permanentes dans `instructions_consolidated.md` avec nettoyage du compteur d'erreurs. +* **Bouton de synchronisation forcée :** Ajout d'un bouton de synchronisation immédiate à la demande dans les paramètres pour déclencher manuellement la synchronisation du dossier OneDrive local. + +### 🤖 Modèles d'IA, Comptes & Furtivité (AI Engine) +* **Gestion Multi-comptes OpenAI & Google Gemini :** Connexion simultanée de plusieurs profils OpenAI et Google Gemini secondaires avec bascule instantanée entre vos différentes clés, comptes et abonnements. +* **Quotas en temps réel :** Visualisation dynamique de votre consommation (crédits / balance restante) sous forme de barres de progression colorées adaptatives dans les options, et pastille live dans le chat. +* **Routage & Résilience Google Antigravity SOTA :** Réparation, de-surcharge réseau (erreur 503), routeurs de secours et transition transparente entre modèles avec résolution dynamique des identifiants d'appels d'outils (tool_call_id). +* **Optimisation de vitesse Gemini :** Streaming et requêtes ultra-rapides pour les modèles Gemini. +* **Incorporation de Claude Opus 4.8 & 4.6 :** Intégration complète de Claude Opus 4.8 (contexte 1M natif) et Claude Opus 4.6 via les abonnements professionnels Google. +* **Système Pending/Steering pour Influencer :** Un vrai système d'interception et de guidage pour orienter, corriger ou ajouter des instructions en temps réel sans blocage du flux de l'IA. +* **Indexation sémantique locale vectorielle :** Indexation et recherche vectorielle haute-performance effectuée localement sur votre machine avec commutateur d'activation directe (BETA) dans le panneau d'options. +* **Intégration de DeepSeek R1 & V3 :** Support complet de **DeepSeek V3** et de **DeepSeek R1** avec capture et rendu en temps réel du bloc de réflexion (*reasoning*) grâce à l'extraction du champ `reasoning_content` dans le chat. +* **Pont Cursor Composer 2.5 (agent.v1) :** Moteur haute-performance autonome sur connexions HTTP/2 persistantes gérant toutes les modifications chirurgicales de fichiers, avec installation automatique et invisible en arrière-plan, et masquage du sélecteur d'intelligence inutile. +* **Sécurité & Furtivité WebSocket :** Spoofing d'empreinte réseau avancé pour éliminer tout risque de détection ou de blocage sur les flux de ChatGPT. +* **WebSocket OpenAI :** Transport temps-réel haute performance basé sur WebSocket pour des réponses fluides et à latence minimale avec OpenAI. + +### 🔌 Extensions & Ponts locaux (MCP & Bridge) +* **Extension Chrome nouvelle génération :** Pilotage d'actions de navigation ultra-stables en natif Rust avec mouvements et clics à vitesse humaine (mouvements Beziers, physique fluide) et mode silencieux. +* **Réparation Chrome en un clic :** Bouton bleu de configuration automatique si le pont Chrome ne répond pas sur un nouveau PC. +* **Empaquetage des ressources Tauri :** Le pont local et l'extension Chrome sont intégrés directement au sein de l'installateur compilé (MSI/EXE). +* **Outils Rust & ripgrep Sidecar :** Intégration de Ripgrep en binaire natif sidecar et de nouveaux outils (`list_dir`, `delete_file`) pour accélérer la recherche et la gestion des fichiers par 10x. +* **Diagnostics Monaco en temps réel :** Remontée automatique des lints et erreurs de compilation de l'éditeur de code à l'IA en temps réel. +* **Logs ultra-compacts :** Nettoyage automatique du contexte de discussion pour éliminer le bruit et optimiser la consommation de jetons. +* **Laboratoire réseau MITM :** Outils de débogage et d'ingénierie inverse intégrés pour inspecter le trafic chiffrés des outils IA. +* **Moteur de remplacement intelligent (Search/Replace) :** Système d'auto-correction à 8 couches (Unicode, indentations, etc.) garantissant que les modifications de l'IA s'insèrent correctement dans vos fichiers même en cas de légères erreurs d'espaces. +* **Outils MCP de diagnostics Chrome avancés :** Intégration de nouveaux outils d'audit (`emulate_experience`, `lighthouse_audit`, `analyze_memory_leaks`) basés sur l'API CDP pour tester les performances, diagnostics Lighthouse et fuites mémoire en local. + +--- + ## Contents - [The three modes](#the-three-modes) — Act, Goal, Plan - [`AGENTS.md` & `DESIGN.md`](#agentsmd--designmd) — system prompt injection -- [Multi-provider, one harness](#multi-provider-one-harness) — Anthropic, OpenAI, Google, Kimi, OpenRouter +- [Multi-provider, one harness](#multi-provider-one-harness) — Anthropic, OpenAI, Google, Kimi, OpenRouter, Ollama +- [Real-time Quota Monitoring](#real-time-quota-monitoring) — OpenAI Codex, Antigravity, OpenRouter - [Tools](#tools) — the agent's toolset - [`clean_context`](#clean_context) — the model cleans its own context - [`bash` / `bash_input`](#bash--bash_input) — PTY-backed shell sessions @@ -53,7 +119,13 @@ - [Agent swarm](#agent-swarm) — peer-to-peer team of 2–8 agents - [Compaction](#compaction) — auto and manual - [Rollback](#rollback) — checkpointed conversation -- [Architecture](#architecture) · [Install](#install) · [Build from source](#build-from-source) +- [Display modes](#display-modes) — technical reasoning density +- [SOTA System Diagnostics](#sota-system-diagnostics) — real-time local dependency audit +- [Architecture](#architecture) +- [Screenshot](#screenshot) +- [OAuth credentials](#oauth-credentials) +- [Community](#community) +- [License](#license) --- @@ -107,6 +179,7 @@ Sinew supports five model providers, each with its own connection method: | **OpenAI** | subscription | | **Google** | subscription | | **Kimi** | subscription | +| **Ollama** | local server | | **OpenRouter** | API key | ### OAuth mode — the real differentiator @@ -127,6 +200,25 @@ Model selection happens at three levels: --- +## Real-time Quota Monitoring + +Sinew is equipped with a built-in real-time quota tracking engine, letting you monitor your usage and limits directly inside the Settings and connection panels without leaving the harness. + +Depending on the provider, three distinct tracking systems are implemented: + +- **OpenAI Codex Quotas (ChatGPT Subscription)**: When connected via OpenAI OAuth, Sinew automatically pulls your active ChatGPT Plus/Pro rate limits directly from the official backend APIs. It tracks remaining requests in your primary (short) and secondary (long) windows, complete with exact reset timelines. +- **Antigravity Quotas (Google Cloud Code / Gemini)**: When authenticated via Google OAuth, Sinew interfaces with Gemini's active developer platform quotas, mapping active rate-limit groups and returning precise remaining request/token percentages and reset times. +- **OpenRouter Credits**: For API-key-based OpenRouter connections, the harness queries the `/auth/key` endpoint to extract your total credit limit, credits used, and exact remaining USD balance (or indicators for unlimited keys). + +### Dynamic Visual Cues +To ensure you are never surprised by rate-limiting, progress bars in the Settings panel are dynamically color-coded based on your remaining percentage: +- **Green (>80% remaining):** Comfortable headroom. +- **Blue (>50% remaining):** Stable usage. +- **Pink (>20% remaining):** Reaching limits soon. +- **Red (<20% remaining):** Critically low; throttle imminent. + +--- + ## Tools The agent has access to a full set of tools: @@ -222,7 +314,7 @@ total: 124 A header with the path and the total line count, then the requested lines, numbered. -And that's Sinew's whole philosophy: give the **minimum useful information**, no repetition. Just the path, the total line count (so the agent knows where it is and can paginate), and each line numbered. Nothing more. The model figures out the rest — target, cross-reference, widen if needed. +And that's Sinew's whole philosophy: give the **minimum useful information**, no redundancy. Just the path, the total line count (so the agent knows where it is and can paginate), and each line numbered. Nothing more. The model figures out the rest — target, cross-reference, widen if needed. --- @@ -527,6 +619,31 @@ Under the hood, each turn records a *checkpoint* that captures the before / afte --- +## Display modes + +Sinew features a customizable display mode selector (*Mode d'affichage*) in the General Options, allowing you to configure the density of **AI thinking processes** (reasoning blocks) and **tool execution details** (bash output, file reads/writes, grep searches) displayed in the chat. + +The interface offers three density levels: + +- **Detailed (*Détaillé*)** — Full visibility. AI reasoning blocks remain open after streaming. Tool calls are rendered in full detail. File modifications (`edit_file`, `write_file`) bypass the standard card wrappers and render their complete, raw line-by-line diffs (`FileChangeBlock`s) directly in the chat stream for maximum technical auditability. +- **Compact** — A clean, balanced view. AI reasoning blocks automatically collapse once streaming is finished, showing only a small summary header (e.g. *Thinking (5.2s)*). All successful tool executions, **including file modifications**, are collapsed into single-line clickable card headers. Diffs and code outputs are hidden by default, but can be expanded manually with a single click. Long outputs inside cards are capped to a max-height of 180px with a scrollbar to keep the chat tidy. +- **Very Compact (*Très compact*)** — The cleanest layout. AI reasoning blocks are visible during generation, then **disappear completely** once finished. All successful, completed tool executions (including file edits and reads) are **filtered out of the chat entirely** (0 pixels used). Only errors, questions from the agent, and the final text responses from the AI remain visible, allowing you to focus purely on the results. + +--- + +## SOTA System Diagnostics + +Sinew features a built-in real-time **SOTA System Diagnostics** utility in the Options panel to ensure your environment is fully configured for state-of-the-art agent operations. + +- **How it works** — Clicking the **Refresh (*Actualiser*)** button queries the local operating system, verifies execution health, resolves absolute system paths, and extracts version details of all crucial development tools (Git, Node.js, Npm, Cargo, Rustc, Python, Pip, and Ripgrep). +- **Why it matters** — These dependencies are directly used by Sinew: + - **Git** powers the conversation rollback and automatic background Git commits. + - **Ripgrep (`rg`)** provides blisteringly fast codebase indexing and regex searches. + - **Python & Pip** run advanced Model Context Protocol (MCP) servers and AI-assisted scripts. + - **Node.js, Npm, Rustc & Cargo** handle application runtime, sidecar compilation, and build pipelines. + +--- + ## Architecture

@@ -549,34 +666,6 @@ Under the hood, each turn records a *checkpoint* that captures the before / afte --- -## Install - -Grab the latest build for your OS from the -[releases page](https://github.com/Paseru/sinew/releases/latest). - -- **macOS** — `.dmg` -- **Windows** — `.msi` / `.exe` -- **Linux** — `.AppImage` / `.deb` - -The app self-updates from GitHub releases. - ---- - -## Build from source - -```bash -# requires Rust 1.80+ and Node 20+ -# see https://tauri.app/start/prerequisites/ for platform deps - -npm install -npm run tauri dev # development -npm run tauri build # release bundle -``` - -The repo is a Cargo workspace (`crates/*` + `src-tauri/`) plus a Vite + React frontend (`src/`). - ---- - ## OAuth credentials Provider OAuth client IDs (and Google's client secret) are embedded in the source. This follows the standard practice for "installed applications" — the same approach used by tools like `gcloud`. These credentials are not treated as secret in this context. diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md new file mode 100644 index 00000000..fcfcbc72 --- /dev/null +++ b/REFACTORING_PLAN.md @@ -0,0 +1,20 @@ +# Rapport d'Audit & Plan de Rationalisation (Refactoring) + +Ce rapport consolide l'analyse globale de la base de code (Frontend React & Backend Rust) pour identifier les meilleures opportunités de rationalisation (DRY) et de refactoring. + +## Top 3 des Cibles Prioritaires de Refactoring + +### 1. Unification des Providers LLM (Backend Rust) +- **Problème** : L'authentification OAuth, les appels réseaux et le formatage des requêtes sont massivement dupliqués à travers 6 crates dédiés (`sinew-anthropic`, `sinew-openai`, `sinew-google`, `sinew-deepseek`, `sinew-kimi`, `sinew-openrouter`) ainsi que dans `providers.rs`. +- **Action de Refactoring** : Créer un crate ou module core pour centraliser le client HTTP, la gestion des tokens OAuth et la standardisation des requêtes/réponses. Chaque crate fournisseur deviendra une simple surcouche de mapping de payload (API-specific). + +### 2. Découpage des Composants Monolithiques (Frontend React) +- **Problème** : Les composants principaux sont devenus des monolithes ingérables, notamment `SettingsPane.tsx` (plus de 8000 lignes) et `ChatPane.tsx` (plus de 6500 lignes). L'interface contient également de nombreux composants UI dupliqués (ex: icônes, formatBytes, MenuItem) répartis entre `EditorPane`, `FileTree` et `ImageContextMenu`. +- **Action de Refactoring** : + - Extraire les fonctions utilitaires de `ChatPane.tsx` vers un module `chatUtils.ts`. + - Découper `SettingsPane.tsx` en sous-composants abstraits (ex: `ProviderCard.tsx`, `SystemSettings.tsx`). + - Centraliser les composants UI partagés dans un dossier `src/components/ui`. + +### 3. Standardisation du Pipeline d'Outils et d'Exécution (Backend Rust) +- **Problème** : Le passage d'arguments et le dispatch des outils se fait manuellement dans `tool_dispatch.rs` via des match/if complexes. De plus, la logique d'exécution des tours de l'agent est éclatée entre `turn.rs` et `turns.rs`. +- **Action de Refactoring** : Implémenter un système de `Trait` commun pour l'ensemble des outils de l'agent, avec un registre d'outils dynamique. Consolider le moteur d'exécution en une pipeline d'actions modulaire pour simplifier la boucle principale. diff --git a/build-error.txt b/build-error.txt new file mode 100644 index 00000000..963fe1d2 --- /dev/null +++ b/build-error.txt @@ -0,0 +1,6 @@ + Info `tauri-build` dependency has workspace inheritance enabled. The features array won't be automatically rewritten. Expected features: [] + Info `tauri` dependency has workspace inheritance enabled. The features array won't be automatically rewritten. Expected features: [protocol-asset] + Info Looking up installed tauri packages to check mismatched versions... + Running beforeBuildCommand `npm run prepare-sidecars && npm run prepare-agent-bridge && npm run build` + Compiling Sinew v0.1.26 (C:\Dev\Sinew\src-tauri) +failed to build app \ No newline at end of file diff --git a/build-log.txt b/build-log.txt new file mode 100644 index 00000000..1302ad96 --- /dev/null +++ b/build-log.txt @@ -0,0 +1,40 @@ +=== 1. Lancement de la compilation Tauri (NSIS uniquement) === + +> sinew@0.1.26 prepare-sidecars +> node scripts/prepare-sidecars.mjs + +ripgrep sidecar already present: src-tauri\binaries\rg-x86_64-pc-windows-msvc.exe + +> sinew@0.1.26 prepare-agent-bridge +> node scripts/prepare-agent-bridge.mjs + +agent-bridge deps already present: agent-bridge +agent-bridge ready for bundle/runtime. +Chrome bridge dependencies already present. + +> sinew@0.1.26 build +> tsc --noEmit && vite build + +vite v5.4.21 building for production... +transforming... +✓ 5347 modules transformed. +rendering chunks... +computing gzip size... +dist/index.html 1.48 kB │ gzip: 0.61 kB +dist/assets/codicon-DCmgc-ay.ttf 80.34 kB +dist/assets/editor.worker-wFIaQSWA.js 230.61 kB +dist/assets/json.worker-CdYhYu-b.js 362.27 kB +dist/assets/html.worker-MgVplfgg.js 668.17 kB +dist/assets/css.worker-zQUO28Un.js 1,009.37 kB +dist/assets/ts.worker-BQHvlPb0.js 6,013.96 kB +dist/assets/xterm-CgzjseLH.css 5.89 kB │ gzip: 2.10 kB +dist/assets/monaco-DIPB3Vbg.css 130.51 kB │ gzip: 21.01 kB +dist/assets/index-tY5BIKa2.css 185.17 kB │ gzip: 27.61 kB +dist/assets/icons-DR2osktw.js 32.83 kB │ gzip: 11.06 kB +dist/assets/katex-HP8lGamR.js 258.47 kB │ gzip: 77.57 kB +dist/assets/markdown-DaqiLpSk.js 334.56 kB │ gzip: 101.44 kB +dist/assets/xterm-Ca-2fTbv.js 602.68 kB │ gzip: 156.70 kB +dist/assets/index-BdiB8aQN.js 693.57 kB │ gzip: 193.00 kB +dist/assets/mermaid-BpOho62B.js 2,881.75 kB │ gzip: 782.58 kB +dist/assets/monaco-CI5vmIO4.js 3,904.22 kB │ gzip: 987.77 kB +✓ built in 27.97s diff --git a/cleanup.js b/cleanup.js new file mode 100644 index 00000000..0d8e96c0 Binary files /dev/null and b/cleanup.js differ diff --git a/crates/sinew-agent-daemon/Cargo.toml b/crates/sinew-agent-daemon/Cargo.toml new file mode 100644 index 00000000..a8418f56 --- /dev/null +++ b/crates/sinew-agent-daemon/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "sinew-agent-daemon" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Persistent background daemon for Sinew agent loops" + +[dependencies] +anyhow = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +uuid = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +sinew-core = { path = "../sinew-core" } +sinew-app = { path = "../sinew-app" } +sinew-cursor = { path = "../sinew-cursor" } +sinew-anthropic = { workspace = true } +sinew-google = { workspace = true } +sinew-kimi = { workspace = true } +sinew-openai = { workspace = true } +sinew-openrouter = { workspace = true } +sinew-deepseek = { workspace = true } + +[lints] +workspace = true diff --git a/crates/sinew-agent-daemon/src/main.rs b/crates/sinew-agent-daemon/src/main.rs new file mode 100644 index 00000000..4df85449 --- /dev/null +++ b/crates/sinew-agent-daemon/src/main.rs @@ -0,0 +1,502 @@ +use std::fs; +use std::path::PathBuf; +use std::collections::HashMap; +use std::sync::Arc; +use anyhow::{Context, Result}; +use tracing::{info, error}; + +mod protocol; + +#[cfg(windows)] +use tokio::net::windows::named_pipe::{ServerOptions, NamedPipeServer}; + +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +#[cfg(not(windows))] +use tokio::net::{TcpListener, TcpStream}; + +fn load_all_providers() -> HashMap> { + let mut providers: HashMap> = HashMap::new(); + + // Load OpenAI + if let Ok(files) = sinew_openai::all_auth_files() { + for (key, path) in files { + if let Ok(p) = sinew_openai::OpenAiProvider::from_file(&path) { + providers.insert(key, Arc::new(p)); + } + } + } + + // Load Anthropic + if let Ok(p) = sinew_anthropic::AnthropicProvider::from_default_sources() { + let arc = Arc::new(p); + providers.insert("anthropic".to_string(), arc.clone()); + providers.insert("anthropic:1".to_string(), arc); + } + + // Load Google + if let Ok(p) = sinew_google::GoogleProvider::from_default_sources() { + let arc = Arc::new(p); + providers.insert("google".to_string(), arc.clone()); + providers.insert("google:1".to_string(), arc); + } + + // Load Kimi + if let Ok(p) = sinew_kimi::KimiProvider::from_default_sources() { + let arc = Arc::new(p); + providers.insert("kimi".to_string(), arc.clone()); + providers.insert("kimi:1".to_string(), arc); + } + + // Load OpenRouter + if let Ok(p) = sinew_openrouter::OpenRouterProvider::from_default_sources(Vec::new()) { + let arc = Arc::new(p); + providers.insert("openrouter".to_string(), arc.clone()); + providers.insert("openrouter:1".to_string(), arc); + } + + // Load DeepSeek + if let Ok(p) = sinew_deepseek::DeepSeekProvider::from_default_sources() { + let arc = Arc::new(p); + providers.insert("deepseek".to_string(), arc.clone()); + providers.insert("deepseek:1".to_string(), arc); + } + + // Load Cursor + if let Ok(p) = sinew_cursor::CursorProvider::from_default_sources() { + let arc = Arc::new(p); + providers.insert("cursor:1".to_string(), arc.clone()); + providers.insert("cursor".to_string(), arc); + } + + providers +} + +#[allow(unreachable_code)] +#[tokio::main] +async fn main() -> Result<()> { + // Initialize tracing + tracing_subscriber::fmt::init(); + info!("Starting Sinew Agent Daemon..."); + + // Write PID file + let local_app_data = std::env::var("LOCALAPPDATA") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(".")); + let daemon_dir = local_app_data.join("Sinew").join("Daemon"); + fs::create_dir_all(&daemon_dir).context("Failed to create daemon state directory")?; + let pid_path = daemon_dir.join("daemon.pid"); + fs::write(&pid_path, std::process::id().to_string().as_bytes()) + .context("Failed to write PID file")?; + info!("PID written to {}", pid_path.display()); + + let providers = Arc::new(tokio::sync::RwLock::new(load_all_providers())); + + // Windows Named Pipe setup + #[cfg(windows)] + { + let pipe_name = r"\\.\pipe\sinew-agent-ipc"; + info!("Listening on Windows Named Pipe: {}", pipe_name); + + let mut first = true; + loop { + let server = ServerOptions::new() + .first_pipe_instance(first) + .create(pipe_name)?; + first = false; + + // Wait for client connection + server.connect().await?; + info!("Client connected via Named Pipe!"); + + let providers_clone = providers.clone(); + tokio::spawn(async move { + if let Err(e) = handle_client(server, providers_clone).await { + error!("Error handling Named Pipe client: {:?}", e); + } + }); + } + } + + #[cfg(not(windows))] + { + let addr = "127.0.0.1:47990"; + info!("Listening on TCP: {}", addr); + let listener = TcpListener::bind(addr).await.context("Failed to bind TCP listener")?; + + loop { + match listener.accept().await { + Ok((stream, _)) => { + info!("Client connected via TCP!"); + let providers_clone = providers.clone(); + tokio::spawn(async move { + if let Err(e) = handle_client_tcp(stream, providers_clone).await { + error!("Error handling TCP client: {:?}", e); + } + }); + } + Err(e) => { + error!("Error accepting TCP connection: {}", e); + } + } + } + } + + Ok(()) +} + +#[cfg(windows)] +async fn handle_client( + stream: NamedPipeServer, + providers: Arc>>>, +) -> Result<()> { + let (reader, writer) = tokio::io::split(stream); + handle_client_inner(reader, writer, providers).await +} + +#[cfg(not(windows))] +async fn handle_client_tcp( + stream: TcpStream, + providers: Arc>>>, +) -> Result<()> { + let (reader, writer) = tokio::io::split(stream); + handle_client_inner(reader, writer, providers).await +} + +async fn handle_client_inner( + reader: R, + mut writer: W, + providers: Arc>>>, +) -> Result<()> +where + R: tokio::io::AsyncRead + Unpin, + W: tokio::io::AsyncWrite + Unpin + Send + 'static, +{ + let mut reader = BufReader::new(reader); + let mut line = String::new(); + + loop { + line.clear(); + let bytes_read = reader.read_line(&mut line).await?; + if bytes_read == 0 { + break; // Connection closed + } + + let request: protocol::DaemonRequest = match serde_json::from_str(&line) { + Ok(req) => req, + Err(e) => { + let err_resp = protocol::DaemonResponse::Error { + message: format!("Invalid JSON request: {}", e), + }; + let mut resp_str = serde_json::to_string(&err_resp)?; + resp_str.push('\n'); + writer.write_all(resp_str.as_bytes()).await?; + continue; + } + }; + + // Process request + info!("Received request: {:?}", request); + match request { + protocol::DaemonRequest::GetStatus => { + let resp = protocol::DaemonResponse::Status { + is_busy: false, + active_conversation_id: None, + }; + let mut resp_str = serde_json::to_string(&resp)?; + resp_str.push('\n'); + writer.write_all(resp_str.as_bytes()).await?; + } + protocol::DaemonRequest::CancelTurn { conversation_id } => { + info!("Cancel turn requested for: {}", conversation_id); + } + protocol::DaemonRequest::ListEntries { workspace_path, relative_path } => { + info!("ListEntries requested for: {} {:?}", workspace_path, relative_path); + let root = PathBuf::from(&workspace_path); + let entries = sinew_app::workspace::list_workspace_entries(&root, relative_path.as_deref()).unwrap_or_default(); + let resp = protocol::DaemonResponse::EntriesList { + entries: serde_json::to_value(&entries).unwrap_or(serde_json::Value::Null), + }; + let mut resp_str = serde_json::to_string(&resp)?; + resp_str.push('\n'); + writer.write_all(resp_str.as_bytes()).await?; + } + protocol::DaemonRequest::ListAllFiles { workspace_path } => { + info!("ListAllFiles requested for: {}", workspace_path); + let root = PathBuf::from(&workspace_path); + let entries = sinew_app::workspace::list_workspace_files(&root).unwrap_or_default(); + let resp = protocol::DaemonResponse::EntriesList { + entries: serde_json::to_value(&entries).unwrap_or(serde_json::Value::Null), + }; + let mut resp_str = serde_json::to_string(&resp)?; + resp_str.push('\n'); + writer.write_all(resp_str.as_bytes()).await?; + } + protocol::DaemonRequest::ReadFile { workspace_path, relative_path } => { + info!("ReadFile requested for: {}/{}", workspace_path, relative_path); + let root = PathBuf::from(&workspace_path); + match sinew_app::workspace::read_workspace_file(&root, &relative_path) { + Ok(doc) => { + let resp = protocol::DaemonResponse::FileContent { + content: doc.content.unwrap_or_default(), + }; + let mut resp_str = serde_json::to_string(&resp)?; + resp_str.push('\n'); + writer.write_all(resp_str.as_bytes()).await?; + } + Err(e) => { + let resp = protocol::DaemonResponse::Error { + message: format!("Failed to read file: {}", e), + }; + let mut resp_str = serde_json::to_string(&resp)?; + resp_str.push('\n'); + writer.write_all(resp_str.as_bytes()).await?; + } + } + } + protocol::DaemonRequest::WriteFile { workspace_path, relative_path, content } => { + info!("WriteFile requested for: {}/{}", workspace_path, relative_path); + let root = PathBuf::from(&workspace_path); + match sinew_app::workspace::write_workspace_file(&root, &relative_path, &content) { + Ok(_) => { + let resp = protocol::DaemonResponse::FileWritten; + let mut resp_str = serde_json::to_string(&resp)?; + resp_str.push('\n'); + writer.write_all(resp_str.as_bytes()).await?; + } + Err(e) => { + let resp = protocol::DaemonResponse::Error { + message: format!("Failed to write file: {}", e), + }; + let mut resp_str = serde_json::to_string(&resp)?; + resp_str.push('\n'); + writer.write_all(resp_str.as_bytes()).await?; + } + } + } + protocol::DaemonRequest::StartTurn { + conversation_id, + workspace_path, + system_prompt, + model_name, + provider: provider_id, + history, + todo_list, + goal_workflow, + mcp_settings, + tool_settings, + skill_settings, + sub_agent_settings, + } => { + info!("Start turn requested for: {}", conversation_id); + + // Get provider from registry + let provider_instance = { + let lock = providers.read().await; + lock.get(&provider_id).cloned() + }; + + let provider_instance = match provider_instance { + Some(p) => p, + None => { + let err_resp = protocol::DaemonResponse::Error { + message: format!("provider `{}` is not configured or missing credentials", provider_id), + }; + let mut resp_str = serde_json::to_string(&err_resp)?; + resp_str.push('\n'); + writer.write_all(resp_str.as_bytes()).await?; + continue; + } + }; + + // Write TurnStarted response + let resp = protocol::DaemonResponse::TurnStarted { + conversation_id: conversation_id.clone(), + }; + let mut resp_str = serde_json::to_string(&resp)?; + resp_str.push('\n'); + writer.write_all(resp_str.as_bytes()).await?; + + // Prepare TurnContext parameters + let workspace_root = PathBuf::from(&workspace_path); + let model = sinew_core::ModelRef { + provider: provider_id.clone(), + name: model_name.clone(), + effort: None, + }; + + let tool_settings = tool_settings + .and_then(|v| serde_json::from_value::(v).ok()) + .unwrap_or_default(); + let mcp_settings = mcp_settings + .and_then(|v| serde_json::from_value::(v).ok()) + .unwrap_or_default(); + let skill_settings = skill_settings + .and_then(|v| serde_json::from_value::(v).ok()) + .unwrap_or_default(); + let _sub_agent_settings = sub_agent_settings + .and_then(|v| serde_json::from_value::(v).ok()) + .unwrap_or_default(); + + let history = serde_json::from_value::>(history).unwrap_or_default(); + let todo_list = serde_json::from_value::(todo_list).unwrap_or_default(); + let goal_workflow = serde_json::from_value::(goal_workflow).unwrap_or_default(); + + // Setup tools + let bash = Arc::new(sinew_app::bash::BashTool::new(workspace_root.clone())); + let glob = Arc::new(sinew_app::glob::GlobTool::new(workspace_root.clone())); + let list_dir = Arc::new(sinew_app::list_dir::ListDirTool::new(workspace_root.clone())); + let grep = Arc::new(sinew_app::grep::GrepTool::new(workspace_root.clone())); + let codebase_search = Arc::new(sinew_app::codebase_search::CodebaseSearchTool::new(workspace_root.clone())); + let check_sota = Arc::new(sinew_app::check_sota::CheckSotaTool::new()); + let computer_use = Arc::new(sinew_app::ComputerUseTool::new()); + let read = Arc::new(sinew_app::read::ReadTool::new(workspace_root.clone())); + let edit_file = Arc::new(sinew_app::edit::EditFileTool::new(workspace_root.clone())); + let write_file = Arc::new(sinew_app::write::WriteFileTool::new(workspace_root.clone())); + let delete_file = Arc::new(sinew_app::delete_file::DeleteFileTool::new(workspace_root.clone())); + + let editor_diagnostics = sinew_app::editor_diagnostics::new_editor_diagnostics_store(); + let read_lints = Arc::new(sinew_app::read_lints::ReadLintsTool::new(workspace_root.clone(), editor_diagnostics.clone())); + + let create_image = Arc::new(sinew_app::image::CreateImageTool::with_settings( + workspace_root.clone(), + tool_settings.image_provider, + tool_settings.openai_image_use_subscription, + tool_settings.gemini_image_use_subscription, + Some(tool_settings.openai_image_model.clone()), + Some(tool_settings.gemini_image_model.clone()), + tool_settings.openai_image_api_key(), + tool_settings.nano_banana_api_key(), + )); + + let todo_list_tool = Some(Arc::new(sinew_app::todo::ToDoListTool::new())); + let question = Some(Arc::new(sinew_app::question::QuestionTool::new())); + let web_search = Arc::new(sinew_app::web::WebSearchTool::with_settings( + tool_settings.web_search_provider, + tool_settings.linkup_api_key(), + )); + let web_fetch = Arc::new(sinew_app::web::WebFetchTool::new()); + let skill = Arc::new(sinew_app::skill::SkillTool::with_settings( + workspace_root.clone(), + skill_settings.clone(), + )); + let mcp_registry = Arc::new(sinew_app::mcp::McpToolRegistry::new(mcp_settings.clone())); + + let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); + let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel(); + let cancel = sinew_app::TurnCancel::with_steering(cmd_tx, tokio::sync::mpsc::unbounded_channel().0); + + let context = sinew_app::TurnContext { + provider: provider_instance, + workspace_root, + model, + cache_key: Some(conversation_id.clone()), + cache_stable_message_count: history.len(), + service_tier: None, + auto_compact: true, + mode: sinew_app::AgentMode::Act, + stop_questions: false, + system_prompt, + history, + todo_list, + goal_workflow, + bash, + glob, + list_dir, + grep, + codebase_search, + check_sota, + computer_use, + read, + edit_file, + write_file, + delete_file, + read_lints, + create_image, + todo_list_tool, + question, + web_search, + web_fetch, + skill, + mcp: mcp_registry, + subagents: None, + teams: None, + tool_settings, + event_scope: None, + max_tool_rounds: 30, + event_tx, + cancel, + cmd_rx, + steering_rx: None, + }; + + // Spawn the turn loop in the daemon + let conversation_id_clone = conversation_id.clone(); + let event_task = tokio::spawn(async move { + sinew_app::run_turn(context).await + }); + + // Forward events back to the client + let _writer_providers = providers.clone(); + let mut writer_clone = writer; + let conversation_id_events = conversation_id.clone(); + + let event_forwarder = tokio::spawn(async move { + while let Some(event) = event_rx.recv().await { + let event_val = serde_json::to_value(&event).unwrap_or(serde_json::Value::Null); + let resp = protocol::DaemonResponse::Event { + conversation_id: conversation_id_events.clone(), + event: event_val, + }; + if let Ok(mut resp_str) = serde_json::to_string(&resp) { + resp_str.push('\n'); + if writer_clone.write_all(resp_str.as_bytes()).await.is_err() { + break; + } + } + } + writer_clone + }); + + // Wait for the turn loop to finish + let turn_result = event_task.await; + let mut writer_recovered = event_forwarder.await?; + + match turn_result { + Ok(output) => { + let output_val = serde_json::json!({ + "history": output.history, + "todo_list": output.todo_list, + "goal_workflow": output.goal_workflow, + "interrupted": output.interrupted, + "compacted": output.compacted, + }); + let resp = protocol::DaemonResponse::TurnFinished { + conversation_id: conversation_id_clone, + success: true, + error: None, + output: Some(output_val), + }; + let mut resp_str = serde_json::to_string(&resp)?; + resp_str.push('\n'); + writer_recovered.write_all(resp_str.as_bytes()).await?; + } + Err(e) => { + let resp = protocol::DaemonResponse::TurnFinished { + conversation_id: conversation_id_clone, + success: false, + error: Some(format!("Turn panicked or aborted: {:?}", e)), + output: None, + }; + let mut resp_str = serde_json::to_string(&resp)?; + resp_str.push('\n'); + writer_recovered.write_all(resp_str.as_bytes()).await?; + } + } + + // Recover the writer back to the loop + writer = writer_recovered; + } + } + } + Ok(()) +} diff --git a/crates/sinew-agent-daemon/src/protocol.rs b/crates/sinew-agent-daemon/src/protocol.rs new file mode 100644 index 00000000..352bf2bb --- /dev/null +++ b/crates/sinew-agent-daemon/src/protocol.rs @@ -0,0 +1,72 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum DaemonRequest { + StartTurn { + conversation_id: String, + workspace_path: String, + system_prompt: String, + model_name: String, + provider: String, + history: serde_json::Value, + todo_list: serde_json::Value, + goal_workflow: serde_json::Value, + mcp_settings: Option, + tool_settings: Option, + skill_settings: Option, + sub_agent_settings: Option, + }, + CancelTurn { + conversation_id: String, + }, + GetStatus, + ListEntries { + workspace_path: String, + relative_path: Option, + }, + ListAllFiles { + workspace_path: String, + }, + ReadFile { + workspace_path: String, + relative_path: String, + }, + WriteFile { + workspace_path: String, + relative_path: String, + content: String, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum DaemonResponse { + Status { + is_busy: bool, + active_conversation_id: Option, + }, + TurnStarted { + conversation_id: String, + }, + Event { + conversation_id: String, + event: serde_json::Value, + }, + TurnFinished { + conversation_id: String, + success: bool, + error: Option, + output: Option, + }, + Error { + message: String, + }, + EntriesList { + entries: serde_json::Value, + }, + FileContent { + content: String, + }, + FileWritten, +} diff --git a/crates/sinew-anthropic/src/client.rs b/crates/sinew-anthropic/src/client.rs index 118fc6a4..2fc974e0 100644 --- a/crates/sinew-anthropic/src/client.rs +++ b/crates/sinew-anthropic/src/client.rs @@ -1,5 +1,6 @@ use async_trait::async_trait; use serde::Serialize; +use std::time::Instant; use sinew_core::{ AppError, ChatMessage, Effort, ModelCapabilities, ModelRef, Part, Provider, ProviderRequest, ProviderStream, Result, Role, TokenEstimate, ToolDescriptor, @@ -162,6 +163,80 @@ impl AnthropicProvider { } values.join(",") } + + pub async fn get_usage(&self) -> Result { + let token = self.config.credential.bearer_or_key(&self.http).await?; + let is_oauth = self.config.credential.is_oauth(); + if !is_oauth { + return Err(AppError::Auth("Usage limits are only available for Claude OAuth accounts".into())); + } + + let url = format!( + "{}/api/oauth/usage", + self.config.base_url.trim_end_matches('/') + ); + + let response = self + .http + .get(&url) + .header("authorization", format!("Bearer {token}")) + .header("anthropic-beta", "oauth-2025-04-20") + .header("content-type", "application/json") + .header("accept", "application/json") + .header("user-agent", USER_AGENT) + .send() + .await + .map_err(|err| AppError::Network(err.to_string()))?; + + if response.status() == reqwest::StatusCode::UNAUTHORIZED { + let refreshed_token = self + .config + .credential + .force_refresh(&self.http, &token) + .await + .map_err(|err| AppError::Auth(format!("Token refresh failed: {err}")))?; + + let response2 = self + .http + .get(&url) + .header("authorization", format!("Bearer {refreshed_token}")) + .header("anthropic-beta", "oauth-2025-04-20") + .header("content-type", "application/json") + .header("accept", "application/json") + .header("user-agent", USER_AGENT) + .send() + .await + .map_err(|err| AppError::Network(err.to_string()))?; + + if !response2.status().is_success() { + let status = response2.status(); + let text = response2.text().await.unwrap_or_default(); + return Err(AppError::Network(format!( + "Failed to fetch Anthropic usage after token refresh: HTTP {status} - {text}" + ))); + } + + let data = response2 + .json::() + .await + .map_err(|err| AppError::Decode(err.to_string()))?; + return Ok(data); + } + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + return Err(AppError::Network(format!( + "Failed to fetch Anthropic usage: HTTP {status} - {text}" + ))); + } + + let data = response + .json::() + .await + .map_err(|err| AppError::Decode(err.to_string()))?; + Ok(data) + } } #[async_trait] @@ -262,10 +337,21 @@ impl Provider for AnthropicProvider { stream: true, }; + let model_name = request.model.name.clone(); + let provider_name = "anthropic".to_string(); + let req_start = Instant::now(); let response = self .send_json_accept("/v1/messages", &body, "text/event-stream") .await?; + let http_ms = req_start.elapsed().as_millis(); + tracing::debug!( + provider = provider_name, + model = model_name, + http_ms, + "Anthropic HTTP round-trip (stream setup)" + ); + if !response.status().is_success() { return Err(read_http_error(response, true).await); } diff --git a/crates/sinew-app/Cargo.toml b/crates/sinew-app/Cargo.toml index 7880af31..176c79ab 100644 --- a/crates/sinew-app/Cargo.toml +++ b/crates/sinew-app/Cargo.toml @@ -8,14 +8,18 @@ description = "Headless desktop runtime for sinew" [dependencies] sinew-core = { workspace = true } +sinew-index = { workspace = true } sinew-openai = { workspace = true } +sinew-google = { workspace = true } anyhow = { workspace = true } base64 = { workspace = true } +chrono = { workspace = true } directories = { workspace = true } eventsource-stream = { workspace = true } futures-util = { workspace = true } kuchikiki = "0.8.8-speedreader" +image = { version = "0.25.10", default-features = false, features = ["png", "jpeg"] } reqwest = { workspace = true } regex = { workspace = true } sha2 = { workspace = true } @@ -29,6 +33,7 @@ tokio = { workspace = true } tracing = { workspace = true } uuid = { workspace = true } walkdir = { workspace = true } +rayon = { workspace = true } [lints] workspace = true diff --git a/crates/sinew-app/src/agent.rs b/crates/sinew-app/src/agent.rs index 7dec7f36..fd060f63 100644 --- a/crates/sinew-app/src/agent.rs +++ b/crates/sinew-app/src/agent.rs @@ -1,4 +1,5 @@ mod assistant_message; +mod boost_distill; mod cancel; mod clean_context; mod compaction; @@ -6,6 +7,7 @@ mod context; mod events; mod history; mod mode; +mod repeat_guard; #[cfg(test)] mod tests; mod tool_dispatch; diff --git a/crates/sinew-app/src/agent/boost_distill.rs b/crates/sinew-app/src/agent/boost_distill.rs new file mode 100644 index 00000000..d01f1b15 --- /dev/null +++ b/crates/sinew-app/src/agent/boost_distill.rs @@ -0,0 +1,107 @@ +//! Boost Local — distillation automatique des grosses sorties d'outils. +//! +//! Quand le Boost Local est actif (variable d'environnement `SINEW_BOOST_DISTILLER` +//! posée par le backend), les sorties **volumineuses** de `bash` et `web_fetch` +//! sont résumées par le petit modèle local avant d'entrer dans le contexte du +//! modèle principal. On économise ainsi des milliers de jetons par tour. +//! +//! Règles de sûreté (SOTA, ne jamais casser l'agent) : +//! * jamais sur `read`/`grep`/`codebase_search` : le modèle a besoin du texte +//! EXACT pour éditer (correspondance `oldContent`) ; +//! * jamais sur une sortie en erreur : le modèle doit voir l'erreur brute ; +//! * uniquement au-dessus d'un seuil élevé (les petites sorties restent brutes) ; +//! * la **fin** de la sortie est conservée telle quelle (codes de sortie, +//! erreurs finales) ; +//! * en cas d'échec/timeout du distillateur, on renvoie la sortie brute. + +use std::time::Duration; + +use serde_json::json; + +use crate::tool_names; + +const OLLAMA_URL: &str = "http://127.0.0.1:11434"; +/// Seuil de déclenchement (~6 000 jetons). En dessous, aucune distillation. +const DISTILL_MIN_CHARS: usize = 24_000; +/// Nombre de caractères de fin conservés bruts (codes d'erreur, exit codes…). +const RAW_TAIL_CHARS: usize = 1_000; + +/// Le distillateur local est-il actif ? (drapeau posé par le backend) +fn distiller_model() -> Option { + std::env::var("SINEW_BOOST_DISTILLER") + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +/// Seul `bash` et `web_fetch` produisent de gros textes non requis à l'identique. +fn is_distillable_tool(tool: &str) -> bool { + tool == tool_names::BASH || tool == tool_names::BASH_INPUT || tool == tool_names::WEB_FETCH +} + +/// Renvoie une version distillée du contenu pour le contexte du modèle, ou `None` +/// si rien ne doit changer (la sortie brute sera alors utilisée). +pub(super) async fn maybe_distill_tool_output( + tool: &str, + content: &str, + is_error: bool, +) -> Option { + if is_error || !is_distillable_tool(tool) || content.len() < DISTILL_MIN_CHARS { + return None; + } + let model = distiller_model()?; + + let orig_tokens = content.len() / 4; + + // On distille le gros du texte mais on garde la fin brute intacte. + let split = content.len().saturating_sub(RAW_TAIL_CHARS); + let head = &content[..split]; + let tail = &content[split..]; + + let prompt = format!( + "You are a context distiller for an AI coding agent. The agent ran a tool and got verbose \ + output. Summarize the ESSENTIAL facts it needs: key results, file paths, names, numbers, \ + warnings, and especially any errors. Be terse and factual, no filler, no code fences. \ + Max 200 words.\n\nTOOL: {tool}\n\nOUTPUT:\n{head}" + ); + + let body = json!({ + "model": model, + "prompt": prompt, + "stream": false, + "keep_alive": -1, + "options": { "num_ctx": 8192, "temperature": 0.1 } + }); + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(60)) + .build() + .ok()?; + + let resp = client + .post(format!("{OLLAMA_URL}/api/generate")) + .json(&body) + .send() + .await + .ok()?; + if !resp.status().is_success() { + return None; + } + let value: serde_json::Value = resp.json().await.ok()?; + let summary = value.get("response").and_then(|r| r.as_str())?.trim(); + if summary.is_empty() { + return None; + } + + let new_tokens = (summary.len() + tail.len()) / 4; + let saved = if orig_tokens > 0 { + 100 - (new_tokens * 100 / orig_tokens).min(100) + } else { + 0 + }; + + Some(format!( + "[Boost Local — sortie distillée localement : ~{orig_tokens} → ~{new_tokens} jetons (-{saved}%). \ + Sortie complète visible dans l'interface.]\n\n{summary}\n\n[Fin de sortie brute conservée :]\n{tail}" + )) +} diff --git a/crates/sinew-app/src/agent/cancel.rs b/crates/sinew-app/src/agent/cancel.rs index 23a6aa32..c3f76f4b 100644 --- a/crates/sinew-app/src/agent/cancel.rs +++ b/crates/sinew-app/src/agent/cancel.rs @@ -5,11 +5,19 @@ use std::{ use tokio::sync::{mpsc, oneshot}; +use sinew_core::ChatMessage; + #[derive(Debug)] pub enum EngineCommand { Cancel, } +#[derive(Debug, Clone)] +pub struct SteeringCommand { + pub id: String, + pub message: ChatMessage, +} + #[derive(Debug)] pub enum QuestionReply { Answer { @@ -26,6 +34,7 @@ pub struct TurnCancel { #[derive(Debug, Default)] struct TurnCancelState { + root_steering: Option>, senders: Vec>, questions: HashMap>, cancelled: bool, @@ -38,15 +47,52 @@ impl TurnCancel { group } + pub fn with_steering( + root: mpsc::UnboundedSender, + steering: mpsc::UnboundedSender, + ) -> Self { + let group = Self::new(root); + if let Ok(mut state) = group.state.lock() { + state.root_steering = Some(steering); + } + group + } + pub fn empty() -> Self { Self::default() } + pub fn steer(&self, id: impl Into, message: ChatMessage) -> bool { + let sender = self + .state + .lock() + .ok() + .and_then(|state| (!state.cancelled).then(|| state.root_steering.clone())) + .flatten(); + sender + .map(|sender| { + sender + .send(SteeringCommand { + id: id.into(), + message, + }) + .is_ok() + }) + .unwrap_or(false) + } + + pub fn close_steering(&self) { + if let Ok(mut state) = self.state.lock() { + state.root_steering = None; + } + } + pub fn register(&self, sender: mpsc::UnboundedSender) { if let Ok(mut state) = self.state.lock() { if state.cancelled { let _ = sender.send(EngineCommand::Cancel); } + state.senders.retain(|sender| !sender.is_closed()); state.senders.push(sender); } } @@ -57,6 +103,7 @@ impl TurnCancel { .lock() .map(|mut state| { state.cancelled = true; + state.senders.retain(|sender| !sender.is_closed()); let questions = state .questions .drain() @@ -75,6 +122,7 @@ impl TurnCancel { sent } + #[allow(clippy::result_unit_err)] pub fn register_question( &self, tool_call_id: impl Into, diff --git a/crates/sinew-app/src/agent/compaction.rs b/crates/sinew-app/src/agent/compaction.rs index c5fccf22..bf553fde 100644 --- a/crates/sinew-app/src/agent/compaction.rs +++ b/crates/sinew-app/src/agent/compaction.rs @@ -5,10 +5,10 @@ use tokio::sync::mpsc; use uuid::Uuid; use sinew_core::{ - AppError, ChatMessage, ModelRef, Part, Provider, ProviderRequest, ServiceTier, ToolDescriptor, + AppError, ChatMessage, ModelRef, Part, Provider, ProviderRequest, Role, ServiceTier, ToolDescriptor, }; -use crate::compact_conversation_history; +use crate::{compact_conversation_history, ReadLintsTool, TodoListState}; use super::{ cancel::EngineCommand, @@ -34,6 +34,9 @@ pub(super) async fn maybe_auto_compact_history( event_scope: Option<&AgentEventScope>, cmd_rx: &mut mpsc::UnboundedReceiver, auto_compaction_attempts: &mut usize, + workspace_root: &std::path::Path, + read_lints: &Arc, + todo_list: &TodoListState, ) -> std::result::Result { if !can_auto_compact_history(history, *auto_compaction_attempts) { return Ok(false); @@ -46,26 +49,85 @@ pub(super) async fn maybe_auto_compact_history( return Ok(false); } - let request_history = - history_with_current_tool_result_ids(history, current_turn_tool_result_ids); - let mut request = ProviderRequest::new(model.clone(), request_history) - .with_system(system_prompt.to_string()) - .with_tools(tool_descriptors.to_vec()) - .with_cache_stable_message_count(*cache_stable_message_count); - if let Some(cache_key) = cache_key { - request = request.with_cache_key(cache_key.clone()); - } - if let Some(service_tier) = service_tier { - request = request.with_service_tier(service_tier); - } + let current_provider_name = provider.name(); + let last_assistant_provider = history + .iter() + .rev() + .filter(|msg| msg.role == Role::Assistant) + .flat_map(|msg| &msg.parts) + .find_map(|part| { + let meta = match part { + Part::Text { meta, .. } => meta, + Part::Thinking { meta, .. } => meta, + Part::ToolCall { meta, .. } => meta, + _ => &None, + }; + meta.as_ref() + .and_then(|m| m.get("provider")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }); + + let provider_changed = if let Some(prev) = last_assistant_provider { + prev != current_provider_name + } else { + false + }; - let should_compact = match provider.estimate_tokens(request).await { - Ok(estimate) => { - let threshold = auto_compact_threshold(caps.context_window, caps.max_output_tokens); - estimate.input_tokens >= threshold + let (should_compact, user_instruction) = if provider_changed { + let mut custom_instruction = String::from("We are transitioning from another AI model to you. Here is the structural state of the workspace to help you resume the task cleanly:\n\n"); + + if let Ok(git_output) = run_git_status(workspace_root) { + let trimmed = git_output.trim(); + if !trimmed.is_empty() { + custom_instruction.push_str("### 📂 Modified Files (Git Status):\n"); + custom_instruction.push_str(trimmed); + custom_instruction.push_str("\n\n"); + } + } + + if let Some(todo_str) = todo_list.system_block() { + let trimmed = todo_str.trim(); + if !trimmed.is_empty() { + custom_instruction.push_str("### 📋 Relay of Checklist/Tasks:\n"); + custom_instruction.push_str(trimmed); + custom_instruction.push_str("\n\n"); + } } - Err(err) if is_context_length_error(&err) => true, - Err(_) => false, + + if let Ok(lints_output) = run_linter_checks(read_lints).await { + let trimmed = lints_output.trim(); + if !trimmed.is_empty() { + custom_instruction.push_str("### 🛡️ Code Health (Linter diagnostics):\n"); + custom_instruction.push_str(trimmed); + custom_instruction.push_str("\n\n"); + } + } + + (true, Some(custom_instruction)) + } else { + let request_history = + history_with_current_tool_result_ids(history, current_turn_tool_result_ids); + let mut request = ProviderRequest::new(model.clone(), request_history) + .with_system(system_prompt.to_string()) + .with_tools(tool_descriptors.to_vec()) + .with_cache_stable_message_count(*cache_stable_message_count); + if let Some(cache_key) = cache_key { + request = request.with_cache_key(cache_key.clone()); + } + if let Some(service_tier) = service_tier { + request = request.with_service_tier(service_tier); + } + + let threshold_exceeded = match provider.estimate_tokens(request).await { + Ok(estimate) => { + let threshold = auto_compact_threshold(caps.context_window, caps.max_output_tokens); + estimate.input_tokens >= threshold + } + Err(err) if is_context_length_error(&err) => true, + Err(_) => false, + }; + (threshold_exceeded, None) }; if !should_compact { @@ -85,6 +147,10 @@ pub(super) async fn maybe_auto_compact_history( event_scope, cmd_rx, auto_compaction_attempts, + workspace_root, + read_lints, + todo_list, + user_instruction, ) .await?; Ok(true) @@ -103,6 +169,10 @@ pub(super) async fn run_auto_compaction( event_scope: Option<&AgentEventScope>, cmd_rx: &mut mpsc::UnboundedReceiver, auto_compaction_attempts: &mut usize, + _workspace_root: &std::path::Path, + _read_lints: &Arc, + _todo_list: &TodoListState, + user_instruction: Option, ) -> std::result::Result<(), String> { if !can_auto_compact_history(history, *auto_compaction_attempts) { return Err("context is still too large, but there is no new content to compact".into()); @@ -152,7 +222,7 @@ pub(super) async fn run_auto_compaction( cache_key.cloned(), *cache_stable_message_count, service_tier, - None, + user_instruction, cmd_rx, Some(summary_delta_tx), ) @@ -284,3 +354,31 @@ fn part_meta(part: &Part) -> Option<&Value> { | Part::ToolResult { meta, .. } => meta.as_ref(), } } + + +fn run_git_status(workspace_root: &std::path::Path) -> std::result::Result { + use std::process::Command; + let mut cmd = Command::new("git"); + cmd.arg("status").arg("--short"); + cmd.current_dir(workspace_root); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW + } + let output = cmd.output().map_err(|err| err.to_string())?; + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + } else { + Err(String::from_utf8_lossy(&output.stderr).into_owned()) + } +} + +async fn run_linter_checks(read_lints: &Arc) -> std::result::Result { + let result = read_lints.run(serde_json::json!({})).await; + if result.is_error { + Err(result.content) + } else { + Ok(result.content) + } +} diff --git a/crates/sinew-app/src/agent/context.rs b/crates/sinew-app/src/agent/context.rs index 4ffb0088..8889d23f 100644 --- a/crates/sinew-app/src/agent/context.rs +++ b/crates/sinew-app/src/agent/context.rs @@ -1,17 +1,18 @@ -use std::sync::Arc; +use std::{path::PathBuf, sync::Arc}; use tokio::sync::mpsc; use sinew_core::{ChatMessage, Provider, ServiceTier}; use crate::{ - BashTool, CreateImageTool, EditFileTool, GlobTool, GoalWorkflowState, GrepTool, - McpToolRegistry, QuestionTool, ReadTool, SkillTool, SubAgentTool, TeamTool, ToDoListTool, - TodoListState, ToolSettings, WebFetchTool, WebSearchTool, WriteFileTool, + BashTool, CheckSotaTool, CodebaseSearchTool, ComputerUseTool, CreateImageTool, DeleteFileTool, EditFileTool, + GlobTool, GoalWorkflowState, GrepTool, ListDirTool, McpToolRegistry, QuestionTool, ReadLintsTool, + ReadTool, SkillTool, SubAgentTool, TeamTool, ToDoListTool, TodoListState, ToolSettings, WebFetchTool, + WebSearchTool, WriteFileTool, }; use super::{ - cancel::{EngineCommand, TurnCancel}, + cancel::{EngineCommand, SteeringCommand, TurnCancel}, events::{AgentEvent, AgentEventScope}, }; @@ -25,6 +26,7 @@ pub enum AgentMode { pub struct TurnContext { pub provider: Arc, + pub workspace_root: PathBuf, pub model: sinew_core::ModelRef, pub cache_key: Option, pub cache_stable_message_count: usize, @@ -38,10 +40,16 @@ pub struct TurnContext { pub goal_workflow: GoalWorkflowState, pub bash: Arc, pub glob: Arc, + pub list_dir: Arc, pub grep: Arc, + pub codebase_search: Arc, + pub check_sota: Arc, + pub computer_use: Arc, pub read: Arc, pub edit_file: Arc, pub write_file: Arc, + pub delete_file: Arc, + pub read_lints: Arc, pub create_image: Arc, pub todo_list_tool: Option>, pub question: Option>, @@ -57,6 +65,7 @@ pub struct TurnContext { pub event_tx: mpsc::UnboundedSender, pub cancel: TurnCancel, pub cmd_rx: mpsc::UnboundedReceiver, + pub steering_rx: Option>, } pub struct TurnOutput { diff --git a/crates/sinew-app/src/agent/events.rs b/crates/sinew-app/src/agent/events.rs index 13ff15d8..9b552266 100644 --- a/crates/sinew-app/src/agent/events.rs +++ b/crates/sinew-app/src/agent/events.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use serde::Serialize; +use serde::{Serialize, Deserialize}; use serde_json::{json, Map, Value}; use tokio::sync::mpsc; @@ -8,7 +8,7 @@ use sinew_core::{ChatMessage, ModelRef, Part, Provider, Usage}; use crate::tool_run::{FileChange, ToolRunImage}; -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum AgentEvent { TurnStarted, @@ -56,6 +56,10 @@ pub enum AgentEvent { max_output_tokens: u32, usage: Usage, }, + SteeringApplied { + id: String, + message: ChatMessage, + }, Interrupted, Error { message: String, diff --git a/crates/sinew-app/src/agent/history.rs b/crates/sinew-app/src/agent/history.rs index 1143cee8..8e39a80b 100644 --- a/crates/sinew-app/src/agent/history.rs +++ b/crates/sinew-app/src/agent/history.rs @@ -228,14 +228,15 @@ pub(super) fn successful_read_fingerprints( else { continue; }; - if name == "read" { + let name_without_prefix = name.strip_prefix("default_api:").unwrap_or(name); + if name_without_prefix == "read" { let Some(path) = input.get("path").and_then(|value| value.as_str()) else { continue; }; if let Ok(normalized) = read.normalize_path(path) { pending_reads.insert(id.clone(), Some(normalized)); } - } else if name == "edit_file" || name == "write_file" { + } else if name_without_prefix == "edit_file" || name_without_prefix == "write_file" { pending_reads.insert(id.clone(), None); } } diff --git a/crates/sinew-app/src/agent/repeat_guard.rs b/crates/sinew-app/src/agent/repeat_guard.rs new file mode 100644 index 00000000..de4c4837 --- /dev/null +++ b/crates/sinew-app/src/agent/repeat_guard.rs @@ -0,0 +1,202 @@ +//! Détection automatique des boucles d'outils (le maillon "Capture" du système +//! d'auto-amélioration). Quand l'agent rejoue la même commande / le même appel +//! d'outil sans progresser, on injecte un rappel ciblé, puis on enregistre +//! l'incident dans `errors_raw.json` et on coupe la boucle. La consolidation +//! (`rules.rs`) transformera ensuite ces incidents répétés en règles globales. + +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::PathBuf; + +use chrono::Local; +use serde_json::Value; + +use crate::tool_names; + +/// Nombre de répétitions identiques avant d'injecter un rappel fort. +const WARN_THRESHOLD: u32 = 3; +/// Nombre de répétitions identiques avant d'enregistrer l'erreur et de couper. +const BREAK_THRESHOLD: u32 = 4; +/// Longueur maximale du détail de commande conservé dans la signature/description. +const DETAIL_MAX: usize = 160; + +/// Incident de boucle détecté, prêt à être enregistré et à arrêter le tour. +pub struct RepeatHit { + pub id: String, + pub description: String, + pub count: u32, +} + +#[derive(Default)] +pub struct RepeatGuard { + counts: HashMap, + warned: HashSet, + pending_reminder: Option, + break_requested: Option, +} + +impl RepeatGuard { + pub fn new() -> Self { + Self::default() + } + + /// Observe le résultat d'un appel d'outil et met à jour les compteurs. + /// On ne suit que les actions à risque de boucle : commandes shell (`bash`) + /// et tout appel d'outil qui termine en erreur. + pub fn observe(&mut self, canonical_name: &str, input: &Value, is_error: bool) { + let is_bash = canonical_name == tool_names::BASH; + if !is_bash && !is_error { + return; + } + + let detail = signature_detail(canonical_name, input); + if detail.is_empty() { + return; + } + let kind = if is_error { "err" } else { "cmd" }; + let signature = format!("{kind}:{canonical_name}:{detail}"); + + let count = self.counts.entry(signature.clone()).or_insert(0); + *count += 1; + let count = *count; + + if count >= WARN_THRESHOLD && self.warned.insert(signature.clone()) { + self.pending_reminder = Some(format!( + "\n\n\n\ + BOUCLE DÉTECTÉE : vous venez de répéter {count} fois la même action sans progresser :\n\ + « {short} »\n\ + ARRÊTEZ de relancer cette commande/outil à l'identique. Identifiez la cause racine, \ + changez d'approche, ou demandez une précision si vous êtes bloqué. \ + Répéter encore sera enregistré comme une erreur et interrompra le tour.\n\ + ", + count = count, + short = truncate(&human_detail(canonical_name, input), DETAIL_MAX), + )); + } + + if count >= BREAK_THRESHOLD && self.break_requested.is_none() { + let human = human_detail(canonical_name, input); + self.break_requested = Some(RepeatHit { + id: error_id(canonical_name, &detail), + description: format!( + "L'agent a répété {count} fois la même action sans progresser : `{detail}`. \ + Détecté automatiquement (boucle d'outil {canonical_name}).", + count = count, + detail = truncate(&human, DETAIL_MAX), + canonical_name = canonical_name, + ), + count, + }); + } + } + + /// Renvoie (et consomme) le rappel à injecter dans le prompt système. + pub fn take_reminder(&mut self) -> Option { + self.pending_reminder.take() + } + + /// Renvoie (et consomme) une demande d'arrêt si une boucle est avérée. + pub fn take_break(&mut self) -> Option { + self.break_requested.take() + } +} + +/// Détail normalisé utilisé pour la signature (insensible à la casse/espaces). +fn signature_detail(canonical_name: &str, input: &Value) -> String { + let raw = human_detail(canonical_name, input); + let lowered = raw.to_lowercase(); + let collapsed: String = lowered.split_whitespace().collect::>().join(" "); + truncate(&collapsed, DETAIL_MAX) +} + +/// Détail lisible : pour bash, la commande ; sinon, l'input compact. +fn human_detail(canonical_name: &str, input: &Value) -> String { + if canonical_name == tool_names::BASH { + if let Some(cmd) = input.get("command").and_then(Value::as_str) { + return cmd.trim().to_string(); + } + } + match input { + Value::Object(_) | Value::Array(_) => { + serde_json::to_string(input).unwrap_or_default() + } + Value::String(s) => s.trim().to_string(), + other => other.to_string(), + } +} + +/// Génère un identifiant stable pour `errors_raw.json` à partir de la signature. +fn error_id(canonical_name: &str, detail: &str) -> String { + let token: String = detail + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { ' ' }) + .collect::() + .split_whitespace() + .take(3) + .collect::>() + .join("_"); + let token = if token.is_empty() { + "call".to_string() + } else { + token.to_lowercase() + }; + format!("repeated_{canonical_name}_{token}") +} + +fn truncate(s: &str, max: usize) -> String { + if s.chars().count() <= max { + return s.to_string(); + } + let mut out: String = s.chars().take(max).collect(); + out.push('…'); + out +} + +/// Enregistre (ou incrémente) un incident de boucle dans `errors_raw.json`. +/// Crée le fichier au besoin. Tolérant aux erreurs : ne panique jamais. +pub fn record_repeated_error(hit: &RepeatHit) { + let Ok(local_app_data) = std::env::var("LOCALAPPDATA") else { + return; + }; + let sinew_dir = PathBuf::from(local_app_data).join("Sinew"); + let _ = fs::create_dir_all(&sinew_dir); + let errors_path = sinew_dir.join("errors_raw.json"); + + let mut errors: Vec = match fs::read_to_string(&errors_path) { + Ok(data) => { + let clean = data.strip_prefix('\u{FEFF}').unwrap_or(&data); + serde_json::from_str(clean).unwrap_or_default() + } + Err(_) => Vec::new(), + }; + + let now = Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); + let mut found = false; + for entry in errors.iter_mut() { + if entry.get("id").and_then(Value::as_str) == Some(hit.id.as_str()) { + let count = entry.get("count").and_then(Value::as_i64).unwrap_or(0) + 1; + if let Value::Object(map) = entry { + map.insert("count".into(), Value::from(count)); + map.insert("description".into(), Value::from(hit.description.clone())); + map.insert("last_occurrence".into(), Value::from(now.clone())); + // Une nouvelle occurrence rouvre l'erreur pour reconsolidation. + map.insert("consolidated_at".into(), Value::Null); + } + found = true; + break; + } + } + + if !found { + errors.push(serde_json::json!({ + "id": hit.id, + "description": hit.description, + "count": 1, + "last_occurrence": now, + })); + } + + if let Ok(serialized) = serde_json::to_string_pretty(&errors) { + let _ = fs::write(&errors_path, format!("{serialized}\n")); + } +} diff --git a/crates/sinew-app/src/agent/tool_dispatch.rs b/crates/sinew-app/src/agent/tool_dispatch.rs index 3b4741fa..c6c5fc1e 100644 --- a/crates/sinew-app/src/agent/tool_dispatch.rs +++ b/crates/sinew-app/src/agent/tool_dispatch.rs @@ -4,9 +4,10 @@ use serde_json::Value; use tokio::sync::mpsc; use crate::{ - tool_names, BashTool, CreateImageTool, EditFileTool, GlobTool, GrepTool, McpToolRegistry, - QuestionTool, ReadFingerprint, ReadTool, SkillTool, SubAgentTool, TeamTool, ToDoListTool, - TodoListState, ToolRunResult, ToolSettings, WebFetchTool, WebSearchTool, WriteFileTool, + tool_names, BashTool, CheckSotaTool, CodebaseSearchTool, ComputerUseTool, CreateImageTool, DeleteFileTool, + EditFileTool, GlobTool, GrepTool, ListDirTool, McpToolRegistry, QuestionTool, ReadFingerprint, + ReadLintsTool, ReadTool, SkillTool, SubAgentTool, TeamTool, ToDoListTool, TodoListState, ToolRunResult, + ToolSettings, WebFetchTool, WebSearchTool, WriteFileTool, }; use super::{cancel::TurnCancel, context::AgentMode, events::AgentEvent}; @@ -28,10 +29,16 @@ pub(super) fn should_wait_for_cooperative_cancel( pub(super) async fn run_tool( bash: &BashTool, glob: &GlobTool, + list_dir: &ListDirTool, grep: &GrepTool, + codebase_search: &CodebaseSearchTool, + check_sota: &CheckSotaTool, + computer_use: &ComputerUseTool, read: &ReadTool, edit_file: &EditFileTool, write_file: &WriteFileTool, + delete_file: &DeleteFileTool, + read_lints: &ReadLintsTool, create_image: &CreateImageTool, todo_list_tool: Option<&ToDoListTool>, question: Option<&QuestionTool>, @@ -52,6 +59,13 @@ pub(super) async fn run_tool( input: Value, ) -> ToolRunResult { let canonical_name = tool_names::canonical_tool_name(name); + if name == "composer_unsupported_tool" { + let message = input + .get("message") + .and_then(Value::as_str) + .unwrap_or("Unsupported Composer tool"); + return ToolRunResult::err(message.to_string(), Vec::new()); + } if !tool_settings.is_enabled(canonical_name) { return ToolRunResult::err( format!("{canonical_name} is disabled in Settings"), @@ -64,20 +78,51 @@ pub(super) async fn run_tool( bash.run_input(input).await } else if canonical_name == tool_names::GLOB { glob.run(input).await + } else if canonical_name == tool_names::LIST_DIR { + list_dir.run(input).await } else if canonical_name == tool_names::GREP { grep.run(input).await + } else if canonical_name == tool_names::CODEBASE_SEARCH { + codebase_search.run(input).await + } else if canonical_name == tool_names::CHECK_SOTA { + check_sota.run(input).await + } else if canonical_name == tool_names::COMPUTER_USE { + computer_use.run(input).await } else if canonical_name == tool_names::READ { read.run(input).await } else if canonical_name == tool_names::EDIT_FILE { if mode == AgentMode::Plan { return ToolRunResult::err("edit_file is unavailable in Plan mode", Vec::new()); } - edit_file.run(input, read_fingerprints).await + let mut result = edit_file.run(input, read_fingerprints).await; + if !result.is_error { + let lints = read_lints.run(serde_json::json!({})).await; + if !lints.is_error && !lints.content.trim().is_empty() && !lints.content.contains("No linter errors found") { + result.content.push_str("\n\n[Auto-Lint Diagnostics (Self-Healing)]:\n"); + result.content.push_str(&lints.content); + } + } + result } else if canonical_name == tool_names::WRITE_FILE { if mode == AgentMode::Plan { return ToolRunResult::err("write_file is unavailable in Plan mode", Vec::new()); } - write_file.run(input, read_fingerprints).await + let mut result = write_file.run(input, read_fingerprints).await; + if !result.is_error { + let lints = read_lints.run(serde_json::json!({})).await; + if !lints.is_error && !lints.content.trim().is_empty() && !lints.content.contains("No linter errors found") { + result.content.push_str("\n\n[Auto-Lint Diagnostics (Self-Healing)]:\n"); + result.content.push_str(&lints.content); + } + } + result + } else if canonical_name == tool_names::DELETE_FILE { + if mode == AgentMode::Plan { + return ToolRunResult::err("delete_file is unavailable in Plan mode", Vec::new()); + } + delete_file.run(input).await + } else if canonical_name == tool_names::READ_LINTS { + read_lints.run(input).await } else if canonical_name == tool_names::CREATE_IMAGE { if mode == AgentMode::Plan { return ToolRunResult::err("create_image is unavailable in Plan mode", Vec::new()); diff --git a/crates/sinew-app/src/agent/tool_summary.rs b/crates/sinew-app/src/agent/tool_summary.rs index 770a2db5..a2cde059 100644 --- a/crates/sinew-app/src/agent/tool_summary.rs +++ b/crates/sinew-app/src/agent/tool_summary.rs @@ -44,6 +44,44 @@ pub(super) fn summarize_tool(name: &str, input: &Value) -> String { return format!("Read {path}"); } } + if name == tool_names::READ_LINTS { + if let Some(scope) = lint_paths_scope(input) { + return format!("Read lints in {scope}"); + } + return "Read lints".to_string(); + } + if name == tool_names::LIST_DIR { + if let Some(path) = input + .get("path") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty() && *value != ".") + { + return format!("List {path}"); + } + return "List directory".to_string(); + } + if name == tool_names::DELETE_FILE { + if let Some(path) = input.get("path").and_then(|value| value.as_str()) { + return format!("Delete {path}"); + } + return "Delete file".to_string(); + } + if name == tool_names::CODEBASE_SEARCH { + if let Some(query) = input + .get("query") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + let mut clipped = query.chars().take(48).collect::(); + if query.chars().count() > 48 { + clipped.push_str("..."); + } + return format!("Search codebase: {clipped}"); + } + return "Search codebase".to_string(); + } if name == tool_names::GREP { let scope = input .get("path") @@ -91,20 +129,20 @@ pub(super) fn summarize_tool(name: &str, input: &Value) -> String { if file_count == 1 { if let Some(path) = groups[0].get("path").and_then(Value::as_str) { if replacement_count > 1 { - return format!("Edit {path} · {replacement_count} replacements"); + return format!("Edit {path} - {replacement_count} replacements"); } return format!("Edit {path}"); } return if replacement_count > 1 { - format!("Edit file · {replacement_count} replacements") + format!("Edit file - {replacement_count} replacements") } else { "Edit file".to_string() }; } return if replacement_count > 0 { - format!("Edit files · {file_count} files · {replacement_count} replacements") + format!("Edit files - {file_count} files - {replacement_count} replacements") } else { - format!("Edit files · {file_count} files") + format!("Edit files - {file_count} files") }; } return "Edit file".to_string(); @@ -125,7 +163,7 @@ pub(super) fn summarize_tool(name: &str, input: &Value) -> String { return if count == 0 { "Clean context".to_string() } else { - format!("Clean context · {count} results") + format!("Clean context - {count} results") }; } if name == tool_names::UPDATE_GOAL { @@ -198,7 +236,7 @@ pub(super) fn summarize_tool(name: &str, input: &Value) -> String { .map(str::trim) .filter(|value| !value.is_empty()); if let (Some(server), Some(tool)) = (server, tool) { - return format!("Load {server} · {tool}"); + return format!("Load {server} - {tool}"); } return "Load MCP tool".to_string(); } @@ -209,7 +247,7 @@ pub(super) fn summarize_tool(name: &str, input: &Value) -> String { .map(str::trim) .filter(|value| !value.is_empty()) { - return format!("Load skill · {skill}"); + return format!("Load skill - {skill}"); } return "Load skill".to_string(); } @@ -220,7 +258,7 @@ pub(super) fn summarize_tool(name: &str, input: &Value) -> String { .map(str::trim) .filter(|value| !value.is_empty()) { - return format!("Sub-agent · {task}"); + return format!("Sub-agent - {task}"); } return "Sub-agent".to_string(); } @@ -241,11 +279,11 @@ pub(super) fn summarize_tool(name: &str, input: &Value) -> String { .map(str::trim) .filter(|value| !value.is_empty()); return match (team, agent, objective) { - (Some(team), Some(agent), _) => format!("Agent Swarm · restart @{agent} · {team}"), - (None, Some(agent), _) => format!("Agent Swarm · restart @{agent}"), - (Some(team), None, Some(objective)) => format!("Agent Swarm · {team} · {objective}"), - (Some(team), None, None) => format!("Agent Swarm · {team}"), - (None, None, Some(objective)) => format!("Agent Swarm · {objective}"), + (Some(team), Some(agent), _) => format!("Agent Swarm - restart @{agent} - {team}"), + (None, Some(agent), _) => format!("Agent Swarm - restart @{agent}"), + (Some(team), None, Some(objective)) => format!("Agent Swarm - {team} - {objective}"), + (Some(team), None, None) => format!("Agent Swarm - {team}"), + (None, None, Some(objective)) => format!("Agent Swarm - {objective}"), _ => "Agent Swarm".to_string(), }; } @@ -256,7 +294,7 @@ pub(super) fn summarize_tool(name: &str, input: &Value) -> String { .map(str::trim) .filter(|value| !value.is_empty()) { - return format!("Agent Swarm · {team}"); + return format!("Agent Swarm - {team}"); } return "Create Agent Swarm".to_string(); } @@ -273,8 +311,8 @@ pub(super) fn summarize_tool(name: &str, input: &Value) -> String { .map(str::trim) .filter(|value| !value.is_empty()); return match (teammate, task) { - (Some(teammate), Some(task)) => format!("Agent · @{teammate} · {task}"), - (Some(teammate), None) => format!("Agent · @{teammate}"), + (Some(teammate), Some(task)) => format!("Agent - @{teammate} - {task}"), + (Some(teammate), None) => format!("Agent - @{teammate}"), _ => "Agent teammate".to_string(), }; } @@ -285,7 +323,7 @@ pub(super) fn summarize_tool(name: &str, input: &Value) -> String { .map(str::trim) .filter(|value| !value.is_empty()) { - return format!("Message · {to}"); + return format!("Message - {to}"); } return "Send Agent Swarm message".to_string(); } @@ -296,7 +334,7 @@ pub(super) fn summarize_tool(name: &str, input: &Value) -> String { .map(str::trim) .filter(|value| !value.is_empty()) { - return format!("Task · create · {subject}"); + return format!("Task - create - {subject}"); } return "Create task".to_string(); } @@ -321,11 +359,11 @@ pub(super) fn summarize_tool(name: &str, input: &Value) -> String { .map(str::trim) .filter(|value| !value.is_empty()); return match (action, task_id, subject) { - (Some("create"), _, Some(subject)) => format!("Task · create · {subject}"), + (Some("create"), _, Some(subject)) => format!("Task - create - {subject}"), (Some(action @ ("update" | "claim" | "delete")), Some(task_id), _) => { - format!("Task · {action} · #{task_id}") + format!("Task - {action} - #{task_id}") } - (Some(action), _, _) => format!("Task · {action}"), + (Some(action), _, _) => format!("Task - {action}"), _ => "Task list".to_string(), }; } @@ -345,8 +383,8 @@ pub(super) fn summarize_tool(name: &str, input: &Value) -> String { .map(str::trim) .filter(|value| !value.is_empty()); return match (task_id, status) { - (Some(task_id), Some(status)) => format!("Task · #{task_id} · {status}"), - (Some(task_id), None) => format!("Task · #{task_id}"), + (Some(task_id), Some(status)) => format!("Task - #{task_id} - {status}"), + (Some(task_id), None) => format!("Task - #{task_id}"), _ => "Update task".to_string(), }; } @@ -424,6 +462,13 @@ fn grep_path_scope(value: &Value) -> Option { } } +fn lint_paths_scope(input: &Value) -> Option { + input + .get("paths") + .or_else(|| input.get("path")) + .and_then(grep_path_scope) +} + pub(super) fn display_mcp_server_name(value: &str) -> String { let trimmed = value.trim(); let Some(rest) = trimmed.get(3..) else { diff --git a/crates/sinew-app/src/agent/turn.rs b/crates/sinew-app/src/agent/turn.rs index c9266acd..f015f94f 100644 --- a/crates/sinew-app/src/agent/turn.rs +++ b/crates/sinew-app/src/agent/turn.rs @@ -1,11 +1,12 @@ use std::{ collections::{BTreeMap, BTreeSet}, - time::Duration, + time::{Duration, Instant}, }; use futures_util::StreamExt; use rand::Rng; use serde_json::{json, Map, Value}; +use tokio::sync::mpsc::error::TryRecvError; use tokio::{sync::mpsc, task::JoinHandle}; use uuid::Uuid; @@ -16,7 +17,7 @@ use sinew_core::{ use super::{ assistant_message::AssistantMessageBuilder, - cancel::EngineCommand, + cancel::{EngineCommand, SteeringCommand}, clean_context::{clean_context_descriptor, run_clean_context}, compaction::{ can_auto_compact_history, is_context_length_error, maybe_auto_compact_history, @@ -30,6 +31,8 @@ use super::{ successful_read_fingerprints, }, mode::{run_update_goal, system_prompt_for_turn, update_goal_descriptor}, + repeat_guard::{record_repeated_error, RepeatGuard}, + boost_distill::maybe_distill_tool_output, tool_dispatch::{run_tool, should_wait_for_cooperative_cancel}, tool_summary::{display_mcp_server_name, pretty_json, should_stream_tool_args, summarize_tool}, }; @@ -38,9 +41,65 @@ use crate::{system_prompt_with_todo, tool_names, ReadFingerprint, ToolRunResult} const SAFE_STREAM_MAX_RETRIES: usize = 5; +fn tool_result_from_composer_bridge_meta(meta: &Option) -> Option { + let Value::Object(map) = meta.as_ref()? else { + return None; + }; + let Value::Object(bridge) = map.get("composer_bridge")? else { + return None; + }; + let content = bridge + .get("content") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let is_error = bridge + .get("is_error") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + Some(if is_error { + ToolRunResult::err(content, Vec::new()) + } else { + ToolRunResult::ok(content, Vec::new()) + }) +} + +fn cursor_system_prompt( + base: &str, + mode: AgentMode, + goal_workflow: &crate::GoalWorkflowState, + plan_mode_prompt: &str, +) -> String { + let base = base.trim(); + match mode { + AgentMode::Act => format!( + "{base}\n\nWhen the user asks to generate or create an image, use the generate image tool." + ), + AgentMode::Plan => { + let plan = plan_mode_prompt.trim(); + if plan.is_empty() { + format!("{base}\n\nYou are in planning mode. Explore the codebase and propose a plan before making changes.") + } else { + format!("{base}\n\nYou are in planning mode.\n{plan}") + } + } + AgentMode::Goal => { + let mut prompt = format!( + "{base}\n\nWork autonomously toward the objective across multiple turns." + ); + if let Some(objective) = super::mode::goal_objective(goal_workflow) { + prompt.push_str("\n\nCurrent objective:\n"); + prompt.push_str(objective); + } + prompt + } + } +} + pub async fn run_turn(ctx: TurnContext) -> TurnOutput { let TurnContext { provider, + workspace_root, model, cache_key, mut cache_stable_message_count, @@ -54,10 +113,16 @@ pub async fn run_turn(ctx: TurnContext) -> TurnOutput { mut goal_workflow, bash, glob, + list_dir, grep, + codebase_search, + check_sota, + computer_use, read, edit_file, write_file, + delete_file, + read_lints, create_image, todo_list_tool, question, @@ -73,24 +138,62 @@ pub async fn run_turn(ctx: TurnContext) -> TurnOutput { event_tx, cancel, mut cmd_rx, + mut steering_rx, } = ctx; send_event(&event_tx, event_scope.as_ref(), AgentEvent::TurnStarted); + let turn_start = Instant::now(); strip_all_visible_tool_result_ids(&mut history); normalize_tool_call_inputs(&mut history); repair_missing_tool_results(&mut history); - mcp.refresh_catalog(&history).await; - let mut cancelled = false; + tokio::select! { + biased; + command = cmd_rx.recv() => { + if matches!(command, Some(EngineCommand::Cancel)) { + cancelled = true; + } + } + _ = mcp.refresh_catalog(&history) => {} + } + + let root_accepts_steering = steering_rx.is_some(); let mut compacted = false; let mut loops = 0usize; let mut auto_compaction_attempts = 0usize; let mut current_turn_tool_result_ids = BTreeSet::new(); + let mut repeat_guard = RepeatGuard::new(); let mut eager_tool_results = BTreeMap::>::new(); let mut read_fingerprints = successful_read_fingerprints(&history, &read); todo_list.normalize(); + if cancelled { + if root_accepts_steering { + cancel.close_steering(); + } + send_event(&event_tx, event_scope.as_ref(), AgentEvent::Interrupted); + send_event( + &event_tx, + event_scope.as_ref(), + AgentEvent::TurnFinished { duration_ms: None }, + ); + todo_list.normalize(); + return TurnOutput { + history, + todo_list, + goal_workflow, + interrupted: true, + compacted: false, + }; + } + 'conversation: loop { + drain_steering_commands( + &mut steering_rx, + &mut history, + &event_tx, + event_scope.as_ref(), + ); if let Some(teams) = &teams { if let Some(messages_prompt) = teams.drain_current_agent_messages_prompt().await { history.push(ChatMessage { @@ -108,7 +211,11 @@ pub async fn run_turn(ctx: TurnContext) -> TurnOutput { bash.input_descriptor(), glob.descriptor(), grep.descriptor(), + codebase_search.descriptor(), + check_sota.descriptor(), + computer_use.descriptor(), read.descriptor(), + read_lints.descriptor(), clean_context_descriptor(), web_search.descriptor(), web_fetch.descriptor(), @@ -137,6 +244,18 @@ pub async fn run_turn(ctx: TurnContext) -> TurnOutput { if let Some(teams) = &teams { tool_descriptors.extend(teams.descriptors()); } + let tool_descriptors = if model.provider == "cursor" { + let mut filtered = tool_descriptors + .into_iter() + .filter(|tool| tool_names::is_cursor_compatible_tool(&tool.name)) + .collect::>(); + if mode != AgentMode::Plan && tool_settings.is_enabled(tool_names::CREATE_IMAGE) { + filtered.push(crate::composer_mcp_descriptor(&create_image.descriptor())); + } + filtered + } else { + tool_descriptors + }; let tool_descriptors = tool_settings.apply_to_descriptors(tool_descriptors); let question_enabled = question.is_some() && tool_settings.is_enabled(tool_names::QUESTION); @@ -147,14 +266,61 @@ pub async fn run_turn(ctx: TurnContext) -> TurnOutput { current_system_prompt.push_str(&team_reminder); } } - let current_system_prompt = system_prompt_for_turn( - ¤t_system_prompt, - mode, - &goal_workflow, - tool_settings.plan_mode_prompt(), - ); + if loops >= 5 { + current_system_prompt.push_str( + "\n\n\n\ + Attention : Vous avez effectué plusieurs tours d'outils consécutifs. \ + Avant d'exécuter d'autres outils, vous DEVEZ prendre du recul, réfléchir sur vos tentatives précédentes \ + et expliquer précisément ce qui bloque et comment vous allez ajuster votre méthode.\n\ + " + ); + } + if let Some(reminder) = repeat_guard.take_reminder() { + current_system_prompt.push_str(&reminder); + } + let current_system_prompt = if model.provider == "cursor" { + cursor_system_prompt( + ¤t_system_prompt, + mode, + &goal_workflow, + tool_settings.plan_mode_prompt(), + ) + } else { + system_prompt_for_turn( + ¤t_system_prompt, + mode, + &goal_workflow, + tool_settings.plan_mode_prompt(), + ) + }; + + let current_provider_name = provider.name(); + let last_assistant_provider = history + .iter() + .rev() + .filter(|msg| msg.role == Role::Assistant) + .flat_map(|msg| &msg.parts) + .find_map(|part| { + let meta = match part { + Part::Text { meta, .. } => meta, + Part::Thinking { meta, .. } => meta, + Part::ToolCall { meta, .. } => meta, + _ => &None, + }; + meta.as_ref() + .and_then(|m| m.get("provider")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }); + + let provider_changed = if let Some(prev) = last_assistant_provider { + prev != current_provider_name + } else { + false + }; - if auto_compact { + if auto_compact || provider_changed { + let compaction_start = Instant::now(); match maybe_auto_compact_history( &provider, &model, @@ -169,14 +335,32 @@ pub async fn run_turn(ctx: TurnContext) -> TurnOutput { event_scope.as_ref(), &mut cmd_rx, &mut auto_compaction_attempts, + &workspace_root, + &read_lints, + &todo_list, ) .await { Ok(true) => { + let compaction_ms = compaction_start.elapsed().as_millis(); + tracing::debug!( + provider = provider.name(), + compaction_ms, + "auto compaction completed before turn" + ); compacted = true; continue; } - Ok(false) => {} + Ok(false) => { + let compaction_ms = compaction_start.elapsed().as_millis(); + if compaction_ms > 10 { + tracing::debug!( + provider = provider.name(), + compaction_ms, + "auto compaction check (no compaction needed)" + ); + } + } Err(err) => { send_event( &event_tx, @@ -188,12 +372,19 @@ pub async fn run_turn(ctx: TurnContext) -> TurnOutput { } } + drain_steering_commands( + &mut steering_rx, + &mut history, + &event_tx, + event_scope.as_ref(), + ); let request_history = history_with_current_tool_result_ids(&history, ¤t_turn_tool_result_ids); let request = ProviderRequest::new(model.clone(), request_history) .with_system(current_system_prompt.clone()) .with_tools(tool_descriptors.clone()) - .with_cache_stable_message_count(cache_stable_message_count); + .with_cache_stable_message_count(cache_stable_message_count) + .with_workspace_root(workspace_root.display().to_string()); let request = match &cache_key { Some(cache_key) => request.with_cache_key(cache_key.clone()), None => request, @@ -203,10 +394,35 @@ pub async fn run_turn(ctx: TurnContext) -> TurnOutput { None => request, }; + let loop_start = Instant::now(); let mut stream_retry_attempts = 0usize; let (message_builder, mut stop_reason, response_usage) = 'stream_attempt: loop { - let mut stream = match provider.stream(request.clone()).await { - Ok(stream) => stream, + let stream_setup_start = Instant::now(); + let stream_res = tokio::select! { + biased; + res = provider.stream(request.clone()) => Some(res), + command = cmd_rx.recv() => { + if matches!(command, Some(EngineCommand::Cancel)) { + cancelled = true; + break 'conversation; + } + None + } + }; + let Some(stream_res) = stream_res else { + continue 'stream_attempt; + }; + let mut stream = match stream_res { + Ok(stream) => { + let stream_setup_ms = stream_setup_start.elapsed().as_millis(); + tracing::debug!( + provider = provider.name(), + model = model.name, + stream_setup_ms, + "provider stream setup completed" + ); + stream + } Err(err) => { if should_retry_stream(&err, stream_retry_attempts) { stream_retry_attempts += 1; @@ -217,7 +433,16 @@ pub async fn run_turn(ctx: TurnContext) -> TurnOutput { error = %err, "retrying provider stream setup" ); - tokio::time::sleep(stream_retry_delay(stream_retry_attempts)).await; + tokio::select! { + biased; + command = cmd_rx.recv() => { + if matches!(command, Some(EngineCommand::Cancel)) { + cancelled = true; + break 'conversation; + } + } + _ = tokio::time::sleep(stream_retry_delay(stream_retry_attempts)) => {} + } continue 'stream_attempt; } @@ -225,6 +450,7 @@ pub async fn run_turn(ctx: TurnContext) -> TurnOutput { && is_context_length_error(&err) && can_auto_compact_history(&history, auto_compaction_attempts) { + let compaction_start = Instant::now(); match run_auto_compaction( &provider, &model, @@ -238,10 +464,20 @@ pub async fn run_turn(ctx: TurnContext) -> TurnOutput { event_scope.as_ref(), &mut cmd_rx, &mut auto_compaction_attempts, + &workspace_root, + &read_lints, + &todo_list, + None, ) .await { Ok(()) => { + let compaction_ms = compaction_start.elapsed().as_millis(); + tracing::debug!( + provider = provider.name(), + compaction_ms, + "auto compaction completed after stream error" + ); compacted = true; continue 'conversation; } @@ -276,6 +512,7 @@ pub async fn run_turn(ctx: TurnContext) -> TurnOutput { let mut stream_error = None; let mut saw_message_stop = false; let mut finalized_tool_calls = 0usize; + let mut first_token = true; loop { tokio::select! { @@ -290,7 +527,19 @@ pub async fn run_turn(ctx: TurnContext) -> TurnOutput { event = stream.next() => { let Some(event) = event else { break; }; let event = match event { - Ok(event) => event, + Ok(event) => { + if first_token { + first_token = false; + let first_token_ms = stream_setup_start.elapsed().as_millis(); + tracing::debug!( + provider = provider.name(), + model = model.name, + first_token_ms, + "first token received" + ); + } + event + } Err(err) => { stream_error = Some(err); break; @@ -409,7 +658,7 @@ pub async fn run_turn(ctx: TurnContext) -> TurnOutput { // Detect a silent stream close: the underlying SSE source returned `None` (or yielded // its last item) without ever emitting a `MessageStop`. This is the classic "OpenAI - // just stops without an error" symptom — usually a connection drop on the provider / + // just stops without an error" symptom — usually a connection drop on the provider / // edge proxy side. Surface it as an explicit stream error so the user gets feedback // and the normal recovery path (auto-compaction, etc.) is given a chance to run. if !cancelled && stream_error.is_none() && !saw_message_stop { @@ -439,7 +688,16 @@ pub async fn run_turn(ctx: TurnContext) -> TurnOutput { error = %err, "retrying provider stream" ); - tokio::time::sleep(stream_retry_delay(stream_retry_attempts)).await; + tokio::select! { + biased; + command = cmd_rx.recv() => { + if matches!(command, Some(EngineCommand::Cancel)) { + cancelled = true; + break 'conversation; + } + } + _ = tokio::time::sleep(stream_retry_delay(stream_retry_attempts)) => {} + } continue 'stream_attempt; } @@ -461,6 +719,10 @@ pub async fn run_turn(ctx: TurnContext) -> TurnOutput { event_scope.as_ref(), &mut cmd_rx, &mut auto_compaction_attempts, + &workspace_root, + &read_lints, + &todo_list, + None, ) .await { @@ -524,15 +786,37 @@ pub async fn run_turn(ctx: TurnContext) -> TurnOutput { history.push(assistant.clone()); } + let steering_after_assistant = if !matches!(stop_reason, StopReason::ToolUse) { + drain_steering_commands( + &mut steering_rx, + &mut history, + &event_tx, + event_scope.as_ref(), + ) + } else { + false + }; + if !matches!(stop_reason, StopReason::ToolUse) && !eager_tool_results.is_empty() { stop_reason = StopReason::ToolUse; } if !matches!(stop_reason, StopReason::ToolUse) { + if steering_after_assistant { + continue 'conversation; + } break; } if loops >= max_tool_rounds { abort_eager_tool_results(&mut eager_tool_results); + tracing::warn!( + provider = provider.name(), + model = model.name, + loops, + max_tool_rounds, + loop_elapsed_ms = loop_start.elapsed().as_millis(), + "tool loop limit reached" + ); send_event( &event_tx, event_scope.as_ref(), @@ -547,32 +831,56 @@ pub async fn run_turn(ctx: TurnContext) -> TurnOutput { let mut tool_results = Vec::new(); for part in &assistant.parts { if let Part::ToolCall { - id, name, input, .. + id, + name, + input, + meta, } = part { - let result = if name == "clean_context" { - run_clean_context(&mut history, input.clone(), ¤t_turn_tool_result_ids) + let result = if let Some(result) = tool_result_from_composer_bridge_meta(meta) { + result + } else if name == "clean_context" { + let tool_start = Instant::now(); + let result = run_clean_context(&mut history, input.clone(), ¤t_turn_tool_result_ids); + let tool_ms = tool_start.elapsed().as_millis(); + tracing::debug!(tool = name, tool_ms, "tool executed"); + result } else if name == "update_goal" { - run_update_goal(&mut goal_workflow, input.clone()) + let tool_start = Instant::now(); + let result = run_update_goal(&mut goal_workflow, input.clone()); + let tool_ms = tool_start.elapsed().as_millis(); + tracing::debug!(tool = name, tool_ms, "tool executed"); + result } else if let Some(handle) = eager_tool_results.remove(id) { - match handle.await { + let tool_start = Instant::now(); + let result = match handle.await { Ok(result) => result, Err(err) => { ToolRunResult::err(format!("write_file task failed: {err}"), Vec::new()) } - } + }; + let tool_ms = tool_start.elapsed().as_millis(); + tracing::debug!(tool = name, tool_ms, eager = true, "tool executed"); + result } else if should_wait_for_cooperative_cancel( name, subagents.as_ref(), teams.as_ref(), ) { + let tool_start = Instant::now(); let result = run_tool( &bash, &glob, + &list_dir, &grep, + &codebase_search, + &check_sota, + &computer_use, &read, &edit_file, &write_file, + &delete_file, + &read_lints, &create_image, todo_list_tool.as_deref(), question.as_deref(), @@ -593,13 +901,23 @@ pub async fn run_turn(ctx: TurnContext) -> TurnOutput { input.clone(), ) .await; + let tool_ms = tool_start.elapsed().as_millis(); + tracing::debug!(tool = name, tool_ms, "tool executed"); if matches!(cmd_rx.try_recv(), Ok(EngineCommand::Cancel)) { cancelled = true; abort_eager_tool_results(&mut eager_tool_results); + } else { + drain_steering_commands( + &mut steering_rx, + &mut history, + &event_tx, + event_scope.as_ref(), + ); } result } else { - tokio::select! { + let tool_start = Instant::now(); + let result = tokio::select! { biased; command = cmd_rx.recv() => { if matches!(command, Some(EngineCommand::Cancel)) { @@ -613,10 +931,16 @@ pub async fn run_turn(ctx: TurnContext) -> TurnOutput { result = run_tool( &bash, &glob, + &list_dir, &grep, + &codebase_search, + &check_sota, + &computer_use, &read, &edit_file, &write_file, + &delete_file, + &read_lints, &create_image, todo_list_tool.as_deref(), question.as_deref(), @@ -636,9 +960,13 @@ pub async fn run_turn(ctx: TurnContext) -> TurnOutput { name, input.clone(), ) => result, - } + }; + let tool_ms = tool_start.elapsed().as_millis(); + tracing::debug!(tool = name, tool_ms, "tool executed"); + result }; let canonical_name = tool_names::canonical_tool_name(name); + repeat_guard.observe(canonical_name, input, result.is_error); if (canonical_name == tool_names::READ || canonical_name == tool_names::EDIT_FILE || canonical_name == tool_names::WRITE_FILE) @@ -687,14 +1015,25 @@ pub async fn run_turn(ctx: TurnContext) -> TurnOutput { if canonical_name != tool_names::CLEAN_CONTEXT { current_turn_tool_result_ids.insert(id.clone()); } + // Boost Local : si actif, distille les grosses sorties (bash/web_fetch) + // pour le contexte du modèle. L'UI a déjà reçu la sortie complète. + let history_content = + match maybe_distill_tool_output(canonical_name, &result_content, result.is_error) + .await + { + Some(distilled) => distilled, + None => result_content.clone(), + }; tool_results.push(Part::ToolResult { tool_call_id: id.clone(), - content: result_content, + content: history_content, images: result_images .into_iter() .map(|image| ToolResultImage { media_type: image.media_type, - data: if canonical_name == tool_names::CREATE_IMAGE { + data: if canonical_name == tool_names::CREATE_IMAGE + && model.provider != "cursor" + { String::new() } else { image.data @@ -720,6 +1059,14 @@ pub async fn run_turn(ctx: TurnContext) -> TurnOutput { } if tool_results.is_empty() { + if drain_steering_commands( + &mut steering_rx, + &mut history, + &event_tx, + event_scope.as_ref(), + ) { + continue 'conversation; + } break; } @@ -734,9 +1081,35 @@ pub async fn run_turn(ctx: TurnContext) -> TurnOutput { role: Role::User, parts: tool_results, }); + let steering_after_tools = drain_steering_commands( + &mut steering_rx, + &mut history, + &event_tx, + event_scope.as_ref(), + ); if cancelled { break 'conversation; } + if let Some(hit) = repeat_guard.take_break() { + record_repeated_error(&hit); + tracing::warn!( + error_id = %hit.id, + count = hit.count, + "repeated tool loop detected, recorded and stopping turn" + ); + send_event( + &event_tx, + event_scope.as_ref(), + AgentEvent::Error { + message: format!( + "Boucle d'outil détectée et stoppée : la même action a été répétée {} fois. \ + Incident enregistré pour auto-amélioration ({}).", + hit.count, hit.id + ), + }, + ); + break 'conversation; + } if stop_after_question_result { stop_questions = true; history.push(ChatMessage { @@ -748,15 +1121,27 @@ pub async fn run_turn(ctx: TurnContext) -> TurnOutput { }); continue 'conversation; } + if steering_after_tools { + continue 'conversation; + } } + if root_accepts_steering { + cancel.close_steering(); + } if cancelled { send_event(&event_tx, event_scope.as_ref(), AgentEvent::Interrupted); } send_event( &event_tx, event_scope.as_ref(), - AgentEvent::TurnFinished { duration_ms: None }, + AgentEvent::TurnFinished { duration_ms: Some(turn_start.elapsed().as_millis() as i64) }, + ); + tracing::debug!( + total_turn_ms = turn_start.elapsed().as_millis(), + cancelled, + compacted, + "turn finished" ); todo_list.normalize(); TurnOutput { @@ -768,6 +1153,39 @@ pub async fn run_turn(ctx: TurnContext) -> TurnOutput { } } +fn drain_steering_commands( + steering_rx: &mut Option>, + history: &mut Vec, + event_tx: &mpsc::UnboundedSender, + event_scope: Option<&AgentEventScope>, +) -> bool { + let Some(rx) = steering_rx.as_mut() else { + return false; + }; + let mut changed = false; + loop { + match rx.try_recv() { + Ok(command) => { + let id = command.id; + let message = command.message; + history.push(message.clone()); + send_event( + event_tx, + event_scope, + AgentEvent::SteeringApplied { id, message }, + ); + changed = true; + } + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + *steering_rx = None; + break; + } + } + } + changed +} + pub(super) fn retain_cancelled_eager_parts( message: &mut ChatMessage, eager_tool_results: &BTreeMap>, @@ -824,8 +1242,24 @@ fn update_read_fingerprint_cache( } } +fn is_non_retryable_stream_err(err: &AppError) -> bool { + let msg = match err { + AppError::Auth(m) | AppError::Network(m) | AppError::Stream(m) => m.to_ascii_lowercase(), + AppError::Decode(m) => m.to_ascii_lowercase(), + AppError::RetryableStream { message, .. } => message.to_ascii_lowercase(), + _ => return false, + }; + msg.contains("unauthenticated") + || msg.contains("non authentifié") + || msg.contains("oauth") + || msg.contains("réglages → fournisseurs") + || msg.contains("not connected") + || msg.contains("n'est pas connecté") +} + fn should_retry_stream(err: &AppError, attempts: usize) -> bool { attempts < SAFE_STREAM_MAX_RETRIES + && !is_non_retryable_stream_err(err) && matches!( err, AppError::Network(_) diff --git a/crates/sinew-app/src/bash.rs b/crates/sinew-app/src/bash.rs index 2e1b90ff..bd77beb0 100644 --- a/crates/sinew-app/src/bash.rs +++ b/crates/sinew-app/src/bash.rs @@ -313,9 +313,9 @@ impl BashTool { ) -> Result { #[cfg(windows)] { - return spawn_windows_piped_session(command, cwd, max_lifetime, before, || { + spawn_windows_piped_session(command, cwd, max_lifetime, before, || { self.next_session_id.fetch_add(1, Ordering::Relaxed) - }); + }) } #[cfg(not(windows))] @@ -819,9 +819,15 @@ mod tests { let root = temp_workspace("interactive_session_accepts_input"); let tool = BashTool::new(&root); + let command = if cfg!(windows) { + "Write-Host -NoNewline 'Name: '; $name = [Console]::ReadLine(); Write-Host \"Hello $name\"" + } else { + "printf 'Name: '; read name; printf 'Hello %s\\n' \"$name\"" + }; + let started = tool .run(json!({ - "command": "printf 'Name: '; read name; printf 'Hello %s\\n' \"$name\"", + "command": command, "yield_time_ms": 500 })) .await; @@ -830,14 +836,26 @@ mod tests { assert!(started.content.contains("process still running")); let session_id = parse_session_id(&started.content); - let finished = tool + let mut finished = tool .run_input(json!({ "session_id": session_id, "input": "Ada\n", - "yield_time_ms": 1_000 + "yield_time_ms": 3_000 })) .await; + for _ in 0..5 { + if finished.is_error || finished.content.contains("[exit status:") { + break; + } + finished = tool + .run_input(json!({ + "session_id": session_id, + "yield_time_ms": 1_000 + })) + .await; + } + assert!(!finished.is_error, "{}", finished.content); assert!( finished.content.contains("Hello Ada"), diff --git a/crates/sinew-app/src/check_sota.rs b/crates/sinew-app/src/check_sota.rs new file mode 100644 index 00000000..b12f19b3 --- /dev/null +++ b/crates/sinew-app/src/check_sota.rs @@ -0,0 +1,305 @@ +use serde_json::{json, Value}; +use sinew_core::ToolDescriptor; +use std::path::PathBuf; +use std::process::Command as StdCommand; + +use crate::{tool_names, tool_run::ToolRunResult}; + +#[derive(Debug, Clone, Default)] +pub struct CheckSotaTool; + +impl CheckSotaTool { + pub fn new() -> Self { + Self + } + + pub fn descriptor(&self) -> ToolDescriptor { + ToolDescriptor { + name: tool_names::CHECK_SOTA.into(), + description: "Check the status of SOTA (State-of-the-Art) development tools and system dependencies on this machine (e.g. ripgrep/rg, git, python, cargo, node, npm). Use this to verify if everything is installed and working correctly.".into(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + } + } + + pub async fn run(&self, _input: Value) -> ToolRunResult { + let mut results = serde_json::Map::new(); + + // 1. Ripgrep (rg) + let rg_status = check_binary("rg"); + results.insert("ripgrep".into(), rg_status); + + // 2. Git + let git_status = check_binary("git"); + results.insert("git".into(), git_status); + + // 3. Python + let python_status = check_binary("python"); + results.insert("python".into(), python_status); + + // 3b. Pip (pip / pip3) + let mut pip_status = check_binary("pip"); + if !pip_status + .get("available") + .and_then(|a| a.as_bool()) + .unwrap_or(false) + { + let pip3_status = check_binary("pip3"); + if pip3_status + .get("available") + .and_then(|a| a.as_bool()) + .unwrap_or(false) + { + pip_status = pip3_status; + } + } + results.insert("pip".into(), pip_status); + + // 4. Cargo / Rust + let cargo_status = check_binary("cargo"); + results.insert("cargo".into(), cargo_status); + + // 4b. Rust Compiler (rustc) + let rustc_status = check_binary("rustc"); + results.insert("rustc".into(), rustc_status); + + // 5. Node + let node_status = check_binary("node"); + results.insert("node".into(), node_status); + + // 6. Npm + let npm_status = check_binary("npm"); + results.insert("npm".into(), npm_status); + + // 7. Sinew Browser Extension + let extension_status = check_sinew_chrome_bridge(); + results.insert("sinew-extension".into(), extension_status); + + // Compute overall SOTA status + let mut overall_ok = true; + for (key, val) in results.iter() { + if key == "sinew-extension" { + continue; + } + if let Some(available) = val.get("available").and_then(|a| a.as_bool()) { + if !available { + overall_ok = false; + } + } + } + + let output = json!({ + "status": if overall_ok { "ok" } else { "warning" }, + "message": if overall_ok { + "All SOTA development tools are fully installed and configured." + } else { + "Some SOTA development tools or dependencies are missing or could not be run." + }, + "tools": results + }); + + ToolRunResult::ok( + serde_json::to_string_pretty(&output).unwrap_or_default(), + Vec::new(), + ) + } +} + +fn check_sinew_chrome_bridge() -> Value { + let mut available = false; + let mut path = None; + let mut version = None; + let mut error = None; + + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + let mut cmd = StdCommand::new("reg"); + cmd.args(["query", "HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts\\com.sinew.chrome_bridge", "/ve"]); + cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW + match cmd.output() { + Ok(output) => { + if output.status.success() { + let out_str = String::from_utf8_lossy(&output.stdout); + if let Some(pos) = out_str.find("REG_SZ") { + let p = out_str[pos + 6..].trim().to_string(); + let p_buf = PathBuf::from(&p); + path = Some(p.clone()); + if p_buf.is_file() { + available = true; + // Check package.json in the same directory for version + let pkg_json_path = p_buf.parent().map(|parent| parent.join("package.json")); + if let Some(pkg_path) = pkg_json_path { + if pkg_path.is_file() { + if let Ok(content) = std::fs::read_to_string(pkg_path) { + if let Ok(pkg) = serde_json::from_str::(&content) { + if let Some(v) = pkg.get("version").and_then(|v| v.as_str()) { + version = Some(v.to_string()); + } + } + } + } + } + if version.is_none() { + version = Some("1.0.0".into()); + } + } else { + error = Some("Fichier manifeste de l'extension introuvable".to_string()); + } + } else { + error = Some("Impossible de lire le chemin dans la base de registre".to_string()); + } + } else { + error = Some("Extension Sinew non enregistrée ou non installée".to_string()); + } + } + Err(e) => { + error = Some(e.to_string()); + } + } + } + + #[cfg(target_os = "macos")] + { + let home = std::env::var("HOME").unwrap_or_default(); + let p_buf = PathBuf::from(home).join("Library/Application Support/Google/Chrome/NativeMessagingHosts/com.sinew.chrome_bridge.json"); + if p_buf.is_file() { + available = true; + path = Some(p_buf.display().to_string()); + // Try package.json in same directory + let pkg_json_path = p_buf.parent().map(|parent| parent.join("package.json")); + if let Some(pkg_path) = pkg_json_path { + if pkg_path.is_file() { + if let Ok(content) = std::fs::read_to_string(pkg_path) { + if let Ok(pkg) = serde_json::from_str::(&content) { + if let Some(v) = pkg.get("version").and_then(|v| v.as_str()) { + version = Some(v.to_string()); + } + } + } + } + } + if version.is_none() { + version = Some("1.0.0".into()); + } + } else { + error = Some("Extension Sinew non configurée ou non installée".to_string()); + } + } + + #[cfg(target_os = "linux")] + { + let home = std::env::var("HOME").unwrap_or_default(); + let p_buf = PathBuf::from(home).join(".config/google-chrome/NativeMessagingHosts/com.sinew.chrome_bridge.json"); + if p_buf.is_file() { + available = true; + path = Some(p_buf.display().to_string()); + // Try package.json in same directory + let pkg_json_path = p_buf.parent().map(|parent| parent.join("package.json")); + if let Some(pkg_path) = pkg_json_path { + if pkg_path.is_file() { + if let Ok(content) = std::fs::read_to_string(pkg_path) { + if let Ok(pkg) = serde_json::from_str::(&content) { + if let Some(v) = pkg.get("version").and_then(|v| v.as_str()) { + version = Some(v.to_string()); + } + } + } + } + } + if version.is_none() { + version = Some("1.0.0".into()); + } + } else { + error = Some("Extension Sinew non configurée ou non installée".to_string()); + } + } + + json!({ + "available": available, + "path": path, + "version": version, + "error": error + }) +} + +fn check_binary(name: &str) -> Value { + let path = find_in_path(name); + let available = path.is_some(); + + let mut version = None; + let mut error = None; + + if let Some(ref p) = path { + #[cfg(windows)] + let mut cmd = { + let is_batch = p.extension().is_some_and(|ext| ext == "cmd" || ext == "bat"); + if is_batch { + let mut c = StdCommand::new("cmd"); + c.arg("/C").arg(p); + c + } else { + StdCommand::new(p) + } + }; + + #[cfg(not(windows))] + let mut cmd = StdCommand::new(p); + + cmd.arg("--version"); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW + } + match cmd.output() { + Ok(output) => { + if output.status.success() { + version = Some(String::from_utf8_lossy(&output.stdout).trim().to_string()); + } else { + let err_msg = String::from_utf8_lossy(&output.stderr).trim().to_string(); + error = Some(format!( + "Exit status: {}. Error: {}", + output.status, err_msg + )); + } + } + Err(e) => { + error = Some(e.to_string()); + } + } + } else { + error = Some("Executable not found in system PATH".to_string()); + } + + json!({ + "available": available, + "path": path.map(|p| p.display().to_string()), + "version": version, + "error": error + }) +} + +fn find_in_path(name: &str) -> Option { + let paths = std::env::var_os("PATH")?; + let mut names = vec![]; + #[cfg(windows)] + { + if !name.ends_with(".exe") && !name.ends_with(".cmd") && !name.ends_with(".bat") { + names.push(format!("{name}.exe")); + names.push(format!("{name}.cmd")); + names.push(format!("{name}.bat")); + } + names.push(name.to_string()); + } + #[cfg(not(windows))] + { + names.push(name.to_string()); + } + std::env::split_paths(&paths) + .flat_map(|path| names.iter().map(move |n| path.join(n))) + .find(|path| path.is_file()) +} diff --git a/crates/sinew-app/src/codebase_search.rs b/crates/sinew-app/src/codebase_search.rs new file mode 100644 index 00000000..72cf2cf5 --- /dev/null +++ b/crates/sinew-app/src/codebase_search.rs @@ -0,0 +1,145 @@ +use std::path::PathBuf; + +use anyhow::{bail, Result}; +use serde::Deserialize; +use serde_json::{json, Value}; +use sinew_core::ToolDescriptor; +use sinew_index::{index_and_search_workspace_isolated, process_isolation_enabled}; + +use crate::{tool_names, tool_run::ToolRunResult}; + +const DEFAULT_LIMIT: usize = 30; +const MAX_LIMIT: usize = 50; + +#[derive(Debug, Clone)] +pub struct CodebaseSearchTool { + workspace_root: PathBuf, +} + +impl CodebaseSearchTool { + pub fn new(workspace_root: impl Into) -> Self { + Self { + workspace_root: workspace_root.into(), + } + } + + pub fn descriptor(&self) -> ToolDescriptor { + ToolDescriptor { + name: tool_names::CODEBASE_SEARCH.into(), + description: + "Search the local workspace index for relevant code chunks by meaning or keywords." + .into(), + input_schema: json!({ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Natural language or keyword query describing what to find in the codebase." + }, + "path": { + "type": "string", + "description": "Optional path prefix within the workspace (e.g. src/auth)." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": MAX_LIMIT, + "default": DEFAULT_LIMIT, + "description": "Maximum number of chunks to return." + } + }, + "required": ["query"], + "additionalProperties": false + }), + } + } + + pub async fn run(&self, input: Value) -> ToolRunResult { + match self.search(input).await { + Ok(output) => ToolRunResult::ok(output, Vec::new()), + Err(err) => ToolRunResult::err(err.to_string(), Vec::new()), + } + } + + async fn search(&self, input: Value) -> Result { + let parsed: CodebaseSearchInput = serde_json::from_value(input) + .map_err(|err| anyhow::anyhow!("invalid codebase_search input: {err}"))?; + let query = parsed.query.trim(); + if query.is_empty() { + bail!("query is required"); + } + let limit = parsed.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT); + let path_prefix = parsed + .path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + + let (stats, hits) = + index_and_search_workspace_isolated(&self.workspace_root, query, path_prefix, limit)?; + + let mut output = format!( + "index: {} files, {} chunks (updated {} files this sync, process isolation {})\nresults: {}\n\n", + stats.files_indexed, + stats.chunks_indexed, + stats.files_updated, + if process_isolation_enabled() { "on" } else { "off" }, + hits.len() + ); + if hits.is_empty() { + output.push_str("No matches."); + return Ok(output); + } + for hit in hits { + output.push_str(&format!( + "{}:{}-{} (score {:.2})\n{}\n\n", + hit.path, + hit.start_line, + hit.end_line, + hit.score, + hit.snippet.trim() + )); + } + Ok(output.trim_end().to_string()) + } +} + +#[derive(Debug, Deserialize)] +struct CodebaseSearchInput { + query: String, + #[serde(default)] + path: Option, + #[serde(default)] + limit: Option, +} + +#[cfg(test)] +mod tests { + use std::fs; + + use super::*; + + #[tokio::test] + async fn codebase_search_finds_indexed_symbols() { + let root = std::env::temp_dir().join(format!( + "sinew-codebase-search-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&root).unwrap(); + let root = root.canonicalize().expect("canonical temp workspace"); + fs::write(root.join("auth.rs"), "pub fn verify_session_token() {}\n").unwrap(); + + let tool = CodebaseSearchTool::new(&root); + let result = tool + .search(json!({ "query": "verify" })) + .await + .expect("search should succeed"); + + assert!(result.contains("auth.rs")); + assert!(result.contains("verify")); + let _ = fs::remove_dir_all(root); + } +} diff --git a/crates/sinew-app/src/computer_use.rs b/crates/sinew-app/src/computer_use.rs new file mode 100644 index 00000000..10138e31 --- /dev/null +++ b/crates/sinew-app/src/computer_use.rs @@ -0,0 +1,457 @@ +use std::ptr::null_mut; +use serde_json::{json, Value}; +use sinew_core::ToolDescriptor; +use crate::{tool_names, tool_run::{ToolRunImage, ToolRunResult}}; +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; + +#[derive(Debug, Clone, Default)] +pub struct ComputerUseTool; + +#[repr(C)] +#[derive(Clone, Copy)] +struct BITMAPINFOHEADER { + bi_size: u32, + bi_width: i32, + bi_height: i32, + bi_planes: u16, + bi_bit_count: u16, + bi_compression: u32, + bi_size_image: u32, + bi_x_pels_per_meter: i32, + bi_y_pels_per_meter: i32, + bi_clr_used: u32, + bi_clr_important: u32, +} + +#[repr(C)] +#[derive(Clone, Copy)] +struct RGBQUAD { + rgb_blue: u8, + rgb_green: u8, + rgb_red: u8, + rgb_reserved: u8, +} + +#[repr(C)] +struct BITMAPINFO { + bmi_header: BITMAPINFOHEADER, + bmi_colors: [RGBQUAD; 1], +} + +#[repr(C)] +struct POINT { + x: i32, + y: i32, +} + +#[cfg(target_os = "windows")] +#[link(name = "user32")] +extern "system" { + fn GetDC(hwnd: *mut std::ffi::c_void) -> *mut std::ffi::c_void; + fn ReleaseDC(hwnd: *mut std::ffi::c_void, hdc: *mut std::ffi::c_void) -> i32; + fn GetSystemMetrics(n_index: i32) -> i32; + fn SetCursorPos(x: i32, y: i32) -> i32; + fn mouse_event(dw_flags: u32, dx: i32, dy: i32, dw_data: u32, dw_extra_info: usize); + fn keybd_event(b_vk: u8, b_scan: u8, dw_flags: u32, dw_extra_info: usize); + fn GetCursorPos(lp_point: *mut POINT) -> i32; + fn VkKeyScanW(ch: u16) -> i16; +} + +#[cfg(target_os = "windows")] +#[link(name = "gdi32")] +extern "system" { + fn CreateCompatibleDC(hdc: *mut std::ffi::c_void) -> *mut std::ffi::c_void; + fn CreateCompatibleBitmap(hdc: *mut std::ffi::c_void, cx: i32, cy: i32) -> *mut std::ffi::c_void; + fn SelectObject(hdc: *mut std::ffi::c_void, hgdiobj: *mut std::ffi::c_void) -> *mut std::ffi::c_void; + fn BitBlt(hdc_dest: *mut std::ffi::c_void, x_dest: i32, y_dest: i32, width: i32, height: i32, hdc_src: *mut std::ffi::c_void, x_src: i32, y_src: i32, dw_rop: u32) -> i32; + fn DeleteDC(hdc: *mut std::ffi::c_void) -> i32; + fn DeleteObject(ho: *mut std::ffi::c_void) -> i32; + fn GetDIBits(hdc: *mut std::ffi::c_void, hbm: *mut std::ffi::c_void, start: u32, lines: u32, lpv_bits: *mut u8, lpbmi: *mut BITMAPINFO, usage: u32) -> i32; +} + +#[cfg(target_os = "windows")] +const SRCCOPY: u32 = 0x00CC0020; +#[cfg(target_os = "windows")] +const DIB_RGB_COLORS: u32 = 0; +#[cfg(target_os = "windows")] +const BI_RGB: u32 = 0; + +impl ComputerUseTool { + pub fn new() -> Self { + Self + } + + pub fn descriptor(&self) -> ToolDescriptor { + ToolDescriptor { + name: tool_names::COMPUTER_USE.into(), + description: "Control the Windows desktop. Take screenshots, move mouse, click, type text, or press keyboard shortcuts.".into(), + input_schema: json!({ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "screenshot", + "mouse_move", + "left_click", + "right_click", + "middle_click", + "double_click", + "left_click_drag", + "type", + "key", + "cursor_position" + ], + "description": "The computer use action to perform." + }, + "coordinate": { + "type": "array", + "items": { "type": "integer" }, + "minItems": 2, + "maxItems": 2, + "description": "Optional [x, y] screen coordinates for mouse action." + }, + "text": { + "type": "string", + "description": "Text to type or key name to press." + } + }, + "required": ["action"], + "additionalProperties": false + }), + } + } + + #[cfg(not(target_os = "windows"))] + pub async fn run(&self, _input: Value) -> ToolRunResult { + ToolRunResult::err("Computer Use is only supported on Windows.", Vec::new()) + } + + #[cfg(target_os = "windows")] + pub async fn run(&self, input: Value) -> ToolRunResult { + let action = input.get("action").and_then(Value::as_str).unwrap_or(""); + let coordinate = input.get("coordinate").and_then(Value::as_array); + let text = input.get("text").and_then(Value::as_str).unwrap_or(""); + + // If coordinates are provided for a mouse action, move cursor first + if let Some(coords) = coordinate { + if coords.len() >= 2 { + let x = coords[0].as_i64().unwrap_or(0) as i32; + let y = coords[1].as_i64().unwrap_or(0) as i32; + unsafe { + SetCursorPos(x, y); + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + } + + match action { + "screenshot" => { + match self.capture_screen_jpeg() { + Ok((width, height, jpeg_bytes)) => { + let base64_data = BASE64_STANDARD.encode(&jpeg_bytes); + let img = ToolRunImage { + media_type: "image/jpeg".to_string(), + data: base64_data, + path: None, + }; + ToolRunResult::ok_with_images( + format!("Screenshot captured. Screen resolution: {}x{}", width, height), + vec![img], + Vec::new() + ) + } + Err(err) => ToolRunResult::err(format!("Failed to capture screenshot: {}", err), Vec::new()) + } + } + "mouse_move" => { + if let Some(coords) = coordinate { + let x = coords[0].as_i64().unwrap_or(0) as i32; + let y = coords[1].as_i64().unwrap_or(0) as i32; + ToolRunResult::ok(format!("Moved mouse to ({}, {})", x, y), Vec::new()) + } else { + ToolRunResult::err("action mouse_move requires coordinate argument", Vec::new()) + } + } + "left_click" => { + unsafe { + mouse_event(0x0002, 0, 0, 0, 0); // DOWN + std::thread::sleep(std::time::Duration::from_millis(50)); + mouse_event(0x0004, 0, 0, 0, 0); // UP + } + ToolRunResult::ok("Left click performed", Vec::new()) + } + "right_click" => { + unsafe { + mouse_event(0x0008, 0, 0, 0, 0); // DOWN + std::thread::sleep(std::time::Duration::from_millis(50)); + mouse_event(0x0010, 0, 0, 0, 0); // UP + } + ToolRunResult::ok("Right click performed", Vec::new()) + } + "middle_click" => { + unsafe { + mouse_event(0x0020, 0, 0, 0, 0); // DOWN + std::thread::sleep(std::time::Duration::from_millis(50)); + mouse_event(0x0040, 0, 0, 0, 0); // UP + } + ToolRunResult::ok("Middle click performed", Vec::new()) + } + "double_click" => { + unsafe { + mouse_event(0x0002, 0, 0, 0, 0); // DOWN + std::thread::sleep(std::time::Duration::from_millis(50)); + mouse_event(0x0004, 0, 0, 0, 0); // UP + std::thread::sleep(std::time::Duration::from_millis(150)); + mouse_event(0x0002, 0, 0, 0, 0); // DOWN + std::thread::sleep(std::time::Duration::from_millis(50)); + mouse_event(0x0004, 0, 0, 0, 0); // UP + } + ToolRunResult::ok("Double click performed", Vec::new()) + } + "left_click_drag" => { + if let Some(coords) = coordinate { + let x = coords[0].as_i64().unwrap_or(0) as i32; + let y = coords[1].as_i64().unwrap_or(0) as i32; + unsafe { + mouse_event(0x0002, 0, 0, 0, 0); // DOWN + std::thread::sleep(std::time::Duration::from_millis(50)); + SetCursorPos(x, y); + std::thread::sleep(std::time::Duration::from_millis(50)); + mouse_event(0x0004, 0, 0, 0, 0); // UP + } + ToolRunResult::ok(format!("Left click drag performed to ({}, {})", x, y), Vec::new()) + } else { + ToolRunResult::err("action left_click_drag requires coordinate argument", Vec::new()) + } + } + "type" => { + if text.is_empty() { + return ToolRunResult::err("action type requires text argument", Vec::new()); + } + match self.type_text(text) { + Ok(_) => ToolRunResult::ok(format!("Typed text: \"{}\"", text), Vec::new()), + Err(err) => ToolRunResult::err(format!("Failed to type text: {}", err), Vec::new()) + } + } + "key" => { + if text.is_empty() { + return ToolRunResult::err("action key requires key name in text argument", Vec::new()); + } + match self.press_key(text) { + Ok(_) => ToolRunResult::ok(format!("Pressed key: {}", text), Vec::new()), + Err(err) => ToolRunResult::err(format!("Failed to press key: {}", err), Vec::new()) + } + } + "cursor_position" => { + let mut pt = POINT { x: 0, y: 0 }; + let success = unsafe { GetCursorPos(&mut pt) }; + if success != 0 { + ToolRunResult::ok(format!("Cursor position: [{}, {}]", pt.x, pt.y), Vec::new()) + } else { + ToolRunResult::err("Failed to get cursor position", Vec::new()) + } + } + _ => ToolRunResult::err(format!("Unknown computer use action: {}", action), Vec::new()) + } + } + + #[cfg(target_os = "windows")] + fn capture_screen_jpeg(&self) -> anyhow::Result<(i32, i32, Vec)> { + unsafe { + let width = GetSystemMetrics(0); + let height = GetSystemMetrics(1); + if width <= 0 || height <= 0 { + anyhow::bail!("invalid screen dimensions: {}x{}", width, height); + } + + let hdc_screen = GetDC(null_mut()); + if hdc_screen.is_null() { + anyhow::bail!("failed to get screen DC"); + } + + let hdc_mem = CreateCompatibleDC(hdc_screen); + if hdc_mem.is_null() { + ReleaseDC(null_mut(), hdc_screen); + anyhow::bail!("failed to create compatible DC"); + } + + let h_bitmap = CreateCompatibleBitmap(hdc_screen, width, height); + if h_bitmap.is_null() { + DeleteDC(hdc_mem); + ReleaseDC(null_mut(), hdc_screen); + anyhow::bail!("failed to create compatible bitmap"); + } + + let h_old = SelectObject(hdc_mem, h_bitmap); + let success = BitBlt(hdc_mem, 0, 0, width, height, hdc_screen, 0, 0, SRCCOPY); + if success == 0 { + SelectObject(hdc_mem, h_old); + DeleteObject(h_bitmap); + DeleteDC(hdc_mem); + ReleaseDC(null_mut(), hdc_screen); + anyhow::bail!("failed BitBlt"); + } + + let mut bmi = BITMAPINFO { + bmi_header: BITMAPINFOHEADER { + bi_size: std::mem::size_of::() as u32, + bi_width: width, + bi_height: -height, + bi_planes: 1, + bi_bit_count: 32, + bi_compression: BI_RGB, + bi_size_image: 0, + bi_x_pels_per_meter: 0, + bi_y_pels_per_meter: 0, + bi_clr_used: 0, + bi_clr_important: 0, + }, + bmi_colors: [RGBQUAD { rgb_blue: 0, rgb_green: 0, rgb_red: 0, rgb_reserved: 0 }], + }; + + let mut buffer = vec![0u8; (width * height * 4) as usize]; + let lines_copied = GetDIBits( + hdc_mem, + h_bitmap, + 0, + height as u32, + buffer.as_mut_ptr(), + &mut bmi, + DIB_RGB_COLORS, + ); + + SelectObject(hdc_mem, h_old); + DeleteObject(h_bitmap); + DeleteDC(hdc_mem); + ReleaseDC(null_mut(), hdc_screen); + + if lines_copied == 0 { + anyhow::bail!("failed GetDIBits"); + } + + // Convert BGRA to RGBA + for chunk in buffer.chunks_exact_mut(4) { + let b = chunk[0]; + let r = chunk[2]; + chunk[0] = r; + chunk[2] = b; + } + + // Compress to JPEG using image crate + use image::ImageEncoder; + let mut jpeg_bytes = Vec::new(); + let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg_bytes, 85); + encoder.write_image( + &buffer, + width as u32, + height as u32, + image::ExtendedColorType::Rgba8, + )?; + + Ok((width, height, jpeg_bytes)) + } + } + + #[cfg(target_os = "windows")] + fn type_text(&self, text: &str) -> anyhow::Result<()> { + for c in text.encode_utf16() { + unsafe { + let res = VkKeyScanW(c); + if res == -1 { + continue; + } + let vk = (res & 0xFF) as u8; + let shift = (res >> 8) & 1 != 0; + let ctrl = (res >> 8) & 2 != 0; + let alt = (res >> 8) & 4 != 0; + + if shift { keybd_event(0x10, 0, 0, 0); } + if ctrl { keybd_event(0x11, 0, 0, 0); } + if alt { keybd_event(0x12, 0, 0, 0); } + + keybd_event(vk, 0, 0, 0); + keybd_event(vk, 0, 0x0002, 0); + + if alt { keybd_event(0x12, 0, 0x0002, 0); } + if ctrl { keybd_event(0x11, 0, 0x0002, 0); } + if shift { keybd_event(0x10, 0, 0x0002, 0); } + + std::thread::sleep(std::time::Duration::from_millis(15)); + } + } + Ok(()) + } + + #[cfg(target_os = "windows")] + fn press_key(&self, key: &str) -> anyhow::Result<()> { + let parts: Vec<&str> = key.split('+').collect(); + let mut vks = Vec::new(); + for part in parts { + if let Some(vk) = self.map_key_to_vk(part) { + vks.push(vk); + } else { + anyhow::bail!("unknown key name: {}", part); + } + } + + unsafe { + for &vk in &vks { + keybd_event(vk, 0, 0, 0); + } + for &vk in vks.iter().rev() { + keybd_event(vk, 0, 0x0002, 0); + } + } + Ok(()) + } + + #[cfg(target_os = "windows")] + fn map_key_to_vk(&self, key: &str) -> Option { + match key.to_lowercase().as_str() { + "enter" | "return" => Some(0x0D), + "esc" | "escape" => Some(0x1B), + "tab" => Some(0x09), + "backspace" => Some(0x08), + "space" => Some(0x20), + "up" => Some(0x26), + "down" => Some(0x28), + "left" => Some(0x25), + "right" => Some(0x27), + "pgup" | "pageup" => Some(0x21), + "pgdn" | "pagedown" => Some(0x22), + "home" => Some(0x24), + "end" => Some(0x23), + "insert" => Some(0x2D), + "delete" => Some(0x2E), + "ctrl" | "control" => Some(0x11), + "alt" => Some(0x12), + "shift" => Some(0x10), + "win" | "super" | "command" => Some(0x5B), + "f1" => Some(0x70), + "f2" => Some(0x71), + "f3" => Some(0x72), + "f4" => Some(0x73), + "f5" => Some(0x74), + "f6" => Some(0x75), + "f7" => Some(0x76), + "f8" => Some(0x77), + "f9" => Some(0x78), + "f10" => Some(0x79), + "f11" => Some(0x7A), + "f12" => Some(0x7B), + _ => { + if key.len() == 1 { + let c = key.chars().next().unwrap(); + if c.is_ascii_alphabetic() { + return Some(c.to_ascii_uppercase() as u8); + } + if c.is_ascii_digit() { + return Some(c as u8); + } + } + None + } + } + } +} diff --git a/crates/sinew-app/src/delete_file.rs b/crates/sinew-app/src/delete_file.rs new file mode 100644 index 00000000..ec6a3cbb --- /dev/null +++ b/crates/sinew-app/src/delete_file.rs @@ -0,0 +1,103 @@ +use std::path::PathBuf; + +use anyhow::{bail, Result}; +use serde::Deserialize; +use serde_json::{json, Value}; +use sinew_core::ToolDescriptor; + +use crate::{ + tool_names, + tool_run::ToolRunResult, + workspace::{normalize_workspace_relative_path, resolve_workspace_path}, +}; + +#[derive(Debug, Clone)] +pub struct DeleteFileTool { + workspace_root: PathBuf, +} + +impl DeleteFileTool { + pub fn new(workspace_root: impl Into) -> Self { + Self { + workspace_root: workspace_root.into(), + } + } + + pub fn descriptor(&self) -> ToolDescriptor { + ToolDescriptor { + name: tool_names::DELETE_FILE.into(), + description: "Delete a file or empty directory inside the workspace.".into(), + input_schema: json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Relative path to the file or empty directory to delete." + } + }, + "required": ["path"], + "additionalProperties": false + }), + } + } + + pub async fn run(&self, input: Value) -> ToolRunResult { + match self.delete(input) { + Ok(output) => ToolRunResult::ok(output, Vec::new()), + Err(err) => ToolRunResult::err(err.to_string(), Vec::new()), + } + } + + fn delete(&self, input: Value) -> Result { + let parsed: DeleteFileInput = serde_json::from_value(input) + .map_err(|err| anyhow::anyhow!("invalid delete_file input: {err}"))?; + let relative = parsed.path.trim(); + if relative.is_empty() { + bail!("path is required"); + } + let relative = normalize_workspace_relative_path(relative)?; + let target = resolve_workspace_path(&self.workspace_root, &relative)?; + if !target.exists() { + bail!("path not found: {relative}"); + } + if target.is_dir() { + std::fs::remove_dir(&target) + .map_err(|err| anyhow::anyhow!("unable to delete directory `{relative}`: {err}"))?; + } else { + std::fs::remove_file(&target) + .map_err(|err| anyhow::anyhow!("unable to delete file `{relative}`: {err}"))?; + } + Ok(format!("deleted {relative}")) + } +} + +#[derive(Debug, Deserialize)] +struct DeleteFileInput { + path: String, +} + +#[cfg(test)] +mod tests { + use std::fs; + + use super::*; + + #[tokio::test] + async fn deletes_workspace_file() { + let root = std::env::temp_dir().join(format!( + "sinew-delete-file-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("tmp.txt"), "x").unwrap(); + let root = root.canonicalize().expect("canonical temp workspace"); + let tool = DeleteFileTool::new(&root); + let result = tool.run(json!({ "path": "tmp.txt" })).await; + assert!(!result.is_error); + assert!(!root.join("tmp.txt").exists()); + let _ = fs::remove_dir_all(root); + } +} diff --git a/crates/sinew-app/src/edit.rs b/crates/sinew-app/src/edit.rs index 164be8b3..df728a71 100644 --- a/crates/sinew-app/src/edit.rs +++ b/crates/sinew-app/src/edit.rs @@ -1,4 +1,4 @@ -use std::{ +use std::{ collections::{BTreeMap, HashMap}, fs, path::{Component, Path, PathBuf}, @@ -133,7 +133,10 @@ impl EditFileTool { let mut writes = Vec::new(); for group in grouped.values_mut() { - let expected = read_fingerprints.get(&group.relative_path).ok_or_else(|| { + let expected = read_fingerprints + .get(&group.relative_path) + .or_else(|| read_fingerprints.get(&group.relative_path.to_lowercase())) + .ok_or_else(|| { anyhow::anyhow!( "edit_file requires a successful read of {} before editing it", group.relative_path @@ -674,7 +677,7 @@ fn line_spans(text: &str) -> Vec { spans } -fn line_text<'a>(text: &'a str, span: LineSpan) -> &'a str { +fn line_text(text: &str, span: LineSpan) -> &str { &text[span.start..span.end] } @@ -735,8 +738,8 @@ fn block_anchor_matches(content: &str, find: &str) -> Vec { if line_text(content, spans[start]).trim() != first { continue; } - for end in start + 2..spans.len() { - if line_text(content, spans[end]).trim() == last { + for (end, span) in spans.iter().enumerate().skip(start + 2) { + if line_text(content, *span).trim() == last { candidates.push((start, end)); break; } @@ -1124,7 +1127,7 @@ fn next_char_boundary(text: &str, offset: usize) -> usize { fn apply_replacements(original: &str, replacements: &[PlannedReplacement]) -> String { let mut updated = original.to_string(); let mut sorted = replacements.iter().collect::>(); - sorted.sort_by(|left, right| right.start.cmp(&left.start)); + sorted.sort_by_key(|right| std::cmp::Reverse(right.start)); for replacement in sorted { updated.replace_range( @@ -1368,6 +1371,8 @@ fn resolve_existing_workspace_file(root: &Path, raw: &str) -> Result<(String, Pa }) .collect::>() .join("/"); + #[cfg(target_os = "windows")] + let relative = relative.to_lowercase(); if relative.is_empty() { bail!("path cannot be the workspace root"); } diff --git a/crates/sinew-app/src/editor_diagnostics.rs b/crates/sinew-app/src/editor_diagnostics.rs new file mode 100644 index 00000000..d9167c55 --- /dev/null +++ b/crates/sinew-app/src/editor_diagnostics.rs @@ -0,0 +1,65 @@ +use std::sync::{Arc, RwLock}; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorDiagnostic { + pub path: String, + pub line: u32, + pub column: u32, + pub end_line: u32, + pub end_column: u32, + pub severity: String, + pub message: String, + #[serde(default)] + pub source: String, +} + +#[derive(Debug, Default)] +pub struct EditorDiagnosticsStore { + pub updated_at_ms: i64, + pub diagnostics: Vec, +} + +impl EditorDiagnosticsStore { + pub fn replace(&mut self, diagnostics: Vec) { + self.updated_at_ms = current_time_ms(); + self.diagnostics = diagnostics; + } + + pub fn matching<'a>( + &'a self, + paths: Option<&[String]>, + ) -> impl Iterator { + let paths = paths.map(normalize_path_set); + self.diagnostics.iter().filter(move |diag| { + paths + .as_ref() + .map(|set| set.contains(&normalize_path(&diag.path))) + .unwrap_or(true) + }) + } +} + +pub type SharedEditorDiagnosticsStore = Arc>; + +pub fn new_editor_diagnostics_store() -> SharedEditorDiagnosticsStore { + Arc::new(RwLock::new(EditorDiagnosticsStore::default())) +} + +fn normalize_path_set(paths: &[String]) -> std::collections::HashSet { + paths.iter().map(|path| normalize_path(path)).collect() +} + +fn normalize_path(path: &str) -> String { + path.replace('\\', "/").trim_start_matches("./").to_string() +} + +fn current_time_ms() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as i64) + .unwrap_or(0) +} diff --git a/crates/sinew-app/src/image.rs b/crates/sinew-app/src/image.rs index 55c6b8db..06ff319d 100644 --- a/crates/sinew-app/src/image.rs +++ b/crates/sinew-app/src/image.rs @@ -25,10 +25,6 @@ use crate::tool_run::{FileChange, FileChangeKind, ToolRunImage, ToolRunResult}; const OPENAI_IMAGES_URL: &str = "https://api.openai.com/v1/images/generations"; const OPENAI_CODEX_RESPONSES_URL: &str = "https://chatgpt.com/backend-api/codex/responses"; -const GPT_IMAGE_MODEL: &str = "gpt-image-2"; -const NANO_BANANA_MODEL: &str = "gemini-3.1-flash-image-preview"; -const NANO_BANANA_URL: &str = - "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-image-preview:generateContent"; const OPENAI_SUBSCRIPTION_IMAGE_INSTRUCTIONS: &str = "You are Sinew, a concise coding assistant. When the user asks for an image, immediately call the image_generation tool with their prompt. Do not reply with text."; const REQUEST_TIMEOUT: Duration = Duration::from_secs(300); const USER_AGENT: &str = "sinew/0.1"; @@ -39,11 +35,22 @@ pub struct CreateImageTool { workspace_root: PathBuf, image_provider: ImageProvider, openai_image_use_subscription: bool, + gemini_image_use_subscription: bool, + openai_image_model: String, + gemini_image_model: String, openai_api_key: Option, nano_banana_api_key: Option, write_lock: Option>, } +pub fn composer_mcp_descriptor(base: &ToolDescriptor) -> ToolDescriptor { + ToolDescriptor { + name: "mcp__sinew__create_image".into(), + description: base.description.clone(), + input_schema: base.input_schema.clone(), + } +} + impl CreateImageTool { pub fn new(workspace_root: impl Into) -> Self { Self::with_api_key(workspace_root, None) @@ -54,6 +61,9 @@ impl CreateImageTool { workspace_root, ImageProvider::GptImage2, false, + false, + None, + None, api_key, None, ) @@ -63,9 +73,18 @@ impl CreateImageTool { workspace_root: impl Into, image_provider: ImageProvider, openai_image_use_subscription: bool, + gemini_image_use_subscription: bool, + openai_image_model: Option, + gemini_image_model: Option, openai_api_key: Option, nano_banana_api_key: Option, ) -> Self { + let openai_model = openai_image_model + .filter(|m| !m.trim().is_empty()) + .unwrap_or_else(|| "gpt-image-2".to_string()); + let gemini_model = gemini_image_model + .filter(|m| !m.trim().is_empty()) + .unwrap_or_else(|| "gemini-3.1-flash-image-preview".to_string()); Self { http: reqwest::Client::builder() .timeout(REQUEST_TIMEOUT) @@ -75,6 +94,9 @@ impl CreateImageTool { workspace_root: workspace_root.into(), image_provider, openai_image_use_subscription, + gemini_image_use_subscription, + openai_image_model: openai_model, + gemini_image_model: gemini_model, openai_api_key: normalize_configured_key(openai_api_key), nano_banana_api_key: normalize_configured_key(nano_banana_api_key), write_lock: None, @@ -227,7 +249,7 @@ impl CreateImageTool { } let mut body = Map::new(); - body.insert("model".into(), json!(GPT_IMAGE_MODEL)); + body.insert("model".into(), json!(self.openai_image_model)); body.insert("prompt".into(), json!(prompt)); body.insert("size".into(), json!(size)); body.insert("quality".into(), json!(quality)); @@ -308,7 +330,8 @@ impl CreateImageTool { } let mut output = format!( - "model: {GPT_IMAGE_MODEL}\nimages: {}\nsize: {size}\nquality: {quality}\nformat: {output_format}", + "model: {}\nimages: {}\nsize: {size}\nquality: {quality}\nformat: {output_format}", + self.openai_image_model, images.len() ); if let Some(request_id) = request_id { @@ -416,7 +439,8 @@ impl CreateImageTool { } let mut output = format!( - "model: {GPT_IMAGE_MODEL}\nsource: OpenAI subscription\nimages: {}\nsize: {size}\nquality: {quality}\nformat: {output_format}", + "model: {}\nsource: OpenAI subscription\nimages: {}\nsize: {size}\nquality: {quality}\nformat: {output_format}", + self.openai_image_model, images.len() ); let request_ids = generated @@ -496,6 +520,7 @@ impl CreateImageTool { .http .post(OPENAI_CODEX_RESPONSES_URL) .header(AUTHORIZATION, format!("Bearer {access_token}")) + .header("user-agent", "codex-cli") .header("openai-beta", "responses=experimental") .header(CONTENT_TYPE, "application/json") .header(ACCEPT, "text/event-stream"); @@ -538,10 +563,19 @@ impl CreateImageTool { bail!("prompt is required"); } - let api_key = load_nano_banana_api_key(self.nano_banana_api_key.as_deref())?; let aspect_ratio = normalize_aspect_ratio(parsed.aspect_ratio.as_deref())?; let image_size = normalize_image_size(parsed.image_size.as_deref())?; + let model = if self.gemini_image_model.is_empty() { + "gemini-3.1-flash-image-preview" + } else { + &self.gemini_image_model + }; + let url = format!( + "https://generativelanguage.googleapis.com/v1beta/models/{}:generateContent", + model + ); + let body = json!({ "contents": [{ "parts": [{ "text": prompt }] @@ -558,16 +592,34 @@ impl CreateImageTool { } } }); - let response = self - .http - .post(NANO_BANANA_URL) - .header("x-goog-api-key", api_key) - .header(CONTENT_TYPE, "application/json") - .header(ACCEPT, "application/json") - .json(&body) - .send() - .await - .context("Nano Banana 2 image request failed")?; + let response = if self.gemini_image_use_subscription { + let credential = sinew_google::Credential::load_default()?.ok_or_else(|| { + anyhow::anyhow!( + "Gemini subscription image generation requires Google to be connected in Settings > Providers." + ) + })?; + let token = credential.bearer(&self.http).await?; + self.http + .post(&url) + .bearer_auth(token) + .header(CONTENT_TYPE, "application/json") + .header(ACCEPT, "application/json") + .json(&body) + .send() + .await + .context("Nano Banana 2 subscription image request failed")? + } else { + let api_key = load_nano_banana_api_key(self.nano_banana_api_key.as_deref())?; + self.http + .post(&url) + .header("x-goog-api-key", api_key) + .header(CONTENT_TYPE, "application/json") + .header(ACCEPT, "application/json") + .json(&body) + .send() + .await + .context("Nano Banana 2 image request failed")? + }; let status = response.status(); let request_id = response @@ -644,7 +696,8 @@ impl CreateImageTool { } let mut output = format!( - "model: {NANO_BANANA_MODEL}\nimages: {}\naspect_ratio: {aspect_ratio}\nimage_size: {image_size}\nthinking_level: high", + "model: {}\nimages: {}\naspect_ratio: {aspect_ratio}\nimage_size: {image_size}\nthinking_level: high", + self.gemini_image_model, images.len() ); if let Some(request_id) = request_id { diff --git a/crates/sinew-app/src/lib.rs b/crates/sinew-app/src/lib.rs index 7b0ebe80..fba820f1 100644 --- a/crates/sinew-app/src/lib.rs +++ b/crates/sinew-app/src/lib.rs @@ -1,13 +1,20 @@ pub mod agent; pub mod bash; +pub mod check_sota; +pub mod codebase_search; +pub mod computer_use; pub mod compact; +pub mod delete_file; +pub mod editor_diagnostics; pub mod edit; pub mod glob; pub mod grep; pub mod image; +pub mod list_dir; pub mod mcp; pub mod question; pub mod read; +pub mod read_lints; mod ripgrep; pub mod skill; pub mod store; @@ -24,24 +31,34 @@ pub mod write; pub use agent::{ clean_context_descriptor, run_turn, system_prompt_for_mode, system_prompt_for_mode_with_plan_prompt, AgentEvent, AgentEventScope, AgentMode, - ConversationEvent, EngineCommand, QuestionReply, TurnCancel, TurnContext, + ConversationEvent, EngineCommand, QuestionReply, TurnCancel, TurnContext, TurnOutput, }; pub use bash::{active_shell_display_name, shell_system_prompt, BashTool}; +pub use codebase_search::CodebaseSearchTool; +pub use check_sota::CheckSotaTool; +pub use computer_use::ComputerUseTool; pub use compact::{compact_conversation_history, CompactConversationOutput}; +pub use delete_file::DeleteFileTool; +pub use editor_diagnostics::{ + new_editor_diagnostics_store, EditorDiagnostic, EditorDiagnosticsStore, + SharedEditorDiagnosticsStore, +}; pub use edit::EditFileTool; pub use glob::GlobTool; pub use grep::GrepTool; -pub use image::CreateImageTool; +pub use image::{composer_mcp_descriptor, CreateImageTool}; +pub use list_dir::ListDirTool; pub use mcp::{probe_mcp_servers, McpServerProbe, McpSettings, McpToolRegistry}; pub use question::QuestionTool; pub use read::{ReadFingerprint, ReadTool}; +pub use read_lints::ReadLintsTool; pub use skill::{ create_installed_skill, list_installed_skills, InstalledSkill, SkillConfig, SkillSettings, SkillSource, SkillTool, }; pub use store::{ tool_settings_view, AppStore, ConversationSummary, GoalWorkflowState, ModeModelSettings, - OpenRouterModelRecord, PlanArtifactState, PlanWorkflowState, SavedConversation, ToolConfig, + OpenRouterModelRecord, OtherWorkspaceSummary, PlanArtifactState, PlanWorkflowState, SavedConversation, ToolConfig, ToolConfigView, ToolSettings, ToolSettingsView, TurnCheckpointRecord, WebSearchProvider, WorkspaceBootstrap, DEFAULT_PLAN_MODE_PROMPT, }; @@ -61,12 +78,12 @@ pub use tool_run::{ pub use web::{WebFetchTool, WebSearchTool}; pub use workspace::{ copy_workspace_entries, create_workspace_directory, create_workspace_file, - delete_workspace_entry, import_workspace_paths, list_workspace_entries, list_workspace_files, - normalize_workspace_root, read_external_file, read_workspace_file, rename_workspace_entry, - resolve_terminal_path, restore_workspace_deleted_entries, search_workspace_files, - trash_workspace_entry, write_workspace_file, FileDocument, ImportedEntry, - TerminalPathResolution, WorkspaceCopyOperation, WorkspaceDeletedEntry, WorkspaceEntry, - WorkspaceEntryKind, WorkspaceFileChangeEvent, WorkspaceInfo, WorkspaceSearchFile, - WorkspaceSearchMatch, WorkspaceSearchResult, + codebase_index_status, delete_workspace_entry, import_workspace_paths, list_workspace_entries, + list_workspace_files, normalize_workspace_root, read_external_file, read_workspace_file, + rename_workspace_entry, resolve_terminal_path, restore_workspace_deleted_entries, + search_workspace_files, trash_workspace_entry, write_workspace_file, CodebaseIndexStatus, + FileDocument, ImportedEntry, TerminalPathResolution, WorkspaceCopyOperation, + WorkspaceDeletedEntry, WorkspaceEntry, WorkspaceEntryKind, WorkspaceFileChangeEvent, + WorkspaceInfo, WorkspaceSearchFile, WorkspaceSearchMatch, WorkspaceSearchResult, }; pub use write::WriteFileTool; diff --git a/crates/sinew-app/src/list_dir.rs b/crates/sinew-app/src/list_dir.rs new file mode 100644 index 00000000..19a73842 --- /dev/null +++ b/crates/sinew-app/src/list_dir.rs @@ -0,0 +1,144 @@ +use std::path::PathBuf; + +use anyhow::{bail, Context, Result}; +use serde::Deserialize; +use serde_json::{json, Value}; +use sinew_core::ToolDescriptor; + +use crate::{ + tool_names, + tool_run::ToolRunResult, + workspace::{normalize_workspace_relative_path, resolve_workspace_path}, +}; + +const MAX_ENTRIES: usize = 500; + +#[derive(Debug, Clone)] +pub struct ListDirTool { + workspace_root: PathBuf, +} + +impl ListDirTool { + pub fn new(workspace_root: impl Into) -> Self { + Self { + workspace_root: workspace_root.into(), + } + } + + pub fn descriptor(&self) -> ToolDescriptor { + ToolDescriptor { + name: tool_names::LIST_DIR.into(), + description: "List files and directories in a single workspace directory (non-recursive).".into(), + input_schema: json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Directory to list. Relative to the workspace root. Defaults to the workspace root." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": MAX_ENTRIES, + "description": "Maximum entries to return." + } + }, + "additionalProperties": false + }), + } + } + + pub async fn run(&self, input: Value) -> ToolRunResult { + match self.list(input) { + Ok(output) => ToolRunResult::ok(output, Vec::new()), + Err(err) => ToolRunResult::err(err.to_string(), Vec::new()), + } + } + + fn list(&self, input: Value) -> Result { + let parsed: ListDirInput = serde_json::from_value(input) + .map_err(|err| anyhow::anyhow!("invalid list_dir input: {err}"))?; + let limit = parsed.limit.unwrap_or(MAX_ENTRIES).clamp(1, MAX_ENTRIES); + let relative = parsed + .path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("."); + let relative = normalize_workspace_relative_path(relative)?; + let target = if relative.is_empty() { + self.workspace_root + .canonicalize() + .with_context(|| format!("unable to resolve workspace root {}", self.workspace_root.display()))? + } else { + resolve_workspace_path(&self.workspace_root, &relative)? + }; + if !target.is_dir() { + bail!("path is not a directory: {relative}"); + } + + let mut dirs = Vec::new(); + let mut files = Vec::new(); + for entry in std::fs::read_dir(&target)? { + let entry = entry?; + let file_name = entry.file_name().to_string_lossy().into_owned(); + if file_name.starts_with('.') && file_name != ".git" { + continue; + } + let file_type = entry.file_type()?; + if file_type.is_dir() { + dirs.push(format!("{file_name}/")); + } else if file_type.is_file() || file_type.is_symlink() { + files.push(file_name); + } + } + dirs.sort(); + files.sort(); + + let mut lines = Vec::new(); + for name in dirs.into_iter().chain(files).take(limit) { + lines.push(name); + } + if lines.is_empty() { + return Ok(format!("{relative} is empty")); + } + Ok(lines.join("\n")) + } +} + +#[derive(Debug, Deserialize)] +struct ListDirInput { + #[serde(default)] + path: Option, + #[serde(default)] + limit: Option, +} + +#[cfg(test)] +mod tests { + use std::fs; + + use super::*; + + #[tokio::test] + async fn lists_single_directory_level() { + let root = std::env::temp_dir().join(format!( + "sinew-list-dir-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write(root.join("Cargo.toml"), "").unwrap(); + let root = root.canonicalize().expect("canonical temp workspace"); + let tool = ListDirTool::new(&root); + let result = tool + .run(json!({ "path": "." })) + .await; + assert!(!result.is_error); + assert!(result.content.contains("src/")); + assert!(result.content.contains("Cargo.toml")); + let _ = fs::remove_dir_all(root); + } +} diff --git a/crates/sinew-app/src/mcp.rs b/crates/sinew-app/src/mcp.rs index 64c97a02..ccf30b52 100644 --- a/crates/sinew-app/src/mcp.rs +++ b/crates/sinew-app/src/mcp.rs @@ -7,7 +7,7 @@ use std::{ path::{Path, PathBuf}, process::Stdio, sync::OnceLock, - time::Duration, + time::{Duration, Instant}, }; use anyhow::{anyhow, bail, Context, Result}; @@ -15,7 +15,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use sinew_core::{ChatMessage, Part, ToolDescriptor}; use tokio::{ - io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}, + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, process::{Child, ChildStdin, ChildStdout, Command}, sync::RwLock, time::timeout, @@ -41,7 +41,7 @@ pub struct McpSettings { pub servers: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct McpServerConfig { pub id: String, @@ -55,9 +55,11 @@ pub struct McpServerConfig { pub cwd: Option, #[serde(default = "default_enabled")] pub enabled: bool, + #[serde(default)] + pub auto_load: bool, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct McpEnvVar { pub key: String, @@ -105,7 +107,54 @@ pub struct McpToolLabel { pub tool_name: String, } -#[derive(Debug)] +use std::sync::Mutex as StdMutex; + +#[derive(Debug, Clone)] +struct McpToolsCacheEntry { + result: Result, String>, + fetched_at: Instant, + config_hash: u64, +} + +static MCP_TOOLS_CACHE: OnceLock>> = OnceLock::new(); + +fn get_mcp_tools_cache() -> &'static StdMutex> { + MCP_TOOLS_CACHE.get_or_init(|| StdMutex::new(HashMap::new())) +} + +fn calculate_config_hash(config: &McpServerConfig) -> u64 { + let mut hasher = DefaultHasher::new(); + config.hash(&mut hasher); + hasher.finish() +} + +async fn get_cached_mcp_tools(config: &McpServerConfig) -> Option, String>> { + let cache = get_mcp_tools_cache(); + let lock = cache.lock().ok()?; + let entry = lock.get(&config.id)?; + let hash = calculate_config_hash(config); + if entry.config_hash == hash && entry.fetched_at.elapsed() < Duration::from_secs(30) { + Some(entry.result.clone()) + } else { + None + } +} + +async fn set_cached_mcp_tools(config: &McpServerConfig, result: Result, String>) { + let cache = get_mcp_tools_cache(); + if let Ok(mut lock) = cache.lock() { + let hash = calculate_config_hash(config); + lock.insert( + config.id.clone(), + McpToolsCacheEntry { + result, + fetched_at: Instant::now(), + config_hash: hash, + }, + ); + } +} + pub struct McpToolRegistry { settings: McpSettings, bindings: RwLock>, @@ -125,19 +174,32 @@ impl McpToolRegistry { let mut next_bindings = HashMap::new(); for server in enabled_servers(&self.settings) { - let mut client = match McpStdioClient::connect(server).await { - Ok(client) => client, - Err(err) => { - warn!("unable to connect MCP server {}: {err}", server.name); - continue; - } - }; + let tools = match get_cached_mcp_tools(server).await { + Some(Ok(tools)) => tools, + Some(Err(_)) => continue, + None => { + let mut client = match McpStdioClient::connect(server).await { + Ok(client) => client, + Err(err) => { + let err_msg = err.to_string(); + warn!("unable to connect MCP server {}: {err_msg}", server.name); + set_cached_mcp_tools(server, Err(err_msg)).await; + continue; + } + }; - let tools = match client.list_tools().await { - Ok(tools) => tools, - Err(err) => { - warn!("unable to list MCP tools for {}: {err}", server.name); - continue; + match client.list_tools().await { + Ok(tools) => { + set_cached_mcp_tools(server, Ok(tools.clone())).await; + tools + } + Err(err) => { + let err_msg = err.to_string(); + warn!("unable to list MCP tools for {}: {err_msg}", server.name); + set_cached_mcp_tools(server, Err(err_msg)).await; + continue; + } + } } }; @@ -167,6 +229,13 @@ impl McpToolRegistry { } loaded.retain(|name| next_bindings.contains_key(name)); + // Auto-load tools for configured servers + for (name, binding) in &next_bindings { + if binding.server.auto_load { + loaded.insert(name.clone()); + } + } + *self.bindings.write().await = next_bindings; *self.loaded.write().await = loaded; self.descriptors().await @@ -440,13 +509,15 @@ pub async fn probe_mcp_servers(settings: &McpSettings) -> Vec { let mut client = match McpStdioClient::connect(server).await { Ok(client) => client, Err(err) => { + let err_msg = err.to_string(); + set_cached_mcp_tools(server, Err(err_msg.clone())).await; probes.push(McpServerProbe { server_id: server.id.clone(), server_name: server.name.clone(), enabled: true, ok: false, tools: Vec::new(), - error: Some(err.to_string()), + error: Some(err_msg), }); continue; } @@ -454,6 +525,7 @@ pub async fn probe_mcp_servers(settings: &McpSettings) -> Vec { match client.list_tools().await { Ok(tools) => { + set_cached_mcp_tools(server, Ok(tools.clone())).await; let mut infos = Vec::with_capacity(tools.len()); for tool in tools { let tool_name = unique_tool_name(server, &tool.name, &known_names); @@ -486,14 +558,18 @@ pub async fn probe_mcp_servers(settings: &McpSettings) -> Vec { error: None, }); } - Err(err) => probes.push(McpServerProbe { - server_id: server.id.clone(), - server_name: server.name.clone(), - enabled: true, - ok: false, - tools: Vec::new(), - error: Some(err.to_string()), - }), + Err(err) => { + let err_msg = err.to_string(); + set_cached_mcp_tools(server, Err(err_msg.clone())).await; + probes.push(McpServerProbe { + server_id: server.id.clone(), + server_name: server.name.clone(), + enabled: true, + ok: false, + tools: Vec::new(), + error: Some(err_msg), + }) + } } } @@ -831,10 +907,13 @@ impl McpStdioClient { .take() .ok_or_else(|| anyhow!("MCP server stdout unavailable"))?; - if let Some(mut stderr) = child.stderr.take() { + if let Some(stderr) = child.stderr.take() { + let server_name = config.name.clone(); tokio::spawn(async move { - let mut sink = Vec::new(); - let _ = stderr.read_to_end(&mut sink).await; + let mut reader = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = reader.next_line().await { + tracing::info!(target: "mcp_server", "[{}] {}", server_name, line); + } }); } @@ -936,8 +1015,12 @@ impl McpStdioClient { bail!("MCP server closed stdout ({status:?})"); } - let value: Value = serde_json::from_str(line.trim()) - .with_context(|| "MCP server emitted invalid JSON")?; + let trimmed = line.trim(); + if trimmed.is_empty() || !trimmed.starts_with('{') { + continue; + } + let value: Value = serde_json::from_str(trimmed) + .with_context(|| format!("MCP server emitted invalid JSON: {:?}", trimmed))?; if value.get("id") == Some(&json!(id)) { if let Some(error) = value.get("error") { bail!("{}", format_json_rpc_error(error)); @@ -1223,7 +1306,7 @@ struct McpListToolsResult { next_cursor: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] struct McpServerTool { name: String, #[serde(default)] diff --git a/crates/sinew-app/src/read.rs b/crates/sinew-app/src/read.rs index bcc7dfa2..67b69cc1 100644 --- a/crates/sinew-app/src/read.rs +++ b/crates/sinew-app/src/read.rs @@ -1,6 +1,6 @@ use std::{ fs, - path::{Component, Path, PathBuf}, + path::{Path, PathBuf}, time::UNIX_EPOCH, }; @@ -65,10 +65,13 @@ impl ReadTool { } pub async fn run(&self, input: Value) -> ToolRunResult { - match self.read(input) { + let tool = self.clone(); + tokio::task::spawn_blocking(move || match tool.read(input) { Ok(output) => output, Err(err) => ToolRunResult::err(err.to_string(), Vec::new()), - } + }) + .await + .unwrap_or_else(|err| ToolRunResult::err(format!("blocking thread panicked: {err}"), Vec::new())) } pub fn normalize_path(&self, raw: &str) -> Result { @@ -255,18 +258,50 @@ fn fingerprint_for_bytes( } } +fn clean_windows_path(path: &Path) -> String { + let s = path.to_string_lossy().replace('\\', "/"); + let clean = if let Some(stripped) = s.strip_prefix("//?/") { + stripped.to_string() + } else { + s + }; + clean.to_lowercase() +} + fn relative_from_root(root: &Path, path: &Path) -> Result { - let relative = path - .strip_prefix(root) - .with_context(|| format!("{} is outside the workspace", path.display()))?; - Ok(relative - .components() - .filter_map(|component| match component { - Component::Normal(value) => Some(value.to_string_lossy().into_owned()), - _ => None, - }) - .collect::>() - .join("/")) + let root_canonical = root + .canonicalize() + .unwrap_or_else(|_| root.to_path_buf()); + let path_canonical = path + .canonicalize() + .unwrap_or_else(|_| path.to_path_buf()); + + #[cfg(target_os = "windows")] + { + let root_str = clean_windows_path(&root_canonical); + let path_str = clean_windows_path(&path_canonical); + + if path_str == root_str || path_str.starts_with(&format!("{}/", root_str)) { + let relative = &path_str[root_str.len()..]; + Ok(relative.trim_start_matches('/').to_string()) + } else { + bail!("{} is outside the workspace", path.display()); + } + } + #[cfg(not(target_os = "windows"))] + { + let relative = path_canonical + .strip_prefix(&root_canonical) + .with_context(|| format!("{} is outside the workspace", path.display()))?; + Ok(relative + .components() + .filter_map(|component| match component { + Component::Normal(value) => Some(value.to_string_lossy().into_owned()), + _ => None, + }) + .collect::>() + .join("/")) + } } #[cfg(test)] diff --git a/crates/sinew-app/src/read_lints.rs b/crates/sinew-app/src/read_lints.rs new file mode 100644 index 00000000..269e56e1 --- /dev/null +++ b/crates/sinew-app/src/read_lints.rs @@ -0,0 +1,563 @@ +use std::{ + collections::HashSet, + path::{Path, PathBuf}, + time::Duration, +}; + +use anyhow::{Context, Result}; +use serde::Deserialize; +use serde_json::{json, Value}; +use sinew_core::ToolDescriptor; +use tokio::{process::Command, time::timeout}; + +use crate::{ + editor_diagnostics::{EditorDiagnostic, SharedEditorDiagnosticsStore}, + tool_names, + tool_run::ToolRunResult, + workspace::{normalize_workspace_relative_path, resolve_workspace_path}, +}; + +const MAX_DIAGNOSTICS: usize = 200; +const PROJECT_LINT_TIMEOUT: Duration = Duration::from_secs(45); +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x08000000; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct DiagnosticKey { + path: String, + line: u32, + column: u32, + severity: String, + message: String, +} + +#[derive(Debug, Clone)] +pub struct ReadLintsTool { + workspace_root: PathBuf, + editor_store: SharedEditorDiagnosticsStore, +} + +impl ReadLintsTool { + pub fn new( + workspace_root: impl Into, + editor_store: SharedEditorDiagnosticsStore, + ) -> Self { + Self { + workspace_root: workspace_root.into(), + editor_store, + } + } + + pub fn descriptor(&self) -> ToolDescriptor { + ToolDescriptor { + name: tool_names::READ_LINTS.into(), + description: "Read linter/compiler diagnostics for workspace files. Uses live editor diagnostics when available, plus project linters (cargo, eslint, ruff) for supported languages.".into(), + input_schema: json!({ + "type": "object", + "properties": { + "paths": { + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ], + "description": "Optional file or directory paths (relative to the workspace root). When omitted, returns diagnostics for all known open editor files plus workspace-wide checks where available." + } + }, + "additionalProperties": false + }), + } + } + + pub async fn run(&self, input: Value) -> ToolRunResult { + match self.read(input).await { + Ok(output) => ToolRunResult::ok(output, Vec::new()), + Err(err) => ToolRunResult::err(err.to_string(), Vec::new()), + } + } + + async fn read(&self, input: Value) -> Result { + let parsed: ReadLintsInput = serde_json::from_value(input) + .map_err(|err| anyhow::anyhow!("invalid read_lints input: {err}"))?; + let requested_paths = normalize_requested_paths(parsed.paths)?; + let path_filter = if requested_paths.is_empty() { + None + } else { + Some(requested_paths.as_slice()) + }; + let diagnostics = self.collect_editor_diagnostics(path_filter); + let project_paths = if requested_paths.is_empty() { + self.default_project_paths(&diagnostics) + } else { + requested_paths + }; + let mut diagnostics = diagnostics; + if let Ok(project_diags) = self.collect_project_diagnostics(&project_paths).await { + diagnostics.extend(project_diags); + } + let mut diagnostics = dedupe_diagnostics(diagnostics); + diagnostics.sort_by(|left, right| { + left.path + .cmp(&right.path) + .then(left.line.cmp(&right.line)) + .then(left.column.cmp(&right.column)) + }); + diagnostics.truncate(MAX_DIAGNOSTICS); + Ok(format_diagnostics(&diagnostics)) + } + + fn collect_editor_diagnostics(&self, paths: Option<&[String]>) -> Vec { + let store = self + .editor_store + .read() + .unwrap_or_else(|err| err.into_inner()); + store.matching(paths).cloned().collect() + } + + fn default_project_paths(&self, editor: &[EditorDiagnostic]) -> Vec { + let mut paths = HashSet::new(); + for diag in editor { + paths.insert(diag.path.clone()); + } + if paths.is_empty() + && self.workspace_root.join("Cargo.toml").is_file() { + paths.insert(".".to_string()); + } + paths.into_iter().collect() + } + + async fn collect_project_diagnostics( + &self, + paths: &[String], + ) -> Result> { + let mut out = Vec::new(); + let mut rust_paths = Vec::new(); + let mut js_paths = Vec::new(); + let mut py_paths = Vec::new(); + + for path in paths { + let normalized = normalize_workspace_relative_path(path)?; + if normalized.is_empty() { + if self.workspace_root.join("Cargo.toml").is_file() { + rust_paths.push(".".to_string()); + } + continue; + } + let absolute = resolve_workspace_path(&self.workspace_root, &normalized)?; + if absolute.is_dir() { + if normalized == "." || self.workspace_root.join("Cargo.toml").is_file() { + rust_paths.push(normalized); + } + continue; + } + if is_rust_path(&absolute) { + rust_paths.push(normalized); + } else if is_js_path(&absolute) { + js_paths.push(normalized); + } else if is_py_path(&absolute) { + py_paths.push(normalized); + } + } + + if !rust_paths.is_empty() && self.workspace_root.join("Cargo.toml").is_file() { + out.extend(run_cargo_check(&self.workspace_root, &rust_paths).await?); + } + if !js_paths.is_empty() && eslint_config_exists(&self.workspace_root) { + out.extend(run_eslint(&self.workspace_root, &js_paths).await?); + } + if !py_paths.is_empty() { + out.extend(run_ruff(&self.workspace_root, &py_paths).await?); + } + Ok(out) + } +} + +#[derive(Debug, Deserialize)] +struct ReadLintsInput { + #[serde(default)] + paths: Option, +} + +fn normalize_requested_paths(paths: Option) -> Result> { + let Some(paths) = paths else { + return Ok(Vec::new()); + }; + let values = match paths { + Value::String(path) => vec![path], + Value::Array(items) => items + .into_iter() + .filter_map(|value| value.as_str().map(str::to_string)) + .collect(), + _ => { + return Err(anyhow::anyhow!( + "paths must be a string or an array of strings" + )) + } + }; + let mut out = Vec::new(); + for path in values { + let trimmed = path.trim(); + if trimmed.is_empty() { + continue; + } + out.push(normalize_workspace_relative_path(trimmed)?.to_string()); + } + Ok(out) +} + +fn format_diagnostics(diagnostics: &[EditorDiagnostic]) -> String { + if diagnostics.is_empty() { + return "No linter errors found.".to_string(); + } + diagnostics + .iter() + .map(|diag| { + let source = if diag.source.trim().is_empty() { + String::new() + } else { + format!(" ({})", diag.source.trim()) + }; + format!( + "{}:{}:{}-{}: {}{}", + diag.path, + diag.line, + diag.column, + diag.severity, + diag.message.trim(), + source + ) + }) + .collect::>() + .join("\n") +} + +fn is_rust_path(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("rs")) +} + +fn is_js_path(path: &Path) -> bool { + matches!( + path.extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_ascii_lowercase()), + Some(ext) if matches!( + ext.as_str(), + "ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs" + ) + ) +} + +fn is_py_path(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("py")) +} + +fn dedupe_diagnostics(diagnostics: Vec) -> Vec { + let mut seen = HashSet::new(); + diagnostics + .into_iter() + .filter(|diag| seen.insert(diag_key(diag))) + .collect() +} + +fn eslint_config_exists(workspace_root: &Path) -> bool { + [ + "eslint.config.js", + "eslint.config.mjs", + "eslint.config.cjs", + "eslint.config.ts", + ".eslintrc", + ".eslintrc.js", + ".eslintrc.cjs", + ".eslintrc.json", + ".eslintrc.yaml", + ".eslintrc.yml", + ] + .iter() + .any(|name| workspace_root.join(name).is_file()) +} + +async fn run_cargo_check(workspace_root: &Path, paths: &[String]) -> Result> { + let mut command = Command::new("cargo"); + command + .arg("check") + .arg("--message-format=json") + .current_dir(workspace_root) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + #[cfg(windows)] + command.creation_flags(CREATE_NO_WINDOW); + + let output = timeout(PROJECT_LINT_TIMEOUT, command.output()) + .await + .context("cargo check timed out")? + .context("unable to run cargo check")?; + let stdout = String::from_utf8_lossy(&output.stdout); + let allowed = path_prefixes(workspace_root, paths)?; + parse_cargo_messages(&stdout, &allowed) +} + +fn path_prefixes(workspace_root: &Path, paths: &[String]) -> Result> { + let mut allowed = HashSet::new(); + for path in paths { + if path == "." { + return Ok(HashSet::new()); + } + let absolute = resolve_workspace_path(workspace_root, path)?; + allowed.insert(normalize_path_string(&absolute)); + } + Ok(allowed) +} + +fn normalize_path_string(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +fn parse_cargo_messages(stdout: &str, allowed: &HashSet) -> Result> { + let filter_all = allowed.is_empty(); + let mut diagnostics = Vec::new(); + let mut seen = HashSet::new(); + + for line in stdout.lines() { + let value: Value = match serde_json::from_str(line) { + Ok(value) => value, + Err(_) => continue, + }; + if value.get("reason").and_then(Value::as_str) != Some("compiler-message") { + continue; + } + let message = value + .pointer("/message") + .ok_or_else(|| anyhow::anyhow!("missing cargo message payload"))?; + let level = message + .get("level") + .and_then(Value::as_str) + .unwrap_or("warning"); + if !matches!(level, "error" | "warning") { + continue; + } + let spans = message + .get("spans") + .and_then(Value::as_array) + .filter(|spans| !spans.is_empty()) + .ok_or_else(|| anyhow::anyhow!("missing cargo span"))?; + let primary = spans + .iter() + .find(|span| span.get("is_primary").and_then(Value::as_bool).unwrap_or(false)) + .or_else(|| spans.first()) + .context("missing primary cargo span")?; + let file_name = primary + .get("file_name") + .and_then(Value::as_str) + .unwrap_or_default(); + if file_name.is_empty() { + continue; + } + let absolute = normalize_path_string(Path::new(file_name)); + if !filter_all && !allowed.iter().any(|prefix| absolute.starts_with(prefix)) { + continue; + } + let relative = workspace_relative_from_absolute(file_name); + let rendered = message + .get("rendered") + .and_then(Value::as_str) + .or_else(|| message.get("message").and_then(Value::as_str)) + .unwrap_or("compiler diagnostic"); + let line = primary.get("line_start").and_then(Value::as_u64).unwrap_or(1) as u32; + let column = primary.get("column_start").and_then(Value::as_u64).unwrap_or(1) as u32; + let end_line = primary.get("line_end").and_then(Value::as_u64).unwrap_or(line as u64) as u32; + let end_column = + primary.get("column_end").and_then(Value::as_u64).unwrap_or(column as u64) as u32; + let diag = EditorDiagnostic { + path: relative, + line, + column, + end_line, + end_column, + severity: level.to_string(), + message: rendered.trim().to_string(), + source: "rustc".to_string(), + }; + if seen.insert(diag_key(&diag)) { + diagnostics.push(diag); + } + } + Ok(diagnostics) +} + +fn workspace_relative_from_absolute(path: &str) -> String { + path.replace('\\', "/") + .trim_start_matches("./") + .to_string() +} + +async fn run_eslint(workspace_root: &Path, paths: &[String]) -> Result> { + let mut command = Command::new("npx"); + command + .arg("--yes") + .arg("eslint") + .arg("--format") + .arg("json") + .current_dir(workspace_root); + for path in paths { + command.arg(path); + } + command + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + #[cfg(windows)] + command.creation_flags(CREATE_NO_WINDOW); + + let output = timeout(PROJECT_LINT_TIMEOUT, command.output()) + .await + .context("eslint timed out")? + .context("unable to run eslint")?; + if output.stdout.is_empty() { + return Ok(Vec::new()); + } + parse_eslint_json(&String::from_utf8_lossy(&output.stdout)) +} + +fn parse_eslint_json(stdout: &str) -> Result> { + let files: Value = serde_json::from_str(stdout).context("invalid eslint json output")?; + let Some(files) = files.as_array() else { + return Ok(Vec::new()); + }; + let mut diagnostics = Vec::new(); + for file in files { + let Some(path) = file.get("filePath").and_then(Value::as_str) else { + continue; + }; + let relative = workspace_relative_from_absolute(path); + let Some(messages) = file.get("messages").and_then(Value::as_array) else { + continue; + }; + for message in messages { + let severity = match message.get("severity").and_then(Value::as_u64) { + Some(2) => "error", + Some(1) => "warning", + _ => "info", + }; + diagnostics.push(EditorDiagnostic { + path: relative.clone(), + line: message.get("line").and_then(Value::as_u64).unwrap_or(1) as u32, + column: message.get("column").and_then(Value::as_u64).unwrap_or(1) as u32, + end_line: message.get("endLine").and_then(Value::as_u64).unwrap_or(1) as u32, + end_column: message.get("endColumn").and_then(Value::as_u64).unwrap_or(1) as u32, + severity: severity.to_string(), + message: message + .get("message") + .and_then(Value::as_str) + .unwrap_or("eslint diagnostic") + .to_string(), + source: message + .get("ruleId") + .and_then(Value::as_str) + .unwrap_or("eslint") + .to_string(), + }); + } + } + Ok(diagnostics) +} + +async fn run_ruff(workspace_root: &Path, paths: &[String]) -> Result> { + let mut command = Command::new("ruff"); + command + .arg("check") + .arg("--output-format") + .arg("json") + .current_dir(workspace_root); + for path in paths { + command.arg(path); + } + command + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + #[cfg(windows)] + command.creation_flags(CREATE_NO_WINDOW); + + let output = timeout(PROJECT_LINT_TIMEOUT, command.output()) + .await + .context("ruff timed out")? + .context("unable to run ruff")?; + if output.stdout.is_empty() { + return Ok(Vec::new()); + } + parse_ruff_json(&String::from_utf8_lossy(&output.stdout)) +} + +fn parse_ruff_json(stdout: &str) -> Result> { + let items: Value = serde_json::from_str(stdout).context("invalid ruff json output")?; + let Some(items) = items.as_array() else { + return Ok(Vec::new()); + }; + let mut diagnostics = Vec::new(); + for item in items { + let filename = item + .get("filename") + .and_then(Value::as_str) + .unwrap_or_default(); + diagnostics.push(EditorDiagnostic { + path: workspace_relative_from_absolute(filename), + line: item.get("location").and_then(|v| v.get("row")).and_then(Value::as_u64).unwrap_or(1) as u32, + column: item.get("location").and_then(|v| v.get("column")).and_then(Value::as_u64).unwrap_or(1) as u32, + end_line: item.get("end_location").and_then(|v| v.get("row")).and_then(Value::as_u64).unwrap_or(1) as u32, + end_column: item.get("end_location").and_then(|v| v.get("column")).and_then(Value::as_u64).unwrap_or(1) as u32, + severity: "warning".to_string(), + message: item + .get("message") + .and_then(Value::as_str) + .unwrap_or("ruff diagnostic") + .to_string(), + source: item + .get("code") + .and_then(Value::as_str) + .unwrap_or("ruff") + .to_string(), + }); + } + Ok(diagnostics) +} + +fn diag_key(diag: &EditorDiagnostic) -> DiagnosticKey { + DiagnosticKey { + path: diag.path.clone(), + line: diag.line, + column: diag.column, + severity: diag.severity.clone(), + message: diag.message.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn formats_diagnostics_for_model() { + let text = format_diagnostics(&[EditorDiagnostic { + path: "src/main.rs".into(), + line: 4, + column: 12, + end_line: 4, + end_column: 18, + severity: "error".into(), + message: "expected `;`".into(), + source: "rustc".into(), + }]); + assert!(text.contains("src/main.rs:4:12-error: expected `;` (rustc)")); + } + + #[test] + fn parses_cargo_json_diagnostics() { + let stdout = r#"{"reason":"compiler-message","message":{"level":"error","message":"cannot find value `x`","rendered":"error: cannot find value `x`","spans":[{"file_name":"src/lib.rs","line_start":2,"column_start":5,"line_end":2,"column_end":6,"is_primary":true}]}}"#; + let allowed = HashSet::from([normalize_path_string(Path::new("src/lib.rs"))]); + let diagnostics = parse_cargo_messages(stdout, &allowed).expect("parse cargo"); + assert_eq!(diagnostics.len(), 1); + assert_eq!(diagnostics[0].path, "src/lib.rs"); + assert_eq!(diagnostics[0].source, "rustc"); + } +} diff --git a/crates/sinew-app/src/store.rs b/crates/sinew-app/src/store.rs index 11ce7178..1ef8152d 100644 --- a/crates/sinew-app/src/store.rs +++ b/crates/sinew-app/src/store.rs @@ -1,7 +1,8 @@ use std::{ collections::{HashMap, HashSet}, path::{Path, PathBuf}, - time::{SystemTime, UNIX_EPOCH}, + thread, + time::{Instant, SystemTime, UNIX_EPOCH}, }; use anyhow::{Context, Result}; @@ -29,6 +30,7 @@ const SUB_AGENT_SETTINGS_KEY: &str = "sub_agent_settings"; const TOOL_SETTINGS_KEY: &str = "tool_settings"; const SKILL_SETTINGS_KEY: &str = "skill_settings"; const OPENROUTER_MODELS_KEY: &str = "openrouter_models"; +const OLLAMA_MODELS_KEY: &str = "ollama_models"; const HIDDEN_TOOL_SETTING_NAMES: &[&str] = &["skill"]; pub const DEFAULT_PLAN_MODE_PROMPT: &str = r#"You are in Plan mode. @@ -61,6 +63,13 @@ pub struct ConversationSummary { pub updated_at_ms: i64, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OtherWorkspaceSummary { + pub workspace_id: String, + pub count: i64, +} + #[derive(Debug, Clone)] pub struct TurnCheckpointRecord { pub history_index: usize, @@ -72,6 +81,7 @@ pub struct TurnCheckpointRecord { pub struct SavedConversation { pub id: String, pub workspace_id: String, + pub git_remote_url: Option, pub title: String, pub model: ModelRef, pub mode_model_settings: ModeModelSettings, @@ -207,6 +217,12 @@ pub struct ToolSettings { #[serde(default)] pub openai_image_use_subscription: bool, #[serde(default)] + pub gemini_image_use_subscription: bool, + #[serde(default)] + pub openai_image_model: String, + #[serde(default)] + pub gemini_image_model: String, + #[serde(default)] pub openai_image_api_key: String, #[serde(default)] pub nano_banana_api_key: String, @@ -256,6 +272,9 @@ pub struct ToolSettingsView { pub default_plan_mode_prompt: String, pub image_provider: ImageProvider, pub openai_image_use_subscription: bool, + pub gemini_image_use_subscription: bool, + pub openai_image_model: String, + pub gemini_image_model: String, pub openai_image_api_key: String, pub nano_banana_api_key: String, pub web_search_provider: WebSearchProvider, @@ -315,6 +334,14 @@ impl ToolSettings { self.openai_image_api_key = self.openai_image_api_key.trim().to_string(); self.nano_banana_api_key = self.nano_banana_api_key.trim().to_string(); self.linkup_api_key = self.linkup_api_key.trim().to_string(); + self.openai_image_model = self.openai_image_model.trim().to_string(); + if self.openai_image_model.is_empty() { + self.openai_image_model = "gpt-image-2".to_string(); + } + self.gemini_image_model = self.gemini_image_model.trim().to_string(); + if self.gemini_image_model.is_empty() { + self.gemini_image_model = "gemini-3.1-flash-image-preview".to_string(); + } self.tools = self .tools .into_iter() @@ -450,6 +477,9 @@ pub fn tool_settings_view(settings: &ToolSettings, catalog: &[ToolDescriptor]) - default_plan_mode_prompt: DEFAULT_PLAN_MODE_PROMPT.to_string(), image_provider: settings.image_provider, openai_image_use_subscription: settings.openai_image_use_subscription, + gemini_image_use_subscription: settings.gemini_image_use_subscription, + openai_image_model: settings.openai_image_model.clone(), + gemini_image_model: settings.gemini_image_model.clone(), openai_image_api_key: settings.openai_image_api_key.clone(), nano_banana_api_key: settings.nano_banana_api_key.clone(), web_search_provider: settings.web_search_provider, @@ -571,21 +601,30 @@ impl AppStore { pub fn bootstrap_workspace( &self, workspace_root: &Path, + project_id: &str, + git_remote_url: Option<&str>, default_model: &ModelRef, default_system: &str, ) -> Result { - let workspace_id = workspace_root.display().to_string(); let mode_model_settings = self.load_mode_model_settings(default_model)?; - let mut conversations = self.list_conversations(&workspace_id)?; - let active_conversation = if let Some(first) = conversations.first() { - self.load_conversation(&workspace_id, &first.id)? + let mut conversations = self.list_conversations(project_id, git_remote_url)?; + let mut active_conversation = if let Some(first) = conversations.first() { + self.load_conversation(project_id, &first.id)? .context("conversation listed in index but missing from store")? } else { - let created = self.create_conversation(&workspace_id, default_model, default_system)?; - conversations = self.list_conversations(&workspace_id)?; + let created = self.create_conversation( + project_id, + git_remote_url, + default_model, + default_system, + )?; + conversations = self.list_conversations(project_id, git_remote_url)?; created }; + // Keep absolute path for frontend to be happy + active_conversation.workspace_id = workspace_root.display().to_string(); + Ok(WorkspaceBootstrap { workspace: workspace_info(workspace_root), conversations, @@ -597,12 +636,21 @@ impl AppStore { pub fn create_conversation( &self, workspace_id: &str, + git_remote_url: Option<&str>, default_model: &ModelRef, default_system: &str, ) -> Result { let id = Uuid::new_v4().to_string(); let now = now_ms(); - let title = DEFAULT_CONVERSATION_TITLE.to_string(); + let host = std::env::var("COMPUTERNAME") + .or_else(|_| std::env::var("HOSTNAME")) + .unwrap_or_default(); + let tag = if !host.trim().is_empty() { + format!("[{}] ", host.trim()) + } else { + "".to_string() + }; + let title = format!("{}{}", tag, DEFAULT_CONVERSATION_TITLE); let todo_list = TodoListState::default(); let todo_list_json = serde_json::to_string(&todo_list)?; let plan_workflow = PlanWorkflowState::default(); @@ -614,11 +662,12 @@ impl AppStore { let mode_model_settings_json = serde_json::to_string(&mode_model_settings)?; let conn = self.connection()?; conn.execute( - "insert into conversations (id, workspace_id, title, title_initialized, model_json, mode_model_settings_json, system_prompt, todo_list_json, plan_workflow_json, goal_workflow_json, created_at_ms, updated_at_ms) - values (?1, ?2, ?3, 0, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + "insert into conversations (id, workspace_id, git_remote_url, title, title_initialized, model_json, mode_model_settings_json, system_prompt, todo_list_json, plan_workflow_json, goal_workflow_json, created_at_ms, updated_at_ms) + values (?1, ?2, ?3, ?4, 0, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", params![ &id, workspace_id, + git_remote_url, &title, serde_json::to_string(&conversation_model)?, mode_model_settings_json, @@ -635,6 +684,7 @@ impl AppStore { Ok(SavedConversation { id, workspace_id: workspace_id.to_string(), + git_remote_url: git_remote_url.map(|s| s.to_string()), title, model: conversation_model, mode_model_settings, @@ -646,8 +696,21 @@ impl AppStore { }) } - pub fn list_conversations(&self, workspace_id: &str) -> Result> { + pub fn list_conversations( + &self, + workspace_id: &str, + git_remote_url: Option<&str>, + ) -> Result> { let conn = self.connection()?; + + // Auto-migrate any conversations that share the same git_remote_url to the current workspace_id! + if let Some(remote_url) = git_remote_url { + let _ = conn.execute( + "update conversations set workspace_id = ?1 where git_remote_url = ?2 and workspace_id != ?1", + params![workspace_id, remote_url], + ); + } + let mut statement = conn .prepare( "select id, title, updated_at_ms from conversations @@ -673,16 +736,91 @@ impl AppStore { Ok(conversations) } + pub fn list_other_workspaces( + &self, + current_workspace_id: &str, + current_project_id: Option<&str>, + ) -> Result> { + let conn = self.connection()?; + + // Clean both paths by converting them to lowercase and replacing forward/backward slashes + // to minimize slight path formatting differences + let normalized_current = current_workspace_id.replace('\\', "/").to_lowercase(); + let normalized_project = current_project_id.map(|p| p.to_lowercase()); + + let mut statement = conn + .prepare( + "select workspace_id, count(*) from conversations + group by workspace_id + order by count(*) desc", + ) + .context("unable to prepare other workspaces list query")?; + + let rows = statement + .query_map([], |row| { + let workspace_id: String = row.get(0)?; + let count: i64 = row.get(1)?; + Ok((workspace_id, count)) + }) + .context("unable to read other workspaces list")?; + + let mut list = Vec::new(); + for row in rows { + let (workspace_id, count) = row.context("bad workspace summary row")?; + let normalized_db = workspace_id.replace('\\', "/").to_lowercase(); + // Exclude current workspace and current project UUID from other workspaces list + if normalized_db != normalized_current + && Some(&normalized_db) != normalized_project.as_ref() + { + list.push(OtherWorkspaceSummary { + workspace_id, + count, + }); + } + } + Ok(list) + } + + pub fn migrate_conversations( + &self, + src_workspace_id: &str, + dest_workspace_id: &str, + ) -> Result<()> { + let conn = self.connection()?; + // Normalize slashes and use case-insensitive matching for paths + let src_norm = src_workspace_id.replace('\\', "/").to_lowercase(); + + // Find all distinct workspace_ids in the database that normalize to the same path + let mut stmt = conn.prepare("select distinct workspace_id from conversations")?; + let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; + let mut ids_to_migrate = Vec::new(); + for id in rows.flatten() { + if id.replace('\\', "/").to_lowercase() == src_norm { + ids_to_migrate.push(id); + } + } + + for id in ids_to_migrate { + conn.execute( + "update conversations set workspace_id = ?2 where workspace_id = ?1", + params![id, dest_workspace_id], + )?; + } + Ok(()) + } + pub fn load_conversation( &self, workspace_id: &str, id: &str, ) -> Result> { + let start = Instant::now(); + let conv_id = id.to_string(); let conn = self.connection()?; let conversation = conn .query_row( - "select title, model_json, system_prompt, todo_list_json, plan_workflow_json, mode_model_settings_json, goal_workflow_json from conversations where workspace_id = ?1 and id = ?2", - params![workspace_id, id], + "select title, model_json, system_prompt, todo_list_json, plan_workflow_json, mode_model_settings_json, goal_workflow_json, git_remote_url, workspace_id from conversations where id = ?1", + params![id], |row| { let model_json: String = row.get(1)?; let todo_list_json: String = row.get(3)?; @@ -712,11 +850,13 @@ impl AppStore { .unwrap_or_default(), serde_json::from_str::(&goal_workflow_json) .unwrap_or_default(), + row.get::<_, Option>(7)?, + row.get::<_, String>(8)?, )) }, ) .optional() - .context("unable to load conversation metadata")?; + .context("unable to load conversation details")?; let Some(( title, @@ -726,11 +866,21 @@ impl AppStore { todo_list, plan_workflow, goal_workflow, + git_remote_url, + db_workspace_id, )) = conversation else { return Ok(None); }; + // Auto-migrate workspace_id if it's different (e.g. workspace folder path changed across PCs) + if db_workspace_id != workspace_id { + let _ = conn.execute( + "update conversations set workspace_id = ?1 where id = ?2", + params![workspace_id, id], + ); + } + let mut statement = conn .prepare( "select message_json from messages @@ -756,9 +906,13 @@ impl AppStore { history.push(row.context("bad stored message")?); } + let load_ms = start.elapsed().as_millis(); + let msg_count = history.len(); + tracing::debug!(conv_id, load_ms, msg_count, "conversation loaded"); Ok(Some(SavedConversation { id: id.to_string(), workspace_id: workspace_id.to_string(), + git_remote_url, title, model, mode_model_settings, @@ -771,6 +925,8 @@ impl AppStore { } pub fn save_conversation(&self, conversation: &SavedConversation) -> Result<()> { + let start = Instant::now(); + let conv_id = conversation.id.clone(); let now = now_ms(); let mut todo_list = conversation.todo_list.clone(); todo_list.normalize(); @@ -782,18 +938,19 @@ impl AppStore { let tx = conn .transaction() .context("unable to open sqlite transaction")?; - let current_title_state = - load_conversation_title_state(&tx, &conversation.workspace_id, &conversation.id)?; + let current_title_state = load_conversation_title_state(&tx, &conversation.id)?; let title_state = resolve_title_for_save( current_title_state.as_ref(), &conversation.title, &conversation.history, ); + let db_workspace_id = self.resolve_db_workspace_id(&conversation.workspace_id); + tx.execute( "update conversations - set title = ?2, model_json = ?3, system_prompt = ?4, updated_at_ms = ?5, todo_list_json = ?6, plan_workflow_json = ?7, mode_model_settings_json = ?8, goal_workflow_json = ?9, title_initialized = ?10 - where id = ?1 and workspace_id = ?11", + set title = ?2, model_json = ?3, system_prompt = ?4, updated_at_ms = ?5, todo_list_json = ?6, plan_workflow_json = ?7, mode_model_settings_json = ?8, goal_workflow_json = ?9, title_initialized = ?10, git_remote_url = ?11, workspace_id = ?12 + where id = ?1", params![ &conversation.id, &title_state.title, @@ -805,7 +962,8 @@ impl AppStore { mode_model_settings_json, goal_workflow_json, title_state.initialized as i64, - &conversation.workspace_id, + &conversation.git_remote_url, + &db_workspace_id, ], ) .context("unable to update conversation")?; @@ -830,6 +988,8 @@ impl AppStore { tx.commit() .context("unable to commit conversation transaction")?; + let save_ms = start.elapsed().as_millis(); + tracing::debug!(conv_id = conv_id, save_ms, msg_count = conversation.history.len(), "conversation saved"); Ok(()) } @@ -850,18 +1010,19 @@ impl AppStore { let tx = conn .transaction() .context("unable to open sqlite transaction")?; - let current_title_state = - load_conversation_title_state(&tx, &conversation.workspace_id, &conversation.id)?; + let current_title_state = load_conversation_title_state(&tx, &conversation.id)?; let title_state = resolve_title_for_save( current_title_state.as_ref(), &conversation.title, &conversation.history, ); + let db_workspace_id = self.resolve_db_workspace_id(&conversation.workspace_id); + tx.execute( "update conversations - set title = ?2, model_json = ?3, system_prompt = ?4, updated_at_ms = ?5, todo_list_json = ?6, plan_workflow_json = ?7, mode_model_settings_json = ?8, goal_workflow_json = ?9, title_initialized = ?10 - where id = ?1 and workspace_id = ?11", + set title = ?2, model_json = ?3, system_prompt = ?4, updated_at_ms = ?5, todo_list_json = ?6, plan_workflow_json = ?7, mode_model_settings_json = ?8, goal_workflow_json = ?9, title_initialized = ?10, git_remote_url = ?11, workspace_id = ?12 + where id = ?1", params![ &conversation.id, &title_state.title, @@ -873,7 +1034,8 @@ impl AppStore { mode_model_settings_json, goal_workflow_json, title_state.initialized as i64, - &conversation.workspace_id, + &conversation.git_remote_url, + &db_workspace_id, ], ) .context("unable to update conversation")?; @@ -1261,6 +1423,48 @@ impl AppStore { self.save_openrouter_models(&models) } + pub fn load_ollama_models(&self) -> Result> { + let conn = self.connection()?; + let stored = conn + .query_row( + "select value_json from app_settings where key = ?1", + params![OLLAMA_MODELS_KEY], + |row| row.get::<_, String>(0), + ) + .optional() + .context("unable to read Ollama model list")?; + + if let Some(json) = stored { + if let Ok(models) = serde_json::from_str::>(&json) { + return Ok(normalize_openrouter_models(models)); + } + } + + Ok(Vec::new()) + } + + pub fn save_ollama_models( + &self, + models: &[OpenRouterModelRecord], + ) -> Result> { + let normalized = normalize_openrouter_models(models.to_vec()); + let conn = self.connection()?; + conn.execute( + "insert into app_settings (key, value_json, updated_at_ms) + values (?1, ?2, ?3) + on conflict(key) do update set + value_json = excluded.value_json, + updated_at_ms = excluded.updated_at_ms", + params![ + OLLAMA_MODELS_KEY, + serde_json::to_string(&normalized)?, + now_ms(), + ], + ) + .context("unable to save Ollama model list")?; + Ok(normalized) + } + pub fn save_sub_agent_settings(&self, settings: &SubAgentSettings) -> Result { let normalized = settings.clone().normalized(); let conn = self.connection()?; @@ -1292,6 +1496,13 @@ impl AppStore { pub fn delete_conversation(&self, workspace_id: &str, id: &str) -> Result<()> { let conn = self.connection()?; + let now = now_ms(); + conn.execute( + "insert or replace into tombstones (id, deleted_at_ms) values (?1, ?2)", + params![id, now], + ) + .context("unable to insert tombstone")?; + conn.execute( "delete from conversations where workspace_id = ?1 and id = ?2", params![workspace_id, id], @@ -1320,13 +1531,57 @@ impl AppStore { .context("unable to load conversation model") } + pub fn set_provider_status(&self, provider_id: &str, status: &str) -> Result<()> { + let conn = self.connection()?; + conn.execute( + "insert into provider_states (provider_id, status) + values (?1, ?2) + on conflict(provider_id) do update set + status = excluded.status", + params![provider_id, status], + ) + .context("unable to set provider status")?; + Ok(()) + } + + pub fn get_provider_status(&self, provider_id: &str) -> Result { + let conn = self.connection()?; + let status = conn + .query_row( + "select status from provider_states where provider_id = ?1", + params![provider_id], + |row| row.get::<_, String>(0), + ) + .optional() + .context("unable to read provider status")?; + + Ok(status.unwrap_or_else(|| "active".to_string())) + } + + pub fn list_archived_providers(&self) -> Result> { + let conn = self.connection()?; + let mut statement = conn + .prepare("select provider_id from provider_states where status = 'archived'") + .context("unable to prepare archived providers query")?; + + let rows = statement + .query_map([], |row| row.get::<_, String>(0)) + .context("unable to query archived providers")?; + + let mut archived = Vec::new(); + for row in rows { + archived.push(row.context("bad provider status row")?); + } + Ok(archived) + } + fn migrate(&self) -> Result<()> { let conn = self.connection()?; let version: i64 = conn .pragma_query_value(None, "user_version", |row| row.get(0)) .unwrap_or(0); - if version >= 9 { + if version >= 12 { return Ok(()); } @@ -1336,6 +1591,7 @@ impl AppStore { create table if not exists conversations ( id text primary key, workspace_id text not null, + git_remote_url text, title text not null, title_initialized integer not null default 0, model_json text not null, @@ -1375,23 +1631,80 @@ impl AppStore { ensure_conversations_title_initialized_column(&conn)?; ensure_app_settings_table(&conn)?; ensure_turn_checkpoints_table(&conn)?; + ensure_tombstones_table(&conn)?; if version < 8 { conn.execute("delete from turn_checkpoints", []) .context("unable to clear legacy turn checkpoints")?; } - conn.pragma_update(None, "user_version", 9) + if version < 10 { + let _ = conn.execute( + "UPDATE conversations SET workspace_id = LOWER(workspace_id)", + [], + ); + } + if version < 11 { + let _ = conn.execute( + "alter table conversations add column git_remote_url text", + [], + ); + } + if version < 12 { + conn.execute_batch( + "create table if not exists provider_states ( + provider_id text primary key, + status text not null + );", + ) + .context("unable to create provider_states table")?; + } + conn.pragma_update(None, "user_version", 12) .context("unable to set sqlite schema version")?; Ok(()) } + fn resolve_db_workspace_id(&self, workspace_id: &str) -> String { + if workspace_id.contains('/') || workspace_id.contains('\\') { + let path = std::path::Path::new(workspace_id); + let id_file = path.join(".sinew").join("project_id.txt"); + if id_file.exists() { + if let Ok(id) = std::fs::read_to_string(&id_file) { + let trimmed = id.trim(); + if !trimmed.is_empty() { + return trimmed.to_string(); + } + } + } + } + workspace_id.to_string() + } + fn connection(&self) -> Result { let conn = Connection::open(&self.path).context("unable to open sqlite database")?; - conn.execute_batch("pragma foreign_keys = on;") - .context("unable to enable foreign keys")?; + let cache_kib = app_sqlite_cache_kib(); + let pragmas = format!( + "pragma foreign_keys = on; + pragma journal_mode = WAL; + pragma synchronous = NORMAL; + pragma cache_size = -{}; + pragma mmap_size = {}; + pragma temp_store = MEMORY; + pragma busy_timeout = 10000;", + cache_kib, + cache_kib * 1024 + ); + conn.execute_batch(&pragmas) + .context("unable to enable foreign keys and performance pragmas")?; Ok(conn) } } +fn app_sqlite_cache_kib() -> i64 { + let parallelism = thread::available_parallelism() + .map(|value| value.get() as i64) + .unwrap_or(4); + (parallelism * 8 * 1024).clamp(32 * 1024, 512 * 1024) +} + fn ensure_conversations_todo_column(conn: &Connection) -> Result<()> { if conversation_has_column(conn, "todo_list_json")? { return Ok(()); @@ -1500,6 +1813,19 @@ fn ensure_turn_checkpoints_table(conn: &Connection) -> Result<()> { Ok(()) } +fn ensure_tombstones_table(conn: &Connection) -> Result<()> { + conn.execute_batch( + r#" + create table if not exists tombstones ( + id text primary key, + deleted_at_ms integer not null + ); + "#, + ) + .context("unable to create tombstones table")?; + Ok(()) +} + fn default_enabled() -> bool { true } @@ -1584,12 +1910,11 @@ struct ConversationTitleState { fn load_conversation_title_state( conn: &Connection, - workspace_id: &str, id: &str, ) -> Result> { conn.query_row( - "select title, title_initialized from conversations where workspace_id = ?1 and id = ?2", - params![workspace_id, id], + "select title, title_initialized from conversations where id = ?1", + params![id], |row| { let initialized: i64 = row.get(1)?; Ok(ConversationTitleState { @@ -1771,7 +2096,8 @@ mod tests { let result = (|| -> Result<()> { store.migrate()?; let model = ModelRef::new("test", "model"); - let mut conversation = store.create_conversation("workspace", &model, "system")?; + let mut conversation = + store.create_conversation("workspace", None, &model, "system")?; conversation .history .push(message(Role::User, "First request", None)); @@ -1782,7 +2108,7 @@ mod tests { .expect("conversation should exist"); assert_eq!(loaded.title, "First request"); assert_eq!( - store.list_conversations("workspace")?[0].title, + store.list_conversations("workspace", None)?[0].title, "First request" ); @@ -1922,4 +2248,30 @@ mod tests { assert_eq!(tools[0].description, "custom edit instructions"); } + + #[test] + fn provider_archiving_and_restoring() -> Result<()> { + let path = std::env::temp_dir().join(format!("sinew-store-provider-test-{}.sqlite3", Uuid::new_v4())); + let store = AppStore { path: path.clone() }; + let result = (|| -> Result<()> { + store.migrate()?; + + // Check default status is active (or not found) + assert_eq!(store.get_provider_status("openai")?, "active"); + + // Archive provider + store.set_provider_status("openai", "archived")?; + assert_eq!(store.get_provider_status("openai")?, "archived"); + assert_eq!(store.list_archived_providers()?, vec!["openai".to_string()]); + + // Restore provider + store.set_provider_status("openai", "active")?; + assert_eq!(store.get_provider_status("openai")?, "active"); + assert!(store.list_archived_providers()?.is_empty()); + + Ok(()) + })(); + let _ = fs::remove_file(&path); + result + } } diff --git a/crates/sinew-app/src/subagent.rs b/crates/sinew-app/src/subagent.rs index b78d6c60..04ac83bb 100644 --- a/crates/sinew-app/src/subagent.rs +++ b/crates/sinew-app/src/subagent.rs @@ -7,10 +7,11 @@ use tokio::sync::mpsc; use crate::tool_run::FileChange; use crate::{ - run_turn, AgentEvent, AgentEventScope, AgentMode, BashTool, CreateImageTool, EditFileTool, - GlobTool, GoalWorkflowState, GrepTool, McpSettings, McpToolRegistry, QuestionTool, ReadTool, - SkillSettings, SkillTool, ToDoListTool, TodoListState, ToolRunResult, ToolSettings, TurnCancel, - TurnContext, WebFetchTool, WebSearchTool, WriteFileTool, + run_turn, AgentEvent, AgentEventScope, AgentMode, BashTool, CheckSotaTool, CodebaseSearchTool, ComputerUseTool, CreateImageTool, + EditFileTool, GlobTool, GoalWorkflowState, GrepTool, McpSettings, McpToolRegistry, + QuestionTool, ReadTool, SkillSettings, SkillTool, ToDoListTool, TodoListState, ToolRunResult, + DeleteFileTool, ListDirTool, ReadLintsTool, SharedEditorDiagnosticsStore, ToolSettings, TurnCancel, TurnContext, WebFetchTool, WebSearchTool, + WriteFileTool, }; const TOOL_PREFIX: &str = "subagent_"; @@ -68,6 +69,7 @@ pub struct SubAgentTool { max_tool_rounds: usize, service_tier: Option, cancel: TurnCancel, + editor_store: SharedEditorDiagnosticsStore, } impl SubAgentTool { @@ -82,6 +84,7 @@ impl SubAgentTool { max_tool_rounds: usize, service_tier: Option, cancel: TurnCancel, + editor_store: SharedEditorDiagnosticsStore, ) -> Self { Self { workspace_root, @@ -94,6 +97,7 @@ impl SubAgentTool { max_tool_rounds, service_tier, cancel, + editor_store, } } @@ -194,6 +198,7 @@ impl SubAgentTool { }; let child_context = TurnContext { provider, + workspace_root: self.workspace_root.clone(), model: agent.model.clone(), cache_key: Some(format!("subagent:{}:{}", agent.id, tool_call_id)), cache_stable_message_count: 0, @@ -213,14 +218,26 @@ impl SubAgentTool { goal_workflow: GoalWorkflowState::Idle, bash: Arc::new(BashTool::new(self.workspace_root.clone())), glob: Arc::new(GlobTool::new(self.workspace_root.clone())), + list_dir: Arc::new(ListDirTool::new(self.workspace_root.clone())), grep: Arc::new(GrepTool::new(self.workspace_root.clone())), + codebase_search: Arc::new(CodebaseSearchTool::new(self.workspace_root.clone())), + check_sota: Arc::new(CheckSotaTool::new()), + computer_use: Arc::new(ComputerUseTool::new()), read: Arc::new(ReadTool::new(self.workspace_root.clone())), edit_file: Arc::new(EditFileTool::new(self.workspace_root.clone())), write_file: Arc::new(WriteFileTool::new(self.workspace_root.clone())), + delete_file: Arc::new(DeleteFileTool::new(self.workspace_root.clone())), + read_lints: Arc::new(ReadLintsTool::new( + self.workspace_root.clone(), + self.editor_store.clone(), + )), create_image: Arc::new(CreateImageTool::with_settings( self.workspace_root.clone(), self.tool_settings.image_provider, self.tool_settings.openai_image_use_subscription, + self.tool_settings.gemini_image_use_subscription, + Some(self.tool_settings.openai_image_model.clone()), + Some(self.tool_settings.gemini_image_model.clone()), self.tool_settings.openai_image_api_key(), self.tool_settings.nano_banana_api_key(), )), @@ -251,6 +268,7 @@ impl SubAgentTool { event_tx: parent_event_tx, cancel: self.cancel.clone(), cmd_rx: child_cmd_rx, + steering_rx: None, }; let output = Box::pin(run_turn(child_context)).await; diff --git a/crates/sinew-app/src/team.rs b/crates/sinew-app/src/team.rs index 40eb0a8c..21872129 100644 --- a/crates/sinew-app/src/team.rs +++ b/crates/sinew-app/src/team.rs @@ -15,10 +15,11 @@ use uuid::Uuid; use crate::tool_run::{DiffLineKind, FileChange, FileChangeKind, ToolRunImage}; use crate::{ run_turn, subagent_system_prompt, tool_names, AgentEvent, AgentEventScope, AgentMode, BashTool, - CreateImageTool, EditFileTool, GlobTool, GoalWorkflowState, GrepTool, McpSettings, - McpToolRegistry, ReadTool, SkillSettings, SkillTool, SubAgentConfig, SubAgentSettings, - TodoListState, ToolRunResult, ToolSettings, TurnCancel, TurnContext, WebFetchTool, - WebSearchTool, WriteFileTool, + CheckSotaTool, CodebaseSearchTool, ComputerUseTool, CreateImageTool, DeleteFileTool, EditFileTool, GlobTool, + GoalWorkflowState, GrepTool, ListDirTool, + McpSettings, McpToolRegistry, ReadLintsTool, ReadTool, SharedEditorDiagnosticsStore, SkillSettings, SkillTool, SubAgentConfig, + SubAgentSettings, TodoListState, ToolRunResult, ToolSettings, TurnCancel, TurnContext, + WebFetchTool, WebSearchTool, WriteFileTool, }; const TEAM_RUN_TOOL: &str = tool_names::TEAM_RUN; diff --git a/crates/sinew-app/src/team/agent_turns.rs b/crates/sinew-app/src/team/agent_turns.rs index 3a20d1bb..669b9720 100644 --- a/crates/sinew-app/src/team/agent_turns.rs +++ b/crates/sinew-app/src/team/agent_turns.rs @@ -52,6 +52,7 @@ impl TeamTool { }; let child_context = TurnContext { provider, + workspace_root: self.workspace_root.clone(), model: agent.model.clone(), cache_key: Some(format!( "team:{}:{}:{}", @@ -70,7 +71,11 @@ impl TeamTool { goal_workflow: GoalWorkflowState::Idle, bash: Arc::new(BashTool::new(self.workspace_root.clone())), glob: Arc::new(GlobTool::new(self.workspace_root.clone())), + list_dir: Arc::new(ListDirTool::new(self.workspace_root.clone())), grep: Arc::new(GrepTool::new(self.workspace_root.clone())), + codebase_search: Arc::new(CodebaseSearchTool::new(self.workspace_root.clone())), + check_sota: Arc::new(CheckSotaTool::new()), + computer_use: Arc::new(ComputerUseTool::new()), read: Arc::new(ReadTool::new(self.workspace_root.clone())), edit_file: Arc::new( EditFileTool::new(self.workspace_root.clone()) @@ -80,11 +85,19 @@ impl TeamTool { WriteFileTool::new(self.workspace_root.clone()) .with_workspace_write_lock(workspace_write_lock.clone()), ), + delete_file: Arc::new(DeleteFileTool::new(self.workspace_root.clone())), + read_lints: Arc::new(ReadLintsTool::new( + self.workspace_root.clone(), + self.editor_store.clone(), + )), create_image: Arc::new( CreateImageTool::with_settings( self.workspace_root.clone(), self.tool_settings.image_provider, self.tool_settings.openai_image_use_subscription, + self.tool_settings.gemini_image_use_subscription, + Some(self.tool_settings.openai_image_model.clone()), + Some(self.tool_settings.gemini_image_model.clone()), self.tool_settings.openai_image_api_key(), self.tool_settings.nano_banana_api_key(), ) @@ -117,6 +130,7 @@ impl TeamTool { event_tx: child_event_tx, cancel: self.cancel.clone(), cmd_rx: child_cmd_rx, + steering_rx: None, }; let engine = tokio::spawn(async move { run_turn(child_context).await }); diff --git a/crates/sinew-app/src/team/context.rs b/crates/sinew-app/src/team/context.rs index 246a0e3a..968fd91e 100644 --- a/crates/sinew-app/src/team/context.rs +++ b/crates/sinew-app/src/team/context.rs @@ -14,6 +14,7 @@ impl TeamTool { max_tool_rounds: usize, service_tier: Option, runtime: Arc>, + editor_store: SharedEditorDiagnosticsStore, cancel: TurnCancel, ) -> Self { Self { @@ -29,6 +30,7 @@ impl TeamTool { max_tool_rounds, service_tier, runtime, + editor_store, cancel, current_agent: None, } diff --git a/crates/sinew-app/src/team/descriptors.rs b/crates/sinew-app/src/team/descriptors.rs index 597931fa..2084b5f9 100644 --- a/crates/sinew-app/src/team/descriptors.rs +++ b/crates/sinew-app/src/team/descriptors.rs @@ -228,14 +228,14 @@ impl TeamTool { pub fn summary_for_tool_name(&self, name: &str) -> Option { let canonical = tool_names::canonical_tool_name(name); match canonical { - TEAM_RUN_TOOL => Some("Agent Swarm · run".to_string()), - TEAM_CREATE_TOOL => Some("Agent Swarm · disabled create".to_string()), - AGENT_TOOL => Some("Agent Swarm · disabled agent spawn".to_string()), - SEND_MESSAGE_TOOL => Some("Agent Swarm · message".to_string()), - TASK_CREATE_TOOL => Some("Task · create".to_string()), - TASK_UPDATE_TOOL => Some("Task · update".to_string()), - TEAM_STATUS_TOOL => Some("Agent Swarm · status".to_string()), - TEAM_STOP_TOOL => Some("Agent Swarm · stop".to_string()), + TEAM_RUN_TOOL => Some("Agent Swarm - run".to_string()), + TEAM_CREATE_TOOL => Some("Agent Swarm - disabled create".to_string()), + AGENT_TOOL => Some("Agent Swarm - disabled agent spawn".to_string()), + SEND_MESSAGE_TOOL => Some("Agent Swarm - message".to_string()), + TASK_CREATE_TOOL => Some("Task - create".to_string()), + TASK_UPDATE_TOOL => Some("Task - update".to_string()), + TEAM_STATUS_TOOL => Some("Agent Swarm - status".to_string()), + TEAM_STOP_TOOL => Some("Agent Swarm - stop".to_string()), _ => None, } } diff --git a/crates/sinew-app/src/team/model.rs b/crates/sinew-app/src/team/model.rs index 7c01f141..a6d3b3af 100644 --- a/crates/sinew-app/src/team/model.rs +++ b/crates/sinew-app/src/team/model.rs @@ -130,6 +130,7 @@ pub struct TeamTool { pub(super) max_tool_rounds: usize, pub(super) service_tier: Option, pub(super) runtime: Arc>, + pub(super) editor_store: SharedEditorDiagnosticsStore, pub(super) cancel: TurnCancel, pub(super) current_agent: Option, } diff --git a/crates/sinew-app/src/team/tests.rs b/crates/sinew-app/src/team/tests.rs index 5a9bd532..2ad4ccac 100644 --- a/crates/sinew-app/src/team/tests.rs +++ b/crates/sinew-app/src/team/tests.rs @@ -400,8 +400,8 @@ fn unlocked_task_wake_message_names_task_and_start_command() { assert!(message.contains("#2")); assert!(message.contains("task 2")); - assert!(message.contains("TaskList action=update taskId=2 status=in_progress")); - assert!(message.contains("do not call TaskList action=list")); + assert!(message.contains("task_list action=update taskId=2 status=in_progress")); + assert!(message.contains("do not call task_list action=list")); } #[test] @@ -537,6 +537,7 @@ fn test_team_tool() -> TeamTool { 1, None, Arc::new(RwLock::new(TeamRuntime::default())), + crate::new_editor_diagnostics_store(), TurnCancel::empty(), ) } diff --git a/crates/sinew-app/src/test_flags.rs b/crates/sinew-app/src/test_flags.rs new file mode 100644 index 00000000..2c44637e Binary files /dev/null and b/crates/sinew-app/src/test_flags.rs differ diff --git a/crates/sinew-app/src/tool_names.rs b/crates/sinew-app/src/tool_names.rs index 8ea277db..0a9c3a9a 100644 --- a/crates/sinew-app/src/tool_names.rs +++ b/crates/sinew-app/src/tool_names.rs @@ -8,11 +8,15 @@ pub const BASH_INPUT: &str = "bash_input"; pub const READ: &str = "read"; pub const GLOB: &str = "glob"; pub const GREP: &str = "grep"; +pub const CODEBASE_SEARCH: &str = "codebase_search"; pub const EDIT_FILE: &str = "edit_file"; pub const WRITE_FILE: &str = "write_file"; pub const WEB_SEARCH: &str = "web_search"; pub const WEB_FETCH: &str = "web_fetch"; pub const CREATE_IMAGE: &str = "create_image"; +pub const LIST_DIR: &str = "list_dir"; +pub const DELETE_FILE: &str = "delete_file"; +pub const READ_LINTS: &str = "read_lints"; pub const QUESTION: &str = "question"; pub const TODO_LIST: &str = "todo_list"; pub const CLEAN_CONTEXT: &str = "clean_context"; @@ -20,6 +24,8 @@ pub const LOAD_MCP_TOOL: &str = "load_mcp_tool"; pub const SKILL: &str = "skill"; pub const UPDATE_GOAL: &str = "update_goal"; pub const CONTEXT_COMPACTION: &str = "context_compaction"; +pub const CHECK_SOTA: &str = "check_sota"; +pub const COMPUTER_USE: &str = "computer_use"; pub const TEAM_RUN: &str = "team_run"; pub const TEAM_CREATE: &str = "team_create"; @@ -51,6 +57,8 @@ pub fn canonical_tool_name(name: &str) -> &str { "TaskCreate" => TASK_CREATE, "TaskList" => TASK_LIST, "TaskUpdate" => TASK_UPDATE, + "CheckSota" | "check_sota" => CHECK_SOTA, + "ComputerUse" | "computer_use" => COMPUTER_USE, _ => name, } } @@ -58,3 +66,26 @@ pub fn canonical_tool_name(name: &str) -> &str { pub fn is_tool_name(name: &str, canonical: &str) -> bool { canonical_tool_name(name) == canonical } + +pub fn is_cursor_compatible_tool(name: &str) -> bool { + let canonical = canonical_tool_name(name); + matches!( + canonical, + BASH + | GLOB + | LIST_DIR + | DELETE_FILE + | READ_LINTS + | GREP + | CODEBASE_SEARCH + | READ + | EDIT_FILE + | WRITE_FILE + | WEB_SEARCH + | WEB_FETCH + | TODO_LIST + | QUESTION + | LOAD_MCP_TOOL + | COMPUTER_USE + ) || name.starts_with("mcp__") +} diff --git a/crates/sinew-app/src/web.rs b/crates/sinew-app/src/web.rs index acefa840..118690fb 100644 --- a/crates/sinew-app/src/web.rs +++ b/crates/sinew-app/src/web.rs @@ -1,4 +1,4 @@ -use std::{env, time::Duration}; +use std::{env, time::{Duration, Instant}}; use anyhow::{bail, Context, Result}; use futures_util::StreamExt; @@ -106,10 +106,13 @@ impl WebSearchTool { } pub async fn run(&self, input: Value) -> ToolRunResult { - match self.search(input).await { + let start = Instant::now(); + let result = match self.search(input).await { Ok(output) => ToolRunResult::ok(output, Vec::new()), Err(err) => ToolRunResult::err(err.to_string(), Vec::new()), - } + }; + tracing::debug!(tool = "web_search", tool_ms = start.elapsed().as_millis(), "web search completed"); + result } async fn search(&self, input: Value) -> Result { @@ -278,10 +281,13 @@ impl WebFetchTool { } pub async fn run(&self, input: Value) -> ToolRunResult { - match self.fetch(input).await { + let start = Instant::now(); + let result = match self.fetch(input).await { Ok(output) => ToolRunResult::ok(output, Vec::new()), Err(err) => ToolRunResult::err(err.to_string(), Vec::new()), - } + }; + tracing::debug!(tool = "web_fetch", tool_ms = start.elapsed().as_millis(), "web fetch completed"); + result } async fn fetch(&self, input: Value) -> Result { diff --git a/crates/sinew-app/src/workspace.rs b/crates/sinew-app/src/workspace.rs index 08af51af..311fcf42 100644 --- a/crates/sinew-app/src/workspace.rs +++ b/crates/sinew-app/src/workspace.rs @@ -1,4 +1,4 @@ -use std::{ +use std::{ cmp::Ordering, fs, path::{Component, Path, PathBuf}, @@ -7,6 +7,7 @@ use std::{ use anyhow::{anyhow, bail, Context, Result}; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; +use rayon::prelude::*; use serde::{Deserialize, Serialize}; use crate::text::{decode_text, encode_text, TextEncoding}; @@ -42,13 +43,24 @@ const IGNORED_DIRS: &[&str] = &[ "venv", ".mypy_cache", "out", + ".sinew", ]; -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CodebaseIndexStatus { + pub files_indexed: usize, + pub chunks_indexed: usize, + pub engine: String, + pub semantic_enabled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WorkspaceInfo { pub path: String, pub name: String, + pub codebase_index: CodebaseIndexStatus, } #[derive(Debug, Clone, Copy, Serialize, Deserialize)] @@ -58,7 +70,7 @@ pub enum WorkspaceEntryKind { Directory, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WorkspaceEntry { pub name: String, @@ -68,7 +80,7 @@ pub struct WorkspaceEntry { pub has_children: bool, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FileDocument { pub name: String, @@ -83,7 +95,7 @@ pub struct FileDocument { pub image_data: Option, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WorkspaceSearchResult { pub query: String, @@ -92,7 +104,7 @@ pub struct WorkspaceSearchResult { pub files: Vec, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WorkspaceSearchFile { pub name: String, @@ -103,7 +115,7 @@ pub struct WorkspaceSearchFile { pub matches: Vec, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WorkspaceSearchMatch { pub line_number: usize, @@ -141,6 +153,14 @@ pub fn normalize_workspace_root(path: impl AsRef) -> Result { bail!("workspace must be a directory"); } + #[cfg(target_os = "windows")] + { + // On Windows, normalize the path to lowercase to prevent case mismatch bugs + // across different computers (e.g. C:\Dev\Sinew vs C:\Dev\sinew). + let lower_str = canonical.to_string_lossy().to_lowercase(); + Ok(PathBuf::from(lower_str)) + } + #[cfg(not(target_os = "windows"))] Ok(canonical) } @@ -152,6 +172,25 @@ pub fn workspace_info(root: &Path) -> WorkspaceInfo { .and_then(|value| value.to_str()) .map(|value| value.to_string()) .unwrap_or_else(|| root.display().to_string()), + codebase_index: codebase_index_status(root), + } +} + +pub fn codebase_index_status(root: &Path) -> CodebaseIndexStatus { + let stats = sinew_index::index_stats_isolated(root).unwrap_or_default(); + let isolated = sinew_index::process_isolation_enabled(); + let semantic_enabled = isolated || std::env::var_os("SINEW_INDEX_EMBEDDINGS").is_some(); + CodebaseIndexStatus { + files_indexed: stats.files_indexed, + chunks_indexed: stats.chunks_indexed, + engine: if isolated { + "isolated-helper+embeddings".into() + } else if semantic_enabled { + "fts+embeddings".into() + } else { + "fts".into() + }, + semantic_enabled, } } @@ -266,58 +305,18 @@ pub fn search_workspace_files(root: &Path, query: &str) -> Result>(); - let mut files_scanned = 0usize; - let mut total_matches = 0usize; - let mut results = Vec::::new(); - - for entry in list_workspace_files(root)? { - if results.len() >= MAX_SEARCH_FILES && total_matches >= MAX_SEARCH_MATCHES { - break; - } - - let path_lower = entry.relative_path.to_lowercase(); - let path_score = path_match_score(&query_lower, &terms, &path_lower); - let mut match_count = 0usize; - let mut matches = Vec::new(); - - if total_matches < MAX_SEARCH_MATCHES { - if let Ok(doc) = read_workspace_file(root, &entry.relative_path) { - if let Some(content) = doc.content { - files_scanned += 1; - for (index, raw_line) in content.lines().enumerate() { - let Some(line_match) = line_match(&query_lower, &terms, raw_line) else { - continue; - }; - match_count += 1; - total_matches += 1; - if matches.len() < MAX_SEARCH_MATCHES_PER_FILE { - matches.push(search_match_from_line(index + 1, raw_line, line_match)); - } - if total_matches >= MAX_SEARCH_MATCHES { - break; - } - } - } - } - } - - if path_score.is_none() && matches.is_empty() { - continue; - } + let entries = list_workspace_files(root)?; + let mut results = entries + .par_iter() + .filter_map(|entry| search_workspace_entry(root, entry, &query_lower, &terms)) + .collect::>(); - let score = search_file_score(path_score, match_count, &entry.relative_path); - results.push(ScoredSearchFile { - score, - file: WorkspaceSearchFile { - name: entry.name, - relative_path: entry.relative_path, - absolute_path: entry.absolute_path, - path_match: path_score.is_some(), - match_count, - matches, - }, - }); - } + let files_scanned = results.iter().map(|result| result.files_scanned).sum(); + let total_matches = results + .iter() + .map(|result| result.total_matches) + .sum::() + .min(MAX_SEARCH_MATCHES); results.sort_by(|left, right| { right @@ -325,6 +324,7 @@ pub fn search_workspace_files(root: &Path, query: &str) -> Result Result Option { + let path_lower = entry.relative_path.to_lowercase(); + let path_score = path_match_score(query_lower, terms, &path_lower); + let mut match_count = 0usize; + let mut matches = Vec::new(); + let mut files_scanned = 0usize; + + if let Ok(doc) = read_workspace_file(root, &entry.relative_path) { + if let Some(content) = doc.content { + files_scanned = 1; + for (index, raw_line) in content.lines().enumerate() { + let Some(line_match) = line_match(query_lower, terms, raw_line) else { + continue; + }; + match_count += 1; + if matches.len() < MAX_SEARCH_MATCHES_PER_FILE { + matches.push(search_match_from_line(index + 1, raw_line, line_match)); + } + if match_count >= MAX_SEARCH_MATCHES_PER_FILE { + break; + } + } + } + } + + if path_score.is_none() && matches.is_empty() { + return None; + } + + let score = search_file_score(path_score, match_count, &entry.relative_path); + Some(ScoredSearchFile { + score, + files_scanned, + total_matches: match_count, + file: WorkspaceSearchFile { + name: entry.name.clone(), + relative_path: entry.relative_path.clone(), + absolute_path: entry.absolute_path.clone(), + path_match: path_score.is_some(), + match_count, + matches, + }, + }) +} + fn is_walk_ignored(entry: &walkdir::DirEntry) -> bool { if entry.depth() == 0 { return false; @@ -347,6 +397,8 @@ fn is_walk_ignored(entry: &walkdir::DirEntry) -> bool { struct ScoredSearchFile { score: i64, + files_scanned: usize, + total_matches: usize, file: WorkspaceSearchFile, } @@ -1230,11 +1282,36 @@ fn workspace_entry_from_path(root: &Path, path: &Path) -> Result }) } -fn ensure_within_root(root: &Path, path: &Path) -> Result<()> { - if path.starts_with(root) { - Ok(()) +#[cfg(target_os = "windows")] +fn clean_windows_path(path: &Path) -> String { + let s = path.to_string_lossy().replace('\\', "/"); + let clean = if let Some(stripped) = s.strip_prefix("//?/") { + stripped.to_string() } else { - bail!("path escapes workspace") + s + }; + clean.to_lowercase() +} + +fn ensure_within_root(root: &Path, path: &Path) -> Result<()> { + let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); + #[cfg(target_os = "windows")] + { + let root_str = clean_windows_path(&canonical_root); + let path_str = clean_windows_path(path); + if path_str == root_str || path_str.starts_with(&format!("{}/", root_str)) { + Ok(()) + } else { + bail!("path escapes workspace") + } + } + #[cfg(not(target_os = "windows"))] + { + if path.starts_with(&canonical_root) { + Ok(()) + } else { + bail!("path escapes workspace") + } } } @@ -1334,17 +1411,38 @@ fn preview_image_media_type(path: &Path) -> Option<&'static str> { } fn relative_from_root(root: &Path, path: &Path) -> Result { - let relative = path - .strip_prefix(root) - .with_context(|| format!("{} is outside the workspace", path.display()))?; - Ok(relative - .components() - .filter_map(|component| match component { - Component::Normal(value) => Some(value.to_string_lossy().into_owned()), - _ => None, - }) - .collect::>() - .join("/")) + let root_canonical = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); + let path_canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + #[cfg(target_os = "windows")] + { + let root_str = root_canonical.to_string_lossy().replace('\\', "/"); + let path_str = path_canonical.to_string_lossy().replace('\\', "/"); + let clean_root = if let Some(stripped) = root_str.strip_prefix("//?/") { stripped } else { &root_str }; + let clean_path = if let Some(stripped) = path_str.strip_prefix("//?/") { stripped } else { &path_str }; + let root_lower = clean_root.to_lowercase(); + let path_lower = clean_path.to_lowercase(); + if path_lower.starts_with(&root_lower) { + let relative_part = &clean_path[root_lower.len()..]; + let trimmed = relative_part.trim_start_matches('/'); + Ok(trimmed.to_string()) + } else { + bail!("{} is outside the workspace", path.display()); + } + } + #[cfg(not(target_os = "windows"))] + { + let relative = path_canonical + .strip_prefix(&root_canonical) + .with_context(|| format!("{} is outside the workspace", path.display()))?; + Ok(relative + .components() + .filter_map(|component| match component { + Component::Normal(value) => Some(value.to_string_lossy().into_owned()), + _ => None, + }) + .collect::>() + .join("/")) + } } fn directory_has_children(path: &Path) -> bool { diff --git a/crates/sinew-app/src/write.rs b/crates/sinew-app/src/write.rs index cb3591df..677184ae 100644 --- a/crates/sinew-app/src/write.rs +++ b/crates/sinew-app/src/write.rs @@ -1,7 +1,7 @@ use std::{ collections::HashMap, fs, - path::{Component, Path, PathBuf}, + path::{Path, PathBuf}, sync::Arc, }; @@ -11,6 +11,9 @@ use serde_json::{json, Value}; use sinew_core::ToolDescriptor; use tokio::sync::{OwnedSemaphorePermit, Semaphore}; +#[cfg(not(target_os = "windows"))] +use std::path::Component; + use crate::{ read::{fingerprint_path, ReadFingerprint}, tool_run::{diff_snapshots, snapshot_workspace_paths, ToolRunResult}, @@ -218,14 +221,38 @@ fn resolve_relative_target(root: &Path, normalized: &str) -> Result { Ok(path) } +fn clean_windows_path(path: &Path) -> String { + let s = path.to_string_lossy().replace('\\', "/"); + let clean = if let Some(stripped) = s.strip_prefix("//?/") { + stripped.to_string() + } else { + s + }; + clean.to_lowercase() +} + +fn is_under_root(root: &Path, path: &Path) -> bool { + #[cfg(target_os = "windows")] + { + let root_str = clean_windows_path(root); + let path_str = clean_windows_path(path); + path_str == root_str || path_str.starts_with(&format!("{}/", root_str)) + } + #[cfg(not(target_os = "windows"))] + { + path.starts_with(root) + } +} + fn ensure_target_in_workspace(root: &Path, path: &Path) -> Result<()> { if path_has_entry(path) { - if path.starts_with(root) { + let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + if is_under_root(root, &canonical) { return Ok(()); } bail!("{} is outside the workspace", path.display()); } - if path.starts_with(root) { + if is_under_root(root, path) { ensure_existing_ancestor_in_workspace(root, path) } else { bail!("{} is outside the workspace", path.display()) @@ -245,7 +272,7 @@ fn ensure_existing_ancestor_in_workspace(root: &Path, path: &Path) -> Result<()> let canonical = ancestor .canonicalize() .with_context(|| format!("unable to resolve path {}", ancestor.display()))?; - if canonical.starts_with(root) { + if is_under_root(root, &canonical) { return Ok(()); } bail!("{} is outside the workspace", path.display()); @@ -257,36 +284,41 @@ fn ensure_existing_ancestor_in_workspace(root: &Path, path: &Path) -> Result<()> } fn ensure_new_absolute_path_is_under_root(root: &Path, path: &Path) -> Result<()> { - let relative = path - .strip_prefix(root) - .with_context(|| format!("{} is outside the workspace", path.display()))?; - if relative.as_os_str().is_empty() { - bail!("path cannot be the workspace root"); - } - for component in relative.components() { - match component { - Component::Normal(_) => {} - Component::CurDir => {} - Component::ParentDir | Component::RootDir | Component::Prefix(_) => { - bail!("{} is outside the workspace", path.display()) - } - } + if is_under_root(root, path) { + Ok(()) + } else { + bail!("{} is outside the workspace", path.display()) } - Ok(()) } fn relative_path(root: &Path, path: &Path) -> Result { - let relative = path - .strip_prefix(root) - .with_context(|| format!("{} is outside the workspace", path.display()))?; - Ok(relative - .components() - .filter_map(|component| match component { - Component::Normal(value) => Some(value.to_string_lossy().into_owned()), - _ => None, - }) - .collect::>() - .join("/")) + #[cfg(target_os = "windows")] + { + let root_str = clean_windows_path(root); + let path_str = clean_windows_path(path); + if path_str == root_str { + bail!("path cannot be the workspace root"); + } + if path_str.starts_with(&format!("{}/", root_str)) { + let relative = &path_str[root_str.len() + 1..]; + return Ok(relative.to_string()); + } + bail!("{} is outside the workspace", path.display()); + } + #[cfg(not(target_os = "windows"))] + { + let relative = path + .strip_prefix(root) + .with_context(|| format!("{} is outside the workspace", path.display()))?; + Ok(relative + .components() + .filter_map(|component| match component { + Component::Normal(value) => Some(value.to_string_lossy().into_owned()), + _ => None, + }) + .collect::>() + .join("/")) + } } fn write_text_file(path: &Path, content: &str) -> Result<()> { diff --git a/crates/sinew-core/src/provider.rs b/crates/sinew-core/src/provider.rs index 6f02c401..9f323688 100644 --- a/crates/sinew-core/src/provider.rs +++ b/crates/sinew-core/src/provider.rs @@ -26,6 +26,7 @@ pub struct ProviderRequest { pub cache_key: Option, pub cache_stable_message_count: Option, pub service_tier: Option, + pub workspace_root: Option, } #[derive(Debug, Clone, Copy)] @@ -47,6 +48,7 @@ impl ProviderRequest { cache_key: None, cache_stable_message_count: None, service_tier: None, + workspace_root: None, } } @@ -83,6 +85,14 @@ impl ProviderRequest { self } + pub fn with_workspace_root(mut self, value: impl Into) -> Self { + let value = value.into(); + if !value.trim().is_empty() { + self.workspace_root = Some(value); + } + self + } + pub fn effective_effort(&self) -> Option { self.effort.or(self.model.effort) } diff --git a/crates/sinew-cursor/Cargo.toml b/crates/sinew-cursor/Cargo.toml new file mode 100644 index 00000000..80858d14 --- /dev/null +++ b/crates/sinew-cursor/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "sinew-cursor" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Cursor provider integration for Sinew (Composer subscription + API pools)" + +[dependencies] +sinew-core = { workspace = true } +sinew-index = { workspace = true } + +anyhow = { workspace = true } +async-trait = { workspace = true } +base64 = { workspace = true } +bytes = { workspace = true } +directories = { workspace = true } +futures = { workspace = true } +async-stream = "0.3" +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +rand = { workspace = true } +sha2 = { workspace = true } +url = { workspace = true } +uuid = { workspace = true } +prost = "0.13" +prost-reflect = "0.14" +prost-types = "0.13" +hyper = { version = "1.6", features = ["client", "http2"] } +hyper-util = { version = "0.1", features = ["client", "client-legacy", "http2", "tokio"] } +http-body-util = { version = "0.1", features = ["full"] } +hyper-rustls = { version = "0.27", features = ["http2", "ring", "native-tokio"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } +tokio-util = { version = "0.7", features = ["io"] } +tokio-stream = "0.1" +hex = "0.4" + +[dev-dependencies] +tracing-subscriber = { workspace = true } + +[lints] +workspace = true diff --git a/crates/sinew-cursor/proto/README.md b/crates/sinew-cursor/proto/README.md new file mode 100644 index 00000000..bf5516d6 --- /dev/null +++ b/crates/sinew-cursor/proto/README.md @@ -0,0 +1,4 @@ +# agent.v1 protobuf + +- `agent.fds` — legacy Buf `fileDesc` export (`node scripts/export-agent-descriptor.mjs`). +- `agent.pb` — standard `google.protobuf.FileDescriptorSet` for `prost-reflect` (`node scripts/agent-bridge/export-agent-fds-prost.mjs`). diff --git a/crates/sinew-cursor/proto/agent.fds b/crates/sinew-cursor/proto/agent.fds new file mode 100644 index 00000000..ea0f31fa Binary files /dev/null and b/crates/sinew-cursor/proto/agent.fds differ diff --git a/crates/sinew-cursor/proto/agent.pb b/crates/sinew-cursor/proto/agent.pb new file mode 100644 index 00000000..e0a75943 Binary files /dev/null and b/crates/sinew-cursor/proto/agent.pb differ diff --git a/crates/sinew-cursor/src/agent/bridge.rs b/crates/sinew-cursor/src/agent/bridge.rs new file mode 100644 index 00000000..a65419b3 --- /dev/null +++ b/crates/sinew-cursor/src/agent/bridge.rs @@ -0,0 +1,331 @@ +use std::process::Stdio; +use std::sync::Arc; + +use async_stream::try_stream; +use sinew_core::{ + AppError, PartKind, ProviderRequest, ProviderStream, Result, StopReason, StreamEvent, + ToolCallIntro, Usage, +}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::Command; +use tokio::sync::{mpsc, Mutex}; + +use crate::identity::CursorIdeIdentity; +use crate::model_info; +use crate::workspace; + +use super::conversation_id::stable_agent_conversation_id; +use super::setup::{ensure_agent_bridge_ready, run_stream_script, tsx_executable}; +use super::state::AgentConversationStore; +use super::tools::execute_tool; +use super::transcript::split_transcript; + +pub(crate) fn tools_json(request: &ProviderRequest) -> Vec { + request + .tools + .iter() + .map(|tool| { + serde_json::json!({ + "name": tool.name, + "description": tool.description, + "parameters": tool.input_schema, + }) + }) + .collect() +} + +/// Stream Composer via Node `agent-bridge` (protobuf Run over HTTP/2). +pub async fn stream_via_node_bridge( + identity: &CursorIdeIdentity, + token: String, + request: ProviderRequest, +) -> Result { + let bridge_dir = ensure_agent_bridge_ready().await?; + let script = run_stream_script(&bridge_dir); + let tsx = tsx_executable(&bridge_dir); + + let model = model_info::resolve_agent_model_id(&request.model, request.service_tier); + let system = request.system_prompt.clone().unwrap_or_default(); + let (history_turns, current_user) = split_transcript(&request.transcript); + let user = if current_user.is_empty() { + request + .transcript + .last() + .map(|m| m.text()) + .unwrap_or_default() + } else { + current_user + }; + let workspace = request.workspace_root.clone().unwrap_or_default(); + let cache_key = request.cache_key.clone().unwrap_or_default(); + let conversation_id = stable_agent_conversation_id(request.cache_key.as_deref()); + let persisted = AgentConversationStore::load().get(&cache_key); + let trimmed = workspace.trim(); + let workspace_snapshot = if !trimmed.is_empty() { + workspace::snapshot(trimmed).map(|snap| { + serde_json::json!({ + "uri": snap.uri, + "name": snap.name, + "branch": snap.branch, + "gitStatus": snap.git_status, + "projectLayout": snap.project_layout, + }) + }) + } else { + None + }; + + // Omit `apiHeaders` so Node uses the same minimal CLI defaults as `test-live.ps1`. + let _ = identity; + let mut payload = serde_json::json!({ + "accessToken": token, + "modelId": model, + "systemPrompt": system, + "userText": user, + "workspaceRoot": workspace, + "conversationId": conversation_id, + "tools": tools_json(&request), + "turns": history_turns, + "workspaceSnapshot": workspace_snapshot, + }); + if let Some(state) = persisted { + if let Some(checkpoint) = state.checkpoint_b64 { + payload["checkpointB64"] = serde_json::Value::String(checkpoint); + } + if !state.blobs.is_empty() { + payload["blobs"] = serde_json::json!(state.blobs); + } + } + + let mut cmd = Command::new(&tsx); + cmd.arg(&script) + .current_dir(&bridge_dir) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + #[cfg(windows)] + { + cmd.creation_flags(0x08000000); + } + + let mut child = cmd.spawn() + .map_err(|err| { + AppError::Provider(format!( + "failed to spawn agent bridge (tsx): {err}. Vérifiez Node/npm (diagnostic SOTA)." + )) + })?; + + let stdin = Arc::new(Mutex::new( + child + .stdin + .take() + .ok_or_else(|| AppError::Provider("agent bridge stdin unavailable".into()))?, + )); + let stdout = child + .stdout + .take() + .ok_or_else(|| AppError::Provider("agent bridge stdout unavailable".into()))?; + + let json = serde_json::to_string(&payload) + .map_err(|err| AppError::Provider(format!("agent bridge payload: {err}")))?; + { + let mut guard = stdin.lock().await; + guard + .write_all(json.as_bytes()) + .await + .map_err(|err| AppError::Provider(format!("agent bridge write: {err}")))?; + guard + .write_all(b"\n") + .await + .map_err(|err| AppError::Provider(format!("agent bridge write: {err}")))?; + } + + let (line_tx, mut line_rx) = mpsc::channel::(128); + let reader_task = tokio::spawn(async move { + let reader = BufReader::new(stdout); + let mut lines = reader.lines(); + while let Ok(Some(line)) = lines.next_line().await { + if line_tx.send(line).await.is_err() { + break; + } + } + }); + + let model_name = request.model.name.clone(); + let workspace_for_tools = workspace.clone(); + let cache_key_for_save = cache_key.clone(); + let events = try_stream! { + yield StreamEvent::MessageStart { model: model_name.clone() }; + let text_index = 0usize; + let thinking_index = 1usize; + let mut next_tool_index = 2usize; + let mut open_part: Option<(usize, PartKind)> = None; + let mut started_text = false; + let mut tools_executed = 0u32; + let mut usage = Usage::default(); + + while let Some(line) = line_rx.recv().await { + let line = line.trim(); + if line.is_empty() { + continue; + } + let value: serde_json::Value = serde_json::from_str(line) + .map_err(|err| AppError::Decode(format!("agent bridge line: {err} ({line})")))?; + + if let Some(err) = value.get("error").and_then(|v| v.as_str()) { + Err(AppError::Network(err.to_string()))?; + } + + if value.get("type").and_then(|v| v.as_str()) == Some("checkpoint") { + if !cache_key_for_save.trim().is_empty() { + if let (Some(checkpoint), Some(blobs_value)) = ( + value.get("checkpointB64").and_then(|v| v.as_str()), + value.get("blobs"), + ) { + let blobs: std::collections::HashMap = + serde_json::from_value(blobs_value.clone()).unwrap_or_default(); + let mut store = AgentConversationStore::load(); + let _ = store.save_checkpoint( + &cache_key_for_save, + checkpoint.to_string(), + blobs, + ); + } + } + continue; + } + + if value.get("type").and_then(|v| v.as_str()) == Some("usage") { + let output = value + .get("outputTokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32; + let total = value + .get("totalTokens") + .and_then(|v| v.as_u64()) + .unwrap_or(output as u64) as u32; + usage.output_tokens = output; + usage.total_tokens = total.max(output); + yield StreamEvent::Usage { usage }; + continue; + } + + if value.get("type").and_then(|v| v.as_str()) == Some("tool_request") { + let tool_name = value + .get("toolName") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let tool_id = value + .get("toolCallId") + .and_then(|v| v.as_str()) + .or_else(|| value.get("execId").and_then(|v| v.as_str())) + .unwrap_or("composer-tool") + .to_string(); + let args = value.get("args").cloned().unwrap_or(serde_json::Value::Null); + if let Some((idx, _)) = open_part.take() { + yield StreamEvent::PartStop { index: idx }; + } + let tool_index = next_tool_index; + next_tool_index += 1; + let args_json = serde_json::to_string(&args) + .map_err(|err| AppError::Provider(format!("tool args json: {err}")))?; + yield StreamEvent::PartStart { + index: tool_index, + kind: PartKind::ToolCall, + tool: Some(ToolCallIntro { + id: tool_id.clone(), + name: tool_name.to_string(), + }), + }; + yield StreamEvent::ToolJsonDelta { + index: tool_index, + chunk: args_json, + }; + let content = execute_tool(tool_name, &args, &workspace_for_tools); + let is_error = content.starts_with("Error:"); + yield StreamEvent::PartMeta { + index: tool_index, + meta: serde_json::json!({ + "composer_bridge": { + "content": content, + "is_error": is_error, + } + }), + }; + yield StreamEvent::PartStop { index: tool_index }; + tools_executed += 1; + let response = serde_json::json!({ + "type": "tool_response", + "execId": value.get("execId"), + "execMsgId": value.get("execMsgId"), + "toolCallId": value.get("toolCallId"), + "content": content, + "isError": is_error, + }); + let response_line = serde_json::to_string(&response) + .map_err(|err| AppError::Provider(format!("tool response json: {err}")))?; + let mut guard = stdin.lock().await; + guard + .write_all(response_line.as_bytes()) + .await + .map_err(|err| AppError::Provider(format!("agent bridge tool write: {err}")))?; + guard + .write_all(b"\n") + .await + .map_err(|err| AppError::Provider(format!("agent bridge tool write: {err}")))?; + continue; + } + + let event_type = value.get("type").and_then(|v| v.as_str()).unwrap_or(""); + let delta = value.get("delta").and_then(|v| v.as_str()).unwrap_or(""); + if delta.is_empty() && event_type != "thinking" && event_type != "text" { + continue; + } + if delta.is_empty() { + continue; + } + + let (index, kind) = if event_type == "thinking" { + (thinking_index, PartKind::Thinking) + } else { + (text_index, PartKind::Text) + }; + + if open_part.map(|(_, k)| k) != Some(kind) { + if let Some((idx, _)) = open_part.take() { + yield StreamEvent::PartStop { index: idx }; + } + open_part = Some((index, kind)); + yield StreamEvent::PartStart { index, kind, tool: None }; + } + started_text = true; + match kind { + PartKind::Text => yield StreamEvent::TextDelta { index, delta: delta.to_string() }, + PartKind::Thinking => yield StreamEvent::ThinkingDelta { index, delta: delta.to_string() }, + _ => {} + } + } + + let _ = reader_task.await; + + if let Some((idx, _)) = open_part.take() { + yield StreamEvent::PartStop { index: idx }; + } + + if !started_text && tools_executed == 0 { + Err(AppError::Network( + "agent bridge returned no text (OAuth Composer connecté ?)".into(), + ))?; + } + + let stop_reason = if tools_executed > 0 { + StopReason::ToolUse + } else { + StopReason::EndTurn + }; + yield StreamEvent::MessageStop { stop_reason, usage }; + }; + + Ok(Box::pin(events)) +} diff --git a/crates/sinew-cursor/src/agent/client_proto.rs b/crates/sinew-cursor/src/agent/client_proto.rs new file mode 100644 index 00000000..81f196e7 --- /dev/null +++ b/crates/sinew-cursor/src/agent/client_proto.rs @@ -0,0 +1,82 @@ +//! Encode outbound `AgentClientMessage` frames. + +use bytes::Bytes; +use prost::Message as _; +use prost_reflect::{DynamicMessage, Value}; + +use sinew_core::Result; + +use super::connect_proto::frame_connect_proto; +use super::proto_dynamic::{message_desc, setf}; + +pub fn frame_client_message(msg: &DynamicMessage) -> Vec { + frame_connect_proto(&msg.encode_to_vec()) +} + +pub fn encode_client_heartbeat() -> Result> { + let client_desc = message_desc("agent.v1.AgentClientMessage")?; + let hb_desc = message_desc("agent.v1.ClientHeartbeat")?; + let mut client = DynamicMessage::new(client_desc); + let heartbeat = DynamicMessage::new(hb_desc); + setf(&mut client, "client_heartbeat", Value::Message(heartbeat))?; + Ok(frame_client_message(&client)) +} + +pub fn encode_exec_client_message( + exec_id: &str, + id: u32, + result_field: &str, + result: DynamicMessage, +) -> Result> { + let exec_desc = message_desc("agent.v1.ExecClientMessage")?; + let mut exec = DynamicMessage::new(exec_desc); + setf(&mut exec, "exec_id", Value::String(exec_id.to_string()))?; + setf(&mut exec, "id", Value::U32(id))?; + setf(&mut exec, result_field, Value::Message(result))?; + + let client_desc = message_desc("agent.v1.AgentClientMessage")?; + let mut client = DynamicMessage::new(client_desc); + setf( + &mut client, + "exec_client_message", + Value::Message(exec), + )?; + Ok(frame_client_message(&client)) +} + +pub fn encode_kv_get_blob_result(id: u32, blob_data: Option>) -> Result> { + let get_desc = message_desc("agent.v1.GetBlobResult")?; + let mut get_result = DynamicMessage::new(get_desc); + if let Some(data) = blob_data { + setf( + &mut get_result, + "blob_data", + Value::Bytes(Bytes::from(data)), + )?; + } + + let kv_desc = message_desc("agent.v1.KvClientMessage")?; + let mut kv = DynamicMessage::new(kv_desc); + setf(&mut kv, "id", Value::U32(id))?; + setf(&mut kv, "get_blob_result", Value::Message(get_result))?; + + let client_desc = message_desc("agent.v1.AgentClientMessage")?; + let mut client = DynamicMessage::new(client_desc); + setf(&mut client, "kv_client_message", Value::Message(kv))?; + Ok(frame_client_message(&client)) +} + +pub fn encode_kv_set_blob_result(id: u32) -> Result> { + let set_desc = message_desc("agent.v1.SetBlobResult")?; + let set_result = DynamicMessage::new(set_desc); + + let kv_desc = message_desc("agent.v1.KvClientMessage")?; + let mut kv = DynamicMessage::new(kv_desc); + setf(&mut kv, "id", Value::U32(id))?; + setf(&mut kv, "set_blob_result", Value::Message(set_result))?; + + let client_desc = message_desc("agent.v1.AgentClientMessage")?; + let mut client = DynamicMessage::new(client_desc); + setf(&mut client, "kv_client_message", Value::Message(kv))?; + Ok(frame_client_message(&client)) +} diff --git a/crates/sinew-cursor/src/agent/connect_proto.rs b/crates/sinew-cursor/src/agent/connect_proto.rs new file mode 100644 index 00000000..abaff725 --- /dev/null +++ b/crates/sinew-cursor/src/agent/connect_proto.rs @@ -0,0 +1,36 @@ +//! Connect-RPC framing for `application/connect+proto` (agent.v1). + +#[allow(unused_imports)] +pub use crate::connect::{append_end_stream_frame, decode_connect_frames, frame_connect_json}; + +#[allow(dead_code)] +/// Wrap raw protobuf payload in a Connect data frame (flags = 0). +pub fn frame_connect_proto(payload: &[u8]) -> Vec { + frame_connect_json(payload, 0) +} + +/// Parse Connect end-stream trailer JSON for an error message, if any. +pub fn parse_connect_end_error(payload: &[u8]) -> Option { + let value: serde_json::Value = serde_json::from_slice(payload).ok()?; + let error = value.get("error")?; + let code = error.get("code").and_then(|v| v.as_str()).unwrap_or("?"); + let message = error + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + Some(format!("{code}: {message}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrips_connect_proto_frame() { + let payload = b"hello agent"; + let mut buffer = frame_connect_proto(payload); + let frames = decode_connect_frames(&mut buffer).expect("decode"); + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].as_ref(), payload); + } +} diff --git a/crates/sinew-cursor/src/agent/conversation_id.rs b/crates/sinew-cursor/src/agent/conversation_id.rs new file mode 100644 index 00000000..629566fb --- /dev/null +++ b/crates/sinew-cursor/src/agent/conversation_id.rs @@ -0,0 +1,37 @@ +use sha2::{Digest, Sha256}; + +/// Stable Cursor `conversationId` from Sinew chat cache key (survives restarts). +pub fn stable_agent_conversation_id(cache_key: Option<&str>) -> String { + let seed = cache_key + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("sinew-default-conversation"); + deterministic_uuid(&format!("cursor-conv-id:{seed}")) +} + +fn deterministic_uuid(seed: &str) -> String { + let digest = Sha256::digest(seed.as_bytes()); + let bytes: [u8; 16] = digest[..16].try_into().expect("16 bytes"); + let b = bytes; + format!( + "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-4{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", + b[0], b[1], b[2], b[3], + b[4], b[5], + b[6], b[7], + b[8], b[9], + b[10], b[11], b[12], b[13], b[14], b[15], + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stable_for_same_key() { + let a = stable_agent_conversation_id(Some("chat-42")); + let b = stable_agent_conversation_id(Some("chat-42")); + assert_eq!(a, b); + assert_ne!(a, stable_agent_conversation_id(Some("chat-43"))); + } +} diff --git a/crates/sinew-cursor/src/agent/exec_handler.rs b/crates/sinew-cursor/src/agent/exec_handler.rs new file mode 100644 index 00000000..72ac135a --- /dev/null +++ b/crates/sinew-cursor/src/agent/exec_handler.rs @@ -0,0 +1,509 @@ +//! Handle `ExecServerMessage` locally (read/ls/write/delete/context/MCP). + +use std::path::{Path, PathBuf}; + +use bytes::Bytes; +use prost_reflect::{DynamicMessage, Value as ProtoValue}; +use serde_json::Value as JsonValue; +use sinew_core::Result; + +use super::client_proto::encode_exec_client_message; +use super::proto_dynamic::{ + get_message_field, get_string_field, get_u32_field, message_desc, oneof_case, setf, +}; +use super::server_decode::decode_mcp_args_from_message; +use super::tools::execute_tool; + +const READ_LIMIT: usize = 512 * 1024; +const REJECT: &str = "Tool not available in Sinew Rust agent bridge."; + +pub struct ExecContext<'a> { + pub workspace_root: &'a str, + pub tools: &'a [JsonValue], + #[allow(dead_code)] pub workspace_snapshot: Option<&'a JsonValue>, +} + +#[derive(Debug, Clone)] +pub struct PendingToolRequest { + pub exec_id: String, + pub exec_msg_id: String, + pub tool_call_id: String, + pub tool_name: String, + pub args: JsonValue, +} + +pub enum ExecOutcome { + Frame(Vec), + ToolRequest(PendingToolRequest), +} + +fn exec_args(exec: &DynamicMessage, field: &str, type_name: &str) -> Result { + Ok(match get_message_field(exec, field) { + Some(m) => m, + None => DynamicMessage::new(message_desc(type_name)?), + }) +} + +pub async fn handle_exec_server_message( + exec: &DynamicMessage, + ctx: &ExecContext<'_>, +) -> Result> { + let exec_id = get_string_field(exec, "exec_id").unwrap_or_default(); + let id = get_u32_field(exec, "id").unwrap_or(0); + let case = oneof_case(exec).unwrap_or_default(); + + match case.as_str() { + "request_context_args" => { + let result = build_request_context_result(ctx)?; + Ok(Some(ExecOutcome::Frame(encode_exec_client_message( + &exec_id, + id, + "request_context_result", + result, + )?))) + } + "read_args" => { + let args = exec_args(exec, "read_args", "agent.v1.ReadArgs")?; + let result = handle_read_args(&args, ctx.workspace_root)?; + Ok(Some(ExecOutcome::Frame(encode_exec_client_message( + &exec_id, id, "read_result", result, + )?))) + } + "ls_args" => { + let args = exec_args(exec, "ls_args", "agent.v1.LsArgs")?; + let result = handle_ls_args(&args, ctx.workspace_root)?; + Ok(Some(ExecOutcome::Frame(encode_exec_client_message( + &exec_id, id, "ls_result", result, + )?))) + } + "write_args" | "edit_args" => { + let field = if case == "write_args" { + "write_args" + } else { + "edit_args" + }; + let args = exec_args(exec, field, "agent.v1.WriteArgs")?; + let result = handle_write_args(&args, ctx.workspace_root)?; + Ok(Some(ExecOutcome::Frame(encode_exec_client_message( + &exec_id, id, "write_result", result, + )?))) + } + "delete_args" => { + let args = exec_args(exec, "delete_args", "agent.v1.DeleteArgs")?; + let result = handle_delete_args(&args, ctx.workspace_root)?; + Ok(Some(ExecOutcome::Frame(encode_exec_client_message( + &exec_id, id, "delete_result", result, + )?))) + } + "grep_args" => Ok(Some(ExecOutcome::Frame(encode_exec_client_message( + &exec_id, + id, + "grep_result", + grep_error_result(REJECT)?, + )?))), + "shell_args" | "shell_stream_args" | "background_shell_spawn_args" => { + Ok(Some(ExecOutcome::Frame(encode_exec_client_message( + &exec_id, + id, + "shell_result", + shell_rejected_result("")?, + )?))) + } + "write_shell_stdin_args" => Ok(Some(ExecOutcome::Frame(encode_exec_client_message( + &exec_id, + id, + "write_shell_stdin_result", + write_shell_stdin_error(REJECT)?, + )?))), + "fetch_args" => Ok(Some(ExecOutcome::Frame(encode_exec_client_message( + &exec_id, + id, + "fetch_result", + fetch_error_result("")?, + )?))), + "diagnostics_args" => Ok(Some(ExecOutcome::Frame(encode_exec_client_message( + &exec_id, + id, + "diagnostics_result", + DynamicMessage::new(message_desc("agent.v1.DiagnosticsResult")?), + )?))), + "list_mcp_resources_exec_args" + | "read_mcp_resource_exec_args" + | "record_screen_args" + | "computer_use_args" + | "setup_vm_environment_args" => Ok(Some(ExecOutcome::Frame(encode_exec_client_message( + &exec_id, + id, + "mcp_result", + DynamicMessage::new(message_desc("agent.v1.McpResult")?), + )?))), + "mcp_args" => { + let mcp = get_message_field(exec, "mcp_args").ok_or_else(|| { + sinew_core::AppError::Provider("mcp_args missing".into()) + })?; + let tool_name = get_string_field(&mcp, "tool_name") + .or_else(|| get_string_field(&mcp, "name")) + .unwrap_or_default(); + let tool_call_id = get_string_field(&mcp, "tool_call_id") + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let args = decode_mcp_args_from_message(&mcp); + Ok(Some(ExecOutcome::ToolRequest(PendingToolRequest { + exec_id, + exec_msg_id: id.to_string(), + tool_call_id, + tool_name, + args, + }))) + } + _ => Ok(None), + } +} + +pub fn encode_mcp_result( + exec_id: &str, + id: u32, + content: &str, + is_error: bool, +) -> Result> { + let mcp_result_desc = message_desc("agent.v1.McpResult")?; + let mut mcp_result = DynamicMessage::new(mcp_result_desc); + if is_error { + let err_desc = message_desc("agent.v1.McpError")?; + let mut err = DynamicMessage::new(err_desc); + setf(&mut err, "error", ProtoValue::String(content.to_string()))?; + setf(&mut mcp_result, "error", ProtoValue::Message(err))?; + } else { + let ok_desc = message_desc("agent.v1.McpSuccess")?; + let text_desc = message_desc("agent.v1.McpTextContent")?; + let item_desc = message_desc("agent.v1.McpToolResultContentItem")?; + let mut text = DynamicMessage::new(text_desc); + setf(&mut text, "text", ProtoValue::String(content.to_string()))?; + let mut item = DynamicMessage::new(item_desc); + setf(&mut item, "text", ProtoValue::Message(text))?; + let mut ok = DynamicMessage::new(ok_desc); + setf(&mut ok, "is_error", ProtoValue::Bool(false))?; + setf(&mut ok, "content", ProtoValue::List(vec![ProtoValue::Message(item)]))?; + setf(&mut mcp_result, "success", ProtoValue::Message(ok))?; + } + encode_exec_client_message(exec_id, id, "mcp_result", mcp_result) +} + +/// Shallow directory listing (parity with Node `shallowLayout` in exec-handlers.mjs). +fn shallow_layout_tree(root: &Path, max_entries: usize) -> Result { + let node_desc = message_desc("agent.v1.LsDirectoryTreeNode")?; + let file_desc = message_desc("agent.v1.LsDirectoryTreeNode_File")?; + let mut children_dirs = Vec::new(); + let mut children_files = Vec::new(); + if let Ok(entries) = std::fs::read_dir(root) { + for entry in entries.flatten().take(max_entries) { + let path = entry.path(); + let abs = path.display().to_string(); + if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + let mut dir_node = DynamicMessage::new(node_desc.clone()); + setf(&mut dir_node, "abs_path", ProtoValue::String(abs))?; + setf( + &mut dir_node, + "children_were_processed", + ProtoValue::Bool(false), + )?; + setf(&mut dir_node, "children_dirs", ProtoValue::List(vec![]))?; + setf(&mut dir_node, "children_files", ProtoValue::List(vec![]))?; + setf(&mut dir_node, "num_files", ProtoValue::I32(0))?; + children_dirs.push(ProtoValue::Message(dir_node)); + } else if entry.file_type().map(|t| t.is_file()).unwrap_or(false) { + let name = entry.file_name().to_string_lossy().into_owned(); + let mut file_node = DynamicMessage::new(file_desc.clone()); + setf(&mut file_node, "name", ProtoValue::String(name))?; + children_files.push(ProtoValue::Message(file_node)); + } + } + } + let mut root_node = DynamicMessage::new(node_desc); + setf( + &mut root_node, + "abs_path", + ProtoValue::String(root.display().to_string()), + )?; + setf( + &mut root_node, + "children_were_processed", + ProtoValue::Bool(true), + )?; + setf(&mut root_node, "children_dirs", ProtoValue::List(children_dirs))?; + setf( + &mut root_node, + "children_files", + ProtoValue::List(children_files), + )?; + let num_files = root_node + .get_field_by_name("children_files") + .map(|v| match v.as_ref() { + ProtoValue::List(items) => items.len() as i32, + _ => 0, + }) + .unwrap_or(0); + setf(&mut root_node, "num_files", ProtoValue::I32(num_files))?; + Ok(root_node) +} + +fn resolve_path(root: &str, raw: &str) -> Result { + let base = PathBuf::from(root); + let target = if Path::new(raw).is_absolute() { + PathBuf::from(raw) + } else { + base.join(raw) + }; + let normalized = std::fs::canonicalize(&target).unwrap_or(target); + let normalized_root = std::fs::canonicalize(&base).unwrap_or(base); + if !normalized.starts_with(&normalized_root) { + return Err(sinew_core::AppError::Provider( + "path outside workspace".into(), + )); + } + Ok(normalized) +} + +fn handle_read_args(args: &DynamicMessage, workspace_root: &str) -> Result { + let path = get_string_field(args, "path") + .or_else(|| get_string_field(args, "file_path")) + .unwrap_or_default(); + let result_desc = message_desc("agent.v1.ReadResult")?; + let mut result = DynamicMessage::new(result_desc); + let read_outcome = (|| -> Result> { + let full = resolve_path(workspace_root, &path)?; + std::fs::read(&full).map_err(|e| sinew_core::AppError::Provider(e.to_string())) + })(); + match read_outcome { + Ok(buf) => { + let truncated = buf.len() > READ_LIMIT; + let slice = if truncated { + &buf[..READ_LIMIT] + } else { + &buf[..] + }; + let content = String::from_utf8_lossy(slice).into_owned(); + let total_lines = content.lines().count() as i32; + let ok_desc = message_desc("agent.v1.ReadSuccess")?; + let mut ok = DynamicMessage::new(ok_desc); + setf(&mut ok, "path", ProtoValue::String(path))?; + setf(&mut ok, "total_lines", ProtoValue::I32(total_lines))?; + setf(&mut ok, "file_size", ProtoValue::I64(buf.len() as i64))?; + setf(&mut ok, "truncated", ProtoValue::Bool(truncated))?; + setf(&mut ok, "content", ProtoValue::String(content))?; + setf(&mut result, "success", ProtoValue::Message(ok))?; + } + Err(err) => { + let err_desc = message_desc("agent.v1.ReadError")?; + let mut err_msg = DynamicMessage::new(err_desc); + setf(&mut err_msg, "path", ProtoValue::String(path))?; + setf(&mut err_msg, "error", ProtoValue::String(err.to_string()))?; + setf(&mut result, "error", ProtoValue::Message(err_msg))?; + } + } + Ok(result) +} + +fn handle_ls_args(args: &DynamicMessage, workspace_root: &str) -> Result { + let path = get_string_field(args, "path") + .or_else(|| get_string_field(args, "target_directory")) + .unwrap_or_else(|| ".".to_string()); + let result_desc = message_desc("agent.v1.LsResult")?; + let mut result = DynamicMessage::new(result_desc); + match resolve_path(workspace_root, &path) { + Ok(full) => { + let root_node = shallow_layout_tree(&full, 120)?; + let ok_desc = message_desc("agent.v1.LsSuccess")?; + let mut ok = DynamicMessage::new(ok_desc); + setf(&mut ok, "directory_tree_root", ProtoValue::Message(root_node))?; + setf(&mut result, "success", ProtoValue::Message(ok))?; + } + Err(err) => { + let rej_desc = message_desc("agent.v1.LsRejected")?; + let mut rej = DynamicMessage::new(rej_desc); + setf(&mut rej, "path", ProtoValue::String(path))?; + setf(&mut rej, "reason", ProtoValue::String(err.to_string()))?; + setf(&mut result, "rejected", ProtoValue::Message(rej))?; + } + } + Ok(result) +} + +fn handle_write_args(args: &DynamicMessage, workspace_root: &str) -> Result { + let path = get_string_field(args, "path") + .or_else(|| get_string_field(args, "file_path")) + .or_else(|| get_string_field(args, "target_file")) + .unwrap_or_default(); + let tool_args = serde_json::json!({ + "path": path, + "old_string": get_string_field(args, "old_string").or_else(|| get_string_field(args, "oldString")), + "new_string": get_string_field(args, "new_string") + .or_else(|| get_string_field(args, "newString")) + .or_else(|| get_string_field(args, "contents")) + .or_else(|| get_string_field(args, "content")), + }); + let content = execute_tool("write", &tool_args, workspace_root); + let result_desc = message_desc("agent.v1.WriteResult")?; + let mut result = DynamicMessage::new(result_desc); + if content.starts_with("Error:") { + let rej_desc = message_desc("agent.v1.WriteRejected")?; + let mut rej = DynamicMessage::new(rej_desc); + setf(&mut rej, "path", ProtoValue::String(path))?; + setf(&mut rej, "reason", ProtoValue::String(content))?; + setf(&mut result, "rejected", ProtoValue::Message(rej))?; + } else { + let ok_desc = message_desc("agent.v1.WriteSuccess")?; + let mut ok = DynamicMessage::new(ok_desc); + setf(&mut ok, "path", ProtoValue::String(path))?; + setf(&mut result, "success", ProtoValue::Message(ok))?; + } + Ok(result) +} + +fn handle_delete_args(args: &DynamicMessage, workspace_root: &str) -> Result { + let path = get_string_field(args, "path") + .or_else(|| get_string_field(args, "file_path")) + .unwrap_or_default(); + let content = execute_tool("delete", &serde_json::json!({ "path": path }), workspace_root); + let result_desc = message_desc("agent.v1.DeleteResult")?; + let mut result = DynamicMessage::new(result_desc); + if content.starts_with("Error:") { + let rej_desc = message_desc("agent.v1.DeleteRejected")?; + let mut rej = DynamicMessage::new(rej_desc); + setf(&mut rej, "path", ProtoValue::String(path))?; + setf(&mut rej, "reason", ProtoValue::String(content))?; + setf(&mut result, "rejected", ProtoValue::Message(rej))?; + } else { + let ok_desc = message_desc("agent.v1.DeleteSuccess")?; + let mut ok = DynamicMessage::new(ok_desc); + setf(&mut ok, "path", ProtoValue::String(path))?; + setf(&mut result, "success", ProtoValue::Message(ok))?; + } + Ok(result) +} + +fn build_request_context_result(ctx: &ExecContext<'_>) -> Result { + let root = ctx.workspace_root.trim(); + let root = if root.is_empty() { "." } else { root }; + let result_desc = message_desc("agent.v1.RequestContextResult")?; + let mut result = DynamicMessage::new(result_desc); + let ok_desc = message_desc("agent.v1.RequestContextSuccess")?; + let ctx_desc = message_desc("agent.v1.RequestContext")?; + let env_desc = message_desc("agent.v1.RequestContextEnv")?; + let mut env = DynamicMessage::new(env_desc); + setf( + &mut env, + "os_version", + ProtoValue::String(format!("{} {}", std::env::consts::OS, std::env::consts::ARCH)), + )?; + setf( + &mut env, + "workspace_paths", + ProtoValue::List(vec![ProtoValue::String(root.to_string())]), + )?; + setf( + &mut env, + "shell", + ProtoValue::String(std::env::var("ComSpec").or_else(|_| std::env::var("SHELL")).unwrap_or_default()), + )?; + setf(&mut env, "sandbox_enabled", ProtoValue::Bool(false))?; + setf(&mut env, "time_zone", ProtoValue::String("UTC".to_string()))?; + + let mut request_context = DynamicMessage::new(ctx_desc); + setf(&mut request_context, "env", ProtoValue::Message(env))?; + if let Ok(full) = resolve_path(root, ".") { + if let Ok(layout) = shallow_layout_tree(&full, 120) { + setf( + &mut request_context, + "project_layouts", + ProtoValue::List(vec![ProtoValue::Message(layout)]), + )?; + } + } + + for tool in ctx.tools { + if let Some(def) = build_mcp_tool_definition(tool) { + if let Some(existing) = request_context.get_field_by_name("tools") { + if let ProtoValue::List(mut list) = existing.as_ref().clone() { + list.push(ProtoValue::Message(def)); + setf(&mut request_context, "tools", ProtoValue::List(list))?; + continue; + } + } + setf(&mut request_context, "tools", ProtoValue::List(vec![ProtoValue::Message(def)]))?; + } + } + + let mut ok = DynamicMessage::new(ok_desc); + setf(&mut ok, "request_context", ProtoValue::Message(request_context))?; + setf(&mut result, "success", ProtoValue::Message(ok))?; + Ok(result) +} + +fn build_mcp_tool_definition(tool: &JsonValue) -> Option { + let name = tool.get("name")?.as_str()?; + let desc = message_desc("agent.v1.McpToolDefinition").ok()?; + let mut msg = DynamicMessage::new(desc); + let schema = tool + .get("parameters") + .cloned() + .unwrap_or(serde_json::json!({"type":"object","properties":{},"required":[]})); + setf(&mut msg, "name", ProtoValue::String(name.to_string())).ok()?; + setf(&mut msg, "tool_name", ProtoValue::String(name.to_string())).ok()?; + setf( + &mut msg, + "description", + ProtoValue::String(tool.get("description").and_then(|v| v.as_str()).unwrap_or("").to_string()), + ) + .ok()?; + setf(&mut msg, "provider_identifier", ProtoValue::String("sinew".to_string())).ok()?; + setf( + &mut msg, + "input_schema", + ProtoValue::Bytes(Bytes::from(schema.to_string())), + ) + .ok()?; + Some(msg) +} + +fn grep_error_result(msg: &str) -> Result { + let result_desc = message_desc("agent.v1.GrepResult")?; + let err_desc = message_desc("agent.v1.GrepError")?; + let mut err = DynamicMessage::new(err_desc); + setf(&mut err, "error", ProtoValue::String(msg.to_string()))?; + let mut result = DynamicMessage::new(result_desc); + setf(&mut result, "error", ProtoValue::Message(err))?; + Ok(result) +} + +fn shell_rejected_result(command: &str) -> Result { + let result_desc = message_desc("agent.v1.ShellResult")?; + let rej_desc = message_desc("agent.v1.ShellRejected")?; + let mut rej = DynamicMessage::new(rej_desc); + setf(&mut rej, "command", ProtoValue::String(command.to_string()))?; + setf(&mut rej, "reason", ProtoValue::String(REJECT.to_string()))?; + setf(&mut rej, "is_readonly", ProtoValue::Bool(false))?; + let mut result = DynamicMessage::new(result_desc); + setf(&mut result, "rejected", ProtoValue::Message(rej))?; + Ok(result) +} + +fn write_shell_stdin_error(msg: &str) -> Result { + let result_desc = message_desc("agent.v1.WriteShellStdinResult")?; + let err_desc = message_desc("agent.v1.WriteShellStdinError")?; + let mut err = DynamicMessage::new(err_desc); + setf(&mut err, "error", ProtoValue::String(msg.to_string()))?; + let mut result = DynamicMessage::new(result_desc); + setf(&mut result, "error", ProtoValue::Message(err))?; + Ok(result) +} + +fn fetch_error_result(url: &str) -> Result { + let result_desc = message_desc("agent.v1.FetchResult")?; + let err_desc = message_desc("agent.v1.FetchError")?; + let mut err = DynamicMessage::new(err_desc); + setf(&mut err, "url", ProtoValue::String(url.to_string()))?; + setf(&mut err, "error", ProtoValue::String(REJECT.to_string()))?; + let mut result = DynamicMessage::new(result_desc); + setf(&mut result, "error", ProtoValue::Message(err))?; + Ok(result) +} diff --git a/crates/sinew-cursor/src/agent/h2_client.rs b/crates/sinew-cursor/src/agent/h2_client.rs new file mode 100644 index 00000000..097b40da --- /dev/null +++ b/crates/sinew-cursor/src/agent/h2_client.rs @@ -0,0 +1,47 @@ +//! Shared HTTP/2 client for `agent.v1` Run (TLS + connection reuse). + +use std::sync::OnceLock; +use std::time::Duration; + +use bytes::Bytes; +use http_body_util::StreamBody; +use hyper::body::Frame; +use hyper_rustls::HttpsConnectorBuilder; +use hyper_util::client::legacy::Client; +use hyper_util::rt::TokioExecutor; +use sinew_core::{AppError, Result}; +use tokio_stream::wrappers::ReceiverStream; + +pub type AgentUploadBody = StreamBody, std::io::Error>>>; + +static H2_CLIENT: OnceLock, AgentUploadBody>> = + OnceLock::new(); +static RUSTLS_PROVIDER: OnceLock<()> = OnceLock::new(); + +fn ensure_rustls_provider() { + RUSTLS_PROVIDER.get_or_init(|| { + let _ = rustls::crypto::ring::default_provider().install_default(); + }); +} + +pub fn shared_h2_client( +) -> Result<&'static Client, AgentUploadBody>> +{ + ensure_rustls_provider(); + if let Some(client) = H2_CLIENT.get() { + return Ok(client); + } + let https = HttpsConnectorBuilder::new() + .with_native_roots() + .map_err(|err| AppError::Network(err.to_string()))? + .https_or_http() + .enable_http2() + .build(); + let client: Client<_, AgentUploadBody> = Client::builder(TokioExecutor::new()) + .http2_only(true) + .pool_max_idle_per_host(2) + .pool_idle_timeout(Duration::from_secs(90)) + .build(https); + let _ = H2_CLIENT.set(client); + Ok(H2_CLIENT.get().expect("h2 client init")) +} diff --git a/crates/sinew-cursor/src/agent/mod.rs b/crates/sinew-cursor/src/agent/mod.rs new file mode 100644 index 00000000..06abba45 --- /dev/null +++ b/crates/sinew-cursor/src/agent/mod.rs @@ -0,0 +1,42 @@ +mod bridge; +mod client_proto; +mod connect_proto; +mod conversation_id; +mod exec_handler; +mod h2_client; +mod proto_dynamic; +mod retry; +mod proto_pool; +mod run_h2; +mod run_request; +mod rust_bridge; +mod server_decode; +mod setup; +mod state; +mod tools; +mod transcript; +#[cfg(test)] +mod models; +pub mod transport; + +pub use bridge::stream_via_node_bridge; +pub use rust_bridge::stream_via_rust_bridge; + +use crate::identity::CursorIdeIdentity; +use sinew_core::{ProviderRequest, ProviderStream, Result}; + +/// Composer `agent.v1` stream: Rust by default, Node if `SINEW_CURSOR_BRIDGE=node`. +pub async fn stream_via_agent_bridge( + identity: &CursorIdeIdentity, + token: String, + request: ProviderRequest, +) -> Result { + if transport::prefer_node_bridge() { + stream_via_node_bridge(identity, token, request).await + } else { + stream_via_rust_bridge(identity, token, request).await + } +} +pub use setup::{bridge_directory, ensure_agent_bridge_ready, node_bridge_available, set_bridge_directory}; +#[cfg(test)] +pub use models::{fetch_usable_models, scan_model_ids, API2_BASE, GET_USABLE_MODELS}; diff --git a/crates/sinew-cursor/src/agent/models.rs b/crates/sinew-cursor/src/agent/models.rs new file mode 100644 index 00000000..7164340d --- /dev/null +++ b/crates/sinew-cursor/src/agent/models.rs @@ -0,0 +1,56 @@ +use reqwest::header::HeaderMap; +use sinew_core::{AppError, Result}; + +use crate::identity::CursorIdeIdentity; + +pub const API2_BASE: &str = "https://api2.cursor.sh"; +pub const GET_USABLE_MODELS: &str = "/agent.v1.AgentService/GetUsableModels"; + +/// Fetch raw `GetUsableModels` protobuf response (OAuth standalone). +pub async fn fetch_usable_models( + http: &reqwest::Client, + identity: &CursorIdeIdentity, + access_token: &str, +) -> Result> { + let session_id = uuid::Uuid::new_v4().to_string(); + let request_id = uuid::Uuid::new_v4().to_string(); + let mut headers = HeaderMap::new(); + identity.apply_agent_authenticated(&mut headers, &session_id, &request_id, access_token); + + let response = http + .post(format!("{API2_BASE}{GET_USABLE_MODELS}")) + .headers(headers) + .header("content-type", "application/proto") + .body(Vec::new()) + .send() + .await + .map_err(|err| AppError::Network(err.to_string()))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(AppError::Network(format!( + "GetUsableModels failed ({status}): {body}" + ))); + } + + response + .bytes() + .await + .map(|bytes| bytes.to_vec()) + .map_err(|err| AppError::Network(err.to_string())) +} + +/// Best-effort list of model id substrings from protobuf bytes. +pub fn scan_model_ids(payload: &[u8]) -> Vec { + let text = String::from_utf8_lossy(payload); + let mut models = Vec::new(); + for token in text.split(|ch: char| !ch.is_ascii_graphic()) { + if token.contains("composer") || token.starts_with("gpt-") || token.starts_with("claude-") { + if token.len() >= 4 && token.len() <= 64 && !models.iter().any(|m| m == token) { + models.push(token.to_string()); + } + } + } + models +} diff --git a/crates/sinew-cursor/src/agent/proto_dynamic.rs b/crates/sinew-cursor/src/agent/proto_dynamic.rs new file mode 100644 index 00000000..7faf5029 --- /dev/null +++ b/crates/sinew-cursor/src/agent/proto_dynamic.rs @@ -0,0 +1,97 @@ +//! Shared prost-reflect helpers for agent.v1 messages. + +use prost_reflect::{DynamicMessage, ReflectMessage, SetFieldError, Value}; +use sinew_core::{AppError, Result}; + +use super::proto_pool::agent_pool; + +pub fn message_desc(name: &str) -> Result { + agent_pool()? + .get_message_by_name(name) + .ok_or_else(|| AppError::Provider(format!("proto message not found: {name}"))) +} + +pub fn setf(msg: &mut DynamicMessage, name: &str, value: Value) -> Result<()> { + msg.try_set_field_by_name(name, value).map_err(field_err) +} + +pub fn field_err(err: SetFieldError) -> AppError { + AppError::Provider(format!("proto field: {err}")) +} + +pub fn get_message_field(msg: &DynamicMessage, name: &str) -> Option { + let field = msg.descriptor().get_field_by_name(name)?; + if !msg.has_field(&field) { + return None; + } + match msg.get_field(&field).as_ref() { + Value::Message(m) => Some(m.clone()), + _ => None, + } +} + +pub fn get_string_field(msg: &DynamicMessage, name: &str) -> Option { + match msg.get_field_by_name(name)?.as_ref() { + Value::String(s) => Some(s.clone()), + _ => None, + } +} + +pub fn get_i32_field(msg: &DynamicMessage, name: &str) -> Option { + match msg.get_field_by_name(name)?.as_ref() { + Value::I32(n) => Some(*n), + _ => None, + } +} + +pub fn get_u32_field(msg: &DynamicMessage, name: &str) -> Option { + match msg.get_field_by_name(name)?.as_ref() { + Value::U32(n) => Some(*n), + _ => None, + } +} + +pub fn get_bytes_field(msg: &DynamicMessage, name: &str) -> Option> { + match msg.get_field_by_name(name)?.as_ref() { + Value::Bytes(b) => Some(b.to_vec()), + _ => None, + } +} + +/// First set field in a protobuf oneof (exec/kv-style messages). +pub fn oneof_case(msg: &DynamicMessage) -> Option { + for oneof in msg.descriptor().oneofs() { + for field in oneof.fields() { + if msg.has_field(&field) { + return Some(field.name().to_string()); + } + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn get_message_field_ignores_unset_message_defaults() { + let asm_desc = message_desc("agent.v1.AgentServerMessage").expect("AgentServerMessage"); + let kv_desc = message_desc("agent.v1.KvServerMessage").expect("KvServerMessage"); + let mut asm = DynamicMessage::new(asm_desc); + + assert!(get_message_field(&asm, "exec_server_message").is_none()); + assert!(get_message_field(&asm, "kv_server_message").is_none()); + + setf( + &mut asm, + "kv_server_message", + Value::Message(DynamicMessage::new(kv_desc)), + ) + .expect("kv_server_message"); + + assert_eq!(oneof_case(&asm).as_deref(), Some("kv_server_message")); + assert!(get_message_field(&asm, "exec_server_message").is_none()); + assert!(get_message_field(&asm, "kv_server_message").is_some()); + } +} diff --git a/crates/sinew-cursor/src/agent/proto_pool.rs b/crates/sinew-cursor/src/agent/proto_pool.rs new file mode 100644 index 00000000..48249cb8 --- /dev/null +++ b/crates/sinew-cursor/src/agent/proto_pool.rs @@ -0,0 +1,32 @@ +use std::sync::OnceLock; + +use prost_reflect::DescriptorPool; +use sinew_core::Result; + +static POOL: OnceLock = OnceLock::new(); + +pub fn agent_pool() -> Result<&'static DescriptorPool> { + Ok(POOL.get_or_init(|| { + let bytes = include_bytes!("../../proto/agent.pb"); + DescriptorPool::decode(bytes.as_ref()) + .unwrap_or_else(|err| panic!("agent.pb descriptor pool: {err}")) + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn loads_agent_messages() { + let pool = agent_pool().expect("pool"); + assert!( + pool.get_message_by_name("agent.v1.AgentClientMessage") + .is_some() + ); + assert!( + pool.get_message_by_name("agent.v1.AgentServerMessage") + .is_some() + ); + } +} diff --git a/crates/sinew-cursor/src/agent/retry.rs b/crates/sinew-cursor/src/agent/retry.rs new file mode 100644 index 00000000..5365cac9 --- /dev/null +++ b/crates/sinew-cursor/src/agent/retry.rs @@ -0,0 +1,60 @@ +//! Retry/backoff for transient agent.v1 HTTP errors (rate limits, gateway). + +use std::time::Duration; + +use hyper::StatusCode; +use sinew_core::AppError; + +pub const MAX_RUN_ATTEMPTS: u32 = 4; + +pub fn is_retryable_status(status: StatusCode) -> bool { + matches!( + status.as_u16(), + 408 | 429 | 500 | 502 | 503 | 504 + ) +} + +pub fn backoff_before_retry(attempt: u32) -> Duration { + let exp = attempt.min(6); + let base_ms = 800u64.saturating_mul(1u64 << exp); + let jitter = rand::random::() % 500; + Duration::from_millis(base_ms.saturating_add(jitter)) +} + +pub fn is_retryable_network_err(err: &AppError) -> bool { + let AppError::Network(msg) = err else { + return false; + }; + let lower = msg.to_ascii_lowercase(); + [ + "timeout", + "timed out", + "connection reset", + "connection refused", + "broken pipe", + "temporarily unavailable", + "dns", + "handshake", + ] + .iter() + .any(|needle| lower.contains(needle)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn retryable_statuses() { + assert!(is_retryable_status(StatusCode::TOO_MANY_REQUESTS)); + assert!(is_retryable_status(StatusCode::SERVICE_UNAVAILABLE)); + assert!(!is_retryable_status(StatusCode::UNAUTHORIZED)); + assert!(!is_retryable_status(StatusCode::OK)); + } + + #[test] + fn backoff_grows() { + assert!(backoff_before_retry(0) >= Duration::from_millis(800)); + assert!(backoff_before_retry(2) > backoff_before_retry(0)); + } +} diff --git a/crates/sinew-cursor/src/agent/run_h2.rs b/crates/sinew-cursor/src/agent/run_h2.rs new file mode 100644 index 00000000..28ba935e --- /dev/null +++ b/crates/sinew-cursor/src/agent/run_h2.rs @@ -0,0 +1,486 @@ +//! Native HTTP/2 `agent.v1.AgentService/Run` client (Connect+proto). + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use bytes::Bytes; +use http_body_util::{BodyExt, StreamBody}; +use hyper::body::Frame; +use hyper::{Method, Request, StatusCode}; +use reqwest::header::HeaderMap; +use sinew_core::{AppError, Result}; +use tokio::sync::mpsc; +use tokio_stream::wrappers::ReceiverStream; +use tracing::{debug, warn}; + +use crate::connect::decode_connect_frames; +use crate::identity::CursorIdeIdentity; + +use super::client_proto::{ + encode_client_heartbeat, encode_kv_get_blob_result, encode_kv_set_blob_result, +}; +use super::connect_proto::frame_connect_proto; +use super::exec_handler::{ + encode_mcp_result, handle_exec_server_message, ExecContext, ExecOutcome, PendingToolRequest, +}; +use super::h2_client::{shared_h2_client, AgentUploadBody}; +use super::proto_dynamic::get_message_field; +use super::retry::{ + backoff_before_retry, is_retryable_network_err, is_retryable_status, MAX_RUN_ATTEMPTS, +}; +use super::run_request::{build_run_request, RunRequestInput}; +use super::server_decode::{ + decode_agent_server_message, decode_server_message, parse_connect_end, BridgeEvent, +}; +use super::state::PersistedAgentConversation; +use super::transcript::TranscriptTurn; + +const API2_RUN: &str = "https://agent.api5.cursor.sh/agent.v1.AgentService/Run"; +const IDLE_AFTER_TEXT_MS: u64 = 2500; +const MAX_TURN_MS: u64 = 120_000; +const HEARTBEAT_INTERVAL_MS: u64 = 15_000; + +#[derive(Debug, Clone)] +pub struct ToolResponse { + pub content: String, + pub is_error: bool, +} + +pub struct AgentRunConfig { + pub token: String, + pub model_id: String, + pub system_prompt: String, + pub user_text: String, + pub conversation_id: String, + pub history_turns: Vec, + pub persisted: Option, + pub workspace_root: String, + pub tools: Vec, + pub workspace_snapshot: Option, +} + +pub struct AgentRunHandle { + pub events: mpsc::Receiver>, + pub tool_responses: mpsc::Sender, +} + +pub async fn run_agent_stream( + identity: &CursorIdeIdentity, + config: AgentRunConfig, +) -> Result { + let (event_tx, event_rx) = mpsc::channel(128); + let (tool_response_tx, tool_response_rx) = mpsc::channel(32); + let identity = identity.clone(); + tokio::spawn(async move { + if let Err(err) = + run_agent_stream_inner(&identity, config, event_tx.clone(), tool_response_rx).await + { + let _ = event_tx.send(Err(err)).await; + } + }); + Ok(AgentRunHandle { + events: event_rx, + tool_responses: tool_response_tx, + }) +} + +async fn run_agent_stream_inner( + identity: &CursorIdeIdentity, + config: AgentRunConfig, + event_tx: mpsc::Sender>, + mut tool_response_rx: mpsc::Receiver, +) -> Result<()> { + let built = build_run_request(&RunRequestInput { + model_id: &config.model_id, + system_prompt: &config.system_prompt, + user_text: &config.user_text, + conversation_id: &config.conversation_id, + history_turns: &config.history_turns, + persisted: config.persisted.clone(), + })?; + + let mut blob_store = built.blob_store; + let initial_frame = frame_connect_proto(&built.request_bytes); + + let client = shared_h2_client()?; + + let session_id = uuid::Uuid::new_v4().to_string(); + let request_id = uuid::Uuid::new_v4().to_string(); + let mut headers = HeaderMap::new(); + identity.apply_agent_authenticated(&mut headers, &session_id, &request_id, &config.token); + + let mut response = None; + let mut body_tx = None; + let mut last_err: Option = None; + for attempt in 0..MAX_RUN_ATTEMPTS { + if attempt > 0 { + tokio::time::sleep(backoff_before_retry(attempt - 1)).await; + } + let (upload_tx, upload_rx) = mpsc::channel(64); + let body: AgentUploadBody = StreamBody::new(ReceiverStream::new(upload_rx)); + let _ = upload_tx + .send(Ok(Frame::data(Bytes::from(initial_frame.clone())))) + .await; + + let mut req_builder = Request::builder() + .method(Method::POST) + .uri(API2_RUN) + .header("content-type", "application/connect+proto") + .header("te", "trailers") + .header("connect-protocol-version", "1"); + for (name, value) in headers.iter() { + if let Ok(val) = value.to_str() { + req_builder = req_builder.header(name.as_str(), val); + } + } + let request = req_builder + .body(body) + .map_err(|err| AppError::Network(err.to_string()))?; + + match client.request(request).await { + Ok(resp) if resp.status() == StatusCode::OK => { + response = Some(resp); + body_tx = Some(upload_tx); + break; + } + Ok(resp) if is_retryable_status(resp.status()) && attempt + 1 < MAX_RUN_ATTEMPTS => { + warn!( + "agent Run HTTP {} — retry {}/{}", + resp.status(), + attempt + 1, + MAX_RUN_ATTEMPTS + ); + last_err = Some(AppError::Network(format!( + "agent Run failed: {}", + resp.status() + ))); + } + Ok(resp) => { + let status = resp.status(); + let body_hint = resp + .into_body() + .collect() + .await + .map(|c| c.to_bytes()) + .ok() + .and_then(|bytes| { + let text = String::from_utf8_lossy(&bytes); + let trimmed = text.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.chars().take(200).collect::()) + } + }); + warn!( + status = %status, + body = body_hint.as_deref(), + "agent Run rejected" + ); + return Err(AppError::Network(format!( + "agent Run failed: {status}{}", + body_hint + .map(|hint| format!(" — {hint}")) + .unwrap_or_default() + ))); + } + Err(err) => { + let net = AppError::Network(format!("agent Run HTTP/2: {err}")); + if is_retryable_network_err(&net) && attempt + 1 < MAX_RUN_ATTEMPTS { + warn!( + "agent Run network error — retry {}/{}", + attempt + 1, + MAX_RUN_ATTEMPTS + ); + last_err = Some(net); + } else { + return Err(net); + } + } + } + } + let response = response.ok_or_else(|| { + last_err.unwrap_or_else(|| AppError::Network("agent Run failed after retries".into())) + })?; + let body_tx = body_tx.expect("body_tx set with successful response"); + + let (frame_tx, mut frame_rx) = mpsc::channel::>(64); + let body_tx_upload = body_tx; + let upload_done = Arc::new(tokio::sync::Notify::new()); + let upload_done_worker = upload_done.clone(); + tokio::spawn(async move { + while let Some(frame) = frame_rx.recv().await { + if body_tx_upload + .send(Ok(Frame::data(Bytes::from(frame)))) + .await + .is_err() + { + break; + } + } + upload_done_worker.notify_waiters(); + }); + + let frame_tx_hb = frame_tx.clone(); + let hb_stop = tokio::spawn(async move { + let mut tick = tokio::time::interval(Duration::from_millis(HEARTBEAT_INTERVAL_MS)); + loop { + tick.tick().await; + match encode_client_heartbeat() { + Ok(bytes) => { + if frame_tx_hb.send(bytes).await.is_err() { + break; + } + } + Err(err) => { + warn!("heartbeat encode: {err}"); + } + } + } + }); + + let exec_ctx = ExecContext { + workspace_root: &config.workspace_root, + tools: &config.tools, + workspace_snapshot: config.workspace_snapshot.as_ref(), + }; + + let mut saw_text = false; + let mut saw_thinking = false; + let mut last_text_at: Option = None; + let mut output_tokens = 0u32; + let mut pending = Vec::new(); + let started = std::time::Instant::now(); + let mut finished = false; + + let mut response_body = response.into_body(); + while !finished { + let frame_result = + tokio::time::timeout(Duration::from_millis(500), response_body.frame()).await; + match frame_result { + Ok(Some(Ok(frame))) => { + if let Some(chunk) = frame.data_ref() { + pending.extend_from_slice(chunk); + // `decode_connect_frames` already drains every complete frame from + // `pending` in one call (and returns Ok([]) when none remain), so this + // must be a single `if let`. Using `while let Ok(..)` here spins forever + // on the always-Ok empty result and starves the body reader. + if let Ok(frames) = decode_connect_frames(&mut pending) { + for payload in frames { + if payload.is_empty() { + continue; + } + if let Some(err) = parse_connect_end(&payload) { + let _ = event_tx.send(Err(AppError::Network(err))).await; + finished = true; + break; + } + if process_server_payload( + &payload, + &mut blob_store, + &frame_tx, + &event_tx, + &exec_ctx, + &mut tool_response_rx, + &mut saw_text, + &mut saw_thinking, + &mut last_text_at, + &mut output_tokens, + &mut finished, + ) + .await? + { + break; + } + } + } + } + } + Ok(Some(Err(e))) => { + return Err(AppError::Network(format!("agent Run body: {e}"))); + } + Ok(None) => break, + Err(_) => { + if saw_text || saw_thinking { + if let Some(at) = last_text_at { + if at.elapsed() >= Duration::from_millis(IDLE_AFTER_TEXT_MS) { + break; + } + } else if saw_thinking + && started.elapsed() >= Duration::from_millis(IDLE_AFTER_TEXT_MS) + { + break; + } + } + if started.elapsed() >= Duration::from_millis(MAX_TURN_MS) { + break; + } + } + } + } + + hb_stop.abort(); + drop(frame_tx); + let _ = tokio::time::timeout(Duration::from_secs(2), upload_done.notified()).await; + + if !saw_text && !saw_thinking { + return Err(AppError::Network( + "agent Run stream ended without text".into(), + )); + } + let duration_ms = started.elapsed().as_millis(); + debug!( + duration_ms, + output_tokens, "cursor h2 agent stream finished" + ); + Ok(()) +} + +async fn process_server_payload( + payload: &[u8], + blob_store: &mut HashMap>, + frame_tx: &mpsc::Sender>, + event_tx: &mpsc::Sender>, + exec_ctx: &ExecContext<'_>, + tool_response_rx: &mut mpsc::Receiver, + saw_text: &mut bool, + saw_thinking: &mut bool, + last_text_at: &mut Option, + output_tokens: &mut u32, + finished: &mut bool, +) -> Result { + let msg = decode_agent_server_message(payload)?; + + if let Some(exec) = get_message_field(&msg, "exec_server_message") { + match handle_exec_server_message(&exec, exec_ctx).await? { + Some(ExecOutcome::Frame(bytes)) => { + let _ = frame_tx.send(bytes).await; + } + Some(ExecOutcome::ToolRequest(PendingToolRequest { + exec_id, + exec_msg_id, + tool_name, + tool_call_id, + args, + })) => { + let _ = event_tx + .send(Ok(BridgeEvent::ToolRequest { + exec_id: exec_id.clone(), + exec_msg_id: exec_msg_id.clone(), + tool_call_id: tool_call_id.clone(), + tool_name: tool_name.clone(), + args: args.clone(), + })) + .await; + let resp = tool_response_rx.recv().await.unwrap_or(ToolResponse { + content: "Error: empty tool response".into(), + is_error: true, + }); + let id = exec_msg_id.parse::().unwrap_or(0); + let bytes = encode_mcp_result(&exec_id, id, &resp.content, resp.is_error)?; + let _ = frame_tx.send(bytes).await; + } + None => {} + } + return Ok(false); + } + + if let Some(kv) = get_message_field(&msg, "kv_server_message") { + handle_kv_message(&kv, blob_store, frame_tx).await?; + return Ok(false); + } + + if let Some(checkpoint) = get_message_field(&msg, "conversation_checkpoint_update") { + use prost::Message as _; + let bytes = checkpoint.encode_to_vec(); + use base64::Engine as _; + let checkpoint_b64 = base64::engine::general_purpose::STANDARD.encode(bytes); + let blobs: HashMap = blob_store + .iter() + .map(|(k, v)| { + ( + k.clone(), + base64::engine::general_purpose::STANDARD.encode(v), + ) + }) + .collect(); + let _ = event_tx + .send(Ok(BridgeEvent::Checkpoint { + checkpoint_b64, + blobs, + })) + .await; + return Ok(false); + } + + match decode_server_message(payload) { + Ok(events) => { + for ev in events { + match &ev { + BridgeEvent::Text(_) => { + *saw_text = true; + *last_text_at = Some(std::time::Instant::now()); + } + BridgeEvent::Thinking(_) => { + *saw_thinking = true; + *last_text_at = Some(std::time::Instant::now()); + } + BridgeEvent::Usage { + output_tokens: o, .. + } => { + *output_tokens = output_tokens.saturating_add(*o); + } + BridgeEvent::StepCompleted | BridgeEvent::TurnEnded + if (*saw_text || *saw_thinking) => + { + *finished = true; + return Ok(true); + } + _ => {} + } + if event_tx.send(Ok(ev)).await.is_err() { + *finished = true; + return Ok(true); + } + } + } + Err(err) => warn!("interaction decode: {err}"), + } + Ok(false) +} + +async fn handle_kv_message( + kv: &prost_reflect::DynamicMessage, + blob_store: &mut HashMap>, + frame_tx: &mpsc::Sender>, +) -> Result<()> { + use super::proto_dynamic::{get_bytes_field, get_u32_field, oneof_case}; + let id = get_u32_field(kv, "id").unwrap_or(0); + match oneof_case(kv).as_deref() { + Some("get_blob_args") => { + let blob_id = get_message_field(kv, "get_blob_args") + .and_then(|args| get_bytes_field(&args, "blob_id")) + .or_else(|| get_bytes_field(kv, "blob_id")) + .unwrap_or_default(); + let key = hex::encode(&blob_id); + let data = blob_store.get(&key).cloned(); + let bytes = encode_kv_get_blob_result(id, data)?; + let _ = frame_tx.send(bytes).await; + } + Some("set_blob_args") => { + if let Some(args) = get_message_field(kv, "set_blob_args") { + if let (Some(blob_id), Some(blob_data)) = ( + get_bytes_field(&args, "blob_id"), + get_bytes_field(&args, "blob_data"), + ) { + blob_store.insert(hex::encode(&blob_id), blob_data); + } + } + let bytes = encode_kv_set_blob_result(id)?; + let _ = frame_tx.send(bytes).await; + } + _ => {} + } + Ok(()) +} diff --git a/crates/sinew-cursor/src/agent/run_request.rs b/crates/sinew-cursor/src/agent/run_request.rs new file mode 100644 index 00000000..21f2bcae --- /dev/null +++ b/crates/sinew-cursor/src/agent/run_request.rs @@ -0,0 +1,303 @@ +//! Build `AgentClientMessage` (runRequest) bytes via prost-reflect. + +use std::collections::HashMap; + +use base64::Engine as _; +use bytes::Bytes; +use prost::Message as _; +use prost_reflect::{DynamicMessage, Value}; +use sha2::{Digest, Sha256}; +use sinew_core::Result; +use uuid::Uuid; + +use super::proto_dynamic::{message_desc, setf}; +use super::proto_pool::agent_pool; +use super::state::PersistedAgentConversation; +use super::transcript::TranscriptTurn; + +pub struct RunRequestInput<'a> { + pub model_id: &'a str, + pub system_prompt: &'a str, + pub user_text: &'a str, + pub conversation_id: &'a str, + pub history_turns: &'a [TranscriptTurn], + pub persisted: Option, +} + +pub struct RunRequestOutput { + pub request_bytes: Vec, + pub blob_store: HashMap>, +} + +pub fn build_run_request(input: &RunRequestInput<'_>) -> Result { + let _pool = agent_pool()?; + let mut blob_store = restore_blobs(&input.persisted); + let conversation_state = load_or_build_state(input, &mut blob_store)?; + let action = build_user_message_action(input.user_text)?; + let model_details = build_model_details(input.model_id)?; + let run_request = build_agent_run_request( + conversation_state, + action, + model_details, + input.conversation_id, + )?; + let client_msg = wrap_run_request(run_request)?; + let request_bytes = client_msg.encode_to_vec(); + Ok(RunRequestOutput { + request_bytes, + blob_store, + }) +} + +fn restore_blobs(persisted: &Option) -> HashMap> { + let mut blob_store = HashMap::new(); + if let Some(state) = persisted { + for (hex, b64) in &state.blobs { + if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(b64) { + blob_store.insert(hex.clone(), bytes); + } + } + } + blob_store +} + +fn load_or_build_state( + input: &RunRequestInput<'_>, + blob_store: &mut HashMap>, +) -> Result { + if let Some(state) = &input.persisted { + if let Some(checkpoint) = &state.checkpoint_b64 { + if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(checkpoint) { + let desc = message_desc("agent.v1.ConversationStateStructure")?; + if let Ok(msg) = DynamicMessage::decode(desc, bytes.as_slice()) { + return Ok(msg); + } + } + } + } + build_conversation_state(input, blob_store) +} + +fn build_conversation_state( + input: &RunRequestInput<'_>, + blob_store: &mut HashMap>, +) -> Result { + let desc = message_desc("agent.v1.ConversationStateStructure")?; + let mut msg = DynamicMessage::new(desc); + let root_ids = build_root_prompt_blob_ids(input.system_prompt, input.history_turns, blob_store)?; + setf( + &mut msg, + "root_prompt_messages_json", + Value::List(bytes_list(root_ids)), + )?; + let turn_ids = build_history_turn_blob_ids(input.history_turns, blob_store)?; + setf(&mut msg, "turns", Value::List(bytes_list(turn_ids)))?; + setf(&mut msg, "todos", Value::List(vec![]))?; + setf(&mut msg, "pending_tool_calls", Value::List(vec![]))?; + setf( + &mut msg, + "previous_workspace_uris", + Value::List(vec![]), + )?; + setf(&mut msg, "summary_archives", Value::List(vec![]))?; + setf(&mut msg, "turn_timings", Value::List(vec![]))?; + setf(&mut msg, "self_summary_count", Value::U32(0))?; + setf(&mut msg, "read_paths", Value::List(vec![]))?; + Ok(msg) +} + +fn bytes_list(ids: Vec>) -> Vec { + ids.into_iter() + .map(|b| Value::Bytes(Bytes::from(b))) + .collect() +} + +fn build_root_prompt_blob_ids( + system_prompt: &str, + turns: &[TranscriptTurn], + blob_store: &mut HashMap>, +) -> Result>> { + let mut ids = Vec::new(); + let system_json = serde_json::json!({ + "role": "system", + "content": system_prompt, + }); + ids.push(store_blob(blob_store, system_json.to_string().as_bytes())); + for turn in turns { + if !turn.user_text.trim().is_empty() { + let user_json = serde_json::json!({ + "role": "user", + "content": [{ "type": "text", "text": turn.user_text }], + }); + ids.push(store_blob(blob_store, user_json.to_string().as_bytes())); + } + if !turn.assistant_text.trim().is_empty() { + let assistant_json = serde_json::json!({ + "role": "assistant", + "content": [{ "type": "text", "text": turn.assistant_text }], + }); + ids.push(store_blob( + blob_store, + assistant_json.to_string().as_bytes(), + )); + } + } + Ok(ids) +} + +fn build_history_turn_blob_ids( + turns: &[TranscriptTurn], + blob_store: &mut HashMap>, +) -> Result>> { + let mut turn_ids = Vec::new(); + for turn in turns { + if turn.user_text.trim().is_empty() { + continue; + } + let user_msg = build_user_message(&turn.user_text)?; + let user_blob = store_blob(blob_store, &user_msg.encode_to_vec()); + let mut step_ids = Vec::new(); + if !turn.assistant_text.trim().is_empty() { + let step = build_conversation_step_assistant(&turn.assistant_text)?; + step_ids.push(store_blob(blob_store, &step.encode_to_vec())); + } + let agent_turn = build_agent_conversation_turn(user_blob, step_ids)?; + let turn_structure = build_conversation_turn_structure(agent_turn)?; + turn_ids.push(store_blob( + blob_store, + &turn_structure.encode_to_vec(), + )); + } + Ok(turn_ids) +} + +fn build_user_message_action(user_text: &str) -> Result { + let desc = message_desc("agent.v1.ConversationAction")?; + let mut action = DynamicMessage::new(desc); + let user_msg = build_user_message(user_text)?; + let user_action_desc = message_desc("agent.v1.UserMessageAction")?; + let mut user_action = DynamicMessage::new(user_action_desc); + setf(&mut user_action, "user_message", Value::Message(user_msg))?; + setf( + &mut action, + "user_message_action", + Value::Message(user_action), + )?; + Ok(action) +} + +fn build_user_message(text: &str) -> Result { + let desc = message_desc("agent.v1.UserMessage")?; + let mut msg = DynamicMessage::new(desc); + setf(&mut msg, "text", Value::String(text.to_string()))?; + setf( + &mut msg, + "message_id", + Value::String(Uuid::new_v4().to_string()), + )?; + Ok(msg) +} + +fn build_conversation_step_assistant(text: &str) -> Result { + let desc = message_desc("agent.v1.ConversationStep")?; + let mut step = DynamicMessage::new(desc); + let assistant_desc = message_desc("agent.v1.AssistantMessage")?; + let mut assistant = DynamicMessage::new(assistant_desc); + setf(&mut assistant, "text", Value::String(text.to_string()))?; + setf(&mut step, "assistant_message", Value::Message(assistant))?; + Ok(step) +} + +fn build_agent_conversation_turn( + user_blob: Vec, + step_blobs: Vec>, +) -> Result { + let desc = message_desc("agent.v1.AgentConversationTurnStructure")?; + let mut msg = DynamicMessage::new(desc); + setf( + &mut msg, + "user_message", + Value::Bytes(Bytes::from(user_blob)), + )?; + setf(&mut msg, "steps", Value::List(bytes_list(step_blobs)))?; + Ok(msg) +} + +fn build_conversation_turn_structure(agent_turn: DynamicMessage) -> Result { + let desc = message_desc("agent.v1.ConversationTurnStructure")?; + let mut msg = DynamicMessage::new(desc); + setf( + &mut msg, + "agent_conversation_turn", + Value::Message(agent_turn), + )?; + Ok(msg) +} + +fn build_model_details(model_id: &str) -> Result { + let desc = message_desc("agent.v1.ModelDetails")?; + let mut msg = DynamicMessage::new(desc); + setf(&mut msg, "model_id", Value::String(model_id.to_string()))?; + setf( + &mut msg, + "display_model_id", + Value::String(model_id.to_string()), + )?; + setf(&mut msg, "display_name", Value::String(model_id.to_string()))?; + Ok(msg) +} + +fn build_agent_run_request( + conversation_state: DynamicMessage, + action: DynamicMessage, + model_details: DynamicMessage, + conversation_id: &str, +) -> Result { + let desc = message_desc("agent.v1.AgentRunRequest")?; + let mut msg = DynamicMessage::new(desc); + setf( + &mut msg, + "conversation_state", + Value::Message(conversation_state), + )?; + setf(&mut msg, "action", Value::Message(action))?; + setf(&mut msg, "model_details", Value::Message(model_details))?; + setf( + &mut msg, + "conversation_id", + Value::String(conversation_id.to_string()), + )?; + Ok(msg) +} + +fn wrap_run_request(run_request: DynamicMessage) -> Result { + let desc = message_desc("agent.v1.AgentClientMessage")?; + let mut msg = DynamicMessage::new(desc); + setf(&mut msg, "run_request", Value::Message(run_request))?; + Ok(msg) +} + +fn store_blob(blob_store: &mut HashMap>, data: &[u8]) -> Vec { + let id = Sha256::digest(data).to_vec(); + blob_store.insert(hex::encode(&id), data.to_vec()); + id +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encodes_minimal_run_request() { + let input = RunRequestInput { + model_id: "composer-2", + system_prompt: "You are Composer.", + user_text: "Hello", + conversation_id: "test-conv-id", + history_turns: &[], + persisted: None, + }; + let out = build_run_request(&input).expect("encode"); + assert!(!out.request_bytes.is_empty()); + } +} diff --git a/crates/sinew-cursor/src/agent/rust_bridge.rs b/crates/sinew-cursor/src/agent/rust_bridge.rs new file mode 100644 index 00000000..18d92317 --- /dev/null +++ b/crates/sinew-cursor/src/agent/rust_bridge.rs @@ -0,0 +1,247 @@ +//! Native Rust `agent.v1` Run client (HTTP/2 + prost-reflect). + +use async_stream::try_stream; +use sinew_core::{ + AppError, PartKind, ProviderRequest, ProviderStream, Result, StopReason, StreamEvent, + ToolCallIntro, Usage, +}; + +use crate::identity::CursorIdeIdentity; +use crate::model_info; +use crate::workspace; + +use super::bridge::tools_json; +use super::conversation_id::stable_agent_conversation_id; +use super::run_h2::{run_agent_stream, AgentRunConfig, ToolResponse}; +use super::server_decode::BridgeEvent; +use super::state::AgentConversationStore; +use super::tools::execute_tool; +use super::transcript::split_transcript; + +const OAUTH_HINT: &str = + "Connectez votre compte Cursor dans Réglages → Fournisseurs (OAuth Google ou GitHub)."; + +fn map_rust_bridge_error(err: AppError) -> AppError { + match &err { + AppError::Auth(msg) | AppError::Network(msg) + if msg.to_ascii_lowercase().contains("unauthenticated") => + { + AppError::Auth(format!( + "Session Cursor refusée (non authentifié). Déconnectez puis reconnectez dans Réglages → Fournisseurs. {OAUTH_HINT}" + )) + } + AppError::Auth(msg) if !msg.contains("Réglages") => { + AppError::Auth(format!("{msg} {OAUTH_HINT}")) + } + AppError::Network(msg) if !msg.contains("Réglages") && !msg.contains("OAuth") => { + AppError::Network(format!("{msg} {OAUTH_HINT}")) + } + _ => err, + } +} + +/// Stream via native Rust HTTP/2 + prost (no Node subprocess). +pub async fn stream_via_rust_bridge( + identity: &CursorIdeIdentity, + token: String, + request: ProviderRequest, +) -> Result { + stream_via_rust_bridge_inner(identity, token, request) + .await + .map_err(map_rust_bridge_error) +} + +async fn stream_via_rust_bridge_inner( + identity: &CursorIdeIdentity, + token: String, + request: ProviderRequest, +) -> Result { + let model = model_info::resolve_agent_model_id(&request.model, request.service_tier); + tracing::debug!( + requested = %request.model.name, + effective = %model, + service_tier = ?request.service_tier, + "cursor agent.v1 model" + ); + let system = request + .system_prompt + .clone() + .unwrap_or_else(|| "You are Composer in Cursor IDE.".to_string()); + let (history_turns, current_user) = split_transcript(&request.transcript); + let user = if current_user.is_empty() { + request + .transcript + .last() + .map(|m| m.text()) + .unwrap_or_default() + } else { + current_user + }; + let workspace = request.workspace_root.clone().unwrap_or_default(); + let cache_key = request.cache_key.clone().unwrap_or_default(); + let conversation_id = stable_agent_conversation_id(request.cache_key.as_deref()); + let persisted = AgentConversationStore::load().get(&cache_key); + let trimmed = workspace.trim(); + let workspace_snapshot = if !trimmed.is_empty() { + workspace::snapshot(trimmed).map(|snap| { + serde_json::json!({ + "uri": snap.uri, + "name": snap.name, + "branch": snap.branch, + "gitStatus": snap.git_status, + "projectLayout": snap.project_layout, + }) + }) + } else { + None + }; + + let config = AgentRunConfig { + token, + model_id: model.clone(), + system_prompt: system, + user_text: user, + conversation_id, + history_turns, + persisted, + workspace_root: workspace.clone(), + tools: tools_json(&request), + workspace_snapshot, + }; + + let handle = run_agent_stream(identity, config).await?; + let mut events_rx = handle.events; + let tool_tx = handle.tool_responses; + + let model_name = model; + let workspace_for_tools = workspace.clone(); + let cache_key_for_save = cache_key; + + let events = try_stream! { + yield StreamEvent::MessageStart { model: model_name.clone() }; + let text_index = 0usize; + let thinking_index = 1usize; + let mut next_tool_index = 2usize; + let mut open_part: Option<(usize, PartKind)> = None; + let mut started_text = false; + let mut started_thinking = false; + let mut tools_executed = 0u32; + let mut usage = Usage::default(); + let mut total_output_tokens = 0u32; + + while let Some(item) = events_rx.recv().await { + let ev = item?; + match ev { + BridgeEvent::Checkpoint { checkpoint_b64, blobs } => { + if !cache_key_for_save.trim().is_empty() { + let mut store = AgentConversationStore::load(); + let _ = store.save_checkpoint( + &cache_key_for_save, + checkpoint_b64, + blobs, + ); + } + } + BridgeEvent::Usage { output_tokens, total_tokens } => { + total_output_tokens = total_output_tokens.saturating_add(output_tokens); + usage.output_tokens = total_output_tokens; + usage.total_tokens = total_tokens.max(total_output_tokens); + yield StreamEvent::Usage { usage }; + } + BridgeEvent::ToolRequest { + exec_id: _, + exec_msg_id: _, + tool_name, + tool_call_id, + args, + } => { + if let Some((idx, _)) = open_part.take() { + yield StreamEvent::PartStop { index: idx }; + } + let tool_index = next_tool_index; + next_tool_index += 1; + let args_json = serde_json::to_string(&args) + .map_err(|err| AppError::Provider(format!("tool args json: {err}")))?; + yield StreamEvent::PartStart { + index: tool_index, + kind: PartKind::ToolCall, + tool: Some(ToolCallIntro { + id: tool_call_id.clone(), + name: tool_name.clone(), + }), + }; + yield StreamEvent::ToolJsonDelta { + index: tool_index, + chunk: args_json, + }; + let content = execute_tool(&tool_name, &args, &workspace_for_tools); + let is_error = content.starts_with("Error:"); + yield StreamEvent::PartMeta { + index: tool_index, + meta: serde_json::json!({ + "composer_bridge": { "content": content, "is_error": is_error } + }), + }; + yield StreamEvent::PartStop { index: tool_index }; + tools_executed += 1; + let _ = tool_tx + .send(ToolResponse { + content, + is_error, + }) + .await; + } + BridgeEvent::Text(delta) => { + let index = text_index; + let kind = PartKind::Text; + if open_part.map(|(_, k)| k) != Some(kind) { + if let Some((idx, _)) = open_part.take() { + yield StreamEvent::PartStop { index: idx }; + } + open_part = Some((index, kind)); + yield StreamEvent::PartStart { index, kind, tool: None }; + } + started_text = true; + yield StreamEvent::TextDelta { index, delta }; + } + BridgeEvent::Thinking(delta) => { + let index = thinking_index; + let kind = PartKind::Thinking; + if open_part.map(|(_, k)| k) != Some(kind) { + if let Some((idx, _)) = open_part.take() { + yield StreamEvent::PartStop { index: idx }; + } + open_part = Some((index, kind)); + yield StreamEvent::PartStart { index, kind, tool: None }; + } + started_thinking = true; + yield StreamEvent::ThinkingDelta { index, delta }; + } + BridgeEvent::StepCompleted | BridgeEvent::TurnEnded => { + if started_text || started_thinking || tools_executed > 0 { + break; + } + } + } + } + + if let Some((idx, _)) = open_part.take() { + yield StreamEvent::PartStop { index: idx }; + } + + if !started_text && !started_thinking && tools_executed == 0 { + Err(AppError::Network(format!( + "Composer n'a renvoyé aucun texte. {OAUTH_HINT}" + )))?; + } + + let stop_reason = if tools_executed > 0 { + StopReason::ToolUse + } else { + StopReason::EndTurn + }; + yield StreamEvent::MessageStop { stop_reason, usage }; + }; + + Ok(Box::pin(events)) +} diff --git a/crates/sinew-cursor/src/agent/server_decode.rs b/crates/sinew-cursor/src/agent/server_decode.rs new file mode 100644 index 00000000..e1ef3f18 --- /dev/null +++ b/crates/sinew-cursor/src/agent/server_decode.rs @@ -0,0 +1,227 @@ +//! Decode `AgentServerMessage` frames into bridge events. + +use base64::Engine as _; +use prost::Message as _; +use prost_reflect::{DynamicMessage, Value}; +use sinew_core::{AppError, Result}; + +use super::proto_dynamic::{get_i32_field, get_message_field, get_string_field, oneof_case}; +use super::proto_pool::agent_pool; + +#[derive(Debug, Clone)] +pub enum BridgeEvent { + Text(String), + Thinking(String), + Usage { output_tokens: u32, total_tokens: u32 }, + ToolRequest { + #[allow(dead_code)] exec_id: String, + #[allow(dead_code)] exec_msg_id: String, + tool_call_id: String, + tool_name: String, + args: serde_json::Value, + }, + Checkpoint { + checkpoint_b64: String, + blobs: std::collections::HashMap, + }, + StepCompleted, + TurnEnded, +} + +pub fn decode_server_message(payload: &[u8]) -> Result> { + let desc = agent_pool()? + .get_message_by_name("agent.v1.AgentServerMessage") + .ok_or_else(|| AppError::Provider("AgentServerMessage descriptor missing".into()))?; + let msg = DynamicMessage::decode(desc, payload) + .map_err(|err| AppError::Decode(format!("AgentServerMessage: {err}")))?; + Ok(collect_events(&msg)) +} + +fn collect_events(msg: &DynamicMessage) -> Vec { + let mut out = Vec::new(); + let update = get_message_field(msg, "interaction_update").or_else(|| { + oneof_case(msg).and_then(|case| { + if case == "interaction_update" { + get_message_field(msg, &case) + } else { + None + } + }) + }); + if let Some(update) = update { + out.extend(interaction_events(&update)); + } + // Exec messages are handled inline in run_h2 (bidirectional loop). + if let Some(checkpoint) = get_message_field(msg, "conversation_checkpoint_update") { + if let Some(ev) = checkpoint_event(&checkpoint) { + out.push(ev); + } + } + out +} + +fn interaction_events(update: &DynamicMessage) -> Vec { + let mut out = Vec::new(); + let case = oneof_case(update); + let push_text = |inner: &DynamicMessage, out: &mut Vec| { + if let Some(text) = get_string_field(inner, "text") { + if !text.is_empty() { + out.push(BridgeEvent::Text(text)); + } + } + }; + let push_thinking = |inner: &DynamicMessage, out: &mut Vec| { + if let Some(text) = get_string_field(inner, "text") { + if !text.is_empty() { + out.push(BridgeEvent::Thinking(text)); + } + } + }; + match case.as_deref() { + Some("text_delta") => { + if let Some(inner) = get_message_field(update, "text_delta") { + push_text(&inner, &mut out); + } + } + Some("thinking_delta") => { + if let Some(inner) = get_message_field(update, "thinking_delta") { + push_thinking(&inner, &mut out); + } + } + Some("token_delta") => { + if let Some(inner) = get_message_field(update, "token_delta") { + let tokens = get_i32_field(&inner, "tokens").unwrap_or(0).max(0) as u32; + out.push(BridgeEvent::Usage { + output_tokens: tokens, + total_tokens: tokens, + }); + } + } + Some("step_completed") => out.push(BridgeEvent::StepCompleted), + Some("turn_ended") => out.push(BridgeEvent::TurnEnded), + _ => { + if let Some(inner) = get_message_field(update, "text_delta") { + push_text(&inner, &mut out); + } + if let Some(inner) = get_message_field(update, "thinking_delta") { + push_thinking(&inner, &mut out); + } + if let Some(inner) = get_message_field(update, "token_delta") { + let tokens = get_i32_field(&inner, "tokens").unwrap_or(0).max(0) as u32; + out.push(BridgeEvent::Usage { + output_tokens: tokens, + total_tokens: tokens, + }); + } + if get_message_field(update, "step_completed").is_some() { + out.push(BridgeEvent::StepCompleted); + } + if get_message_field(update, "turn_ended").is_some() { + out.push(BridgeEvent::TurnEnded); + } + } + } + out +} + +pub fn decode_agent_server_message(payload: &[u8]) -> Result { + let desc = agent_pool()? + .get_message_by_name("agent.v1.AgentServerMessage") + .ok_or_else(|| AppError::Provider("AgentServerMessage descriptor missing".into()))?; + DynamicMessage::decode(desc, payload) + .map_err(|err| AppError::Decode(format!("AgentServerMessage: {err}"))) +} + +pub fn decode_mcp_args_from_message(mcp: &DynamicMessage) -> serde_json::Value { + decode_mcp_args_map(mcp) +} + +fn decode_mcp_args_map(mcp: &DynamicMessage) -> serde_json::Value { + let Some(args_msg) = get_message_field(mcp, "args") else { + return serde_json::Value::Null; + }; + let mut map = serde_json::Map::new(); + for (key, value) in args_msg.fields() { + map.insert(key.name().to_string(), value_to_json(value)); + } + serde_json::Value::Object(map) +} + +fn value_to_json(value: &Value) -> serde_json::Value { + match value { + Value::String(s) => serde_json::Value::String(s.clone()), + Value::Bool(b) => serde_json::Value::Bool(*b), + Value::I32(n) => serde_json::json!(*n), + Value::I64(n) => serde_json::json!(*n), + Value::U32(n) => serde_json::json!(*n), + Value::U64(n) => serde_json::json!(*n), + Value::F32(n) => serde_json::json!(*n), + Value::F64(n) => serde_json::json!(*n), + Value::Bytes(b) => { + if let Ok(text) = std::str::from_utf8(b) { + if let Ok(json) = serde_json::from_str::(text) { + return json; + } + return serde_json::Value::String(text.to_string()); + } + serde_json::Value::String(base64::engine::general_purpose::STANDARD.encode(b)) + } + Value::List(items) => { + serde_json::Value::Array(items.iter().map(value_to_json).collect()) + } + Value::Message(m) => { + let mut obj = serde_json::Map::new(); + for (field, val) in m.fields() { + obj.insert(field.name().to_string(), value_to_json(val)); + } + serde_json::Value::Object(obj) + } + Value::EnumNumber(n) => serde_json::json!(*n), + _ => serde_json::Value::Null, + } +} + +fn checkpoint_event(state: &DynamicMessage) -> Option { + let bytes = state.encode_to_vec(); + let checkpoint_b64 = base64::engine::general_purpose::STANDARD.encode(bytes); + Some(BridgeEvent::Checkpoint { + checkpoint_b64, + blobs: std::collections::HashMap::new(), + }) +} + +pub fn parse_connect_end(payload: &[u8]) -> Option { + super::connect_proto::parse_connect_end_error(payload) +} + +#[cfg(test)] +mod tests { + use prost_reflect::{DynamicMessage, Value}; + use crate::agent::proto_dynamic::setf; + use super::*; + + #[test] + fn decodes_interaction_text_delta() { + let pool = agent_pool().expect("pool"); + let td_desc = pool + .get_message_by_name("agent.v1.TextDeltaUpdate") + .expect("TextDeltaUpdate"); + let mut td = DynamicMessage::new(td_desc); + setf(&mut td, "text", Value::String("OK".into())).expect("text"); + let iu_desc = pool + .get_message_by_name("agent.v1.InteractionUpdate") + .expect("InteractionUpdate"); + let mut iu = DynamicMessage::new(iu_desc); + setf(&mut iu, "text_delta", Value::Message(td)).expect("text_delta"); + let events = interaction_events(&iu); + assert!(matches!(&events[..], [BridgeEvent::Text(t)] if t == "OK")); + + let asm_desc = pool + .get_message_by_name("agent.v1.AgentServerMessage") + .expect("AgentServerMessage"); + let mut asm = DynamicMessage::new(asm_desc); + setf(&mut asm, "interaction_update", Value::Message(iu)).expect("interaction_update"); + let events = collect_events(&asm); + assert!(events.iter().any(|e| matches!(e, BridgeEvent::Text(_)))); + } +} diff --git a/crates/sinew-cursor/src/agent/setup.rs b/crates/sinew-cursor/src/agent/setup.rs new file mode 100644 index 00000000..5b65f50e --- /dev/null +++ b/crates/sinew-cursor/src/agent/setup.rs @@ -0,0 +1,152 @@ +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use sinew_core::{AppError, Result}; +use tokio::process::Command; +use tokio::sync::Mutex; + +static PREPARE_LOCK: Mutex<()> = Mutex::const_new(()); +static BRIDGE_DIR: OnceLock = OnceLock::new(); + +/// Directory containing `run-stream.mjs` (set by Sinew at startup or dev tree). +pub fn bridge_directory() -> Option { + if let Some(dir) = BRIDGE_DIR.get() { + return Some(dir.clone()); + } + resolve_bridge_directory().inspect(|dir| { + let _ = BRIDGE_DIR.set(dir.clone()); + }) +} + +pub fn set_bridge_directory(dir: PathBuf) { + let _ = BRIDGE_DIR.set(dir); +} + +fn resolve_bridge_directory() -> Option { + if let Ok(dir) = std::env::var("SINEW_CURSOR_AGENT_BRIDGE_DIR") { + let trimmed = dir.trim(); + if !trimmed.is_empty() { + let path = PathBuf::from(trimmed); + if path.join("run-stream.mjs").is_file() { + return Some(path); + } + } + } + if let Ok(script) = std::env::var("SINEW_CURSOR_AGENT_BRIDGE") { + let trimmed = script.trim(); + if !trimmed.is_empty() { + let path = PathBuf::from(trimmed); + if let Some(parent) = path.parent() { + if parent.join("run-stream.mjs").is_file() { + return Some(parent.to_path_buf()); + } + } + } + } + dev_bridge_directory() +} + +fn dev_bridge_directory() -> Option { + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let candidate = manifest + .join("..") + .join("..") + .join("scripts") + .join("agent-bridge"); + if candidate.join("run-stream.mjs").is_file() { + candidate.canonicalize().ok().or(Some(candidate)) + } else { + None + } +} + +fn tsx_binary(dir: &Path) -> PathBuf { + #[cfg(windows)] + { + dir.join("node_modules").join(".bin").join("tsx.cmd") + } + #[cfg(not(windows))] + { + dir.join("node_modules").join(".bin").join("tsx") + } +} + +/// True when the Node bridge can run without `npm ci` (bundled release or dev install). +pub fn bridge_ready(dir: &Path) -> bool { + tsx_binary(dir).is_file() && dir.join("vendor").join("agent_pb.ts").is_file() +} + +/// Node fallback is available (bundled or dev tree with deps installed). +pub fn node_bridge_available() -> bool { + bridge_directory() + .map(|dir| bridge_ready(&dir)) + .unwrap_or(false) +} + +/// Install `agent-bridge` npm deps if missing (no-op when bundled `node_modules` exists). +pub async fn ensure_agent_bridge_ready() -> Result { + let dir = bridge_directory().ok_or_else(|| { + AppError::Provider( + "agent bridge introuvable (réinstallez Sinew ou définissez SINEW_CURSOR_AGENT_BRIDGE_DIR)".into(), + ) + })?; + if bridge_ready(&dir) { + return Ok(dir); + } + + let _guard = PREPARE_LOCK.lock().await; + if bridge_ready(&dir) { + return Ok(dir); + } + + tracing::info!(path = %dir.display(), "installation automatique agent-bridge (npm ci)"); + run_npm_ci(&dir).await?; + if !bridge_ready(&dir) { + return Err(AppError::Provider( + "agent bridge: npm ci terminé mais tsx/vendor manquant (Node/npm requis)".into(), + )); + } + Ok(dir) +} + +async fn run_npm_ci(dir: &Path) -> Result<()> { + #[cfg(windows)] + let npm = "npm.cmd"; + #[cfg(not(windows))] + let npm = "npm"; + + let mut cmd = Command::new(npm); + cmd.arg("ci") + .arg("--omit=dev") + .current_dir(dir) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()); + + #[cfg(windows)] + { + cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW + } + + let output = cmd + .output() + .await + .map_err(|err| AppError::Provider(format!("agent bridge npm ci: {err}")))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(AppError::Provider(format!( + "agent bridge npm ci failed ({}): {}", + output.status, + stderr.trim() + ))); + } + Ok(()) +} + +pub fn run_stream_script(dir: &Path) -> PathBuf { + dir.join("run-stream.mjs") +} + +pub fn tsx_executable(dir: &Path) -> PathBuf { + tsx_binary(dir) +} diff --git a/crates/sinew-cursor/src/agent/state.rs b/crates/sinew-cursor/src/agent/state.rs new file mode 100644 index 00000000..e1636ff6 --- /dev/null +++ b/crates/sinew-cursor/src/agent/state.rs @@ -0,0 +1,113 @@ +use std::{ + collections::HashMap, + fs, + path::PathBuf, +}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct PersistedFile { + conversations: HashMap, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PersistedAgentConversation { + #[serde(default)] + pub checkpoint_b64: Option, + /// hex blob id -> base64 blob bytes + #[serde(default)] + pub blobs: HashMap, +} + +pub struct AgentConversationStore { + path: PathBuf, + conversations: HashMap, +} + +impl AgentConversationStore { + pub fn load() -> Self { + let path = store_path(); + let conversations = fs::read_to_string(&path) + .ok() + .and_then(|json| serde_json::from_str::(&json).ok()) + .map(|file| file.conversations) + .unwrap_or_default(); + Self { + path, + conversations, + } + } + + pub fn get(&self, cache_key: &str) -> Option { + if cache_key.trim().is_empty() { + return None; + } + self.conversations.get(cache_key).cloned() + } + + pub fn save_checkpoint( + &mut self, + cache_key: &str, + checkpoint_b64: String, + blobs: HashMap, + ) -> Result<()> { + if cache_key.trim().is_empty() { + return Ok(()); + } + let (checkpoint_b64, blobs) = trim_checkpoint_payload(checkpoint_b64, blobs); + self.conversations.insert( + cache_key.to_string(), + PersistedAgentConversation { + checkpoint_b64: Some(checkpoint_b64), + blobs, + }, + ); + self.flush() + } + + fn flush(&self) -> Result<()> { + if let Some(parent) = self.path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("unable to create {}", parent.display()))?; + } + let payload = PersistedFile { + conversations: self.conversations.clone(), + }; + let json = serde_json::to_string_pretty(&payload)?; + fs::write(&self.path, json) + .with_context(|| format!("unable to write {}", self.path.display()))?; + Ok(()) + } +} + +fn store_path() -> PathBuf { + directories::ProjectDirs::from("dev", "hyrak", "sinew") + .map(|dirs| dirs.data_dir().join("cursor-agent-conversations.json")) + .unwrap_or_else(|| PathBuf::from("cursor-agent-conversations.json")) +} + +const MAX_CHECKPOINT_B64_CHARS: usize = 2 * 1024 * 1024; +const MAX_BLOB_B64_CHARS: usize = 256 * 1024; +const MAX_BLOBS_PER_CONVERSATION: usize = 24; + +fn trim_checkpoint_payload( + checkpoint_b64: String, + blobs: HashMap, +) -> (String, HashMap) { + let checkpoint = if checkpoint_b64.len() > MAX_CHECKPOINT_B64_CHARS { + checkpoint_b64[..MAX_CHECKPOINT_B64_CHARS].to_string() + } else { + checkpoint_b64 + }; + let mut trimmed = HashMap::new(); + for (id, blob) in blobs.into_iter().take(MAX_BLOBS_PER_CONVERSATION) { + if blob.len() > MAX_BLOB_B64_CHARS { + trimmed.insert(id, blob[..MAX_BLOB_B64_CHARS].to_string()); + } else { + trimmed.insert(id, blob); + } + } + (checkpoint, trimmed) +} diff --git a/crates/sinew-cursor/src/agent/tools.rs b/crates/sinew-cursor/src/agent/tools.rs new file mode 100644 index 00000000..25fc6d56 --- /dev/null +++ b/crates/sinew-cursor/src/agent/tools.rs @@ -0,0 +1,262 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; +use serde_json::Value; + +const READ_LIMIT: usize = 512 * 1024; + +/// Run a Sinew tool invoked via Cursor `mcpArgs` (best-effort parity). +pub fn execute_tool(name: &str, args: &Value, workspace_root: &str) -> String { + let root = PathBuf::from(workspace_root); + if !root.is_dir() { + return "Error: workspace root is not a directory".into(); + } + match normalize_tool_name(name).as_str() { + "read" => exec_read(&root, args), + "list_dir" => exec_list_dir(&root, args), + "grep" => exec_grep(&root, args), + "glob" => exec_glob(&root, args), + "bash" => exec_shell(&root, args), + "write" => exec_write(&root, args), + "edit" => exec_edit(&root, args), + "delete" => exec_delete(&root, args), + _ => format!("Error: unsupported tool '{name}' in Composer bridge"), + } +} + +fn normalize_tool_name(name: &str) -> String { + match name.trim().to_ascii_lowercase().as_str() { + "read" | "readfile" | "read_file" => "read".into(), + "listdir" | "list_dir" | "ls" => "list_dir".into(), + "grep" | "rg" => "grep".into(), + "glob" | "glob_file_search" => "glob".into(), + "bash" | "shell" | "run_terminal_cmd" => "bash".into(), + "write" | "writefile" | "write_file" => "write".into(), + "strreplace" | "search_replace" | "edit" | "editfile" | "edit_file" => "edit".into(), + "delete" | "deletefile" | "delete_file" => "delete".into(), + other => other.to_string(), + } +} + +fn resolve_path(root: &Path, raw: &str) -> PathBuf { + let path = PathBuf::from(raw); + if path.is_absolute() { + path + } else { + root.join(path) + } +} + +fn pick_string(args: &Value, keys: &[&str]) -> Option { + for key in keys { + if let Some(value) = args.get(*key).and_then(|v| v.as_str()) { + if !value.trim().is_empty() { + return Some(value.to_string()); + } + } + } + None +} + +fn exec_read(root: &Path, args: &Value) -> String { + let Some(path) = pick_string(args, &["path", "filePath", "file_path", "target_file"]) else { + return "Error: read requires path".into(); + }; + let full = resolve_path(root, &path); + if !full.starts_with(root) { + return "Error: path outside workspace".into(); + } + match std::fs::read_to_string(&full) { + Ok(content) => { + if content.len() > READ_LIMIT { + format!( + "{}\n\n[truncated: {} bytes total]", + &content[..READ_LIMIT], + content.len() + ) + } else { + content + } + } + Err(err) => format!("Error reading {}: {err}", full.display()), + } +} + +fn exec_list_dir(root: &Path, args: &Value) -> String { + let path = pick_string(args, &["path", "target_directory", "directory"]).unwrap_or_else(|| ".".into()); + let full = resolve_path(root, &path); + if !full.starts_with(root) { + return "Error: path outside workspace".into(); + } + let mut entries = Vec::new(); + let Ok(read_dir) = std::fs::read_dir(&full) else { + return format!("Error: cannot read directory {}", full.display()); + }; + for entry in read_dir.flatten().take(500) { + let name = entry.file_name().to_string_lossy().to_string(); + let kind = if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + "dir" + } else { + "file" + }; + entries.push(format!("{kind}\t{name}")); + } + entries.sort(); + entries.join("\n") +} + +fn exec_grep(root: &Path, args: &Value) -> String { + let pattern = pick_string(args, &["pattern", "query", "regex"]).unwrap_or_default(); + if pattern.is_empty() { + return "Error: grep requires pattern".into(); + } + let path = pick_string(args, &["path", "glob", "target"]).unwrap_or_else(|| ".".into()); + let full = resolve_path(root, &path); + let mut cmd = Command::new("rg"); + cmd.arg("--line-number") + .arg("--max-count") + .arg("200") + .arg(&pattern) + .arg(&full); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x0800_0000); + } + match cmd.output() { + Ok(output) => { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + if stdout.trim().is_empty() && !output.status.success() { + if stderr.trim().is_empty() { + "No matches".into() + } else { + stderr.to_string() + } + } else { + stdout.to_string() + } + } + Err(err) => format!("Error: rg failed ({err})"), + } +} + +fn exec_glob(root: &Path, args: &Value) -> String { + let pattern = pick_string(args, &["glob_pattern", "pattern", "glob"]).unwrap_or_default(); + if pattern.is_empty() { + return "Error: glob requires pattern".into(); + } + let mut cmd = Command::new("rg"); + cmd.arg("--files") + .arg("-g") + .arg(&pattern) + .arg(root); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x0800_0000); + } + match cmd.output() { + Ok(output) => String::from_utf8_lossy(&output.stdout).to_string(), + Err(err) => format!("Error: glob via rg failed ({err})"), + } +} + +fn exec_shell(root: &Path, args: &Value) -> String { + let command = pick_string(args, &["command", "cmd"]).unwrap_or_default(); + if command.is_empty() { + return "Error: shell requires command".into(); + } + let cwd = pick_string(args, &["working_directory", "cwd", "workdir"]) + .map(|p| resolve_path(root, &p)) + .unwrap_or_else(|| root.to_path_buf()); + #[cfg(windows)] + let mut cmd = { + let mut c = Command::new("cmd"); + c.arg("/C").arg(&command); + c + }; + #[cfg(not(windows))] + let mut cmd = { + let mut c = Command::new("sh"); + c.arg("-lc").arg(&command); + c + }; + cmd.current_dir(&cwd); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x0800_0000); + } + // Best-effort timeout via thread is heavy; keep simple sync for bridge spike. + match cmd.output() { + Ok(output) => { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + format!("exit={}\n{stdout}{stderr}", output.status) + } + Err(err) => format!("Error: shell failed ({err})"), + } +} + +fn exec_edit(root: &Path, args: &Value) -> String { + let Some(path) = pick_string(args, &["path", "filePath", "file_path", "target_file"]) else { + return "Error: edit requires path".into(); + }; + let old_str = pick_string(args, &["old_string", "oldString"]).unwrap_or_default(); + let new_str = pick_string(args, &["new_string", "newString", "content", "text"]) + .unwrap_or_default(); + if old_str.is_empty() { + return exec_write(root, args); + } + let full = resolve_path(root, &path); + if !full.starts_with(root) { + return "Error: path outside workspace".into(); + } + match std::fs::read_to_string(&full) { + Ok(prior) => { + if !prior.contains(&old_str) { + return format!("Error: old_string not found in {}", full.display()); + } + let updated = prior.replace(&old_str, &new_str); + match std::fs::write(&full, &updated) { + Ok(()) => format!("Edited {}", full.display()), + Err(err) => format!("Error writing {}: {err}", full.display()), + } + } + Err(err) => format!("Error reading {}: {err}", full.display()), + } +} + +fn exec_write(root: &Path, args: &Value) -> String { + let Some(path) = pick_string(args, &["path", "filePath", "file_path", "target_file"]) else { + return "Error: write requires path".into(); + }; + let full = resolve_path(root, &path); + if !full.starts_with(root) { + return "Error: path outside workspace".into(); + } + let content = pick_string(args, &["content", "contents", "text", "new_string", "replacement"]) + .unwrap_or_default(); + let len = content.len(); + if let Some(parent) = full.parent() { + let _ = std::fs::create_dir_all(parent); + } + match std::fs::write(&full, &content) { + Ok(()) => format!("Wrote {len} bytes to {}", full.display()), + Err(err) => format!("Error writing {}: {err}", full.display()), + } +} + +fn exec_delete(root: &Path, args: &Value) -> String { + let Some(path) = pick_string(args, &["path", "filePath", "file_path", "target_file"]) else { + return "Error: delete requires path".into(); + }; + let full = resolve_path(root, &path); + if !full.starts_with(root) { + return "Error: path outside workspace".into(); + } + match std::fs::remove_file(&full) { + Ok(()) => format!("Deleted {}", full.display()), + Err(err) => format!("Error deleting {}: {err}", full.display()), + } +} diff --git a/crates/sinew-cursor/src/agent/transcript.rs b/crates/sinew-cursor/src/agent/transcript.rs new file mode 100644 index 00000000..6ec21f7b --- /dev/null +++ b/crates/sinew-cursor/src/agent/transcript.rs @@ -0,0 +1,63 @@ +use sinew_core::{ChatMessage, Role}; + +#[derive(Debug, Clone, serde::Serialize)] +pub struct TranscriptTurn { + pub user_text: String, + pub assistant_text: String, +} + +/// Split transcript into prior turns and the latest user message (Composer action). +pub fn split_transcript(transcript: &[ChatMessage]) -> (Vec, String) { + let mut turns = Vec::new(); + let mut pending_user: Option = None; + + for message in transcript { + let text = message.text(); + let trimmed = text.trim(); + if trimmed.is_empty() { + continue; + } + match message.role { + Role::User => { + if let Some(user) = pending_user.take() { + turns.push(TranscriptTurn { + user_text: user, + assistant_text: String::new(), + }); + } + pending_user = Some(trimmed.to_string()); + } + Role::Assistant => { + if let Some(user) = pending_user.take() { + turns.push(TranscriptTurn { + user_text: user, + assistant_text: trimmed.to_string(), + }); + } + } + } + } + + let current_user = pending_user.unwrap_or_default(); + (turns, current_user) +} + +#[cfg(test)] +mod tests { + use super::*; + use sinew_core::ChatMessage; + + #[test] + fn splits_history_and_current_user() { + let transcript = vec![ + ChatMessage::user_text("hello"), + ChatMessage::assistant_text("hi there"), + ChatMessage::user_text("follow up"), + ]; + let (turns, current) = split_transcript(&transcript); + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].user_text, "hello"); + assert_eq!(turns[0].assistant_text, "hi there"); + assert_eq!(current, "follow up"); + } +} diff --git a/crates/sinew-cursor/src/agent/transport.rs b/crates/sinew-cursor/src/agent/transport.rs new file mode 100644 index 00000000..ad058410 --- /dev/null +++ b/crates/sinew-cursor/src/agent/transport.rs @@ -0,0 +1,110 @@ +/// Force the Node `agent-bridge` subprocess (`SINEW_CURSOR_BRIDGE=node`). +pub fn prefer_node_bridge() -> bool { + match std::env::var("SINEW_CURSOR_BRIDGE") { + Ok(value) => matches!( + value.trim().to_ascii_lowercase().as_str(), + "node" | "0" | "false" + ), + Err(_) => false, + } +} + +/// Use the native Rust HTTP/2 bridge (default). +pub fn use_rust_agent_bridge() -> bool { + !prefer_node_bridge() +} + +/// Force-disable automatic Node fallback (`SINEW_CURSOR_BRIDGE_FALLBACK=0`). +pub fn forbid_node_fallback() -> bool { + match std::env::var("SINEW_CURSOR_BRIDGE_FALLBACK") { + Ok(value) => matches!(value.trim().to_ascii_lowercase().as_str(), "0" | "false" | "no"), + Err(_) => false, + } +} + +/// Automatic Node fallback is disabled — Composer uses the native Rust bridge by default. +/// Set `SINEW_CURSOR_BRIDGE=node` only for local debugging. +pub fn should_auto_fallback_to_node() -> bool { + false +} + +/// Install Node bridge deps only when explicitly forcing the Node bridge. +pub fn should_prepare_node_bridge_at_startup() -> bool { + prefer_node_bridge() + && super::setup::bridge_directory().is_some_and(|dir| !super::setup::bridge_ready(&dir)) +} + +/// Transport selection for Cursor Composer streaming. +/// +/// Defaults to `agent.v1` (works with Sinew OAuth). Set `SINEW_CURSOR_TRANSPORT=idempotent` +/// only to force the legacy IdempotentSSE path (currently broken server-side). +pub fn use_agent_transport() -> bool { + match std::env::var("SINEW_CURSOR_TRANSPORT") { + Ok(value) => { + let trimmed = value.trim().to_ascii_lowercase(); + !matches!(trimmed.as_str(), "idempotent" | "sse" | "idempotent_sse") + } + Err(_) => true, + } +} + +#[cfg(test)] +mod tests { + use super::use_agent_transport; + use std::sync::{Mutex, OnceLock}; + + fn env_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|e| e.into_inner()) + } + + #[test] + fn defaults_to_agent_without_env() { + let _guard = env_lock(); + std::env::remove_var("SINEW_CURSOR_TRANSPORT"); + assert!(use_agent_transport()); + } + + #[test] + fn rust_bridge_by_default() { + let _guard = env_lock(); + std::env::remove_var("SINEW_CURSOR_BRIDGE"); + assert!(super::use_rust_agent_bridge()); + assert!(!super::prefer_node_bridge()); + } + + #[test] + fn node_bridge_when_forced() { + let _guard = env_lock(); + std::env::set_var("SINEW_CURSOR_BRIDGE", "node"); + assert!(!super::use_rust_agent_bridge()); + assert!(super::prefer_node_bridge()); + std::env::remove_var("SINEW_CURSOR_BRIDGE"); + } + + #[test] + fn auto_fallback_disabled_by_default() { + let _guard = env_lock(); + std::env::remove_var("SINEW_CURSOR_BRIDGE"); + std::env::remove_var("SINEW_CURSOR_BRIDGE_FALLBACK"); + assert!(!super::should_auto_fallback_to_node()); + } + + #[test] + fn forbid_fallback_when_disabled() { + let _guard = env_lock(); + std::env::set_var("SINEW_CURSOR_BRIDGE_FALLBACK", "0"); + assert!(super::forbid_node_fallback()); + std::env::remove_var("SINEW_CURSOR_BRIDGE_FALLBACK"); + } + + #[test] + fn idempotent_only_when_forced() { + let _guard = env_lock(); + std::env::set_var("SINEW_CURSOR_TRANSPORT", "idempotent"); + assert!(!use_agent_transport()); + std::env::remove_var("SINEW_CURSOR_TRANSPORT"); + } +} diff --git a/crates/sinew-cursor/src/auth/composer.rs b/crates/sinew-cursor/src/auth/composer.rs new file mode 100644 index 00000000..032b657f --- /dev/null +++ b/crates/sinew-cursor/src/auth/composer.rs @@ -0,0 +1,335 @@ +use std::{ + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use directories::ProjectDirs; +use serde::{Deserialize, Serialize}; + +use sinew_core::{AppError, Result}; + +use crate::identity::CursorIdeIdentity; + +const CURSOR_AUTH_CLIENT_ID: &str = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB"; +const CURSOR_OAUTH_TOKEN_URL: &str = "https://api2.cursor.sh/oauth/token"; +const REFRESH_SKEW_MS: i64 = 120_000; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CursorComposerAuthStatus { + pub connected: bool, + #[serde(default = "default_disconnected_state")] + pub connection_state: String, + pub email: Option, + pub membership_type: Option, + pub subscription_status: Option, + pub source: Option, + pub expires_at_ms: Option, + pub last_sync_ms: Option, + pub login_id: Option, + pub error: Option, +} + +fn default_disconnected_state() -> String { + "disconnected".into() +} + +impl CursorComposerAuthStatus { + pub fn disconnected() -> Self { + Self { + connected: false, + connection_state: default_disconnected_state(), + email: None, + membership_type: None, + subscription_status: None, + source: None, + expires_at_ms: None, + last_sync_ms: None, + login_id: None, + error: None, + } + } + + pub fn with_connection_state( + mut self, + connection_state: impl Into, + login_id: Option, + error: Option, + ) -> Self { + self.connection_state = connection_state.into(); + self.login_id = login_id; + self.error = error; + self + } +} + +#[derive(Debug, Clone)] +pub struct ComposerSession { + pub access_token: String, + pub refresh_token: Option, + pub email: Option, + pub membership_type: Option, + pub subscription_status: Option, + pub expires_at_ms: Option, + pub source_path: PathBuf, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StoredAuth { + provider: String, + auth_mode: String, + tokens: StoredTokens, + profile: StoredProfile, + #[serde(default, skip_serializing_if = "Option::is_none")] + last_sync_ms: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StoredTokens { + access_token: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + refresh_token: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + expires_at_ms: Option, +} + +#[derive(Debug, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct StoredProfile { + #[serde(default, skip_serializing_if = "Option::is_none")] + email: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + membership_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + subscription_status: Option, +} + +pub fn default_composer_auth_path() -> Result { + let dirs = ProjectDirs::from("dev", "hyrak", "sinew") + .ok_or_else(|| AppError::Auth("unable to resolve local data directory".into()))?; + Ok(dirs.data_local_dir().join("cursor-composer-auth.json")) +} + +pub fn load_composer_auth_status() -> Result { + load_composer_auth_status_from(&default_composer_auth_path()?) +} + +pub fn load_composer_auth_status_from(path: &Path) -> Result { + let bytes = match std::fs::read(path) { + Ok(bytes) => bytes, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Ok(CursorComposerAuthStatus::disconnected()) + } + Err(err) => return Err(AppError::Auth(format!("unable to read auth file: {err}"))), + }; + let auth: StoredAuth = serde_json::from_slice(&bytes) + .map_err(|err| AppError::Auth(format!("invalid auth file: {err}")))?; + Ok(status_from_auth(&auth)) +} + +pub fn save_oauth_tokens( + access_token: String, + refresh_token: String, + email: Option, + membership_type: Option, + subscription_status: Option, +) -> Result { + let path = default_composer_auth_path()?; + let mut auth = StoredAuth { + provider: PROVIDER_ID.into(), + auth_mode: "oauth".into(), + tokens: StoredTokens { + access_token, + refresh_token: Some(refresh_token), + expires_at_ms: None, + }, + profile: StoredProfile { + email, + membership_type, + subscription_status, + }, + last_sync_ms: Some(now_ms()), + }; + auth.tokens.expires_at_ms = jwt_exp_ms(&auth.tokens.access_token); + write_auth_file(&path, &auth)?; + Ok(status_from_auth(&auth)) +} + +pub fn sync_composer_auth_from_ide() -> Result { + Err(AppError::Auth( + "Direct IDE session sync is disabled. Connect Cursor from Sinew Settings using OAuth.".into(), + )) +} + +pub fn load_composer_session() -> Result> { + let path = default_composer_auth_path()?; + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(AppError::Auth(format!("unable to read auth file: {err}"))), + }; + let auth: StoredAuth = serde_json::from_slice(&bytes) + .map_err(|err| AppError::Auth(format!("invalid auth file: {err}")))?; + if auth.provider != PROVIDER_ID || auth.tokens.access_token.trim().is_empty() { + return Ok(None); + } + if auth.auth_mode != "oauth" { + return Ok(None); + } + Ok(Some(ComposerSession { + access_token: auth.tokens.access_token, + refresh_token: auth.tokens.refresh_token, + email: auth.profile.email, + membership_type: auth.profile.membership_type, + subscription_status: auth.profile.subscription_status, + expires_at_ms: auth.tokens.expires_at_ms, + source_path: path, + })) +} + +pub async fn ensure_fresh_composer_token( + http: &reqwest::Client, + session: &ComposerSession, +) -> Result { + let expires_at_ms = session + .expires_at_ms + .or_else(|| jwt_exp_ms(&session.access_token)); + if !token_needs_refresh(expires_at_ms) { + return Ok(session.access_token.clone()); + } + let Some(refresh) = session.refresh_token.as_ref() else { + return Ok(session.access_token.clone()); + }; + let body = serde_json::json!({ + "grant_type": "refresh_token", + "client_id": CURSOR_AUTH_CLIENT_ID, + "refresh_token": refresh, + }); + let identity = CursorIdeIdentity::load(); + let session_id = uuid::Uuid::new_v4().to_string(); + let request_id = uuid::Uuid::new_v4().to_string(); + let mut headers = reqwest::header::HeaderMap::new(); + identity.apply_authenticated(&mut headers, &session_id, &request_id, &session.access_token); + + let response = http + .post(CURSOR_OAUTH_TOKEN_URL) + .headers(headers) + .header("content-type", "application/json") + .json(&body) + .send() + .await + .map_err(|err| AppError::Network(err.to_string()))?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(AppError::Auth(format!( + "rafraîchissement OAuth Composer échoué ({status}) : reconnectez-vous dans Réglages → Fournisseurs. {body}" + ))); + } + let payload: serde_json::Value = response + .json() + .await + .map_err(|err| AppError::Decode(err.to_string()))?; + let access = payload + .get("access_token") + .and_then(|value| value.as_str()) + .unwrap_or(&session.access_token) + .to_string(); + let refresh_token = payload + .get("refresh_token") + .and_then(|value| value.as_str()) + .map(str::to_string) + .or_else(|| session.refresh_token.clone()); + let expires_at_ms = jwt_exp_ms(&access); + let mut auth: StoredAuth = serde_json::from_slice(&std::fs::read(&session.source_path)?) + .map_err(|err| AppError::Auth(format!("invalid auth file: {err}")))?; + auth.tokens.access_token = access.clone(); + auth.tokens.refresh_token = refresh_token; + auth.tokens.expires_at_ms = expires_at_ms; + auth.last_sync_ms = Some(now_ms()); + write_auth_file(&session.source_path, &auth)?; + Ok(access) +} + +pub fn delete_composer_auth() -> Result<()> { + let path = default_composer_auth_path()?; + match std::fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(AppError::Auth(format!("unable to delete auth file: {err}"))), + } +} + +const PROVIDER_ID: &str = "cursor"; + +fn status_from_auth(auth: &StoredAuth) -> CursorComposerAuthStatus { + let mut status = CursorComposerAuthStatus { + connected: !auth.tokens.access_token.trim().is_empty(), + connection_state: if auth.tokens.access_token.trim().is_empty() { + "disconnected".into() + } else { + "connected".into() + }, + email: auth.profile.email.clone(), + membership_type: auth.profile.membership_type.clone(), + subscription_status: auth.profile.subscription_status.clone(), + source: Some(auth.auth_mode.clone()), + expires_at_ms: auth.tokens.expires_at_ms, + last_sync_ms: auth.last_sync_ms, + login_id: None, + error: None, + }; + if status.expires_at_ms.is_none() { + status.expires_at_ms = jwt_exp_ms(&auth.tokens.access_token); + } + status +} + +fn write_auth_file(path: &Path, auth: &StoredAuth) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|err| AppError::Auth(format!("unable to create auth directory: {err}")))?; + } + let pretty = serde_json::to_vec_pretty(auth) + .map_err(|err| AppError::Decode(format!("unable to serialize auth file: {err}")))?; + std::fs::write(path, pretty) + .map_err(|err| AppError::Auth(format!("unable to write auth file: {err}")))?; + Ok(()) +} + +fn token_needs_refresh(expires_at_ms: Option) -> bool { + let Some(expires_at_ms) = expires_at_ms else { + return false; + }; + expires_at_ms - now_ms() <= REFRESH_SKEW_MS +} + +fn jwt_exp_ms(token: &str) -> Option { + let payload = token.split('.').nth(1)?; + let padded = match payload.len() % 4 { + 0 => payload.to_string(), + n => format!("{}{}", payload, "=".repeat(4 - n)), + }; + let bytes = base64_decode(&padded).ok()?; + let json: serde_json::Value = serde_json::from_slice(&bytes).ok()?; + json.get("exp") + .and_then(|value| value.as_i64()) + .map(|seconds| seconds * 1000) +} + +fn base64_decode(input: &str) -> Result> { + use base64::Engine as _; + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(input) + .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(input)) + .map_err(|err| AppError::Decode(err.to_string())) +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as i64) + .unwrap_or(0) +} diff --git a/crates/sinew-cursor/src/auth/mod.rs b/crates/sinew-cursor/src/auth/mod.rs new file mode 100644 index 00000000..5c650354 --- /dev/null +++ b/crates/sinew-cursor/src/auth/mod.rs @@ -0,0 +1,2 @@ +pub mod composer; +pub mod oauth; diff --git a/crates/sinew-cursor/src/auth/oauth.rs b/crates/sinew-cursor/src/auth/oauth.rs new file mode 100644 index 00000000..683cdabf --- /dev/null +++ b/crates/sinew-cursor/src/auth/oauth.rs @@ -0,0 +1,251 @@ +use std::time::Duration; + +use base64::Engine as _; +use rand::RngCore; +use reqwest::Client; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use tokio::sync::Notify; + +use sinew_core::{AppError, Result}; + +use crate::identity::CursorIdeIdentity; + +use super::composer::{save_oauth_tokens, CursorComposerAuthStatus}; + +const CURSOR_WEBSITE_URL: &str = "https://cursor.com"; +const CURSOR_BACKEND_URL: &str = "https://api2.cursor.sh"; +const CURSOR_LOGIN_PATH: &str = "/loginDeepControl"; +const POLL_INTERVAL_MS: u64 = 500; +const POLL_TIMEOUT_SECS: u64 = 600; + +#[derive(Debug, Clone)] +pub struct CursorLoginChallenge { + pub auth_url: String, + pub uuid: String, + pub verifier: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PollResponse { + #[serde(default)] + access_token: Option, + #[serde(default)] + refresh_token: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StripeProfileResponse { + #[serde(default)] + membership_type: Option, + #[serde(default)] + individual_membership_type: Option, + #[serde(default)] + subscription_status: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct EmailResponse { + #[serde(default)] + email: Option, +} + +pub fn create_login_challenge() -> CursorLoginChallenge { + let verifier = generate_verifier(); + let challenge = generate_challenge(&verifier); + let uuid = uuid::Uuid::new_v4().to_string(); + let auth_url = format!( + "{CURSOR_WEBSITE_URL}{CURSOR_LOGIN_PATH}?challenge={challenge}&uuid={uuid}&mode=login" + ); + CursorLoginChallenge { + auth_url, + uuid, + verifier, + } +} + +pub async fn wait_for_oauth_login( + http: &Client, + challenge: &CursorLoginChallenge, + cancel: &Notify, +) -> Result { + let deadline = tokio::time::Instant::now() + Duration::from_secs(POLL_TIMEOUT_SECS); + + loop { + tokio::select! { + _ = cancel.notified() => { + return Err(AppError::Auth("Cursor login canceled".into())); + } + _ = tokio::time::sleep(Duration::from_millis(POLL_INTERVAL_MS)) => {} + } + + if tokio::time::Instant::now() >= deadline { + return Err(AppError::Auth( + "Cursor login timed out. Complete sign-in in the browser.".into(), + )); + } + + let Some((access_token, refresh_token)) = poll_once(http, challenge).await? else { + continue; + }; + + let profile = fetch_profile(http, &access_token).await.unwrap_or_default(); + return save_oauth_tokens( + access_token, + refresh_token, + profile.email, + profile.membership_type, + profile.subscription_status, + ); + } +} + +async fn poll_once( + http: &Client, + challenge: &CursorLoginChallenge, +) -> Result> { + let identity = CursorIdeIdentity::load(); + let session_id = uuid::Uuid::new_v4().to_string(); + let request_id = uuid::Uuid::new_v4().to_string(); + let mut headers = reqwest::header::HeaderMap::new(); + identity.apply(&mut headers, &session_id, &request_id); + + let url = format!( + "{CURSOR_BACKEND_URL}/auth/poll?uuid={}&verifier={}", + urlencoding(challenge.uuid.as_str()), + urlencoding(challenge.verifier.as_str()), + ); + let response = http + .get(url) + .headers(headers) + .send() + .await + .map_err(|err| AppError::Network(format!("cursor auth poll failed: {err}")))?; + + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(AppError::Auth(format!( + "cursor auth poll failed with {status}: {body}" + ))); + } + + let body: PollResponse = response + .json() + .await + .map_err(|err| AppError::Decode(format!("invalid cursor auth poll body: {err}")))?; + + match (body.access_token, body.refresh_token) { + (Some(access_token), Some(refresh_token)) + if !access_token.trim().is_empty() && !refresh_token.trim().is_empty() => + { + Ok(Some((access_token, refresh_token))) + } + _ => Ok(None), + } +} + +#[derive(Default)] +struct OAuthProfile { + email: Option, + membership_type: Option, + subscription_status: Option, +} + +async fn fetch_profile(http: &Client, access_token: &str) -> Result { + let mut profile = OAuthProfile::default(); + + if let Ok(email) = fetch_email(http, access_token).await { + profile.email = email; + } + + if let Ok(stripe) = fetch_stripe_profile(http, access_token).await { + profile.membership_type = stripe + .individual_membership_type + .or(stripe.membership_type); + profile.subscription_status = stripe.subscription_status; + } + + Ok(profile) +} + +async fn fetch_email(http: &Client, access_token: &str) -> Result> { + let identity = CursorIdeIdentity::load(); + let session_id = uuid::Uuid::new_v4().to_string(); + let request_id = uuid::Uuid::new_v4().to_string(); + let mut headers = reqwest::header::HeaderMap::new(); + identity.apply_authenticated(&mut headers, &session_id, &request_id, access_token); + + let response = http + .post(format!( + "{CURSOR_BACKEND_URL}/aiserver.v1.AuthService/GetEmail" + )) + .headers(headers) + .header("authorization", format!("Bearer {access_token}")) + .header("content-type", "application/json") + .header("connect-protocol-version", "1") + .json(&serde_json::json!({})) + .send() + .await + .map_err(|err| AppError::Network(err.to_string()))?; + + if !response.status().is_success() { + return Ok(None); + } + + let body: EmailResponse = response + .json() + .await + .map_err(|err| AppError::Decode(err.to_string()))?; + Ok(body.email.filter(|value| !value.trim().is_empty())) +} + +async fn fetch_stripe_profile(http: &Client, access_token: &str) -> Result { + let identity = CursorIdeIdentity::load(); + let session_id = uuid::Uuid::new_v4().to_string(); + let request_id = uuid::Uuid::new_v4().to_string(); + let mut headers = reqwest::header::HeaderMap::new(); + identity.apply_authenticated(&mut headers, &session_id, &request_id, access_token); + + let response = http + .get(format!("{CURSOR_BACKEND_URL}/auth/full_stripe_profile")) + .headers(headers) + .header("authorization", format!("Bearer {access_token}")) + .send() + .await + .map_err(|err| AppError::Network(err.to_string()))?; + + if !response.status().is_success() { + return Err(AppError::Network(format!( + "cursor stripe profile returned {}", + response.status() + ))); + } + + response + .json() + .await + .map_err(|err| AppError::Decode(err.to_string())) +} + +fn generate_verifier() -> String { + let mut bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut bytes); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) +} + +fn generate_challenge(verifier: &str) -> String { + let digest = Sha256::digest(verifier.as_bytes()); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest) +} + +fn urlencoding(value: &str) -> String { + url::form_urlencoded::byte_serialize(value.as_bytes()).collect() +} diff --git a/crates/sinew-cursor/src/client.rs b/crates/sinew-cursor/src/client.rs new file mode 100644 index 00000000..5ba8a2b5 --- /dev/null +++ b/crates/sinew-cursor/src/client.rs @@ -0,0 +1,417 @@ +use std::sync::Mutex; +use std::time::Instant; + +use async_trait::async_trait; +use futures::StreamExt; +use reqwest::Client; +use serde_json::json; +use sinew_core::{ + AppError, ModelCapabilities, ModelRef, Provider, ProviderRequest, ProviderStream, Result, + PartKind, StopReason, StreamEvent, TokenEstimate, ToolCallIntro, Usage, +}; + +use crate::{ + auth::composer::{ensure_fresh_composer_token, load_composer_session, ComposerSession}, + connect::{decode_connect_frames, parse_connect_events, ComposerEvent}, + conversation::build_stream_request, + encryption::BlobEncryptionKey, + identity::{CachedUsage, CursorIdeIdentity, USAGE_CACHE_TTL}, + model_info, + stream_state::StreamStateStore, + usage::{fetch_usage, CursorUsageInfo}, +}; + +const COMPOSER_CHAT_URL: &str = + "https://api2.cursor.sh/aiserver.v1.ChatService/StreamUnifiedChatWithToolsIdempotentSSE"; +const AUTO_POOL_THRESHOLD: f64 = 98.0; +const STREAM_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(45); + +#[derive(Clone)] +pub struct CursorConfig { + pub composer: Option, +} + +impl CursorConfig { + pub fn from_default_sources() -> Result { + Ok(Self { + composer: load_composer_session()?, + }) + } +} + +pub struct CursorProvider { + config: CursorConfig, + http: Client, + identity: CursorIdeIdentity, + stream_state: Mutex, + usage_cache: Mutex>, +} + +impl CursorProvider { + pub fn new(config: CursorConfig) -> Result { + let identity = CursorIdeIdentity::load(); + let http = Client::builder() + .user_agent(identity.user_agent()) + .build() + .map_err(|err| AppError::Network(err.to_string()))?; + Ok(Self { + config, + http, + identity, + stream_state: Mutex::new(StreamStateStore::load()), + usage_cache: Mutex::new(None), + }) + } + + pub fn from_default_sources() -> Result { + Self::new(CursorConfig::from_default_sources()?) + } + + pub async fn usage_snapshot(&self) -> Result> { + let Some(session) = self.config.composer.as_ref() else { + return Ok(None); + }; + let token = ensure_fresh_composer_token(&self.http, session).await?; + Ok(Some( + fetch_usage(&self.http, &self.identity, &token).await?, + )) + } + + async fn composer_token(&self) -> Result { + let session = load_composer_session()?.ok_or_else(|| { + AppError::Auth( + "Cursor n'est pas connecté. Ouvrez Réglages → Fournisseurs et connectez-vous via OAuth (Google ou GitHub).".into(), + ) + })?; + let token = ensure_fresh_composer_token(&self.http, &session).await?; + if self.should_block_for_pool_exhaustion(&token).await? { + return Err(AppError::Auth(format!( + "Cursor Auto+Composer pool is exhausted ({:.0}% used). Wait for reset or use Cursor IDE.", + self.cached_usage(&token).await?.auto_percent_used + ))); + } + Ok(token) + } + + async fn cached_usage(&self, token: &str) -> Result { + if let Ok(guard) = self.usage_cache.lock() { + if let Some(cached) = guard.as_ref() { + if cached.fetched_at.elapsed() < USAGE_CACHE_TTL { + return Ok(cached.info.clone()); + } + } + } + let usage = fetch_usage(&self.http, &self.identity, token).await?; + if let Ok(mut guard) = self.usage_cache.lock() { + *guard = Some(CachedUsage { + fetched_at: Instant::now(), + info: usage.clone(), + }); + } + Ok(usage) + } + + async fn should_block_for_pool_exhaustion(&self, token: &str) -> Result { + let usage = self.cached_usage(token).await?; + Ok(usage.auto_percent_used >= AUTO_POOL_THRESHOLD) + } +} + +#[async_trait] +impl Provider for CursorProvider { + fn name(&self) -> &str { + "cursor" + } + + fn capabilities(&self, model: &ModelRef) -> Option { + if model.provider != "cursor" { + return None; + } + Some(model_info::capabilities(model)) + } + + async fn estimate_tokens(&self, request: ProviderRequest) -> Result { + Ok(TokenEstimate { + input_tokens: rough_token_estimate(&request), + exact: false, + }) + } + + async fn stream(&self, request: ProviderRequest) -> Result { + if request.model.provider != "cursor" { + return Err(AppError::Unsupported(format!( + "cursor provider cannot run model provider {}", + request.model.provider + ))); + } + let token = self.composer_token().await?; + if crate::agent::transport::use_agent_transport() { + return crate::agent::stream_via_agent_bridge(&self.identity, token, request).await; + } + stream_composer(self, token, request).await + } +} + +async fn stream_composer( + provider: &CursorProvider, + token: String, + request: ProviderRequest, +) -> Result { + let mut identity = provider.identity.clone(); + identity.refresh(); + identity.ensure_ready()?; + + let session_key = request + .cache_key + .as_deref() + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let (conversation_id, idempotency_key, encryption_key, seqno) = { + let mut guard = provider.stream_state.lock().map_err(|_| { + AppError::Provider("cursor stream state lock poisoned".into()) + })?; + let state = guard.conversation_state(&session_key); + ( + session_key.clone(), + state.idempotency_key.clone(), + state.encryption_key.clone(), + state.seqno, + ) + }; + let blob_key = BlobEncryptionKey::from_stored(&encryption_key)?; + let (payload, next_seqno) = build_stream_request( + &request, + &conversation_id, + &idempotency_key, + seqno, + &identity, + &blob_key, + ); + if let Ok(mut guard) = provider.stream_state.lock() { + guard.update_seqno(&session_key, next_seqno); + } + + let session_id = uuid::Uuid::new_v4().to_string(); + let request_id = uuid::Uuid::new_v4().to_string(); + let mut headers = reqwest::header::HeaderMap::new(); + identity.apply_authenticated(&mut headers, &session_id, &request_id, &token); + + headers.insert( + reqwest::header::HeaderName::from_static("x-idempotency-key"), + reqwest::header::HeaderValue::from_str(&idempotency_key).unwrap(), + ); + headers.insert( + reqwest::header::HeaderName::from_static("x-idempotent-encryption-key"), + reqwest::header::HeaderValue::from_str(&blob_key.idempotent_header_b64()).unwrap(), + ); + headers.insert( + reqwest::header::HeaderName::from_static("x-blob-encryption-key"), + reqwest::header::HeaderValue::from_str(&blob_key.blob_header_hex()).unwrap(), + ); + + let response = provider + .http + .post(COMPOSER_CHAT_URL) + .headers(headers) + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/connect+json") + .header("connect-protocol-version", "1") + .header("accept", "application/connect+json") + .body(payload) + .timeout(std::time::Duration::from_secs(120)) + .send() + .await + .map_err(|err| AppError::Network(err.to_string()))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(AppError::Network(format!( + "Composer stream failed ({status}): {body}" + ))); + } + + #[cfg(test)] + eprintln!( + "Composer HTTP status={} content-type={:?}", + response.status(), + response.headers().get("content-type") + ); + + let model = request.model.name.clone(); + let mut byte_stream = response.bytes_stream(); + let mut buffer = Vec::new(); + let mut last_text = String::new(); + let mut last_thinking = String::new(); + let mut started = false; + let text_index = 0usize; + let thinking_index = 1usize; + let mut tool_index = 2usize; + let mut emitted_tools = std::collections::HashSet::::new(); + let mut saw_tool_call = false; + let mut usage = Usage::default(); + + let events = async_stream::try_stream! { + let mut open_part: Option<(usize, PartKind)> = None; + + loop { + let next = tokio::time::timeout(STREAM_IDLE_TIMEOUT, byte_stream.next()).await; + let chunk = match next { + Ok(Some(chunk)) => chunk, + Ok(None) => break, + Err(_) if !started => { + Err(AppError::Network( + "Composer stream timed out waiting for the first response. Reconnect Cursor in Settings > Providers.".into(), + ))?; + break; + } + Err(_) => break, + }; + let chunk = chunk.map_err(|err| AppError::Network(err.to_string()))?; + buffer.extend_from_slice(&chunk); + for frame in decode_connect_frames(&mut buffer)? { + for event in parse_connect_events(&frame)? { + if !started { + started = true; + yield StreamEvent::MessageStart { model: model.clone() }; + } + match event { + ComposerEvent::Text(text) => { + let delta = if text.starts_with(&last_text) { + text[last_text.len()..].to_string() + } else { + text.clone() + }; + last_text = text; + if !delta.is_empty() { + if open_part.map(|(_, kind)| kind) != Some(PartKind::Text) { + if let Some((idx, _)) = open_part.take() { + yield StreamEvent::PartStop { index: idx }; + } + open_part = Some((text_index, PartKind::Text)); + yield StreamEvent::PartStart { + index: text_index, + kind: PartKind::Text, + tool: None, + }; + } + yield StreamEvent::TextDelta { index: text_index, delta }; + } + } + ComposerEvent::Thinking(thinking) => { + let delta = if thinking.starts_with(&last_thinking) { + thinking[last_thinking.len()..].to_string() + } else { + thinking.clone() + }; + last_thinking = thinking; + if !delta.is_empty() { + if open_part.map(|(_, kind)| kind) != Some(PartKind::Thinking) { + if let Some((idx, _)) = open_part.take() { + yield StreamEvent::PartStop { index: idx }; + } + open_part = Some((thinking_index, PartKind::Thinking)); + yield StreamEvent::PartStart { + index: thinking_index, + kind: PartKind::Thinking, + tool: None, + }; + } + yield StreamEvent::ThinkingDelta { index: thinking_index, delta }; + } + } + ComposerEvent::ToolCall(call) => { + if !emitted_tools.insert(call.id.clone()) { + continue; + } + if let Some((idx, _)) = open_part.take() { + yield StreamEvent::PartStop { index: idx }; + } + saw_tool_call = true; + let input_json = serde_json::to_string(&call.input) + .unwrap_or_else(|_| "{}".into()); + yield StreamEvent::PartStart { + index: tool_index, + kind: PartKind::ToolCall, + tool: Some(ToolCallIntro { + id: call.id.clone(), + name: call.sinew_name.clone(), + }), + }; + yield StreamEvent::PartMeta { + index: tool_index, + meta: json!({ "cursor_tool": call.cursor_tool }), + }; + yield StreamEvent::ToolJsonDelta { + index: tool_index, + chunk: input_json, + }; + yield StreamEvent::PartStop { index: tool_index }; + tool_index += 1; + } + ComposerEvent::Usage(update) => { + merge_usage(&mut usage, update); + yield StreamEvent::Usage { usage }; + } + } + } + } + } + if let Some((idx, _)) = open_part.take() { + yield StreamEvent::PartStop { index: idx }; + } + if !started { + yield StreamEvent::MessageStart { model: model.clone() }; + } + yield StreamEvent::MessageStop { + stop_reason: if saw_tool_call { + StopReason::ToolUse + } else { + StopReason::EndTurn + }, + usage, + }; + }; + + Ok(Box::pin(events)) +} + +fn merge_usage(into: &mut Usage, update: Usage) { + if update.input_tokens > 0 { + into.input_tokens = update.input_tokens; + } + if update.output_tokens > 0 { + into.output_tokens = update.output_tokens; + } + if update.total_tokens > 0 { + into.total_tokens = update.total_tokens; + } else if into.input_tokens > 0 || into.output_tokens > 0 { + into.total_tokens = into.input_tokens + into.output_tokens; + } + if update.reasoning_tokens > 0 { + into.reasoning_tokens = update.reasoning_tokens; + } + if update.cache_read_tokens > 0 { + into.cache_read_tokens = update.cache_read_tokens; + } + if update.cache_creation_tokens > 0 { + into.cache_creation_tokens = update.cache_creation_tokens; + } +} + +fn rough_token_estimate(request: &ProviderRequest) -> u32 { + let mut chars = request + .system_prompt + .as_ref() + .map(|value| value.len()) + .unwrap_or(0); + for message in &request.transcript { + for part in &message.parts { + if let sinew_core::Part::Text { text, .. } = part { + chars += text.len(); + } + } + } + (chars / 4).max(1) as u32 +} diff --git a/crates/sinew-cursor/src/connect.rs b/crates/sinew-cursor/src/connect.rs new file mode 100644 index 00000000..f494adb8 --- /dev/null +++ b/crates/sinew-cursor/src/connect.rs @@ -0,0 +1,255 @@ +use bytes::Bytes; +use serde_json::Value; +use sinew_core::{Result, Usage}; + +use crate::tools::{resolve_tool_call, ParsedToolCall}; + +pub fn frame_connect_json(payload: &[u8], flags: u8) -> Vec { + let mut frame = Vec::with_capacity(5 + payload.len()); + frame.push(flags); + frame.extend_from_slice(&(payload.len() as u32).to_be_bytes()); + frame.extend_from_slice(payload); + frame +} + +pub fn append_end_stream_frame(buffer: &mut Vec) { + buffer.extend(frame_connect_json(&[], 0x02)); +} + +pub fn decode_connect_frames(buffer: &mut Vec) -> Result> { + let mut frames = Vec::new(); + loop { + if buffer.len() < 5 { + break; + } + let flags = buffer[0]; + let length = u32::from_be_bytes([buffer[1], buffer[2], buffer[3], buffer[4]]) as usize; + if buffer.len() < 5 + length { + break; + } + let payload = buffer[5..5 + length].to_vec(); + buffer.drain(..5 + length); + if flags & 0x02 != 0 && payload.is_empty() { + continue; + } + frames.push(Bytes::from(payload)); + } + Ok(frames) +} + +#[derive(Debug, Clone)] +pub enum ComposerEvent { + Text(String), + Thinking(String), + ToolCall(ParsedToolCall), + Usage(Usage), +} + +pub fn parse_connect_events(payload: &[u8]) -> Result> { + let value: Value = match serde_json::from_slice(payload) { + Ok(value) => value, + Err(_) => return Ok(Vec::new()), + }; + Ok(collect_events(&value)) +} + +fn collect_events(value: &Value) -> Vec { + let mut events = Vec::new(); + if let Some(server_chunk) = value + .get("serverChunk") + .or_else(|| value.get("server_chunk")) + { + push_events_from_value(server_chunk, &mut events); + if let Some(nested) = server_chunk.get("streamUnifiedChatResponse") { + push_events_from_value(nested, &mut events); + } + if let Some(nested) = server_chunk.get("stream_unified_chat_response") { + push_events_from_value(nested, &mut events); + } + if let Some(nested) = server_chunk.get("clientSideToolV2Call") { + if let Some(parsed) = resolve_tool_call(nested) { + events.push(ComposerEvent::ToolCall(parsed)); + } + } + } + push_events_from_value(value, &mut events); + if let Some(nested) = value.get("streamUnifiedChatResponse") { + push_events_from_value(nested, &mut events); + } + if let Some(nested) = value.get("stream_unified_chat_response") { + push_events_from_value(nested, &mut events); + } + for key in [ + "clientSideToolV2Call", + "client_side_tool_v2_call", + "toolCallV2", + "tool_call_v2", + "partialToolCall", + "partial_tool_call", + ] { + if let Some(call) = value.get(key) { + if let Some(parsed) = resolve_tool_call(call) { + events.push(ComposerEvent::ToolCall(parsed)); + } + } + } + events +} + +fn push_events_from_value(value: &Value, events: &mut Vec) { + if let Some(text) = value.get("text").and_then(Value::as_str) { + if !text.is_empty() { + events.push(ComposerEvent::Text(text.to_string())); + } + } + if let Some(thinking) = value.get("thinking") { + if let Some(text) = thinking.as_str() { + if !text.is_empty() { + events.push(ComposerEvent::Thinking(text.to_string())); + } + } else if let Some(text) = thinking.get("text").and_then(Value::as_str) { + if !text.is_empty() { + events.push(ComposerEvent::Thinking(text.to_string())); + } + } + } + for key in [ + "clientSideToolV2Call", + "client_side_tool_v2_call", + "toolCallV2", + "tool_call_v2", + "partialToolCall", + "partial_tool_call", + "toolCall", + "tool_call", + ] { + if let Some(call) = value.get(key) { + if let Some(parsed) = resolve_tool_call(call) { + events.push(ComposerEvent::ToolCall(parsed)); + } + } + } + if let Some(error) = value.get("error") { + let message = error + .get("message") + .and_then(Value::as_str) + .unwrap_or("Composer error"); + events.push(ComposerEvent::Text(format!("[error] {message}"))); + } + if let Some(usage) = parse_usage_value(value) { + events.push(ComposerEvent::Usage(usage)); + } +} + +fn parse_usage_value(value: &Value) -> Option { + let usage = value + .get("usage") + .or_else(|| value.get("tokenUsage")) + .or_else(|| value.get("token_usage")) + .or_else(|| value.get("usageInfo")) + .or_else(|| value.get("usage_info"))?; + let input_tokens = usage_field_u32(usage, &["inputTokens", "input_tokens", "promptTokens", "prompt_tokens"]); + let output_tokens = usage_field_u32( + usage, + &[ + "outputTokens", + "output_tokens", + "completionTokens", + "completion_tokens", + ], + ); + let total_tokens = usage_field_u32(usage, &["totalTokens", "total_tokens"]).max(input_tokens + output_tokens); + let reasoning_tokens = usage_field_u32( + usage, + &["reasoningTokens", "reasoning_tokens", "thinkingTokens", "thinking_tokens"], + ); + let cache_read_tokens = usage_field_u32( + usage, + &["cacheReadTokens", "cache_read_tokens", "cachedInputTokens", "cached_input_tokens"], + ); + let cache_creation_tokens = usage_field_u32( + usage, + &[ + "cacheCreationTokens", + "cache_creation_tokens", + "cacheWriteTokens", + "cache_write_tokens", + ], + ); + if input_tokens == 0 + && output_tokens == 0 + && total_tokens == 0 + && reasoning_tokens == 0 + && cache_read_tokens == 0 + && cache_creation_tokens == 0 + { + return None; + } + Some(Usage { + input_tokens, + output_tokens, + total_tokens, + reasoning_tokens, + cache_read_tokens, + cache_creation_tokens, + }) +} + +fn usage_field_u32(value: &Value, keys: &[&str]) -> u32 { + for key in keys { + if let Some(number) = value.get(*key).and_then(Value::as_u64) { + return number.min(u32::MAX as u64) as u32; + } + } + 0 +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn end_stream_frame_uses_connect_end_flag() { + let mut body = frame_connect_json(&[], 0); + append_end_stream_frame(&mut body); + assert_eq!(body.len(), 10); + assert_eq!(body[5], 0x02); + } + + #[test] + fn parses_nested_usage_payload() { + let events = collect_events(&json!({ + "streamUnifiedChatResponse": { + "usage": { + "inputTokens": 120, + "outputTokens": 45, + "reasoningTokens": 10 + } + } + })); + assert!(events.iter().any(|event| matches!( + event, + ComposerEvent::Usage(usage) + if usage.input_tokens == 120 + && usage.output_tokens == 45 + && usage.reasoning_tokens == 10 + ))); + } + + #[test] + fn resolves_unsupported_tool_calls_from_stream() { + let events = collect_events(&json!({ + "clientSideToolV2Call": { + "tool": "CLIENT_SIDE_TOOL_V2_APPLY_PATCH", + "toolCallId": "call_unknown" + } + })); + assert!(events.iter().any(|event| matches!( + event, + ComposerEvent::ToolCall(call) + if call.sinew_name == crate::tools::COMPOSER_UNSUPPORTED_TOOL + && call.cursor_tool == "CLIENT_SIDE_TOOL_V2_APPLY_PATCH" + ))); + } +} diff --git a/crates/sinew-cursor/src/context_injection.rs b/crates/sinew-cursor/src/context_injection.rs new file mode 100644 index 00000000..aee663cd --- /dev/null +++ b/crates/sinew-cursor/src/context_injection.rs @@ -0,0 +1,125 @@ +use std::path::Path; + +use sinew_core::{Part, ProviderRequest, Role}; + +use crate::sanitize::sanitize_outbound_text; + +const MIN_QUERY_CHARS: usize = 12; +const MAX_INJECTED_CHARS: usize = 12_000; +const INJECT_CHUNK_LIMIT: usize = 8; + +pub fn append_local_index_excerpts(request: &ProviderRequest, context: &mut String) { + if is_tool_result_continuation(request) { + return; + } + let Some(workspace_root) = request.workspace_root.as_deref() else { + return; + }; + let Some(query) = latest_user_search_query(request) else { + return; + }; + if query.chars().count() < MIN_QUERY_CHARS { + return; + } + + let root = Path::new(workspace_root); + let Ok((_stats, hits)) = + sinew_index::index_and_search_workspace_isolated(root, &query, None, INJECT_CHUNK_LIMIT) + else { + return; + }; + if hits.is_empty() { + return; + } + + context.push_str("\n\n## Relevant workspace excerpts\n"); + let mut remaining = MAX_INJECTED_CHARS; + for hit in hits { + let block = format!( + "\n### {}:{}-{}\n{}\n", + hit.path, + hit.start_line, + hit.end_line, + hit.snippet.replace("[[", "").replace("]]", "") + ); + let block = sanitize_outbound_text(&block); + if block.len() > remaining { + break; + } + context.push_str(&block); + remaining -= block.len(); + } +} + +fn latest_user_search_query(request: &ProviderRequest) -> Option { + for message in request.transcript.iter().rev() { + if message.role != Role::User { + continue; + } + let text = user_visible_text(message); + if text.chars().count() >= MIN_QUERY_CHARS { + return Some(text); + } + let only_tool_results = message + .parts + .iter() + .all(|part| matches!(part, Part::ToolResult { .. })); + if only_tool_results { + continue; + } + } + None +} + +fn user_visible_text(message: &sinew_core::ChatMessage) -> String { + message + .parts + .iter() + .filter_map(|part| match part { + Part::Text { text, .. } if !text.trim().is_empty() => { + Some(sanitize_outbound_text(text)) + } + _ => None, + }) + .collect::>() + .join("\n") +} + +fn is_tool_result_continuation(request: &ProviderRequest) -> bool { + let Some(last) = request.transcript.last() else { + return false; + }; + last.role == Role::User + && !last.parts.is_empty() + && last + .parts + .iter() + .all(|part| matches!(part, Part::ToolResult { .. })) +} + +#[cfg(test)] +mod tests { + use super::*; + use sinew_core::{ChatMessage, ModelRef, ProviderRequest}; + + #[test] + fn skips_injection_on_tool_result_turns() { + let mut context = String::from("base"); + let request = ProviderRequest::new( + ModelRef::new("cursor", "composer-2.5-fast"), + vec![ChatMessage { + role: Role::User, + parts: vec![Part::ToolResult { + tool_call_id: "call_1".into(), + content: "ok".into(), + images: Vec::new(), + is_error: false, + meta: None, + }], + }], + ) + .with_workspace_root("/tmp/example"); + append_local_index_excerpts(&request, &mut context); + assert_eq!(context, "base"); + } +} diff --git a/crates/sinew-cursor/src/conversation.rs b/crates/sinew-cursor/src/conversation.rs new file mode 100644 index 00000000..89686ffc --- /dev/null +++ b/crates/sinew-cursor/src/conversation.rs @@ -0,0 +1,758 @@ +use std::collections::HashMap; + +use sha2::{Digest, Sha256}; +use serde_json::{json, Value}; +use sinew_core::{Effort, Part, ProviderRequest, Role, ServiceTier, ToolDescriptor}; + +use crate::{ + context_injection::append_local_index_excerpts, + encryption::BlobEncryptionKey, + identity::CursorIdeIdentity, + images::message_images, + sanitize::{sanitize_outbound_json, sanitize_outbound_text}, + tools::{build_client_tool_result, cursor_tool_name, is_mappable_sinew_tool, SUPPORTED_TOOLS}, + workspace::{snapshot, WorkspaceSnapshot}, +}; + +const HEADER_ONLY_THRESHOLD: usize = 24; + +pub fn build_stream_request( + request: &ProviderRequest, + conversation_id: &str, + idempotency_key: &str, + seqno: u32, + identity: &CursorIdeIdentity, + encryption_key: &BlobEncryptionKey, +) -> (Vec, u32) { + if is_tool_result_continuation(request) { + let (mut framed, seqno) = build_tool_result_frames(request, idempotency_key, seqno); + crate::connect::append_end_stream_frame(&mut framed); + return (framed, seqno); + } + let body = build_full_request(request, conversation_id, identity, encryption_key); + let chunk = json!({ + "clientChunk": body, + "idempotencyKey": idempotency_key, + "seqno": seqno, + }); + let payload = serde_json::to_vec(&chunk).unwrap_or_default(); + let mut framed = crate::connect::frame_connect_json(&payload, 0); + crate::connect::append_end_stream_frame(&mut framed); + (framed, seqno + 1) +} + +fn build_full_request( + request: &ProviderRequest, + conversation_id: &str, + identity: &CursorIdeIdentity, + encryption_key: &BlobEncryptionKey, +) -> Value { + let body_key = encryption_key.body_json_string(); + let workspace = request + .workspace_root + .as_deref() + .and_then(snapshot); + let (model_name, enable_slow_pool, max_mode, thinking_level) = + model_details(&request.model.name, request.effective_effort(), request.service_tier); + let (conversation, headers) = build_conversation(request, conversation_id); + let explicit_context = build_explicit_context(request, &request.tools); + let mut stream_request = json!({ + "streamUnifiedChatRequest": { + "conversation": conversation, + "explicitContext": explicit_context, + "modelDetails": { + "modelName": model_name, + "enableSlowPool": enable_slow_pool, + "maxMode": max_mode + }, + "isAgentic": true, + "isChat": false, + "unifiedMode": "UNIFIED_MODE_AGENT", + "useUnifiedChatPrompt": true, + "enableYoloMode": false, + "supportedTools": SUPPORTED_TOOLS, + "conversationId": conversation_id, + "environmentInfo": build_environment_info(identity, workspace.as_ref()), + "shouldDisableTools": false, + "allowModelFallbacks": false, + "mcpTools": build_mcp_tools(&request.tools), + "blobEncryptionKey": body_key, + "speculativeSummarizationEncryptionKey": body_key, + } + }); + if let Some(level) = thinking_level { + stream_request["streamUnifiedChatRequest"]["thinkingLevel"] = json!(level); + } + if let Some(workspace) = workspace.as_ref() { + attach_workspace_context(&mut stream_request, workspace); + } + if !headers.is_empty() { + stream_request["streamUnifiedChatRequest"]["fullConversationHeadersOnly"] = Value::Array(headers); + } + stream_request +} + +fn attach_workspace_context(stream_request: &mut Value, workspace: &WorkspaceSnapshot) { + let req = stream_request + .get_mut("streamUnifiedChatRequest") + .and_then(Value::as_object_mut); + let Some(req) = req else { + return; + }; + req.insert( + "workspaceFolders".into(), + json!([{ + "uri": sanitize_outbound_text(&workspace.uri), + "name": sanitize_outbound_text(&workspace.name), + }]), + ); + req.insert( + "projectLayouts".into(), + json!([{ + "rootPath": sanitize_outbound_text(&workspace.name), + "content": sanitize_outbound_json(workspace.project_layout.clone()), + }]), + ); + if let Some(branch) = workspace.branch.as_ref() { + req.insert( + "repositoryInfo".into(), + json!({ + "repoName": sanitize_outbound_text(&workspace.name), + "branchName": sanitize_outbound_text(branch), + "workspaceUri": sanitize_outbound_text(&workspace.uri), + }), + ); + } +} + +fn build_explicit_context(request: &ProviderRequest, tools: &[ToolDescriptor]) -> Value { + let mut context = request + .system_prompt + .as_deref() + .map(sanitize_outbound_text) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| { + "You are Composer in Cursor IDE, a coding assistant. Respond in French when the user writes in French.".into() + }); + let mcp_summary = mcp_instructions(tools); + if !mcp_summary.is_empty() { + context.push_str("\n\n"); + context.push_str(&mcp_summary); + } + append_local_index_excerpts(request, &mut context); + json!({ "context": context }) +} + +fn mcp_instructions(tools: &[ToolDescriptor]) -> String { + let mut lines = Vec::new(); + for tool in tools { + if tool.name.starts_with("mcp__") { + lines.push(format!( + "- {}: {}", + tool.name, + sanitize_outbound_text(&tool.description) + )); + } + } + if lines.is_empty() { + return String::new(); + } + format!("Available MCP tools:\n{}", lines.join("\n")) +} + +fn build_mcp_tools(tools: &[ToolDescriptor]) -> Vec { + tools + .iter() + .filter(|tool| tool.name.starts_with("mcp__")) + .map(|tool| { + json!({ + "name": sanitize_outbound_text(&tool.name), + "providerIdentifier": "cursor-mcp", + "toolName": sanitize_outbound_text(&tool.name), + "description": sanitize_outbound_text(&tool.description), + "inputSchema": sanitize_outbound_json(tool.input_schema.clone()), + }) + }) + .collect() +} + +fn build_environment_info(identity: &CursorIdeIdentity, workspace: Option<&WorkspaceSnapshot>) -> Value { + let home = std::env::var("USERPROFILE") + .or_else(|_| std::env::var("HOME")) + .unwrap_or_default(); + let mut info = json!({ + "exthostPlatform": identity.platform, + "exthostArch": identity.arch, + "exthostShell": identity.shell, + "cursorVersion": identity.client_version, + "localOsType": identity.platform, + "localTimezone": identity.timezone, + "homeDirectory": home, + "isRemote": false, + }); + if let Some(workspace) = workspace { + if let Some(obj) = info.as_object_mut() { + obj.insert( + "workspaceUris".into(), + Value::Array(vec![Value::String(workspace.uri.clone())]), + ); + } + } + info +} + +fn build_conversation(request: &ProviderRequest, conversation_id: &str) -> (Vec, Vec) { + let mut messages = Vec::new(); + let mut headers = Vec::new(); + let mut pending_calls: HashMap = HashMap::new(); + let mut message_index = 0usize; + + for message in &request.transcript { + match message.role { + Role::Assistant => { + let text = message_text(message); + let tool_calls = tool_calls_from_message(message); + for (id, name, cursor_tool, input) in &tool_calls { + pending_calls.insert( + id.clone(), + (name.clone(), cursor_tool.clone(), input.clone()), + ); + } + if !text.is_empty() || !tool_calls.is_empty() { + let bubble_id = + stable_bubble_id(conversation_id, message_index, Role::Assistant, message); + headers.push(json!({ + "bubbleId": bubble_id, + "type": "MESSAGE_TYPE_AI", + })); + let mut entry = json!({ + "type": "MESSAGE_TYPE_AI", + "text": text, + "bubbleId": bubble_id, + "isAgentic": true, + }); + if !tool_calls.is_empty() { + entry["clientSideToolV2Calls"] = + Value::Array(assistant_tool_calls_payload(&tool_calls)); + } + messages.push(entry); + message_index += 1; + } + } + Role::User => { + let text = message_text(message); + let images = message_images(message); + let tool_results = tool_results_from_message(message, &pending_calls); + if text.is_empty() && tool_results.is_empty() && images.is_empty() { + continue; + } + let bubble_id = + stable_bubble_id(conversation_id, message_index, Role::User, message); + let request_id = stable_request_id(conversation_id, message_index, message); + headers.push(json!({ + "bubbleId": bubble_id, + "type": "MESSAGE_TYPE_HUMAN", + })); + let mut entry = json!({ + "type": "MESSAGE_TYPE_HUMAN", + "text": text, + "bubbleId": bubble_id, + "requestId": request_id, + }); + if !images.is_empty() { + entry["images"] = Value::Array(images); + } + if !tool_results.is_empty() { + entry["toolResults"] = Value::Array(tool_results); + } + if let Some(status) = request + .workspace_root + .as_deref() + .and_then(snapshot) + .and_then(|snapshot| snapshot.git_status) + { + entry["gitStatusRaw"] = json!(sanitize_outbound_text(&status)); + } + messages.push(entry); + message_index += 1; + } + } + } + + if messages.is_empty() { + let bubble_id = stable_bubble_id( + conversation_id, + 0, + Role::User, + &sinew_core::ChatMessage::user_text("Continue."), + ); + let request_id = stable_request_id( + conversation_id, + 0, + &sinew_core::ChatMessage::user_text("Continue."), + ); + messages.push(json!({ + "type": "MESSAGE_TYPE_HUMAN", + "text": "Continue.", + "bubbleId": bubble_id, + "requestId": request_id, + })); + } + + let header_only = if messages.len() >= HEADER_ONLY_THRESHOLD { + headers + } else { + Vec::new() + }; + (messages, header_only) +} + +fn tool_calls_from_message( + message: &sinew_core::ChatMessage, +) -> Vec<(String, String, String, Value)> { + message + .parts + .iter() + .filter_map(|part| match part { + Part::ToolCall { + id, + name, + input, + meta, + } => { + let cursor_tool = resolve_cursor_tool(name, meta)?; + Some(( + id.clone(), + name.clone(), + cursor_tool, + sanitize_outbound_json(input.clone()), + )) + } + _ => None, + }) + .collect() +} + +fn resolve_cursor_tool(name: &str, meta: &Option) -> Option { + if name.starts_with("mcp__") { + return Some("CLIENT_SIDE_TOOL_V2_CALL_MCP_TOOL".into()); + } + if !is_mappable_sinew_tool(name) { + return None; + } + meta.as_ref() + .and_then(|value| value.get("cursor_tool")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or_else(|| { + let mapped = cursor_tool_name(name); + (mapped != "CLIENT_SIDE_TOOL_V2_UNSPECIFIED").then(|| mapped.to_string()) + }) +} + +fn assistant_tool_calls_payload( + tool_calls: &[(String, String, String, Value)], +) -> Vec { + tool_calls + .iter() + .map(|(id, _name, cursor_tool, input)| { + let raw_args = serde_json::to_string(&input).unwrap_or_else(|_| "{}".into()); + json!({ + "tool": cursor_tool, + "toolCallId": id, + "rawArgs": sanitize_outbound_text(&raw_args), + }) + }) + .collect() +} + +fn tool_results_from_message( + message: &sinew_core::ChatMessage, + pending_calls: &HashMap, +) -> Vec { + message + .parts + .iter() + .filter_map(|part| match part { + Part::ToolResult { + tool_call_id, + content, + images, + is_error, + .. + } => { + let (sinew_name, cursor_tool, args) = pending_calls + .get(tool_call_id) + .cloned() + .unwrap_or_else(|| { + ( + "bash".into(), + "CLIENT_SIDE_TOOL_V2_RUN_TERMINAL_COMMAND_V2".into(), + json!({}), + ) + }); + Some(json!({ + "toolCallId": tool_call_id, + "toolName": cursor_tool, + "toolIndex": 0, + "content": sanitize_outbound_text(content), + "args": sanitize_outbound_text(&args.to_string()), + "rawArgs": sanitize_outbound_text(&args.to_string()), + "error": if *is_error { + json!({ "message": sanitize_outbound_text(content) }) + } else { + Value::Null + }, + "result": build_client_tool_result( + tool_call_id, + &sinew_name, + &cursor_tool, + content, + *is_error, + Some(images), + ), + })) + } + _ => None, + }) + .collect() +} + +fn is_tool_result_continuation(request: &ProviderRequest) -> bool { + let Some(last) = request.transcript.last() else { + return false; + }; + matches!(last.role, Role::User) + && last.parts.iter().all(|part| matches!(part, Part::ToolResult { .. })) + && !last.parts.is_empty() +} + +fn build_tool_result_frames( + request: &ProviderRequest, + idempotency_key: &str, + mut seqno: u32, +) -> (Vec, u32) { + let mut pending_calls: HashMap = HashMap::new(); + for message in &request.transcript { + if matches!(message.role, Role::Assistant) { + for (id, name, cursor_tool, input) in tool_calls_from_message(message) { + pending_calls.insert(id, (name, cursor_tool, input)); + } + } + } + let Some(last) = request.transcript.last() else { + return (Vec::new(), seqno); + }; + let mut framed = Vec::new(); + for part in &last.parts { + let Part::ToolResult { + tool_call_id, + content, + images, + is_error, + .. + } = part + else { + continue; + }; + let (sinew_name, cursor_tool, _) = pending_calls.get(tool_call_id).cloned().unwrap_or_else(|| { + ( + "bash".into(), + "CLIENT_SIDE_TOOL_V2_RUN_TERMINAL_COMMAND_V2".into(), + json!({}), + ) + }); + let chunk = json!({ + "clientChunk": { + "clientSideToolV2Result": build_client_tool_result( + tool_call_id, + &sinew_name, + &cursor_tool, + content, + *is_error, + Some(images), + ) + }, + "idempotencyKey": idempotency_key, + "seqno": seqno, + }); + if let Ok(payload) = serde_json::to_vec(&chunk) { + framed.extend(crate::connect::frame_connect_json(&payload, 0)); + seqno += 1; + } + } + (framed, seqno) +} + +fn model_details( + model: &str, + effort: Option, + service_tier: Option, +) -> (String, bool, bool, Option<&'static str>) { + let is_fast = match service_tier { + Some(ServiceTier::Fast) => true, + _ => model == "composer-2.5-fast", + }; + + let (model_name, default_slow_pool, max_mode, default_thinking) = if is_fast { + ( + "composer-2.5-fast".into(), + false, + false, + Some("THINKING_LEVEL_MEDIUM"), + ) + } else { + ( + "composer-2.5".into(), + false, + true, + Some("THINKING_LEVEL_HIGH"), + ) + }; + let enable_slow_pool = match service_tier { + Some(ServiceTier::Flex) => true, + Some(ServiceTier::Fast) => false, + None => default_slow_pool, + }; + let thinking_level = effort_to_thinking_level(effort).or(default_thinking); + (model_name, enable_slow_pool, max_mode, thinking_level) +} + +fn effort_to_thinking_level(effort: Option) -> Option<&'static str> { + match effort { + None => None, + Some(Effort::None) => Some("THINKING_LEVEL_NONE"), + Some(Effort::Low) => Some("THINKING_LEVEL_LOW"), + Some(Effort::Medium) => Some("THINKING_LEVEL_MEDIUM"), + Some(Effort::High) => Some("THINKING_LEVEL_HIGH"), + Some(Effort::Xhigh) | Some(Effort::Max) => Some("THINKING_LEVEL_XHIGH"), + } +} + +fn stable_bubble_id( + conversation_id: &str, + message_index: usize, + role: Role, + message: &sinew_core::ChatMessage, +) -> String { + if let Some(id) = message + .parts + .iter() + .find_map(|part| part_meta(part).and_then(|meta| meta.get("cursor_bubble_id"))) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + { + return id.to_string(); + } + deterministic_uuid( + "bubble", + conversation_id, + message_index, + role, + &message_fingerprint(message), + ) +} + +fn stable_request_id( + conversation_id: &str, + message_index: usize, + message: &sinew_core::ChatMessage, +) -> String { + if let Some(id) = message + .parts + .iter() + .find_map(|part| part_meta(part).and_then(|meta| meta.get("cursor_request_id"))) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + { + return id.to_string(); + } + deterministic_uuid( + "request", + conversation_id, + message_index, + Role::User, + &message_fingerprint(message), + ) +} + +fn part_meta(part: &Part) -> Option<&Value> { + match part { + Part::Text { meta, .. } + | Part::Image { meta, .. } + | Part::Thinking { meta, .. } + | Part::ToolCall { meta, .. } + | Part::ToolResult { meta, .. } => meta.as_ref(), + } +} + +fn message_fingerprint(message: &sinew_core::ChatMessage) -> String { + let mut hasher = Sha256::new(); + for part in &message.parts { + match part { + Part::Text { text, .. } => { + hasher.update(b"text:"); + hasher.update(text.as_bytes()); + } + Part::Image { media_type, data, .. } => { + hasher.update(b"image:"); + hasher.update(media_type.as_bytes()); + hasher.update(data.as_bytes()); + } + Part::Thinking { text, .. } => { + hasher.update(b"thinking:"); + hasher.update(text.as_bytes()); + } + Part::ToolCall { + id, + name, + input, + .. + } => { + hasher.update(b"tool_call:"); + hasher.update(id.as_bytes()); + hasher.update(name.as_bytes()); + if let Ok(encoded) = serde_json::to_vec(input) { + hasher.update(&encoded); + } + } + Part::ToolResult { + tool_call_id, + content, + is_error, + .. + } => { + hasher.update(b"tool_result:"); + hasher.update(tool_call_id.as_bytes()); + hasher.update(content.as_bytes()); + hasher.update([u8::from(*is_error)]); + } + } + } + hex_digest(hasher.finalize()) +} + +fn deterministic_uuid( + kind: &str, + conversation_id: &str, + message_index: usize, + role: Role, + fingerprint: &str, +) -> String { + let role_tag = match role { + Role::User => "human", + Role::Assistant => "ai", + }; + let seed = format!("{kind}:{conversation_id}:{message_index}:{role_tag}:{fingerprint}"); + let digest = Sha256::digest(seed.as_bytes()); + let bytes: [u8; 16] = digest[..16] + .try_into() + .unwrap_or([0u8; 16]); + uuid::Uuid::from_bytes(bytes).to_string() +} + +fn hex_digest(bytes: impl AsRef<[u8]>) -> String { + bytes + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn message_text(message: &sinew_core::ChatMessage) -> String { + message + .parts + .iter() + .filter_map(|part| match part { + Part::Text { text, .. } if !text.trim().is_empty() => { + Some(sanitize_outbound_text(text)) + } + _ => None, + }) + .collect::>() + .join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + use sinew_core::{ChatMessage, ModelRef, Part, Role}; + + #[test] + fn human_message_includes_attached_images() { + let png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + let request = ProviderRequest::new( + ModelRef::new("cursor", "composer-2.5-fast"), + vec![ChatMessage { + role: Role::User, + parts: vec![ + Part::Image { + media_type: "image/png".into(), + data: png.into(), + meta: None, + }, + Part::Text { + text: "Describe this screenshot.".into(), + meta: None, + }, + ], + }], + ) + .with_cache_key("conv-image-test"); + let (messages, _) = build_conversation(&request, "conv-image-test"); + assert_eq!(messages.len(), 1); + let images = messages[0]["images"].as_array().expect("images array"); + assert_eq!(images.len(), 1); + assert_eq!(images[0]["data"].as_str(), Some(png)); + assert_eq!(images[0]["dimension"]["width"], 1); + } + + #[test] + fn tool_result_continuation_detected() { + let request = ProviderRequest::new( + ModelRef::new("cursor", "composer-2.5-fast"), + vec![ChatMessage { + role: Role::User, + parts: vec![Part::ToolResult { + tool_call_id: "call_1".into(), + content: "ok".into(), + images: Vec::new(), + is_error: false, + meta: None, + }], + }], + ); + assert!(is_tool_result_continuation(&request)); + } + + #[test] + fn effort_maps_to_thinking_level() { + use sinew_core::Effort; + + let (_, _, _, level) = super::model_details("composer-2.5-fast", Some(Effort::High), None); + assert_eq!(level, Some("THINKING_LEVEL_HIGH")); + } + + #[test] + fn bubble_ids_stay_stable_for_same_transcript() { + let request = ProviderRequest::new( + ModelRef::new("cursor", "composer-2.5-fast"), + vec![ + ChatMessage::user_text("Find auth code"), + ChatMessage::assistant_text("I'll search the codebase."), + ], + ) + .with_cache_key("conv-stable-test"); + let (first, _) = build_conversation(&request, "conv-stable-test"); + let (second, _) = build_conversation(&request, "conv-stable-test"); + assert_eq!( + first[0]["bubbleId"].as_str(), + second[0]["bubbleId"].as_str() + ); + assert_eq!( + first[1]["bubbleId"].as_str(), + second[1]["bubbleId"].as_str() + ); + } +} diff --git a/crates/sinew-cursor/src/encryption.rs b/crates/sinew-cursor/src/encryption.rs new file mode 100644 index 00000000..2a0a3b9b --- /dev/null +++ b/crates/sinew-cursor/src/encryption.rs @@ -0,0 +1,103 @@ +//! Blob + idempotent encryption material for `StreamUnifiedChatWithToolsIdempotentSSE`. +//! +//! Live probes (May 2026): `x-idempotent-encryption-key` must be **base64url** that decodes to +//! **exactly 32 bytes**. Other lengths → immediate `invalid`; 32-byte values (including raw blob +//! key, SHA-256/HMAC derivations) → HTTP 200 then **hang** (~10 s) with no `serverChunk`. +//! The correct 32-byte payload is still unknown — likely not in the Cursor JS bundle; see +//! `scripts/CAPTURE-MITM.md` and `scripts/probe_idempotent_crypto.py`. + +use base64::Engine as _; +use sinew_core::{AppError, Result}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BlobEncryptionKey([u8; 32]); + +impl BlobEncryptionKey { + #[allow(dead_code)] + pub fn from_raw(bytes: [u8; 32]) -> Self { + Self(bytes) + } + + pub fn random() -> Self { + let mut bytes = [0u8; 32]; + rand::RngCore::fill_bytes(&mut rand::rng(), &mut bytes); + Self(bytes) + } + + pub fn from_stored(value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(AppError::Provider("empty blob encryption key".into())); + } + if let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(trimmed) { + return Self::from_bytes(&decoded); + } + if let Ok(decoded) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(trimmed) { + return Self::from_bytes(&decoded); + } + if trimmed.len() == 64 && trimmed.chars().all(|ch| ch.is_ascii_hexdigit()) { + let mut bytes = [0u8; 32]; + for (index, chunk) in bytes.iter_mut().enumerate() { + let byte = u8::from_str_radix(&trimmed[index * 2..index * 2 + 2], 16) + .map_err(|err| AppError::Provider(format!("invalid encryption key hex: {err}")))?; + *chunk = byte; + } + return Ok(Self(bytes)); + } + Err(AppError::Provider( + "unsupported blob encryption key encoding".into(), + )) + } + + fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() != 32 { + return Err(AppError::Provider(format!( + "blob encryption key must be 32 bytes, got {}", + bytes.len() + ))); + } + let mut out = [0u8; 32]; + out.copy_from_slice(bytes); + Ok(Self(out)) + } + + pub fn blob_header_hex(&self) -> String { + self.0.iter().map(|byte| format!("{byte:02x}")).collect() + } + + pub fn idempotent_header_b64(&self) -> String { + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(self.0) + } + + pub fn body_json_string(&self) -> String { + base64::engine::general_purpose::STANDARD.encode(self.0) + } + + pub fn persist_standard_b64(&self) -> String { + self.body_json_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrips_standard_and_url_safe_storage() { + let key = BlobEncryptionKey::random(); + let standard = key.persist_standard_b64(); + let restored = BlobEncryptionKey::from_stored(&standard).expect("standard b64"); + assert_eq!(restored, key); + + let url_safe = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(key.0); + let restored = BlobEncryptionKey::from_stored(&url_safe).expect("url-safe b64"); + assert_eq!(restored, key); + } + + #[test] + fn header_formats_are_distinct() { + let key = BlobEncryptionKey([0xAB; 32]); + assert_eq!(key.blob_header_hex(), "ab".repeat(32)); + assert_eq!(key.idempotent_header_b64(), base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0xAB; 32])); + } +} diff --git a/crates/sinew-cursor/src/identity.rs b/crates/sinew-cursor/src/identity.rs new file mode 100644 index 00000000..5e0f8e0e --- /dev/null +++ b/crates/sinew-cursor/src/identity.rs @@ -0,0 +1,473 @@ +use std::{ + fs, + path::{Path, PathBuf}, + sync::OnceLock, + time::{Duration, Instant}, +}; + +use base64::Engine as _; +use directories::ProjectDirs; +use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use sinew_core::{AppError, Result}; + +pub const CURSOR_CLIENT_VERSION: &str = "3.5.38"; +// Last verified against Cursor IDE 3.5.33 (May 2026). Override with SINEW_CURSOR_CLIENT_VERSION. + +/// `agent.v1` CLI transport (cursor-oauth-opencode); distinct from IDE `3.5.38`. +pub const AGENT_CLI_CLIENT_VERSION: &str = "cli-2026.01.09-231024f"; + +static EPHEMERAL_MACHINE_ID: OnceLock = OnceLock::new(); + +#[derive(Clone, Debug)] +pub struct CursorIdeIdentity { + pub client_version: String, + pub machine_id: String, + pub mac_machine_id: Option, + pub timezone: String, + pub platform: String, + pub arch: String, + pub shell: String, +} + +impl CursorIdeIdentity { + pub fn load() -> Self { + Self::assemble() + } + + pub fn refresh(&mut self) { + *self = Self::assemble(); + } + + pub fn ensure_ready(&self) -> Result<()> { + if self.machine_id.trim().is_empty() { + return Err(AppError::Auth( + "Composer device machineId unavailable.".into(), + )); + } + if self.client_version.trim().is_empty() { + return Err(AppError::Auth("Cursor client version unavailable.".into())); + } + Ok(()) + } + + fn assemble() -> Self { + let (machine_id, mac_machine_id) = load_machine_ids(); + let client_version = Self::resolve_client_version(); + let (platform, arch) = detect_platform(); + Self { + client_version, + machine_id, + mac_machine_id, + timezone: read_local_timezone(), + platform, + arch, + shell: detect_shell(), + } + } + + pub fn user_agent(&self) -> String { + format!("Cursor/{}", self.client_version) + } + + pub fn resolve_client_version() -> String { + if let Ok(version) = std::env::var("SINEW_CURSOR_CLIENT_VERSION") { + let trimmed = version.trim(); + if !trimmed.is_empty() { + return trimmed.to_string(); + } + } + CURSOR_CLIENT_VERSION.to_string() + } + + pub fn apply(&self, headers: &mut HeaderMap, session_id: &str, request_id: &str) { + self.apply_common(headers, session_id, request_id); + set_header(headers, "x-cursor-checksum", &self.checksum()); + } + + /// Authenticated Cursor API calls derive `x-client-key` and the checksum + /// machine id from the bearer token, matching standalone OAuth clients. + pub fn apply_authenticated( + &self, + headers: &mut HeaderMap, + session_id: &str, + request_id: &str, + access_token: &str, + ) { + self.apply_common(headers, session_id, request_id); + let machine_id = Self::token_machine_id(access_token); + set_header( + headers, + "x-client-key", + &Self::token_client_key(access_token), + ); + set_header( + headers, + "x-cursor-checksum", + &self.checksum_for_machine_id(&machine_id, None), + ); + } + + /// Minimal CLI headers for `agent.v1` (matches `run-stream.mjs` defaults). + /// Do not mix IDE `apply_common` headers here — the server rejects cli+ide combos as unauthenticated. + pub fn apply_agent_authenticated( + &self, + headers: &mut HeaderMap, + _session_id: &str, + _request_id: &str, + access_token: &str, + ) { + let machine_id = Self::token_machine_id(access_token); + set_header(headers, "authorization", &format!("Bearer {access_token}")); + set_header( + headers, + "x-client-key", + &Self::token_client_key(access_token), + ); + set_header( + headers, + "x-cursor-checksum", + &self.checksum_for_machine_id(&machine_id, None), + ); + set_header(headers, "x-cursor-client-type", "cli"); + set_header(headers, "x-ghost-mode", "true"); + let agent_version = std::env::var("SINEW_CURSOR_AGENT_VERSION") + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| AGENT_CLI_CLIENT_VERSION.to_string()); + set_header(headers, "x-cursor-client-version", &agent_version); + } + + fn apply_common(&self, headers: &mut HeaderMap, session_id: &str, request_id: &str) { + set_header(headers, "user-agent", &self.user_agent()); + set_header(headers, "x-cursor-client-version", &self.client_version); + set_header(headers, "x-cursor-client-type", "ide"); + set_header(headers, "x-cursor-client-device-type", "desktop"); + set_header(headers, "x-cursor-client-os", &self.platform); + set_header(headers, "x-cursor-client-arch", &self.arch); + set_header(headers, "x-ghost-mode", "false"); + set_header(headers, "x-new-onboarding-completed", "true"); + set_header(headers, "x-cursor-timezone", &self.timezone); + set_header(headers, "connect-accept-encoding", "gzip"); + set_header(headers, "x-session-id", session_id); + set_header(headers, "x-request-id", request_id); + set_header(headers, "x-amzn-trace-id", &format!("Root={request_id}")); + } + + pub fn token_client_key(access_token: &str) -> String { + sha256_hex(access_token) + } + + pub fn token_machine_id(access_token: &str) -> String { + sha256_hex(&format!("{access_token}machineId")) + } + + fn checksum_for_machine_id(&self, machine_id: &str, mac_machine_id: Option<&str>) -> String { + let millis = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0); + let bucket = millis / 1_000_000; + let mut bytes = [ + ((bucket >> 40) & 0xff) as u8, + ((bucket >> 32) & 0xff) as u8, + ((bucket >> 24) & 0xff) as u8, + ((bucket >> 16) & 0xff) as u8, + ((bucket >> 8) & 0xff) as u8, + (bucket & 0xff) as u8, + ]; + let mut state = 165u8; + for (index, byte) in bytes.iter_mut().enumerate() { + *byte = (*byte ^ state).wrapping_add((index % 256) as u8); + state = *byte; + } + let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); + if let Some(mac_id) = mac_machine_id { + format!("{encoded}{machine_id}/{mac_id}") + } else { + format!("{encoded}{machine_id}") + } + } + + fn checksum(&self) -> String { + self.checksum_for_machine_id(&self.machine_id, self.mac_machine_id.as_deref()) + } +} + +fn sha256_hex(input: &str) -> String { + let digest = Sha256::digest(input.as_bytes()); + digest.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn cursor_storage_json_path() -> Option { + let base_dirs = directories::BaseDirs::new()?; + let config_dir = base_dirs.config_dir(); + let path = config_dir + .join("Cursor") + .join("User") + .join("globalStorage") + .join("storage.json"); + if path.exists() { + Some(path) + } else { + None + } +} + +/// Standalone Composer identity: always prefer the Sinew-persisted device id +/// (the same id used during OAuth login). Cursor IDE telemetry ids are only +/// used when explicitly opted in via `SINEW_CURSOR_USE_IDE_MACHINE=1`. +fn load_machine_ids() -> (String, Option) { + if let Some(sinew_id) = load_sinew_persisted_machine_id() { + return (sinew_id, None); + } + if use_cursor_ide_machine_ids() { + if let Some(ids) = load_cursor_storage_ids() { + return ids; + } + } + (load_or_create_sinew_machine_id(), None) +} + +fn use_cursor_ide_machine_ids() -> bool { + std::env::var("SINEW_CURSOR_USE_IDE_MACHINE") + .map(|value| { + let trimmed = value.trim(); + trimmed == "1" || trimmed.eq_ignore_ascii_case("true") + }) + .unwrap_or(false) +} + +fn load_sinew_persisted_machine_id() -> Option { + let path = sinew_device_path()?; + let contents = fs::read_to_string(path).ok()?; + let device: PersistedDevice = serde_json::from_str(&contents).ok()?; + let trimmed = device.machine_id.trim(); + if is_valid_machine_id(trimmed) { + Some(trimmed.to_string()) + } else { + None + } +} + +fn load_cursor_storage_ids() -> Option<(String, Option)> { + let path = cursor_storage_json_path()?; + let content = fs::read_to_string(path).ok()?; + let json: serde_json::Value = serde_json::from_str(&content).ok()?; + let machine_id = json.get("telemetry.machineId")?.as_str()?.to_string(); + let mac_machine_id = json + .get("telemetry.macMachineId") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + if !machine_id.trim().is_empty() { + Some((machine_id, mac_machine_id)) + } else { + None + } +} + +fn detect_platform() -> (String, String) { + let arch = normalize_arch(std::env::consts::ARCH); + if cfg!(windows) { + ("windows".into(), arch) + } else if cfg!(target_os = "macos") { + ("darwin".into(), arch) + } else { + ("linux".into(), arch) + } +} + +fn normalize_arch(arch: &str) -> String { + match arch { + "x86_64" | "x86" => "x64".into(), + "aarch64" | "arm64" => "arm64".into(), + other => other.to_string(), + } +} + +fn detect_shell() -> String { + if std::env::var("PSModulePath").is_ok() { + "powershell".into() + } else if cfg!(windows) { + "cmd".into() + } else { + std::env::var("SHELL").unwrap_or_else(|_| "bash".into()) + } +} + +fn set_header(headers: &mut HeaderMap, name: &str, value: &str) { + if let (Ok(header_name), Ok(header_value)) = ( + HeaderName::from_bytes(name.as_bytes()), + HeaderValue::from_str(value), + ) { + headers.insert(header_name, header_value); + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PersistedDevice { + machine_id: String, +} + +fn sinew_device_path() -> Option { + ProjectDirs::from("dev", "hyrak", "sinew") + .map(|dirs| dirs.data_local_dir().join("cursor-composer-device.json")) +} + +fn load_or_create_sinew_machine_id() -> String { + if let Some(existing) = load_sinew_persisted_machine_id() { + return existing; + } + + let Some(path) = sinew_device_path() else { + return uuid::Uuid::new_v4().to_string(); + }; + + let machine_id = uuid::Uuid::new_v4().to_string(); + if let Err(err) = persist_sinew_machine_id(&path, &machine_id) { + tracing::warn!("unable to persist composer device id: {err}"); + return EPHEMERAL_MACHINE_ID + .get_or_init(|| machine_id.clone()) + .clone(); + } + machine_id +} + +fn persist_sinew_machine_id(path: &Path, machine_id: &str) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|err| AppError::Auth(format!("unable to create device dir: {err}")))?; + } + let payload = PersistedDevice { + machine_id: machine_id.to_string(), + }; + let json = serde_json::to_string_pretty(&payload) + .map_err(|err| AppError::Auth(format!("unable to encode device id: {err}")))?; + fs::write(path, json) + .map_err(|err| AppError::Auth(format!("unable to persist device id: {err}")))?; + Ok(()) +} + +fn is_valid_machine_id(value: &str) -> bool { + !value.is_empty() && uuid::Uuid::parse_str(value).is_ok() +} + +fn read_local_timezone() -> String { + static CACHED_TZ: OnceLock = OnceLock::new(); + CACHED_TZ + .get_or_init(|| { + if let Ok(tz) = std::env::var("TZ") { + if !tz.trim().is_empty() { + return tz.to_string(); + } + } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + if let Ok(output) = std::process::Command::new("powershell") + .args(["-NoProfile", "-Command", "(Get-TimeZone).Id"]) + .creation_flags(0x08000000) + .output() + { + let tz = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !tz.is_empty() { + return tz; + } + } + } + "UTC".into() + }) + .clone() +} + +#[cfg(test)] +mod identity_tests { + use super::*; + use std::fs; + + #[test] + fn persisted_device_roundtrip() { + let path = + std::env::temp_dir().join(format!("sinew-cursor-device-{}.json", uuid::Uuid::new_v4())); + let id = uuid::Uuid::new_v4().to_string(); + persist_sinew_machine_id(&path, &id).expect("persist device id"); + let contents = fs::read_to_string(&path).expect("read device file"); + let device: PersistedDevice = serde_json::from_str(&contents).expect("parse device file"); + assert_eq!(device.machine_id, id); + let _ = fs::remove_file(path); + } + + #[test] + fn rejects_invalid_machine_id() { + assert!(!is_valid_machine_id("")); + assert!(!is_valid_machine_id("not-a-uuid")); + assert!(is_valid_machine_id(&uuid::Uuid::new_v4().to_string())); + } + + #[test] + fn apply_agent_authenticated_sets_bearer() { + let identity = CursorIdeIdentity::load(); + let token = "test-access-token"; + let mut headers = HeaderMap::new(); + identity.apply_agent_authenticated(&mut headers, "session", "request", token); + let auth = headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!(auth, format!("Bearer {token}")); + } + + #[test] + fn token_derived_auth_headers_are_stable() { + let token = "test-token"; + let client_key = CursorIdeIdentity::token_client_key(token); + let machine_id = CursorIdeIdentity::token_machine_id(token); + assert_eq!(client_key.len(), 64); + assert_eq!(machine_id.len(), 64); + assert_ne!(client_key, machine_id); + assert_eq!(client_key, CursorIdeIdentity::token_client_key(token)); + } + + #[test] + fn sinew_device_id_takes_precedence_over_cursor_storage() { + let sinew_id = uuid::Uuid::new_v4().to_string(); + let path = std::env::temp_dir().join(format!( + "sinew-cursor-device-priority-{}.json", + uuid::Uuid::new_v4() + )); + persist_sinew_machine_id(&path, &sinew_id).expect("persist device id"); + + let loaded = load_sinew_persisted_machine_id_from_path(&path); + assert_eq!(loaded.as_deref(), Some(sinew_id.as_str())); + let _ = fs::remove_file(path); + } + + fn load_sinew_persisted_machine_id_from_path(path: &std::path::Path) -> Option { + let contents = fs::read_to_string(path).ok()?; + let device: PersistedDevice = serde_json::from_str(&contents).ok()?; + let trimmed = device.machine_id.trim(); + if is_valid_machine_id(trimmed) { + Some(trimmed.to_string()) + } else { + None + } + } + + #[test] + fn client_version_defaults_to_constant() { + assert_eq!( + CursorIdeIdentity::resolve_client_version(), + CURSOR_CLIENT_VERSION + ); + } +} + +pub(crate) const USAGE_CACHE_TTL: Duration = Duration::from_secs(300); + +pub(crate) struct CachedUsage { + pub fetched_at: Instant, + pub info: crate::usage::CursorUsageInfo, +} diff --git a/crates/sinew-cursor/src/images.rs b/crates/sinew-cursor/src/images.rs new file mode 100644 index 00000000..601b0d4e --- /dev/null +++ b/crates/sinew-cursor/src/images.rs @@ -0,0 +1,188 @@ +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; +use serde_json::{json, Value}; +use sinew_core::{ChatMessage, Part}; + +pub fn wire_images_from_tool_results(images: &[sinew_core::ToolResultImage]) -> Vec { + images + .iter() + .filter_map(|image| wire_image(&image.media_type, &image.data)) + .collect() +} + +/// Build Cursor `ConversationMessage.images` payloads (`ImageProto` in JSON). +pub fn message_images(message: &ChatMessage) -> Vec { + message + .parts + .iter() + .filter_map(|part| match part { + Part::Image { media_type, data, .. } => wire_image(media_type, data), + _ => None, + }) + .collect() +} + +fn wire_image(media_type: &str, data: &str) -> Option { + let trimmed = data.trim(); + if trimmed.is_empty() { + return None; + } + let bytes = BASE64_STANDARD.decode(trimmed).ok()?; + if bytes.is_empty() { + return None; + } + let (width, height) = image_dimensions(&bytes, media_type); + Some(json!({ + "data": trimmed, + "dimension": { + "width": width.max(1), + "height": height.max(1), + } + })) +} + +fn image_dimensions(bytes: &[u8], media_type: &str) -> (i32, i32) { + match media_type { + "image/png" => png_dimensions(bytes), + "image/jpeg" | "image/jpg" => jpeg_dimensions(bytes), + "image/gif" => gif_dimensions(bytes), + "image/webp" => webp_dimensions(bytes), + _ => (0, 0), + } +} + +fn png_dimensions(bytes: &[u8]) -> (i32, i32) { + if bytes.len() < 24 || bytes[0..8] != [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A] { + return (0, 0); + } + let width = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]) as i32; + let height = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]) as i32; + (width, height) +} + +fn gif_dimensions(bytes: &[u8]) -> (i32, i32) { + if bytes.len() < 10 || &bytes[0..3] != b"GIF" { + return (0, 0); + } + let width = u16::from_le_bytes([bytes[6], bytes[7]]) as i32; + let height = u16::from_le_bytes([bytes[8], bytes[9]]) as i32; + (width, height) +} + +fn jpeg_dimensions(bytes: &[u8]) -> (i32, i32) { + if bytes.len() < 4 || bytes[0] != 0xFF || bytes[1] != 0xD8 { + return (0, 0); + } + let mut index = 2usize; + while index + 4 < bytes.len() { + if bytes[index] != 0xFF { + index += 1; + continue; + } + while index < bytes.len() && bytes[index] == 0xFF { + index += 1; + } + if index >= bytes.len() { + break; + } + let marker = bytes[index]; + index += 1; + if marker == 0xD9 || marker == 0xDA { + break; + } + if index + 1 >= bytes.len() { + break; + } + let segment_len = u16::from_be_bytes([bytes[index], bytes[index + 1]]) as usize; + if segment_len < 2 || index + segment_len > bytes.len() { + break; + } + if matches!( + marker, + 0xC0 | 0xC1 | 0xC2 | 0xC3 | 0xC5 | 0xC6 | 0xC7 | 0xC9 | 0xCA | 0xCB | 0xCD | 0xCE | 0xCF + ) && index + 7 <= bytes.len() + { + let height = u16::from_be_bytes([bytes[index + 3], bytes[index + 4]]) as i32; + let width = u16::from_be_bytes([bytes[index + 5], bytes[index + 6]]) as i32; + return (width, height); + } + index += segment_len; + } + (0, 0) +} + +fn webp_dimensions(bytes: &[u8]) -> (i32, i32) { + if bytes.len() < 30 || &bytes[0..4] != b"RIFF" || &bytes[8..12] != b"WEBP" { + return (0, 0); + } + let mut offset = 12usize; + while offset + 8 <= bytes.len() { + let tag = &bytes[offset..offset + 4]; + let size = u32::from_le_bytes([ + bytes[offset + 4], + bytes[offset + 5], + bytes[offset + 6], + bytes[offset + 7], + ]) as usize; + offset += 8; + if tag == b"VP8 " && offset + 10 <= bytes.len() { + let width = (u16::from_le_bytes([bytes[offset + 6], bytes[offset + 7]]) & 0x3FFF) as i32; + let height = (u16::from_le_bytes([bytes[offset + 8], bytes[offset + 9]]) & 0x3FFF) as i32; + return (width, height); + } + if tag == b"VP8L" && offset + 5 <= bytes.len() { + let bits = u32::from_le_bytes([ + bytes[offset], + bytes[offset + 1], + bytes[offset + 2], + bytes[offset + 3], + ]); + let width = ((bits & 0x3FFF) + 1) as i32; + let height = (((bits >> 14) & 0x3FFF) + 1) as i32; + return (width, height); + } + if tag == b"VP8X" && offset + 10 <= bytes.len() { + let width = 1 + + (bytes[offset + 4] as i32 + | ((bytes[offset + 5] as i32) << 8) + | ((bytes[offset + 6] as i32) << 16)); + let height = 1 + + (bytes[offset + 7] as i32 + | ((bytes[offset + 8] as i32) << 8) + | ((bytes[offset + 9] as i32) << 16)); + return (width, height); + } + offset = offset.saturating_add(size + (size & 1)); + } + (0, 0) +} + +#[cfg(test)] +mod tests { + use super::*; + use sinew_core::{ChatMessage, Role}; + + const PNG_1X1: &str = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + + #[test] + fn builds_wire_image_with_dimensions() { + let images = message_images(&ChatMessage { + role: Role::User, + parts: vec![ + Part::Image { + media_type: "image/png".into(), + data: PNG_1X1.into(), + meta: None, + }, + Part::Text { + text: "What is this?".into(), + meta: None, + }, + ], + }); + assert_eq!(images.len(), 1); + assert_eq!(images[0]["dimension"]["width"], 1); + assert_eq!(images[0]["dimension"]["height"], 1); + assert_eq!(images[0]["data"].as_str(), Some(PNG_1X1)); + } +} diff --git a/crates/sinew-cursor/src/lib.rs b/crates/sinew-cursor/src/lib.rs new file mode 100644 index 00000000..455ebba4 --- /dev/null +++ b/crates/sinew-cursor/src/lib.rs @@ -0,0 +1,30 @@ +pub mod agent; +mod auth; +mod client; +mod connect; +mod context_injection; +mod conversation; +mod encryption; +mod identity; +mod images; +mod model_info; +mod sanitize; +mod stream_state; +mod tools; +mod usage; +mod workspace; + +#[cfg(test)] +mod tests; + +pub use auth::composer::{ + delete_composer_auth, ensure_fresh_composer_token, load_composer_auth_status, + load_composer_session, sync_composer_auth_from_ide, ComposerSession, CursorComposerAuthStatus, +}; +pub use auth::oauth::{create_login_challenge, wait_for_oauth_login, CursorLoginChallenge}; +pub use agent::{ensure_agent_bridge_ready, set_bridge_directory}; +pub use client::{CursorConfig, CursorProvider}; +pub use identity::CursorIdeIdentity; +pub use model_info::{capabilities, MODEL_COMPOSER_25, MODEL_COMPOSER_25_FAST, PROVIDER_ID}; +pub use sanitize::{sanitize_outbound_json, sanitize_outbound_text}; +pub use usage::{fetch_usage, CursorUsageInfo}; diff --git a/crates/sinew-cursor/src/model_info.rs b/crates/sinew-cursor/src/model_info.rs new file mode 100644 index 00000000..2c4dbb5a --- /dev/null +++ b/crates/sinew-cursor/src/model_info.rs @@ -0,0 +1,77 @@ +use sinew_core::{EffortMode, ModelCapabilities, ModelRef, ServiceTier}; + +pub const PROVIDER_ID: &str = "cursor"; +pub const MODEL_COMPOSER_25: &str = "composer-2.5"; +pub const MODEL_COMPOSER_25_FAST: &str = "composer-2.5-fast"; + +struct CursorModelInfo { + id: &'static str, + context_window: u32, + preferred_window: u32, + max_output_tokens: u32, +} + +const MODELS: &[CursorModelInfo] = &[CursorModelInfo { + id: MODEL_COMPOSER_25_FAST, + context_window: 272_000, + preferred_window: 240_000, + max_output_tokens: 128_000, +}, CursorModelInfo { + id: MODEL_COMPOSER_25, + context_window: 272_000, + preferred_window: 240_000, + max_output_tokens: 128_000, +}]; + +fn model_info(model_id: &str) -> &'static CursorModelInfo { + MODELS + .iter() + .find(|info| info.id == model_id) + .unwrap_or(&MODELS[0]) +} + +/// Effective `agent.v1` model id (maps Composer 2.5 + fast tier → `composer-2.5-fast`). +pub fn resolve_agent_model_id(model: &ModelRef, service_tier: Option) -> String { + let want_fast = matches!(service_tier, Some(ServiceTier::Fast)) + || model.name == MODEL_COMPOSER_25_FAST; + match model.name.as_str() { + MODEL_COMPOSER_25 if want_fast => MODEL_COMPOSER_25_FAST.to_string(), + MODEL_COMPOSER_25_FAST if !want_fast => MODEL_COMPOSER_25.to_string(), + other => other.to_string(), + } +} + +pub fn capabilities(model: &ModelRef) -> ModelCapabilities { + let info = model_info(&model.name); + ModelCapabilities { + model: model.clone(), + context_window: info.context_window, + preferred_window: info.preferred_window, + max_output_tokens: info.max_output_tokens, + supports_thinking: true, + visible_thinking: true, + supports_tools: true, + supports_images: true, + effort_mode: EffortMode::Tier, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fast_tier_maps_composer_25_to_fast_model() { + let model = ModelRef::new(PROVIDER_ID, MODEL_COMPOSER_25); + assert_eq!( + resolve_agent_model_id(&model, Some(ServiceTier::Fast)), + MODEL_COMPOSER_25_FAST + ); + } + + #[test] + fn without_fast_tier_keeps_composer_25() { + let model = ModelRef::new(PROVIDER_ID, MODEL_COMPOSER_25); + assert_eq!(resolve_agent_model_id(&model, None), MODEL_COMPOSER_25); + } +} diff --git a/crates/sinew-cursor/src/sanitize.rs b/crates/sinew-cursor/src/sanitize.rs new file mode 100644 index 00000000..5cbc5120 --- /dev/null +++ b/crates/sinew-cursor/src/sanitize.rs @@ -0,0 +1,65 @@ +use serde_json::Value; + +const BRAND_REPLACEMENTS: &[(&str, &str)] = &[ + ("Sinew", "Cursor"), + ("sinew", "cursor"), + ("SINEW", "CURSOR"), + ("Hyrak", "Cursor"), + ("hyrak", "cursor"), + ("HYRAK", "CURSOR"), + ("Hyrrak", "Cursor"), + ("hyrrak", "cursor"), + ("HYRRAK", "CURSOR"), +]; + +pub fn sanitize_outbound_text(text: &str) -> String { + let mut out = text.to_string(); + for (from, to) in BRAND_REPLACEMENTS { + if out.contains(from) { + out = out.replace(from, to); + } + } + out.trim().to_string() +} + +pub fn sanitize_outbound_json(value: Value) -> Value { + match value { + Value::String(text) => Value::String(sanitize_outbound_text(&text)), + Value::Array(items) => Value::Array( + items + .into_iter() + .map(sanitize_outbound_json) + .collect::>(), + ), + Value::Object(map) => Value::Object( + map.into_iter() + .map(|(key, value)| (sanitize_outbound_text(&key), sanitize_outbound_json(value))) + .collect(), + ), + other => other, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitizes_brand_variants() { + let text = sanitize_outbound_text("Sinew/Hyrak/hyrrak in SINEW path"); + assert!(!text.to_ascii_lowercase().contains("sinew")); + assert!(!text.to_ascii_lowercase().contains("hyrak")); + assert!(text.contains("Cursor")); + } + + #[test] + fn sanitizes_json_recursively() { + let value = sanitize_outbound_json(serde_json::json!({ + "message": "Sinew MCP error", + "path": "C:\\Dev\\sinew\\src\\main.rs", + "nested": [{ "note": "Hyrak team" }] + })); + assert_eq!(value["message"], "Cursor MCP error"); + assert_eq!(value["nested"][0]["note"], "Cursor team"); + } +} diff --git a/crates/sinew-cursor/src/stream_state.rs b/crates/sinew-cursor/src/stream_state.rs new file mode 100644 index 00000000..c8452299 --- /dev/null +++ b/crates/sinew-cursor/src/stream_state.rs @@ -0,0 +1,120 @@ +use std::{ + collections::HashMap, + fs, + path::PathBuf, +}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::encryption::BlobEncryptionKey; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct PersistedStateFile { + conversations: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PersistedConversationState { + idempotency_key: String, + #[serde(default)] + encryption_key: Option, + seqno: u32, +} + +#[derive(Debug, Clone)] +pub struct ConversationStreamState { + pub idempotency_key: String, + pub encryption_key: String, + pub seqno: u32, +} + +pub struct StreamStateStore { + path: PathBuf, + conversations: HashMap, +} + +impl StreamStateStore { + pub fn load() -> Self { + let path = stream_state_path(); + let conversations = fs::read_to_string(&path) + .ok() + .and_then(|json| serde_json::from_str::(&json).ok()) + .map(|file| { + file.conversations + .into_iter() + .map(|(key, value)| { + let encryption_key = value + .encryption_key + .and_then(|stored| BlobEncryptionKey::from_stored(&stored).ok()) + .unwrap_or_else(BlobEncryptionKey::random) + .persist_standard_b64(); + ( + key, + ConversationStreamState { + idempotency_key: value.idempotency_key, + encryption_key, + seqno: value.seqno, + }, + ) + }) + .collect() + }) + .unwrap_or_default(); + Self { path, conversations } + } + + pub fn conversation_state(&mut self, cache_key: &str) -> ConversationStreamState { + self.conversations + .entry(cache_key.to_string()) + .or_insert_with(|| { + let encryption_key = BlobEncryptionKey::random().persist_standard_b64(); + ConversationStreamState { + idempotency_key: uuid::Uuid::new_v4().to_string(), + encryption_key, + seqno: 0, + } + }) + .clone() + } + + pub fn update_seqno(&mut self, cache_key: &str, seqno: u32) { + if let Some(state) = self.conversations.get_mut(cache_key) { + state.seqno = seqno; + let _ = self.save(); + } + } + + fn save(&self) -> Result<()> { + if let Some(parent) = self.path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("unable to create {}", parent.display()))?; + } + let payload = PersistedStateFile { + conversations: self + .conversations + .iter() + .map(|(key, value)| { + ( + key.clone(), + PersistedConversationState { + idempotency_key: value.idempotency_key.clone(), + encryption_key: Some(value.encryption_key.clone()), + seqno: value.seqno, + }, + ) + }) + .collect(), + }; + let json = serde_json::to_string_pretty(&payload)?; + fs::write(&self.path, json) + .with_context(|| format!("unable to write {}", self.path.display()))?; + Ok(()) + } +} + +fn stream_state_path() -> PathBuf { + directories::ProjectDirs::from("dev", "hyrak", "sinew") + .map(|dirs| dirs.data_dir().join("cursor-composer-stream-state.json")) + .unwrap_or_else(|| PathBuf::from("cursor-composer-stream-state.json")) +} diff --git a/crates/sinew-cursor/src/tests.rs b/crates/sinew-cursor/src/tests.rs new file mode 100644 index 00000000..fbb12f7f --- /dev/null +++ b/crates/sinew-cursor/src/tests.rs @@ -0,0 +1,556 @@ +#[cfg(test)] +mod tests { + use sinew_core::{ChatMessage, ModelRef, Part, ProviderRequest, Role}; + + use crate::{ + conversation::build_stream_request, + encryption::BlobEncryptionKey, + identity::CursorIdeIdentity, + sanitize::sanitize_outbound_text, + tools::{build_client_tool_result, parse_tool_call, SUPPORTED_TOOLS}, + }; + + fn test_blob_key() -> BlobEncryptionKey { + BlobEncryptionKey::from_raw([0xAB; 32]) + } + + #[test] + fn sanitizes_sinew_branding() { + let text = sanitize_outbound_text("You are Sinew from Hyrak"); + assert!(!text.contains("Sinew")); + assert!(text.contains("Cursor")); + } + + #[test] + fn oauth_only_identity_is_ready() { + let identity = CursorIdeIdentity { + machine_id: uuid::Uuid::new_v4().to_string(), + mac_machine_id: None, + client_version: crate::identity::CURSOR_CLIENT_VERSION.into(), + timezone: "UTC".into(), + platform: "windows".into(), + arch: "x64".into(), + shell: "powershell".into(), + }; + identity + .ensure_ready() + .expect("oauth-only identity should be ready"); + } + + #[test] + fn loaded_identity_is_ready_without_cursor_ide() { + let identity = CursorIdeIdentity::load(); + identity + .ensure_ready() + .expect("loaded identity should always have a machine id"); + assert!(!identity.machine_id.is_empty()); + } + + #[test] + fn composer_supports_images() { + let caps = crate::model_info::capabilities(&ModelRef::new("cursor", "composer-2.5-fast")); + assert!(caps.supports_images); + } + + #[test] + fn supported_tools_include_generate_image() { + assert!(SUPPORTED_TOOLS + .iter() + .any(|tool| *tool == "CLIENT_SIDE_TOOL_V2_GENERATE_IMAGE")); + } + + #[test] + fn builds_idempotent_request_with_workspace() { + let request = ProviderRequest::new( + ModelRef::new("cursor", "composer-2.5-fast"), + vec![ChatMessage::user_text("hello")], + ) + .with_system("You are Sinew") + .with_workspace_root(std::env::current_dir().unwrap().display().to_string()); + let identity = CursorIdeIdentity::load(); + let (payload, next_seqno) = + build_stream_request(&request, "conv", "idem", 0, &identity, &test_blob_key()); + assert!(next_seqno >= 1); + assert!(!payload.is_empty()); + let body = String::from_utf8_lossy(&payload); + assert!(body.contains("streamUnifiedChatRequest") || body.contains("clientChunk")); + } + + #[test] + fn parses_read_file_tool_call() { + let value = serde_json::json!({ + "tool": "CLIENT_SIDE_TOOL_V2_READ_FILE_V2", + "toolCallId": "call_1", + "readFileV2Params": { + "targetFile": "src/main.rs", + "limit": 100 + } + }); + let parsed = parse_tool_call(&value).expect("parsed"); + assert_eq!(parsed.cursor_tool, "CLIENT_SIDE_TOOL_V2_READ_FILE_V2"); + assert_eq!(parsed.sinew_name, "read"); + assert_eq!(parsed.input["path"], "src/main.rs"); + } + + #[test] + fn builds_tool_result_payload() { + let result = build_client_tool_result( + "call_1", + "read", + "CLIENT_SIDE_TOOL_V2_READ_FILE_V2", + "file contents", + false, + None, + ); + assert_eq!(result["toolCallId"], "call_1"); + assert_eq!(result["readFileV2Result"]["contents"], "file contents"); + } + + #[test] + fn assistant_tool_call_history_roundtrip_fields() { + let request = ProviderRequest::new( + ModelRef::new("cursor", "composer-2.5-fast"), + vec![ + ChatMessage { + role: Role::Assistant, + parts: vec![Part::ToolCall { + id: "call_1".into(), + name: "read".into(), + input: serde_json::json!({ "path": "a.rs", "limit": 10 }), + meta: None, + }], + }, + ChatMessage { + role: Role::User, + parts: vec![Part::ToolResult { + tool_call_id: "call_1".into(), + content: "ok".into(), + images: Vec::new(), + is_error: false, + meta: None, + }], + }, + ], + ); + let identity = CursorIdeIdentity::load(); + let (payload, _) = + build_stream_request(&request, "conv", "idem", 1, &identity, &test_blob_key()); + let body = String::from_utf8_lossy(&payload); + assert!(body.contains("toolResults") || body.contains("clientSideToolV2Result")); + } + + #[test] + fn assistant_tool_call_history_uses_cursor_tool_meta() { + let request = ProviderRequest::new( + ModelRef::new("cursor", "composer-2.5-fast"), + vec![ + ChatMessage { + role: Role::Assistant, + parts: vec![Part::ToolCall { + id: "call_1".into(), + name: "todo_list".into(), + input: serde_json::json!({}), + meta: Some(serde_json::json!({ + "cursor_tool": "CLIENT_SIDE_TOOL_V2_TODO_READ" + })), + }], + }, + ChatMessage { + role: Role::User, + parts: vec![Part::ToolResult { + tool_call_id: "call_1".into(), + content: "ok".into(), + images: Vec::new(), + is_error: false, + meta: None, + }], + }, + ], + ); + let identity = CursorIdeIdentity::load(); + let (payload, _) = + build_stream_request(&request, "conv", "idem", 1, &identity, &test_blob_key()); + let body = String::from_utf8_lossy(&payload); + assert!(body.contains("CLIENT_SIDE_TOOL_V2_TODO_READ")); + } + + #[test] + fn request_uses_conversation_cache_key_as_conversation_id() { + let request = ProviderRequest::new( + ModelRef::new("cursor", "composer-2.5-fast"), + vec![ChatMessage::user_text("hello")], + ) + .with_cache_key("sinew-conv-123"); + let identity = CursorIdeIdentity::load(); + let (payload, _) = build_stream_request( + &request, + "sinew-conv-123", + "idem", + 0, + &identity, + &test_blob_key(), + ); + let body = String::from_utf8_lossy(&payload); + assert!(body.contains("sinew-conv-123")); + } + + #[test] + fn assistant_history_includes_tool_calls_in_ai_bubble() { + let request = ProviderRequest::new( + ModelRef::new("cursor", "composer-2.5-fast"), + vec![ChatMessage { + role: Role::Assistant, + parts: vec![Part::ToolCall { + id: "call_1".into(), + name: "read".into(), + input: serde_json::json!({ "path": "a.rs" }), + meta: Some(serde_json::json!({ + "cursor_tool": "CLIENT_SIDE_TOOL_V2_READ_FILE_V2" + })), + }], + }], + ); + let identity = CursorIdeIdentity::load(); + let (payload, _) = + build_stream_request(&request, "conv", "idem", 0, &identity, &test_blob_key()); + let body = String::from_utf8_lossy(&payload); + assert!(body.contains("clientSideToolV2Calls")); + assert!(body.contains("CLIENT_SIDE_TOOL_V2_READ_FILE_V2")); + } + + // Tests live : ils dépendent d'un compte et du réseau, donc ils restent hors contrôle courant. + #[tokio::test] + #[ignore] + async fn test_live_cursor_usage() { + let provider = match crate::client::CursorProvider::from_default_sources() { + Ok(provider) => provider, + Err(err) => { + println!("Skipping usage test: {err:?}"); + return; + } + }; + match provider.usage_snapshot().await { + Ok(Some(usage)) => println!("USAGE OK: {usage:?}"), + Ok(None) => println!("USAGE: not connected"), + Err(err) => println!("USAGE ERROR: {err:?}"), + } + } + + #[tokio::test] + #[ignore] + async fn test_live_agent_usable_models() { + let session = match crate::auth::composer::load_composer_session() { + Ok(Some(session)) => session, + _ => { + println!("Skipping agent models test: not connected"); + return; + } + }; + let http = reqwest::Client::builder() + .user_agent(crate::identity::CursorIdeIdentity::load().user_agent()) + .build() + .expect("http client"); + let token = match crate::auth::composer::ensure_fresh_composer_token(&http, &session).await + { + Ok(token) => token, + Err(err) => { + println!("Skipping agent models test: {err:?}"); + return; + } + }; + let identity = crate::identity::CursorIdeIdentity::load(); + match crate::agent::fetch_usable_models(&http, &identity, &token).await { + Ok(bytes) => { + let models = crate::agent::scan_model_ids(&bytes); + println!( + "GetUsableModels OK: {} bytes, models={models:?}", + bytes.len() + ); + assert!(!bytes.is_empty()); + } + Err(err) => println!("GetUsableModels ERROR: {err:?}"), + } + } + + #[tokio::test] + #[ignore] + async fn test_live_rust_agent_bridge() { + use futures::StreamExt; + use sinew_core::{ChatMessage, ModelRef, ProviderRequest, StreamEvent}; + + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("sinew_cursor=debug")), + ) + .with_test_writer() + .try_init(); + + let session = match crate::auth::composer::load_composer_session() { + Ok(Some(session)) => session, + _ => { + println!("Skipping rust bridge live test: not connected"); + return; + } + }; + let http = reqwest::Client::builder() + .user_agent(crate::identity::CursorIdeIdentity::load().user_agent()) + .build() + .expect("http client"); + let token = match crate::auth::composer::ensure_fresh_composer_token(&http, &session).await + { + Ok(token) => token, + Err(err) => { + println!("Skipping rust bridge live test: {err:?}"); + return; + } + }; + let identity = crate::identity::CursorIdeIdentity::load(); + let test_model = + std::env::var("SINEW_TEST_MODEL").unwrap_or_else(|_| "composer-2.5".to_string()); + let request = ProviderRequest::new( + ModelRef::new("cursor", &test_model), + vec![ChatMessage::user_text("Dis OK en une phrase.")], + ) + .with_workspace_root(r"C:\Dev\Sinew") + .with_cache_key(format!("rust-live-{}", uuid::Uuid::new_v4())); + let assert_live = std::env::var("SINEW_CURSOR_LIVE_ASSERT") + .map(|v| v.trim() == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false); + match crate::agent::stream_via_rust_bridge(&identity, token, request).await { + Ok(mut stream) => { + let mut saw_text = false; + while let Some(event) = stream.next().await { + match event { + Ok(StreamEvent::TextDelta { .. }) + | Ok(StreamEvent::ThinkingDelta { .. }) => { + saw_text = true; + } + Err(err) => { + println!("RUST BRIDGE STREAM ERROR: {err:?}"); + if assert_live { + panic!("rust agent bridge stream error: {err:?}"); + } + return; + } + Ok(_) => {} + } + } + println!("Rust agent bridge live: saw_text={saw_text}"); + if assert_live && !saw_text { + panic!("rust agent bridge returned no text"); + } + } + Err(err) => { + println!("RUST BRIDGE ERROR: {err:?}"); + if assert_live { + panic!("rust agent bridge failed: {err:?}"); + } + } + } + } + + #[tokio::test] + #[ignore] + async fn test_live_composer_request() { + use futures::StreamExt; + use sinew_core::Provider; + + let provider = match crate::client::CursorProvider::from_default_sources() { + Ok(provider) => provider, + Err(e) => { + println!("Skipping live test: unable to load provider: {e:?}"); + return; + } + }; + let identity = crate::identity::CursorIdeIdentity::load(); + println!("Using machine_id={}", identity.machine_id); + println!("mac_machine_id={:?}", identity.mac_machine_id); + let request = ProviderRequest::new( + ModelRef::new("cursor", "composer-2.5"), + vec![ChatMessage::user_text("Say OK")], + ) + .with_workspace_root(r"C:\Dev\sinew") + .with_cache_key(format!("live-test-{}", uuid::Uuid::new_v4())); + println!("Sending live Composer request..."); + let assert_live = std::env::var("SINEW_CURSOR_LIVE_ASSERT") + .map(|value| { + let trimmed = value.trim(); + trimmed == "1" || trimmed.eq_ignore_ascii_case("true") + }) + .unwrap_or(false); + match provider.stream(request).await { + Ok(mut stream) => { + println!("Stream established. Reading events:"); + let mut saw_message = false; + while let Some(event) = stream.next().await { + println!("EVENT: {:?}", event); + match event { + Ok(sinew_core::StreamEvent::MessageStart { .. }) + | Ok(sinew_core::StreamEvent::TextDelta { .. }) + | Ok(sinew_core::StreamEvent::ThinkingDelta { .. }) => { + saw_message = true; + } + Ok(_) => {} + Err(err) => { + println!("STREAM EVENT ERROR: {:?}", err); + if assert_live { + panic!("live Composer stream error: {err:?}"); + } + } + } + } + if assert_live && !saw_message { + panic!( + "live Composer stream returned no text/thinking events (idempotent key still blocked?)" + ); + } + } + Err(err) => { + println!("STREAM ERROR: {:?}", err); + if assert_live { + panic!("live Composer stream failed: {err:?}"); + } + } + } + } + + #[test] + fn sinew_ui_fast_default_maps_to_composer_25_fast() { + use sinew_core::ServiceTier; + + let ui_model = ModelRef::new("cursor", "composer-2.5"); + let effective = + crate::model_info::resolve_agent_model_id(&ui_model, Some(ServiceTier::Fast)); + assert_eq!(effective, crate::model_info::MODEL_COMPOSER_25_FAST); + } + + async fn live_sinew_stream( + label: &str, + request: ProviderRequest, + max_wait: std::time::Duration, + ) -> Result<(), String> { + use futures::StreamExt; + use sinew_core::{Provider, StreamEvent}; + + let provider = crate::client::CursorProvider::from_default_sources() + .map_err(|e| format!("provider: {e}"))?; + let setup_started = std::time::Instant::now(); + let mut stream = tokio::time::timeout(max_wait, provider.stream(request)) + .await + .map_err(|_| format!("stream setup timed out after {:?}", max_wait))? + .map_err(|e| format!("stream setup: {e}"))?; + println!( + "{label}: stream setup OK in {}ms", + setup_started.elapsed().as_millis() + ); + let read_started = std::time::Instant::now(); + let result = tokio::time::timeout(max_wait, async { + let mut saw_lifecycle = false; + while let Some(event) = stream.next().await { + match event { + Ok(StreamEvent::MessageStart { model }) => { + println!("{label}: MessageStart model={model}"); + // Same criterion as sinew_app::agent::turn "first token received". + saw_lifecycle = true; + break; + } + Ok(StreamEvent::TextDelta { .. }) | Ok(StreamEvent::ThinkingDelta { .. }) => { + saw_lifecycle = true; + break; + } + Ok(_) => {} + Err(err) => return Err(format!("stream event: {err}")), + } + } + if saw_lifecycle { + Ok(()) + } else { + Err("stream ended before MessageStart or text/thinking".into()) + } + }) + .await; + match result { + Ok(Ok(())) => { + println!( + "{label}: OK (first event in {}ms)", + read_started.elapsed().as_millis() + ); + Ok(()) + } + Ok(Err(err)) => Err(err), + Err(_) => Err(format!("read timed out after {:?}", max_wait)), + } + } + + #[tokio::test] + #[ignore] + async fn test_live_sinew_composer_25_fast_tier() { + use sinew_core::{ChatMessage, ServiceTier}; + + let request = ProviderRequest::new( + ModelRef::new("cursor", "composer-2.5"), + vec![ChatMessage::user_text("Réponds uniquement: OK")], + ) + .with_service_tier(ServiceTier::Fast) + .with_workspace_root(r"C:\Dev\sinew") + .with_cache_key(format!("sinew-fast-{}", uuid::Uuid::new_v4())); + + let effective = + crate::model_info::resolve_agent_model_id(&request.model, request.service_tier); + println!("Sinew UI model=composer-2.5 + fast tier → agent model_id={effective}"); + + match live_sinew_stream( + "composer-2.5 + ServiceTier::Fast (éclair Sinew)", + request, + std::time::Duration::from_secs(90), + ) + .await + { + Ok(()) => {} + Err(err) => { + println!("FAIL: {err}"); + if std::env::var("SINEW_CURSOR_LIVE_ASSERT") + .map(|v| v.trim() == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false) + { + panic!("{err}"); + } + } + } + } + + #[tokio::test] + #[ignore] + async fn test_live_sinew_composer_25_no_fast_tier() { + let request = ProviderRequest::new( + ModelRef::new("cursor", "composer-2.5"), + vec![ChatMessage::user_text("Réponds uniquement: OK")], + ) + .with_workspace_root(r"C:\Dev\sinew") + .with_cache_key(format!("sinew-slow-{}", uuid::Uuid::new_v4())); + + let effective = + crate::model_info::resolve_agent_model_id(&request.model, request.service_tier); + println!("Sinew UI model=composer-2.5 sans fast → agent model_id={effective}"); + + match live_sinew_stream( + "composer-2.5 sans fast", + request, + std::time::Duration::from_secs(90), + ) + .await + { + Ok(()) => {} + Err(err) => { + println!("FAIL: {err}"); + if std::env::var("SINEW_CURSOR_LIVE_ASSERT") + .map(|v| v.trim() == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false) + { + panic!("{err}"); + } + } + } + } +} diff --git a/crates/sinew-cursor/src/tools.rs b/crates/sinew-cursor/src/tools.rs new file mode 100644 index 00000000..2f4804fb --- /dev/null +++ b/crates/sinew-cursor/src/tools.rs @@ -0,0 +1,739 @@ +use serde_json::{json, Map, Value}; + +#[derive(Debug, Clone)] +pub struct ParsedToolCall { + pub id: String, + pub cursor_tool: String, + pub sinew_name: String, + pub input: Value, +} + +pub const COMPOSER_UNSUPPORTED_TOOL: &str = "composer_unsupported_tool"; + +pub const SUPPORTED_TOOLS: &[&str] = &[ + "CLIENT_SIDE_TOOL_V2_READ_FILE_V2", + "CLIENT_SIDE_TOOL_V2_EDIT_FILE_V2", + "CLIENT_SIDE_TOOL_V2_LIST_DIR_V2", + "CLIENT_SIDE_TOOL_V2_RIPGREP_RAW_SEARCH", + "CLIENT_SIDE_TOOL_V2_GLOB_FILE_SEARCH", + "CLIENT_SIDE_TOOL_V2_RUN_TERMINAL_COMMAND_V2", + "CLIENT_SIDE_TOOL_V2_WEB_SEARCH", + "CLIENT_SIDE_TOOL_V2_WEB_FETCH", + "CLIENT_SIDE_TOOL_V2_SEMANTIC_SEARCH_FULL", + "CLIENT_SIDE_TOOL_V2_TODO_READ", + "CLIENT_SIDE_TOOL_V2_TODO_WRITE", + "CLIENT_SIDE_TOOL_V2_ASK_QUESTION", + "CLIENT_SIDE_TOOL_V2_CALL_MCP_TOOL", + "CLIENT_SIDE_TOOL_V2_GENERATE_IMAGE", + "CLIENT_SIDE_TOOL_V2_DELETE_FILE", + "CLIENT_SIDE_TOOL_V2_READ_LINTS", + "READ_LINTS", +]; + +pub fn sinew_tool_name(cursor_tool: &str) -> Option<&'static str> { + match cursor_tool { + "CLIENT_SIDE_TOOL_V2_READ_FILE_V2" | "CLIENT_SIDE_TOOL_V2_READ_FILE" => Some("read"), + "CLIENT_SIDE_TOOL_V2_EDIT_FILE_V2" | "CLIENT_SIDE_TOOL_V2_EDIT_FILE" => Some("edit_file"), + "CLIENT_SIDE_TOOL_V2_LIST_DIR_V2" | "CLIENT_SIDE_TOOL_V2_LIST_DIR" => Some("list_dir"), + "CLIENT_SIDE_TOOL_V2_RIPGREP_RAW_SEARCH" | "CLIENT_SIDE_TOOL_V2_RIPGREP_SEARCH" => { + Some("grep") + } + "CLIENT_SIDE_TOOL_V2_GLOB_FILE_SEARCH" | "CLIENT_SIDE_TOOL_V2_FILE_SEARCH" => Some("glob"), + "CLIENT_SIDE_TOOL_V2_RUN_TERMINAL_COMMAND_V2" => Some("bash"), + "CLIENT_SIDE_TOOL_V2_WEB_SEARCH" => Some("web_search"), + "CLIENT_SIDE_TOOL_V2_WEB_FETCH" => Some("web_fetch"), + "CLIENT_SIDE_TOOL_V2_TODO_READ" | "CLIENT_SIDE_TOOL_V2_TODO_WRITE" => Some("todo_list"), + "CLIENT_SIDE_TOOL_V2_ASK_QUESTION" => Some("question"), + "CLIENT_SIDE_TOOL_V2_CALL_MCP_TOOL" | "CLIENT_SIDE_TOOL_V2_MCP" => Some("load_mcp_tool"), + "CLIENT_SIDE_TOOL_V2_GENERATE_IMAGE" => Some("create_image"), + "CLIENT_SIDE_TOOL_V2_SEMANTIC_SEARCH_FULL" => Some("codebase_search"), + "CLIENT_SIDE_TOOL_V2_DELETE_FILE" => Some("delete_file"), + "CLIENT_SIDE_TOOL_V2_READ_LINTS" | "READ_LINTS" => Some("read_lints"), + _ => None, + } +} + +pub fn is_mappable_sinew_tool(name: &str) -> bool { + cursor_tool_name(name) != "CLIENT_SIDE_TOOL_V2_UNSPECIFIED" +} + +pub fn cursor_tool_name(sinew_tool: &str) -> &'static str { + match sinew_tool { + "read" => "CLIENT_SIDE_TOOL_V2_READ_FILE_V2", + "edit_file" => "CLIENT_SIDE_TOOL_V2_EDIT_FILE_V2", + "write_file" => "CLIENT_SIDE_TOOL_V2_EDIT_FILE_V2", + "glob" => "CLIENT_SIDE_TOOL_V2_GLOB_FILE_SEARCH", + "list_dir" => "CLIENT_SIDE_TOOL_V2_LIST_DIR_V2", + "delete_file" => "CLIENT_SIDE_TOOL_V2_DELETE_FILE", + "read_lints" => "CLIENT_SIDE_TOOL_V2_READ_LINTS", + "grep" => "CLIENT_SIDE_TOOL_V2_RIPGREP_RAW_SEARCH", + "codebase_search" => "CLIENT_SIDE_TOOL_V2_SEMANTIC_SEARCH_FULL", + "bash" => "CLIENT_SIDE_TOOL_V2_RUN_TERMINAL_COMMAND_V2", + "web_search" => "CLIENT_SIDE_TOOL_V2_WEB_SEARCH", + "web_fetch" => "CLIENT_SIDE_TOOL_V2_WEB_FETCH", + "todo_list" => "CLIENT_SIDE_TOOL_V2_TODO_WRITE", + "question" => "CLIENT_SIDE_TOOL_V2_ASK_QUESTION", + "load_mcp_tool" => "CLIENT_SIDE_TOOL_V2_CALL_MCP_TOOL", + "create_image" => "CLIENT_SIDE_TOOL_V2_GENERATE_IMAGE", + _ => "CLIENT_SIDE_TOOL_V2_UNSPECIFIED", + } +} + +pub fn parse_tool_call(value: &Value) -> Option { + let tool = value + .get("tool") + .and_then(Value::as_str) + .or_else(|| { + value + .get("tool") + .and_then(Value::as_i64) + .and_then(tool_name_from_number) + })?; + let sinew_name = sinew_tool_name(tool)?.to_string(); + let id = value + .get("toolCallId") + .or_else(|| value.get("tool_call_id")) + .and_then(Value::as_str) + .map(str::to_string) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let mut input = if let Some(raw) = value + .get("rawArgs") + .or_else(|| value.get("raw_args")) + .and_then(Value::as_str) + .filter(|raw| !raw.trim().is_empty()) + { + serde_json::from_str(raw).unwrap_or_else(|_| json!({ "raw_args": raw })) + } else { + map_tool_params(tool, value) + }; + let mut sinew_name = sinew_name; + if sinew_name == "load_mcp_tool" { + if let Some((name, mapped_input)) = map_builtin_mcp_call(&input) { + sinew_name = name; + input = mapped_input; + } + } + if sinew_name == "edit_file" + && input.get("content").is_some() + && input.get("path").is_some() + && input.get("files").is_none() + { + sinew_name = "write_file".to_string(); + } + Some(ParsedToolCall { + id, + cursor_tool: tool.to_string(), + sinew_name, + input, + }) +} + +pub fn resolve_tool_call(value: &Value) -> Option { + let tool = tool_name_from_value(value)?; + if let Some(parsed) = parse_tool_call(value) { + return Some(parsed); + } + tracing::warn!(cursor_tool = %tool, "unsupported Composer tool call"); + Some(ParsedToolCall { + id: tool_call_id_from_value(value), + cursor_tool: tool.clone(), + sinew_name: COMPOSER_UNSUPPORTED_TOOL.into(), + input: json!({ + "message": format!("Unsupported Composer tool: {tool}"), + }), + }) +} + +fn tool_name_from_value(value: &Value) -> Option { + value + .get("tool") + .and_then(|tool| { + tool.as_str() + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_string) + .or_else(|| { + tool.as_i64() + .and_then(tool_name_from_number) + .map(str::to_string) + }) + }) +} + +fn tool_call_id_from_value(value: &Value) -> String { + value + .get("toolCallId") + .or_else(|| value.get("tool_call_id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()) +} + +pub fn build_client_tool_result( + tool_call_id: &str, + sinew_name: &str, + cursor_tool: &str, + content: &str, + is_error: bool, + result_images: Option<&[sinew_core::ToolResultImage]>, +) -> Value { + let mut result = json!({ + "toolCallId": tool_call_id, + "tool": cursor_tool, + }); + if is_error { + result["error"] = json!({ "message": crate::sanitize::sanitize_outbound_text(content) }); + return result; + } + let content = crate::sanitize::sanitize_outbound_text(content); + match sinew_name { + "read" => { + result["readFileV2Result"] = json!({ "contents": content }); + } + "write_file" => { + result["editFileV2Result"] = json!({ "resultForModel": content }); + } + "edit_file" => { + result["editFileV2Result"] = json!({ "resultForModel": content }); + } + "bash" => { + result["runTerminalCommandV2Result"] = json!({ "output": content }); + } + "grep" => { + result["ripgrepRawSearchResult"] = json!({ "output": content }); + } + "glob" => { + result["globFileSearchResult"] = json!({ "directories": [] , "output": content }); + } + "list_dir" => { + result["listDirV2Result"] = json!({ "directoryTree": content }); + } + "codebase_search" => { + result["semanticSearchFullResult"] = json!({ "resultForModel": content }); + } + "delete_file" => { + result["deleteFileResult"] = json!({ "resultForModel": content }); + } + "read_lints" => { + result["readLintsResult"] = json!({ "resultForModel": content }); + } + "web_search" => { + result["webSearchResult"] = json!({ "references": [], "output": content }); + } + "web_fetch" => { + result["webFetchResult"] = json!({ "markdown": content }); + } + "todo_list" => { + if cursor_tool == "CLIENT_SIDE_TOOL_V2_TODO_READ" { + result["todoReadResult"] = json!({ "content": content }); + } else { + result["todoWriteResult"] = json!({ "content": content }); + } + } + "question" => { + result["askQuestionResult"] = json!({ "content": content }); + } + "load_mcp_tool" => { + result["callMcpToolResult"] = json!({ "result": content }); + } + "create_image" => { + let mut payload = json!({ "resultForModel": content }); + let images = crate::images::wire_images_from_tool_results(result_images.unwrap_or(&[])); + if !images.is_empty() { + payload["images"] = json!(images); + } + result["generateImageResult"] = payload; + } + "composer_unsupported_tool" => { + result["resultForModel"] = json!(content); + } + _ => { + result["resultForModel"] = json!(content); + } + } + crate::sanitize::sanitize_outbound_json(result) +} + +fn tool_name_from_number(value: i64) -> Option<&'static str> { + match value { + 40 => Some("CLIENT_SIDE_TOOL_V2_READ_FILE_V2"), + 38 => Some("CLIENT_SIDE_TOOL_V2_EDIT_FILE_V2"), + 39 => Some("CLIENT_SIDE_TOOL_V2_LIST_DIR_V2"), + 41 => Some("CLIENT_SIDE_TOOL_V2_RIPGREP_RAW_SEARCH"), + 42 => Some("CLIENT_SIDE_TOOL_V2_GLOB_FILE_SEARCH"), + 15 => Some("CLIENT_SIDE_TOOL_V2_RUN_TERMINAL_COMMAND_V2"), + 18 => Some("CLIENT_SIDE_TOOL_V2_WEB_SEARCH"), + 57 => Some("CLIENT_SIDE_TOOL_V2_WEB_FETCH"), + 34 => Some("CLIENT_SIDE_TOOL_V2_TODO_READ"), + 35 => Some("CLIENT_SIDE_TOOL_V2_TODO_WRITE"), + 51 => Some("CLIENT_SIDE_TOOL_V2_ASK_QUESTION"), + 49 => Some("CLIENT_SIDE_TOOL_V2_CALL_MCP_TOOL"), + 53 => Some("CLIENT_SIDE_TOOL_V2_GENERATE_IMAGE"), + 58 => Some("CLIENT_SIDE_TOOL_V2_SEMANTIC_SEARCH_FULL"), + 59 => Some("CLIENT_SIDE_TOOL_V2_DELETE_FILE"), + 30 => Some("CLIENT_SIDE_TOOL_V2_READ_LINTS"), + _ => None, + } +} + +fn map_tool_params(tool: &str, value: &Value) -> Value { + match tool { + "CLIENT_SIDE_TOOL_V2_READ_FILE_V2" => { + let params = params(value, "readFileV2Params", "read_file_v2_params"); + json!({ + "path": field(params, &["targetFile", "target_file"]), + "offset": field(params, &["offset"]).unwrap_or(json!(0)), + "limit": field(params, &["limit"]).unwrap_or(json!(500)), + }) + } + "CLIENT_SIDE_TOOL_V2_RIPGREP_RAW_SEARCH" | "CLIENT_SIDE_TOOL_V2_RIPGREP_SEARCH" => { + let params = params(value, "ripgrepRawSearchParams", "ripgrep_raw_search_params") + .or_else(|| params(value, "ripgrepSearchParams", "ripgrep_search_params")); + json!({ + "pattern": field(params, &["pattern", "query", "searchTerm", "search_term"]), + "path": field(params, &["path", "targetDirectory", "target_directory"]).unwrap_or(json!(".")), + "include": field(params, &["glob", "include"]), + "limit": field(params, &["limit"]).unwrap_or(json!(100)), + }) + } + "CLIENT_SIDE_TOOL_V2_GLOB_FILE_SEARCH" | "CLIENT_SIDE_TOOL_V2_FILE_SEARCH" => { + let params = params(value, "globFileSearchParams", "glob_file_search_params") + .or_else(|| params(value, "fileSearchParams", "file_search_params")); + json!({ + "pattern": field(params, &["globPattern", "glob_pattern"]).unwrap_or(json!("**/*")), + "path": field(params, &["targetDirectory", "target_directory"]).unwrap_or(json!(".")), + "limit": field(params, &["limit"]).unwrap_or(json!(200)), + }) + } + "CLIENT_SIDE_TOOL_V2_SEMANTIC_SEARCH_FULL" => { + let params = params(value, "semanticSearchFullParams", "semantic_search_full_params") + .or_else(|| params(value, "semanticSearchParams", "semantic_search_params")); + json!({ + "query": field(params, &["query", "searchQuery", "search_query", "pattern"]) + .unwrap_or(json!("")), + "path": field(params, &["targetDirectory", "target_directory", "path"]).unwrap_or(json!(".")), + "limit": field(params, &["limit", "maxResults", "max_results"]).unwrap_or(json!(40)), + }) + } + "CLIENT_SIDE_TOOL_V2_DELETE_FILE" => { + let params = params(value, "deleteFileParams", "delete_file_params") + .or_else(|| params(value, "deleteFileV2Params", "delete_file_v2_params")); + json!({ + "path": field( + params, + &[ + "relativeWorkspacePath", + "relative_workspace_path", + "targetFile", + "target_file", + ], + ) + .unwrap_or(json!("")), + }) + } + "CLIENT_SIDE_TOOL_V2_RUN_TERMINAL_COMMAND_V2" => { + let params = params(value, "runTerminalCommandV2Params", "run_terminal_command_v2_params"); + json!({ + "command": field(params, &["command"]), + "cwd": field(params, &["cwd"]), + }) + } + "CLIENT_SIDE_TOOL_V2_WEB_SEARCH" => { + let params = params(value, "webSearchParams", "web_search_params"); + json!({ + "query": field(params, &["searchTerm", "search_term"]).unwrap_or(json!("")), + }) + } + "CLIENT_SIDE_TOOL_V2_WEB_FETCH" => { + let params = params(value, "webFetchParams", "web_fetch_params"); + json!({ "url": field(params, &["url"]) }) + } + "CLIENT_SIDE_TOOL_V2_GENERATE_IMAGE" => { + map_generate_image_params(value) + } + "CLIENT_SIDE_TOOL_V2_EDIT_FILE_V2" | "CLIENT_SIDE_TOOL_V2_EDIT_FILE" => { + map_edit_params(value) + } + "CLIENT_SIDE_TOOL_V2_LIST_DIR_V2" | "CLIENT_SIDE_TOOL_V2_LIST_DIR" => { + let params = params(value, "listDirV2Params", "list_dir_v2_params") + .or_else(|| params(value, "listDirParams", "list_dir_params")); + json!({ + "path": field(params, &["targetDirectory", "target_directory"]).unwrap_or(json!(".")), + "limit": field(params, &["limit"]).unwrap_or(json!(500)), + }) + } + "CLIENT_SIDE_TOOL_V2_READ_LINTS" | "READ_LINTS" => { + let params = params(value, "readLintsParams", "read_lints_params"); + json!({ + "paths": field(params, &[ + "paths", + "path", + "relativeWorkspacePaths", + "relative_workspace_paths", + "targetFiles", + "target_files", + ]), + }) + } + _ => flatten_params(value), + } +} + +fn map_generate_image_params(value: &Value) -> Value { + let params = params(value, "generateImageParams", "generate_image_params") + .or_else(|| params(value, "generateImageV2Params", "generate_image_v2_params")); + let mut mapped = json!({ + "prompt": field(params, &["prompt", "description", "text", "query"]).unwrap_or(json!("")), + }); + if let Some(size) = field(params, &["size", "imageSize", "image_size"]) { + mapped["size"] = size; + } + if let Some(n) = field(params, &["n", "numImages", "num_images", "count"]) { + mapped["n"] = n; + } + if let Some(format) = field(params, &["outputFormat", "output_format", "format"]) { + mapped["output_format"] = format; + } + if let Some(ratio) = field(params, &["aspectRatio", "aspect_ratio"]) { + mapped["aspect_ratio"] = ratio; + } + if let Some(image_size) = field(params, &["imageSizeTier", "image_size_tier", "resolution"]) { + mapped["image_size"] = image_size; + } + mapped +} + +fn map_builtin_mcp_call(input: &Value) -> Option<(String, Value)> { + let tool_name = input + .get("toolName") + .or_else(|| input.get("tool_name")) + .or_else(|| input.get("name")) + .and_then(Value::as_str)?; + if !is_create_image_mcp_tool(tool_name) { + return None; + } + let args = input + .get("args") + .or_else(|| input.get("arguments")) + .or_else(|| input.get("input")) + .cloned() + .unwrap_or_else(|| json!({})); + Some(("create_image".into(), map_create_image_input(&args))) +} + +fn is_create_image_mcp_tool(name: &str) -> bool { + matches!( + name, + "mcp__sinew__create_image" | "mcp__cursor__create_image" + ) +} + +fn map_create_image_input(value: &Value) -> Value { + if value.get("prompt").is_some() { + return value.clone(); + } + map_generate_image_params(value) +} + +fn map_edit_params(value: &Value) -> Value { + let params = params(value, "editFileV2Params", "edit_file_v2_params") + .or_else(|| params(value, "editFileParams", "edit_file_params")); + let path = field(params, &["relativeWorkspacePath", "relative_workspace_path"]) + .unwrap_or_else(|| json!("")); + if let Some(contents) = field(params, &["contentsAfterEdit", "contents_after_edit"]) { + return json!({ + "path": path, + "content": contents, + }); + } + if let Some(diff) = params + .and_then(|params| params.get("diff")) + .or_else(|| params.and_then(|params| params.get("Diff"))) + { + if let Some(mapped) = map_diff_edits(&path, diff) { + return mapped; + } + } + if let Some(text) = params + .and_then(|params| params.get("streamingContent")) + .or_else(|| params.and_then(|params| params.get("streaming_content"))) + .or_else(|| params.and_then(|params| params.get("resultForModel"))) + .or_else(|| params.and_then(|params| params.get("result_for_model"))) + .and_then(Value::as_str) + { + if let Some(mapped) = map_search_replace_blocks(&path, text) { + return mapped; + } + } + json!({ + "files": [{ + "path": path, + "edits": [{ + "oldContent": "", + "newContent": field(params, &["streamingContent", "streaming_content", "resultForModel", "result_for_model"]).unwrap_or(json!("")), + "replaceAll": false + }] + }] + }) +} + +fn map_diff_edits(path: &Value, diff: &Value) -> Option { + let chunks = diff + .get("chunks") + .or_else(|| diff.get("edits")) + .and_then(Value::as_array)?; + let mut edits = Vec::new(); + for chunk in chunks { + let old_content = field(Some(chunk), &["originalText", "original_text", "before", "oldContent", "old_content"]); + let new_content = field(Some(chunk), &["newText", "new_text", "after", "newContent", "new_content"]); + if old_content.is_some() || new_content.is_some() { + edits.push(json!({ + "oldContent": old_content.unwrap_or(json!("")), + "newContent": new_content.unwrap_or(json!("")), + "replaceAll": false + })); + } + } + if edits.is_empty() { + return None; + } + Some(json!({ "files": [{ "path": path, "edits": edits }] })) +} + +fn map_search_replace_blocks(path: &Value, text: &str) -> Option { + let mut edits = Vec::new(); + let mut search = None; + let mut replace = None; + for line in text.lines() { + if let Some(rest) = line.strip_prefix("<<<<<<< SEARCH") { + search = Some(rest.trim_start_matches('\n').to_string()); + replace = None; + } else if let Some(rest) = line.strip_prefix("=======") { + replace = Some(rest.trim_start_matches('\n').to_string()); + } else if line.starts_with(">>>>>>> REPLACE") { + if let (Some(old_content), Some(new_content)) = (search.take(), replace.take()) { + edits.push(json!({ + "oldContent": old_content, + "newContent": new_content, + "replaceAll": false + })); + } + } else if let Some(current) = replace.as_mut() { + if !current.is_empty() { + current.push('\n'); + } + current.push_str(line); + } else if let Some(current) = search.as_mut() { + if !current.is_empty() { + current.push('\n'); + } + current.push_str(line); + } + } + if edits.is_empty() { + return None; + } + Some(json!({ "files": [{ "path": path, "edits": edits }] })) +} + +fn params<'a>(value: &'a Value, camel: &str, snake: &str) -> Option<&'a Value> { + value.get(camel).or_else(|| value.get(snake)) +} + +fn field(value: Option<&Value>, keys: &[&str]) -> Option { + let obj = value?; + for key in keys { + if let Some(found) = obj.get(*key) { + return Some(found.clone()); + } + } + None +} + +fn flatten_params(value: &Value) -> Value { + let mut mapped = Map::new(); + if let Some(obj) = value.as_object() { + for (key, val) in obj { + if key.ends_with("Params") || key.ends_with("_params") { + if let Some(inner) = val.as_object() { + for (inner_key, inner_val) in inner { + mapped.insert(inner_key.clone(), inner_val.clone()); + } + } + } + } + } + Value::Object(mapped) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_search_replace_blocks_to_edit_file_input() { + let text = "<<<<<<< SEARCH\nold line\n=======\nnew line\n>>>>>>> REPLACE"; + let mapped = map_search_replace_blocks(&json!("src/a.rs"), text).expect("mapped"); + assert_eq!(mapped["files"][0]["edits"][0]["oldContent"], "old line"); + assert_eq!(mapped["files"][0]["edits"][0]["newContent"], "new line"); + } + + #[test] + fn maps_list_dir_to_shallow_glob() { + let value = json!({ + "tool": "CLIENT_SIDE_TOOL_V2_LIST_DIR_V2", + "toolCallId": "call_1", + "listDirV2Params": { + "targetDirectory": "src" + } + }); + let parsed = parse_tool_call(&value).expect("parsed"); + assert_eq!(parsed.sinew_name, "list_dir"); + assert_eq!(parsed.input["path"], "src"); + assert_eq!(parsed.input["limit"], 500); + } + + #[test] + fn maps_delete_file_to_native_delete() { + let value = json!({ + "tool": "CLIENT_SIDE_TOOL_V2_DELETE_FILE", + "toolCallId": "call_1", + "deleteFileParams": { + "relativeWorkspacePath": "tmp/old.txt" + } + }); + let parsed = parse_tool_call(&value).expect("parsed"); + assert_eq!(parsed.sinew_name, "delete_file"); + assert_eq!(parsed.input["path"], "tmp/old.txt"); + } + + #[test] + fn maps_read_lints_to_native_read_lints() { + let value = json!({ + "tool": "CLIENT_SIDE_TOOL_V2_READ_LINTS", + "toolCallId": "call_1", + "readLintsParams": { + "paths": ["src/main.rs"] + } + }); + let parsed = parse_tool_call(&value).expect("parsed"); + assert_eq!(parsed.sinew_name, "read_lints"); + assert_eq!(parsed.input["paths"], json!(["src/main.rs"])); + } + + #[test] + fn maps_semantic_search_to_codebase_search() { + let value = json!({ + "tool": "CLIENT_SIDE_TOOL_V2_SEMANTIC_SEARCH_FULL", + "toolCallId": "call_1", + "semanticSearchFullParams": { + "query": "auth token", + "targetDirectory": "src" + } + }); + let parsed = parse_tool_call(&value).expect("parsed"); + assert_eq!(parsed.sinew_name, "codebase_search"); + assert_eq!(parsed.input["query"], "auth token"); + assert_eq!(parsed.input["path"], "src"); + assert_eq!(parsed.input["limit"], 40); + } + + #[test] + fn maps_generate_image_to_create_image() { + let value = json!({ + "tool": "CLIENT_SIDE_TOOL_V2_GENERATE_IMAGE", + "toolCallId": "call_img", + "generateImageParams": { + "prompt": "A minimal blue logo" + } + }); + let parsed = parse_tool_call(&value).expect("parsed"); + assert_eq!(parsed.sinew_name, "create_image"); + assert_eq!(parsed.input["prompt"], "A minimal blue logo"); + } + + #[test] + fn maps_builtin_mcp_create_image_call() { + let value = json!({ + "tool": "CLIENT_SIDE_TOOL_V2_CALL_MCP_TOOL", + "toolCallId": "call_mcp_img", + "callMcpToolParams": { + "toolName": "mcp__sinew__create_image", + "args": { "prompt": "Sunset over mountains" } + } + }); + let parsed = parse_tool_call(&value).expect("parsed"); + assert_eq!(parsed.sinew_name, "create_image"); + assert_eq!(parsed.input["prompt"], "Sunset over mountains"); + } + + #[test] + fn maps_sanitized_mcp_create_image_call() { + let value = json!({ + "tool": "CLIENT_SIDE_TOOL_V2_CALL_MCP_TOOL", + "toolCallId": "call_mcp_img", + "callMcpToolParams": { + "toolName": "mcp__cursor__create_image", + "args": { "prompt": "Sunset over mountains" } + } + }); + let parsed = parse_tool_call(&value).expect("parsed"); + assert_eq!(parsed.sinew_name, "create_image"); + assert_eq!(parsed.input["prompt"], "Sunset over mountains"); + } + + #[test] + fn builds_todo_read_tool_result() { + let result = build_client_tool_result( + "call_todo", + "todo_list", + "CLIENT_SIDE_TOOL_V2_TODO_READ", + "[]", + false, + None, + ); + assert_eq!(result["todoReadResult"]["content"], "[]"); + assert!(result.get("todoWriteResult").is_none()); + } + + #[test] + fn resolves_unsupported_tool_call() { + let value = json!({ + "tool": "CLIENT_SIDE_TOOL_V2_APPLY_PATCH", + "toolCallId": "call_unknown", + }); + let parsed = resolve_tool_call(&value).expect("resolved"); + assert_eq!(parsed.sinew_name, COMPOSER_UNSUPPORTED_TOOL); + assert!(parsed.input["message"] + .as_str() + .unwrap_or("") + .contains("APPLY_PATCH")); + } + + #[test] + fn builds_generate_image_tool_result() { + let result = build_client_tool_result( + "call_img", + "create_image", + "CLIENT_SIDE_TOOL_V2_GENERATE_IMAGE", + "saved: assets/logo.png", + false, + Some(&[sinew_core::ToolResultImage { + media_type: "image/png".into(), + data: PNG_1X1.into(), + path: Some("assets/logo.png".into()), + }]), + ); + assert_eq!( + result["generateImageResult"]["resultForModel"], + "saved: assets/logo.png" + ); + assert_eq!(result["generateImageResult"]["images"][0]["dimension"]["width"], 1); + } + + const PNG_1X1: &str = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; +} diff --git a/crates/sinew-cursor/src/usage.rs b/crates/sinew-cursor/src/usage.rs new file mode 100644 index 00000000..2ff483ab --- /dev/null +++ b/crates/sinew-cursor/src/usage.rs @@ -0,0 +1,101 @@ +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use sinew_core::{AppError, Result}; + +const USAGE_URL: &str = + "https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CursorUsageInfo { + pub auto_percent_used: f64, + pub api_percent_used: f64, + pub total_percent_used: f64, + pub auto_limit: Option, + pub api_limit: Option, + pub membership_type: Option, +} + +pub async fn fetch_usage( + http: &Client, + identity: &crate::identity::CursorIdeIdentity, + access_token: &str, +) -> Result { + let session_id = uuid::Uuid::new_v4().to_string(); + let request_id = uuid::Uuid::new_v4().to_string(); + let mut headers = reqwest::header::HeaderMap::new(); + identity.apply_authenticated(&mut headers, &session_id, &request_id, access_token); + + let response = http + .post(USAGE_URL) + .headers(headers) + .header("authorization", format!("Bearer {access_token}")) + .header("content-type", "application/json") + .header("connect-protocol-version", "1") + .json(&serde_json::json!({})) + .send() + .await + .map_err(|err| AppError::Network(err.to_string()))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(AppError::Network(format!( + "Cursor usage endpoint returned {status}: {body}" + ))); + } + + let payload: UsageResponse = response + .json() + .await + .map_err(|err| AppError::Decode(err.to_string()))?; + + Ok(CursorUsageInfo { + auto_percent_used: payload + .plan_usage + .as_ref() + .and_then(|usage| usage.auto_percent_used) + .unwrap_or(0.0), + api_percent_used: payload + .plan_usage + .as_ref() + .and_then(|usage| usage.api_percent_used) + .unwrap_or(0.0), + total_percent_used: payload + .plan_usage + .as_ref() + .and_then(|usage| usage.total_percent_used) + .unwrap_or(0.0), + auto_limit: payload + .plan_usage + .as_ref() + .and_then(|usage| usage.auto_limit), + api_limit: payload + .plan_usage + .as_ref() + .and_then(|usage| usage.api_limit), + membership_type: None, + }) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct UsageResponse { + #[serde(default)] + plan_usage: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PlanUsage { + #[serde(default)] + auto_percent_used: Option, + #[serde(default)] + api_percent_used: Option, + #[serde(default)] + total_percent_used: Option, + #[serde(default)] + auto_limit: Option, + #[serde(default)] + api_limit: Option, +} diff --git a/crates/sinew-cursor/src/workspace.rs b/crates/sinew-cursor/src/workspace.rs new file mode 100644 index 00000000..52415225 --- /dev/null +++ b/crates/sinew-cursor/src/workspace.rs @@ -0,0 +1,180 @@ +use std::{ + path::{Path, PathBuf}, + process::Command, +}; + +use serde_json::{json, Value}; +use sinew_index::IndexStats; + +const SKIP_DIRS: &[&str] = &[ + ".git", + "node_modules", + "target", + "dist", + "build", + ".next", + ".turbo", + "__pycache__", +]; + +pub struct WorkspaceSnapshot { + pub uri: String, + pub name: String, + pub branch: Option, + pub git_status: Option, + pub project_layout: Value, +} + +pub fn snapshot(workspace_root: &str) -> Option { + let path = PathBuf::from(workspace_root); + if !path.is_dir() { + return None; + } + let name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("workspace") + .to_string(); + let index_stats = sinew_index::index_stats(&path).unwrap_or(IndexStats { + files_indexed: 0, + chunks_indexed: 0, + files_updated: 0, + embeddings_backfilled: 0, + }); + let mut project_layout = build_project_layout(&path, 4, 200); + if let Some(object) = project_layout.as_object_mut() { + object.insert( + "localIndex".into(), + json!({ + "filesIndexed": index_stats.files_indexed, + "chunksIndexed": index_stats.chunks_indexed, + "engine": "cursor-local-index" + }), + ); + } + Some(WorkspaceSnapshot { + uri: path_to_file_uri(&path), + name, + branch: git_branch(&path), + git_status: git_status_porcelain(&path), + project_layout, + }) +} + +pub fn path_to_file_uri(path: &Path) -> String { + let normalized = path.display().to_string().replace('\\', "/"); + if normalized.starts_with("//") { + return format!("file:{normalized}"); + } + format!("file:///{normalized}") +} + +fn git_branch(root: &Path) -> Option { + let mut cmd = Command::new("git"); + cmd.args(["-C", &root.display().to_string(), "branch", "--show-current"]); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x08000000); + } + let output = cmd.output().ok()?; + if !output.status.success() { + return None; + } + let branch = String::from_utf8_lossy(&output.stdout).trim().to_string(); + (!branch.is_empty()).then_some(branch) +} + +fn git_status_porcelain(root: &Path) -> Option { + let mut cmd = Command::new("git"); + cmd.args(["-C", &root.display().to_string(), "status", "--porcelain"]); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x08000000); + } + let output = cmd.output().ok()?; + if !output.status.success() { + return None; + } + let status = String::from_utf8_lossy(&output.stdout).trim().to_string(); + (!status.is_empty()).then_some(status) +} + +fn build_project_layout(root: &Path, max_depth: usize, max_entries: usize) -> Value { + let mut directories = Vec::new(); + let mut files = Vec::new(); + let mut remaining = max_entries; + collect_layout( + root, + root, + max_depth, + &mut directories, + &mut files, + &mut remaining, + ); + json!({ + "directories": directories, + "files": files, + "totalFiles": files.len(), + "totalSubfolders": directories.len(), + }) +} + +fn collect_layout( + root: &Path, + current: &Path, + depth: usize, + directories: &mut Vec, + files: &mut Vec, + remaining: &mut usize, +) { + if depth == 0 || *remaining == 0 { + return; + } + let read_dir = match std::fs::read_dir(current) { + Ok(read_dir) => read_dir, + Err(_) => return, + }; + let mut entries = read_dir + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .collect::>(); + entries.sort(); + for path in entries { + if *remaining == 0 { + break; + } + let file_name = path.file_name().and_then(|name| name.to_str()).unwrap_or(""); + if file_name.is_empty() || should_skip(file_name) { + continue; + } + let relative = path + .strip_prefix(root) + .map(|value| value.display().to_string().replace('\\', "/")) + .unwrap_or_else(|_| file_name.to_string()); + if path.is_dir() { + directories.push(json!({ "name": relative })); + *remaining = remaining.saturating_sub(1); + collect_layout(root, &path, depth - 1, directories, files, remaining); + } else { + files.push(json!({ "name": relative })); + *remaining = remaining.saturating_sub(1); + } + } +} + +fn should_skip(name: &str) -> bool { + SKIP_DIRS.contains(&name) || name.starts_with('.') +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn file_uri_uses_forward_slashes() { + let uri = path_to_file_uri(Path::new(r"C:\Dev\sinew")); + assert!(uri.starts_with("file:///C:/Dev/sinew")); + } +} diff --git a/crates/sinew-deepseek/Cargo.toml b/crates/sinew-deepseek/Cargo.toml new file mode 100644 index 00000000..d2c775c1 --- /dev/null +++ b/crates/sinew-deepseek/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "sinew-deepseek" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "DeepSeek provider integration for Sinew" + +[dependencies] +sinew-core = { workspace = true } + +async-trait = { workspace = true } +bytes = { workspace = true } +directories = { workspace = true } +eventsource-stream = { workspace = true } +futures = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } + +[lints] +workspace = true diff --git a/crates/sinew-deepseek/src/auth.rs b/crates/sinew-deepseek/src/auth.rs new file mode 100644 index 00000000..908ebd97 --- /dev/null +++ b/crates/sinew-deepseek/src/auth.rs @@ -0,0 +1,221 @@ +use std::{ + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use directories::ProjectDirs; +use serde::{Deserialize, Serialize}; + +use sinew_core::{AppError, Result}; + +use crate::model_info::PROVIDER_ID; + +#[derive(Clone)] +pub struct Credential { + api_key: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeepSeekAuthStatus { + pub connected: bool, + pub key_preview: Option, + pub last_validated_ms: Option, +} + +impl DeepSeekAuthStatus { + pub fn disconnected() -> Self { + Self { + connected: false, + key_preview: None, + last_validated_ms: None, + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StoredAuth { + provider: String, + auth_mode: String, + tokens: StoredTokens, + #[serde(default, skip_serializing_if = "Option::is_none")] + last_validated_ms: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StoredTokens { + api_key: String, +} + +impl Credential { + pub fn from_api_key(api_key: impl Into) -> Self { + Self { + api_key: api_key.into(), + } + } + + pub fn load_default() -> Result> { + Self::from_sinew_auth_file(&default_auth_path()?) + } + + pub fn from_sinew_auth_file(path: &Path) -> Result> { + let bytes = match std::fs::read(path) { + Ok(bytes) => bytes, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(AppError::Auth(format!("unable to read auth file: {err}"))), + }; + + let payload: StoredAuth = serde_json::from_slice(&bytes) + .map_err(|err| AppError::Auth(format!("invalid auth file: {err}")))?; + if payload.provider != PROVIDER_ID || payload.auth_mode != "api_key" { + return Ok(None); + } + let api_key = payload.tokens.api_key.trim(); + if api_key.is_empty() { + return Err(AppError::Auth("deepseek auth is missing API key".into())); + } + + Ok(Some(Self::from_api_key(api_key.to_string()))) + } + + pub fn api_key(&self) -> &str { + &self.api_key + } +} + +pub fn load_default_api_key() -> Result> { + Ok(Credential::load_default()?.map(|credential| credential.api_key)) +} + +pub fn save_default_api_key(api_key: &str) -> Result { + let api_key = api_key.trim(); + if api_key.is_empty() { + return Err(AppError::Auth("DeepSeek API key cannot be empty".into())); + } + let auth = StoredAuth { + provider: PROVIDER_ID.into(), + auth_mode: "api_key".into(), + tokens: StoredTokens { + api_key: api_key.to_string(), + }, + last_validated_ms: Some(now_ms()), + }; + write_auth_file(&default_auth_path()?, &auth)?; + Ok(status_from_auth(&auth)) +} + +pub fn touch_default_auth_validation() -> Result { + let path = default_auth_path()?; + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Ok(DeepSeekAuthStatus::disconnected()) + } + Err(err) => return Err(AppError::Auth(format!("unable to read auth file: {err}"))), + }; + let mut auth: StoredAuth = serde_json::from_slice(&bytes) + .map_err(|err| AppError::Auth(format!("invalid auth file: {err}")))?; + auth.last_validated_ms = Some(now_ms()); + write_auth_file(&path, &auth)?; + Ok(status_from_auth(&auth)) +} + +pub fn load_default_auth_status() -> Result { + load_auth_status(&default_auth_path()?) +} + +pub fn load_auth_status(path: &Path) -> Result { + let bytes = match std::fs::read(path) { + Ok(bytes) => bytes, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Ok(DeepSeekAuthStatus::disconnected()) + } + Err(err) => return Err(AppError::Auth(format!("unable to read auth file: {err}"))), + }; + let payload: StoredAuth = serde_json::from_slice(&bytes) + .map_err(|err| AppError::Auth(format!("invalid auth file: {err}")))?; + Ok(status_from_auth(&payload)) +} + +pub fn delete_default_auth() -> Result<()> { + let path = default_auth_path()?; + match std::fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(AppError::Auth(format!("unable to delete auth file: {err}"))), + } +} + +fn default_auth_path() -> Result { + let dirs = ProjectDirs::from("dev", "hyrak", "sinew") + .ok_or_else(|| AppError::Auth("unable to resolve local data directory".into()))?; + Ok(dirs.data_local_dir().join("deepseek-auth.json")) +} + +fn status_from_auth(auth: &StoredAuth) -> DeepSeekAuthStatus { + if auth.provider != PROVIDER_ID || auth.auth_mode != "api_key" { + return DeepSeekAuthStatus::disconnected(); + } + let api_key = auth.tokens.api_key.trim(); + DeepSeekAuthStatus { + connected: !api_key.is_empty(), + key_preview: (!api_key.is_empty()).then(|| key_preview(api_key)), + last_validated_ms: auth.last_validated_ms, + } +} + +fn key_preview(api_key: &str) -> String { + let chars = api_key.chars().collect::>(); + if chars.len() <= 12 { + return "••••".to_string(); + } + let prefix = chars.iter().take(7).collect::(); + let suffix = chars + .iter() + .rev() + .take(4) + .copied() + .collect::>() + .into_iter() + .rev() + .collect::(); + format!("{prefix}…{suffix}") +} + +fn write_auth_file(path: &Path, auth: &StoredAuth) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|err| AppError::Auth(format!("unable to create auth directory: {err}")))?; + } + let pretty = serde_json::to_vec_pretty(auth) + .map_err(|err| AppError::Decode(format!("unable to serialize auth file: {err}")))?; + let temp = path.with_extension("json.tmp"); + std::fs::write(&temp, pretty) + .map_err(|err| AppError::Auth(format!("unable to write temp auth file: {err}")))?; + apply_permissions(&temp)?; + std::fs::rename(&temp, path) + .map_err(|err| AppError::Auth(format!("unable to replace auth file: {err}")))?; + Ok(()) +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as i64) + .unwrap_or(0) +} + +#[cfg(unix)] +fn apply_permissions(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .map_err(|err| AppError::Auth(format!("unable to chmod auth file: {err}")))?; + Ok(()) +} + +#[cfg(not(unix))] +fn apply_permissions(_path: &Path) -> Result<()> { + Ok(()) +} diff --git a/crates/sinew-deepseek/src/client.rs b/crates/sinew-deepseek/src/client.rs new file mode 100644 index 00000000..dc786a31 --- /dev/null +++ b/crates/sinew-deepseek/src/client.rs @@ -0,0 +1,407 @@ +use async_trait::async_trait; +use serde::Serialize; +use serde_json::Value; +use std::time::Instant; +use sinew_core::{ + AppError, ChatMessage, Effort, ModelCapabilities, ModelRef, Part, Provider, ProviderRequest, + ProviderStream, Result, Role, TokenEstimate, ToolDescriptor, +}; + +use crate::{ + auth::Credential, + model_info, + stream::map_stream, + wire, +}; + +const BASE_URL: &str = "https://api.deepseek.com"; +const USER_AGENT: &str = "sinew/0.1"; + +#[derive(Clone)] +pub struct DeepSeekConfig { + pub credential: Credential, + pub base_url: String, +} + +impl DeepSeekConfig { + pub fn new(credential: Credential) -> Self { + Self { + credential, + base_url: BASE_URL.into(), + } + } + + pub fn from_default_sources() -> Result { + if let Some(credential) = Credential::load_default()? { + return Ok(Self::new(credential)); + } + + Err(AppError::Auth( + "no deepseek api key found. Connect DeepSeek in Settings > Providers.".into(), + )) + } +} + +pub struct DeepSeekProvider { + config: DeepSeekConfig, + http: reqwest::Client, +} + +impl DeepSeekProvider { + pub fn new(config: DeepSeekConfig) -> Result { + let http = reqwest::Client::builder() + .user_agent(USER_AGENT) + .build() + .map_err(|err| AppError::Network(err.to_string()))?; + Ok(Self { config, http }) + } + + pub fn from_default_sources() -> Result { + Self::new(DeepSeekConfig::from_default_sources()?) + } + + async fn post(&self, route: &str) -> Result { + let request = self + .http + .post(format!( + "{}{}", + self.config.base_url.trim_end_matches('/'), + route + )) + .bearer_auth(self.config.credential.api_key()) + .header("content-type", "application/json") + .header("accept", "application/json"); + Ok(request) + } + + async fn send_json( + &self, + route: &str, + body: &T, + ) -> Result { + let req_start = Instant::now(); + let request = self.post(route).await?; + let response = request + .json(body) + .send() + .await + .map_err(|err| AppError::Network(err.to_string()))?; + let http_ms = req_start.elapsed().as_millis(); + tracing::debug!(provider = "deepseek", route, http_ms, "HTTP round-trip"); + Ok(response) + } +} + +#[async_trait] +impl Provider for DeepSeekProvider { + fn name(&self) -> &str { + "deepseek" + } + + fn capabilities(&self, model: &ModelRef) -> Option { + if model.provider != "deepseek" { + return None; + } + Some(model_info::capabilities(model)) + } + + async fn estimate_tokens(&self, request: ProviderRequest) -> Result { + if request.model.provider != "deepseek" { + return Err(AppError::Unsupported(format!( + "deepseek provider cannot count model provider {}", + request.model.provider + ))); + } + Ok(TokenEstimate { + input_tokens: rough_token_estimate(&request), + exact: false, + }) + } + + async fn stream(&self, request: ProviderRequest) -> Result { + if request.model.provider != "deepseek" { + return Err(AppError::Unsupported(format!( + "deepseek provider cannot run model provider {}", + request.model.provider + ))); + } + + let caps = model_info::capabilities(&request.model); + let (thinking, reasoning_effort, temperature) = if caps.supports_thinking { + if request.effective_effort() != Some(Effort::None) { + ( + Some(wire::WireThinking { kind: "enabled" }), + Some("high"), + None, + ) + } else { + ( + Some(wire::WireThinking { kind: "disabled" }), + None, + request.temperature, + ) + } + } else { + (None, None, request.temperature) + }; + + let body = wire::ChatCompletionsRequest { + model: &request.model.name, + messages: to_wire_messages(&request, caps.supports_thinking)?, + tools: if caps.supports_tools { + request.tools.iter().map(to_wire_tool).collect() + } else { + Vec::new() + }, + max_tokens: request.max_output_tokens.or(Some(caps.max_output_tokens)), + temperature, + thinking, + reasoning_effort, + stream: true, + }; + + let response = self.send_json("/chat/completions", &body).await?; + if !response.status().is_success() { + return Err(read_http_error(response).await); + } + + Ok(map_stream(response.bytes_stream(), request.model.name)) + } +} + +pub async fn validate_api_key(api_key: &str) -> std::result::Result<(), String> { + let http = reqwest::Client::builder() + .user_agent(USER_AGENT) + .build() + .map_err(|err| err.to_string())?; + + let response = http + .get(format!("{}/models", BASE_URL)) + .bearer_auth(api_key) + .send() + .await + .map_err(|err| format!("Network error: {err}"))?; + + if response.status().is_success() { + Ok(()) + } else { + Err(format!("HTTP {}", response.status())) + } +} + +fn to_wire_tool(tool: &ToolDescriptor) -> wire::WireTool<'_> { + wire::WireTool { + kind: "function", + function: wire::WireToolFunction { + name: &tool.name, + description: &tool.description, + parameters: &tool.input_schema, + }, + } +} + +fn to_wire_messages<'a>( + request: &'a ProviderRequest, + supports_thinking: bool, +) -> Result>> { + let mut messages = Vec::new(); + if let Some(system) = request + .system_prompt + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + messages.push(wire::WireMessage::System { + role: "system", + content: system, + }); + } + + for message in &request.transcript { + match message.role { + Role::User => push_user_messages(message, &mut messages), + Role::Assistant => push_assistant_message(message, &mut messages, supports_thinking), + } + } + + Ok(messages) +} + +fn push_user_messages<'a>(message: &'a ChatMessage, messages: &mut Vec>) { + let mut builder = ContentBuilder::default(); + for part in &message.parts { + if part_is_ui_only(part) { + continue; + } + match part { + Part::Text { text, .. } => builder.push_text(text), + Part::ToolResult { + tool_call_id, + content, + .. + } => { + flush_user_builder(&mut builder, messages); + let mut result = ContentBuilder::default(); + result.push_text(content); + let content = result + .finish_allow_empty() + .unwrap_or_else(|| wire::WireContent::Text(String::new())); + messages.push(wire::WireMessage::Tool { + role: "tool", + content, + tool_call_id, + }); + } + Part::Thinking { .. } | Part::ToolCall { .. } | Part::Image { .. } => {} + } + } + flush_user_builder(&mut builder, messages); +} + +fn flush_user_builder<'a>(builder: &mut ContentBuilder, messages: &mut Vec>) { + if let Some(content) = builder.finish() { + messages.push(wire::WireMessage::User { + role: "user", + content, + }); + } +} + +fn push_assistant_message<'a>( + message: &'a ChatMessage, + messages: &mut Vec>, + supports_thinking: bool, +) { + let mut text = String::new(); + let mut reasoning = String::new(); + let mut tool_calls = Vec::new(); + + for part in &message.parts { + if part_is_ui_only(part) { + continue; + } + match part { + Part::Text { text: value, .. } => text.push_str(value), + Part::Thinking { text: value, .. } => reasoning.push_str(value), + Part::ToolCall { + id, name, input, .. + } => tool_calls.push(wire::WireToolCall { + id, + kind: "function", + function: wire::WireToolCallFunction { + name, + arguments: input.to_string(), + }, + }), + Part::Image { .. } | Part::ToolResult { .. } => {} + } + } + + if text.is_empty() && reasoning.is_empty() && tool_calls.is_empty() { + return; + } + + let content = (!text.is_empty()).then_some(wire::WireContent::Text(text)); + let reasoning_content = if supports_thinking { + Some(reasoning) + } else if !reasoning.is_empty() { + Some(reasoning) + } else { + None + }; + + messages.push(wire::WireMessage::Assistant { + role: "assistant", + content, + reasoning_content, + tool_calls, + }); +} + +#[derive(Default)] +struct ContentBuilder { + text: String, +} + +impl ContentBuilder { + fn push_text(&mut self, text: &str) { + if text.is_empty() { + return; + } + self.text.push_str(text); + } + + fn finish(&mut self) -> Option { + self.finish_inner(false) + } + + fn finish_allow_empty(&mut self) -> Option { + self.finish_inner(true) + } + + fn finish_inner(&mut self, allow_empty_text: bool) -> Option { + if self.text.is_empty() && !allow_empty_text { + return None; + } + Some(wire::WireContent::Text(std::mem::take(&mut self.text))) + } +} + +fn part_is_ui_only(part: &Part) -> bool { + part_meta(part) + .and_then(|meta| meta.get("ui_only")) + .and_then(|value| value.as_bool()) + == Some(true) +} + +fn part_meta(part: &Part) -> Option<&Value> { + match part { + Part::Text { meta, .. } + | Part::Image { meta, .. } + | Part::Thinking { meta, .. } + | Part::ToolCall { meta, .. } + | Part::ToolResult { meta, .. } => meta.as_ref(), + } +} + +fn rough_token_estimate(request: &ProviderRequest) -> u32 { + let mut chars: usize = 0; + if let Some(system) = &request.system_prompt { + chars += system.chars().count(); + } + for message in &request.transcript { + for part in &message.parts { + if part_is_ui_only(part) { + continue; + } + match part { + Part::Text { text, .. } => chars += text.chars().count(), + Part::Thinking { text, .. } => chars += text.chars().count(), + Part::ToolCall { name, input, .. } => { + chars += name.chars().count() + input.to_string().chars().count() + } + Part::ToolResult { content, .. } => chars += content.chars().count(), + Part::Image { .. } => {} + } + } + } + (chars / 4) as u32 +} + +async fn read_http_error(response: reqwest::Response) -> AppError { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + + if status == reqwest::StatusCode::UNAUTHORIZED { + AppError::Auth("DeepSeek API key is invalid or unauthorized. Please verify your API key in Settings > Providers.".into()) + } else if status == reqwest::StatusCode::TOO_MANY_REQUESTS { + AppError::RateLimit(format!("DeepSeek API rate limit exceeded: {body}")) + } else if status.is_client_error() { + if body.contains("context") || body.contains("too long") { + AppError::ContextLength(body) + } else { + AppError::InvalidRequest(body) + } + } else { + AppError::Provider(format!("HTTP {status}: {body}")) + } +} diff --git a/crates/sinew-deepseek/src/lib.rs b/crates/sinew-deepseek/src/lib.rs new file mode 100644 index 00000000..2be46bf8 --- /dev/null +++ b/crates/sinew-deepseek/src/lib.rs @@ -0,0 +1,15 @@ +mod auth; +mod client; +mod model_info; +mod stream; +mod wire; + +pub use auth::{ + delete_default_auth, load_default_api_key, load_default_auth_status, save_default_api_key, + touch_default_auth_validation, Credential, DeepSeekAuthStatus, +}; +pub use client::{DeepSeekConfig, DeepSeekProvider, validate_api_key}; +pub use model_info::{ + capabilities, PROVIDER_ID, DEEPSEEK_CHAT_MODEL, DEEPSEEK_REASONER_MODEL, + DEEPSEEK_V4_FLASH_MODEL, DEEPSEEK_V4_PRO_MODEL, +}; diff --git a/crates/sinew-deepseek/src/model_info.rs b/crates/sinew-deepseek/src/model_info.rs new file mode 100644 index 00000000..b3ccc712 --- /dev/null +++ b/crates/sinew-deepseek/src/model_info.rs @@ -0,0 +1,47 @@ +use sinew_core::{EffortMode, ModelCapabilities, ModelRef}; + +pub const PROVIDER_ID: &str = "deepseek"; +pub const DEEPSEEK_V4_FLASH_MODEL: &str = "deepseek-v4-flash"; +pub const DEEPSEEK_V4_PRO_MODEL: &str = "deepseek-v4-pro"; +pub const DEEPSEEK_CHAT_MODEL: &str = "deepseek-chat"; +pub const DEEPSEEK_REASONER_MODEL: &str = "deepseek-reasoner"; + +pub fn capabilities(model: &ModelRef) -> ModelCapabilities { + if model.name == DEEPSEEK_V4_FLASH_MODEL || model.name == DEEPSEEK_V4_PRO_MODEL { + ModelCapabilities { + model: model.clone(), + context_window: 1_000_000, + preferred_window: 800_000, + max_output_tokens: 384_000, + supports_thinking: true, + visible_thinking: true, + supports_tools: true, + supports_images: false, + effort_mode: EffortMode::Flag, + } + } else if model.name == DEEPSEEK_REASONER_MODEL { + ModelCapabilities { + model: model.clone(), + context_window: 128_000, + preferred_window: 120_000, + max_output_tokens: 8192, + supports_thinking: true, + visible_thinking: true, + supports_tools: false, + supports_images: false, + effort_mode: EffortMode::Flag, + } + } else { + ModelCapabilities { + model: model.clone(), + context_window: 128_000, + preferred_window: 120_000, + max_output_tokens: 8192, + supports_thinking: false, + visible_thinking: false, + supports_tools: true, + supports_images: false, + effort_mode: EffortMode::None, + } + } +} diff --git a/crates/sinew-deepseek/src/stream.rs b/crates/sinew-deepseek/src/stream.rs new file mode 100644 index 00000000..e10ae027 --- /dev/null +++ b/crates/sinew-deepseek/src/stream.rs @@ -0,0 +1,346 @@ +use std::collections::HashMap; + +use eventsource_stream::Eventsource; +use futures::{stream::Stream, StreamExt}; +use serde_json::{json, Value}; + +use sinew_core::{ + AppError, PartKind, ProviderStream, StopReason, StreamEvent, ToolCallIntro, Usage, +}; + +use crate::wire::{self, ChatChunk}; + +pub fn map_stream(body: S, model: String) -> ProviderStream +where + S: Stream> + Send + 'static, + E: std::error::Error + Send + Sync + 'static, +{ + let source = Box::pin(body.eventsource()); + let parser = EventParser::new(model); + + futures::stream::unfold( + (source, parser, Vec::::new(), false, false), + |(mut source, mut parser, mut pending, done, mut saw_any_event)| async move { + loop { + if let Some(next) = pending.pop() { + return Some((Ok(next), (source, parser, pending, done, saw_any_event))); + } + if done { + return None; + } + + match source.next().await { + Some(Ok(event)) => { + saw_any_event = true; + let data = event.data.trim(); + if data == "[DONE]" { + let mut produced = parser.finish(); + produced.reverse(); + pending = produced; + if pending.is_empty() { + return None; + } + continue; + } + + if let Ok(value) = serde_json::from_str::(data) { + if let Some(error) = value.get("error") { + let message = error + .get("message") + .and_then(Value::as_str) + .unwrap_or("deepseek stream error"); + return Some(( + Err(AppError::Provider(message.to_string())), + (source, parser, pending, true, saw_any_event), + )); + } + } + + let parsed: std::result::Result = serde_json::from_str(data); + match parsed { + Ok(parsed) => { + let mut produced = parser.push(parsed); + produced.reverse(); + pending = produced; + } + Err(err) => { + return Some(( + Err(AppError::Decode(format!("bad deepseek event: {err}"))), + (source, parser, pending, true, saw_any_event), + )); + } + } + } + Some(Err(err)) => { + return Some(( + Err(AppError::Stream(err.to_string())), + (source, parser, pending, true, saw_any_event), + )); + } + None => { + if !saw_any_event { + return Some(( + Err(AppError::Stream( + "deepseek SSE closed before any event; \ + the server likely dropped the connection" + .into(), + )), + (source, parser, pending, true, saw_any_event), + )); + } + let mut produced = parser.finish(); + produced.reverse(); + pending = produced; + if pending.is_empty() { + return None; + } + } + } + } + }, + ) + .boxed() +} + +#[derive(Debug, Default)] +struct ToolState { + part_index: Option, + id: String, + name: String, + pending_args: String, +} + +struct EventParser { + model: String, + started: bool, + next_index: usize, + open_part: Option<(usize, PartKind)>, + tool_states: HashMap, + saw_tool_call: bool, + stop_reason: Option, + usage: Usage, + done: bool, +} + +impl EventParser { + fn new(model: String) -> Self { + Self { + model, + started: false, + next_index: 0, + open_part: None, + tool_states: HashMap::new(), + saw_tool_call: false, + stop_reason: None, + usage: Usage::default(), + done: false, + } + } + + fn push(&mut self, chunk: ChatChunk) -> Vec { + if self.done { + return Vec::new(); + } + if let Some(model) = chunk.model.filter(|value| !value.trim().is_empty()) { + self.model = model; + } + if let Some(usage) = chunk.usage { + self.usage = usage_from_body(usage); + } + + let mut out = Vec::new(); + for choice in chunk.choices { + if let Some(reasoning) = choice + .delta + .reasoning_content + .filter(|value| !value.is_empty()) + { + self.ensure_started(&mut out); + let index = self.ensure_open(PartKind::Thinking, &mut out); + out.push(StreamEvent::ThinkingDelta { + index, + delta: reasoning, + }); + } + if let Some(text) = choice.delta.content.filter(|value| !value.is_empty()) { + self.ensure_started(&mut out); + let index = self.ensure_open(PartKind::Text, &mut out); + out.push(StreamEvent::TextDelta { index, delta: text }); + } + for call in choice.delta.tool_calls { + self.push_tool_delta(call, &mut out); + } + if let Some(reason) = choice.finish_reason { + self.stop_reason = Some(map_stop_reason(&reason, self.saw_tool_call)); + out.extend(self.finish()); + } + } + + out + } + + fn push_tool_delta(&mut self, call: wire::ToolCallDelta, out: &mut Vec) { + self.ensure_started(out); + self.saw_tool_call = true; + let key = call.index.unwrap_or(self.tool_states.len()); + let mut state = self.tool_states.remove(&key).unwrap_or_default(); + if let Some(id) = call.id.filter(|value| !value.trim().is_empty()) { + state.id = id; + } + let mut new_args = String::new(); + if let Some(function) = call.function { + if let Some(name) = function.name.filter(|value| !value.trim().is_empty()) { + state.name = name; + } + if let Some(arguments) = function.arguments.filter(|value| !value.is_empty()) { + new_args = arguments; + } + } + + if state.part_index.is_none() && !state.name.is_empty() { + self.close_open(out); + let part_index = self.next_index(); + let id = if state.id.is_empty() { + format!("call_deepseek_{key}") + } else { + state.id.clone() + }; + state.id = id.clone(); + state.part_index = Some(part_index); + out.push(StreamEvent::PartStart { + index: part_index, + kind: PartKind::ToolCall, + tool: Some(ToolCallIntro { + id, + name: state.name.clone(), + }), + }); + if !state.pending_args.is_empty() { + out.push(StreamEvent::ToolJsonDelta { + index: part_index, + chunk: std::mem::take(&mut state.pending_args), + }); + } + } + + if let Some(part_index) = state.part_index { + if !new_args.is_empty() { + out.push(StreamEvent::ToolJsonDelta { + index: part_index, + chunk: new_args, + }); + } + } else if !new_args.is_empty() { + state.pending_args.push_str(&new_args); + } + + self.tool_states.insert(key, state); + } + + fn ensure_started(&mut self, out: &mut Vec) { + if self.started { + return; + } + self.started = true; + out.push(StreamEvent::MessageStart { + model: self.model.clone(), + }); + } + + fn ensure_open(&mut self, kind: PartKind, out: &mut Vec) -> usize { + if self.open_part.map(|(_, current)| current) == Some(kind) { + return self.open_part.map(|(index, _)| index).unwrap_or(0); + } + self.close_open(out); + let index = self.next_index(); + self.open_part = Some((index, kind)); + out.push(StreamEvent::PartStart { + index, + kind, + tool: None, + }); + index + } + + fn close_open(&mut self, out: &mut Vec) { + if let Some((index, _)) = self.open_part.take() { + out.push(StreamEvent::PartStop { index }); + } + } + + fn next_index(&mut self) -> usize { + let index = self.next_index; + self.next_index += 1; + index + } + + fn finish(&mut self) -> Vec { + if self.done { + return Vec::new(); + } + self.done = true; + let mut out = Vec::new(); + if !self.started { + self.ensure_started(&mut out); + } + self.close_open(&mut out); + let mut keys = self.tool_states.keys().copied().collect::>(); + keys.sort_unstable(); + for key in keys { + if let Some(state) = self.tool_states.remove(&key) { + if let Some(index) = state.part_index { + out.push(StreamEvent::PartMeta { + index, + meta: json!({ "provider": "deepseek", "id": state.id, "name": state.name }), + }); + out.push(StreamEvent::PartStop { index }); + } + } + } + out.push(StreamEvent::MessageStop { + stop_reason: self.stop_reason.unwrap_or({ + if self.saw_tool_call { + StopReason::ToolUse + } else { + StopReason::EndTurn + } + }), + usage: self.usage, + }); + out + } +} + +fn usage_from_body(body: wire::UsageBody) -> Usage { + let cache_read_tokens = body + .prompt_tokens_details + .and_then(|details| details.cached_tokens) + .unwrap_or(0); + let reasoning_tokens = body + .completion_tokens_details + .and_then(|details| details.reasoning_tokens) + .unwrap_or(0); + Usage { + input_tokens: body.prompt_tokens, + output_tokens: body.completion_tokens, + total_tokens: if body.total_tokens > 0 { + body.total_tokens + } else { + body.prompt_tokens.saturating_add(body.completion_tokens) + }, + reasoning_tokens, + cache_read_tokens, + cache_creation_tokens: 0, + } +} + +fn map_stop_reason(raw: &str, saw_tool_call: bool) -> StopReason { + match raw { + "stop" => StopReason::EndTurn, + "tool_calls" | "function_call" => StopReason::ToolUse, + "length" => StopReason::MaxTokens, + "content_filter" => StopReason::Other, + _ if saw_tool_call => StopReason::ToolUse, + _ => StopReason::Other, + } +} diff --git a/crates/sinew-deepseek/src/wire.rs b/crates/sinew-deepseek/src/wire.rs new file mode 100644 index 00000000..a9abc412 --- /dev/null +++ b/crates/sinew-deepseek/src/wire.rs @@ -0,0 +1,158 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Serialize)] +pub struct ChatCompletionsRequest<'a> { + pub model: &'a str, + pub messages: Vec>, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub tools: Vec>, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub thinking: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning_effort: Option<&'static str>, + pub stream: bool, +} + +#[derive(Debug, Serialize)] +pub struct WireThinking { + #[serde(rename = "type")] + pub kind: &'static str, +} + +#[derive(Debug, Serialize)] +#[serde(untagged)] +pub enum WireMessage<'a> { + System { + role: &'static str, + content: &'a str, + }, + User { + role: &'static str, + content: WireContent, + }, + Assistant { + role: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + reasoning_content: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + tool_calls: Vec>, + }, + Tool { + role: &'static str, + content: WireContent, + tool_call_id: &'a str, + }, +} + +#[derive(Debug, Serialize)] +#[serde(untagged)] +pub enum WireContent { + Text(String), +} + +#[derive(Debug, Serialize)] +pub struct WireToolCall<'a> { + pub id: &'a str, + #[serde(rename = "type")] + pub kind: &'static str, + pub function: WireToolCallFunction<'a>, +} + +#[derive(Debug, Serialize)] +pub struct WireToolCallFunction<'a> { + pub name: &'a str, + pub arguments: String, +} + +#[derive(Debug, Serialize)] +pub struct WireTool<'a> { + #[serde(rename = "type")] + pub kind: &'static str, + pub function: WireToolFunction<'a>, +} + +#[derive(Debug, Serialize)] +pub struct WireToolFunction<'a> { + pub name: &'a str, + pub description: &'a str, + pub parameters: &'a Value, +} + +#[derive(Debug, Deserialize)] +pub struct ChatChunk { + #[serde(default)] + pub model: Option, + #[serde(default)] + pub choices: Vec, + #[serde(default)] + pub usage: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ChatChoice { + #[serde(default)] + pub delta: ChatDelta, + #[serde(default)] + pub finish_reason: Option, +} + +#[derive(Debug, Default, Deserialize)] +pub struct ChatDelta { + #[serde(default)] + pub content: Option, + #[serde(default)] + pub reasoning_content: Option, + #[serde(default)] + pub tool_calls: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct ToolCallDelta { + #[serde(default)] + pub index: Option, + #[serde(default)] + pub id: Option, + #[serde(default)] + pub function: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ToolCallFunctionDelta { + #[serde(default)] + pub name: Option, + #[serde(default)] + pub arguments: Option, +} + +#[derive(Debug, Default, Clone, Deserialize)] +pub struct UsageBody { + #[serde(default)] + pub prompt_tokens: u32, + #[serde(default)] + pub completion_tokens: u32, + #[serde(default)] + pub total_tokens: u32, + #[serde(default)] + pub prompt_tokens_details: Option, + #[serde(default)] + pub completion_tokens_details: Option, +} + +#[derive(Debug, Default, Clone, Deserialize)] +pub struct PromptTokensDetails { + #[serde(default)] + pub cached_tokens: Option, +} + +#[derive(Debug, Default, Clone, Deserialize)] +pub struct CompletionTokensDetails { + #[serde(default)] + pub reasoning_tokens: Option, +} diff --git a/crates/sinew-google/src/auth.rs b/crates/sinew-google/src/auth.rs index 68c25524..84846565 100644 --- a/crates/sinew-google/src/auth.rs +++ b/crates/sinew-google/src/auth.rs @@ -89,7 +89,8 @@ impl GoogleUserData { } pub fn is_stale_antigravity_default(&self) -> bool { - self.project_id == "default" && self.user_tier.as_deref() == Some("free-tier") + (self.project_id == "default" && self.user_tier.as_deref() == Some("free-tier")) + || self.project_id == "rising-fact-p41fc" } } @@ -143,6 +144,15 @@ impl Credential { }))) } + pub fn source_path(&self) -> Option { + match self { + Self::OAuth(state) => { + let guard = state.blocking_lock(); + guard.source_path.clone() + } + } + } + pub fn load_default() -> Result> { Self::from_sinew_auth_file(&default_auth_path()?) } @@ -239,7 +249,7 @@ fn now_ms() -> i64 { } fn expires_at(expires_in_seconds: u64) -> i64 { - now_ms() + (expires_in_seconds as i64 * 1000) - REFRESH_SKEW_MS + now_ms() + (expires_in_seconds as i64 * 1000) } #[derive(Debug, Deserialize)] @@ -325,6 +335,52 @@ pub fn default_auth_path() -> Result { Ok(dirs.data_local_dir().join("google-auth.json")) } +pub fn path_for_auth_key(key: &str) -> Result { + let dirs = ProjectDirs::from("dev", "hyrak", "sinew") + .ok_or_else(|| AppError::Auth("unable to resolve local data directory".into()))?; + let dir = dirs.data_local_dir(); + if key == "google" { + Ok(dir.join("google-auth.json")) + } else if key.starts_with("google:") { + let suffix = key.strip_prefix("google:").unwrap(); + Ok(dir.join(format!("google-auth-{}.json", suffix))) + } else { + Err(AppError::Auth(format!("invalid auth key: {key}"))) + } +} + +pub fn all_auth_files() -> Result> { + let dirs = ProjectDirs::from("dev", "hyrak", "sinew") + .ok_or_else(|| AppError::Auth("unable to resolve local data directory".into()))?; + let dir = dirs.data_local_dir(); + let mut files = Vec::new(); + + let default_path = dir.join("google-auth.json"); + if default_path.exists() { + files.push(("google".to_string(), default_path)); + } + + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if let Some(filename) = path.file_name().and_then(|f| f.to_str()) { + if filename.starts_with("google-auth-") && filename.ends_with(".json") { + let suffix = filename + .strip_prefix("google-auth-") + .and_then(|s| s.strip_suffix(".json")) + .unwrap_or("custom"); + let key = format!("google:{}", suffix); + if !files.iter().any(|(_, p)| p == &path) { + files.push((key, path)); + } + } + } + } + } + + Ok(files) +} + pub fn load_default_auth_status() -> Result { load_auth_status(&default_auth_path()?) } @@ -342,9 +398,8 @@ pub fn load_auth_status(path: &Path) -> Result { Ok(status_from_auth(&payload)) } -pub fn load_default_user_data() -> Result> { - let path = default_auth_path()?; - let bytes = match std::fs::read(&path) { +pub fn load_user_data(path: &Path) -> Result> { + let bytes = match std::fs::read(path) { Ok(bytes) => bytes, Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(err) => return Err(AppError::Auth(format!("unable to read auth file: {err}"))), @@ -359,9 +414,8 @@ pub fn load_default_user_data() -> Result> { .filter(|user| !user.is_stale_antigravity_default())) } -pub fn save_default_user_data(user: &GoogleUserData) -> Result<()> { - let path = default_auth_path()?; - let bytes = std::fs::read(&path) +pub fn save_user_data(path: &Path, user: &GoogleUserData) -> Result<()> { + let bytes = std::fs::read(path) .map_err(|err| AppError::Auth(format!("unable to read auth file: {err}")))?; let mut payload: StoredAuth = serde_json::from_slice(&bytes) .map_err(|err| AppError::Auth(format!("invalid auth file: {err}")))?; @@ -371,7 +425,15 @@ pub fn save_default_user_data(user: &GoogleUserData) -> Result<()> { )); } payload.user = Some(user.clone()); - write_auth_file(&path, &payload) + write_auth_file(path, &payload) +} + +pub fn load_default_user_data() -> Result> { + load_user_data(&default_auth_path()?) +} + +pub fn save_default_user_data(user: &GoogleUserData) -> Result<()> { + save_user_data(&default_auth_path()?, user) } pub fn delete_default_auth() -> Result<()> { @@ -474,6 +536,7 @@ pub async fn exchange_oauth_code( code: &str, redirect_uri: &str, pkce: &PkceCodes, + target_key: Option, ) -> Result { let response = http .post(GOOGLE_OAUTH_TOKEN_URL) @@ -507,7 +570,34 @@ pub async fn exchange_oauth_code( .await .ok() .or_else(|| body.id_token.as_deref().and_then(token_email)); - save_oauth_tokens(&default_auth_path()?, body, email) + + let target_path = if let Some(key) = target_key { + path_for_auth_key(&key)? + } else { + let default_path = default_auth_path()?; + if default_path.exists() + && load_auth_status(&default_path) + .map(|s| s.connected) + .unwrap_or(false) + { + let dir = default_path.parent().unwrap(); + let mut index = 2; + loop { + let p = dir.join(format!("google-auth-{}.json", index)); + if !p.exists() { + break p; + } + index += 1; + if index > 100 { + break dir.join(format!("google-auth-{}.json", index)); + } + } + } else { + default_path + } + }; + + save_oauth_tokens(&target_path, body, email) } fn save_oauth_tokens( diff --git a/crates/sinew-google/src/client.rs b/crates/sinew-google/src/client.rs index ac60dceb..789ff58a 100644 --- a/crates/sinew-google/src/client.rs +++ b/crates/sinew-google/src/client.rs @@ -1,4 +1,4 @@ -use std::time::Duration; +use std::time::{Duration, Instant}; use async_trait::async_trait; use serde_json::{json, Value}; @@ -10,19 +10,20 @@ use tokio::sync::Mutex; use crate::{ auth::{ - generate_state, load_default_user_data, save_default_user_data, Credential, GoogleUserData, + generate_state, Credential, GoogleUserData, + load_user_data, save_user_data, default_auth_path, }, model_info, stream::map_stream, wire, }; -const BASE_URL: &str = "https://daily-cloudcode-pa.googleapis.com/v1internal"; -const PROD_BASE_URL: &str = "https://cloudcode-pa.googleapis.com/v1internal"; +const BASE_URL: &str = "https://cloudcode-pa.googleapis.com/v1internal"; +const DAILY_BASE_URL: &str = "https://daily-cloudcode-pa.googleapis.com/v1internal"; const SANDBOX_BASE_URL: &str = "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal"; const AUTOPUSH_BASE_URL: &str = "https://autopush-cloudcode-pa.sandbox.googleapis.com/v1internal"; const USER_AGENT: &str = "sinew/0.1"; -const DEFAULT_ANTIGRAVITY_VERSION: &str = "2.0.0"; +const DEFAULT_ANTIGRAVITY_VERSION: &str = "2.0.1"; const ANTIGRAVITY_SYSTEM_INSTRUCTION: &str = "You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.**Absolute paths only****Proactiveness**"; const FALLBACK_PROJECT_ID: &str = "rising-fact-p41fc"; @@ -49,6 +50,17 @@ impl GoogleConfig { "no Antigravity OAuth credential found. Connect Google in Settings > Providers.".into(), )) } + + pub fn from_file(path: &std::path::Path) -> Result { + if let Some(credential) = Credential::from_sinew_auth_file(path)? { + return Ok(Self::new(credential)); + } + + Err(AppError::Auth(format!( + "unable to load google credential from path {}", + path.display() + ))) + } } pub struct GoogleProvider { @@ -63,7 +75,11 @@ impl GoogleProvider { .user_agent(USER_AGENT) .build() .map_err(|err| AppError::Network(err.to_string()))?; - let user_data = load_default_user_data().unwrap_or(None); + + let path = config.credential.source_path() + .unwrap_or_else(|| default_auth_path().unwrap_or_default()); + let user_data = load_user_data(&path).unwrap_or(None); + Ok(Self { config, http, @@ -75,6 +91,10 @@ impl GoogleProvider { Self::new(GoogleConfig::from_default_sources()?) } + pub fn from_file(path: &std::path::Path) -> Result { + Self::new(GoogleConfig::from_file(path)?) + } + async fn post(&self, method: &str) -> Result { self.post_to(&self.config.base_url, method).await } @@ -86,6 +106,7 @@ impl GoogleProvider { .post(method_url(base_url, method)) .bearer_auth(token) .header("user-agent", antigravity_user_agent()) + .header("x-goog-api-client", "gl-node/22.21.1") .header("content-type", "application/json") .header("accept", "application/json")) } @@ -110,8 +131,12 @@ impl GoogleProvider { } let user_data = self.setup_user().await?; - if let Err(err) = save_default_user_data(&user_data) { - tracing::warn!(error = %err, "failed to persist Antigravity user data"); + if user_data.project_id != FALLBACK_PROJECT_ID { + let path = self.config.credential.source_path() + .unwrap_or_else(|| default_auth_path().unwrap_or_default()); + if let Err(err) = save_user_data(&path, &user_data) { + tracing::warn!(error = %err, "failed to persist Antigravity user data"); + } } *self.user_data.lock().await = Some(user_data.clone()); Ok(user_data) @@ -227,11 +252,38 @@ impl GoogleProvider { &self, body: &wire::LoadCodeAssistRequest, ) -> Result { - let bases = [PROD_BASE_URL, BASE_URL, SANDBOX_BASE_URL, AUTOPUSH_BASE_URL]; - let mut last_error = None; - for base_url in bases { + let primary_bases = [BASE_URL, DAILY_BASE_URL]; + let mut first_unavailable = None; + for base_url in primary_bases { let token = self.config.credential.bearer(&self.http).await?; - let response = self + let request = self + .http + .post(method_url(base_url, "loadCodeAssist")) + .bearer_auth(token) + .header("user-agent", antigravity_load_code_assist_user_agent()) + .header("x-goog-api-client", "gl-node/22.21.1") + .header("content-type", "application/json") + .header("accept", "application/json") + .json(body); + let response = request + .send() + .await + .map_err(|err| AppError::Network(err.to_string()))?; + if response.status().is_success() { + return Ok(response); + } + let status = response.status(); + let err = read_http_error(response).await; + if status == reqwest::StatusCode::SERVICE_UNAVAILABLE { + first_unavailable.get_or_insert(err); + continue; + } + return Err(err); + } + + for base_url in [SANDBOX_BASE_URL, AUTOPUSH_BASE_URL] { + let token = self.config.credential.bearer(&self.http).await?; + let request = self .http .post(method_url(base_url, "loadCodeAssist")) .bearer_auth(token) @@ -239,16 +291,23 @@ impl GoogleProvider { .header("x-goog-api-client", "gl-node/22.21.1") .header("content-type", "application/json") .header("accept", "application/json") - .json(body) + .json(body); + let response = request .send() .await .map_err(|err| AppError::Network(err.to_string()))?; if response.status().is_success() { return Ok(response); } - last_error = Some(read_http_error(response).await); + if response.status() == reqwest::StatusCode::SERVICE_UNAVAILABLE { + continue; + } + tracing::warn!( + status = %response.status(), + "Antigravity staging loadCodeAssist fallback rejected; preserving primary 503" + ); } - Err(last_error + Err(first_unavailable .unwrap_or_else(|| AppError::Provider("Antigravity project discovery failed".into()))) } @@ -262,10 +321,8 @@ impl GoogleProvider { cloudaicompanion_project: project_id.clone(), metadata: client_metadata(project_id), }; - let response = self - .post("onboardUser") - .await? - .json(&body) + let request = self.post("onboardUser").await?.json(&body); + let response = request .send() .await .map_err(|err| AppError::Network(err.to_string()))?; @@ -316,14 +373,14 @@ impl Provider for GoogleProvider { } fn capabilities(&self, model: &ModelRef) -> Option { - if model.provider != "google" { + if model.provider != "google" && !model.provider.starts_with("google:") { return None; } Some(model_info::capabilities(model)) } async fn estimate_tokens(&self, request: ProviderRequest) -> Result { - if request.model.provider != "google" { + if request.model.provider != "google" && !request.model.provider.starts_with("google:") { return Err(AppError::Unsupported(format!( "Antigravity provider cannot count model provider {}", request.model.provider @@ -336,7 +393,7 @@ impl Provider for GoogleProvider { } async fn stream(&self, mut request: ProviderRequest) -> Result { - if request.model.provider != "google" { + if request.model.provider != "google" && !request.model.provider.starts_with("google:") { return Err(AppError::Unsupported(format!( "Antigravity provider cannot stream model provider {}", request.model.provider @@ -346,7 +403,11 @@ impl Provider for GoogleProvider { let caps = model_info::capabilities(&request.model); let user_data = self.ensure_user_data().await?; let body = build_generate_request(&request, &user_data, &caps)?; + let req_start = Instant::now(); + let model_name = request.model.name.clone(); let response = self.post_stream_with_fallbacks(&body).await?; + let http_ms = req_start.elapsed().as_millis(); + tracing::debug!(provider = "google", model = model_name, http_ms, "HTTP round-trip"); Ok(map_stream(response.bytes_stream(), request.model.name)) } @@ -357,9 +418,9 @@ impl GoogleProvider { &self, body: &wire::CodeAssistGenerateRequest, ) -> Result { - let bases = [self.config.base_url.as_str(), PROD_BASE_URL]; - let mut last_error = None; - for base_url in bases { + let primary_bases = [self.config.base_url.as_str(), DAILY_BASE_URL]; + let mut first_unavailable = None; + for base_url in primary_bases { let request = self .post_to(base_url, "streamGenerateContent") .await? @@ -375,16 +436,37 @@ impl GoogleProvider { } let status = response.status(); let err = read_http_error(response).await; - if matches!( - status, - reqwest::StatusCode::FORBIDDEN | reqwest::StatusCode::NOT_FOUND - ) { - last_error = Some(err); + if status == reqwest::StatusCode::SERVICE_UNAVAILABLE { + first_unavailable.get_or_insert(err); continue; } return Err(err); } - Err(last_error.unwrap_or_else(|| AppError::Provider("Antigravity request failed".into()))) + + for base_url in [SANDBOX_BASE_URL, AUTOPUSH_BASE_URL] { + let request = self + .post_to(base_url, "streamGenerateContent") + .await? + .query(&[("alt", "sse")]) + .header("accept", "text/event-stream") + .json(body); + let response = request + .send() + .await + .map_err(|err| AppError::Network(err.to_string()))?; + if response.status().is_success() { + return Ok(response); + } + if response.status() == reqwest::StatusCode::SERVICE_UNAVAILABLE { + continue; + } + tracing::warn!( + status = %response.status(), + "Antigravity staging stream fallback rejected; preserving primary 503" + ); + } + Err(first_unavailable + .unwrap_or_else(|| AppError::Provider("Antigravity request failed".into()))) } } @@ -399,9 +481,13 @@ fn build_generate_request( model: model.clone(), project: Some(user_data.project_id.clone()), request: wire::VertexGenerateContentRequest { - contents: to_contents(&request.transcript, &model)?, + contents: to_contents(&request.transcript, &model, caps.supports_tools)?, system_instruction: system_instruction(request.system_prompt.as_deref()), - tools: to_tools(&request.tools), + tools: if caps.supports_tools { + to_tools(&request.tools) + } else { + Vec::new() + }, generation_config: Some(generation_config(request, caps, thinking_level)), session_id: request.cache_key.clone(), }, @@ -441,7 +527,7 @@ fn system_instruction(text: Option<&str>) -> Option { fn generation_config( request: &ProviderRequest, - _caps: &ModelCapabilities, + caps: &ModelCapabilities, thinking_level: Option<&'static str>, ) -> wire::GenerationConfig { wire::GenerationConfig { @@ -449,11 +535,15 @@ fn generation_config( top_p: Some(0.95), top_k: Some(64), max_output_tokens: None, - thinking_config: thinking_level.map(|level| wire::ThinkingConfig { - include_thoughts: Some(true), - thinking_budget: None, - thinking_level: Some(level), - }), + thinking_config: if caps.supports_thinking { + thinking_level.map(|level| wire::ThinkingConfig { + include_thoughts: Some(true), + thinking_budget: None, + thinking_level: Some(level), + }) + } else { + None + }, } } @@ -579,8 +669,12 @@ fn unsupported_schema_field(key: &str) -> bool { ) } -fn to_contents(transcript: &[ChatMessage], model: &str) -> Result> { - let mut contents = Vec::new(); +fn to_contents( + transcript: &[ChatMessage], + model: &str, + supports_tools: bool, +) -> Result> { + let mut contents: Vec = Vec::new(); for message in transcript { let role = match message.role { Role::User => "user", @@ -618,7 +712,8 @@ fn to_contents(transcript: &[ChatMessage], model: &str) -> Result Result { + if !supports_tools { + continue; + } let (_, raw_id) = split_tool_id(name, id); parts.push(wire::Part::FunctionCall { function_call: wire::FunctionCall { @@ -645,7 +743,10 @@ fn to_contents(transcript: &[ChatMessage], model: &str) -> Result { - let (name, raw_id) = split_prefixed_tool_id(tool_call_id); + if !supports_tools { + continue; + } + let (name, raw_id) = resolve_tool_result_name_and_id(tool_call_id, transcript); let mut response = json!({ "output": content, }); @@ -687,12 +788,26 @@ fn to_contents(transcript: &[ChatMessage], model: &str) -> Result (String, String) { } fn split_prefixed_tool_id(id: &str) -> (String, String) { - if let Some((name, raw_id)) = id.split_once("__") { + if let Some((name, raw_id)) = id.rsplit_once("__") { if !name.trim().is_empty() && !raw_id.trim().is_empty() { return (name.to_string(), raw_id.to_string()); } @@ -714,6 +829,23 @@ fn split_prefixed_tool_id(id: &str) -> (String, String) { ("generic_tool".into(), id.to_string()) } +fn resolve_tool_result_name_and_id( + tool_call_id: &str, + transcript: &[ChatMessage], +) -> (String, String) { + for message in transcript { + for part in &message.parts { + if let Part::ToolCall { id, name, .. } = part { + if id == tool_call_id { + let (_, raw_id) = split_tool_id(name, id); + return (name.clone(), raw_id); + } + } + } + } + split_prefixed_tool_id(tool_call_id) +} + fn thought_signature(meta: &Option) -> Option { meta.as_ref() .and_then(|meta| { @@ -725,10 +857,8 @@ fn thought_signature(meta: &Option) -> Option { .map(str::to_string) } -fn thought_signature_for_tool_call(meta: &Option, model: &str) -> Option { - thought_signature(meta).or_else(|| { - model_info::is_gemini3_model(model).then(|| "skip_thought_signature_validator".into()) - }) +fn thought_signature_for_tool_call(meta: &Option, _model: &str) -> Option { + thought_signature(meta).or_else(|| Some("skip_thought_signature_validator".into())) } fn model_supports_multimodal_function_response(model: &str) -> bool { @@ -828,7 +958,7 @@ async fn read_http_error(response: reqwest::Response) -> AppError { } else if status == reqwest::StatusCode::TOO_MANY_REQUESTS { AppError::RateLimit(message) } else if status.is_client_error() { - if message.contains("context") || message.contains("token") && message.contains("limit") { + if (message.contains("context") || message.contains("token")) && message.contains("limit") { AppError::ContextLength(message) } else { AppError::InvalidRequest(message) @@ -842,8 +972,19 @@ fn method_url(base_url: &str, method: &str) -> String { format!("{}:{method}", base_url.trim_end_matches('/')) } -fn antigravity_user_agent() -> String { - format!("antigravity/{} darwin/arm64", antigravity_version()) +pub fn antigravity_user_agent() -> String { + let platform = if cfg!(target_os = "windows") { + "windows/amd64" + } else if cfg!(all(target_os = "macos", target_arch = "aarch64")) { + "darwin/arm64" + } else if cfg!(target_os = "macos") { + "darwin/amd64" + } else if cfg!(target_arch = "aarch64") { + "linux/arm64" + } else { + "linux/amd64" + }; + format!("antigravity/{} {platform}", antigravity_version()) } fn antigravity_version() -> String { @@ -855,7 +996,7 @@ fn antigravity_version() -> String { version } -fn antigravity_load_code_assist_user_agent() -> String { +pub fn antigravity_load_code_assist_user_agent() -> String { format!( "{} google-api-nodejs-client/10.3.0", antigravity_user_agent() diff --git a/crates/sinew-google/src/lib.rs b/crates/sinew-google/src/lib.rs index 5641f1e5..2faef670 100644 --- a/crates/sinew-google/src/lib.rs +++ b/crates/sinew-google/src/lib.rs @@ -5,9 +5,10 @@ pub mod stream; pub mod wire; pub use auth::{ - delete_default_auth, exchange_oauth_code, generate_pkce, generate_state, - load_default_auth_status, oauth_authorize_url, purge_legacy_oauth_if_needed, GoogleAuthStatus, - GoogleUserData, PkceCodes, + all_auth_files, delete_default_auth, exchange_oauth_code, generate_pkce, generate_state, + load_auth_status, load_default_auth_status, load_default_user_data, load_user_data, + oauth_authorize_url, path_for_auth_key, purge_legacy_oauth_if_needed, save_user_data, + Credential, GoogleAuthStatus, GoogleUserData, PkceCodes, }; -pub use client::{GoogleConfig, GoogleProvider}; +pub use client::{GoogleConfig, GoogleProvider, antigravity_user_agent, antigravity_load_code_assist_user_agent}; pub use model_info::MODEL_ID; diff --git a/crates/sinew-google/src/model_info.rs b/crates/sinew-google/src/model_info.rs index 63a30d7b..d6fd3cf5 100644 --- a/crates/sinew-google/src/model_info.rs +++ b/crates/sinew-google/src/model_info.rs @@ -41,6 +41,34 @@ const MODELS: &[GoogleModelInfo] = &[ max_output_tokens: GEMINI_MAX_OUTPUT, supports_images: true, }, + GoogleModelInfo { + id: "claude-opus-4.6", + context_window: 200_000, + preferred_window: 180_000, + max_output_tokens: 8192, + supports_images: true, + }, + GoogleModelInfo { + id: "claude-sonnet-4.6", + context_window: 200_000, + preferred_window: 180_000, + max_output_tokens: 8192, + supports_images: true, + }, + GoogleModelInfo { + id: "gpt-oss-120b", + context_window: 128_000, + preferred_window: 100_000, + max_output_tokens: 4096, + supports_images: false, + }, + GoogleModelInfo { + id: "gemini-2.5-pro", + context_window: GEMINI_WINDOW, + preferred_window: 950_000, + max_output_tokens: GEMINI_MAX_OUTPUT, + supports_images: true, + }, ]; fn model_info(model_id: &str) -> &'static GoogleModelInfo { @@ -69,24 +97,24 @@ pub fn antigravity_model_and_thinking( let base = canonical_model(model).name; let requested = effort.or(model.effort).unwrap_or(Effort::High); let is_pro = is_gemini_pro_model(&base); - let thinking_level = match requested { + let (thinking_level, model_suffix) = match requested { // Antigravity's pro models do not accept `minimal`; clamp them to low. Effort::None => { if is_pro { - "low" + ("LOW", "low") } else { - "minimal" + ("MINIMAL", "minimal") } } - Effort::Low => "low", - Effort::Medium => "medium", - Effort::High | Effort::Xhigh | Effort::Max => "high", + Effort::Low => ("LOW", "low"), + Effort::Medium => ("MEDIUM", "medium"), + Effort::High | Effort::Xhigh | Effort::Max => ("HIGH", "high"), }; - // Antigravity exposes 3.5-flash uniquement sous l'ID `gemini-3.5-flash-low`. - // Le thinkingLevel reste libre, mais l'ID modèle est figé. + // Gemini 3.5 Flash on Antigravity is routed through the agent variant. + // The `thinkingLevel` still carries LOW/MEDIUM/HIGH quota intent. if base == "gemini-3.5-flash" { - return ("gemini-3.5-flash-low".into(), Some(thinking_level)); + return ("gemini-3-flash-agent".into(), Some(thinking_level)); } // Gemini 3.1 Pro on Antigravity is always routed to the agentic variant // (`gemini-pro-agent`), which is the fine-tuned artefact for tool use and @@ -94,8 +122,20 @@ pub fn antigravity_model_and_thinking( if base == "gemini-3.1-pro" { return ("gemini-pro-agent".into(), Some(thinking_level)); } + // Claude Opus 4.6 uses a specific thinking ID on the server. + if base == "claude-opus-4.6" { + return ("claude-opus-4-6-thinking".into(), Some(thinking_level)); + } + // Claude Sonnet 4.6 on the server does not have dots. + if base == "claude-sonnet-4.6" { + return ("claude-sonnet-4-6".into(), Some(thinking_level)); + } + // GPT-OSS 120B on the server is called gpt-oss-120b-medium. + if base == "gpt-oss-120b" { + return ("gpt-oss-120b-medium".into(), Some(thinking_level)); + } if is_pro { - (format!("{base}-{thinking_level}"), Some(thinking_level)) + (format!("{base}-{model_suffix}"), Some(thinking_level)) } else { (base, Some(thinking_level)) } @@ -104,14 +144,17 @@ pub fn antigravity_model_and_thinking( pub fn capabilities(model: &ModelRef) -> ModelCapabilities { let model = canonical_model(model); let info = model_info(&model.name); + let is_gpt_oss = model.name == "gpt-oss-120b"; + let supports_tools = !is_gpt_oss; + let supports_thinking = !is_gpt_oss; ModelCapabilities { model, context_window: info.context_window, preferred_window: info.preferred_window, max_output_tokens: info.max_output_tokens, - supports_thinking: true, - visible_thinking: true, - supports_tools: true, + supports_thinking, + visible_thinking: supports_thinking, + supports_tools, supports_images: info.supports_images, effort_mode: EffortMode::Tier, } diff --git a/crates/sinew-index/Cargo.toml b/crates/sinew-index/Cargo.toml new file mode 100644 index 00000000..f9959672 --- /dev/null +++ b/crates/sinew-index/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "sinew-index" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Local codebase index for Sinew (FTS chunks, incremental sync)" + +[dependencies] +anyhow = { workspace = true } +regex = { workspace = true } +directories = { workspace = true } +fastembed = "4" +notify = { workspace = true } +ignore = { workspace = true } +rayon = { workspace = true } +rusqlite = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +tracing = { workspace = true } +walkdir = { workspace = true } + +[lints] +workspace = true diff --git a/crates/sinew-index/src/background.rs b/crates/sinew-index/src/background.rs new file mode 100644 index 00000000..07cf9af8 --- /dev/null +++ b/crates/sinew-index/src/background.rs @@ -0,0 +1,128 @@ +use std::{ + collections::HashSet, + path::{Path, PathBuf}, + process::{Command, Stdio}, + sync::{Mutex, OnceLock}, + thread, + time::Duration, +}; + +use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher}; + +use crate::indexer::sync_changed_paths; + +static ACTIVE: OnceLock>> = OnceLock::new(); + +pub fn start_background_indexing(workspace_root: PathBuf) { + if !background_indexing_enabled() { + return; + } + + let key = workspace_root.display().to_string(); + let active = ACTIVE.get_or_init(|| Mutex::new(HashSet::new())); + let mut guard = match active.lock() { + Ok(guard) => guard, + Err(_) => return, + }; + if !guard.insert(key) { + return; + } + drop(guard); + + if crate::process::process_isolation_enabled() + && !crate::process::helper_child() + && spawn_watch_helper(&workspace_root).is_ok() + { + return; + } + + thread::spawn(move || run_background_indexing_loop(workspace_root, None)); +} + +pub(crate) fn run_background_indexing_loop(workspace_root: PathBuf, parent_pid: Option) { + let (tx, rx) = std::sync::mpsc::channel::>(); + let watch_root = workspace_root.clone(); + let mut watcher = match RecommendedWatcher::new( + move |result: notify::Result| { + if let Ok(event) = result { + if is_indexable_event(&event.kind) && !event.paths.is_empty() { + let _ = tx.send(event.paths); + } + } + }, + notify::Config::default(), + ) { + Ok(watcher) => watcher, + Err(_) => return, + }; + if watcher + .watch(&watch_root, RecursiveMode::Recursive) + .is_err() + { + return; + } + + loop { + if !crate::process::parent_is_alive(parent_pid) { + break; + } + match rx.recv_timeout(Duration::from_secs(3)) { + Ok(paths) => { + let mut changed = paths; + while let Ok(paths) = rx.recv_timeout(Duration::from_millis(400)) { + changed.extend(paths); + } + let _ = sync_changed_paths(&workspace_root, changed); + } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, + } + } +} + +fn is_indexable_event(kind: &EventKind) -> bool { + matches!( + kind, + EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) | EventKind::Any + ) +} + +fn background_indexing_enabled() -> bool { + !matches!( + std::env::var("SINEW_INDEX_BACKGROUND") + .unwrap_or_else(|_| "1".to_string()) + .trim() + .to_ascii_lowercase() + .as_str(), + "0" | "false" | "off" | "no" + ) +} + +fn spawn_watch_helper(workspace_root: &Path) -> std::io::Result<()> { + let mut command = Command::new(std::env::current_exe()?); + command + .arg("--sinew-helper") + .arg("codebase-index-watch") + .arg(workspace_root) + .arg(std::process::id().to_string()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .env("SINEW_INDEX_HELPER_CHILD", "1") + .env_remove("SINEW_INDEX_EMBEDDINGS"); + hide_helper_window(&mut command); + command.spawn().map(|_| ()) +} + +#[cfg(windows)] +fn hide_helper_window(command: &mut Command) { + use std::os::windows::process::CommandExt; + command.creation_flags(0x08000000); +} + +#[cfg(not(windows))] +fn hide_helper_window(_command: &mut Command) {} + +pub fn warm_workspace_index(workspace_root: &Path) { + start_background_indexing(workspace_root.to_path_buf()); +} diff --git a/crates/sinew-index/src/chunk.rs b/crates/sinew-index/src/chunk.rs new file mode 100644 index 00000000..cd6bfa91 --- /dev/null +++ b/crates/sinew-index/src/chunk.rs @@ -0,0 +1,141 @@ +use std::path::Path; + +use regex::Regex; + +#[derive(Debug, Clone)] +pub struct FileChunk { + pub start_line: i64, + pub end_line: i64, + pub content: String, + pub embedding: Option>, +} + +const CHUNK_LINES: usize = 80; +const CHUNK_OVERLAP: usize = 12; +const MAX_CHUNK_CHARS: usize = 12_000; +const MIN_SYMBOL_LINES: usize = 4; +const MAX_SYMBOL_LINES: usize = 120; + +pub fn chunk_file_content(content: &str, relative_path: &str) -> Vec { + let lines: Vec<&str> = content.lines().collect(); + if lines.is_empty() { + return Vec::new(); + } + if let Some(chunks) = chunk_by_symbols(&lines, relative_path) { + if !chunks.is_empty() { + return chunks; + } + } + chunk_by_lines(&lines) +} + +fn chunk_by_symbols(lines: &[&str], relative_path: &str) -> Option> { + let ext = Path::new(relative_path) + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + let pattern = symbol_pattern(&ext)?; + let regex = Regex::new(pattern).ok()?; + let mut starts = Vec::new(); + for (index, line) in lines.iter().enumerate() { + if regex.is_match(line) { + starts.push(index); + } + } + if starts.is_empty() { + return None; + } + let mut chunks = Vec::new(); + for (index, start) in starts.iter().enumerate() { + let next = starts.get(index + 1).copied().unwrap_or(lines.len()); + let mut end = next; + if end.saturating_sub(*start) > MAX_SYMBOL_LINES { + end = (*start + MAX_SYMBOL_LINES).min(lines.len()); + } + if end.saturating_sub(*start) < MIN_SYMBOL_LINES && index + 1 < starts.len() { + continue; + } + push_chunk(&mut chunks, lines, *start, end); + } + if chunks.is_empty() { + None + } else { + Some(chunks) + } +} + +fn symbol_pattern(ext: &str) -> Option<&'static str> { + match ext { + "rs" => Some( + r"^\s*(pub(\([^)]*\))?\s+)?(async\s+)?(fn|struct|enum|trait|impl|mod|type|const|static|macro_rules!)\b", + ), + "ts" | "tsx" | "js" | "jsx" => { + Some(r"^\s*(export\s+)?(async\s+)?(function|class|interface|type|enum|const)\b") + } + "py" => Some(r"^\s*(async\s+)?(def|class)\b"), + "go" => Some(r"^\s*func(\s|\()"), + "java" | "kt" => Some( + r"^\s*(public|private|protected|internal|data|sealed|open|abstract|class|interface|enum|fun)\b", + ), + "cs" => Some( + r"^\s*(public|private|protected|internal|static|class|interface|struct|enum|record)\b", + ), + _ => None, + } +} + +fn chunk_by_lines(lines: &[&str]) -> Vec { + let mut chunks = Vec::new(); + let mut start = 0usize; + while start < lines.len() { + let end = (start + CHUNK_LINES).min(lines.len()); + push_chunk(&mut chunks, lines, start, end); + if end >= lines.len() { + break; + } + start = end.saturating_sub(CHUNK_OVERLAP); + } + chunks +} + +fn push_chunk(chunks: &mut Vec, lines: &[&str], start: usize, end: usize) { + let body = lines[start..end].join("\n"); + if body.trim().is_empty() { + return; + } + let content = if body.len() > MAX_CHUNK_CHARS { + body.chars().take(MAX_CHUNK_CHARS).collect::() + } else { + body + }; + chunks.push(FileChunk { + start_line: (start + 1) as i64, + end_line: end as i64, + content, + embedding: None, + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chunks_rust_symbols_when_present() { + let content = "fn alpha() {}\n\npub fn beta() {\n let x = 1;\n}\n\nstruct Gamma;\n"; + let chunks = chunk_file_content(content, "src/lib.rs"); + assert!(chunks.len() >= 2); + assert!(chunks.iter().any(|chunk| chunk.content.contains("beta"))); + } + + #[test] + fn chunks_overlap_for_long_files_without_symbols() { + let content = (1..=200) + .map(|line| format!("// line {line}")) + .collect::>() + .join("\n"); + let chunks = chunk_file_content(&content, "notes.txt"); + assert!(chunks.len() > 1); + } +} diff --git a/crates/sinew-index/src/embeddings.rs b/crates/sinew-index/src/embeddings.rs new file mode 100644 index 00000000..addd24f8 --- /dev/null +++ b/crates/sinew-index/src/embeddings.rs @@ -0,0 +1,101 @@ +use std::sync::{Mutex, OnceLock}; + +use anyhow::{Context, Result}; +use fastembed::{EmbeddingModel, InitOptions, TextEmbedding}; + +static EMBEDDER: OnceLock>> = OnceLock::new(); + +pub fn is_available() -> bool { + if std::env::var_os("SINEW_INDEX_EMBEDDINGS").is_none() { + return false; + } + embedder().is_ok() +} + +pub fn embed_query(text: &str) -> Result> { + let model = embedder()?; + let mut guard = model + .lock() + .map_err(|_| anyhow::anyhow!("embedding model lock poisoned"))?; + let embedder = guard.as_mut().context("embedding model unavailable")?; + let prefixed = format!("query: {}", text.trim()); + let vectors = embedder + .embed(vec![prefixed], None) + .context("unable to embed query")?; + vectors + .into_iter() + .next() + .context("embedding model returned no vector") +} + +pub fn embed_passages(texts: &[String]) -> Result>> { + if texts.is_empty() { + return Ok(Vec::new()); + } + let model = embedder()?; + let mut guard = model + .lock() + .map_err(|_| anyhow::anyhow!("embedding model lock poisoned"))?; + let embedder = guard.as_mut().context("embedding model unavailable")?; + let prefixed = texts + .iter() + .map(|text| format!("passage: {}", text.trim())) + .collect::>(); + embedder + .embed(prefixed, None) + .context("unable to embed passages") +} + +pub fn cosine_similarity(left: &[f32], right: &[f32]) -> f32 { + if left.len() != right.len() || left.is_empty() { + return 0.0; + } + let mut dot = 0.0f32; + let mut left_norm = 0.0f32; + let mut right_norm = 0.0f32; + for (a, b) in left.iter().zip(right.iter()) { + dot += a * b; + left_norm += a * a; + right_norm += b * b; + } + if left_norm <= f32::EPSILON || right_norm <= f32::EPSILON { + return 0.0; + } + dot / (left_norm.sqrt() * right_norm.sqrt()) +} + +pub fn vector_to_bytes(values: &[f32]) -> Vec { + values + .iter() + .flat_map(|value| value.to_le_bytes()) + .collect() +} + +pub fn bytes_to_vector(bytes: &[u8]) -> Vec { + bytes + .chunks_exact(4) + .map(|chunk| f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])) + .collect() +} + +fn embedder() -> Result<&'static Mutex>> { + let slot = EMBEDDER.get_or_init(|| { + let mut options = InitOptions::new(EmbeddingModel::BGESmallENV15); + if let Some(proj_dirs) = directories::ProjectDirs::from("dev", "hyrak", "sinew") { + let cache_dir = proj_dirs.cache_dir().join("fastembed_cache"); + let _ = std::fs::create_dir_all(&cache_dir); + options = options.with_cache_dir(cache_dir); + } + let model = TextEmbedding::try_new(options).ok(); + Mutex::new(model) + }); + if slot + .lock() + .ok() + .and_then(|guard| guard.as_ref().map(|_| ())) + .is_none() + { + anyhow::bail!("local embedding model is unavailable"); + } + Ok(slot) +} diff --git a/crates/sinew-index/src/indexer.rs b/crates/sinew-index/src/indexer.rs new file mode 100644 index 00000000..d8864458 --- /dev/null +++ b/crates/sinew-index/src/indexer.rs @@ -0,0 +1,443 @@ +use std::{ + collections::{HashMap, HashSet}, + fs, + io::Read, + path::{Component, Path, PathBuf}, + time::SystemTime, +}; + +use anyhow::Result; +use ignore::gitignore::{Gitignore, GitignoreBuilder}; +use rayon::prelude::*; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use walkdir::WalkDir; + +use crate::{ + chunk::chunk_file_content, + store::{FileSignature, IndexFileData, IndexFileMetadata, IndexStore}, + SKIP_DIRS, TEXT_EXTENSIONS, +}; + +fn load_gitignore(workspace_root: &Path) -> Gitignore { + let mut builder = GitignoreBuilder::new(workspace_root); + + let gitignore_path = workspace_root.join(".gitignore"); + if gitignore_path.exists() { + let _ = builder.add(&gitignore_path); + } + + let cursorignore_path = workspace_root.join(".cursorignore"); + if cursorignore_path.exists() { + let _ = builder.add(&cursorignore_path); + } + + let sinewignore_path = workspace_root.join(".sinewignore"); + if sinewignore_path.exists() { + let _ = builder.add(&sinewignore_path); + } + + let _ = builder.add_line(None, ".sinew/worktrees/"); + + builder.build().unwrap_or_else(|_| Gitignore::empty()) +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct IndexStats { + pub files_indexed: usize, + pub chunks_indexed: usize, + pub files_updated: usize, + pub embeddings_backfilled: usize, +} + +#[derive(Debug, Clone)] +struct FileCandidate { + path: PathBuf, + relative: String, + mtime_ms: i64, + size_bytes: i64, +} + +#[derive(Debug)] +enum PreparedIndexChange { + Unchanged, + Touch(IndexFileMetadata), + Replace(IndexFileData), + Remove(String), +} + +pub fn ensure_workspace_index(workspace_root: &Path) -> Result { + let gitignore = load_gitignore(workspace_root); + let store = IndexStore::open(workspace_root)?; + let mut stats = IndexStats::default(); + let signatures = store.file_signatures()?; + let candidates = collect_workspace_candidates(workspace_root, workspace_root, None, &gitignore); + let seen = candidates + .iter() + .map(|candidate| candidate.relative.clone()) + .collect::>(); + + let changes = prepare_index_changes(&candidates, &signatures)?; + apply_prepared_changes(&store, changes, &mut stats)?; + + let stale_paths = store + .list_files()? + .into_iter() + .filter(|path| !seen.contains(path)) + .collect::>(); + store.remove_files(&stale_paths)?; + + let (files, chunks) = store.stats()?; + stats.files_indexed = files; + stats.chunks_indexed = chunks; + stats.embeddings_backfilled = backfill_missing_embeddings(&store)?; + Ok(stats) +} + +pub fn sync_changed_paths( + workspace_root: &Path, + paths: impl IntoIterator, +) -> Result { + const MAX_DIRECTORY_FILES_PER_EVENT: usize = 256; + + let gitignore = load_gitignore(workspace_root); + let store = IndexStore::open(workspace_root)?; + let mut stats = IndexStats::default(); + let mut unique = HashSet::::new(); + let mut candidates = Vec::::new(); + let mut removals = HashSet::::new(); + + for path in paths { + let path = normalize_absolute_path(&path); + if !is_under_workspace(workspace_root, &path) || should_skip_entry(&path, workspace_root, &gitignore) { + continue; + } + + if path.is_dir() { + for candidate in collect_workspace_candidates( + workspace_root, + &path, + Some(MAX_DIRECTORY_FILES_PER_EVENT), + &gitignore, + ) { + if unique.insert(candidate.relative.clone()) { + candidates.push(candidate); + } + } + continue; + } + + let relative = normalize_relative_path(workspace_root, &path); + if !unique.insert(relative.clone()) { + continue; + } + + if path.exists() && is_text_candidate(&path) { + if let Some(candidate) = candidate_from_path(workspace_root, &path) { + candidates.push(candidate); + } + } else { + removals.insert(relative); + } + } + + let signatures = store.file_signatures()?; + let changes = prepare_index_changes(&candidates, &signatures)?; + apply_prepared_changes(&store, changes, &mut stats)?; + + let removals = removals.into_iter().collect::>(); + stats.files_updated += store.remove_files(&removals)?; + + let (files, chunks) = store.stats()?; + stats.files_indexed = files; + stats.chunks_indexed = chunks; + Ok(stats) +} + +pub fn index_stats(workspace_root: &Path) -> Result { + let store = IndexStore::open(workspace_root)?; + let (files, chunks) = store.stats()?; + Ok(IndexStats { + files_indexed: files, + chunks_indexed: chunks, + files_updated: 0, + embeddings_backfilled: 0, + }) +} + +fn collect_workspace_candidates( + workspace_root: &Path, + scan_root: &Path, + max_files: Option, + gitignore: &Gitignore, +) -> Vec { + let mut candidates = Vec::new(); + + for entry in WalkDir::new(scan_root) + .follow_links(false) + .into_iter() + .filter_entry(|entry| !should_skip_entry(entry.path(), workspace_root, gitignore)) + { + if max_files.is_some_and(|max| candidates.len() >= max) { + break; + } + + let entry = match entry { + Ok(entry) => entry, + Err(_) => continue, + }; + if !entry.file_type().is_file() || !is_text_candidate(entry.path()) { + continue; + } + if let Some(candidate) = candidate_from_path(workspace_root, entry.path()) { + candidates.push(candidate); + } + } + + candidates +} + +fn candidate_from_path(workspace_root: &Path, path: &Path) -> Option { + let metadata = fs::metadata(path).ok()?; + if !metadata.is_file() { + return None; + } + let mtime_ms = metadata + .modified() + .ok() + .and_then(system_time_to_ms) + .unwrap_or(0); + Some(FileCandidate { + path: path.to_path_buf(), + relative: normalize_relative_path(workspace_root, path), + mtime_ms, + size_bytes: metadata.len().min(i64::MAX as u64) as i64, + }) +} + +fn prepare_index_changes( + candidates: &[FileCandidate], + signatures: &HashMap, +) -> Result> { + let prepared = candidates + .par_iter() + .map(|candidate| prepare_index_change(candidate, signatures.get(&candidate.relative))) + .collect::>(); + prepared.into_iter().collect() +} + +fn prepare_index_change( + candidate: &FileCandidate, + existing: Option<&FileSignature>, +) -> Result { + if existing + .map(|signature| { + signature.mtime_ms == candidate.mtime_ms && signature.size_bytes == candidate.size_bytes + }) + .unwrap_or(false) + { + return Ok(PreparedIndexChange::Unchanged); + } + + if candidate.size_bytes > MAX_TEXT_FILE_BYTES as i64 { + return Ok(PreparedIndexChange::Remove(candidate.relative.clone())); + } + + let content = match read_text_file_limited(&candidate.path) { + Ok(content) => content, + Err(_) => return Ok(PreparedIndexChange::Remove(candidate.relative.clone())), + }; + let hash = sha256_hex(content.as_bytes()); + + if existing + .map(|signature| signature.content_hash.as_str() == hash.as_str()) + .unwrap_or(false) + { + return Ok(PreparedIndexChange::Touch(IndexFileMetadata { + path: candidate.relative.clone(), + content_hash: hash, + mtime_ms: candidate.mtime_ms, + size_bytes: candidate.size_bytes, + })); + } + + Ok(PreparedIndexChange::Replace(IndexFileData { + path: candidate.relative.clone(), + content_hash: hash, + mtime_ms: candidate.mtime_ms, + size_bytes: candidate.size_bytes, + chunks: chunk_file_content(&content, &candidate.relative), + })) +} + +fn apply_prepared_changes( + store: &IndexStore, + changes: Vec, + stats: &mut IndexStats, +) -> Result<()> { + let mut replacements = Vec::new(); + let mut metadata_updates = Vec::new(); + let mut removals = Vec::new(); + + for change in changes { + match change { + PreparedIndexChange::Unchanged => {} + PreparedIndexChange::Touch(metadata) => metadata_updates.push(metadata), + PreparedIndexChange::Replace(file) => replacements.push(file), + PreparedIndexChange::Remove(path) => removals.push(path), + } + } + + store.replace_files(&replacements)?; + stats.files_updated += replacements.len(); + store.touch_file_metadata_batch(&metadata_updates)?; + stats.files_updated += store.remove_files(&removals)?; + Ok(()) +} + +fn should_skip_entry(path: &Path, workspace_root: &Path, gitignore: &Gitignore) -> bool { + if path == workspace_root { + return false; + } + let base_skip = path.components().any(|component| { + matches!(component, Component::Normal(name) if SKIP_DIRS.contains(&name.to_string_lossy().as_ref())) + }); + if base_skip { + return true; + } + let is_dir = path.is_dir(); + gitignore.matched(path, is_dir).is_ignore() +} + +fn normalize_relative_path(workspace_root: &Path, path: &Path) -> String { + path.strip_prefix(workspace_root) + .unwrap_or(path) + .display() + .to_string() + .replace('\\', "/") +} + +fn normalize_absolute_path(path: &Path) -> PathBuf { + path.to_path_buf() +} + +fn is_under_workspace(workspace_root: &Path, path: &Path) -> bool { + let root = normalize_absolute_path(workspace_root); + path.starts_with(&root) || path.starts_with(workspace_root) +} + +fn is_text_candidate(path: &Path) -> bool { + let Some(ext) = path.extension().and_then(|value| value.to_str()) else { + return false; + }; + TEXT_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()) +} + +const MAX_TEXT_FILE_BYTES: u64 = 512 * 1024; + +fn read_text_file_limited(path: &Path) -> Result { + let metadata = fs::metadata(path)?; + if metadata.len() > MAX_TEXT_FILE_BYTES { + anyhow::bail!("file too large"); + } + let mut file = fs::File::open(path)?; + let mut buffer = Vec::with_capacity(metadata.len().min(MAX_TEXT_FILE_BYTES) as usize); + file.read_to_end(&mut buffer)?; + if buffer.iter().take(8192).any(|byte| *byte == 0) { + anyhow::bail!("binary file"); + } + Ok(String::from_utf8_lossy(&buffer).into_owned()) +} + +fn sha256_hex(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + digest.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn system_time_to_ms(value: SystemTime) -> Option { + value + .duration_since(SystemTime::UNIX_EPOCH) + .ok() + .map(|duration| duration.as_millis().min(i64::MAX as u128) as i64) +} + +const EMBEDDING_BACKFILL_BATCH: usize = 16; +const EMBEDDING_BACKFILL_MAX_PER_RUN: usize = 128; + +fn backfill_missing_embeddings(store: &IndexStore) -> Result { + if !crate::embeddings::is_available() { + return Ok(0); + } + let pending = store.list_chunks_without_embedding(EMBEDDING_BACKFILL_MAX_PER_RUN)?; + if pending.is_empty() { + return Ok(0); + } + let mut updated = 0usize; + for batch in pending.chunks(EMBEDDING_BACKFILL_BATCH) { + let texts = batch + .iter() + .map(|(_, content)| content.clone()) + .collect::>(); + let Ok(vectors) = crate::embeddings::embed_passages(&texts) else { + break; + }; + for ((chunk_id, _), vector) in batch.iter().zip(vectors) { + store.update_chunk_embedding(*chunk_id, &vector)?; + updated += 1; + } + } + Ok(updated) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sync_changed_paths_updates_and_removes_one_file() { + let dir = std::env::temp_dir().join(format!("sinew-index-sync-test-{}", unique_id())); + fs::create_dir_all(&dir).unwrap(); + let file = dir.join("alpha.rs"); + fs::write(&file, "pub fn first_name() {}\n").unwrap(); + + let stats = sync_changed_paths(&dir, vec![file.clone()]).unwrap(); + assert_eq!(stats.files_indexed, 1); + assert_eq!(stats.files_updated, 1); + + fs::write(&file, "pub fn second_name() {}\n").unwrap(); + let stats = sync_changed_paths(&dir, vec![file.clone()]).unwrap(); + assert_eq!(stats.files_indexed, 1); + assert_eq!(stats.files_updated, 1); + + fs::remove_file(&file).unwrap(); + let stats = sync_changed_paths(&dir, vec![file]).unwrap(); + assert_eq!(stats.files_indexed, 0); + assert_eq!(stats.files_updated, 1); + + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn unchanged_files_are_skipped_after_initial_index() { + let dir = std::env::temp_dir().join(format!("sinew-index-skip-test-{}", unique_id())); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("alpha.rs"), "pub fn stable_name() {}\n").unwrap(); + + let first = ensure_workspace_index(&dir).unwrap(); + assert_eq!(first.files_updated, 1); + + let second = ensure_workspace_index(&dir).unwrap(); + assert_eq!(second.files_indexed, 1); + assert_eq!(second.files_updated, 0); + + let _ = fs::remove_dir_all(dir); + } + + fn unique_id() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() as u64 + } +} + diff --git a/crates/sinew-index/src/lib.rs b/crates/sinew-index/src/lib.rs new file mode 100644 index 00000000..46be547b --- /dev/null +++ b/crates/sinew-index/src/lib.rs @@ -0,0 +1,38 @@ +mod background; +mod chunk; +mod embeddings; +mod indexer; +mod process; +mod search; +mod store; + +pub use background::{start_background_indexing, warm_workspace_index}; +pub use indexer::{ensure_workspace_index, index_stats, sync_changed_paths, IndexStats}; +pub use process::{ + ensure_workspace_index_isolated, index_and_search_workspace_isolated, index_stats_isolated, + process_isolation_enabled, run_helper_if_requested, +}; +pub fn semantic_search_enabled() -> bool { + embeddings::is_available() +} + +pub use embeddings::is_available as embeddings_available; +pub use search::{search_workspace, CodebaseHit}; + +const SKIP_DIRS: &[&str] = &[ + ".git", + "node_modules", + "target", + "dist", + "build", + ".next", + ".turbo", + "__pycache__", + ".sinew", +]; + +const TEXT_EXTENSIONS: &[&str] = &[ + "rs", "ts", "tsx", "js", "jsx", "py", "go", "java", "kt", "cs", "cpp", "c", "h", "hpp", "md", + "txt", "json", "yaml", "yml", "toml", "sql", "html", "css", "scss", "vue", "svelte", "sh", + "ps1", "rb", "swift", "dart", "lua", "zig", "xml", "ini", "cfg", "env", +]; diff --git a/crates/sinew-index/src/process.rs b/crates/sinew-index/src/process.rs new file mode 100644 index 00000000..793c700c --- /dev/null +++ b/crates/sinew-index/src/process.rs @@ -0,0 +1,464 @@ +use std::{ + env, + io::{Read, Write}, + path::{Path, PathBuf}, + process::{Command, Stdio}, +}; + +use anyhow::{bail, Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::{ + background, ensure_workspace_index, index_stats, search_workspace, CodebaseHit, IndexStats, +}; + +const HELPER_ARG: &str = "--sinew-helper"; +const REQUEST_HELPER: &str = "codebase-index"; +const WATCH_HELPER: &str = "codebase-index-watch"; +const CHILD_ENV: &str = "SINEW_INDEX_HELPER_CHILD"; +const EMBEDDINGS_ENV: &str = "SINEW_INDEX_EMBEDDINGS"; +const ISOLATION_ENV: &str = "SINEW_INDEX_PROCESS_ISOLATION"; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "command", rename_all = "snake_case")] +enum HelperRequest { + Ensure { + workspace_root: String, + }, + IndexAndSearch { + workspace_root: String, + query: String, + path_prefix: Option, + limit: usize, + }, + Stats { + workspace_root: String, + }, +} + +#[derive(Debug, Serialize, Deserialize)] +struct HelperResponse { + ok: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + stats: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + hits: Option>, +} + +impl HelperResponse { + fn ok(stats: Option, hits: Option>) -> Self { + Self { + ok: true, + error: None, + stats, + hits, + } + } + + fn err(error: impl Into) -> Self { + Self { + ok: false, + error: Some(error.into()), + stats: None, + hits: None, + } + } +} + +pub fn run_helper_if_requested() -> bool { + let mut args = env::args_os(); + let _exe = args.next(); + let Some(flag) = args.next() else { + return false; + }; + if flag != HELPER_ARG { + return false; + } + + apply_memory_limit_to_current_process(); + + env::set_var(CHILD_ENV, "1"); + + let code = match args.next().and_then(|value| value.into_string().ok()) { + Some(kind) if kind == REQUEST_HELPER => { + env::set_var(EMBEDDINGS_ENV, "1"); + run_request_helper() + } + Some(kind) if kind == WATCH_HELPER => { + env::remove_var(EMBEDDINGS_ENV); + run_watch_helper(args.collect()) + } + Some(other) => { + eprintln!("unknown Sinew helper: {other}"); + 2 + } + None => { + eprintln!("missing Sinew helper name"); + 2 + } + }; + std::process::exit(code); +} + +pub fn process_isolation_enabled() -> bool { + !matches!( + env::var(ISOLATION_ENV) + .unwrap_or_else(|_| "1".to_string()) + .trim() + .to_ascii_lowercase() + .as_str(), + "0" | "false" | "off" | "no" + ) +} + +pub fn helper_child() -> bool { + env::var_os(CHILD_ENV).is_some() +} + +pub fn ensure_workspace_index_isolated(workspace_root: &Path) -> Result { + if should_use_process_helper() { + if let Ok(response) = request_helper(&HelperRequest::Ensure { + workspace_root: workspace_root.display().to_string(), + }) { + return response_to_stats(response); + } + } + ensure_workspace_index(workspace_root) +} + +pub fn index_stats_isolated(workspace_root: &Path) -> Result { + index_stats(workspace_root) +} + +pub fn index_and_search_workspace_isolated( + workspace_root: &Path, + query: &str, + path_prefix: Option<&str>, + limit: usize, +) -> Result<(IndexStats, Vec)> { + if should_use_process_helper() { + if let Ok(response) = request_helper(&HelperRequest::IndexAndSearch { + workspace_root: workspace_root.display().to_string(), + query: query.to_string(), + path_prefix: path_prefix.map(str::to_string), + limit, + }) { + return response_to_search(response); + } + } + + let stats = ensure_workspace_index(workspace_root)?; + let hits = search_workspace(workspace_root, query, path_prefix, limit)?; + Ok((stats, hits)) +} + +pub(crate) fn parent_is_alive(parent_pid: Option) -> bool { + match parent_pid { + Some(pid) => process_is_alive(pid), + None => true, + } +} + +fn should_use_process_helper() -> bool { + process_isolation_enabled() && !helper_child() +} + +fn request_helper(request: &HelperRequest) -> Result { + let enable_embeddings = matches!(request, HelperRequest::IndexAndSearch { .. }); + let mut command = helper_command(enable_embeddings)?; + command + .arg(REQUEST_HELPER) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = command + .spawn() + .context("unable to spawn Sinew codebase index helper")?; + { + let mut stdin = child + .stdin + .take() + .context("Sinew codebase index helper stdin unavailable")?; + let payload = serde_json::to_vec(request)?; + stdin.write_all(&payload)?; + } + + let output = child.wait_with_output()?; + if output.stdout.is_empty() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + bail!("Sinew codebase index helper returned no output: {stderr}"); + } + let response: HelperResponse = serde_json::from_slice(&output.stdout) + .context("invalid Sinew codebase index helper response")?; + if !output.status.success() && !response.ok { + bail!( + "Sinew codebase index helper failed: {}", + response.error.unwrap_or_else(|| "unknown error".into()) + ); + } + Ok(response) +} + +fn helper_command(enable_embeddings: bool) -> Result { + let exe = env::current_exe().context("unable to resolve current Sinew executable")?; + let mut command = Command::new(exe); + command.arg(HELPER_ARG).env(CHILD_ENV, "1"); + if enable_embeddings { + command.env(EMBEDDINGS_ENV, "1"); + } else { + command.env_remove(EMBEDDINGS_ENV); + } + hide_helper_window(&mut command); + Ok(command) +} + +fn run_request_helper() -> i32 { + let mut input = String::new(); + if let Err(err) = std::io::stdin().read_to_string(&mut input) { + return write_response(HelperResponse::err(format!( + "unable to read helper request: {err}" + ))); + } + let request: HelperRequest = match serde_json::from_str(&input) { + Ok(request) => request, + Err(err) => { + return write_response(HelperResponse::err(format!( + "invalid helper request: {err}" + ))) + } + }; + let response = match handle_request(request) { + Ok(response) => response, + Err(err) => HelperResponse::err(err.to_string()), + }; + write_response(response) +} + +fn run_watch_helper(args: Vec) -> i32 { + let Some(workspace_root) = args.first().map(PathBuf::from) else { + eprintln!("missing workspace root for codebase index helper"); + return 2; + }; + let parent_pid = args + .get(1) + .and_then(|value| value.to_str()) + .and_then(|value| value.parse::().ok()); + background::run_background_indexing_loop(workspace_root, parent_pid); + 0 +} + +fn handle_request(request: HelperRequest) -> Result { + match request { + HelperRequest::Ensure { workspace_root } => { + let stats = ensure_workspace_index(Path::new(&workspace_root))?; + Ok(HelperResponse::ok(Some(stats), None)) + } + HelperRequest::IndexAndSearch { + workspace_root, + query, + path_prefix, + limit, + } => { + let root = Path::new(&workspace_root); + let stats = ensure_workspace_index(root)?; + let hits = search_workspace(root, &query, path_prefix.as_deref(), limit)?; + Ok(HelperResponse::ok(Some(stats), Some(hits))) + } + HelperRequest::Stats { workspace_root } => { + let stats = index_stats(Path::new(&workspace_root))?; + Ok(HelperResponse::ok(Some(stats), None)) + } + } +} + +fn response_to_stats(response: HelperResponse) -> Result { + if !response.ok { + bail!(response + .error + .unwrap_or_else(|| "index helper failed".into())); + } + response.stats.context("index helper returned no stats") +} + +fn response_to_search(response: HelperResponse) -> Result<(IndexStats, Vec)> { + if !response.ok { + bail!(response + .error + .unwrap_or_else(|| "index helper failed".into())); + } + let stats = response.stats.context("index helper returned no stats")?; + Ok((stats, response.hits.unwrap_or_default())) +} + +fn write_response(response: HelperResponse) -> i32 { + let is_ok = response.ok; + match serde_json::to_vec(&response) { + Ok(bytes) => { + let _ = std::io::stdout().write_all(&bytes); + let _ = std::io::stdout().write_all(b"\n"); + } + Err(err) => { + let _ = writeln!(std::io::stderr(), "unable to write helper response: {err}"); + return 1; + } + } + if is_ok { + 0 + } else { + 1 + } +} + +#[cfg(windows)] +fn hide_helper_window(command: &mut Command) { + use std::os::windows::process::CommandExt; + command.creation_flags(0x08000000); +} + +#[cfg(not(windows))] +fn hide_helper_window(_command: &mut Command) {} + +#[cfg(windows)] +fn process_is_alive(pid: u32) -> bool { + use std::ffi::c_void; + + type Handle = *mut c_void; + const SYNCHRONIZE: u32 = 0x0010_0000; + const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x0000_1000; + const WAIT_TIMEOUT: u32 = 0x0000_0102; + + extern "system" { + fn OpenProcess(dwDesiredAccess: u32, bInheritHandle: i32, dwProcessId: u32) -> Handle; + fn WaitForSingleObject(hHandle: Handle, dwMilliseconds: u32) -> u32; + fn CloseHandle(hObject: Handle) -> i32; + } + + unsafe { + let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE, 0, pid); + if handle.is_null() { + return false; + } + let status = WaitForSingleObject(handle, 0); + let _ = CloseHandle(handle); + status == WAIT_TIMEOUT + } +} + +#[cfg(all(unix, not(windows)))] +fn process_is_alive(pid: u32) -> bool { + Command::new("kill") + .arg("-0") + .arg(pid.to_string()) + .status() + .map(|status| status.success()) + .unwrap_or(false) +} + +#[cfg(not(any(windows, unix)))] +fn process_is_alive(_pid: u32) -> bool { + true +} + +#[cfg(windows)] +fn apply_memory_limit_to_current_process() { + let limit_gb: u64 = std::env::var("SINEW_INDEX_MEMORY_LIMIT_GB") + .ok() + .and_then(|val| val.trim().parse().ok()) + .unwrap_or(12); + + if limit_gb == 0 { + return; + } + + let limit_bytes = limit_gb * 1024 * 1024 * 1024; + + use std::ffi::c_void; + type Handle = *mut c_void; + + #[repr(C)] + #[allow(non_camel_case_types)] + struct IO_COUNTERS { + read_operation_count: u64, + write_operation_count: u64, + other_operation_count: u64, + read_transfer_count: u64, + write_transfer_count: u64, + other_transfer_count: u64, + } + + #[repr(C)] + #[allow(non_camel_case_types)] + struct JOBOBJECT_BASIC_LIMIT_INFORMATION { + per_process_user_time_limit: i64, + per_job_user_time_limit: i64, + limit_flags: u32, + minimum_working_set_size: usize, + maximum_working_set_size: usize, + active_process_limit: u32, + affinity: usize, + priority_class: u32, + scheduling_class: u32, + } + + #[repr(C)] + #[allow(non_camel_case_types)] + struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION { + basic_limit_information: JOBOBJECT_BASIC_LIMIT_INFORMATION, + io_info: IO_COUNTERS, + process_memory_limit: usize, + job_memory_limit: usize, + peak_process_memory_used: usize, + peak_job_memory_used: usize, + } + + const JOB_OBJECT_LIMIT_JOB_MEMORY: u32 = 0x00000200; + const JOB_OBJECT_LIMIT_PROCESS_MEMORY: u32 = 0x00000100; + const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE: u32 = 0x00002000; + const JOB_OBJECT_EXTENDED_LIMIT_INFORMATION: i32 = 9; + + extern "system" { + fn CreateJobObjectW(lpJobAttributes: *mut c_void, lpName: *const u16) -> Handle; + fn SetInformationJobObject( + hJob: Handle, + JobObjectInfoClass: i32, + lpJobObjectInfo: *const c_void, + cbJobObjectInfoLength: u32, + ) -> i32; + fn AssignProcessToJobObject(hJob: Handle, hProcess: Handle) -> i32; + fn GetCurrentProcess() -> Handle; + fn CloseHandle(hObject: Handle) -> i32; + } + + unsafe { + let job = CreateJobObjectW(std::ptr::null_mut(), std::ptr::null()); + if job.is_null() { + return; + } + + let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed(); + info.basic_limit_information.limit_flags = JOB_OBJECT_LIMIT_JOB_MEMORY + | JOB_OBJECT_LIMIT_PROCESS_MEMORY + | JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + info.process_memory_limit = limit_bytes as usize; + info.job_memory_limit = limit_bytes as usize; + + let r = SetInformationJobObject( + job, + JOB_OBJECT_EXTENDED_LIMIT_INFORMATION, + &info as *const _ as *const c_void, + std::mem::size_of::() as u32, + ); + if r != 0 { + let current_process = GetCurrentProcess(); + let _ = AssignProcessToJobObject(job, current_process); + } else { + let _ = CloseHandle(job); + } + } +} + +#[cfg(not(windows))] +fn apply_memory_limit_to_current_process() {} diff --git a/crates/sinew-index/src/search.rs b/crates/sinew-index/src/search.rs new file mode 100644 index 00000000..5da02735 --- /dev/null +++ b/crates/sinew-index/src/search.rs @@ -0,0 +1,215 @@ +use std::path::Path; +use std::time::Instant; + +use anyhow::{Context, Result}; +use rusqlite::params; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::{embeddings, store::IndexStore}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CodebaseHit { + pub path: String, + pub start_line: i64, + pub end_line: i64, + pub snippet: String, + pub score: f64, +} + +struct RawHit { + path: String, + start_line: i64, + end_line: i64, + snippet: String, + bm25: f64, + embedding: Option>, +} + +pub fn search_workspace( + workspace_root: &Path, + query: &str, + path_prefix: Option<&str>, + limit: usize, +) -> Result> { + let start = Instant::now(); + let store = IndexStore::open(workspace_root)?; + let fts_query = build_fts_query(query); + if fts_query.is_empty() { + return Ok(Vec::new()); + } + let limit = limit.clamp(1, 50); + let candidate_limit = (limit * 4).max(8) as i64; + let mut hits = search_fts_candidates(&store, &fts_query, path_prefix, candidate_limit)?; + if hits.is_empty() { + return Ok(Vec::new()); + } + if embeddings::is_available() { + if let Ok(query_embedding) = query_embedding(&store, query) { + hits = rerank_with_embeddings(hits, &query_embedding); + } + } + hits.truncate(limit); + let hit_count = hits.len(); + let search_ms = start.elapsed().as_millis(); + tracing::debug!(query, hit_count, search_ms, "workspace search completed"); + Ok(hits + .into_iter() + .map(|hit| CodebaseHit { + path: hit.path, + start_line: hit.start_line, + end_line: hit.end_line, + snippet: hit.snippet, + score: hit.bm25, + }) + .collect()) +} + +fn search_fts_candidates( + store: &IndexStore, + fts_query: &str, + path_prefix: Option<&str>, + limit: i64, +) -> Result> { + let conn = store.connection()?; + let sql = if path_prefix + .filter(|value| !value.trim().is_empty()) + .is_some() + { + search_sql_with_prefix() + } else { + search_sql() + }; + let mut stmt = conn.prepare(&sql)?; + let rows = if let Some(prefix) = path_prefix.filter(|value| !value.trim().is_empty()) { + stmt.query_map(params![fts_query, format!("{prefix}%"), limit], map_raw_hit)? + } else { + stmt.query_map(params![fts_query, limit], map_raw_hit)? + }; + rows.collect::, _>>() + .context("unable to search codebase index") +} + +fn query_embedding(store: &IndexStore, query: &str) -> Result> { + let hash = query_hash(query); + if let Some(cached) = store.load_query_embedding(&hash)? { + return Ok(cached); + } + let embedding = embeddings::embed_query(query)?; + store.save_query_embedding(&hash, &embedding)?; + Ok(embedding) +} + +fn rerank_with_embeddings(mut hits: Vec, query_embedding: &[f32]) -> Vec { + for hit in &mut hits { + let fts_score = bm25_to_score(hit.bm25); + let semantic = hit + .embedding + .as_deref() + .map(|embedding| embeddings::cosine_similarity(query_embedding, embedding) as f64) + .unwrap_or(0.0); + hit.bm25 = 0.35 * fts_score + 0.65 * semantic; + } + hits.sort_by(|left, right| { + right + .bm25 + .partial_cmp(&left.bm25) + .unwrap_or(std::cmp::Ordering::Equal) + }); + hits +} + +fn bm25_to_score(value: f64) -> f64 { + 1.0 / (1.0 + value.abs()) +} + +fn query_hash(query: &str) -> String { + let digest = Sha256::digest(query.trim().to_ascii_lowercase().as_bytes()); + digest.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn search_sql() -> String { + " + SELECT c.path, c.start_line, c.end_line, snippet(chunks_fts, 1, '[[', ']]', '…', 10) AS snippet, + bm25(chunks_fts) AS score, c.embedding + FROM chunks_fts + JOIN chunks c ON c.id = chunks_fts.rowid + WHERE chunks_fts MATCH ?1 + ORDER BY score + LIMIT ?2 + " + .to_string() +} + +fn search_sql_with_prefix() -> String { + " + SELECT c.path, c.start_line, c.end_line, snippet(chunks_fts, 1, '[[', ']]', '…', 10) AS snippet, + bm25(chunks_fts) AS score, c.embedding + FROM chunks_fts + JOIN chunks c ON c.id = chunks_fts.rowid + WHERE chunks_fts MATCH ?1 AND c.path LIKE ?2 + ORDER BY score + LIMIT ?3 + " + .to_string() +} + +fn map_raw_hit(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let embedding = row + .get::<_, Option>>(5)? + .map(|bytes| embeddings::bytes_to_vector(&bytes)); + Ok(RawHit { + path: row.get(0)?, + start_line: row.get(1)?, + end_line: row.get(2)?, + snippet: row.get(3)?, + bm25: row.get(4)?, + embedding, + }) +} + +fn build_fts_query(query: &str) -> String { + query + .split_whitespace() + .filter(|term| !term.is_empty()) + .map(|term| { + let cleaned: String = term + .chars() + .filter(|ch| ch.is_alphanumeric() || *ch == '_' || *ch == '-') + .collect(); + if cleaned.is_empty() { + String::new() + } else { + format!("{}*", cleaned.replace('"', "")) + } + }) + .filter(|term| !term.is_empty()) + .collect::>() + .join(" OR ") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + use crate::indexer::ensure_workspace_index; + + #[test] + fn indexes_and_searches_workspace() { + let dir = std::env::temp_dir().join(format!("sinew-index-test-{}", uuid_simple())); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("alpha.rs"), "pub fn authenticate_user() {}\n").unwrap(); + ensure_workspace_index(&dir).unwrap(); + let hits = search_workspace(&dir, "authenticate", None, 5).unwrap(); + assert!(!hits.is_empty()); + let _ = fs::remove_dir_all(dir); + } + + fn uuid_simple() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() as u64 + } +} diff --git a/crates/sinew-index/src/store.rs b/crates/sinew-index/src/store.rs new file mode 100644 index 00000000..6cf6a287 --- /dev/null +++ b/crates/sinew-index/src/store.rs @@ -0,0 +1,483 @@ +use std::{ + collections::HashMap, + ffi::OsString, + path::{Path, PathBuf}, + thread, + time::Duration, +}; + +use anyhow::{Context, Result}; +use rusqlite::{params, Connection}; + +use crate::chunk::FileChunk; + +#[derive(Debug, Clone)] +pub struct FileSignature { + pub content_hash: String, + pub mtime_ms: i64, + pub size_bytes: i64, +} + +#[derive(Debug, Clone)] +pub struct IndexFileMetadata { + pub path: String, + pub content_hash: String, + pub mtime_ms: i64, + pub size_bytes: i64, +} + +#[derive(Debug, Clone)] +pub struct IndexFileData { + pub path: String, + pub content_hash: String, + pub mtime_ms: i64, + pub size_bytes: i64, + pub chunks: Vec, +} + +pub struct IndexStore { + path: PathBuf, +} + +impl IndexStore { + pub fn open(workspace_root: &Path) -> Result { + let path = index_db_path(workspace_root)?; + migrate_legacy_index_db(workspace_root, &path)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("unable to create index dir {}", parent.display()))?; + } + let store = Self { path }; + store.init_schema()?; + Ok(store) + } + + pub(crate) fn connection(&self) -> Result { + let conn = + Connection::open(&self.path).context("unable to open codebase index database")?; + let _ = conn.busy_timeout(Duration::from_secs(10)); + let tuning = sqlite_tuning(); + let pragmas = format!( + "PRAGMA journal_mode = WAL; + PRAGMA synchronous = NORMAL; + PRAGMA cache_size = -{}; + PRAGMA mmap_size = {}; + PRAGMA temp_store = MEMORY; + PRAGMA cache_spill = FALSE; + PRAGMA threads = {}; + PRAGMA busy_timeout = 10000;", + tuning.cache_kib, tuning.mmap_bytes, + MachinePowerProfile::current().parallelism.min(8) + ); + let _ = conn.execute_batch(&pragmas); + Ok(conn) + } + + fn init_schema(&self) -> Result<()> { + let conn = self.connection()?; + conn.execute_batch( + " + CREATE TABLE IF NOT EXISTS files ( + path TEXT PRIMARY KEY, + content_hash TEXT NOT NULL, + mtime_ms INTEGER NOT NULL, + size_bytes INTEGER NOT NULL DEFAULT 0, + indexed_at_ms INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS chunks ( + id INTEGER PRIMARY KEY, + path TEXT NOT NULL, + start_line INTEGER NOT NULL, + end_line INTEGER NOT NULL, + content TEXT NOT NULL + ); + CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5( + path, + content, + content='chunks', + content_rowid='id', + tokenize='unicode61' + ); + CREATE TRIGGER IF NOT EXISTS chunks_ai AFTER INSERT ON chunks BEGIN + INSERT INTO chunks_fts(rowid, path, content) VALUES (new.id, new.path, new.content); + END; + CREATE TRIGGER IF NOT EXISTS chunks_ad AFTER DELETE ON chunks BEGIN + INSERT INTO chunks_fts(chunks_fts, rowid, path, content) VALUES('delete', old.id, old.path, old.content); + END; + CREATE TRIGGER IF NOT EXISTS chunks_au AFTER UPDATE ON chunks BEGIN + INSERT INTO chunks_fts(chunks_fts, rowid, path, content) VALUES('delete', old.id, old.path, old.content); + INSERT INTO chunks_fts(rowid, path, content) VALUES (new.id, new.path, new.content); + END; + CREATE TABLE IF NOT EXISTS query_cache ( + query_hash TEXT PRIMARY KEY, + embedding BLOB NOT NULL, + cached_at_ms INTEGER NOT NULL + ); + ", + )?; + let _ = conn.execute( + "ALTER TABLE files ADD COLUMN size_bytes INTEGER NOT NULL DEFAULT 0", + [], + ); + let _ = conn.execute("ALTER TABLE chunks ADD COLUMN embedding BLOB", []); + Ok(()) + } + + pub fn file_signatures(&self) -> Result> { + let conn = self.connection()?; + let mut stmt = + conn.prepare("SELECT path, content_hash, mtime_ms, size_bytes FROM files")?; + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + FileSignature { + content_hash: row.get(1)?, + mtime_ms: row.get(2)?, + size_bytes: row.get(3)?, + }, + )) + })?; + + let mut signatures = HashMap::new(); + for row in rows { + let (path, signature) = row?; + signatures.insert(path, signature); + } + Ok(signatures) + } + + pub fn list_files(&self) -> Result> { + let conn = self.connection()?; + let mut stmt = conn.prepare("SELECT path FROM files")?; + let rows = stmt.query_map([], |row| row.get(0))?; + rows.collect::, _>>() + .context("unable to list indexed files") + } + + pub fn replace_files(&self, files: &[IndexFileData]) -> Result<()> { + if files.is_empty() { + return Ok(()); + } + + let conn = self.connection()?; + let tx = conn.unchecked_transaction()?; + let now = now_ms(); + + { + let mut delete_chunks = tx.prepare("DELETE FROM chunks WHERE path = ?1")?; + let mut delete_file = tx.prepare("DELETE FROM files WHERE path = ?1")?; + let mut insert_file = tx.prepare( + "INSERT INTO files (path, content_hash, mtime_ms, size_bytes, indexed_at_ms) VALUES (?1, ?2, ?3, ?4, ?5)", + )?; + let mut insert_chunk = tx.prepare( + "INSERT INTO chunks (path, start_line, end_line, content, embedding) VALUES (?1, ?2, ?3, ?4, ?5)", + )?; + + for file in files { + delete_chunks.execute(params![file.path.as_str()])?; + delete_file.execute(params![file.path.as_str()])?; + insert_file.execute(params![ + file.path.as_str(), + file.content_hash.as_str(), + file.mtime_ms, + file.size_bytes, + now + ])?; + for chunk in &file.chunks { + let embedding = chunk + .embedding + .as_ref() + .map(|values| crate::embeddings::vector_to_bytes(values)); + insert_chunk.execute(params![ + file.path.as_str(), + chunk.start_line, + chunk.end_line, + chunk.content.as_str(), + embedding + ])?; + } + } + } + + tx.commit()?; + Ok(()) + } + + pub fn touch_file_metadata_batch(&self, files: &[IndexFileMetadata]) -> Result<()> { + if files.is_empty() { + return Ok(()); + } + + let conn = self.connection()?; + let tx = conn.unchecked_transaction()?; + let now = now_ms(); + { + let mut update = tx.prepare( + "UPDATE files SET content_hash = ?2, mtime_ms = ?3, size_bytes = ?4, indexed_at_ms = ?5 WHERE path = ?1", + )?; + for file in files { + update.execute(params![ + file.path.as_str(), + file.content_hash.as_str(), + file.mtime_ms, + file.size_bytes, + now + ])?; + } + } + tx.commit()?; + Ok(()) + } + + pub fn remove_files(&self, paths: &[String]) -> Result { + if paths.is_empty() { + return Ok(0); + } + + let conn = self.connection()?; + let tx = conn.unchecked_transaction()?; + let mut removed = 0usize; + { + let mut delete_chunks = tx.prepare("DELETE FROM chunks WHERE path = ?1")?; + let mut delete_file = tx.prepare("DELETE FROM files WHERE path = ?1")?; + for path in paths { + delete_chunks.execute(params![path.as_str()])?; + removed += delete_file.execute(params![path.as_str()])?; + } + } + tx.commit()?; + Ok(removed) + } + + pub fn update_chunk_embedding(&self, chunk_id: i64, embedding: &[f32]) -> Result<()> { + let conn = self.connection()?; + conn.execute( + "UPDATE chunks SET embedding = ?1 WHERE id = ?2", + params![crate::embeddings::vector_to_bytes(embedding), chunk_id], + )?; + Ok(()) + } + + pub fn list_chunks_without_embedding(&self, limit: usize) -> Result> { + if limit == 0 { + return Ok(Vec::new()); + } + + let conn = self.connection()?; + let mut stmt = conn.prepare( + "SELECT id, content FROM chunks WHERE embedding IS NULL OR length(embedding) = 0 ORDER BY id LIMIT ?1", + )?; + let rows = stmt.query_map(params![limit as i64], |row| Ok((row.get(0)?, row.get(1)?)))?; + rows.collect::, _>>() + .context("unable to list chunks missing embeddings") + } + + pub fn stats(&self) -> Result<(usize, usize)> { + let conn = self.connection()?; + let files: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?; + let chunks: i64 = conn.query_row("SELECT COUNT(*) FROM chunks", [], |row| row.get(0))?; + Ok((files as usize, chunks as usize)) + } + + pub fn load_query_embedding(&self, query_hash: &str) -> Result>> { + let conn = self.connection()?; + let mut stmt = + conn.prepare("SELECT embedding FROM query_cache WHERE query_hash = ?1 LIMIT 1")?; + let mut rows = stmt.query(params![query_hash])?; + if let Some(row) = rows.next()? { + let bytes: Vec = row.get(0)?; + return Ok(Some(crate::embeddings::bytes_to_vector(&bytes))); + } + Ok(None) + } + + pub fn save_query_embedding(&self, query_hash: &str, embedding: &[f32]) -> Result<()> { + let conn = self.connection()?; + conn.execute( + "INSERT INTO query_cache (query_hash, embedding, cached_at_ms) VALUES (?1, ?2, ?3) + ON CONFLICT(query_hash) DO UPDATE SET embedding = excluded.embedding, cached_at_ms = excluded.cached_at_ms", + params![ + query_hash, + crate::embeddings::vector_to_bytes(embedding), + now_ms() + ], + )?; + Ok(()) + } +} + +pub fn index_db_path(workspace_root: &Path) -> Result { + let dirs = directories::ProjectDirs::from("dev", "hyrak", "sinew") + .context("unable to resolve Sinew data directory")?; + Ok(index_db_path_under(dirs.data_local_dir(), workspace_root)) +} + +fn legacy_index_db_path(workspace_root: &Path) -> Result> { + let dirs = directories::ProjectDirs::from("dev", "hyrak", "sinew") + .context("unable to resolve Sinew data directory")?; + if dirs.data_dir() == dirs.data_local_dir() { + return Ok(None); + } + Ok(Some(index_db_path_under(dirs.data_dir(), workspace_root))) +} + +fn index_db_path_under(base: &Path, workspace_root: &Path) -> PathBuf { + let workspace_id = sha256_hex(workspace_root.display().to_string().as_bytes()); + base.join("codebase-index") + .join(workspace_id) + .join("index.db") +} + +fn migrate_legacy_index_db(workspace_root: &Path, local_path: &Path) -> Result<()> { + let Some(legacy_path) = legacy_index_db_path(workspace_root)? else { + return Ok(()); + }; + if !legacy_path.exists() || local_path.exists() { + return Ok(()); + } + if let Some(parent) = local_path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("unable to create local index dir {}", parent.display()))?; + } + + for suffix in ["", "-wal", "-shm"] { + let source = sqlite_sibling_path(&legacy_path, suffix); + if !source.exists() { + continue; + } + let target = sqlite_sibling_path(local_path, suffix); + if !target.exists() { + let _ = std::fs::copy(source, target); + } + } + Ok(()) +} + +fn sqlite_sibling_path(path: &Path, suffix: &str) -> PathBuf { + if suffix.is_empty() { + return path.to_path_buf(); + } + let mut value = OsString::from(path.as_os_str()); + value.push(suffix); + PathBuf::from(value) +} + +fn sha256_hex(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + let digest = Sha256::digest(bytes); + digest.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .min(i64::MAX as u128) as i64 +} + +#[derive(Debug, Clone, Copy)] +struct SqliteTuning { + cache_kib: i64, + mmap_bytes: i64, +} + +static PROFILE: std::sync::OnceLock = std::sync::OnceLock::new(); + +fn sqlite_tuning() -> SqliteTuning { + let profile = MachinePowerProfile::current(); + // Cache scales with memory (approx. 1/32 of memory size), + // clamped between 32 MiB and 1 GiB. + let cache_kib = (profile.total_memory_kib / 32) + .clamp(32 * 1024, 1024 * 1024) as i64; + // Memory mapping scales up to 4x cache size on SSD/NVMe (up to 4 GiB max) + let mmap_bytes = (cache_kib * 1024 * profile.storage_multiplier()) + .clamp(128 * 1024 * 1024, 4 * 1024 * 1024 * 1024); + SqliteTuning { + cache_kib, + mmap_bytes, + } +} + +#[derive(Debug, Clone, Copy)] +struct MachinePowerProfile { + parallelism: i64, + high_throughput_storage: bool, + total_memory_kib: u64, +} + +impl MachinePowerProfile { + fn current() -> Self { + *PROFILE.get_or_init(|| { + let parallelism = thread::available_parallelism() + .map(|value| value.get() as i64) + .unwrap_or(4); + let high_throughput_storage = high_throughput_storage_available(); + let total_memory_kib = total_system_memory_kib(); + Self { + parallelism, + high_throughput_storage, + total_memory_kib, + } + }) + } + + fn storage_multiplier(self) -> i64 { + if self.high_throughput_storage { + 4 + } else { + 2 + } + } +} + +#[cfg(target_os = "windows")] +fn total_system_memory_kib() -> u64 { + use std::os::windows::process::CommandExt; + let output = std::process::Command::new("powershell") + .args([ + "-NoProfile", + "-Command", + "(Get-CimInstance Win32_OperatingSystem).TotalVisibleMemorySize", + ]) + .creation_flags(0x08000000) + .output(); + if let Ok(output) = output { + let text = String::from_utf8_lossy(&output.stdout); + if let Ok(kib) = text.trim().parse::() { + return kib; + } + } + // Fallback standard value: 16 GB + 16 * 1024 * 1024 +} + +#[cfg(not(target_os = "windows"))] +fn total_system_memory_kib() -> u64 { + 16 * 1024 * 1024 +} + +#[cfg(target_os = "windows")] +fn high_throughput_storage_available() -> bool { + use std::os::windows::process::CommandExt; + + let output = std::process::Command::new("powershell") + .args([ + "-NoProfile", + "-Command", + "Get-CimInstance Win32_DiskDrive | ForEach-Object { $_.Model + ' ' + $_.PNPDeviceID + ' ' + $_.Caption }", + ]) + .creation_flags(0x08000000) + .output(); + let Ok(output) = output else { + return true; + }; + let text = String::from_utf8_lossy(&output.stdout).to_ascii_lowercase(); + text.contains("nvme") || text.contains("ssd") || text.contains("solid state") +} + +#[cfg(not(target_os = "windows"))] +fn high_throughput_storage_available() -> bool { + true +} + diff --git a/crates/sinew-kimi/src/client.rs b/crates/sinew-kimi/src/client.rs index c3e6f92c..7642b1ec 100644 --- a/crates/sinew-kimi/src/client.rs +++ b/crates/sinew-kimi/src/client.rs @@ -1,6 +1,7 @@ use async_trait::async_trait; use serde::Serialize; use serde_json::Value; +use std::time::Instant; use sinew_core::{ AppError, ChatMessage, Effort, ModelCapabilities, ModelRef, Part, Provider, ProviderRequest, ProviderStream, Result, Role, TokenEstimate, ToolDescriptor, @@ -82,6 +83,7 @@ impl KimiProvider { route: &str, body: &T, ) -> Result { + let req_start = Instant::now(); let (request, token) = self.post(route).await?; let response = request .json(body) @@ -90,6 +92,8 @@ impl KimiProvider { .map_err(|err| AppError::Network(err.to_string()))?; if response.status() != reqwest::StatusCode::UNAUTHORIZED { + let http_ms = req_start.elapsed().as_millis(); + tracing::debug!(provider = "kimi", route, http_ms, "HTTP round-trip"); return Ok(response); } @@ -100,11 +104,14 @@ impl KimiProvider { .map_err(map_refresh_failure)?; let (request, _) = self.post(route).await?; - request + let response = request .json(body) .send() .await - .map_err(|err| AppError::Network(err.to_string())) + .map_err(|err| AppError::Network(err.to_string()))?; + let http_ms = req_start.elapsed().as_millis(); + tracing::debug!(provider = "kimi", route, http_ms, retried = true, "HTTP round-trip"); + Ok(response) } } diff --git a/crates/sinew-ollama/Cargo.toml b/crates/sinew-ollama/Cargo.toml new file mode 100644 index 00000000..5f0e37e7 --- /dev/null +++ b/crates/sinew-ollama/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "sinew-ollama" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Ollama (local models) provider integration for Sinew" + +[dependencies] +sinew-core = { workspace = true } + +async-trait = { workspace = true } +bytes = { workspace = true } +directories = { workspace = true } +eventsource-stream = { workspace = true } +futures = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } + +[lints] +workspace = true diff --git a/crates/sinew-ollama/src/auth.rs b/crates/sinew-ollama/src/auth.rs new file mode 100644 index 00000000..ddf92015 --- /dev/null +++ b/crates/sinew-ollama/src/auth.rs @@ -0,0 +1,176 @@ +use std::{ + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use directories::ProjectDirs; +use serde::{Deserialize, Serialize}; + +use sinew_core::{AppError, Result}; + +use crate::model_info::PROVIDER_ID; + +pub const DEFAULT_BASE_URL: &str = "http://localhost:11434"; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OllamaAuthStatus { + pub connected: bool, + pub base_url: Option, + pub last_validated_ms: Option, +} + +impl OllamaAuthStatus { + pub fn disconnected() -> Self { + Self { + connected: false, + base_url: None, + last_validated_ms: None, + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StoredAuth { + provider: String, + auth_mode: String, + base_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + last_validated_ms: Option, +} + +pub fn default_base_url() -> String { + DEFAULT_BASE_URL.to_string() +} + +pub fn load_default_base_url() -> Result> { + let path = default_auth_path()?; + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(AppError::Auth(format!("unable to read auth file: {err}"))), + }; + let payload: StoredAuth = serde_json::from_slice(&bytes) + .map_err(|err| AppError::Auth(format!("invalid auth file: {err}")))?; + if payload.provider != PROVIDER_ID { + return Ok(None); + } + let base_url = normalize_base_url(&payload.base_url); + if base_url.is_empty() { + return Ok(None); + } + Ok(Some(base_url)) +} + +pub fn save_default_base_url(base_url: &str) -> Result { + let base_url = normalize_base_url(base_url); + if base_url.is_empty() { + return Err(AppError::Auth("Ollama address cannot be empty".into())); + } + let auth = StoredAuth { + provider: PROVIDER_ID.into(), + auth_mode: "local".into(), + base_url, + last_validated_ms: Some(now_ms()), + }; + write_auth_file(&default_auth_path()?, &auth)?; + Ok(status_from_auth(&auth)) +} + +pub fn touch_default_auth_validation() -> Result { + let path = default_auth_path()?; + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Ok(OllamaAuthStatus::disconnected()) + } + Err(err) => return Err(AppError::Auth(format!("unable to read auth file: {err}"))), + }; + let mut auth: StoredAuth = serde_json::from_slice(&bytes) + .map_err(|err| AppError::Auth(format!("invalid auth file: {err}")))?; + auth.last_validated_ms = Some(now_ms()); + write_auth_file(&path, &auth)?; + Ok(status_from_auth(&auth)) +} + +pub fn load_default_auth_status() -> Result { + let path = default_auth_path()?; + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Ok(OllamaAuthStatus::disconnected()) + } + Err(err) => return Err(AppError::Auth(format!("unable to read auth file: {err}"))), + }; + let payload: StoredAuth = serde_json::from_slice(&bytes) + .map_err(|err| AppError::Auth(format!("invalid auth file: {err}")))?; + Ok(status_from_auth(&payload)) +} + +pub fn delete_default_auth() -> Result<()> { + let path = default_auth_path()?; + match std::fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(AppError::Auth(format!("unable to delete auth file: {err}"))), + } +} + +pub fn normalize_base_url(base_url: &str) -> String { + base_url.trim().trim_end_matches('/').to_string() +} + +fn default_auth_path() -> Result { + let dirs = ProjectDirs::from("dev", "hyrak", "sinew") + .ok_or_else(|| AppError::Auth("unable to resolve local data directory".into()))?; + Ok(dirs.data_local_dir().join("ollama-auth.json")) +} + +fn status_from_auth(auth: &StoredAuth) -> OllamaAuthStatus { + if auth.provider != PROVIDER_ID { + return OllamaAuthStatus::disconnected(); + } + let base_url = normalize_base_url(&auth.base_url); + OllamaAuthStatus { + connected: !base_url.is_empty(), + base_url: (!base_url.is_empty()).then_some(base_url), + last_validated_ms: auth.last_validated_ms, + } +} + +fn write_auth_file(path: &Path, auth: &StoredAuth) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|err| AppError::Auth(format!("unable to create auth directory: {err}")))?; + } + let pretty = serde_json::to_vec_pretty(auth) + .map_err(|err| AppError::Decode(format!("unable to serialize auth file: {err}")))?; + let temp = path.with_extension("json.tmp"); + std::fs::write(&temp, pretty) + .map_err(|err| AppError::Auth(format!("unable to write temp auth file: {err}")))?; + apply_permissions(&temp)?; + std::fs::rename(&temp, path) + .map_err(|err| AppError::Auth(format!("unable to replace auth file: {err}")))?; + Ok(()) +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as i64) + .unwrap_or(0) +} + +#[cfg(unix)] +fn apply_permissions(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .map_err(|err| AppError::Auth(format!("unable to chmod auth file: {err}")))?; + Ok(()) +} + +#[cfg(not(unix))] +fn apply_permissions(_path: &Path) -> Result<()> { + Ok(()) +} diff --git a/crates/sinew-ollama/src/client.rs b/crates/sinew-ollama/src/client.rs new file mode 100644 index 00000000..55a87610 --- /dev/null +++ b/crates/sinew-ollama/src/client.rs @@ -0,0 +1,610 @@ +use std::{collections::HashMap, sync::Arc, time::Instant}; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sinew_core::{ + AppError, ChatMessage, ModelCapabilities, ModelRef, Part, Provider, ProviderRequest, + ProviderStream, Result, Role, TokenEstimate, ToolDescriptor, +}; + +use crate::{ + auth::{self, normalize_base_url}, + model_info::PROVIDER_ID, + stream::map_stream, + wire, +}; + +const USER_AGENT: &str = "Sinew/0.1"; + +#[derive(Clone)] +pub struct OllamaConfig { + pub base_url: String, + pub models: Vec, +} + +impl OllamaConfig { + pub fn new(base_url: impl Into, models: Vec) -> Self { + Self { + base_url: normalize_base_url(&base_url.into()), + models, + } + } + + pub fn from_default_sources(models: Vec) -> Result { + let base_url = auth::load_default_base_url()?.ok_or_else(|| { + AppError::Auth("Ollama is not connected. Connect it in Settings > Providers.".into()) + })?; + Ok(Self::new(base_url, models)) + } +} + +pub struct OllamaProvider { + config: OllamaConfig, + http: reqwest::Client, + models: Arc>, +} + +impl OllamaProvider { + pub fn new(config: OllamaConfig) -> Result { + let http = reqwest::Client::builder() + .user_agent(USER_AGENT) + .build() + .map_err(|err| AppError::Network(err.to_string()))?; + let models = config + .models + .iter() + .map(|caps| (caps.model.name.clone(), caps.clone())) + .collect::>(); + Ok(Self { + config, + http, + models: Arc::new(models), + }) + } + + pub fn from_default_sources(models: Vec) -> Result { + Self::new(OllamaConfig::from_default_sources(models)?) + } + + fn endpoint(&self, route: &str) -> String { + format!("{}{}", self.config.base_url.trim_end_matches('/'), route) + } +} + +#[async_trait] +impl Provider for OllamaProvider { + fn name(&self) -> &str { + PROVIDER_ID + } + + fn capabilities(&self, model: &ModelRef) -> Option { + if model.provider != PROVIDER_ID { + return None; + } + self.models.get(&model.name).cloned() + } + + async fn estimate_tokens(&self, request: ProviderRequest) -> Result { + if request.model.provider != PROVIDER_ID { + return Err(AppError::Unsupported(format!( + "ollama provider cannot count model provider {}", + request.model.provider + ))); + } + Ok(TokenEstimate { + input_tokens: rough_token_estimate(&request), + exact: false, + }) + } + + async fn stream(&self, request: ProviderRequest) -> Result { + if request.model.provider != PROVIDER_ID { + return Err(AppError::Unsupported(format!( + "ollama provider cannot run model provider {}", + request.model.provider + ))); + } + + let caps = self.capabilities(&request.model).ok_or_else(|| { + AppError::Unsupported(format!("model `{}` is not supported", request.model.name)) + })?; + if !caps.supports_images && request_contains_images(&request) { + return Err(AppError::InvalidRequest(format!( + "Ollama model `{}` does not support image input", + request.model.name + ))); + } + + let body = build_chat_request(&request, &caps)?; + let req_start = Instant::now(); + let model_name = request.model.name.clone(); + let response = self + .http + .post(self.endpoint("/v1/chat/completions")) + .header("content-type", "application/json") + .json(&body) + .send() + .await + .map_err(|err| AppError::Network(err.to_string()))?; + let http_ms = req_start.elapsed().as_millis(); + tracing::debug!(provider = "ollama", model = model_name, http_ms, "HTTP round-trip"); + if !response.status().is_success() { + return Err(read_http_error(response).await); + } + + Ok(map_stream(response.bytes_stream(), request.model.name)) + } +} + +fn build_chat_request<'a>( + request: &'a ProviderRequest, + caps: &ModelCapabilities, +) -> Result> { + Ok(wire::ChatCompletionsRequest { + model: &request.model.name, + messages: to_wire_messages(request, caps.supports_images)?, + tools: request.tools.iter().map(to_wire_tool).collect(), + max_tokens: Some( + request + .max_output_tokens + .unwrap_or(caps.max_output_tokens) + .min(caps.max_output_tokens), + ), + temperature: request.temperature, + stream: true, + stream_options: Some(wire::StreamOptions { + include_usage: true, + }), + }) +} + +fn to_wire_tool(tool: &ToolDescriptor) -> wire::WireTool<'_> { + wire::WireTool { + kind: "function", + function: wire::WireToolFunction { + name: &tool.name, + description: &tool.description, + parameters: &tool.input_schema, + }, + } +} + +fn to_wire_messages<'a>( + request: &'a ProviderRequest, + supports_images: bool, +) -> Result>> { + let mut messages = Vec::new(); + if let Some(system) = request + .system_prompt + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + messages.push(wire::WireMessage::System { + role: "system", + content: wire::WireContent::Text(system.to_string()), + }); + } + + for message in &request.transcript { + match message.role { + Role::User => push_user_messages(message, &mut messages, supports_images), + Role::Assistant => push_assistant_message(message, &mut messages), + } + } + + Ok(messages) +} + +fn push_user_messages<'a>( + message: &'a ChatMessage, + messages: &mut Vec>, + supports_images: bool, +) { + let mut builder = ContentBuilder::new(supports_images); + for part in &message.parts { + if part_is_ui_only(part) { + continue; + } + match part { + Part::Text { text, .. } => builder.push_text(text), + Part::Image { + media_type, data, .. + } => builder.push_image(media_type, data), + Part::ToolResult { + tool_call_id, + content, + images, + .. + } => { + flush_user_builder(&mut builder, messages); + let mut result = ContentBuilder::new(supports_images); + result.push_text(content); + for image in images { + if !image.data.trim().is_empty() { + result.push_image(&image.media_type, &image.data); + } + } + let content = result + .finish_allow_empty() + .unwrap_or_else(|| wire::WireContent::Text(String::new())); + messages.push(wire::WireMessage::Tool { + role: "tool", + content, + tool_call_id, + }); + } + Part::Thinking { .. } | Part::ToolCall { .. } => {} + } + } + flush_user_builder(&mut builder, messages); +} + +fn flush_user_builder<'a>(builder: &mut ContentBuilder, messages: &mut Vec>) { + if let Some(content) = builder.finish() { + messages.push(wire::WireMessage::User { + role: "user", + content, + }); + } +} + +fn push_assistant_message<'a>( + message: &'a ChatMessage, + messages: &mut Vec>, +) { + let mut text = String::new(); + let mut tool_calls = Vec::new(); + + for part in &message.parts { + if part_is_ui_only(part) { + continue; + } + match part { + Part::Text { text: value, .. } => text.push_str(value), + Part::ToolCall { + id, name, input, .. + } => tool_calls.push(wire::WireToolCall { + id, + kind: "function", + function: wire::WireToolCallFunction { + name, + arguments: input.to_string(), + }, + }), + Part::Thinking { .. } | Part::Image { .. } | Part::ToolResult { .. } => {} + } + } + + if text.is_empty() && tool_calls.is_empty() { + return; + } + + let content = (!text.is_empty()).then_some(wire::WireContent::Text(text)); + messages.push(wire::WireMessage::Assistant { + role: "assistant", + content, + tool_calls, + }); +} + +#[derive(Default)] +struct ContentBuilder { + text: String, + blocks: Vec, + has_media: bool, + supports_images: bool, +} + +impl ContentBuilder { + fn new(supports_images: bool) -> Self { + Self { + supports_images, + ..Self::default() + } + } + + fn push_text(&mut self, text: &str) { + if text.is_empty() { + return; + } + if self.has_media { + self.blocks.push(wire::WireContentBlock::Text { + text: text.to_string(), + }); + } else { + self.text.push_str(text); + } + } + + fn push_image(&mut self, media_type: &str, data: &str) { + if data.trim().is_empty() { + return; + } + if !self.supports_images { + self.push_text(&format!("\n[Image omitted: {media_type}]\n")); + return; + } + if !self.has_media { + self.has_media = true; + if !self.text.is_empty() { + self.blocks.push(wire::WireContentBlock::Text { + text: std::mem::take(&mut self.text), + }); + } + } + self.blocks.push(wire::WireContentBlock::ImageUrl { + image_url: wire::WireImageUrl { + url: format!("data:{media_type};base64,{data}"), + }, + }); + } + + fn finish(&mut self) -> Option { + self.finish_inner(false) + } + + fn finish_allow_empty(&mut self) -> Option { + self.finish_inner(true) + } + + fn finish_inner(&mut self, allow_empty_text: bool) -> Option { + if self.has_media { + if self.blocks.is_empty() { + return None; + } + self.has_media = false; + return Some(wire::WireContent::Blocks(std::mem::take(&mut self.blocks))); + } + if self.text.is_empty() && !allow_empty_text { + return None; + } + Some(wire::WireContent::Text(std::mem::take(&mut self.text))) + } +} + +fn request_contains_images(request: &ProviderRequest) -> bool { + request.transcript.iter().any(|message| { + message.parts.iter().any(|part| match part { + Part::Image { .. } => true, + Part::ToolResult { images, .. } => !images.is_empty(), + Part::Text { .. } | Part::Thinking { .. } | Part::ToolCall { .. } => false, + }) + }) +} + +fn part_is_ui_only(part: &Part) -> bool { + part_meta(part) + .and_then(|meta| meta.get("ui_only")) + .and_then(Value::as_bool) + == Some(true) +} + +fn part_meta(part: &Part) -> Option<&Value> { + match part { + Part::Text { meta, .. } + | Part::Image { meta, .. } + | Part::Thinking { meta, .. } + | Part::ToolCall { meta, .. } + | Part::ToolResult { meta, .. } => meta.as_ref(), + } +} + +fn rough_token_estimate(request: &ProviderRequest) -> u32 { + let mut chars: usize = 0; + if let Some(system) = &request.system_prompt { + chars += system.chars().count(); + } + for message in &request.transcript { + for part in &message.parts { + if part_is_ui_only(part) { + continue; + } + match part { + Part::Text { text, .. } | Part::Thinking { text, .. } => { + chars += text.chars().count() + } + Part::Image { .. } => chars += 4_000, + Part::ToolCall { name, input, .. } => { + chars += name.chars().count(); + chars += input.to_string().chars().count(); + } + Part::ToolResult { + content, images, .. + } => { + chars += content.chars().count(); + chars += images.len() * 4_000; + } + } + } + } + for tool in &request.tools { + chars += tool.name.chars().count(); + chars += tool.description.chars().count(); + chars += tool.input_schema.to_string().chars().count(); + } + ((chars / 4).max(1)).min(u32::MAX as usize) as u32 +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OllamaCatalogModel { + pub id: String, + pub name: String, + pub context_window: u32, + pub max_output_tokens: u32, + pub supports_images: bool, + pub supports_thinking: bool, + pub supports_tools: bool, +} + +#[derive(Debug, Deserialize)] +struct TagsResponse { + #[serde(default)] + models: Vec, +} + +#[derive(Debug, Deserialize)] +struct TagEntry { + #[serde(default)] + name: String, + #[serde(default)] + model: Option, +} + +#[derive(Debug, Deserialize)] +struct ShowResponse { + #[serde(default)] + capabilities: Vec, + #[serde(default)] + model_info: HashMap, +} + +fn ollama_http() -> Result { + reqwest::Client::builder() + .user_agent(USER_AGENT) + .build() + .map_err(|err| AppError::Network(err.to_string())) +} + +pub async fn validate_endpoint(base_url: &str) -> Result<()> { + let base_url = normalize_base_url(base_url); + if base_url.is_empty() { + return Err(AppError::Auth("Ollama address cannot be empty".into())); + } + let http = ollama_http()?; + let response = http + .get(format!("{}/api/tags", base_url)) + .send() + .await + .map_err(|err| { + AppError::Network(format!( + "Unable to reach Ollama at {base_url}: {err}. Is `ollama serve` running?" + )) + })?; + if response.status().is_success() { + Ok(()) + } else { + Err(read_http_error(response).await) + } +} + +pub async fn fetch_model_catalog(base_url: &str) -> Result> { + let base_url = normalize_base_url(base_url); + if base_url.is_empty() { + return Err(AppError::Auth("Ollama address cannot be empty".into())); + } + let http = ollama_http()?; + let response = http + .get(format!("{}/api/tags", base_url)) + .send() + .await + .map_err(|err| { + AppError::Network(format!("Unable to reach Ollama at {base_url}: {err}")) + })?; + if !response.status().is_success() { + return Err(read_http_error(response).await); + } + let tags: TagsResponse = response + .json() + .await + .map_err(|err| AppError::Decode(format!("invalid Ollama tags body: {err}")))?; + + let mut catalog = Vec::with_capacity(tags.models.len()); + for entry in tags.models { + let id = entry.model.unwrap_or_default(); + let id = if id.trim().is_empty() { entry.name.clone() } else { id }; + let id = id.trim().to_string(); + if id.is_empty() { + continue; + } + let details = fetch_model_details(&http, &base_url, &id).await; + catalog.push(catalog_model_from_details(id, details)); + } + Ok(catalog) +} + +async fn fetch_model_details( + http: &reqwest::Client, + base_url: &str, + model: &str, +) -> Option { + let response = http + .post(format!("{}/api/show", base_url)) + .json(&serde_json::json!({ "model": model })) + .send() + .await + .ok()?; + if !response.status().is_success() { + return None; + } + response.json::().await.ok() +} + +fn catalog_model_from_details(id: String, details: Option) -> OllamaCatalogModel { + let (context_window, supports_images, supports_thinking, supports_tools) = match &details { + Some(show) => { + let context = show + .model_info + .iter() + .find(|(key, _)| key.ends_with(".context_length")) + .and_then(|(_, value)| value.as_u64()) + .map(|value| value.min(u32::MAX as u64) as u32) + .unwrap_or(8_192) + .max(1); + let caps = &show.capabilities; + ( + context, + caps.iter().any(|c| c == "vision"), + caps.iter().any(|c| c == "thinking"), + caps.iter().any(|c| c == "tools"), + ) + } + None => (8_192, false, false, true), + }; + let max_output_tokens = context_window.min(16_384).max(1); + OllamaCatalogModel { + id: id.clone(), + name: id, + context_window, + max_output_tokens, + supports_images, + supports_thinking, + supports_tools, + } +} + +async fn read_http_error(response: reqwest::Response) -> AppError { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + let parsed: std::result::Result = serde_json::from_str(&body); + let message = parsed + .ok() + .and_then(|payload| { + if payload.error.message.trim().is_empty() { + None + } else { + Some(payload.error.message) + } + }) + .filter(|value| !value.trim().is_empty()) + .unwrap_or(body); + + if status == reqwest::StatusCode::NOT_FOUND { + AppError::InvalidRequest(if message.trim().is_empty() { + "Ollama model not found. Pull it first with `ollama pull `.".into() + } else { + message + }) + } else if status == reqwest::StatusCode::TOO_MANY_REQUESTS { + AppError::RateLimit(message) + } else if status.is_client_error() { + if message.contains("context") || message.contains("too long") { + AppError::ContextLength(message) + } else { + AppError::InvalidRequest(message) + } + } else { + AppError::Provider(format!("HTTP {status}: {message}")) + } +} diff --git a/crates/sinew-ollama/src/lib.rs b/crates/sinew-ollama/src/lib.rs new file mode 100644 index 00000000..afe5769e --- /dev/null +++ b/crates/sinew-ollama/src/lib.rs @@ -0,0 +1,14 @@ +mod auth; +mod client; +mod model_info; +mod stream; +mod wire; + +pub use auth::{ + default_base_url, delete_default_auth, load_default_auth_status, load_default_base_url, + save_default_base_url, touch_default_auth_validation, OllamaAuthStatus, +}; +pub use client::{ + fetch_model_catalog, validate_endpoint, OllamaCatalogModel, OllamaConfig, OllamaProvider, +}; +pub use model_info::{capabilities_from_catalog_model, capabilities_from_parts, PROVIDER_ID}; diff --git a/crates/sinew-ollama/src/model_info.rs b/crates/sinew-ollama/src/model_info.rs new file mode 100644 index 00000000..7e414eb8 --- /dev/null +++ b/crates/sinew-ollama/src/model_info.rs @@ -0,0 +1,47 @@ +use sinew_core::{EffortMode, ModelCapabilities, ModelRef}; + +use crate::client::OllamaCatalogModel; + +pub const PROVIDER_ID: &str = "ollama"; + +pub fn capabilities_from_catalog_model(model: &OllamaCatalogModel) -> ModelCapabilities { + capabilities_from_parts( + &model.id, + model.context_window, + model.max_output_tokens, + model.supports_images, + model.supports_thinking, + model.supports_tools, + ) +} + +pub fn capabilities_from_parts( + id: &str, + context_window: u32, + max_output_tokens: u32, + supports_images: bool, + supports_thinking: bool, + supports_tools: bool, +) -> ModelCapabilities { + let context_window = context_window.max(1); + let max_output_tokens = max_output_tokens.max(1).min(context_window); + ModelCapabilities { + model: ModelRef::new(PROVIDER_ID, id), + context_window, + preferred_window: preferred_window(context_window), + max_output_tokens, + supports_thinking, + visible_thinking: supports_thinking, + supports_tools, + supports_images, + effort_mode: if supports_thinking { + EffortMode::Flag + } else { + EffortMode::None + }, + } +} + +fn preferred_window(context_window: u32) -> u32 { + ((context_window as u64 * 9) / 10).max(1).min(u32::MAX as u64) as u32 +} diff --git a/crates/sinew-ollama/src/stream.rs b/crates/sinew-ollama/src/stream.rs new file mode 100644 index 00000000..79f6df44 --- /dev/null +++ b/crates/sinew-ollama/src/stream.rs @@ -0,0 +1,394 @@ +use std::collections::HashMap; + +use eventsource_stream::Eventsource; +use futures::{stream::Stream, StreamExt}; +use serde_json::{json, Value}; + +use sinew_core::{ + AppError, PartKind, ProviderStream, StopReason, StreamEvent, ToolCallIntro, Usage, +}; + +use crate::wire::{self, ChatChunk}; + +pub fn map_stream(body: S, model: String) -> ProviderStream +where + S: Stream> + Send + 'static, + E: std::error::Error + Send + Sync + 'static, +{ + let source = Box::pin(body.eventsource()); + let parser = EventParser::new(model); + + futures::stream::unfold( + (source, parser, Vec::::new(), false, false), + |(mut source, mut parser, mut pending, done, mut saw_any_event)| async move { + loop { + if let Some(next) = pending.pop() { + return Some((Ok(next), (source, parser, pending, done, saw_any_event))); + } + if done { + return None; + } + + match source.next().await { + Some(Ok(event)) => { + saw_any_event = true; + let data = event.data.trim(); + if data.is_empty() { + continue; + } + if data == "[DONE]" { + let mut produced = parser.finish(); + produced.reverse(); + pending = produced; + if pending.is_empty() { + return None; + } + continue; + } + + if let Ok(value) = serde_json::from_str::(data) { + if let Some(error) = value.get("error") { + let message = error + .get("message") + .and_then(Value::as_str) + .unwrap_or("ollama stream error"); + return Some(( + Err(AppError::Provider(message.to_string())), + (source, parser, pending, true, saw_any_event), + )); + } + } + + let parsed: std::result::Result = serde_json::from_str(data); + match parsed { + Ok(parsed) => { + if let Some(message) = chunk_error_message(&parsed) { + return Some(( + Err(AppError::Provider(message)), + (source, parser, pending, true, saw_any_event), + )); + } + let mut produced = parser.push(parsed); + produced.reverse(); + pending = produced; + } + Err(err) => { + return Some(( + Err(AppError::Decode(format!("bad ollama event: {err}"))), + (source, parser, pending, true, saw_any_event), + )); + } + } + } + Some(Err(err)) => { + return Some(( + Err(AppError::Stream(err.to_string())), + (source, parser, pending, true, saw_any_event), + )); + } + None => { + if !saw_any_event { + return Some(( + Err(AppError::Stream( + "ollama SSE closed before any event; \ + is the Ollama server reachable?" + .into(), + )), + (source, parser, pending, true, saw_any_event), + )); + } + let mut produced = parser.finish(); + produced.reverse(); + pending = produced; + if pending.is_empty() { + return None; + } + } + } + } + }, + ) + .boxed() +} + +#[derive(Debug, Default)] +struct ToolState { + part_index: Option, + id: String, + name: String, + pending_args: String, +} + +struct EventParser { + model: String, + started: bool, + next_index: usize, + open_part: Option<(usize, PartKind)>, + tool_states: HashMap, + saw_tool_call: bool, + stop_reason: Option, + usage: Usage, + done: bool, +} + +impl EventParser { + fn new(model: String) -> Self { + Self { + model, + started: false, + next_index: 0, + open_part: None, + tool_states: HashMap::new(), + saw_tool_call: false, + stop_reason: None, + usage: Usage::default(), + done: false, + } + } + + fn push(&mut self, chunk: ChatChunk) -> Vec { + if self.done { + return Vec::new(); + } + if let Some(model) = chunk.model.filter(|value| !value.trim().is_empty()) { + self.model = model; + } + if let Some(usage) = chunk.usage { + self.usage = usage_from_body(usage); + } + + let mut out = Vec::new(); + for choice in chunk.choices { + if let Some(usage) = choice.usage { + self.usage = usage_from_body(usage); + } + if let Some(reasoning) = + reasoning_delta(&choice.delta).filter(|value| !value.is_empty()) + { + self.ensure_started(&mut out); + let index = self.ensure_open(PartKind::Thinking, &mut out); + out.push(StreamEvent::ThinkingDelta { + index, + delta: reasoning, + }); + } + if let Some(text) = choice.delta.content.filter(|value| !value.is_empty()) { + self.ensure_started(&mut out); + let index = self.ensure_open(PartKind::Text, &mut out); + out.push(StreamEvent::TextDelta { index, delta: text }); + } + for call in choice.delta.tool_calls { + self.push_tool_delta(call, &mut out); + } + if let Some(reason) = choice.finish_reason { + self.stop_reason = Some(map_stop_reason(&reason, self.saw_tool_call)); + out.extend(self.finish()); + } + } + + out + } + + fn push_tool_delta(&mut self, call: wire::ToolCallDelta, out: &mut Vec) { + self.ensure_started(out); + self.saw_tool_call = true; + let key = call.index.unwrap_or(self.tool_states.len()); + let mut state = self.tool_states.remove(&key).unwrap_or_default(); + if let Some(id) = call.id.filter(|value| !value.trim().is_empty()) { + state.id = id; + } + let mut new_args = String::new(); + if let Some(function) = call.function { + if let Some(name) = function.name.filter(|value| !value.trim().is_empty()) { + state.name = name; + } + if let Some(arguments) = function.arguments.filter(|value| !value.is_empty()) { + new_args = arguments; + } + } + + if state.part_index.is_none() && !state.name.is_empty() { + self.close_open(out); + let part_index = self.next_index(); + let id = if state.id.is_empty() { + format!("call_ollama_{key}") + } else { + state.id.clone() + }; + state.id = id.clone(); + state.part_index = Some(part_index); + out.push(StreamEvent::PartStart { + index: part_index, + kind: PartKind::ToolCall, + tool: Some(ToolCallIntro { + id, + name: state.name.clone(), + }), + }); + if !state.pending_args.is_empty() { + out.push(StreamEvent::ToolJsonDelta { + index: part_index, + chunk: std::mem::take(&mut state.pending_args), + }); + } + } + + if let Some(part_index) = state.part_index { + if !new_args.is_empty() { + out.push(StreamEvent::ToolJsonDelta { + index: part_index, + chunk: new_args, + }); + } + } else if !new_args.is_empty() { + state.pending_args.push_str(&new_args); + } + + self.tool_states.insert(key, state); + } + + fn ensure_started(&mut self, out: &mut Vec) { + if self.started { + return; + } + self.started = true; + out.push(StreamEvent::MessageStart { + model: self.model.clone(), + }); + } + + fn ensure_open(&mut self, kind: PartKind, out: &mut Vec) -> usize { + if self.open_part.map(|(_, current)| current) == Some(kind) { + return self.open_part.map(|(index, _)| index).unwrap_or(0); + } + self.close_open(out); + let index = self.next_index(); + self.open_part = Some((index, kind)); + out.push(StreamEvent::PartStart { + index, + kind, + tool: None, + }); + index + } + + fn close_open(&mut self, out: &mut Vec) { + if let Some((index, _)) = self.open_part.take() { + out.push(StreamEvent::PartStop { index }); + } + } + + fn next_index(&mut self) -> usize { + let index = self.next_index; + self.next_index += 1; + index + } + + fn finish(&mut self) -> Vec { + if self.done { + return Vec::new(); + } + self.done = true; + let mut out = Vec::new(); + if !self.started { + self.ensure_started(&mut out); + } + self.close_open(&mut out); + let mut keys = self.tool_states.keys().copied().collect::>(); + keys.sort_unstable(); + for key in keys { + if let Some(state) = self.tool_states.remove(&key) { + if let Some(index) = state.part_index { + out.push(StreamEvent::PartMeta { + index, + meta: json!({ "provider": "ollama", "id": state.id, "name": state.name }), + }); + out.push(StreamEvent::PartStop { index }); + } + } + } + out.push(StreamEvent::MessageStop { + stop_reason: self.stop_reason.unwrap_or({ + if self.saw_tool_call { + StopReason::ToolUse + } else { + StopReason::EndTurn + } + }), + usage: self.usage, + }); + out + } +} + +fn chunk_error_message(chunk: &ChatChunk) -> Option { + chunk + .choices + .iter() + .filter_map(|choice| choice.error.as_ref()) + .find_map(error_message) + .or_else(|| { + chunk + .choices + .iter() + .any(|choice| choice.error.is_some()) + .then(|| "ollama stream error".to_string()) + }) +} + +fn reasoning_delta(delta: &wire::ChatDelta) -> Option { + if let Some(value) = delta.thinking.as_deref().filter(|value| !value.is_empty()) { + return Some(value.to_string()); + } + if let Some(value) = delta + .reasoning_content + .as_deref() + .filter(|value| !value.is_empty()) + { + return Some(value.to_string()); + } + if let Some(value) = delta.reasoning.as_deref().filter(|value| !value.is_empty()) { + return Some(value.to_string()); + } + None +} + +fn usage_from_body(body: wire::UsageBody) -> Usage { + Usage { + input_tokens: body.prompt_tokens, + output_tokens: body.completion_tokens, + total_tokens: if body.total_tokens > 0 { + body.total_tokens + } else { + body.prompt_tokens.saturating_add(body.completion_tokens) + }, + reasoning_tokens: 0, + cache_read_tokens: 0, + cache_creation_tokens: 0, + } +} + +fn error_message(error: &wire::ApiErrorBody) -> Option { + if error.message.trim().is_empty() { + return None; + } + Some(match (&error.kind, &error.code) { + (Some(kind), Some(code)) if !kind.trim().is_empty() => { + format!("{kind} ({code}): {}", error.message) + } + (Some(kind), None) if !kind.trim().is_empty() => format!("{kind}: {}", error.message), + _ => error.message.clone(), + }) +} + +fn map_stop_reason(raw: &str, saw_tool_call: bool) -> StopReason { + match raw { + "stop" => StopReason::EndTurn, + "tool_calls" | "function_call" => StopReason::ToolUse, + "length" => StopReason::MaxTokens, + "content_filter" => StopReason::Other, + "error" => StopReason::Other, + _ if saw_tool_call => StopReason::ToolUse, + _ => StopReason::Other, + } +} diff --git a/crates/sinew-ollama/src/wire.rs b/crates/sinew-ollama/src/wire.rs new file mode 100644 index 00000000..486f34f9 --- /dev/null +++ b/crates/sinew-ollama/src/wire.rs @@ -0,0 +1,174 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Serialize)] +pub struct ChatCompletionsRequest<'a> { + pub model: &'a str, + pub messages: Vec>, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub tools: Vec>, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, + pub stream: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub stream_options: Option, +} + +#[derive(Debug, Serialize)] +pub struct StreamOptions { + pub include_usage: bool, +} + +#[derive(Debug, Serialize)] +#[serde(untagged)] +pub enum WireMessage<'a> { + System { + role: &'static str, + content: WireContent, + }, + User { + role: &'static str, + content: WireContent, + }, + Assistant { + role: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + content: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + tool_calls: Vec>, + }, + Tool { + role: &'static str, + content: WireContent, + tool_call_id: &'a str, + }, +} + +#[derive(Debug, Serialize)] +#[serde(untagged)] +pub enum WireContent { + Text(String), + Blocks(Vec), +} + +#[derive(Debug, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum WireContentBlock { + Text { text: String }, + ImageUrl { image_url: WireImageUrl }, +} + +#[derive(Debug, Serialize)] +pub struct WireImageUrl { + pub url: String, +} + +#[derive(Debug, Serialize)] +pub struct WireToolCall<'a> { + pub id: &'a str, + #[serde(rename = "type")] + pub kind: &'static str, + pub function: WireToolCallFunction<'a>, +} + +#[derive(Debug, Serialize)] +pub struct WireToolCallFunction<'a> { + pub name: &'a str, + pub arguments: String, +} + +#[derive(Debug, Serialize)] +pub struct WireTool<'a> { + #[serde(rename = "type")] + pub kind: &'static str, + pub function: WireToolFunction<'a>, +} + +#[derive(Debug, Serialize)] +pub struct WireToolFunction<'a> { + pub name: &'a str, + pub description: &'a str, + pub parameters: &'a Value, +} + +#[derive(Debug, Deserialize)] +pub struct ChatChunk { + #[serde(default)] + pub model: Option, + #[serde(default)] + pub choices: Vec, + #[serde(default)] + pub usage: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ChatChoice { + #[serde(default)] + pub delta: ChatDelta, + #[serde(default)] + pub finish_reason: Option, + #[serde(default)] + pub usage: Option, + #[serde(default)] + pub error: Option, +} + +#[derive(Debug, Default, Deserialize)] +pub struct ChatDelta { + #[serde(default)] + pub content: Option, + #[serde(default)] + pub reasoning: Option, + #[serde(default)] + pub reasoning_content: Option, + #[serde(default)] + pub thinking: Option, + #[serde(default)] + pub tool_calls: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct ToolCallDelta { + #[serde(default)] + pub index: Option, + #[serde(default)] + pub id: Option, + #[serde(default)] + pub function: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ToolCallFunctionDelta { + #[serde(default)] + pub name: Option, + #[serde(default)] + pub arguments: Option, +} + +#[derive(Debug, Default, Clone, Deserialize)] +pub struct UsageBody { + #[serde(default)] + pub prompt_tokens: u32, + #[serde(default)] + pub completion_tokens: u32, + #[serde(default)] + pub total_tokens: u32, +} + +#[derive(Debug, Default, Deserialize)] +pub struct ApiErrorEnvelope { + #[serde(default)] + pub error: ApiErrorBody, +} + +#[derive(Debug, Default, Deserialize)] +pub struct ApiErrorBody { + #[serde(default)] + pub message: String, + #[serde(default, rename = "type")] + pub kind: Option, + #[serde(default)] + pub code: Option, +} diff --git a/crates/sinew-openai/src/auth.rs b/crates/sinew-openai/src/auth.rs index 5c6f87d3..b6184b75 100644 --- a/crates/sinew-openai/src/auth.rs +++ b/crates/sinew-openai/src/auth.rs @@ -324,6 +324,38 @@ pub fn default_auth_path() -> Result { Ok(dirs.data_local_dir().join("openai-auth.json")) } +pub fn all_auth_files() -> Result> { + let dirs = ProjectDirs::from("dev", "hyrak", "sinew") + .ok_or_else(|| AppError::Auth("unable to resolve local data directory".into()))?; + let dir = dirs.data_local_dir(); + let mut files = Vec::new(); + + let default_path = dir.join("openai-auth.json"); + if default_path.exists() { + files.push(("openai".to_string(), default_path)); + } + + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if let Some(filename) = path.file_name().and_then(|f| f.to_str()) { + if filename.starts_with("openai-auth-") && filename.ends_with(".json") { + let suffix = filename + .strip_prefix("openai-auth-") + .and_then(|s| s.strip_suffix(".json")) + .unwrap_or("custom"); + let key = format!("openai:{}", suffix); + if !files.iter().any(|(_, p)| p == &path) { + files.push((key, path)); + } + } + } + } + } + + Ok(files) +} + pub fn load_default_auth_status() -> Result { load_auth_status(&default_auth_path()?) } @@ -391,11 +423,26 @@ struct AuthCodeTokenBody { expires_in: Option, } +pub fn path_for_auth_key(key: &str) -> Result { + let dirs = ProjectDirs::from("dev", "hyrak", "sinew") + .ok_or_else(|| AppError::Auth("unable to resolve local data directory".into()))?; + let dir = dirs.data_local_dir(); + if key == "openai" { + Ok(dir.join("openai-auth.json")) + } else if key.starts_with("openai:") { + let suffix = key.strip_prefix("openai:").unwrap(); + Ok(dir.join(format!("openai-auth-{}.json", suffix))) + } else { + Err(AppError::Auth(format!("invalid auth key: {key}"))) + } +} + pub async fn exchange_oauth_code( http: &reqwest::Client, code: &str, redirect_uri: &str, pkce: &PkceCodes, + target_key: Option, ) -> Result { let response = http .post(OPENAI_OAUTH_TOKEN_URL) @@ -424,8 +471,35 @@ pub async fn exchange_oauth_code( .json() .await .map_err(|err| AppError::Decode(format!("invalid openai oauth body: {err}")))?; + + let target_path = if let Some(key) = target_key { + path_for_auth_key(&key)? + } else { + let default_path = default_auth_path()?; + if default_path.exists() + && load_auth_status(&default_path) + .map(|s| s.connected) + .unwrap_or(false) + { + let dir = default_path.parent().unwrap(); + let mut index = 2; + loop { + let p = dir.join(format!("openai-auth-{}.json", index)); + if !p.exists() { + break p; + } + index += 1; + if index > 100 { + break dir.join(format!("openai-auth-{}.json", index)); + } + } + } else { + default_path + } + }; + save_oauth_tokens( - &default_auth_path()?, + &target_path, &body.id_token, &body.access_token, &body.refresh_token, @@ -463,6 +537,31 @@ fn save_oauth_tokens( Ok(status_from_auth(&auth)) } +pub fn save_raw_access_token(path: &Path, access_token: &str) -> Result { + let expires_at_ms = + token_expiry_ms(access_token).unwrap_or_else(|| now_ms() + 3_600_000_i64 - REFRESH_SKEW_MS); + let account_id = token_account_id(access_token); + let email = token_email(access_token); + let plan_type = token_plan_type(access_token); + + let auth = StoredAuth { + provider: "openai".into(), + auth_mode: "oauth".into(), + tokens: StoredTokens { + access_token: access_token.into(), + refresh_token: "".into(), + id_token: Some(access_token.into()), + account_id, + email, + plan_type, + expires_at_ms: Some(expires_at_ms), + }, + last_refresh_ms: Some(now_ms()), + }; + write_auth_file(path, &auth)?; + Ok(status_from_auth(&auth)) +} + fn write_auth_file(path: &Path, auth: &StoredAuth) -> Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) diff --git a/crates/sinew-openai/src/client.rs b/crates/sinew-openai/src/client.rs index 5069f8dc..5547f328 100644 --- a/crates/sinew-openai/src/client.rs +++ b/crates/sinew-openai/src/client.rs @@ -1,6 +1,7 @@ use std::{ collections::HashSet, sync::{Arc, Mutex}, + time::Instant, }; use async_trait::async_trait; @@ -49,6 +50,17 @@ impl OpenAiConfig { "no openai oauth credential found. Connect OpenAI in Settings > Providers".into(), )) } + + pub fn from_file(path: &std::path::Path) -> Result { + if let Some(credential) = Credential::from_sinew_auth_file(path)? { + return Ok(Self::new(credential)); + } + + Err(AppError::Auth(format!( + "unable to load openai credential from path {}", + path.display() + ))) + } } pub struct OpenAiProvider { @@ -74,6 +86,10 @@ impl OpenAiProvider { Self::new(OpenAiConfig::from_default_sources()?) } + pub fn from_file(path: &std::path::Path) -> Result { + Self::new(OpenAiConfig::from_file(path)?) + } + async fn post(&self, route: &str) -> Result { let bearer = self.config.credential.bearer(&self.http).await?; let base_url = if bearer.is_oauth { @@ -88,10 +104,13 @@ impl OpenAiProvider { .header("authorization", format!("Bearer {}", bearer.token)); if bearer.is_oauth { + request = request.header("user-agent", "codex-cli"); request = request.header("openai-beta", "responses=experimental"); if let Some(account_id) = bearer.account_id { request = request.header("chatgpt-account-id", account_id); } + } else { + request = request.header("user-agent", USER_AGENT); } Ok(request) @@ -105,14 +124,14 @@ impl Provider for OpenAiProvider { } fn capabilities(&self, model: &ModelRef) -> Option { - if model.provider != "openai" { + if model.provider != "openai" && !model.provider.starts_with("openai:") { return None; } Some(model_info::capabilities(model)) } async fn estimate_tokens(&self, request: ProviderRequest) -> Result { - if request.model.provider != "openai" { + if request.model.provider != "openai" && !request.model.provider.starts_with("openai:") { return Err(AppError::Unsupported(format!( "openai provider cannot count model provider {}", request.model.provider @@ -163,7 +182,7 @@ impl Provider for OpenAiProvider { } async fn stream(&self, request: ProviderRequest) -> Result { - if request.model.provider != "openai" { + if request.model.provider != "openai" && !request.model.provider.starts_with("openai:") { return Err(AppError::Unsupported(format!( "openai provider cannot run model provider {}", request.model.provider @@ -251,17 +270,29 @@ async fn stream_sse_request_with_bearer( .header("authorization", format!("Bearer {}", bearer.token)); if bearer.is_oauth { + builder = builder.header("user-agent", "codex-cli"); builder = builder.header("openai-beta", "responses=experimental"); if let Some(account_id) = bearer.account_id { builder = builder.header("chatgpt-account-id", account_id); } + } else { + builder = builder.header("user-agent", USER_AGENT); } + let req_start = Instant::now(); let response = builder .json(&body) .send() .await .map_err(|err| AppError::Network(err.to_string()))?; + let http_ms = req_start.elapsed().as_millis(); + tracing::debug!( + provider = "openai", + model = default_model, + http_ms, + transport = "sse", + "OpenAI HTTP round-trip (stream setup)" + ); if !response.status().is_success() { return Err(read_http_error(response).await); diff --git a/crates/sinew-openai/src/lib.rs b/crates/sinew-openai/src/lib.rs index a4be9542..9d3b1e2e 100644 --- a/crates/sinew-openai/src/lib.rs +++ b/crates/sinew-openai/src/lib.rs @@ -7,9 +7,9 @@ mod websocket; mod wire; pub use auth::{ - delete_default_auth, exchange_oauth_code, generate_pkce, generate_state, - load_default_auth_status, oauth_authorize_url, BearerToken, Credential, OpenAiAuthStatus, - PkceCodes, + all_auth_files, default_auth_path, delete_default_auth, exchange_oauth_code, generate_pkce, + generate_state, load_auth_status, load_default_auth_status, oauth_authorize_url, + save_raw_access_token, BearerToken, Credential, OpenAiAuthStatus, PkceCodes, }; pub use client::{OpenAiConfig, OpenAiProvider}; pub use model_info::{MODEL_ID, MODEL_MAX_OUTPUT, MODEL_WINDOW}; diff --git a/crates/sinew-openai/src/stream.rs b/crates/sinew-openai/src/stream.rs index 476bc3a4..e5989ca0 100644 --- a/crates/sinew-openai/src/stream.rs +++ b/crates/sinew-openai/src/stream.rs @@ -255,10 +255,8 @@ impl EventParser { }); } } - Some("message") => { - if message_text(item).is_some() { - self.start_text(index, out); - } + Some("message") if message_text(item).is_some() => { + self.start_text(index, out); } _ => {} } diff --git a/crates/sinew-openai/src/websocket.rs b/crates/sinew-openai/src/websocket.rs index 514c455a..5311e629 100644 --- a/crates/sinew-openai/src/websocket.rs +++ b/crates/sinew-openai/src/websocket.rs @@ -159,7 +159,11 @@ impl ResponsesWebsocketConnection { "authorization", header_value(&format!("Bearer {}", bearer.token), "authorization")?, ); - headers.insert("user-agent", HeaderValue::from_static(USER_AGENT)); + if bearer.is_oauth { + headers.insert("user-agent", HeaderValue::from_static("codex-cli")); + } else { + headers.insert("user-agent", HeaderValue::from_static(USER_AGENT)); + } headers.insert("content-type", HeaderValue::from_static("application/json")); headers.insert( "openai-beta", diff --git a/crates/sinew-openrouter/src/client.rs b/crates/sinew-openrouter/src/client.rs index 10f7a4eb..814555ec 100644 --- a/crates/sinew-openrouter/src/client.rs +++ b/crates/sinew-openrouter/src/client.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, sync::Arc}; +use std::{collections::HashMap, sync::Arc, time::Instant}; use async_trait::async_trait; use serde::{Deserialize, Serialize}; @@ -139,13 +139,16 @@ impl Provider for OpenRouterProvider { } let body = build_chat_request(&request, &caps)?; - + let req_start = Instant::now(); + let model_name = request.model.name.clone(); let response = self .post("/chat/completions") .json(&body) .send() .await .map_err(|err| AppError::Network(err.to_string()))?; + let http_ms = req_start.elapsed().as_millis(); + tracing::debug!(provider = "openrouter", model = model_name, http_ms, "HTTP round-trip"); if !response.status().is_success() { return Err(read_http_error(response).await); } diff --git a/extract.cjs b/extract.cjs new file mode 100644 index 00000000..a7c61ce2 --- /dev/null +++ b/extract.cjs @@ -0,0 +1,31 @@ +const fs = require('fs'); + +let chatPane = fs.readFileSync('src/components/chat/ChatPane.tsx', 'utf8'); + +const fnsToExtract = [ + /function formatTurnDuration\(durationMs: number\): string \{[\s\S]*?\n\}/, + /function formatFullTokenCount\(value: number\): string \{[\s\S]*?\n\}/, + /function formatCompactTokenCount\(value: number\): string \{[\s\S]*?\n\}/, + /function safeTokenCount\(value: number \| undefined\): number \{[\s\S]*?\n\}/, + /function hashString\(value: string\): string \{[\s\S]*?\n\}/ +]; + +let utilsCode = 'import { ChatBlock, ChatMessage, Part } from "../../types";\n\n'; + +for (const regex of fnsToExtract) { + const match = chatPane.match(regex); + if (match) { + // Add export + const code = match[0].replace(/^function/, 'export function'); + utilsCode += code + '\n\n'; + // Remove from ChatPane + chatPane = chatPane.replace(match[0], ''); + } +} + +// Add imports to ChatPane +const importStatement = 'import {\n formatTurnDuration,\n formatFullTokenCount,\n formatCompactTokenCount,\n safeTokenCount,\n hashString\n} from "./chatUtils";\n'; +chatPane = chatPane.replace(/(import .*?\n)+/, (match) => match + importStatement); + +fs.writeFileSync('src/components/chat/chatUtils.ts', utilsCode); +fs.writeFileSync('src/components/chat/ChatPane.tsx', chatPane); diff --git a/fix-encoding-regex.cjs b/fix-encoding-regex.cjs new file mode 100644 index 00000000..4b63b57a --- /dev/null +++ b/fix-encoding-regex.cjs @@ -0,0 +1,60 @@ +const fs = require('fs'); +let code = fs.readFileSync('src/components/SettingsPane.tsx', 'utf8'); + +// The file has some corrupted sequences, let's fix them with regex or simple replacements + +// "Cursor connecté � vous pouvez..." => "Cursor connecté — vous pouvez..." +code = code.replace(/Cursor connect\u00C3\u00A9 \u00EF\u00BF\u00BD vous/g, 'Cursor connecté — vous'); +code = code.replace(/Cursor connecté � vous/g, 'Cursor connecté — vous'); + +// "return to Cursor » � c'est normal" => "return to Cursor » — c'est normal" +code = code.replace(/» � c'est normal/g, "» — c'est normal"); + +// "ï¿½Ü¬ï¸ Jour" => "☀️ Jour" +code = code.replace(/ï¿½Ü¬ï¸ Jour/g, '☀️ Jour'); +code = code.replace(/ï¿½Ü¬ï¸ Day/g, '☀️ Day'); + +// "�x ⏳Système" => "💻 Système" +// wait, we replaced part of it already? +code = code.replace(/�x ⏳Système/g, '💻 Système'); +code = code.replace(/�x ⏳System/g, '💻 System'); + +// "�x CARTE GENERALE 3" => "⚙️ CARTE GENERALE 3" +code = code.replace(/�x CARTE GENERALE 3/g, '⚙️ CARTE GENERALE 3'); + +// "JulienPiron.fr � Améliorations Clés" => "JulienPiron.fr — Améliorations Clés" +code = code.replace(/JulienPiron\.fr � Améliorations Clés/g, 'JulienPiron.fr — Améliorations Clés'); +code = code.replace(/JulienPiron\.fr Fork � Key Enhancements/g, 'JulienPiron.fr Fork — Key Enhancements'); + +// percent == null ? "� " : +code = code.replace(/"� "/g, '"⏳ "'); + +// No servers yet � add +code = code.replace(/No servers yet � add/g, 'No servers yet — add'); + +// No sub-agents yet � click +code = code.replace(/No sub-agents yet � click/g, 'No sub-agents yet — click'); + +// "�S Modèles vérifiés" +code = code.replace(/�S Modèles vérifiés/g, '✅ Modèles vérifiés'); +code = code.replace(/�S Modèles/g, '✅ Modèles'); + +// Replace general broken A tilde sequences +code = code.replace(/Améliorations/g, 'Améliorations'); +code = code.replace(/Clés/g, 'Clés'); +code = code.replace(/Paramètres/g, 'Paramètres'); +code = code.replace(/vérifiés/g, 'vérifiés'); +code = code.replace(/Modèles/g, 'Modèles'); +code = code.replace(/système/g, 'système'); +code = code.replace(/à/g, 'à'); +code = code.replace(/après/g, 'après'); +code = code.replace(/sécurisé/g, 'sécurisé'); +code = code.replace(/également/g, 'également'); +code = code.replace(/première/g, 'première'); +code = code.replace(/Libération/g, 'Libération'); +code = code.replace(/Côte/g, 'Côte'); +code = code.replace(/«/g, '«'); +code = code.replace(/»/g, '»'); + +fs.writeFileSync('src/components/SettingsPane.tsx', code, 'utf8'); +console.log('Fixed'); \ No newline at end of file diff --git a/fix-encoding.cjs b/fix-encoding.cjs new file mode 100644 index 00000000..b9df8519 --- /dev/null +++ b/fix-encoding.cjs @@ -0,0 +1,36 @@ +const fs = require('fs'); +let code = fs.readFileSync('src/components/SettingsPane.tsx', 'utf8'); + +const replacements = { + '� vous': '— vous', + '« return to Cursor » � c\'est normal': '« return to Cursor » — c\'est normal', + '�x}�': '🛠️', + 'ï¿½Ü¬ï¸ ': '☀️', + '�x �': '💻', + 'Système': 'Système', + '�S�': '✨', + '�0diteur': 'Éditeur', + '�a�': '⚡', + '�x��': '🧠', + '�x ': '⚙️', + 'Libération': 'Libération', + '� Améliorations Clés': '— Améliorations Clés', + 'après la frappe. �0cran': 'après la frappe. Écran', + 'sécurisé': 'sécurisé', + '� ': '⏳', + 'Paramètres � Providers': 'Paramètres > Providers', + 'No servers yet � add': 'No servers yet — add', + '�aï¿½ï¸ ': '⚠️', + 'également': 'également', + 'première': 'première', + 'No sub-agents yet � click': 'No sub-agents yet — click', + '�S Modèles vérifiés': '✅ Modèles vérifiés', +}; + +for (const [bad, good] of Object.entries(replacements)) { + code = code.split(bad).join(good); +} + +// Ensure remaining � are cleaned up or checked +fs.writeFileSync('src/components/SettingsPane.tsx', code, 'utf8'); +console.log('Done'); \ No newline at end of file diff --git a/fix-encoding2.cjs b/fix-encoding2.cjs new file mode 100644 index 00000000..f4c8c619 --- /dev/null +++ b/fix-encoding2.cjs @@ -0,0 +1,23 @@ +const fs = require('fs'); +let code = fs.readFileSync('src/components/SettingsPane.tsx', 'utf8'); + +const replacements = { + '� ': '⏳ ', + '�S ': '✅ ', + 'é': 'é', + 'è': 'è', + 'ê': 'ê', + 'à ': 'à', + 'â': 'â', + 'î': 'î', + 'ç': 'ç', + 'ô': 'ô', + '�x ': '💻 ' +}; + +for (const [bad, good] of Object.entries(replacements)) { + code = code.split(bad).join(good); +} + +fs.writeFileSync('src/components/SettingsPane.tsx', code, 'utf8'); +console.log('Fixed 2'); \ No newline at end of file diff --git a/fix-encoding3.cjs b/fix-encoding3.cjs new file mode 100644 index 00000000..01ee49c9 --- /dev/null +++ b/fix-encoding3.cjs @@ -0,0 +1,12 @@ +const fs = require('fs'); +let code = fs.readFileSync('src/components/SettingsPane.tsx', 'utf8'); + +code = code.replace(/ï¿½Ü¬ï¸ /g, '☀️'); +code = code.replace(/�x /g, '⚙️ '); +code = code.replace(/�x ⏳/g, '💻 '); +code = code.replace(/�S /g, '✅ '); +code = code.replace(/� /g, '— '); +code = code.replace(/�/g, '—'); + +fs.writeFileSync('src/components/SettingsPane.tsx', code, 'utf8'); +console.log('Fixed 3'); \ No newline at end of file diff --git a/fix-encoding4.cjs b/fix-encoding4.cjs new file mode 100644 index 00000000..9f2b451a --- /dev/null +++ b/fix-encoding4.cjs @@ -0,0 +1,9 @@ +const fs = require('fs'); +let code = fs.readFileSync('src/components/SettingsPane.tsx', 'utf8'); + +code = code.replace(/—Ü¬ï¸ /g, '☀️'); +code = code.replace(/—x /g, '💻 '); +code = code.replace(/⚡ï¸ /g, '⚠️'); + +fs.writeFileSync('src/components/SettingsPane.tsx', code, 'utf8'); +console.log('Fixed 4'); \ No newline at end of file diff --git a/fix.cjs b/fix.cjs new file mode 100644 index 00000000..afee3092 --- /dev/null +++ b/fix.cjs @@ -0,0 +1,12 @@ +const fs = require('fs'); + +let chatUtils = fs.readFileSync('src/components/chat/chatUtils.ts', 'utf8'); +chatUtils = chatUtils.replace('import { ChatBlock, ChatMessage, Part } from "../../types";\n\n', ''); +fs.writeFileSync('src/components/chat/chatUtils.ts', chatUtils); + +let chatPane = fs.readFileSync('src/components/chat/ChatPane.tsx', 'utf8'); +if (!chatPane.includes('formatFullTokenCount')) { + console.log("Adding imports to ChatPane"); +} +chatPane = 'import { formatTurnDuration, formatFullTokenCount, formatCompactTokenCount, safeTokenCount, hashString } from "./chatUtils";\n' + chatPane; +fs.writeFileSync('src/components/chat/ChatPane.tsx', chatPane); diff --git a/fix2.cjs b/fix2.cjs new file mode 100644 index 00000000..9ab5106b --- /dev/null +++ b/fix2.cjs @@ -0,0 +1,4 @@ +const fs = require('fs'); +let chatPane = fs.readFileSync('src/components/chat/ChatPane.tsx', 'utf8'); +chatPane = chatPane.replace('safeTokenCount, ', ''); +fs.writeFileSync('src/components/chat/ChatPane.tsx', chatPane); diff --git a/index.html b/index.html index 863d5263..73c9c6ec 100644 --- a/index.html +++ b/index.html @@ -10,9 +10,23 @@ href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap" rel="stylesheet" /> +

- + \ No newline at end of file diff --git a/launch-sinew-dev.bat b/launch-sinew-dev.bat new file mode 100644 index 00000000..4cef1194 --- /dev/null +++ b/launch-sinew-dev.bat @@ -0,0 +1,36 @@ +@echo off +title Sinew Dev Environment +cd /d "%~dp0" +echo ================================================== +echo DEMARRAGE DE L'ENVIRONNEMENT DE DEV SINEW +echo ================================================== +echo. + +if not exist node_modules ( + echo [1/3] Installation des dependances npm... + call npm install + if errorlevel 1 ( + echo [ERREUR] Impossible d'installer les dependances npm. + pause + exit /b 1 + ) +) else ( + echo [1/3] Dependances npm deja installees. +) + +echo. +echo [2/3] Preparation des sidecars... +call npm run prepare-sidecars +if errorlevel 1 ( + echo [ATTENTION] La preparation des sidecars a signale une erreur. Tentative de continuation... +) + +echo. +echo [3/3] Lancement de l'application en mode dev (Tauri)... +call npm run tauri dev + +if errorlevel 1 ( + echo. + echo [ERREUR] L'application s'est arretee avec une erreur. + pause +) diff --git a/move.py b/move.py new file mode 100644 index 00000000..367db08f --- /dev/null +++ b/move.py @@ -0,0 +1,39 @@ +import re + +with open('src/components/SettingsPane.tsx', 'r', encoding='utf-8') as f: + content = f.read() + +# 1. Find the blocks +sync_re = re.compile(r'(\s*\{\/\*\s*Synchronisation Multi-PC.*?)(?=\{\/\*\s*Recherche Sémantique)', re.DOTALL) +semantic_re = re.compile(r'(\s*\{\/\*\s*Recherche Sémantique.*?)(?=\{\/\*\s*Grille des 8 sous-cartes)', re.DOTALL) +ai_learning_re = re.compile(r'(\s*\{\/\*\s*Apprentissage Automatique IA.*?)(?=\{\/\*\s*Pacte de Libération)', re.DOTALL) + +sync_match = sync_re.search(content) +semantic_match = semantic_re.search(content) +ai_match = ai_learning_re.search(content) + +if not sync_match or not semantic_match or not ai_match: + print("Could not find one of the source blocks!") + exit(1) + +sync_block = sync_match.group(1) +semantic_block = semantic_match.group(1) +ai_block = ai_match.group(1) + +# Remove them from current position +content = content.replace(sync_block, '') +content = content.replace(semantic_block, '') +content = content.replace(ai_block, '') + +# Prepare the new unified diagnostic layout +# We will inject them right before "Diagnostic Système SOTA" inside the diagnostic tab. +sota_marker = r"{/* Diagnostic Système SOTA */}" +if sota_marker not in content: + print("Could not find SOTA marker!") + exit(1) + +# To harmonize appearance, they are all .settings-pane__about-card. +# The diagnostic tab has
which applies CSS grid (usually 2 columns or auto-fit). +# Sync, Semantic, and AI Learning are full-width cards. If we put them in the grid, they might be squished or they might span full width if we set gridColumn: "1 / -1". +# But SOTA and Pacte are currently inside the grid? No, wait! +# Let's see if SOTA is inside the grid. diff --git a/package-lock.json b/package-lock.json index 867c9f34..1c115d17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,13 @@ { "name": "sinew", - "version": "0.1.12", + "version": "0.1.26", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sinew", - "version": "0.1.12", + "version": "0.1.26", + "hasInstallScript": true, "dependencies": { "@iconify-json/solar": "1.2.5", "@iconify-json/vscode-icons": "1.2.45", @@ -20,6 +21,7 @@ "@xterm/addon-web-links": "0.13.0-beta.218", "@xterm/addon-webgl": "0.20.0-beta.217", "@xterm/xterm": "6.1.0-beta.218", + "cytoscape": "^3.33.4", "highlight.js": "^11.10.0", "lucide-react": "1.8.0", "mermaid": "11.15.0", @@ -2114,9 +2116,9 @@ "license": "MIT" }, "node_modules/cytoscape": { - "version": "3.33.3", - "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.3.tgz", - "integrity": "sha512-Gej7U+OKR+LZ8kvX7rb2HhCYJ0IhvEFsnkud4SB1PR+BUY/TsSO0dmOW59WEVLu51b1Rm+gQRKoz4bLYxGSZ2g==", + "version": "3.33.4", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.4.tgz", + "integrity": "sha512-HIN5Pmd9MrX9BkV7tDwnOcEJCSFvCpc8X97h3f508J6I5FsqAY65wKOCvgH2CuP42CaahWaz4tuh32SOOIH7ww==", "license": "MIT", "engines": { "node": ">=0.10" diff --git a/package.json b/package.json index 488e307c..506d00de 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,14 @@ "scripts": { "dev": "vite", "build": "tsc --noEmit && vite build", + "check": "powershell -ExecutionPolicy Bypass -File ./scripts/check.ps1", + "test:chrome-bridge": "node sinew-chrome-bridge/e2e-local.mjs", + "test:chrome-bridge:structured": "node sinew-chrome-bridge/e2e-structured.mjs", "prepare-sidecars": "node scripts/prepare-sidecars.mjs", + "prepare-agent-bridge": "node scripts/prepare-agent-bridge.mjs", "preview": "vite preview", - "tauri": "tauri" + "tauri": "tauri", + "compil": "powershell -ExecutionPolicy Bypass -File ./scripts/compil.ps1" }, "dependencies": { "@iconify-json/solar": "1.2.5", @@ -23,6 +28,7 @@ "@xterm/addon-web-links": "0.13.0-beta.218", "@xterm/addon-webgl": "0.20.0-beta.217", "@xterm/xterm": "6.1.0-beta.218", + "cytoscape": "^3.33.4", "highlight.js": "^11.10.0", "lucide-react": "1.8.0", "mermaid": "11.15.0", diff --git a/patch.cjs b/patch.cjs new file mode 100644 index 00000000..f7ec91bc --- /dev/null +++ b/patch.cjs @@ -0,0 +1,44 @@ +const fs = require('fs'); +let code = fs.readFileSync('src/components/SettingsPane.tsx', 'utf8'); + +// 1. Extract blocks +const syncRe = /(\s*\{\/\*\s*Synchronisation Multi-PC.*?)(?=\{\/\*\s*Recherche Sémantique)/s; +const semanticRe = /(\s*\{\/\*\s*Recherche Sémantique.*?)(?=\{\/\*\s*Grille des 8 sous-cartes)/s; +const aiLearningRe = /(\s*\{\/\*\s*Apprentissage Automatique IA.*?)(?=\{\/\*\s*Pacte de Libération)/s; + +const syncMatch = code.match(syncRe); +const semanticMatch = code.match(semanticRe); +const aiMatch = code.match(aiLearningRe); + +if (!syncMatch || !semanticMatch || !aiMatch) { + console.log("Could not find all source blocks!"); + process.exit(1); +} + +const syncBlock = syncMatch[1]; +const semanticBlock = semanticMatch[1]; +const aiBlock = aiMatch[1]; + +// 2. Remove them from their original locations +code = code.replace(syncBlock, ''); +code = code.replace(semanticBlock, ''); +code = code.replace(aiBlock, ''); + +// 3. Inject them into the diagnostic tab's grid +// The diagnostic tab starts with: +//
+// +// {/* Diagnostic Système SOTA */} +const insertionPoint = /\s*\{\/\*\s*Diagnostic Système SOTA\s*\*\/\}/; + +if (!insertionPoint.test(code)) { + console.log("Could not find SOTA marker!"); + process.exit(1); +} + +const injectedContent = syncBlock + semanticBlock + aiBlock + '\n {/* Diagnostic Système SOTA */}'; + +code = code.replace(insertionPoint, injectedContent); + +fs.writeFileSync('src/components/SettingsPane.tsx', code); +console.log("Done!"); diff --git a/patch2.js b/patch2.js new file mode 100644 index 00000000..ac02e262 Binary files /dev/null and b/patch2.js differ diff --git a/patch_dispatch.cjs b/patch_dispatch.cjs new file mode 100644 index 00000000..6b12d4a9 --- /dev/null +++ b/patch_dispatch.cjs @@ -0,0 +1,35 @@ +const fs = require('fs'); +let code = fs.readFileSync('crates/sinew-app/src/agent/tool_dispatch.rs', 'utf8'); + +const editFileReplacement = \ } else if canonical_name == tool_names::EDIT_FILE { + if mode == AgentMode::Plan { + return ToolRunResult::err("edit_file is unavailable in Plan mode", Vec::new()); + } + let mut result = edit_file.run(input, read_fingerprints).await; + if !result.is_error { + let lints = read_lints.run(serde_json::json!({})).await; + if !lints.is_error && !lints.output.trim().is_empty() && !lints.output.contains("No linter errors found") { + result.output.push_str("\\n\\n[Auto-Lint Diagnostics (Self-Healing)]:\\n"); + result.output.push_str(&lints.output); + } + } + result + } else if canonical_name == tool_names::WRITE_FILE { + if mode == AgentMode::Plan { + return ToolRunResult::err("write_file is unavailable in Plan mode", Vec::new()); + } + let mut result = write_file.run(input, read_fingerprints).await; + if !result.is_error { + let lints = read_lints.run(serde_json::json!({})).await; + if !lints.is_error && !lints.output.trim().is_empty() && !lints.output.contains("No linter errors found") { + result.output.push_str("\\n\\n[Auto-Lint Diagnostics (Self-Healing)]:\\n"); + result.output.push_str(&lints.output); + } + } + result\; + +const regex = /\}\s*else if canonical_name == tool_names::EDIT_FILE\s*\{\s*if mode == AgentMode::Plan\s*\{\s*return ToolRunResult::err\("edit_file is unavailable in Plan mode", Vec::new\(\)\);\s*\}\s*edit_file\.run\(input, read_fingerprints\)\.await\s*\}\s*else if canonical_name == tool_names::WRITE_FILE\s*\{\s*if mode == AgentMode::Plan\s*\{\s*return ToolRunResult::err\("write_file is unavailable in Plan mode", Vec::new\(\)\);\s*\}\s*write_file\.run\(input, read_fingerprints\)\.await/; + +code = code.replace(regex, editFileReplacement); + +fs.writeFileSync('crates/sinew-app/src/agent/tool_dispatch.rs', code); diff --git a/patch_duplicate.cjs b/patch_duplicate.cjs new file mode 100644 index 00000000..63c6ecb5 --- /dev/null +++ b/patch_duplicate.cjs @@ -0,0 +1,7 @@ +const fs = require('fs'); +let code = fs.readFileSync('src/components/SettingsPane.tsx', 'utf8'); + +const regex = /\{\/\*\s*Recherche Sémantique Vectorielle.*?<\/div>\s*<\/div>\n\n/s; +code = code.replace(regex, ''); + +fs.writeFileSync('src/components/SettingsPane.tsx', code); diff --git a/patch_todo.cjs b/patch_todo.cjs new file mode 100644 index 00000000..d3109199 --- /dev/null +++ b/patch_todo.cjs @@ -0,0 +1,83 @@ +const fs = require('fs'); +let code = fs.readFileSync('src/components/chat/TodoStrip.tsx', 'utf8'); + +// The replacement logic for Kanban rendering +const replacement = \ {active !== "messages" && visibleTaskEntries.length > 0 && ( + active === "team" ? ( +
+ {["pending", "in_progress", "blocked", "completed"].map((status) => { + const columnTasks = visibleTaskEntries.filter((e) => e.task.status === status); + if (columnTasks.length === 0 && status !== "in_progress") return null; + + const statusColors = { + pending: "var(--text-3)", + in_progress: "var(--accent-hi)", + blocked: "var(--danger)", + completed: "var(--ok)" + }; + const statusLabels = { + pending: "À faire", + in_progress: "En cours", + blocked: "Bloqué", + completed: "Terminé" + }; + + return ( +
+
+ + {statusLabels[status]} ({columnTasks.length}) +
+
+ {columnTasks.map(({ task, index }) => ( +
+
+ {task.text} +
+ +
+ ))} +
+
+ ); + })} +
+ ) : ( +
+ {visibleTaskEntries.map(({ task, index }) => ( +
+ + + {task.text} + {active === "team" && ( + + )} + +
+ ))} +
+ ) + )}\; + +code = code.replace( + /\{\s*active !== "messages" && visibleTaskEntries\.length > 0 && \(\s*
[\s\S]*?<\/div>\s*\)\s*\}/, + replacement +); + +fs.writeFileSync('src/components/chat/TodoStrip.tsx', code); diff --git a/peek.cjs b/peek.cjs new file mode 100644 index 00000000..ec14ac70 --- /dev/null +++ b/peek.cjs @@ -0,0 +1,5 @@ +const fs = require('fs'); +const code = fs.readFileSync('crates/sinew-app/src/agent/turn.rs', 'utf8'); + +const startIndex = code.indexOf('let result = run_tool('); +console.log(code.substring(startIndex, startIndex + 2000)); diff --git a/peek2.cjs b/peek2.cjs new file mode 100644 index 00000000..6ca9c682 --- /dev/null +++ b/peek2.cjs @@ -0,0 +1,3 @@ +const fs = require('fs'); +const code = fs.readFileSync('src/components/chat/TodoStrip.tsx', 'utf8'); +console.log(code.substring(24500, 25500)); diff --git a/scripts/agent-bridge/README.md b/scripts/agent-bridge/README.md new file mode 100644 index 00000000..b269205c --- /dev/null +++ b/scripts/agent-bridge/README.md @@ -0,0 +1,27 @@ +# agent-bridge (Node) + +Pont **optionnel** pour `agent.v1` — Sinew utilise par défaut le **bridge Rust** (aucune config). + +## Utilisateur final + +1. Ouvrir Sinew → **Réglages → Fournisseurs** +2. **Connecter Cursor** (OAuth Google ou GitHub) +3. Utiliser Composer — rien d'autre à installer ni variable d'environnement. + +## Développeur + +```bash +npm run prepare-agent-bridge # une fois (ou automatique via beforeBuildCommand / beforeDevCommand) +``` + +Repli Node automatique si le bridge Rust échoue **et** que `node_modules` est présent (build release ou `prepare-agent-bridge`). + +Forcer Node : `SINEW_CURSOR_BRIDGE=node` +Désactiver le repli : `SINEW_CURSOR_BRIDGE_FALLBACK=0` + +Tests : + +```powershell +.\scripts\agent-bridge\test-live.ps1 +.\scripts\agent-bridge\test-live-rust.ps1 +``` diff --git a/scripts/agent-bridge/exec-handlers.mjs b/scripts/agent-bridge/exec-handlers.mjs new file mode 100644 index 00000000..7a0a3f8d --- /dev/null +++ b/scripts/agent-bridge/exec-handlers.mjs @@ -0,0 +1,243 @@ +import fs from "node:fs"; +import path from "node:path"; +import { create } from "@bufbuild/protobuf"; +import { + DeleteRejectedSchema, + DeleteResultSchema, + DeleteSuccessSchema, + LsDirectoryTreeNodeSchema, + LsRejectedSchema, + LsResultSchema, + LsSuccessSchema, + ReadErrorSchema, + ReadResultSchema, + ReadSuccessSchema, + WriteRejectedSchema, + WriteResultSchema, + WriteSuccessSchema, +} from "./vendor/agent_pb.ts"; + +const READ_LIMIT = 512 * 1024; + +export function resolveWorkspacePath(workspaceRoot, rawPath) { + const root = workspaceRoot?.trim() || process.cwd(); + const target = path.resolve(root, rawPath || "."); + const normalizedRoot = path.resolve(root); + if (!target.startsWith(normalizedRoot)) { + throw new Error("path outside workspace"); + } + return target; +} + +function shallowLayout(workspaceRoot, maxEntries = 80) { + const root = workspaceRoot?.trim() || process.cwd(); + const childrenDirs = []; + const childrenFiles = []; + try { + const entries = fs.readdirSync(root, { withFileTypes: true }); + for (const entry of entries.slice(0, maxEntries)) { + const absPath = path.join(root, entry.name); + if (entry.isDirectory()) { + childrenDirs.push( + create(LsDirectoryTreeNodeSchema, { + absPath, + childrenDirs: [], + childrenFiles: [], + childrenWereProcessed: false, + fullSubtreeExtensionCounts: {}, + numFiles: 0, + }), + ); + } else if (entry.isFile()) { + childrenFiles.push({ name: entry.name }); + } + } + } catch { + /* ignore */ + } + return create(LsDirectoryTreeNodeSchema, { + absPath: root, + childrenDirs, + childrenFiles, + childrenWereProcessed: true, + fullSubtreeExtensionCounts: {}, + numFiles: childrenFiles.length, + }); +} + +export function buildProjectLayout(workspaceRoot, snapshot) { + if (snapshot?.projectLayout) { + return undefined; + } + return shallowLayout(workspaceRoot); +} + +export function handleReadArgs(execMsg, args, workspaceRoot, sendExecResult) { + const filePath = args.path || args.filePath || ""; + try { + const full = resolveWorkspacePath(workspaceRoot, filePath); + const buf = fs.readFileSync(full); + const truncated = buf.length > READ_LIMIT; + const slice = truncated ? buf.subarray(0, READ_LIMIT) : buf; + const content = slice.toString("utf8"); + const totalLines = content.split("\n").length; + sendExecResult( + execMsg, + "readResult", + create(ReadResultSchema, { + result: { + case: "success", + value: create(ReadSuccessSchema, { + path: filePath, + totalLines, + fileSize: BigInt(buf.length), + truncated, + output: { case: "content", value: content }, + }), + }, + }), + ); + } catch (err) { + sendExecResult( + execMsg, + "readResult", + create(ReadResultSchema, { + result: { + case: "error", + value: create(ReadErrorSchema, { + path: filePath, + error: String(err?.message || err), + }), + }, + }), + ); + } +} + +export function handleLsArgs(execMsg, args, workspaceRoot, sendExecResult) { + const dirPath = args.path || args.targetDirectory || "."; + try { + const full = resolveWorkspacePath(workspaceRoot, dirPath); + const tree = shallowLayout(full, 120); + sendExecResult( + execMsg, + "lsResult", + create(LsResultSchema, { + result: { + case: "success", + value: create(LsSuccessSchema, { directoryTreeRoot: tree }), + }, + }), + ); + } catch (err) { + sendExecResult( + execMsg, + "lsResult", + create(LsResultSchema, { + result: { + case: "rejected", + value: create(LsRejectedSchema, { + path: dirPath, + reason: String(err?.message || err), + }), + }, + }), + ); + } +} + +export function handleWriteArgs(execMsg, args, workspaceRoot, sendExecResult) { + const filePath = args.path || args.filePath || args.target_file || ""; + const oldString = args.old_string ?? args.oldString ?? ""; + const newString = + args.new_string ?? + args.newString ?? + args.contents ?? + args.content ?? + args.text ?? + args.replacement ?? + ""; + try { + const full = resolveWorkspacePath(workspaceRoot, filePath); + fs.mkdirSync(path.dirname(full), { recursive: true }); + let content = String(newString); + if (oldString && fs.existsSync(full)) { + const prior = fs.readFileSync(full, "utf8"); + if (!prior.includes(oldString)) { + throw new Error("old_string not found in file"); + } + content = prior.replace(oldString, newString); + } else if (!oldString) { + content = String(newString); + } + fs.writeFileSync(full, content, "utf8"); + const lines = String(content).split("\n").length; + sendExecResult( + execMsg, + "writeResult", + create(WriteResultSchema, { + result: { + case: "success", + value: create(WriteSuccessSchema, { + path: filePath, + linesCreated: lines, + fileSize: Buffer.byteLength(String(content), "utf8"), + }), + }, + }), + ); + } catch (err) { + sendExecResult( + execMsg, + "writeResult", + create(WriteResultSchema, { + result: { + case: "rejected", + value: create(WriteRejectedSchema, { + path: filePath, + reason: String(err?.message || err), + }), + }, + }), + ); + } +} + +export function handleDeleteArgs(execMsg, args, workspaceRoot, sendExecResult) { + const filePath = args.path || args.filePath || ""; + try { + const full = resolveWorkspacePath(workspaceRoot, filePath); + const prev = fs.existsSync(full) ? fs.readFileSync(full, "utf8") : ""; + const size = BigInt(fs.statSync(full).size); + fs.unlinkSync(full); + sendExecResult( + execMsg, + "deleteResult", + create(DeleteResultSchema, { + result: { + case: "success", + value: create(DeleteSuccessSchema, { + path: filePath, + deletedFile: filePath, + fileSize: size, + prevContent: prev, + }), + }, + }), + ); + } catch (err) { + sendExecResult( + execMsg, + "deleteResult", + create(DeleteResultSchema, { + result: { + case: "rejected", + value: create(DeleteRejectedSchema, { + path: filePath, + reason: String(err?.message || err), + }), + }, + }), + ); + } +} diff --git a/scripts/agent-bridge/export-agent-fds-prost.mjs b/scripts/agent-bridge/export-agent-fds-prost.mjs new file mode 100644 index 00000000..296892a4 --- /dev/null +++ b/scripts/agent-bridge/export-agent-fds-prost.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node +/** + * Export agent.v1 as standard google.protobuf.FileDescriptorSet (prost-reflect). + */ +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; +import { + FileDescriptorProtoSchema, + FileDescriptorSetSchema, +} from "@bufbuild/protobuf/wkt"; +import { base64Decode } from "@bufbuild/protobuf/wire"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const vendorPath = path.join(__dirname, "vendor", "agent_pb.ts"); +const outPath = path.join( + __dirname, + "..", + "..", + "crates", + "sinew-cursor", + "proto", + "agent.pb", +); + +const source = await readFile(vendorPath, "utf8"); +const match = source.match(/fileDesc\(\s*\n?\s*"([A-Za-z0-9+/=]+)"/); +if (!match) { + console.error(`Could not find fileDesc(...) in ${vendorPath}`); + process.exit(1); +} + +const root = fromBinary(FileDescriptorProtoSchema, base64Decode(match[1])); +const fds = create(FileDescriptorSetSchema, { file: [root] }); +const bytes = toBinary(FileDescriptorSetSchema, fds); +await mkdir(path.dirname(outPath), { recursive: true }); +await writeFile(outPath, bytes); +console.log(`Wrote ${outPath} (${bytes.length} bytes)`); diff --git a/scripts/agent-bridge/h2-bridge.mjs b/scripts/agent-bridge/h2-bridge.mjs new file mode 100644 index 00000000..88a89d47 --- /dev/null +++ b/scripts/agent-bridge/h2-bridge.mjs @@ -0,0 +1,175 @@ +#!/usr/bin/env node +/** + * HTTP/2 bidirectional pipe for Cursor agent.v1 (from cursor-oauth-opencode). + */ +import http2 from "node:http2"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// Centralized JSON logger +const LOG_DIR = path.join( + process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"), + "dev", "hyrak", "sinew", "data", "logs" +); +let _logStream = null; +function _ensureLog() { + if (_logStream) return; + try { fs.mkdirSync(LOG_DIR, { recursive: true }); } catch {} + _logStream = fs.createWriteStream(path.join(LOG_DIR, "h2-bridge.log"), { flags: "a" }); +} +function logEvent(ev) { + _ensureLog(); + try { _logStream.write(JSON.stringify({ ts: Date.now(), ...ev }) + "\n"); } catch {} +} +const bridgeStart = Date.now(); + +function writeMessage(data) { + const lenBuf = Buffer.alloc(4); + lenBuf.writeUInt32BE(data.length, 0); + process.stdout.write(lenBuf); + process.stdout.write(data); +} + +let stdinBuf = Buffer.alloc(0); +let stdinResolve = null; +let stdinEnded = false; + +process.stdin.on("data", (chunk) => { + stdinBuf = Buffer.concat([stdinBuf, chunk]); + if (stdinResolve) { + const r = stdinResolve; + stdinResolve = null; + r(); + } +}); + +process.stdin.on("end", () => { + stdinEnded = true; + if (stdinResolve) { + const r = stdinResolve; + stdinResolve = null; + r(); + } +}); + +function waitForData() { + return new Promise((resolve) => { + stdinResolve = resolve; + }); +} + +async function readExact(n) { + while (stdinBuf.length < n) { + if (stdinEnded) return null; + await waitForData(); + } + const result = stdinBuf.subarray(0, n); + stdinBuf = stdinBuf.subarray(n); + return Buffer.from(result); +} + +async function readMessage() { + const lenBuf = await readExact(4); + if (!lenBuf) return null; + const len = lenBuf.readUInt32BE(0); + if (len === 0) return Buffer.alloc(0); + return readExact(len); +} + +const configBuf = await readMessage(); +if (!configBuf) process.exit(1); + +const config = JSON.parse(configBuf.toString("utf8")); +const { accessToken, url, path: rpcPath, unary } = config; +logEvent({ event: "bridge_start", url: url || "https://agent.api5.cursor.sh", unary }); +const extraHeaders = + config.headers && typeof config.headers === "object" ? config.headers : {}; + +const client = http2.connect(url || "https://agent.api5.cursor.sh"); + +let timeout = setTimeout(killBridge, 30_000); + +function resetTimeout() { + clearTimeout(timeout); + timeout = setTimeout(killBridge, 120_000); +} + +function killBridge() { + clearTimeout(timeout); + client.destroy(); + process.exit(1); +} + +client.on("error", () => { + clearTimeout(timeout); + process.exit(1); +}); + +const headers = { + ":method": "POST", + ":path": rpcPath || "/agent.v1.AgentService/Run", + "content-type": unary ? "application/proto" : "application/connect+proto", + te: "trailers", + authorization: `Bearer ${accessToken}`, + "x-ghost-mode": "true", + "x-cursor-client-version": + extraHeaders["x-cursor-client-version"] || "cli-2026.01.09-231024f", + "x-cursor-client-type": extraHeaders["x-cursor-client-type"] || "cli", + "x-request-id": crypto.randomUUID(), +}; +for (const [key, value] of Object.entries(extraHeaders)) { + if (!key.startsWith(":") && typeof value === "string" && value.length > 0) { + headers[key] = value; + } +} +if (!unary) { + headers["connect-protocol-version"] = "1"; +} + +const h2Stream = client.request(headers); + +h2Stream.on("data", (chunk) => { + resetTimeout(); + writeMessage(chunk); +}); + +h2Stream.on("end", () => { + clearTimeout(timeout); + logEvent({ event: "h2_stream_end", duration_ms: Date.now() - bridgeStart }); + if (_logStream) { try { _logStream.end(); } catch {} } + client.close(); + setTimeout(() => process.exit(0), 100); +}); + +h2Stream.on("error", () => { + clearTimeout(timeout); + logEvent({ event: "h2_stream_error", duration_ms: Date.now() - bridgeStart }); + if (_logStream) { try { _logStream.end(); } catch {} } + client.close(); + process.exit(1); +}); + +if (unary) { + const body = await readMessage(); + if (body && body.length > 0 && !h2Stream.closed && !h2Stream.destroyed) { + h2Stream.end(body); + } else { + h2Stream.end(); + } +} else { + (async () => { + while (true) { + const msg = await readMessage(); + if (!msg || msg.length === 0) break; + if (!h2Stream.closed && !h2Stream.destroyed) { + resetTimeout(); + h2Stream.write(msg); + } + } + if (!h2Stream.closed && !h2Stream.destroyed) { + h2Stream.end(); + } + })(); +} diff --git a/scripts/agent-bridge/install-proto.mjs b/scripts/agent-bridge/install-proto.mjs new file mode 100644 index 00000000..4a592ccf --- /dev/null +++ b/scripts/agent-bridge/install-proto.mjs @@ -0,0 +1,25 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const vendorDir = path.join(__dirname, "vendor"); +const protoUrl = + "https://raw.githubusercontent.com/jaredboynton/cursor-oauth-opencode/main/src/proto/agent_pb.ts"; +const outPath = path.join(vendorDir, "agent_pb.ts"); + +if (existsSync(outPath)) { + console.log("agent_pb.ts already present"); + process.exit(0); +} + +await mkdir(vendorDir, { recursive: true }); +const res = await fetch(protoUrl); +if (!res.ok) { + console.error(`Failed to download agent_pb.ts: ${res.status}`); + process.exit(1); +} +const text = await res.text(); +await writeFile(outPath, text, "utf8"); +console.log(`Wrote ${outPath} (${text.length} bytes)`); diff --git a/scripts/agent-bridge/package-lock.json b/scripts/agent-bridge/package-lock.json new file mode 100644 index 00000000..a8490c57 --- /dev/null +++ b/scripts/agent-bridge/package-lock.json @@ -0,0 +1,510 @@ +{ + "name": "sinew-agent-bridge", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sinew-agent-bridge", + "hasInstallScript": true, + "dependencies": { + "@bufbuild/protobuf": "^2.0.0", + "tsx": "^4.19.0" + } + }, + "node_modules/@bufbuild/protobuf": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.12.0.tgz", + "integrity": "sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/tsx": { + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", + "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + } + } +} diff --git a/scripts/agent-bridge/package.json b/scripts/agent-bridge/package.json new file mode 100644 index 00000000..7eb6fca3 --- /dev/null +++ b/scripts/agent-bridge/package.json @@ -0,0 +1,14 @@ +{ + "name": "sinew-agent-bridge", + "private": true, + "type": "module", + "description": "Node bridge: agent.v1 Run (connect+proto) for Sinew OAuth", + "scripts": { + "postinstall": "node install-proto.mjs", + "run": "node run-stream.mjs" + }, + "dependencies": { + "@bufbuild/protobuf": "^2.0.0", + "tsx": "^4.19.0" + } +} diff --git a/scripts/agent-bridge/run-stream.mjs b/scripts/agent-bridge/run-stream.mjs new file mode 100644 index 00000000..abc5e109 --- /dev/null +++ b/scripts/agent-bridge/run-stream.mjs @@ -0,0 +1,931 @@ +#!/usr/bin/env node +/** + * Minimal agent.v1 Run bridge for Sinew. + * stdin: line 1 = config JSON; further lines = tool_response from Sinew + * stdout: NDJSON text/thinking/error/tool_request + */ +import http2 from "node:http2"; +import { createHash, randomUUID } from "node:crypto"; +import path from "node:path"; +import fs from "node:fs"; +import os from "node:os"; +import readline from "node:readline"; +import { + buildProjectLayout, + handleDeleteArgs, + handleLsArgs, + handleReadArgs, + handleWriteArgs, +} from "./exec-handlers.mjs"; +import { create, toBinary, fromBinary } from "@bufbuild/protobuf"; +import { + AgentClientMessageSchema, + AgentRunRequestSchema, + AgentServerMessageSchema, + AssistantMessageSchema, + ClientHeartbeatSchema, + ConversationActionSchema, + ConversationStateStructureSchema, + ConversationStepSchema, + AgentConversationTurnStructureSchema, + ConversationTurnStructureSchema, + BackgroundShellSpawnResultSchema, + DeleteRejectedSchema, + DeleteResultSchema, + DiagnosticsResultSchema, + ExecClientMessageSchema, + FetchErrorSchema, + FetchResultSchema, + GetBlobResultSchema, + GrepErrorSchema, + GrepResultSchema, + LsRejectedSchema, + LsResultSchema, + ReadRejectedSchema, + ReadResultSchema, + ShellRejectedSchema, + ShellResultSchema, + WriteRejectedSchema, + WriteResultSchema, + KvClientMessageSchema, + LsDirectoryTreeNodeSchema, + McpErrorSchema, + McpInstructionsSchema, + McpRejectedSchema, + McpResultSchema, + McpTextContentSchema, + McpToolDefinitionSchema, + McpToolResultContentItemSchema, + ModelDetailsSchema, + RequestContextEnvSchema, + RequestContextResultSchema, + RequestContextSchema, + RequestContextSuccessSchema, + SetBlobResultSchema, + WriteShellStdinErrorSchema, + WriteShellStdinResultSchema, + UserMessageActionSchema, + UserMessageSchema, +} from "./vendor/agent_pb.ts"; + +const CONNECT_END = 0b00000010; +let outputTokenCount = 0; +let totalTokenCount = 0; + +// --- Centralized JSON logger → %LOCALAPPDATA%/dev/hyrak/sinew/data/logs/agent-bridge.log --- +const LOG_DIR = path.join( + process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"), + "dev", "hyrak", "sinew", "data", "logs" +); +let _logStream = null; +function _ensureLog() { + if (_logStream) return; + try { fs.mkdirSync(LOG_DIR, { recursive: true }); } catch {} + _logStream = fs.createWriteStream(path.join(LOG_DIR, "agent-bridge.log"), { flags: "a" }); +} +function logEvent(ev) { + _ensureLog(); + try { + _logStream.write(JSON.stringify({ ts: Date.now(), ...ev }) + "\n"); + } catch {} +} +function timeMs(start) { + return Date.now() - start; +} + +function sha256Hex(input) { + return createHash("sha256").update(input, "utf8").digest("hex"); +} + +function cursorChecksum(accessToken) { + const machineId = sha256Hex(`${accessToken}machineId`); + const millis = Date.now(); + const bucket = Math.floor(millis / 1_000_000); + const bytes = [ + (bucket >> 40) & 0xff, + (bucket >> 32) & 0xff, + (bucket >> 24) & 0xff, + (bucket >> 16) & 0xff, + (bucket >> 8) & 0xff, + bucket & 0xff, + ]; + let state = 165; + for (let index = 0; index < bytes.length; index++) { + let x = (bytes[index] ^ state) + (index % 256); + x &= 0xff; + bytes[index] = x; + state = x; + } + const encoded = Buffer.from(bytes).toString("base64url"); + return `${encoded}${machineId}`; +} + +function frameConnect(data, flags = 0) { + const frame = Buffer.alloc(5 + data.length); + frame[0] = flags; + frame.writeUInt32BE(data.length, 1); + frame.set(data, 5); + return frame; +} + +function parseConnectEnd(data) { + try { + const payload = JSON.parse(Buffer.from(data).toString("utf8")); + if (payload?.error) { + return `${payload.error.code}: ${payload.error.message}`; + } + } catch { + /* ignore */ + } + return null; +} + +function storeBlob(blobStore, data) { + const blobId = new Uint8Array(createHash("sha256").update(data).digest()); + blobStore.set(Buffer.from(blobId).toString("hex"), data); + return blobId; +} + +function buildMcpToolDefinitions(tools) { + if (!Array.isArray(tools)) return []; + return tools.map((tool) => { + const schema = tool.parameters || { type: "object", properties: {}, required: [] }; + const inputSchema = new TextEncoder().encode(JSON.stringify(schema)); + return create(McpToolDefinitionSchema, { + name: tool.name, + toolName: tool.name, + description: tool.description || "", + providerIdentifier: "sinew", + inputSchema, + }); + }); +} + +function decodeMcpArgs(argsMap) { + const decoded = {}; + for (const [key, value] of Object.entries(argsMap || {})) { + if (value instanceof Uint8Array) { + try { + decoded[key] = JSON.parse(new TextDecoder().decode(value)); + } catch { + decoded[key] = new TextDecoder().decode(value); + } + } else { + decoded[key] = value; + } + } + return decoded; +} + +function restoreBlobStore(blobs) { + const blobStore = new Map(); + if (blobs && typeof blobs === "object") { + for (const [hex, b64] of Object.entries(blobs)) { + if (typeof b64 === "string") { + blobStore.set(hex, Buffer.from(b64, "base64")); + } + } + } + return blobStore; +} + +function buildRootPromptMessagesJson(systemPrompt, turns, blobStore) { + const ids = []; + ids.push( + storeBlob( + blobStore, + new TextEncoder().encode( + JSON.stringify({ role: "system", content: systemPrompt || "" }), + ), + ), + ); + for (const turn of turns || []) { + const userText = turn.user_text || turn.userText || ""; + const assistantText = turn.assistant_text || turn.assistantText || ""; + if (userText.trim()) { + ids.push( + storeBlob( + blobStore, + new TextEncoder().encode( + JSON.stringify({ + role: "user", + content: [{ type: "text", text: userText }], + }), + ), + ), + ); + } + if (assistantText.trim()) { + ids.push( + storeBlob( + blobStore, + new TextEncoder().encode( + JSON.stringify({ + role: "assistant", + content: [{ type: "text", text: assistantText }], + }), + ), + ), + ); + } + } + return ids; +} + +function buildTurnBlobIds(turns, blobStore) { + const turnIds = []; + for (const turn of turns || []) { + const userText = turn.user_text || turn.userText || ""; + if (!userText.trim()) continue; + const userMsg = create(UserMessageSchema, { + text: userText, + messageId: randomUUID(), + }); + const userBlobId = storeBlob(blobStore, toBinary(UserMessageSchema, userMsg)); + const stepBlobIds = []; + const assistantText = turn.assistant_text || turn.assistantText || ""; + if (assistantText.trim()) { + const step = create(ConversationStepSchema, { + message: { + case: "assistantMessage", + value: create(AssistantMessageSchema, { text: assistantText }), + }, + }); + stepBlobIds.push(storeBlob(blobStore, toBinary(ConversationStepSchema, step))); + } + const agentTurn = create(AgentConversationTurnStructureSchema, { + userMessage: userBlobId, + steps: stepBlobIds, + }); + const turnStructure = create(ConversationTurnStructureSchema, { + turn: { case: "agentConversationTurn", value: agentTurn }, + }); + turnIds.push(storeBlob(blobStore, toBinary(ConversationTurnStructureSchema, turnStructure))); + } + return turnIds; +} + +function loadCheckpointState(config, blobStore) { + if (!config.checkpointB64) return null; + try { + const bytes = Buffer.from(config.checkpointB64, "base64"); + return fromBinary(ConversationStateStructureSchema, bytes); + } catch (err) { + debug(`checkpoint load failed: ${err}`); + return null; + } +} + +function buildRequest(modelId, systemPrompt, userText, conversationId, config) { + const blobStore = restoreBlobStore(config.blobs); + const historyTurns = config.turns || []; + const loaded = loadCheckpointState(config, blobStore); + + const conversationState = + loaded ?? + create(ConversationStateStructureSchema, { + rootPromptMessagesJson: buildRootPromptMessagesJson( + systemPrompt, + historyTurns, + blobStore, + ), + turns: buildTurnBlobIds(historyTurns, blobStore), + todos: [], + pendingToolCalls: [], + previousWorkspaceUris: [], + fileStates: {}, + fileStatesV2: {}, + summaryArchives: [], + turnTimings: [], + subagentStates: {}, + selfSummaryCount: 0, + readPaths: [], + }); + + const action = create(ConversationActionSchema, { + action: { + case: "userMessageAction", + value: create(UserMessageActionSchema, { + userMessage: create(UserMessageSchema, { + text: userText, + messageId: randomUUID(), + }), + }), + }, + }); + // Current user turn lives in action only; history turn blobs use blob-id refs (see opencode buildCursorRequest). + + const modelDetails = create(ModelDetailsSchema, { + modelId, + displayModelId: modelId, + displayName: modelId, + }); + + const runRequest = create(AgentRunRequestSchema, { + conversationState, + action, + modelDetails, + conversationId: conversationId || randomUUID(), + }); + + const clientMessage = create(AgentClientMessageSchema, { + message: { case: "runRequest", value: runRequest }, + }); + + return { requestBytes: toBinary(AgentClientMessageSchema, clientMessage), blobStore }; +} + +function sendExecResult(execMsg, resultCase, result, sendFrame) { + const execClient = create(ExecClientMessageSchema, { + execId: execMsg.execId, + id: execMsg.id, + message: { case: resultCase, value: result }, + }); + const clientMsg = create(AgentClientMessageSchema, { + message: { case: "execClientMessage", value: execClient }, + }); + sendFrame(frameConnect(toBinary(AgentClientMessageSchema, clientMsg))); +} + +function buildRequestContext(workspaceRoot, snapshot, tools) { + const root = workspaceRoot?.trim() || process.cwd(); + const projectFolder = path.join( + process.env.USERPROFILE || process.env.HOME || root, + ".cursor", + "projects", + "sinew-bridge", + ); + const layout = buildProjectLayout(root, snapshot); + const mcpTools = buildMcpToolDefinitions(tools); + return create(RequestContextSchema, { + rules: [], + env: create(RequestContextEnvSchema, { + osVersion: `${process.platform} ${process.version}`, + workspacePaths: [root], + shell: process.env.ComSpec || process.env.SHELL || "", + sandboxEnabled: false, + terminalsFolder: path.join(projectFolder, "terminals"), + agentSharedNotesFolder: path.join(projectFolder, "shared-notes"), + agentConversationNotesFolder: path.join(projectFolder, "conversation-notes"), + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", + projectFolder, + agentTranscriptsFolder: path.join(projectFolder, "transcripts"), + }), + repositoryInfo: [], + tools: mcpTools, + gitRepos: [], + projectLayouts: layout ? [layout] : [], + mcpInstructions: [ + create(McpInstructionsSchema, { + serverName: "sinew", + instructions: [ + `Workspace root: ${root}.`, + "Use the MCP tools listed in this request context (Read, Grep, Bash, etc.).", + ].join("\n"), + }), + ], + fileContents: {}, + customSubagents: [], + }); +} + +const REJECT = "Tool not available in Sinew agent bridge spike."; + +async function handleExecMessage(execMsg, sendFrame, emit, waitToolResponse) { + const execStart = Date.now(); + const execCase = Object.keys(execMsg.message.value || {}).find((k) => + k.endsWith("Args") + ); + const result = create(RequestContextResultSchema, { + result: { + case: "success", + value: create(RequestContextSuccessSchema, { requestContext }), + }, + }); + sendExecResult(execMsg, "requestContextResult", result, sendFrame); + return; + } + if (execCase === "readArgs") { + handleReadArgs(execMsg, execMsg.message.value, config.workspaceRoot, send); + return; + } + if (execCase === "lsArgs") { + handleLsArgs(execMsg, execMsg.message.value, config.workspaceRoot, send); + return; + } + if (execCase === "grepArgs") { + sendExecResult( + execMsg, + "grepResult", + create(GrepResultSchema, { + result: { case: "error", value: create(GrepErrorSchema, { error: REJECT }) }, + }), + sendFrame, + ); + return; + } + if (execCase === "writeArgs" || execCase === "editArgs") { + handleWriteArgs(execMsg, execMsg.message.value, config.workspaceRoot, send); + return; + } + if (execCase === "deleteArgs") { + handleDeleteArgs(execMsg, execMsg.message.value, config.workspaceRoot, send); + return; + } + if (execCase === "shellArgs" || execCase === "shellStreamArgs") { + const args = execMsg.message.value; + sendExecResult( + execMsg, + "shellResult", + create(ShellResultSchema, { + result: { + case: "rejected", + value: create(ShellRejectedSchema, { + command: args.command ?? "", + workingDirectory: args.workingDirectory ?? "", + reason: REJECT, + isReadonly: false, + }), + }, + }), + sendFrame, + ); + return; + } + if (execCase === "backgroundShellSpawnArgs") { + const args = execMsg.message.value; + sendExecResult( + execMsg, + "backgroundShellSpawnResult", + create(BackgroundShellSpawnResultSchema, { + result: { + case: "rejected", + value: create(ShellRejectedSchema, { + command: args.command ?? "", + workingDirectory: args.workingDirectory ?? "", + reason: REJECT, + isReadonly: false, + }), + }, + }), + sendFrame, + ); + return; + } + if (execCase === "writeShellStdinArgs") { + sendExecResult( + execMsg, + "writeShellStdinResult", + create(WriteShellStdinResultSchema, { + result: { case: "error", value: create(WriteShellStdinErrorSchema, { error: REJECT }) }, + }), + sendFrame, + ); + return; + } + if (execCase === "fetchArgs") { + const args = execMsg.message.value; + sendExecResult( + execMsg, + "fetchResult", + create(FetchResultSchema, { + result: { + case: "error", + value: create(FetchErrorSchema, { url: args.url ?? "", error: REJECT }), + }, + }), + sendFrame, + ); + return; + } + if (execCase === "diagnosticsArgs") { + sendExecResult(execMsg, "diagnosticsResult", create(DiagnosticsResultSchema, {}), sendFrame); + return; + } + const miscCaseMap = { + listMcpResourcesExecArgs: "listMcpResourcesExecResult", + readMcpResourceExecArgs: "readMcpResourceExecResult", + recordScreenArgs: "recordScreenResult", + computerUseArgs: "computerUseResult", + setupVmEnvironmentArgs: "setupVmEnvironmentResult", + }; + const resultCase = miscCaseMap[execCase]; + if (resultCase) { + sendExecResult(execMsg, resultCase, create(McpResultSchema, {}), sendFrame); + return; + } + if (execCase === "mcpArgs") { + const mcpArgs = execMsg.message.value; + const toolName = mcpArgs.toolName || mcpArgs.name || ""; + const args = decodeMcpArgs(mcpArgs.args); + emit({ + type: "tool_request", + execId: execMsg.execId, + execMsgId: execMsg.id, + toolCallId: mcpArgs.toolCallId || randomUUID(), + toolName, + args, + }); + const resp = await waitToolResponse(); + const toolMs = timeMs(execStart); + logEvent({ event: "mcp_tool_exec", toolName, tool_ms: toolMs, isError: Boolean(resp?.isError) }); + const content = resp?.content || "Error: empty tool response"; + const isError = Boolean(resp?.isError) || content.startsWith("Error:"); + const mcpResult = isError + ? create(McpResultSchema, { + result: { case: "error", value: create(McpErrorSchema, { error: content }) }, + }) + : create(McpResultSchema, { + result: { + case: "success", + value: create(McpSuccessSchema, { + isError: false, + content: [ + create(McpToolResultContentItemSchema, { + content: { + case: "text", + value: create(McpTextContentSchema, { text: content }), + }, + }), + ], + }), + }, + }); + sendExecResult(execMsg, "mcpResult", mcpResult, sendFrame); + return; + } + debug(`unhandled exec: ${execCase ?? "?"}`); +} + +function emitCheckpoint(state, blobStore, emit) { + try { + const bytes = toBinary(ConversationStateStructureSchema, state); + const blobs = {}; + for (const [hex, data] of blobStore.entries()) { + blobs[hex] = Buffer.from(data).toString("base64"); + } + emit({ + type: "checkpoint", + checkpointB64: Buffer.from(bytes).toString("base64"), + blobs, + }); + } catch (err) { + debug(`checkpoint emit failed: ${err}`); + } +} + +function handleServerMessage(msg, blobStore, sendFrame, emit, waitToolResponse) { + const msgCase = msg.message?.case; + if (msgCase === "execServerMessage") { + void handleExecMessage(msg.message.value, sendFrame, emit, waitToolResponse); + } else if (msgCase === "conversationCheckpointUpdate") { + emitCheckpoint(msg.message.value, blobStore, emit); + } else if (msgCase === "interactionUpdate") { + const u = msg.message.value; + const c = u.message?.case; + debug(`interaction ${c ?? "?"}`); + if (c === "textDelta") { + const d = u.message.value.text || ""; + if (d) { + if (!sawText) { + sawText = true; + armMaxTurnTimer(); + } + lastTextAt = Date.now(); + emit({ type: "text", delta: d }); + bumpIdleFinish(); + } + } else if (c === "thinkingDelta") { + const d = u.message.value.text || ""; + if (d) emit({ type: "thinking", delta: d }); + } else if (c === "tokenDelta") { + const delta = u.message.value.tokens ?? 0; + outputTokenCount += delta; + totalTokenCount = Math.max(totalTokenCount, outputTokenCount); + emit({ + type: "usage", + outputTokens: outputTokenCount, + totalTokens: totalTokenCount, + }); + } else if (c === "thinkingCompleted") { + debug("interaction thinkingCompleted"); + if (sawText) bumpIdleFinish(); + } else if (c === "heartbeat") { + if (sawText && lastTextAt > 0 && Date.now() - lastTextAt >= IDLE_AFTER_TEXT_MS) { + debug("finish after text + server heartbeat idle"); + gracefulFinish(0); + } + } else if (c === "stepCompleted") { + debug("interaction stepCompleted"); + gracefulFinish(0); + } else if (c === "turnEnded") { + debug("interaction turnEnded"); + gracefulFinish(0); + } + } else if (msgCase === "kvServerMessage") { + const kv = msg.message.value; + if (kv.message?.case === "getBlobArgs") { + const blobId = kv.message.value.blobId; + const key = Buffer.from(blobId).toString("hex"); + const blobData = blobStore.get(key); + const response = create(KvClientMessageSchema, { + id: kv.id, + message: { + case: "getBlobResult", + value: create(GetBlobResultSchema, blobData ? { blobData } : {}), + }, + }); + const clientMsg = create(AgentClientMessageSchema, { + message: { case: "kvClientMessage", value: response }, + }); + sendFrame(frameConnect(toBinary(AgentClientMessageSchema, clientMsg))); + } else if (kv.message?.case === "setBlobArgs") { + const { blobId, blobData } = kv.message.value; + blobStore.set(Buffer.from(blobId).toString("hex"), blobData); + const response = create(KvClientMessageSchema, { + id: kv.id, + message: { case: "setBlobResult", value: create(SetBlobResultSchema, {}) }, + }); + const clientMsg = create(AgentClientMessageSchema, { + message: { case: "kvClientMessage", value: response }, + }); + sendFrame(frameConnect(toBinary(AgentClientMessageSchema, clientMsg))); + } + } +} + +async function readConfigLine(rl) { + const line = await new Promise((resolve, reject) => { + rl.once("line", resolve); + rl.once("close", () => reject(new Error("stdin closed before config"))); + }); + if (!line?.trim()) throw new Error("no config on stdin"); + return JSON.parse(line); +} + +function waitToolResponse(rl) { + return new Promise((resolve, reject) => { + rl.once("line", (line) => { + try { + resolve(JSON.parse(line)); + } catch (err) { + reject(err); + } + }); + rl.once("close", () => reject(new Error("stdin closed waiting for tool response"))); + }); +} + +let sawText = false; +let lastTextAt = 0; +let finished = false; +let idleTimer = null; +let maxTurnTimer = null; +let heartbeatTimer = null; +let h2Client = null; +let h2Stream = null; + +const IDLE_AFTER_TEXT_MS = 2500; +const MAX_TURN_MS = 120_000; + +function gracefulFinish(code = 0) { + if (finished) return; + finished = true; + logEvent({ event: "bridge_end", total_ms: timeMs(bridgeStart), tokens: totalTokenCount, exitCode: code }); + if (_logStream) { try { _logStream.end(); } catch {} } + if (heartbeatTimer) clearInterval(heartbeatTimer); + if (idleTimer) clearTimeout(idleTimer); + if (maxTurnTimer) clearTimeout(maxTurnTimer); + try { + h2Stream?.end(); + } catch { + /* ignore */ + } + setTimeout(() => { + try { + h2Client?.close(); + } catch { + /* ignore */ + } + process.exit(code); + }, 300); +} + +function armMaxTurnTimer() { + if (finished || maxTurnTimer) return; + maxTurnTimer = setTimeout(() => { + debug(`max turn ${MAX_TURN_MS}ms`); + gracefulFinish(0); + }, MAX_TURN_MS); + maxTurnTimer.unref?.(); +} + +function bumpIdleFinish() { + if (finished) return; + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(() => gracefulFinish(0), IDLE_AFTER_TEXT_MS); + idleTimer.unref?.(); +} + +const stdinRl = readline.createInterface({ input: process.stdin, terminal: false }); +const bridgeStart = Date.now(); +logEvent({ event: "bridge_start" }); +const config = await readConfigLine(stdinRl); +const { + accessToken, + modelId, + systemPrompt, + userText, + workspaceRoot, + conversationId, + tools, + workspaceSnapshot, +} = config; +const waitForTool = () => waitToolResponse(stdinRl); +if (!accessToken || !modelId || !userText) { + console.log(JSON.stringify({ error: "missing accessToken, modelId, or userText" })); + process.exit(1); +} + +const emit = (obj) => { + process.stdout.write(`${JSON.stringify(obj)}\n`); +}; +const debug = (msg) => { + process.stderr.write(`[agent-bridge] ${msg}\n`); +}; + +const apiHeaders = + config.apiHeaders && typeof config.apiHeaders === "object" + ? config.apiHeaders + : { + "x-cursor-client-type": "cli", + "x-ghost-mode": "true", + "x-client-key": sha256Hex(accessToken), + "x-cursor-checksum": cursorChecksum(accessToken), + "x-cursor-client-version": "cli-2026.01.09-231024f", + }; + +const { requestBytes, blobStore } = buildRequest( + modelId, + systemPrompt || "You are Composer in Cursor IDE.", + userText, + conversationId, + config, +); + +const sendFrame = (frame) => { + if (finished || !h2Stream || h2Stream.closed || h2Stream.destroyed) return; + h2Stream.write(frame); +}; + +const h2Headers = { + ...apiHeaders, + ":method": "POST", + ":path": "/agent.v1.AgentService/Run", + "content-type": "application/connect+proto", + te: "trailers", + authorization: `Bearer ${accessToken}`, + "connect-protocol-version": "1", +}; + +const MAX_RUN_ATTEMPTS = 4; +const RETRY_HTTP_STATUS = new Set([408, 429, 500, 502, 503, 504]); + +function backoffMs(attempt) { + return 800 * 2 ** attempt + Math.floor(Math.random() * 500); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isRetryableNetworkError(err) { + const msg = String(err?.message || err).toLowerCase(); + return ( + msg.startsWith("retry:") || + msg.includes("timeout") || + msg.includes("econnreset") || + msg.includes("etimedout") || + msg.includes("socket hang up") + ); +} + +const heartbeat = create(AgentClientMessageSchema, { + message: { case: "clientHeartbeat", value: create(ClientHeartbeatSchema, {}) }, +}); +const heartbeatBytes = () => + frameConnect(toBinary(AgentClientMessageSchema, heartbeat)); + +let pending = Buffer.alloc(0); +function ingestConnectBytes(chunk) { + pending = Buffer.concat([pending, chunk]); + let offset = 0; + while (pending.length >= offset + 5) { + const flags = pending[offset]; + const flen = pending.readUInt32BE(offset + 1); + if (pending.length < offset + 5 + flen) break; + const frame = pending.subarray(offset + 5, offset + 5 + flen); + offset += 5 + flen; + + if (flags & CONNECT_END) { + const err = parseConnectEnd(frame); + if (err) emit({ error: err }); + continue; + } + if (!frame.length) continue; + + try { + const msg = fromBinary(AgentServerMessageSchema, frame); + debug(`server case=${msg.message?.case ?? "?"}`); + handleServerMessage(msg, blobStore, sendFrame, emit, waitForTool); + } catch (err) { + debug(`parse err: ${err}`); + } + } + pending = pending.subarray(offset); +} + +function wireH2Stream() { + sendFrame(frameConnect(requestBytes)); + heartbeatTimer = setInterval(() => { + sendFrame(heartbeatBytes()); + }, 15_000); + + h2Stream.on("data", ingestConnectBytes); + h2Stream.on("end", () => { + if (finished) return; + if (!sawText) { + emit({ error: "stream ended without text deltas" }); + gracefulFinish(1); + return; + } + gracefulFinish(0); + }); + h2Stream.on("error", (err) => { + emit({ error: `h2 stream: ${err}` }); + gracefulFinish(1); + }); +} + +async function openRunWithRetry() { + const openStart = Date.now(); + for (let attempt = 0; attempt < MAX_RUN_ATTEMPTS; attempt++) { + if (attempt > 0) { + debug(`Run retry ${attempt + 1}/${MAX_RUN_ATTEMPTS}`); + await sleep(backoffMs(attempt - 1)); + } + try { + await new Promise((resolve, reject) => { + const client = http2.connect("https://agent.api5.cursor.sh"); + client.on("error", (err) => { + try { + client.close(); + } catch { + /* ignore */ + } + reject(err); + }); + const stream = client.request(h2Headers); + stream.on("response", (headers) => { + const status = Number(headers[":status"] ?? 0); + if (status === 200) { + h2Client = client; + h2Stream = stream; + resolve(); + return; + } + try { + client.close(); + } catch { + /* ignore */ + } + if (RETRY_HTTP_STATUS.has(status) && attempt + 1 < MAX_RUN_ATTEMPTS) { + reject(new Error(`retry:${status}`)); + return; + } + reject(new Error(`agent Run failed: ${status}`)); + }); + stream.on("error", (err) => { + try { + client.close(); + } catch { + /* ignore */ + } + reject(err); + }); + }); + wireH2Stream(); + logEvent({ event: "h2_connected", attempt, connect_ms: timeMs(openStart) }); + return; + } catch (err) { + if (attempt + 1 < MAX_RUN_ATTEMPTS && isRetryableNetworkError(err)) { + continue; + } + emit({ error: String(err?.message || err) }); + gracefulFinish(1); + return; + } + } +} + +await openRunWithRetry(); diff --git a/scripts/agent-bridge/test-live-rust.ps1 b/scripts/agent-bridge/test-live-rust.ps1 new file mode 100644 index 00000000..557fbe88 --- /dev/null +++ b/scripts/agent-bridge/test-live-rust.ps1 @@ -0,0 +1,7 @@ +# Live test: Rust agent bridge via sinew-cursor (requires OAuth token). +$ErrorActionPreference = "Stop" +Set-Location (Split-Path $PSScriptRoot -Parent | Join-Path -ChildPath "..") +$env:SINEW_CURSOR_TRANSPORT = "agent" +$env:SINEW_CURSOR_BRIDGE = "rust" +$env:SINEW_CURSOR_LIVE_ASSERT = "1" +cargo test -p sinew-cursor test_live_rust_agent_bridge -- --ignored --nocapture diff --git a/scripts/agent-bridge/test-live.ps1 b/scripts/agent-bridge/test-live.ps1 new file mode 100644 index 00000000..ef7dc3d9 --- /dev/null +++ b/scripts/agent-bridge/test-live.ps1 @@ -0,0 +1,29 @@ +$ErrorActionPreference = "Stop" +$authPath = Join-Path $env:LOCALAPPDATA "Hyrak\sinew\data\cursor-composer-auth.json" +if (-not (Test-Path $authPath)) { throw "Missing auth: $authPath" } +$auth = Get-Content $authPath -Raw | ConvertFrom-Json +$token = $auth.tokens.accessToken +if (-not $token) { $token = $auth.accessToken } +if (-not $token) { throw "No accessToken" } + +$payload = @{ + accessToken = $token + modelId = "composer-2-fast" + systemPrompt = "You are Composer. Reply in one short sentence." + userText = "Dis bonjour en francais, une phrase." + workspaceRoot = "C:\Dev\Sinew" +} | ConvertTo-Json -Compress + +Set-Location $PSScriptRoot +$job = Start-Job -ScriptBlock { + param($p, $dir) + Set-Location $dir + $p | npx --yes tsx run-stream.mjs 2>&1 +} -ArgumentList $payload, $PSScriptRoot +if (-not (Wait-Job $job -Timeout 90)) { + Stop-Job $job -Force + Remove-Job $job -Force + throw "agent-bridge live test timed out after 90s" +} +Receive-Job $job +Remove-Job $job diff --git a/scripts/agent-bridge/vendor/agent_pb.ts b/scripts/agent-bridge/vendor/agent_pb.ts new file mode 100644 index 00000000..050b6168 --- /dev/null +++ b/scripts/agent-bridge/vendor/agent_pb.ts @@ -0,0 +1,15274 @@ +// @generated by protoc-gen-es v2.10.2 with parameter "target=ts" +// @generated from file agent.proto (package agent.v1, syntax proto3) +/* eslint-disable */ + +import type { Message } from "@bufbuild/protobuf"; +import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; + +/** + * Describes the file agent.proto. + */ +export const file_agent: GenFile = + /*@__PURE__*/ + fileDesc( + "CgthZ2VudC5wcm90bxIIYWdlbnQudjEicgoOR2xvYlRvb2xSZXN1bHQSLAoHc3VjY2VzcxgBIAEoCzIZLmFnZW50LnYxLkdsb2JUb29sU3VjY2Vzc0gAEigKBWVycm9yGAIgASgLMhcuYWdlbnQudjEuR2xvYlRvb2xFcnJvckgAQggKBnJlc3VsdCIeCg1HbG9iVG9vbEVycm9yEg0KBWVycm9yGAEgASgJIokBCg9HbG9iVG9vbFN1Y2Nlc3MSDwoHcGF0dGVybhgBIAEoCRIMCgRwYXRoGAIgASgJEg0KBWZpbGVzGAMgAygJEhMKC3RvdGFsX2ZpbGVzGAQgASgFEhgKEGNsaWVudF90cnVuY2F0ZWQYBSABKAgSGQoRcmlwZ3JlcF90cnVuY2F0ZWQYBiABKAgiRgoMR2xvYlRvb2xDYWxsEgwKBGFyZ3MYASABKAwSKAoGcmVzdWx0GAIgASgLMhguYWdlbnQudjEuR2xvYlRvb2xSZXN1bHQibQoRUmVhZExpbnRzVG9vbENhbGwSKQoEYXJncxgBIAEoCzIbLmFnZW50LnYxLlJlYWRMaW50c1Rvb2xBcmdzEi0KBnJlc3VsdBgCIAEoCzIdLmFnZW50LnYxLlJlYWRMaW50c1Rvb2xSZXN1bHQiIgoRUmVhZExpbnRzVG9vbEFyZ3MSDQoFcGF0aHMYASADKAkigQEKE1JlYWRMaW50c1Rvb2xSZXN1bHQSMQoHc3VjY2VzcxgBIAEoCzIeLmFnZW50LnYxLlJlYWRMaW50c1Rvb2xTdWNjZXNzSAASLQoFZXJyb3IYAiABKAsyHC5hZ2VudC52MS5SZWFkTGludHNUb29sRXJyb3JIAEIICgZyZXN1bHQiewoUUmVhZExpbnRzVG9vbFN1Y2Nlc3MSMwoQZmlsZV9kaWFnbm9zdGljcxgBIAMoCzIZLmFnZW50LnYxLkZpbGVEaWFnbm9zdGljcxITCgt0b3RhbF9maWxlcxgCIAEoBRIZChF0b3RhbF9kaWFnbm9zdGljcxgDIAEoBSJpCg9GaWxlRGlhZ25vc3RpY3MSDAoEcGF0aBgBIAEoCRItCgtkaWFnbm9zdGljcxgCIAMoCzIYLmFnZW50LnYxLkRpYWdub3N0aWNJdGVtEhkKEWRpYWdub3N0aWNzX2NvdW50GAMgASgFIqsBCg5EaWFnbm9zdGljSXRlbRIuCghzZXZlcml0eRgBIAEoDjIcLmFnZW50LnYxLkRpYWdub3N0aWNTZXZlcml0eRIoCgVyYW5nZRgCIAEoCzIZLmFnZW50LnYxLkRpYWdub3N0aWNSYW5nZRIPCgdtZXNzYWdlGAMgASgJEg4KBnNvdXJjZRgEIAEoCRIMCgRjb2RlGAUgASgJEhAKCGlzX3N0YWxlGAYgASgIIlUKD0RpYWdub3N0aWNSYW5nZRIhCgVzdGFydBgBIAEoCzISLmFnZW50LnYxLlBvc2l0aW9uEh8KA2VuZBgCIAEoCzISLmFnZW50LnYxLlBvc2l0aW9uIisKElJlYWRMaW50c1Rvb2xFcnJvchIVCg1lcnJvcl9tZXNzYWdlGAEgASgJIh0KDE1jcFRvb2xFcnJvchINCgVlcnJvchgBIAEoCSLSAQoNTWNwVG9vbFJlc3VsdBInCgdzdWNjZXNzGAEgASgLMhQuYWdlbnQudjEuTWNwU3VjY2Vzc0gAEicKBWVycm9yGAIgASgLMhYuYWdlbnQudjEuTWNwVG9vbEVycm9ySAASKQoIcmVqZWN0ZWQYAyABKAsyFS5hZ2VudC52MS5NY3BSZWplY3RlZEgAEjoKEXBlcm1pc3Npb25fZGVuaWVkGAQgASgLMh0uYWdlbnQudjEuTWNwUGVybWlzc2lvbkRlbmllZEgAQggKBnJlc3VsdCJXCgtNY3BUb29sQ2FsbBIfCgRhcmdzGAEgASgLMhEuYWdlbnQudjEuTWNwQXJncxInCgZyZXN1bHQYAiABKAsyFy5hZ2VudC52MS5NY3BUb29sUmVzdWx0Im0KEVNlbVNlYXJjaFRvb2xDYWxsEikKBGFyZ3MYASABKAsyGy5hZ2VudC52MS5TZW1TZWFyY2hUb29sQXJncxItCgZyZXN1bHQYAiABKAsyHS5hZ2VudC52MS5TZW1TZWFyY2hUb29sUmVzdWx0IlMKEVNlbVNlYXJjaFRvb2xBcmdzEg0KBXF1ZXJ5GAEgASgJEhoKEnRhcmdldF9kaXJlY3RvcmllcxgCIAMoCRITCgtleHBsYW5hdGlvbhgDIAEoCSKBAQoTU2VtU2VhcmNoVG9vbFJlc3VsdBIxCgdzdWNjZXNzGAEgASgLMh4uYWdlbnQudjEuU2VtU2VhcmNoVG9vbFN1Y2Nlc3NIABItCgVlcnJvchgCIAEoCzIcLmFnZW50LnYxLlNlbVNlYXJjaFRvb2xFcnJvckgAQggKBnJlc3VsdCI9ChRTZW1TZWFyY2hUb29sU3VjY2VzcxIPCgdyZXN1bHRzGAEgASgJEhQKDGNvZGVfcmVzdWx0cxgCIAMoDCIrChJTZW1TZWFyY2hUb29sRXJyb3ISFQoNZXJyb3JfbWVzc2FnZRgBIAEoCSKCAQoYTGlzdE1jcFJlc291cmNlc1Rvb2xDYWxsEjAKBGFyZ3MYASABKAsyIi5hZ2VudC52MS5MaXN0TWNwUmVzb3VyY2VzRXhlY0FyZ3MSNAoGcmVzdWx0GAIgASgLMiQuYWdlbnQudjEuTGlzdE1jcFJlc291cmNlc0V4ZWNSZXN1bHQifwoXUmVhZE1jcFJlc291cmNlVG9vbENhbGwSLwoEYXJncxgBIAEoCzIhLmFnZW50LnYxLlJlYWRNY3BSZXNvdXJjZUV4ZWNBcmdzEjMKBnJlc3VsdBgCIAEoCzIjLmFnZW50LnYxLlJlYWRNY3BSZXNvdXJjZUV4ZWNSZXN1bHQiWQoNRmV0Y2hUb29sQ2FsbBIhCgRhcmdzGAEgASgLMhMuYWdlbnQudjEuRmV0Y2hBcmdzEiUKBnJlc3VsdBgCIAEoCzIVLmFnZW50LnYxLkZldGNoUmVzdWx0Im4KFFJlY29yZFNjcmVlblRvb2xDYWxsEigKBGFyZ3MYASABKAsyGi5hZ2VudC52MS5SZWNvcmRTY3JlZW5BcmdzEiwKBnJlc3VsdBgCIAEoCzIcLmFnZW50LnYxLlJlY29yZFNjcmVlblJlc3VsdCJ3ChdXcml0ZVNoZWxsU3RkaW5Ub29sQ2FsbBIrCgRhcmdzGAEgASgLMh0uYWdlbnQudjEuV3JpdGVTaGVsbFN0ZGluQXJncxIvCgZyZXN1bHQYAiABKAsyHy5hZ2VudC52MS5Xcml0ZVNoZWxsU3RkaW5SZXN1bHQisQEKC1JlZmxlY3RBcmdzEiIKGnVuZXhwZWN0ZWRfYWN0aW9uX291dGNvbWVzGAEgASgJEh0KFXJlbGV2YW50X2luc3RydWN0aW9ucxgCIAEoCRIZChFzY2VuYXJpb19hbmFseXNpcxgDIAEoCRIaChJjcml0aWNhbF9zeW50aGVzaXMYBCABKAkSEgoKbmV4dF9zdGVwcxgFIAEoCRIUCgx0b29sX2NhbGxfaWQYBiABKAkibwoNUmVmbGVjdFJlc3VsdBIrCgdzdWNjZXNzGAEgASgLMhguYWdlbnQudjEuUmVmbGVjdFN1Y2Nlc3NIABInCgVlcnJvchgCIAEoCzIWLmFnZW50LnYxLlJlZmxlY3RFcnJvckgAQggKBnJlc3VsdCIQCg5SZWZsZWN0U3VjY2VzcyIdCgxSZWZsZWN0RXJyb3ISDQoFZXJyb3IYASABKAkiXwoPUmVmbGVjdFRvb2xDYWxsEiMKBGFyZ3MYASABKAsyFS5hZ2VudC52MS5SZWZsZWN0QXJncxInCgZyZXN1bHQYAiABKAsyFy5hZ2VudC52MS5SZWZsZWN0UmVzdWx0IlkKF1N0YXJ0R3JpbmRFeGVjdXRpb25BcmdzEhgKC2V4cGxhbmF0aW9uGAEgASgJSACIAQESFAoMdG9vbF9jYWxsX2lkGAIgASgJQg4KDF9leHBsYW5hdGlvbiKTAQoZU3RhcnRHcmluZEV4ZWN1dGlvblJlc3VsdBI3CgdzdWNjZXNzGAEgASgLMiQuYWdlbnQudjEuU3RhcnRHcmluZEV4ZWN1dGlvblN1Y2Nlc3NIABIzCgVlcnJvchgCIAEoCzIiLmFnZW50LnYxLlN0YXJ0R3JpbmRFeGVjdXRpb25FcnJvckgAQggKBnJlc3VsdCIcChpTdGFydEdyaW5kRXhlY3V0aW9uU3VjY2VzcyIpChhTdGFydEdyaW5kRXhlY3V0aW9uRXJyb3ISDQoFZXJyb3IYASABKAkigwEKG1N0YXJ0R3JpbmRFeGVjdXRpb25Ub29sQ2FsbBIvCgRhcmdzGAEgASgLMiEuYWdlbnQudjEuU3RhcnRHcmluZEV4ZWN1dGlvbkFyZ3MSMwoGcmVzdWx0GAIgASgLMiMuYWdlbnQudjEuU3RhcnRHcmluZEV4ZWN1dGlvblJlc3VsdCJYChZTdGFydEdyaW5kUGxhbm5pbmdBcmdzEhgKC2V4cGxhbmF0aW9uGAEgASgJSACIAQESFAoMdG9vbF9jYWxsX2lkGAIgASgJQg4KDF9leHBsYW5hdGlvbiKQAQoYU3RhcnRHcmluZFBsYW5uaW5nUmVzdWx0EjYKB3N1Y2Nlc3MYASABKAsyIy5hZ2VudC52MS5TdGFydEdyaW5kUGxhbm5pbmdTdWNjZXNzSAASMgoFZXJyb3IYAiABKAsyIS5hZ2VudC52MS5TdGFydEdyaW5kUGxhbm5pbmdFcnJvckgAQggKBnJlc3VsdCIbChlTdGFydEdyaW5kUGxhbm5pbmdTdWNjZXNzIigKF1N0YXJ0R3JpbmRQbGFubmluZ0Vycm9yEg0KBWVycm9yGAEgASgJIoABChpTdGFydEdyaW5kUGxhbm5pbmdUb29sQ2FsbBIuCgRhcmdzGAEgASgLMiAuYWdlbnQudjEuU3RhcnRHcmluZFBsYW5uaW5nQXJncxIyCgZyZXN1bHQYAiABKAsyIi5hZ2VudC52MS5TdGFydEdyaW5kUGxhbm5pbmdSZXN1bHQinAEKCFRhc2tBcmdzEhMKC2Rlc2NyaXB0aW9uGAEgASgJEg4KBnByb21wdBgCIAEoCRItCg1zdWJhZ2VudF90eXBlGAMgASgLMhYuYWdlbnQudjEuU3ViYWdlbnRUeXBlEhIKBW1vZGVsGAQgASgJSACIAQESEwoGcmVzdW1lGAUgASgJSAGIAQFCCAoGX21vZGVsQgkKB19yZXN1bWUiqgEKC1Rhc2tTdWNjZXNzEjYKEmNvbnZlcnNhdGlvbl9zdGVwcxgBIAMoCzIaLmFnZW50LnYxLkNvbnZlcnNhdGlvblN0ZXASFQoIYWdlbnRfaWQYAiABKAlIAIgBARIVCg1pc19iYWNrZ3JvdW5kGAMgASgIEhgKC2R1cmF0aW9uX21zGAQgASgESAGIAQFCCwoJX2FnZW50X2lkQg4KDF9kdXJhdGlvbl9tcyIaCglUYXNrRXJyb3ISDQoFZXJyb3IYASABKAkiZgoKVGFza1Jlc3VsdBIoCgdzdWNjZXNzGAEgASgLMhUuYWdlbnQudjEuVGFza1N1Y2Nlc3NIABIkCgVlcnJvchgCIAEoCzITLmFnZW50LnYxLlRhc2tFcnJvckgAQggKBnJlc3VsdCJWCgxUYXNrVG9vbENhbGwSIAoEYXJncxgBIAEoCzISLmFnZW50LnYxLlRhc2tBcmdzEiQKBnJlc3VsdBgCIAEoCzIULmFnZW50LnYxLlRhc2tSZXN1bHQiTAoRVGFza1Rvb2xDYWxsRGVsdGESNwoSaW50ZXJhY3Rpb25fdXBkYXRlGAEgASgLMhsuYWdlbnQudjEuSW50ZXJhY3Rpb25VcGRhdGUiyw8KCFRvb2xDYWxsEjIKD3NoZWxsX3Rvb2xfY2FsbBgBIAEoCzIXLmFnZW50LnYxLlNoZWxsVG9vbENhbGxIABI0ChBkZWxldGVfdG9vbF9jYWxsGAMgASgLMhguYWdlbnQudjEuRGVsZXRlVG9vbENhbGxIABIwCg5nbG9iX3Rvb2xfY2FsbBgEIAEoCzIWLmFnZW50LnYxLkdsb2JUb29sQ2FsbEgAEjAKDmdyZXBfdG9vbF9jYWxsGAUgASgLMhYuYWdlbnQudjEuR3JlcFRvb2xDYWxsSAASMAoOcmVhZF90b29sX2NhbGwYCCABKAsyFi5hZ2VudC52MS5SZWFkVG9vbENhbGxIABI/ChZ1cGRhdGVfdG9kb3NfdG9vbF9jYWxsGAkgASgLMh0uYWdlbnQudjEuVXBkYXRlVG9kb3NUb29sQ2FsbEgAEjsKFHJlYWRfdG9kb3NfdG9vbF9jYWxsGAogASgLMhsuYWdlbnQudjEuUmVhZFRvZG9zVG9vbENhbGxIABIwCg5lZGl0X3Rvb2xfY2FsbBgMIAEoCzIWLmFnZW50LnYxLkVkaXRUb29sQ2FsbEgAEiwKDGxzX3Rvb2xfY2FsbBgNIAEoCzIULmFnZW50LnYxLkxzVG9vbENhbGxIABI7ChRyZWFkX2xpbnRzX3Rvb2xfY2FsbBgOIAEoCzIbLmFnZW50LnYxLlJlYWRMaW50c1Rvb2xDYWxsSAASLgoNbWNwX3Rvb2xfY2FsbBgPIAEoCzIVLmFnZW50LnYxLk1jcFRvb2xDYWxsSAASOwoUc2VtX3NlYXJjaF90b29sX2NhbGwYECABKAsyGy5hZ2VudC52MS5TZW1TZWFyY2hUb29sQ2FsbEgAEj0KFWNyZWF0ZV9wbGFuX3Rvb2xfY2FsbBgRIAEoCzIcLmFnZW50LnYxLkNyZWF0ZVBsYW5Ub29sQ2FsbEgAEjsKFHdlYl9zZWFyY2hfdG9vbF9jYWxsGBIgASgLMhsuYWdlbnQudjEuV2ViU2VhcmNoVG9vbENhbGxIABIwCg50YXNrX3Rvb2xfY2FsbBgTIAEoCzIWLmFnZW50LnYxLlRhc2tUb29sQ2FsbEgAEkoKHGxpc3RfbWNwX3Jlc291cmNlc190b29sX2NhbGwYFCABKAsyIi5hZ2VudC52MS5MaXN0TWNwUmVzb3VyY2VzVG9vbENhbGxIABJIChtyZWFkX21jcF9yZXNvdXJjZV90b29sX2NhbGwYFSABKAsyIS5hZ2VudC52MS5SZWFkTWNwUmVzb3VyY2VUb29sQ2FsbEgAEkYKGmFwcGx5X2FnZW50X2RpZmZfdG9vbF9jYWxsGBYgASgLMiAuYWdlbnQudjEuQXBwbHlBZ2VudERpZmZUb29sQ2FsbEgAEj8KFmFza19xdWVzdGlvbl90b29sX2NhbGwYFyABKAsyHS5hZ2VudC52MS5Bc2tRdWVzdGlvblRvb2xDYWxsSAASMgoPZmV0Y2hfdG9vbF9jYWxsGBggASgLMhcuYWdlbnQudjEuRmV0Y2hUb29sQ2FsbEgAEj0KFXN3aXRjaF9tb2RlX3Rvb2xfY2FsbBgZIAEoCzIcLmFnZW50LnYxLlN3aXRjaE1vZGVUb29sQ2FsbEgAEjsKFGV4YV9zZWFyY2hfdG9vbF9jYWxsGBogASgLMhsuYWdlbnQudjEuRXhhU2VhcmNoVG9vbENhbGxIABI5ChNleGFfZmV0Y2hfdG9vbF9jYWxsGBsgASgLMhouYWdlbnQudjEuRXhhRmV0Y2hUb29sQ2FsbEgAEkMKGGdlbmVyYXRlX2ltYWdlX3Rvb2xfY2FsbBgcIAEoCzIfLmFnZW50LnYxLkdlbmVyYXRlSW1hZ2VUb29sQ2FsbEgAEkEKF3JlY29yZF9zY3JlZW5fdG9vbF9jYWxsGB0gASgLMh4uYWdlbnQudjEuUmVjb3JkU2NyZWVuVG9vbENhbGxIABI/ChZjb21wdXRlcl91c2VfdG9vbF9jYWxsGB4gASgLMh0uYWdlbnQudjEuQ29tcHV0ZXJVc2VUb29sQ2FsbEgAEkgKG3dyaXRlX3NoZWxsX3N0ZGluX3Rvb2xfY2FsbBgfIAEoCzIhLmFnZW50LnYxLldyaXRlU2hlbGxTdGRpblRvb2xDYWxsSAASNgoRcmVmbGVjdF90b29sX2NhbGwYICABKAsyGS5hZ2VudC52MS5SZWZsZWN0VG9vbENhbGxIABJOCh5zZXR1cF92bV9lbnZpcm9ubWVudF90b29sX2NhbGwYISABKAsyJC5hZ2VudC52MS5TZXR1cFZtRW52aXJvbm1lbnRUb29sQ2FsbEgAEjoKE3RydW5jYXRlZF90b29sX2NhbGwYIiABKAsyGy5hZ2VudC52MS5UcnVuY2F0ZWRUb29sQ2FsbEgAElAKH3N0YXJ0X2dyaW5kX2V4ZWN1dGlvbl90b29sX2NhbGwYIyABKAsyJS5hZ2VudC52MS5TdGFydEdyaW5kRXhlY3V0aW9uVG9vbENhbGxIABJOCh5zdGFydF9ncmluZF9wbGFubmluZ190b29sX2NhbGwYJCABKAsyJC5hZ2VudC52MS5TdGFydEdyaW5kUGxhbm5pbmdUb29sQ2FsbEgAQgYKBHRvb2wiFwoVVHJ1bmNhdGVkVG9vbENhbGxBcmdzIhoKGFRydW5jYXRlZFRvb2xDYWxsU3VjY2VzcyInChZUcnVuY2F0ZWRUb29sQ2FsbEVycm9yEg0KBWVycm9yGAEgASgJIo0BChdUcnVuY2F0ZWRUb29sQ2FsbFJlc3VsdBI1CgdzdWNjZXNzGAEgASgLMiIuYWdlbnQudjEuVHJ1bmNhdGVkVG9vbENhbGxTdWNjZXNzSAASMQoFZXJyb3IYAiABKAsyIC5hZ2VudC52MS5UcnVuY2F0ZWRUb29sQ2FsbEVycm9ySABCCAoGcmVzdWx0IpQBChFUcnVuY2F0ZWRUb29sQ2FsbBIdChVvcmlnaW5hbF9zdGVwX2Jsb2JfaWQYASABKAwSLQoEYXJncxgCIAEoCzIfLmFnZW50LnYxLlRydW5jYXRlZFRvb2xDYWxsQXJncxIxCgZyZXN1bHQYAyABKAsyIS5hZ2VudC52MS5UcnVuY2F0ZWRUb29sQ2FsbFJlc3VsdCLRAQoNVG9vbENhbGxEZWx0YRI9ChVzaGVsbF90b29sX2NhbGxfZGVsdGEYASABKAsyHC5hZ2VudC52MS5TaGVsbFRvb2xDYWxsRGVsdGFIABI7ChR0YXNrX3Rvb2xfY2FsbF9kZWx0YRgCIAEoCzIbLmFnZW50LnYxLlRhc2tUb29sQ2FsbERlbHRhSAASOwoUZWRpdF90b29sX2NhbGxfZGVsdGEYAyABKAsyGy5hZ2VudC52MS5FZGl0VG9vbENhbGxEZWx0YUgAQgcKBWRlbHRhIrYBChBDb252ZXJzYXRpb25TdGVwEjcKEWFzc2lzdGFudF9tZXNzYWdlGAEgASgLMhouYWdlbnQudjEuQXNzaXN0YW50TWVzc2FnZUgAEicKCXRvb2xfY2FsbBgCIAEoCzISLmFnZW50LnYxLlRvb2xDYWxsSAASNQoQdGhpbmtpbmdfbWVzc2FnZRgDIAEoCzIZLmFnZW50LnYxLlRoaW5raW5nTWVzc2FnZUgAQgkKB21lc3NhZ2UigQQKEkNvbnZlcnNhdGlvbkFjdGlvbhI6ChN1c2VyX21lc3NhZ2VfYWN0aW9uGAEgASgLMhsuYWdlbnQudjEuVXNlck1lc3NhZ2VBY3Rpb25IABIvCg1yZXN1bWVfYWN0aW9uGAIgASgLMhYuYWdlbnQudjEuUmVzdW1lQWN0aW9uSAASLwoNY2FuY2VsX2FjdGlvbhgDIAEoCzIWLmFnZW50LnYxLkNhbmNlbEFjdGlvbkgAEjUKEHN1bW1hcml6ZV9hY3Rpb24YBCABKAsyGS5hZ2VudC52MS5TdW1tYXJpemVBY3Rpb25IABI8ChRzaGVsbF9jb21tYW5kX2FjdGlvbhgFIAEoCzIcLmFnZW50LnYxLlNoZWxsQ29tbWFuZEFjdGlvbkgAEjYKEXN0YXJ0X3BsYW5fYWN0aW9uGAYgASgLMhkuYWdlbnQudjEuU3RhcnRQbGFuQWN0aW9uSAASOgoTZXhlY3V0ZV9wbGFuX2FjdGlvbhgHIAEoCzIbLmFnZW50LnYxLkV4ZWN1dGVQbGFuQWN0aW9uSAASWgokYXN5bmNfYXNrX3F1ZXN0aW9uX2NvbXBsZXRpb25fYWN0aW9uGAggASgLMiouYWdlbnQudjEuQXN5bmNBc2tRdWVzdGlvbkNvbXBsZXRpb25BY3Rpb25IAEIICgZhY3Rpb24ivwEKEVVzZXJNZXNzYWdlQWN0aW9uEisKDHVzZXJfbWVzc2FnZRgBIAEoCzIVLmFnZW50LnYxLlVzZXJNZXNzYWdlEjEKD3JlcXVlc3RfY29udGV4dBgCIAEoCzIYLmFnZW50LnYxLlJlcXVlc3RDb250ZXh0EikKHHNlbmRfdG9faW50ZXJhY3Rpb25fbGlzdGVuZXIYAyABKAhIAIgBAUIfCh1fc2VuZF90b19pbnRlcmFjdGlvbl9saXN0ZW5lciIOCgxDYW5jZWxBY3Rpb24iQQoMUmVzdW1lQWN0aW9uEjEKD3JlcXVlc3RfY29udGV4dBgCIAEoCzIYLmFnZW50LnYxLlJlcXVlc3RDb250ZXh0IqABCiBBc3luY0Fza1F1ZXN0aW9uQ29tcGxldGlvbkFjdGlvbhIdChVvcmlnaW5hbF90b29sX2NhbGxfaWQYASABKAkSMAoNb3JpZ2luYWxfYXJncxgCIAEoCzIZLmFnZW50LnYxLkFza1F1ZXN0aW9uQXJncxIrCgZyZXN1bHQYAyABKAsyGy5hZ2VudC52MS5Bc2tRdWVzdGlvblJlc3VsdCIRCg9TdW1tYXJpemVBY3Rpb24iVAoSU2hlbGxDb21tYW5kQWN0aW9uEi0KDXNoZWxsX2NvbW1hbmQYASABKAsyFi5hZ2VudC52MS5TaGVsbENvbW1hbmQSDwoHZXhlY19pZBgCIAEoCSKCAQoPU3RhcnRQbGFuQWN0aW9uEisKDHVzZXJfbWVzc2FnZRgBIAEoCzIVLmFnZW50LnYxLlVzZXJNZXNzYWdlEjEKD3JlcXVlc3RfY29udGV4dBgCIAEoCzIYLmFnZW50LnYxLlJlcXVlc3RDb250ZXh0Eg8KB2lzX3NwZWMYAyABKAgi4gEKEUV4ZWN1dGVQbGFuQWN0aW9uEjEKD3JlcXVlc3RfY29udGV4dBgBIAEoCzIYLmFnZW50LnYxLlJlcXVlc3RDb250ZXh0Ei0KBHBsYW4YAiABKAsyGi5hZ2VudC52MS5Db252ZXJzYXRpb25QbGFuSACIAQESGgoNcGxhbl9maWxlX3VyaRgDIAEoCUgBiAEBEh4KEXBsYW5fZmlsZV9jb250ZW50GAQgASgJSAKIAQFCBwoFX3BsYW5CEAoOX3BsYW5fZmlsZV91cmlCFAoSX3BsYW5fZmlsZV9jb250ZW50IugCCgtVc2VyTWVzc2FnZRIMCgR0ZXh0GAEgASgJEhIKCm1lc3NhZ2VfaWQYAiABKAkSOAoQc2VsZWN0ZWRfY29udGV4dBgDIAEoCzIZLmFnZW50LnYxLlNlbGVjdGVkQ29udGV4dEgAiAEBEgwKBG1vZGUYBCABKAUSHQoQaXNfc2ltdWxhdGVkX21zZxgFIAEoCEgBiAEBEh8KEmJlc3Rfb2Zfbl9ncm91cF9pZBgGIAEoCUgCiAEBEigKG3RyeV91c2VfYmVzdF9vZl9uX3Byb21vdGlvbhgHIAEoCEgDiAEBEhYKCXJpY2hfdGV4dBgIIAEoCUgEiAEBQhMKEV9zZWxlY3RlZF9jb250ZXh0QhMKEV9pc19zaW11bGF0ZWRfbXNnQhUKE19iZXN0X29mX25fZ3JvdXBfaWRCHgocX3RyeV91c2VfYmVzdF9vZl9uX3Byb21vdGlvbkIMCgpfcmljaF90ZXh0IiAKEEFzc2lzdGFudE1lc3NhZ2USDAoEdGV4dBgBIAEoCSI0Cg9UaGlua2luZ01lc3NhZ2USDAoEdGV4dBgBIAEoCRITCgtkdXJhdGlvbl9tcxgCIAEoDSIfCgxTaGVsbENvbW1hbmQSDwoHY29tbWFuZBgBIAEoCSJACgtTaGVsbE91dHB1dBIOCgZzdGRvdXQYASABKAkSDgoGc3RkZXJyGAIgASgJEhEKCWV4aXRfY29kZRgDIAEoBSKiAQoQQ29udmVyc2F0aW9uVHVybhJCChdhZ2VudF9jb252ZXJzYXRpb25fdHVybhgBIAEoCzIfLmFnZW50LnYxLkFnZW50Q29udmVyc2F0aW9uVHVybkgAEkIKF3NoZWxsX2NvbnZlcnNhdGlvbl90dXJuGAIgASgLMh8uYWdlbnQudjEuU2hlbGxDb252ZXJzYXRpb25UdXJuSABCBgoEdHVybiIgChBDb252ZXJzYXRpb25QbGFuEgwKBHBsYW4YASABKAkivQEKGUNvbnZlcnNhdGlvblR1cm5TdHJ1Y3R1cmUSSwoXYWdlbnRfY29udmVyc2F0aW9uX3R1cm4YASABKAsyKC5hZ2VudC52MS5BZ2VudENvbnZlcnNhdGlvblR1cm5TdHJ1Y3R1cmVIABJLChdzaGVsbF9jb252ZXJzYXRpb25fdHVybhgCIAEoCzIoLmFnZW50LnYxLlNoZWxsQ29udmVyc2F0aW9uVHVyblN0cnVjdHVyZUgAQgYKBHR1cm4ilwEKFUFnZW50Q29udmVyc2F0aW9uVHVybhIrCgx1c2VyX21lc3NhZ2UYASABKAsyFS5hZ2VudC52MS5Vc2VyTWVzc2FnZRIpCgVzdGVwcxgCIAMoCzIaLmFnZW50LnYxLkNvbnZlcnNhdGlvblN0ZXASFwoKcmVxdWVzdF9pZBgDIAEoCUgAiAEBQg0KC19yZXF1ZXN0X2lkIm0KHkFnZW50Q29udmVyc2F0aW9uVHVyblN0cnVjdHVyZRIUCgx1c2VyX21lc3NhZ2UYASABKAwSDQoFc3RlcHMYAiADKAwSFwoKcmVxdWVzdF9pZBgDIAEoCUgAiAEBQg0KC19yZXF1ZXN0X2lkInMKFVNoZWxsQ29udmVyc2F0aW9uVHVybhItCg1zaGVsbF9jb21tYW5kGAEgASgLMhYuYWdlbnQudjEuU2hlbGxDb21tYW5kEisKDHNoZWxsX291dHB1dBgCIAEoCzIVLmFnZW50LnYxLlNoZWxsT3V0cHV0Ik0KHlNoZWxsQ29udmVyc2F0aW9uVHVyblN0cnVjdHVyZRIVCg1zaGVsbF9jb21tYW5kGAEgASgMEhQKDHNoZWxsX291dHB1dBgCIAEoDCImChNDb252ZXJzYXRpb25TdW1tYXJ5Eg8KB3N1bW1hcnkYASABKAkieAoaQ29udmVyc2F0aW9uU3VtbWFyeUFyY2hpdmUSGwoTc3VtbWFyaXplZF9tZXNzYWdlcxgBIAMoDBIPCgdzdW1tYXJ5GAIgASgJEhMKC3dpbmRvd190YWlsGAMgASgNEhcKD3N1bW1hcnlfbWVzc2FnZRgEIAEoDCJDChhDb252ZXJzYXRpb25Ub2tlbkRldGFpbHMSEwoLdXNlZF90b2tlbnMYASABKA0SEgoKbWF4X3Rva2VucxgCIAEoDSJfCglGaWxlU3RhdGUSFAoHY29udGVudBgBIAEoCUgAiAEBEhwKD2luaXRpYWxfY29udGVudBgCIAEoCUgBiAEBQgoKCF9jb250ZW50QhIKEF9pbml0aWFsX2NvbnRlbnQiaAoSRmlsZVN0YXRlU3RydWN0dXJlEhQKB2NvbnRlbnQYASABKAxIAIgBARIcCg9pbml0aWFsX2NvbnRlbnQYAiABKAxIAYgBAUIKCghfY29udGVudEISChBfaW5pdGlhbF9jb250ZW50IjcKClN0ZXBUaW1pbmcSEwoLZHVyYXRpb25fbXMYASABKAQSFAoMdGltZXN0YW1wX21zGAIgASgEIvYEChFDb252ZXJzYXRpb25TdGF0ZRIhChlyb290X3Byb21wdF9tZXNzYWdlc19qc29uGAEgAygJEikKBXR1cm5zGAggAygLMhouYWdlbnQudjEuQ29udmVyc2F0aW9uVHVybhIhCgV0b2RvcxgDIAMoCzISLmFnZW50LnYxLlRvZG9JdGVtEhoKEnBlbmRpbmdfdG9vbF9jYWxscxgEIAMoCRI5Cg10b2tlbl9kZXRhaWxzGAUgASgLMiIuYWdlbnQudjEuQ29udmVyc2F0aW9uVG9rZW5EZXRhaWxzEjMKB3N1bW1hcnkYBiABKAsyHS5hZ2VudC52MS5Db252ZXJzYXRpb25TdW1tYXJ5SACIAQESLQoEcGxhbhgHIAEoCzIaLmFnZW50LnYxLkNvbnZlcnNhdGlvblBsYW5IAYgBARJCCg9zdW1tYXJ5X2FyY2hpdmUYCSABKAsyJC5hZ2VudC52MS5Db252ZXJzYXRpb25TdW1tYXJ5QXJjaGl2ZUgCiAEBEkAKC2ZpbGVfc3RhdGVzGAogAygLMisuYWdlbnQudjEuQ29udmVyc2F0aW9uU3RhdGUuRmlsZVN0YXRlc0VudHJ5Ej4KEHN1bW1hcnlfYXJjaGl2ZXMYCyADKAsyJC5hZ2VudC52MS5Db252ZXJzYXRpb25TdW1tYXJ5QXJjaGl2ZRpGCg9GaWxlU3RhdGVzRW50cnkSCwoDa2V5GAEgASgJEiIKBXZhbHVlGAIgASgLMhMuYWdlbnQudjEuRmlsZVN0YXRlOgI4AUIKCghfc3VtbWFyeUIHCgVfcGxhbkISChBfc3VtbWFyeV9hcmNoaXZlIscBChZTdWJhZ2VudFBlcnNpc3RlZFN0YXRlEkAKEmNvbnZlcnNhdGlvbl9zdGF0ZRgBIAEoCzIkLmFnZW50LnYxLkNvbnZlcnNhdGlvblN0YXRlU3RydWN0dXJlEhwKFGNyZWF0ZWRfdGltZXN0YW1wX21zGAIgASgEEh4KFmxhc3RfdXNlZF90aW1lc3RhbXBfbXMYAyABKAQSLQoNc3ViYWdlbnRfdHlwZRgEIAEoCzIWLmFnZW50LnYxLlN1YmFnZW50VHlwZSK3BwoaQ29udmVyc2F0aW9uU3RhdGVTdHJ1Y3R1cmUSEQoJdHVybnNfb2xkGAIgAygMEiEKGXJvb3RfcHJvbXB0X21lc3NhZ2VzX2pzb24YASADKAwSDQoFdHVybnMYCCADKAwSDQoFdG9kb3MYAyADKAwSGgoScGVuZGluZ190b29sX2NhbGxzGAQgAygJEjkKDXRva2VuX2RldGFpbHMYBSABKAsyIi5hZ2VudC52MS5Db252ZXJzYXRpb25Ub2tlbkRldGFpbHMSFAoHc3VtbWFyeRgGIAEoDEgAiAEBEhEKBHBsYW4YByABKAxIAYgBARIfChdwcmV2aW91c193b3Jrc3BhY2VfdXJpcxgJIAMoCRIRCgRtb2RlGAogASgFSAKIAQESHAoPc3VtbWFyeV9hcmNoaXZlGAsgASgMSAOIAQESSQoLZmlsZV9zdGF0ZXMYDCADKAsyNC5hZ2VudC52MS5Db252ZXJzYXRpb25TdGF0ZVN0cnVjdHVyZS5GaWxlU3RhdGVzRW50cnkSTgoOZmlsZV9zdGF0ZXNfdjIYDyADKAsyNi5hZ2VudC52MS5Db252ZXJzYXRpb25TdGF0ZVN0cnVjdHVyZS5GaWxlU3RhdGVzVjJFbnRyeRIYChBzdW1tYXJ5X2FyY2hpdmVzGA0gAygMEioKDHR1cm5fdGltaW5ncxgOIAMoCzIULmFnZW50LnYxLlN0ZXBUaW1pbmcSUQoPc3ViYWdlbnRfc3RhdGVzGBAgAygLMjguYWdlbnQudjEuQ29udmVyc2F0aW9uU3RhdGVTdHJ1Y3R1cmUuU3ViYWdlbnRTdGF0ZXNFbnRyeRIaChJzZWxmX3N1bW1hcnlfY291bnQYESABKA0SEgoKcmVhZF9wYXRocxgSIAMoCRoxCg9GaWxlU3RhdGVzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgMOgI4ARpRChFGaWxlU3RhdGVzVjJFbnRyeRILCgNrZXkYASABKAkSKwoFdmFsdWUYAiABKAsyHC5hZ2VudC52MS5GaWxlU3RhdGVTdHJ1Y3R1cmU6AjgBGlcKE1N1YmFnZW50U3RhdGVzRW50cnkSCwoDa2V5GAEgASgJEi8KBXZhbHVlGAIgASgLMiAuYWdlbnQudjEuU3ViYWdlbnRQZXJzaXN0ZWRTdGF0ZToCOAFCCgoIX3N1bW1hcnlCBwoFX3BsYW5CBwoFX21vZGVCEgoQX3N1bW1hcnlfYXJjaGl2ZSIRCg9UaGlua2luZ0RldGFpbHMiSAoRQXBpS2V5Q3JlZGVudGlhbHMSDwoHYXBpX2tleRgBIAEoCRIVCghiYXNlX3VybBgCIAEoCUgAiAEBQgsKCV9iYXNlX3VybCJJChBBenVyZUNyZWRlbnRpYWxzEg8KB2FwaV9rZXkYASABKAkSEAoIYmFzZV91cmwYAiABKAkSEgoKZGVwbG95bWVudBgDIAEoCSJ6ChJCZWRyb2NrQ3JlZGVudGlhbHMSEgoKYWNjZXNzX2tleRgBIAEoCRISCgpzZWNyZXRfa2V5GAIgASgJEg4KBnJlZ2lvbhgDIAEoCRIaCg1zZXNzaW9uX3Rva2VuGAQgASgJSACIAQFCEAoOX3Nlc3Npb25fdG9rZW4isQMKDE1vZGVsRGV0YWlscxIQCghtb2RlbF9pZBgBIAEoCRIYChBkaXNwbGF5X21vZGVsX2lkGAMgASgJEhQKDGRpc3BsYXlfbmFtZRgEIAEoCRIaChJkaXNwbGF5X25hbWVfc2hvcnQYBSABKAkSDwoHYWxpYXNlcxgGIAMoCRI4ChB0aGlua2luZ19kZXRhaWxzGAIgASgLMhkuYWdlbnQudjEuVGhpbmtpbmdEZXRhaWxzSAGIAQESFQoIbWF4X21vZGUYByABKAhIAogBARI6ChNhcGlfa2V5X2NyZWRlbnRpYWxzGAggASgLMhsuYWdlbnQudjEuQXBpS2V5Q3JlZGVudGlhbHNIABI3ChFhenVyZV9jcmVkZW50aWFscxgJIAEoCzIaLmFnZW50LnYxLkF6dXJlQ3JlZGVudGlhbHNIABI7ChNiZWRyb2NrX2NyZWRlbnRpYWxzGAogASgLMhwuYWdlbnQudjEuQmVkcm9ja0NyZWRlbnRpYWxzSABCDQoLY3JlZGVudGlhbHNCEwoRX3RoaW5raW5nX2RldGFpbHNCCwoJX21heF9tb2RlIrcCCg5SZXF1ZXN0ZWRNb2RlbBIQCghtb2RlbF9pZBgBIAEoCRIQCghtYXhfbW9kZRgCIAEoCBJACgpwYXJhbWV0ZXJzGAMgAygLMiwuYWdlbnQudjEuUmVxdWVzdGVkTW9kZWxfTW9kZWxQYXJhbWV0ZXJieXRlcxI6ChNhcGlfa2V5X2NyZWRlbnRpYWxzGAQgASgLMhsuYWdlbnQudjEuQXBpS2V5Q3JlZGVudGlhbHNIABI3ChFhenVyZV9jcmVkZW50aWFscxgFIAEoCzIaLmFnZW50LnYxLkF6dXJlQ3JlZGVudGlhbHNIABI7ChNiZWRyb2NrX2NyZWRlbnRpYWxzGAYgASgLMhwuYWdlbnQudjEuQmVkcm9ja0NyZWRlbnRpYWxzSABCDQoLY3JlZGVudGlhbHMiPwoiUmVxdWVzdGVkTW9kZWxfTW9kZWxQYXJhbWV0ZXJieXRlcxIKCgJpZBgBIAEoCRINCgV2YWx1ZRgCIAEoCSK5BAoPQWdlbnRSdW5SZXF1ZXN0EkAKEmNvbnZlcnNhdGlvbl9zdGF0ZRgBIAEoCzIkLmFnZW50LnYxLkNvbnZlcnNhdGlvblN0YXRlU3RydWN0dXJlEiwKBmFjdGlvbhgCIAEoCzIcLmFnZW50LnYxLkNvbnZlcnNhdGlvbkFjdGlvbhItCg1tb2RlbF9kZXRhaWxzGAMgASgLMhYuYWdlbnQudjEuTW9kZWxEZXRhaWxzEjYKD3JlcXVlc3RlZF9tb2RlbBgJIAEoCzIYLmFnZW50LnYxLlJlcXVlc3RlZE1vZGVsSACIAQESJQoJbWNwX3Rvb2xzGAQgASgLMhIuYWdlbnQudjEuTWNwVG9vbHMSHAoPY29udmVyc2F0aW9uX2lkGAUgASgJSAGIAQESRAoXbWNwX2ZpbGVfc3lzdGVtX29wdGlvbnMYBiABKAsyHi5hZ2VudC52MS5NY3BGaWxlU3lzdGVtT3B0aW9uc0gCiAEBEjIKDXNraWxsX29wdGlvbnMYByABKAsyFi5hZ2VudC52MS5Ta2lsbE9wdGlvbnNIA4gBARIhChRjdXN0b21fc3lzdGVtX3Byb21wdBgIIAEoCUgEiAEBQhIKEF9yZXF1ZXN0ZWRfbW9kZWxCEgoQX2NvbnZlcnNhdGlvbl9pZEIaChhfbWNwX2ZpbGVfc3lzdGVtX29wdGlvbnNCEAoOX3NraWxsX29wdGlvbnNCFwoVX2N1c3RvbV9zeXN0ZW1fcHJvbXB0Ih8KD1RleHREZWx0YVVwZGF0ZRIMCgR0ZXh0GAEgASgJImYKFVRvb2xDYWxsU3RhcnRlZFVwZGF0ZRIPCgdjYWxsX2lkGAEgASgJEiUKCXRvb2xfY2FsbBgCIAEoCzISLmFnZW50LnYxLlRvb2xDYWxsEhUKDW1vZGVsX2NhbGxfaWQYAyABKAkiaAoXVG9vbENhbGxDb21wbGV0ZWRVcGRhdGUSDwoHY2FsbF9pZBgBIAEoCRIlCgl0b29sX2NhbGwYAiABKAsyEi5hZ2VudC52MS5Ub29sQ2FsbBIVCg1tb2RlbF9jYWxsX2lkGAMgASgJIm8KE1Rvb2xDYWxsRGVsdGFVcGRhdGUSDwoHY2FsbF9pZBgBIAEoCRIwCg90b29sX2NhbGxfZGVsdGEYAiABKAsyFy5hZ2VudC52MS5Ub29sQ2FsbERlbHRhEhUKDW1vZGVsX2NhbGxfaWQYAyABKAkifwoVUGFydGlhbFRvb2xDYWxsVXBkYXRlEg8KB2NhbGxfaWQYASABKAkSJQoJdG9vbF9jYWxsGAIgASgLMhIuYWdlbnQudjEuVG9vbENhbGwSFwoPYXJnc190ZXh0X2RlbHRhGAMgASgJEhUKDW1vZGVsX2NhbGxfaWQYBCABKAkiIwoTVGhpbmtpbmdEZWx0YVVwZGF0ZRIMCgR0ZXh0GAEgASgJIjcKF1RoaW5raW5nQ29tcGxldGVkVXBkYXRlEhwKFHRoaW5raW5nX2R1cmF0aW9uX21zGAEgASgFIiIKEFRva2VuRGVsdGFVcGRhdGUSDgoGdG9rZW5zGAEgASgFIiAKDVN1bW1hcnlVcGRhdGUSDwoHc3VtbWFyeRgBIAEoCSIWChRTdW1tYXJ5U3RhcnRlZFVwZGF0ZSIRCg9IZWFydGJlYXRVcGRhdGUiGAoWU3VtbWFyeUNvbXBsZXRlZFVwZGF0ZSLXAQoWU2hlbGxPdXRwdXREZWx0YVVwZGF0ZRItCgZzdGRvdXQYASABKAsyGy5hZ2VudC52MS5TaGVsbFN0cmVhbVN0ZG91dEgAEi0KBnN0ZGVychgCIAEoCzIbLmFnZW50LnYxLlNoZWxsU3RyZWFtU3RkZXJySAASKQoEZXhpdBgDIAEoCzIZLmFnZW50LnYxLlNoZWxsU3RyZWFtRXhpdEgAEisKBXN0YXJ0GAQgASgLMhouYWdlbnQudjEuU2hlbGxTdHJlYW1TdGFydEgAQgcKBWV2ZW50IhEKD1R1cm5FbmRlZFVwZGF0ZSJIChlVc2VyTWVzc2FnZUFwcGVuZGVkVXBkYXRlEisKDHVzZXJfbWVzc2FnZRgBIAEoCzIVLmFnZW50LnYxLlVzZXJNZXNzYWdlIiQKEVN0ZXBTdGFydGVkVXBkYXRlEg8KB3N0ZXBfaWQYASABKAQiQAoTU3RlcENvbXBsZXRlZFVwZGF0ZRIPCgdzdGVwX2lkGAEgASgEEhgKEHN0ZXBfZHVyYXRpb25fbXMYAiABKAMi7wcKEUludGVyYWN0aW9uVXBkYXRlEi8KCnRleHRfZGVsdGEYASABKAsyGS5hZ2VudC52MS5UZXh0RGVsdGFVcGRhdGVIABI8ChFwYXJ0aWFsX3Rvb2xfY2FsbBgHIAEoCzIfLmFnZW50LnYxLlBhcnRpYWxUb29sQ2FsbFVwZGF0ZUgAEjgKD3Rvb2xfY2FsbF9kZWx0YRgPIAEoCzIdLmFnZW50LnYxLlRvb2xDYWxsRGVsdGFVcGRhdGVIABI8ChF0b29sX2NhbGxfc3RhcnRlZBgCIAEoCzIfLmFnZW50LnYxLlRvb2xDYWxsU3RhcnRlZFVwZGF0ZUgAEkAKE3Rvb2xfY2FsbF9jb21wbGV0ZWQYAyABKAsyIS5hZ2VudC52MS5Ub29sQ2FsbENvbXBsZXRlZFVwZGF0ZUgAEjcKDnRoaW5raW5nX2RlbHRhGAQgASgLMh0uYWdlbnQudjEuVGhpbmtpbmdEZWx0YVVwZGF0ZUgAEj8KEnRoaW5raW5nX2NvbXBsZXRlZBgFIAEoCzIhLmFnZW50LnYxLlRoaW5raW5nQ29tcGxldGVkVXBkYXRlSAASRAoVdXNlcl9tZXNzYWdlX2FwcGVuZGVkGAYgASgLMiMuYWdlbnQudjEuVXNlck1lc3NhZ2VBcHBlbmRlZFVwZGF0ZUgAEjEKC3Rva2VuX2RlbHRhGAggASgLMhouYWdlbnQudjEuVG9rZW5EZWx0YVVwZGF0ZUgAEioKB3N1bW1hcnkYCSABKAsyFy5hZ2VudC52MS5TdW1tYXJ5VXBkYXRlSAASOQoPc3VtbWFyeV9zdGFydGVkGAogASgLMh4uYWdlbnQudjEuU3VtbWFyeVN0YXJ0ZWRVcGRhdGVIABI9ChFzdW1tYXJ5X2NvbXBsZXRlZBgLIAEoCzIgLmFnZW50LnYxLlN1bW1hcnlDb21wbGV0ZWRVcGRhdGVIABI+ChJzaGVsbF9vdXRwdXRfZGVsdGEYDCABKAsyIC5hZ2VudC52MS5TaGVsbE91dHB1dERlbHRhVXBkYXRlSAASLgoJaGVhcnRiZWF0GA0gASgLMhkuYWdlbnQudjEuSGVhcnRiZWF0VXBkYXRlSAASLwoKdHVybl9lbmRlZBgOIAEoCzIZLmFnZW50LnYxLlR1cm5FbmRlZFVwZGF0ZUgAEjMKDHN0ZXBfc3RhcnRlZBgQIAEoCzIbLmFnZW50LnYxLlN0ZXBTdGFydGVkVXBkYXRlSAASNwoOc3RlcF9jb21wbGV0ZWQYESABKAsyHS5hZ2VudC52MS5TdGVwQ29tcGxldGVkVXBkYXRlSABCCQoHbWVzc2FnZSKaBAoQSW50ZXJhY3Rpb25RdWVyeRIKCgJpZBgBIAEoDRJDChh3ZWJfc2VhcmNoX3JlcXVlc3RfcXVlcnkYAiABKAsyHy5hZ2VudC52MS5XZWJTZWFyY2hSZXF1ZXN0UXVlcnlIABJPCh5hc2tfcXVlc3Rpb25faW50ZXJhY3Rpb25fcXVlcnkYAyABKAsyJS5hZ2VudC52MS5Bc2tRdWVzdGlvbkludGVyYWN0aW9uUXVlcnlIABJFChlzd2l0Y2hfbW9kZV9yZXF1ZXN0X3F1ZXJ5GAQgASgLMiAuYWdlbnQudjEuU3dpdGNoTW9kZVJlcXVlc3RRdWVyeUgAEkMKGGV4YV9zZWFyY2hfcmVxdWVzdF9xdWVyeRgFIAEoCzIfLmFnZW50LnYxLkV4YVNlYXJjaFJlcXVlc3RRdWVyeUgAEkEKF2V4YV9mZXRjaF9yZXF1ZXN0X3F1ZXJ5GAYgASgLMh4uYWdlbnQudjEuRXhhRmV0Y2hSZXF1ZXN0UXVlcnlIABJFChljcmVhdGVfcGxhbl9yZXF1ZXN0X3F1ZXJ5GAcgASgLMiAuYWdlbnQudjEuQ3JlYXRlUGxhblJlcXVlc3RRdWVyeUgAEkUKGXNldHVwX3ZtX2Vudmlyb25tZW50X2FyZ3MYCCABKAsyIC5hZ2VudC52MS5TZXR1cFZtRW52aXJvbm1lbnRBcmdzSABCBwoFcXVlcnkixgQKE0ludGVyYWN0aW9uUmVzcG9uc2USCgoCaWQYASABKA0SSQobd2ViX3NlYXJjaF9yZXF1ZXN0X3Jlc3BvbnNlGAIgASgLMiIuYWdlbnQudjEuV2ViU2VhcmNoUmVxdWVzdFJlc3BvbnNlSAASVQohYXNrX3F1ZXN0aW9uX2ludGVyYWN0aW9uX3Jlc3BvbnNlGAMgASgLMiguYWdlbnQudjEuQXNrUXVlc3Rpb25JbnRlcmFjdGlvblJlc3BvbnNlSAASSwocc3dpdGNoX21vZGVfcmVxdWVzdF9yZXNwb25zZRgEIAEoCzIjLmFnZW50LnYxLlN3aXRjaE1vZGVSZXF1ZXN0UmVzcG9uc2VIABJJChtleGFfc2VhcmNoX3JlcXVlc3RfcmVzcG9uc2UYBSABKAsyIi5hZ2VudC52MS5FeGFTZWFyY2hSZXF1ZXN0UmVzcG9uc2VIABJHChpleGFfZmV0Y2hfcmVxdWVzdF9yZXNwb25zZRgGIAEoCzIhLmFnZW50LnYxLkV4YUZldGNoUmVxdWVzdFJlc3BvbnNlSAASSwocY3JlYXRlX3BsYW5fcmVxdWVzdF9yZXNwb25zZRgHIAEoCzIjLmFnZW50LnYxLkNyZWF0ZVBsYW5SZXF1ZXN0UmVzcG9uc2VIABJJChtzZXR1cF92bV9lbnZpcm9ubWVudF9yZXN1bHQYCCABKAsyIi5hZ2VudC52MS5TZXR1cFZtRW52aXJvbm1lbnRSZXN1bHRIAEIICgZyZXN1bHQiXAobQXNrUXVlc3Rpb25JbnRlcmFjdGlvblF1ZXJ5EicKBGFyZ3MYASABKAsyGS5hZ2VudC52MS5Bc2tRdWVzdGlvbkFyZ3MSFAoMdG9vbF9jYWxsX2lkGAIgASgJIk0KHkFza1F1ZXN0aW9uSW50ZXJhY3Rpb25SZXNwb25zZRIrCgZyZXN1bHQYASABKAsyGy5hZ2VudC52MS5Bc2tRdWVzdGlvblJlc3VsdCIRCg9DbGllbnRIZWFydGJlYXQixgQKDlByZXdhcm1SZXF1ZXN0Ei0KDW1vZGVsX2RldGFpbHMYASABKAsyFi5hZ2VudC52MS5Nb2RlbERldGFpbHMSNgoPcmVxdWVzdGVkX21vZGVsGAkgASgLMhguYWdlbnQudjEuUmVxdWVzdGVkTW9kZWxIAIgBARIcCg9jb252ZXJzYXRpb25faWQYAiABKAlIAYgBARJAChJjb252ZXJzYXRpb25fc3RhdGUYAyABKAsyJC5hZ2VudC52MS5Db252ZXJzYXRpb25TdGF0ZVN0cnVjdHVyZRIlCgltY3BfdG9vbHMYBCABKAsyEi5hZ2VudC52MS5NY3BUb29scxJEChdtY3BfZmlsZV9zeXN0ZW1fb3B0aW9ucxgFIAEoCzIeLmFnZW50LnYxLk1jcEZpbGVTeXN0ZW1PcHRpb25zSAKIAQESHwoSYmVzdF9vZl9uX2dyb3VwX2lkGAYgASgJSAOIAQESKAobdHJ5X3VzZV9iZXN0X29mX25fcHJvbW90aW9uGAcgASgISASIAQESIQoUY3VzdG9tX3N5c3RlbV9wcm9tcHQYCCABKAlIBYgBAUISChBfcmVxdWVzdGVkX21vZGVsQhIKEF9jb252ZXJzYXRpb25faWRCGgoYX21jcF9maWxlX3N5c3RlbV9vcHRpb25zQhUKE19iZXN0X29mX25fZ3JvdXBfaWRCHgocX3RyeV91c2VfYmVzdF9vZl9uX3Byb21vdGlvbkIXChVfY3VzdG9tX3N5c3RlbV9wcm9tcHQiHQoPRXhlY1NlcnZlckFib3J0EgoKAmlkGAEgASgNIlEKGEV4ZWNTZXJ2ZXJDb250cm9sTWVzc2FnZRIqCgVhYm9ydBgBIAEoCzIZLmFnZW50LnYxLkV4ZWNTZXJ2ZXJBYm9ydEgAQgkKB21lc3NhZ2Ui+AMKEkFnZW50Q2xpZW50TWVzc2FnZRIwCgtydW5fcmVxdWVzdBgBIAEoCzIZLmFnZW50LnYxLkFnZW50UnVuUmVxdWVzdEgAEjoKE2V4ZWNfY2xpZW50X21lc3NhZ2UYAiABKAsyGy5hZ2VudC52MS5FeGVjQ2xpZW50TWVzc2FnZUgAEkkKG2V4ZWNfY2xpZW50X2NvbnRyb2xfbWVzc2FnZRgFIAEoCzIiLmFnZW50LnYxLkV4ZWNDbGllbnRDb250cm9sTWVzc2FnZUgAEjYKEWt2X2NsaWVudF9tZXNzYWdlGAMgASgLMhkuYWdlbnQudjEuS3ZDbGllbnRNZXNzYWdlSAASOwoTY29udmVyc2F0aW9uX2FjdGlvbhgEIAEoCzIcLmFnZW50LnYxLkNvbnZlcnNhdGlvbkFjdGlvbkgAEj0KFGludGVyYWN0aW9uX3Jlc3BvbnNlGAYgASgLMh0uYWdlbnQudjEuSW50ZXJhY3Rpb25SZXNwb25zZUgAEjUKEGNsaWVudF9oZWFydGJlYXQYByABKAsyGS5hZ2VudC52MS5DbGllbnRIZWFydGJlYXRIABIzCg9wcmV3YXJtX3JlcXVlc3QYCCABKAsyGC5hZ2VudC52MS5QcmV3YXJtUmVxdWVzdEgAQgkKB21lc3NhZ2UiogMKEkFnZW50U2VydmVyTWVzc2FnZRI5ChJpbnRlcmFjdGlvbl91cGRhdGUYASABKAsyGy5hZ2VudC52MS5JbnRlcmFjdGlvblVwZGF0ZUgAEjoKE2V4ZWNfc2VydmVyX21lc3NhZ2UYAiABKAsyGy5hZ2VudC52MS5FeGVjU2VydmVyTWVzc2FnZUgAEkkKG2V4ZWNfc2VydmVyX2NvbnRyb2xfbWVzc2FnZRgFIAEoCzIiLmFnZW50LnYxLkV4ZWNTZXJ2ZXJDb250cm9sTWVzc2FnZUgAEk4KHmNvbnZlcnNhdGlvbl9jaGVja3BvaW50X3VwZGF0ZRgDIAEoCzIkLmFnZW50LnYxLkNvbnZlcnNhdGlvblN0YXRlU3RydWN0dXJlSAASNgoRa3Zfc2VydmVyX21lc3NhZ2UYBCABKAsyGS5hZ2VudC52MS5LdlNlcnZlck1lc3NhZ2VIABI3ChFpbnRlcmFjdGlvbl9xdWVyeRgHIAEoCzIaLmFnZW50LnYxLkludGVyYWN0aW9uUXVlcnlIAEIJCgdtZXNzYWdlIigKEE5hbWVBZ2VudFJlcXVlc3QSFAoMdXNlcl9tZXNzYWdlGAEgASgJIiEKEU5hbWVBZ2VudFJlc3BvbnNlEgwKBG5hbWUYASABKAkiMgoWR2V0VXNhYmxlTW9kZWxzUmVxdWVzdBIYChBjdXN0b21fbW9kZWxfaWRzGAEgAygJIkEKF0dldFVzYWJsZU1vZGVsc1Jlc3BvbnNlEiYKBm1vZGVscxgBIAMoCzIWLmFnZW50LnYxLk1vZGVsRGV0YWlscyIeChxHZXREZWZhdWx0TW9kZWxGb3JDbGlSZXF1ZXN0IkYKHUdldERlZmF1bHRNb2RlbEZvckNsaVJlc3BvbnNlEiUKBW1vZGVsGAEgASgLMhYuYWdlbnQudjEuTW9kZWxEZXRhaWxzIh8KHUdldEFsbG93ZWRNb2RlbEludGVudHNSZXF1ZXN0IjcKHkdldEFsbG93ZWRNb2RlbEludGVudHNSZXNwb25zZRIVCg1tb2RlbF9pbnRlbnRzGAEgAygJIpcCChNJZGVFZGl0b3JzU3RhdGVGaWxlEhUKDXJlbGF0aXZlX3BhdGgYASABKAkSFQoNYWJzb2x1dGVfcGF0aBgCIAEoCRIhChRpc19jdXJyZW50bHlfZm9jdXNlZBgDIAEoCEgAiAEBEiAKE2N1cnJlbnRfbGluZV9udW1iZXIYBCABKAVIAYgBARIeChFjdXJyZW50X2xpbmVfdGV4dBgFIAEoCUgCiAEBEhcKCmxpbmVfY291bnQYBiABKAVIA4gBAUIXChVfaXNfY3VycmVudGx5X2ZvY3VzZWRCFgoUX2N1cnJlbnRfbGluZV9udW1iZXJCFAoSX2N1cnJlbnRfbGluZV90ZXh0Qg0KC19saW5lX2NvdW50IlMKE0lkZUVkaXRvcnNTdGF0ZUxpdGUSPAoVcmVjZW50bHlfdmlld2VkX2ZpbGVzGAEgAygLMh0uYWdlbnQudjEuSWRlRWRpdG9yc1N0YXRlRmlsZSJ0ChZBcHBseUFnZW50RGlmZlRvb2xDYWxsEioKBGFyZ3MYASABKAsyHC5hZ2VudC52MS5BcHBseUFnZW50RGlmZkFyZ3MSLgoGcmVzdWx0GAIgASgLMh4uYWdlbnQudjEuQXBwbHlBZ2VudERpZmZSZXN1bHQiJgoSQXBwbHlBZ2VudERpZmZBcmdzEhAKCGFnZW50X2lkGAEgASgJIoQBChRBcHBseUFnZW50RGlmZlJlc3VsdBIyCgdzdWNjZXNzGAEgASgLMh8uYWdlbnQudjEuQXBwbHlBZ2VudERpZmZTdWNjZXNzSAASLgoFZXJyb3IYAiABKAsyHS5hZ2VudC52MS5BcHBseUFnZW50RGlmZkVycm9ySABCCAoGcmVzdWx0Ik4KFUFwcGx5QWdlbnREaWZmU3VjY2VzcxI1Cg9hcHBsaWVkX2NoYW5nZXMYASADKAsyHC5hZ2VudC52MS5BcHBsaWVkQWdlbnRDaGFuZ2Ui6QEKEkFwcGxpZWRBZ2VudENoYW5nZRIMCgRwYXRoGAEgASgJEhMKC2NoYW5nZV90eXBlGAIgASgFEhsKDmJlZm9yZV9jb250ZW50GAMgASgJSACIAQESGgoNYWZ0ZXJfY29udGVudBgEIAEoCUgBiAEBEhIKBWVycm9yGAUgASgJSAKIAQESHgoRbWVzc2FnZV9mb3JfbW9kZWwYBiABKAlIA4gBAUIRCg9fYmVmb3JlX2NvbnRlbnRCEAoOX2FmdGVyX2NvbnRlbnRCCAoGX2Vycm9yQhQKEl9tZXNzYWdlX2Zvcl9tb2RlbCJbChNBcHBseUFnZW50RGlmZkVycm9yEg0KBWVycm9yGAEgASgJEjUKD2FwcGxpZWRfY2hhbmdlcxgCIAMoCzIcLmFnZW50LnYxLkFwcGxpZWRBZ2VudENoYW5nZSJrChNBc2tRdWVzdGlvblRvb2xDYWxsEicKBGFyZ3MYASABKAsyGS5hZ2VudC52MS5Bc2tRdWVzdGlvbkFyZ3MSKwoGcmVzdWx0GAIgASgLMhsuYWdlbnQudjEuQXNrUXVlc3Rpb25SZXN1bHQijwEKD0Fza1F1ZXN0aW9uQXJncxINCgV0aXRsZRgBIAEoCRI1CglxdWVzdGlvbnMYAiADKAsyIi5hZ2VudC52MS5Bc2tRdWVzdGlvbkFyZ3NfUXVlc3Rpb24SEQoJcnVuX2FzeW5jGAUgASgIEiMKG2FzeW5jX29yaWdpbmFsX3Rvb2xfY2FsbF9pZBgGIAEoCSKBAQoYQXNrUXVlc3Rpb25BcmdzX1F1ZXN0aW9uEgoKAmlkGAEgASgJEg4KBnByb21wdBgCIAEoCRIxCgdvcHRpb25zGAMgAygLMiAuYWdlbnQudjEuQXNrUXVlc3Rpb25BcmdzX09wdGlvbhIWCg5hbGxvd19tdWx0aXBsZRgEIAEoCCIzChZBc2tRdWVzdGlvbkFyZ3NfT3B0aW9uEgoKAmlkGAEgASgJEg0KBWxhYmVsGAIgASgJIhIKEEFza1F1ZXN0aW9uQXN5bmMi2wEKEUFza1F1ZXN0aW9uUmVzdWx0Ei8KB3N1Y2Nlc3MYASABKAsyHC5hZ2VudC52MS5Bc2tRdWVzdGlvblN1Y2Nlc3NIABIrCgVlcnJvchgCIAEoCzIaLmFnZW50LnYxLkFza1F1ZXN0aW9uRXJyb3JIABIxCghyZWplY3RlZBgDIAEoCzIdLmFnZW50LnYxLkFza1F1ZXN0aW9uUmVqZWN0ZWRIABIrCgVhc3luYxgEIAEoCzIaLmFnZW50LnYxLkFza1F1ZXN0aW9uQXN5bmNIAEIICgZyZXN1bHQiSgoSQXNrUXVlc3Rpb25TdWNjZXNzEjQKB2Fuc3dlcnMYASADKAsyIy5hZ2VudC52MS5Bc2tRdWVzdGlvblN1Y2Nlc3NfQW5zd2VyIk0KGUFza1F1ZXN0aW9uU3VjY2Vzc19BbnN3ZXISEwoLcXVlc3Rpb25faWQYASABKAkSGwoTc2VsZWN0ZWRfb3B0aW9uX2lkcxgCIAMoCSIpChBBc2tRdWVzdGlvbkVycm9yEhUKDWVycm9yX21lc3NhZ2UYASABKAkiJQoTQXNrUXVlc3Rpb25SZWplY3RlZBIOCgZyZWFzb24YASABKAkiiQIKGEJhY2tncm91bmRTaGVsbFNwYXduQXJncxIPCgdjb21tYW5kGAEgASgJEhkKEXdvcmtpbmdfZGlyZWN0b3J5GAIgASgJEhQKDHRvb2xfY2FsbF9pZBgDIAEoCRI7Cg5wYXJzaW5nX3Jlc3VsdBgEIAEoCzIjLmFnZW50LnYxLlNoZWxsQ29tbWFuZFBhcnNpbmdSZXN1bHQSNAoOc2FuZGJveF9wb2xpY3kYBSABKAsyFy5hZ2VudC52MS5TYW5kYm94UG9saWN5SACIAQESJQodZW5hYmxlX3dyaXRlX3NoZWxsX3N0ZGluX3Rvb2wYBiABKAhCEQoPX3NhbmRib3hfcG9saWN5IoECChpCYWNrZ3JvdW5kU2hlbGxTcGF3blJlc3VsdBI4CgdzdWNjZXNzGAEgASgLMiUuYWdlbnQudjEuQmFja2dyb3VuZFNoZWxsU3Bhd25TdWNjZXNzSAASNAoFZXJyb3IYAiABKAsyIy5hZ2VudC52MS5CYWNrZ3JvdW5kU2hlbGxTcGF3bkVycm9ySAASKwoIcmVqZWN0ZWQYAyABKAsyFy5hZ2VudC52MS5TaGVsbFJlamVjdGVkSAASPAoRcGVybWlzc2lvbl9kZW5pZWQYBCABKAsyHy5hZ2VudC52MS5TaGVsbFBlcm1pc3Npb25EZW5pZWRIAEIICgZyZXN1bHQidQobQmFja2dyb3VuZFNoZWxsU3Bhd25TdWNjZXNzEhAKCHNoZWxsX2lkGAEgASgNEg8KB2NvbW1hbmQYAiABKAkSGQoRd29ya2luZ19kaXJlY3RvcnkYAyABKAkSEAoDcGlkGAQgASgNSACIAQFCBgoEX3BpZCJWChlCYWNrZ3JvdW5kU2hlbGxTcGF3bkVycm9yEg8KB2NvbW1hbmQYASABKAkSGQoRd29ya2luZ19kaXJlY3RvcnkYAiABKAkSDQoFZXJyb3IYAyABKAkiNgoTV3JpdGVTaGVsbFN0ZGluQXJncxIQCghzaGVsbF9pZBgBIAEoDRINCgVjaGFycxgCIAEoCSKHAQoVV3JpdGVTaGVsbFN0ZGluUmVzdWx0EjMKB3N1Y2Nlc3MYASABKAsyIC5hZ2VudC52MS5Xcml0ZVNoZWxsU3RkaW5TdWNjZXNzSAASLwoFZXJyb3IYAiABKAsyHi5hZ2VudC52MS5Xcml0ZVNoZWxsU3RkaW5FcnJvckgAQggKBnJlc3VsdCJdChZXcml0ZVNoZWxsU3RkaW5TdWNjZXNzEhAKCHNoZWxsX2lkGAEgASgNEjEKKXRlcm1pbmFsX2ZpbGVfbGVuZ3RoX2JlZm9yZV9pbnB1dF93cml0dGVuGAIgASgNIiUKFFdyaXRlU2hlbGxTdGRpbkVycm9yEg0KBWVycm9yGAEgASgJIiIKCkNvb3JkaW5hdGUSCQoBeBgBIAEoBRIJCgF5GAIgASgFIlUKD0NvbXB1dGVyVXNlQXJncxIUCgx0b29sX2NhbGxfaWQYASABKAkSLAoHYWN0aW9ucxgCIAMoCzIbLmFnZW50LnYxLkNvbXB1dGVyVXNlQWN0aW9uIoEEChFDb21wdXRlclVzZUFjdGlvbhIvCgptb3VzZV9tb3ZlGAEgASgLMhkuYWdlbnQudjEuTW91c2VNb3ZlQWN0aW9uSAASJgoFY2xpY2sYAiABKAsyFS5hZ2VudC52MS5DbGlja0FjdGlvbkgAEi8KCm1vdXNlX2Rvd24YAyABKAsyGS5hZ2VudC52MS5Nb3VzZURvd25BY3Rpb25IABIrCghtb3VzZV91cBgEIAEoCzIXLmFnZW50LnYxLk1vdXNlVXBBY3Rpb25IABIkCgRkcmFnGAUgASgLMhQuYWdlbnQudjEuRHJhZ0FjdGlvbkgAEigKBnNjcm9sbBgGIAEoCzIWLmFnZW50LnYxLlNjcm9sbEFjdGlvbkgAEiQKBHR5cGUYByABKAsyFC5hZ2VudC52MS5UeXBlQWN0aW9uSAASIgoDa2V5GAggASgLMhMuYWdlbnQudjEuS2V5QWN0aW9uSAASJAoEd2FpdBgJIAEoCzIULmFnZW50LnYxLldhaXRBY3Rpb25IABIwCgpzY3JlZW5zaG90GAogASgLMhouYWdlbnQudjEuU2NyZWVuc2hvdEFjdGlvbkgAEjkKD2N1cnNvcl9wb3NpdGlvbhgLIAEoCzIeLmFnZW50LnYxLkN1cnNvclBvc2l0aW9uQWN0aW9uSABCCAoGYWN0aW9uIjsKD01vdXNlTW92ZUFjdGlvbhIoCgpjb29yZGluYXRlGAEgASgLMhQuYWdlbnQudjEuQ29vcmRpbmF0ZSKYAQoLQ2xpY2tBY3Rpb24SLQoKY29vcmRpbmF0ZRgBIAEoCzIULmFnZW50LnYxLkNvb3JkaW5hdGVIAIgBARIOCgZidXR0b24YAiABKAUSDQoFY291bnQYAyABKAUSGgoNbW9kaWZpZXJfa2V5cxgEIAEoCUgBiAEBQg0KC19jb29yZGluYXRlQhAKDl9tb2RpZmllcl9rZXlzIiEKD01vdXNlRG93bkFjdGlvbhIOCgZidXR0b24YASABKAUiHwoNTW91c2VVcEFjdGlvbhIOCgZidXR0b24YASABKAUiQAoKRHJhZ0FjdGlvbhIiCgRwYXRoGAEgAygLMhQuYWdlbnQudjEuQ29vcmRpbmF0ZRIOCgZidXR0b24YAiABKAUinQEKDFNjcm9sbEFjdGlvbhItCgpjb29yZGluYXRlGAEgASgLMhQuYWdlbnQudjEuQ29vcmRpbmF0ZUgAiAEBEhEKCWRpcmVjdGlvbhgCIAEoBRIOCgZhbW91bnQYAyABKAUSGgoNbW9kaWZpZXJfa2V5cxgEIAEoCUgBiAEBQg0KC19jb29yZGluYXRlQhAKDl9tb2RpZmllcl9rZXlzIhoKClR5cGVBY3Rpb24SDAoEdGV4dBgBIAEoCSJMCglLZXlBY3Rpb24SCwoDa2V5GAEgASgJEh0KEGhvbGRfZHVyYXRpb25fbXMYAiABKAVIAIgBAUITChFfaG9sZF9kdXJhdGlvbl9tcyIhCgpXYWl0QWN0aW9uEhMKC2R1cmF0aW9uX21zGAEgASgFIhIKEFNjcmVlbnNob3RBY3Rpb24iFgoUQ3Vyc29yUG9zaXRpb25BY3Rpb24iewoRQ29tcHV0ZXJVc2VSZXN1bHQSLwoHc3VjY2VzcxgBIAEoCzIcLmFnZW50LnYxLkNvbXB1dGVyVXNlU3VjY2Vzc0gAEisKBWVycm9yGAIgASgLMhouYWdlbnQudjEuQ29tcHV0ZXJVc2VFcnJvckgAQggKBnJlc3VsdCL7AQoSQ29tcHV0ZXJVc2VTdWNjZXNzEhQKDGFjdGlvbl9jb3VudBgBIAEoBRITCgtkdXJhdGlvbl9tcxgCIAEoBRIXCgpzY3JlZW5zaG90GAMgASgJSACIAQESEAoDbG9nGAQgASgJSAGIAQESHAoPc2NyZWVuc2hvdF9wYXRoGAUgASgJSAKIAQESMgoPY3Vyc29yX3Bvc2l0aW9uGAYgASgLMhQuYWdlbnQudjEuQ29vcmRpbmF0ZUgDiAEBQg0KC19zY3JlZW5zaG90QgYKBF9sb2dCEgoQX3NjcmVlbnNob3RfcGF0aEISChBfY3Vyc29yX3Bvc2l0aW9uIsABChBDb21wdXRlclVzZUVycm9yEg0KBWVycm9yGAEgASgJEhQKDGFjdGlvbl9jb3VudBgCIAEoBRITCgtkdXJhdGlvbl9tcxgDIAEoBRIQCgNsb2cYBCABKAlIAIgBARIXCgpzY3JlZW5zaG90GAUgASgJSAGIAQESHAoPc2NyZWVuc2hvdF9wYXRoGAYgASgJSAKIAQFCBgoEX2xvZ0INCgtfc2NyZWVuc2hvdEISChBfc2NyZWVuc2hvdF9wYXRoImsKE0NvbXB1dGVyVXNlVG9vbENhbGwSJwoEYXJncxgBIAEoCzIZLmFnZW50LnYxLkNvbXB1dGVyVXNlQXJncxIrCgZyZXN1bHQYAiABKAsyGy5hZ2VudC52MS5Db21wdXRlclVzZVJlc3VsdCJoChJDcmVhdGVQbGFuVG9vbENhbGwSJgoEYXJncxgBIAEoCzIYLmFnZW50LnYxLkNyZWF0ZVBsYW5BcmdzEioKBnJlc3VsdBgCIAEoCzIaLmFnZW50LnYxLkNyZWF0ZVBsYW5SZXN1bHQiOAoFUGhhc2USDAoEbmFtZRgBIAEoCRIhCgV0b2RvcxgCIAMoCzISLmFnZW50LnYxLlRvZG9JdGVtIpYBCg5DcmVhdGVQbGFuQXJncxIMCgRwbGFuGAEgASgJEiEKBXRvZG9zGAIgAygLMhIuYWdlbnQudjEuVG9kb0l0ZW0SEAoIb3ZlcnZpZXcYAyABKAkSDAoEbmFtZRgEIAEoCRISCgppc19wcm9qZWN0GAUgASgIEh8KBnBoYXNlcxgGIAMoCzIPLmFnZW50LnYxLlBoYXNlIooBChBDcmVhdGVQbGFuUmVzdWx0EhAKCHBsYW5fdXJpGAMgASgJEi4KB3N1Y2Nlc3MYASABKAsyGy5hZ2VudC52MS5DcmVhdGVQbGFuU3VjY2Vzc0gAEioKBWVycm9yGAIgASgLMhkuYWdlbnQudjEuQ3JlYXRlUGxhbkVycm9ySABCCAoGcmVzdWx0IhMKEUNyZWF0ZVBsYW5TdWNjZXNzIiAKD0NyZWF0ZVBsYW5FcnJvchINCgVlcnJvchgBIAEoCSJWChZDcmVhdGVQbGFuUmVxdWVzdFF1ZXJ5EiYKBGFyZ3MYASABKAsyGC5hZ2VudC52MS5DcmVhdGVQbGFuQXJncxIUCgx0b29sX2NhbGxfaWQYAiABKAkiRwoZQ3JlYXRlUGxhblJlcXVlc3RSZXNwb25zZRIqCgZyZXN1bHQYASABKAsyGi5hZ2VudC52MS5DcmVhdGVQbGFuUmVzdWx0IhYKFEN1cnNvclJ1bGVUeXBlR2xvYmFsIigKF0N1cnNvclJ1bGVUeXBlRmlsZUdsb2JzEg0KBWdsb2JzGAEgAygJIjEKGkN1cnNvclJ1bGVUeXBlQWdlbnRGZXRjaGVkEhMKC2Rlc2NyaXB0aW9uGAEgASgJIiAKHkN1cnNvclJ1bGVUeXBlTWFudWFsbHlBdHRhY2hlZCKLAgoOQ3Vyc29yUnVsZVR5cGUSMAoGZ2xvYmFsGAEgASgLMh4uYWdlbnQudjEuQ3Vyc29yUnVsZVR5cGVHbG9iYWxIABI5CgxmaWxlX2dsb2JiZWQYAiABKAsyIS5hZ2VudC52MS5DdXJzb3JSdWxlVHlwZUZpbGVHbG9ic0gAEj0KDWFnZW50X2ZldGNoZWQYAyABKAsyJC5hZ2VudC52MS5DdXJzb3JSdWxlVHlwZUFnZW50RmV0Y2hlZEgAEkUKEW1hbnVhbGx5X2F0dGFjaGVkGAQgASgLMiguYWdlbnQudjEuQ3Vyc29yUnVsZVR5cGVNYW51YWxseUF0dGFjaGVkSABCBgoEdHlwZSLIAQoKQ3Vyc29yUnVsZRIRCglmdWxsX3BhdGgYASABKAkSDwoHY29udGVudBgCIAEoCRImCgR0eXBlGAMgASgLMhguYWdlbnQudjEuQ3Vyc29yUnVsZVR5cGUSDgoGc291cmNlGAQgASgFEh4KEWdpdF9yZW1vdGVfb3JpZ2luGAUgASgJSACIAQESGAoLcGFyc2VfZXJyb3IYBiABKAlIAYgBAUIUChJfZ2l0X3JlbW90ZV9vcmlnaW5CDgoMX3BhcnNlX2Vycm9yIjAKCkRlbGV0ZUFyZ3MSDAoEcGF0aBgBIAEoCRIUCgx0b29sX2NhbGxfaWQYAiABKAki7QIKDERlbGV0ZVJlc3VsdBIqCgdzdWNjZXNzGAEgASgLMhcuYWdlbnQudjEuRGVsZXRlU3VjY2Vzc0gAEjYKDmZpbGVfbm90X2ZvdW5kGAIgASgLMhwuYWdlbnQudjEuRGVsZXRlRmlsZU5vdEZvdW5kSAASKwoIbm90X2ZpbGUYAyABKAsyFy5hZ2VudC52MS5EZWxldGVOb3RGaWxlSAASPQoRcGVybWlzc2lvbl9kZW5pZWQYBCABKAsyIC5hZ2VudC52MS5EZWxldGVQZXJtaXNzaW9uRGVuaWVkSAASLQoJZmlsZV9idXN5GAUgASgLMhguYWdlbnQudjEuRGVsZXRlRmlsZUJ1c3lIABIsCghyZWplY3RlZBgGIAEoCzIYLmFnZW50LnYxLkRlbGV0ZVJlamVjdGVkSAASJgoFZXJyb3IYByABKAsyFS5hZ2VudC52MS5EZWxldGVFcnJvckgAQggKBnJlc3VsdCJcCg1EZWxldGVTdWNjZXNzEgwKBHBhdGgYASABKAkSFAoMZGVsZXRlZF9maWxlGAIgASgJEhEKCWZpbGVfc2l6ZRgDIAEoAxIUCgxwcmV2X2NvbnRlbnQYBCABKAkiIgoSRGVsZXRlRmlsZU5vdEZvdW5kEgwKBHBhdGgYASABKAkiMgoNRGVsZXRlTm90RmlsZRIMCgRwYXRoGAEgASgJEhMKC2FjdHVhbF90eXBlGAIgASgJIlkKFkRlbGV0ZVBlcm1pc3Npb25EZW5pZWQSDAoEcGF0aBgBIAEoCRIcChRjbGllbnRfdmlzaWJsZV9lcnJvchgCIAEoCRITCgtpc19yZWFkb25seRgDIAEoCCIeCg5EZWxldGVGaWxlQnVzeRIMCgRwYXRoGAEgASgJIi4KDkRlbGV0ZVJlamVjdGVkEgwKBHBhdGgYASABKAkSDgoGcmVhc29uGAIgASgJIioKC0RlbGV0ZUVycm9yEgwKBHBhdGgYASABKAkSDQoFZXJyb3IYAiABKAkiXAoORGVsZXRlVG9vbENhbGwSIgoEYXJncxgBIAEoCzIULmFnZW50LnYxLkRlbGV0ZUFyZ3MSJgoGcmVzdWx0GAIgASgLMhYuYWdlbnQudjEuRGVsZXRlUmVzdWx0IjUKD0RpYWdub3N0aWNzQXJncxIMCgRwYXRoGAEgASgJEhQKDHRvb2xfY2FsbF9pZBgCIAEoCSKvAgoRRGlhZ25vc3RpY3NSZXN1bHQSLwoHc3VjY2VzcxgBIAEoCzIcLmFnZW50LnYxLkRpYWdub3N0aWNzU3VjY2Vzc0gAEisKBWVycm9yGAIgASgLMhouYWdlbnQudjEuRGlhZ25vc3RpY3NFcnJvckgAEjEKCHJlamVjdGVkGAMgASgLMh0uYWdlbnQudjEuRGlhZ25vc3RpY3NSZWplY3RlZEgAEjsKDmZpbGVfbm90X2ZvdW5kGAQgASgLMiEuYWdlbnQudjEuRGlhZ25vc3RpY3NGaWxlTm90Rm91bmRIABJCChFwZXJtaXNzaW9uX2RlbmllZBgFIAEoCzIlLmFnZW50LnYxLkRpYWdub3N0aWNzUGVybWlzc2lvbkRlbmllZEgAQggKBnJlc3VsdCJoChJEaWFnbm9zdGljc1N1Y2Nlc3MSDAoEcGF0aBgBIAEoCRIpCgtkaWFnbm9zdGljcxgCIAMoCzIULmFnZW50LnYxLkRpYWdub3N0aWMSGQoRdG90YWxfZGlhZ25vc3RpY3MYAyABKAUifwoKRGlhZ25vc3RpYxIQCghzZXZlcml0eRgBIAEoBRIeCgVyYW5nZRgCIAEoCzIPLmFnZW50LnYxLlJhbmdlEg8KB21lc3NhZ2UYAyABKAkSDgoGc291cmNlGAQgASgJEgwKBGNvZGUYBSABKAkSEAoIaXNfc3RhbGUYBiABKAgiLwoQRGlhZ25vc3RpY3NFcnJvchIMCgRwYXRoGAEgASgJEg0KBWVycm9yGAIgASgJIjMKE0RpYWdub3N0aWNzUmVqZWN0ZWQSDAoEcGF0aBgBIAEoCRIOCgZyZWFzb24YAiABKAkiJwoXRGlhZ25vc3RpY3NGaWxlTm90Rm91bmQSDAoEcGF0aBgBIAEoCSIrChtEaWFnbm9zdGljc1Blcm1pc3Npb25EZW5pZWQSDAoEcGF0aBgBIAEoCSJICghFZGl0QXJncxIMCgRwYXRoGAEgASgJEhsKDnN0cmVhbV9jb250ZW50GAYgASgJSACIAQFCEQoPX3N0cmVhbV9jb250ZW50ItYCCgpFZGl0UmVzdWx0EigKB3N1Y2Nlc3MYASABKAsyFS5hZ2VudC52MS5FZGl0U3VjY2Vzc0gAEjQKDmZpbGVfbm90X2ZvdW5kGAIgASgLMhouYWdlbnQudjEuRWRpdEZpbGVOb3RGb3VuZEgAEkQKFnJlYWRfcGVybWlzc2lvbl9kZW5pZWQYAyABKAsyIi5hZ2VudC52MS5FZGl0UmVhZFBlcm1pc3Npb25EZW5pZWRIABJGChd3cml0ZV9wZXJtaXNzaW9uX2RlbmllZBgEIAEoCzIjLmFnZW50LnYxLkVkaXRXcml0ZVBlcm1pc3Npb25EZW5pZWRIABIqCghyZWplY3RlZBgGIAEoCzIWLmFnZW50LnYxLkVkaXRSZWplY3RlZEgAEiQKBWVycm9yGAcgASgLMhMuYWdlbnQudjEuRWRpdEVycm9ySABCCAoGcmVzdWx0IqQCCgtFZGl0U3VjY2VzcxIMCgRwYXRoGAEgASgJEhgKC2xpbmVzX2FkZGVkGAMgASgFSACIAQESGgoNbGluZXNfcmVtb3ZlZBgEIAEoBUgBiAEBEhgKC2RpZmZfc3RyaW5nGAUgASgJSAKIAQESJQoYYmVmb3JlX2Z1bGxfZmlsZV9jb250ZW50GAYgASgJSAOIAQESHwoXYWZ0ZXJfZnVsbF9maWxlX2NvbnRlbnQYByABKAkSFAoHbWVzc2FnZRgIIAEoCUgEiAEBQg4KDF9saW5lc19hZGRlZEIQCg5fbGluZXNfcmVtb3ZlZEIOCgxfZGlmZl9zdHJpbmdCGwoZX2JlZm9yZV9mdWxsX2ZpbGVfY29udGVudEIKCghfbWVzc2FnZSIgChBFZGl0RmlsZU5vdEZvdW5kEgwKBHBhdGgYASABKAkiKAoYRWRpdFJlYWRQZXJtaXNzaW9uRGVuaWVkEgwKBHBhdGgYASABKAkiTQoZRWRpdFdyaXRlUGVybWlzc2lvbkRlbmllZBIMCgRwYXRoGAEgASgJEg0KBWVycm9yGAIgASgJEhMKC2lzX3JlYWRvbmx5GAMgASgIIiwKDEVkaXRSZWplY3RlZBIMCgRwYXRoGAEgASgJEg4KBnJlYXNvbhgCIAEoCSJiCglFZGl0RXJyb3ISDAoEcGF0aBgBIAEoCRINCgVlcnJvchgCIAEoCRIgChNtb2RlbF92aXNpYmxlX2Vycm9yGAUgASgJSACIAQFCFgoUX21vZGVsX3Zpc2libGVfZXJyb3IiVgoMRWRpdFRvb2xDYWxsEiAKBGFyZ3MYASABKAsyEi5hZ2VudC52MS5FZGl0QXJncxIkCgZyZXN1bHQYAiABKAsyFC5hZ2VudC52MS5FZGl0UmVzdWx0IjEKEUVkaXRUb29sQ2FsbERlbHRhEhwKFHN0cmVhbV9jb250ZW50X2RlbHRhGAEgASgJIjEKDEV4YUZldGNoQXJncxILCgNpZHMYASADKAkSFAoMdG9vbF9jYWxsX2lkGAIgASgJIqIBCg5FeGFGZXRjaFJlc3VsdBIsCgdzdWNjZXNzGAEgASgLMhkuYWdlbnQudjEuRXhhRmV0Y2hTdWNjZXNzSAASKAoFZXJyb3IYAiABKAsyFy5hZ2VudC52MS5FeGFGZXRjaEVycm9ySAASLgoIcmVqZWN0ZWQYAyABKAsyGi5hZ2VudC52MS5FeGFGZXRjaFJlamVjdGVkSABCCAoGcmVzdWx0Ij4KD0V4YUZldGNoU3VjY2VzcxIrCghjb250ZW50cxgBIAMoCzIZLmFnZW50LnYxLkV4YUZldGNoQ29udGVudCIeCg1FeGFGZXRjaEVycm9yEg0KBWVycm9yGAEgASgJIiIKEEV4YUZldGNoUmVqZWN0ZWQSDgoGcmVhc29uGAEgASgJIlMKD0V4YUZldGNoQ29udGVudBINCgV0aXRsZRgBIAEoCRILCgN1cmwYAiABKAkSDAoEdGV4dBgDIAEoCRIWCg5wdWJsaXNoZWRfZGF0ZRgEIAEoCSJiChBFeGFGZXRjaFRvb2xDYWxsEiQKBGFyZ3MYASABKAsyFi5hZ2VudC52MS5FeGFGZXRjaEFyZ3MSKAoGcmVzdWx0GAIgASgLMhguYWdlbnQudjEuRXhhRmV0Y2hSZXN1bHQiPAoURXhhRmV0Y2hSZXF1ZXN0UXVlcnkSJAoEYXJncxgBIAEoCzIWLmFnZW50LnYxLkV4YUZldGNoQXJncyKjAQoXRXhhRmV0Y2hSZXF1ZXN0UmVzcG9uc2USPgoIYXBwcm92ZWQYASABKAsyKi5hZ2VudC52MS5FeGFGZXRjaFJlcXVlc3RSZXNwb25zZV9BcHByb3ZlZEgAEj4KCHJlamVjdGVkGAIgASgLMiouYWdlbnQudjEuRXhhRmV0Y2hSZXF1ZXN0UmVzcG9uc2VfUmVqZWN0ZWRIAEIICgZyZXN1bHQiIgogRXhhRmV0Y2hSZXF1ZXN0UmVzcG9uc2VfQXBwcm92ZWQiMgogRXhhRmV0Y2hSZXF1ZXN0UmVzcG9uc2VfUmVqZWN0ZWQSDgoGcmVhc29uGAEgASgJIlcKDUV4YVNlYXJjaEFyZ3MSDQoFcXVlcnkYASABKAkSDAoEdHlwZRgCIAEoCRITCgtudW1fcmVzdWx0cxgDIAEoBRIUCgx0b29sX2NhbGxfaWQYBCABKAkipgEKD0V4YVNlYXJjaFJlc3VsdBItCgdzdWNjZXNzGAEgASgLMhouYWdlbnQudjEuRXhhU2VhcmNoU3VjY2Vzc0gAEikKBWVycm9yGAIgASgLMhguYWdlbnQudjEuRXhhU2VhcmNoRXJyb3JIABIvCghyZWplY3RlZBgDIAEoCzIbLmFnZW50LnYxLkV4YVNlYXJjaFJlamVjdGVkSABCCAoGcmVzdWx0IkQKEEV4YVNlYXJjaFN1Y2Nlc3MSMAoKcmVmZXJlbmNlcxgBIAMoCzIcLmFnZW50LnYxLkV4YVNlYXJjaFJlZmVyZW5jZSIfCg5FeGFTZWFyY2hFcnJvchINCgVlcnJvchgBIAEoCSIjChFFeGFTZWFyY2hSZWplY3RlZBIOCgZyZWFzb24YASABKAkiVgoSRXhhU2VhcmNoUmVmZXJlbmNlEg0KBXRpdGxlGAEgASgJEgsKA3VybBgCIAEoCRIMCgR0ZXh0GAMgASgJEhYKDnB1Ymxpc2hlZF9kYXRlGAQgASgJImUKEUV4YVNlYXJjaFRvb2xDYWxsEiUKBGFyZ3MYASABKAsyFy5hZ2VudC52MS5FeGFTZWFyY2hBcmdzEikKBnJlc3VsdBgCIAEoCzIZLmFnZW50LnYxLkV4YVNlYXJjaFJlc3VsdCI+ChVFeGFTZWFyY2hSZXF1ZXN0UXVlcnkSJQoEYXJncxgBIAEoCzIXLmFnZW50LnYxLkV4YVNlYXJjaEFyZ3MipgEKGEV4YVNlYXJjaFJlcXVlc3RSZXNwb25zZRI/CghhcHByb3ZlZBgBIAEoCzIrLmFnZW50LnYxLkV4YVNlYXJjaFJlcXVlc3RSZXNwb25zZV9BcHByb3ZlZEgAEj8KCHJlamVjdGVkGAIgASgLMisuYWdlbnQudjEuRXhhU2VhcmNoUmVxdWVzdFJlc3BvbnNlX1JlamVjdGVkSABCCAoGcmVzdWx0IiMKIUV4YVNlYXJjaFJlcXVlc3RSZXNwb25zZV9BcHByb3ZlZCIzCiFFeGFTZWFyY2hSZXF1ZXN0UmVzcG9uc2VfUmVqZWN0ZWQSDgoGcmVhc29uGAEgASgJIiMKFUV4ZWNDbGllbnRTdHJlYW1DbG9zZRIKCgJpZBgBIAEoDSJWCg9FeGVjQ2xpZW50VGhyb3cSCgoCaWQYASABKA0SDQoFZXJyb3IYAiABKAkSGAoLc3RhY2tfdHJhY2UYAyABKAlIAIgBAUIOCgxfc3RhY2tfdHJhY2UiIQoTRXhlY0NsaWVudEhlYXJ0YmVhdBIKCgJpZBgBIAEoDSK+AQoYRXhlY0NsaWVudENvbnRyb2xNZXNzYWdlEjcKDHN0cmVhbV9jbG9zZRgBIAEoCzIfLmFnZW50LnYxLkV4ZWNDbGllbnRTdHJlYW1DbG9zZUgAEioKBXRocm93GAIgASgLMhkuYWdlbnQudjEuRXhlY0NsaWVudFRocm93SAASMgoJaGVhcnRiZWF0GAMgASgLMh0uYWdlbnQudjEuRXhlY0NsaWVudEhlYXJ0YmVhdEgAQgkKB21lc3NhZ2UihAEKC1NwYW5Db250ZXh0EhAKCHRyYWNlX2lkGAEgASgJEg8KB3NwYW5faWQYAiABKAkSGAoLdHJhY2VfZmxhZ3MYAyABKA1IAIgBARIYCgt0cmFjZV9zdGF0ZRgEIAEoCUgBiAEBQg4KDF90cmFjZV9mbGFnc0IOCgxfdHJhY2Vfc3RhdGUiCwoJQWJvcnRBcmdzIg0KC0Fib3J0UmVzdWx0IoUIChFFeGVjU2VydmVyTWVzc2FnZRIKCgJpZBgBIAEoDRIPCgdleGVjX2lkGA8gASgJEjAKDHNwYW5fY29udGV4dBgTIAEoCzIVLmFnZW50LnYxLlNwYW5Db250ZXh0SAGIAQESKQoKc2hlbGxfYXJncxgCIAEoCzITLmFnZW50LnYxLlNoZWxsQXJnc0gAEikKCndyaXRlX2FyZ3MYAyABKAsyEy5hZ2VudC52MS5Xcml0ZUFyZ3NIABIrCgtkZWxldGVfYXJncxgEIAEoCzIULmFnZW50LnYxLkRlbGV0ZUFyZ3NIABInCglncmVwX2FyZ3MYBSABKAsyEi5hZ2VudC52MS5HcmVwQXJnc0gAEicKCXJlYWRfYXJncxgHIAEoCzISLmFnZW50LnYxLlJlYWRBcmdzSAASIwoHbHNfYXJncxgIIAEoCzIQLmFnZW50LnYxLkxzQXJnc0gAEjUKEGRpYWdub3N0aWNzX2FyZ3MYCSABKAsyGS5hZ2VudC52MS5EaWFnbm9zdGljc0FyZ3NIABI8ChRyZXF1ZXN0X2NvbnRleHRfYXJncxgKIAEoCzIcLmFnZW50LnYxLlJlcXVlc3RDb250ZXh0QXJnc0gAEiUKCG1jcF9hcmdzGAsgASgLMhEuYWdlbnQudjEuTWNwQXJnc0gAEjAKEXNoZWxsX3N0cmVhbV9hcmdzGA4gASgLMhMuYWdlbnQudjEuU2hlbGxBcmdzSAASSQobYmFja2dyb3VuZF9zaGVsbF9zcGF3bl9hcmdzGBAgASgLMiIuYWdlbnQudjEuQmFja2dyb3VuZFNoZWxsU3Bhd25BcmdzSAASSgocbGlzdF9tY3BfcmVzb3VyY2VzX2V4ZWNfYXJncxgRIAEoCzIiLmFnZW50LnYxLkxpc3RNY3BSZXNvdXJjZXNFeGVjQXJnc0gAEkgKG3JlYWRfbWNwX3Jlc291cmNlX2V4ZWNfYXJncxgSIAEoCzIhLmFnZW50LnYxLlJlYWRNY3BSZXNvdXJjZUV4ZWNBcmdzSAASKQoKZmV0Y2hfYXJncxgUIAEoCzITLmFnZW50LnYxLkZldGNoQXJnc0gAEjgKEnJlY29yZF9zY3JlZW5fYXJncxgVIAEoCzIaLmFnZW50LnYxLlJlY29yZFNjcmVlbkFyZ3NIABI2ChFjb21wdXRlcl91c2VfYXJncxgWIAEoCzIZLmFnZW50LnYxLkNvbXB1dGVyVXNlQXJnc0gAEj8KFndyaXRlX3NoZWxsX3N0ZGluX2FyZ3MYFyABKAsyHS5hZ2VudC52MS5Xcml0ZVNoZWxsU3RkaW5BcmdzSABCCQoHbWVzc2FnZUIPCg1fc3Bhbl9jb250ZXh0Iv8HChFFeGVjQ2xpZW50TWVzc2FnZRIKCgJpZBgBIAEoDRIPCgdleGVjX2lkGA8gASgJEi0KDHNoZWxsX3Jlc3VsdBgCIAEoCzIVLmFnZW50LnYxLlNoZWxsUmVzdWx0SAASLQoMd3JpdGVfcmVzdWx0GAMgASgLMhUuYWdlbnQudjEuV3JpdGVSZXN1bHRIABIvCg1kZWxldGVfcmVzdWx0GAQgASgLMhYuYWdlbnQudjEuRGVsZXRlUmVzdWx0SAASKwoLZ3JlcF9yZXN1bHQYBSABKAsyFC5hZ2VudC52MS5HcmVwUmVzdWx0SAASKwoLcmVhZF9yZXN1bHQYByABKAsyFC5hZ2VudC52MS5SZWFkUmVzdWx0SAASJwoJbHNfcmVzdWx0GAggASgLMhIuYWdlbnQudjEuTHNSZXN1bHRIABI5ChJkaWFnbm9zdGljc19yZXN1bHQYCSABKAsyGy5hZ2VudC52MS5EaWFnbm9zdGljc1Jlc3VsdEgAEkAKFnJlcXVlc3RfY29udGV4dF9yZXN1bHQYCiABKAsyHi5hZ2VudC52MS5SZXF1ZXN0Q29udGV4dFJlc3VsdEgAEikKCm1jcF9yZXN1bHQYCyABKAsyEy5hZ2VudC52MS5NY3BSZXN1bHRIABItCgxzaGVsbF9zdHJlYW0YDiABKAsyFS5hZ2VudC52MS5TaGVsbFN0cmVhbUgAEk0KHWJhY2tncm91bmRfc2hlbGxfc3Bhd25fcmVzdWx0GBAgASgLMiQuYWdlbnQudjEuQmFja2dyb3VuZFNoZWxsU3Bhd25SZXN1bHRIABJOCh5saXN0X21jcF9yZXNvdXJjZXNfZXhlY19yZXN1bHQYESABKAsyJC5hZ2VudC52MS5MaXN0TWNwUmVzb3VyY2VzRXhlY1Jlc3VsdEgAEkwKHXJlYWRfbWNwX3Jlc291cmNlX2V4ZWNfcmVzdWx0GBIgASgLMiMuYWdlbnQudjEuUmVhZE1jcFJlc291cmNlRXhlY1Jlc3VsdEgAEi0KDGZldGNoX3Jlc3VsdBgUIAEoCzIVLmFnZW50LnYxLkZldGNoUmVzdWx0SAASPAoUcmVjb3JkX3NjcmVlbl9yZXN1bHQYFSABKAsyHC5hZ2VudC52MS5SZWNvcmRTY3JlZW5SZXN1bHRIABI6ChNjb21wdXRlcl91c2VfcmVzdWx0GBYgASgLMhsuYWdlbnQudjEuQ29tcHV0ZXJVc2VSZXN1bHRIABJDChh3cml0ZV9zaGVsbF9zdGRpbl9yZXN1bHQYFyABKAsyHy5hZ2VudC52MS5Xcml0ZVNoZWxsU3RkaW5SZXN1bHRIAEIJCgdtZXNzYWdlIi4KCUZldGNoQXJncxILCgN1cmwYASABKAkSFAoMdG9vbF9jYWxsX2lkGAIgASgJImkKC0ZldGNoUmVzdWx0EikKB3N1Y2Nlc3MYASABKAsyFi5hZ2VudC52MS5GZXRjaFN1Y2Nlc3NIABIlCgVlcnJvchgCIAEoCzIULmFnZW50LnYxLkZldGNoRXJyb3JIAEIICgZyZXN1bHQiVwoMRmV0Y2hTdWNjZXNzEgsKA3VybBgBIAEoCRIPCgdjb250ZW50GAIgASgJEhMKC3N0YXR1c19jb2RlGAMgASgFEhQKDGNvbnRlbnRfdHlwZRgEIAEoCSIoCgpGZXRjaEVycm9yEgsKA3VybBgBIAEoCRINCgVlcnJvchgCIAEoCSJtChFHZW5lcmF0ZUltYWdlQXJncxITCgtkZXNjcmlwdGlvbhgBIAEoCRIWCglmaWxlX3BhdGgYAiABKAlIAIgBARIdChVyZWZlcmVuY2VfaW1hZ2VfcGF0aHMYBSADKAlCDAoKX2ZpbGVfcGF0aCKBAQoTR2VuZXJhdGVJbWFnZVJlc3VsdBIxCgdzdWNjZXNzGAEgASgLMh4uYWdlbnQudjEuR2VuZXJhdGVJbWFnZVN1Y2Nlc3NIABItCgVlcnJvchgCIAEoCzIcLmFnZW50LnYxLkdlbmVyYXRlSW1hZ2VFcnJvckgAQggKBnJlc3VsdCI9ChRHZW5lcmF0ZUltYWdlU3VjY2VzcxIRCglmaWxlX3BhdGgYASABKAkSEgoKaW1hZ2VfZGF0YRgCIAEoCSIjChJHZW5lcmF0ZUltYWdlRXJyb3ISDQoFZXJyb3IYASABKAkicQoVR2VuZXJhdGVJbWFnZVRvb2xDYWxsEikKBGFyZ3MYASABKAsyGy5hZ2VudC52MS5HZW5lcmF0ZUltYWdlQXJncxItCgZyZXN1bHQYAiABKAsyHS5hZ2VudC52MS5HZW5lcmF0ZUltYWdlUmVzdWx0IsYECghHcmVwQXJncxIPCgdwYXR0ZXJuGAEgASgJEhEKBHBhdGgYAiABKAlIAIgBARIRCgRnbG9iGAMgASgJSAGIAQESGAoLb3V0cHV0X21vZGUYBCABKAlIAogBARIbCg5jb250ZXh0X2JlZm9yZRgFIAEoBUgDiAEBEhoKDWNvbnRleHRfYWZ0ZXIYBiABKAVIBIgBARIUCgdjb250ZXh0GAcgASgFSAWIAQESHQoQY2FzZV9pbnNlbnNpdGl2ZRgIIAEoCEgGiAEBEhEKBHR5cGUYCSABKAlIB4gBARIXCgpoZWFkX2xpbWl0GAogASgFSAiIAQESFgoJbXVsdGlsaW5lGAsgASgISAmIAQESEQoEc29ydBgMIAEoCUgKiAEBEhsKDnNvcnRfYXNjZW5kaW5nGA0gASgISAuIAQESFAoMdG9vbF9jYWxsX2lkGA4gASgJEjQKDnNhbmRib3hfcG9saWN5GA8gASgLMhcuYWdlbnQudjEuU2FuZGJveFBvbGljeUgMiAEBQgcKBV9wYXRoQgcKBV9nbG9iQg4KDF9vdXRwdXRfbW9kZUIRCg9fY29udGV4dF9iZWZvcmVCEAoOX2NvbnRleHRfYWZ0ZXJCCgoIX2NvbnRleHRCEwoRX2Nhc2VfaW5zZW5zaXRpdmVCBwoFX3R5cGVCDQoLX2hlYWRfbGltaXRCDAoKX211bHRpbGluZUIHCgVfc29ydEIRCg9fc29ydF9hc2NlbmRpbmdCEQoPX3NhbmRib3hfcG9saWN5ImYKCkdyZXBSZXN1bHQSKAoHc3VjY2VzcxgBIAEoCzIVLmFnZW50LnYxLkdyZXBTdWNjZXNzSAASJAoFZXJyb3IYAiABKAsyEy5hZ2VudC52MS5HcmVwRXJyb3JIAEIICgZyZXN1bHQiGgoJR3JlcEVycm9yEg0KBWVycm9yGAEgASgJIrQCCgtHcmVwU3VjY2VzcxIPCgdwYXR0ZXJuGAEgASgJEgwKBHBhdGgYAiABKAkSEwoLb3V0cHV0X21vZGUYAyABKAkSRgoRd29ya3NwYWNlX3Jlc3VsdHMYBCADKAsyKy5hZ2VudC52MS5HcmVwU3VjY2Vzcy5Xb3Jrc3BhY2VSZXN1bHRzRW50cnkSPAoUYWN0aXZlX2VkaXRvcl9yZXN1bHQYBSABKAsyGS5hZ2VudC52MS5HcmVwVW5pb25SZXN1bHRIAIgBARpSChVXb3Jrc3BhY2VSZXN1bHRzRW50cnkSCwoDa2V5GAEgASgJEigKBXZhbHVlGAIgASgLMhkuYWdlbnQudjEuR3JlcFVuaW9uUmVzdWx0OgI4AUIXChVfYWN0aXZlX2VkaXRvcl9yZXN1bHQiowEKD0dyZXBVbmlvblJlc3VsdBIqCgVjb3VudBgBIAEoCzIZLmFnZW50LnYxLkdyZXBDb3VudFJlc3VsdEgAEioKBWZpbGVzGAIgASgLMhkuYWdlbnQudjEuR3JlcEZpbGVzUmVzdWx0SAASLgoHY29udGVudBgDIAEoCzIbLmFnZW50LnYxLkdyZXBDb250ZW50UmVzdWx0SABCCAoGcmVzdWx0IpsBCg9HcmVwQ291bnRSZXN1bHQSJwoGY291bnRzGAEgAygLMhcuYWdlbnQudjEuR3JlcEZpbGVDb3VudBITCgt0b3RhbF9maWxlcxgCIAEoBRIVCg10b3RhbF9tYXRjaGVzGAMgASgFEhgKEGNsaWVudF90cnVuY2F0ZWQYBCABKAgSGQoRcmlwZ3JlcF90cnVuY2F0ZWQYBSABKAgiLAoNR3JlcEZpbGVDb3VudBIMCgRmaWxlGAEgASgJEg0KBWNvdW50GAIgASgFImoKD0dyZXBGaWxlc1Jlc3VsdBINCgVmaWxlcxgBIAMoCRITCgt0b3RhbF9maWxlcxgCIAEoBRIYChBjbGllbnRfdHJ1bmNhdGVkGAMgASgIEhkKEXJpcGdyZXBfdHJ1bmNhdGVkGAQgASgIIqQBChFHcmVwQ29udGVudFJlc3VsdBIoCgdtYXRjaGVzGAEgAygLMhcuYWdlbnQudjEuR3JlcEZpbGVNYXRjaBITCgt0b3RhbF9saW5lcxgCIAEoBRIbChN0b3RhbF9tYXRjaGVkX2xpbmVzGAMgASgFEhgKEGNsaWVudF90cnVuY2F0ZWQYBCABKAgSGQoRcmlwZ3JlcF90cnVuY2F0ZWQYBSABKAgiSgoNR3JlcEZpbGVNYXRjaBIMCgRmaWxlGAEgASgJEisKB21hdGNoZXMYAiADKAsyGi5hZ2VudC52MS5HcmVwQ29udGVudE1hdGNoImwKEEdyZXBDb250ZW50TWF0Y2gSEwoLbGluZV9udW1iZXIYASABKAUSDwoHY29udGVudBgCIAEoCRIZChFjb250ZW50X3RydW5jYXRlZBgDIAEoCBIXCg9pc19jb250ZXh0X2xpbmUYBCABKAgiHQoKR3JlcFN0cmVhbRIPCgdwYXR0ZXJuGAEgASgJIlYKDEdyZXBUb29sQ2FsbBIgCgRhcmdzGAEgASgLMhIuYWdlbnQudjEuR3JlcEFyZ3MSJAoGcmVzdWx0GAIgASgLMhQuYWdlbnQudjEuR3JlcFJlc3VsdCIeCgtHZXRCbG9iQXJncxIPCgdibG9iX2lkGAEgASgMIjUKDUdldEJsb2JSZXN1bHQSFgoJYmxvYl9kYXRhGAEgASgMSACIAQFCDAoKX2Jsb2JfZGF0YSIxCgtTZXRCbG9iQXJncxIPCgdibG9iX2lkGAEgASgMEhEKCWJsb2JfZGF0YRgCIAEoDCI+Cg1TZXRCbG9iUmVzdWx0EiMKBWVycm9yGAEgASgLMg8uYWdlbnQudjEuRXJyb3JIAIgBAUIICgZfZXJyb3IiywEKD0t2U2VydmVyTWVzc2FnZRIKCgJpZBgBIAEoDRIwCgxzcGFuX2NvbnRleHQYBCABKAsyFS5hZ2VudC52MS5TcGFuQ29udGV4dEgBiAEBEi4KDWdldF9ibG9iX2FyZ3MYAiABKAsyFS5hZ2VudC52MS5HZXRCbG9iQXJnc0gAEi4KDXNldF9ibG9iX2FyZ3MYAyABKAsyFS5hZ2VudC52MS5TZXRCbG9iQXJnc0gAQgkKB21lc3NhZ2VCDwoNX3NwYW5fY29udGV4dCKQAQoPS3ZDbGllbnRNZXNzYWdlEgoKAmlkGAEgASgNEjIKD2dldF9ibG9iX3Jlc3VsdBgCIAEoCzIXLmFnZW50LnYxLkdldEJsb2JSZXN1bHRIABIyCg9zZXRfYmxvYl9yZXN1bHQYAyABKAsyFy5hZ2VudC52MS5TZXRCbG9iUmVzdWx0SABCCQoHbWVzc2FnZSKtAQoGTHNBcmdzEgwKBHBhdGgYASABKAkSDgoGaWdub3JlGAIgAygJEhQKDHRvb2xfY2FsbF9pZBgDIAEoCRI0Cg5zYW5kYm94X3BvbGljeRgEIAEoCzIXLmFnZW50LnYxLlNhbmRib3hQb2xpY3lIAIgBARIXCgp0aW1lb3V0X21zGAUgASgNSAGIAQFCEQoPX3NhbmRib3hfcG9saWN5Qg0KC190aW1lb3V0X21zIrIBCghMc1Jlc3VsdBImCgdzdWNjZXNzGAEgASgLMhMuYWdlbnQudjEuTHNTdWNjZXNzSAASIgoFZXJyb3IYAiABKAsyES5hZ2VudC52MS5Mc0Vycm9ySAASKAoIcmVqZWN0ZWQYAyABKAsyFC5hZ2VudC52MS5Mc1JlamVjdGVkSAASJgoHdGltZW91dBgEIAEoCzITLmFnZW50LnYxLkxzVGltZW91dEgAQggKBnJlc3VsdCJHCglMc1N1Y2Nlc3MSOgoTZGlyZWN0b3J5X3RyZWVfcm9vdBgBIAEoCzIdLmFnZW50LnYxLkxzRGlyZWN0b3J5VHJlZU5vZGUi9gIKE0xzRGlyZWN0b3J5VHJlZU5vZGUSEAoIYWJzX3BhdGgYASABKAkSNAoNY2hpbGRyZW5fZGlycxgCIAMoCzIdLmFnZW50LnYxLkxzRGlyZWN0b3J5VHJlZU5vZGUSOgoOY2hpbGRyZW5fZmlsZXMYAyADKAsyIi5hZ2VudC52MS5Mc0RpcmVjdG9yeVRyZWVOb2RlX0ZpbGUSHwoXY2hpbGRyZW5fd2VyZV9wcm9jZXNzZWQYBCABKAgSZAodZnVsbF9zdWJ0cmVlX2V4dGVuc2lvbl9jb3VudHMYBSADKAsyPS5hZ2VudC52MS5Mc0RpcmVjdG9yeVRyZWVOb2RlLkZ1bGxTdWJ0cmVlRXh0ZW5zaW9uQ291bnRzRW50cnkSEQoJbnVtX2ZpbGVzGAYgASgFGkEKH0Z1bGxTdWJ0cmVlRXh0ZW5zaW9uQ291bnRzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgFOgI4ASJ6ChhMc0RpcmVjdG9yeVRyZWVOb2RlX0ZpbGUSDAoEbmFtZRgBIAEoCRI6ChF0ZXJtaW5hbF9tZXRhZGF0YRgCIAEoCzIaLmFnZW50LnYxLlRlcm1pbmFsTWV0YWRhdGFIAIgBAUIUChJfdGVybWluYWxfbWV0YWRhdGEiJgoHTHNFcnJvchIMCgRwYXRoGAEgASgJEg0KBWVycm9yGAIgASgJIioKCkxzUmVqZWN0ZWQSDAoEcGF0aBgBIAEoCRIOCgZyZWFzb24YAiABKAkiRwoJTHNUaW1lb3V0EjoKE2RpcmVjdG9yeV90cmVlX3Jvb3QYASABKAsyHS5hZ2VudC52MS5Mc0RpcmVjdG9yeVRyZWVOb2RlIvEBChBUZXJtaW5hbE1ldGFkYXRhEhAKA2N3ZBgBIAEoCUgAiAEBEjkKDWxhc3RfY29tbWFuZHMYAiADKAsyIi5hZ2VudC52MS5UZXJtaW5hbE1ldGFkYXRhX0NvbW1hbmQSHQoQbGFzdF9tb2RpZmllZF9tcxgDIAEoA0gBiAEBEkAKD2N1cnJlbnRfY29tbWFuZBgEIAEoCzIiLmFnZW50LnYxLlRlcm1pbmFsTWV0YWRhdGFfQ29tbWFuZEgCiAEBQgYKBF9jd2RCEwoRX2xhc3RfbW9kaWZpZWRfbXNCEgoQX2N1cnJlbnRfY29tbWFuZCKnAQoYVGVybWluYWxNZXRhZGF0YV9Db21tYW5kEg8KB2NvbW1hbmQYASABKAkSFgoJZXhpdF9jb2RlGAIgASgFSACIAQESGQoMdGltZXN0YW1wX21zGAMgASgDSAGIAQESGAoLZHVyYXRpb25fbXMYBCABKANIAogBAUIMCgpfZXhpdF9jb2RlQg8KDV90aW1lc3RhbXBfbXNCDgoMX2R1cmF0aW9uX21zIlAKCkxzVG9vbENhbGwSHgoEYXJncxgBIAEoCzIQLmFnZW50LnYxLkxzQXJncxIiCgZyZXN1bHQYAiABKAsyEi5hZ2VudC52MS5Mc1Jlc3VsdCK1AQoHTWNwQXJncxIMCgRuYW1lGAEgASgJEikKBGFyZ3MYAiADKAsyGy5hZ2VudC52MS5NY3BBcmdzLkFyZ3NFbnRyeRIUCgx0b29sX2NhbGxfaWQYAyABKAkSGwoTcHJvdmlkZXJfaWRlbnRpZmllchgEIAEoCRIRCgl0b29sX25hbWUYBSABKAkaKwoJQXJnc0VudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoDDoCOAEi/wEKCU1jcFJlc3VsdBInCgdzdWNjZXNzGAEgASgLMhQuYWdlbnQudjEuTWNwU3VjY2Vzc0gAEiMKBWVycm9yGAIgASgLMhIuYWdlbnQudjEuTWNwRXJyb3JIABIpCghyZWplY3RlZBgDIAEoCzIVLmFnZW50LnYxLk1jcFJlamVjdGVkSAASOgoRcGVybWlzc2lvbl9kZW5pZWQYBCABKAsyHS5hZ2VudC52MS5NY3BQZXJtaXNzaW9uRGVuaWVkSAASMwoOdG9vbF9ub3RfZm91bmQYBSABKAsyGS5hZ2VudC52MS5NY3BUb29sTm90Rm91bmRIAEIICgZyZXN1bHQiOAoPTWNwVG9vbE5vdEZvdW5kEgwKBG5hbWUYASABKAkSFwoPYXZhaWxhYmxlX3Rvb2xzGAIgAygJImoKDk1jcFRleHRDb250ZW50EgwKBHRleHQYASABKAkSNgoPb3V0cHV0X2xvY2F0aW9uGAIgASgLMhguYWdlbnQudjEuT3V0cHV0TG9jYXRpb25IAIgBAUISChBfb3V0cHV0X2xvY2F0aW9uIjIKD01jcEltYWdlQ29udGVudBIMCgRkYXRhGAEgASgMEhEKCW1pbWVfdHlwZRgCIAEoCSJ7ChhNY3BUb29sUmVzdWx0Q29udGVudEl0ZW0SKAoEdGV4dBgBIAEoCzIYLmFnZW50LnYxLk1jcFRleHRDb250ZW50SAASKgoFaW1hZ2UYAiABKAsyGS5hZ2VudC52MS5NY3BJbWFnZUNvbnRlbnRIAEIJCgdjb250ZW50IlMKCk1jcFN1Y2Nlc3MSMwoHY29udGVudBgBIAMoCzIiLmFnZW50LnYxLk1jcFRvb2xSZXN1bHRDb250ZW50SXRlbRIQCghpc19lcnJvchgCIAEoCCIZCghNY3BFcnJvchINCgVlcnJvchgBIAEoCSIyCgtNY3BSZWplY3RlZBIOCgZyZWFzb24YASABKAkSEwoLaXNfcmVhZG9ubHkYAiABKAgiOQoTTWNwUGVybWlzc2lvbkRlbmllZBINCgVlcnJvchgBIAEoCRITCgtpc19yZWFkb25seRgCIAEoCCI6ChhMaXN0TWNwUmVzb3VyY2VzRXhlY0FyZ3MSEwoGc2VydmVyGAEgASgJSACIAQFCCQoHX3NlcnZlciLGAQoaTGlzdE1jcFJlc291cmNlc0V4ZWNSZXN1bHQSNAoHc3VjY2VzcxgBIAEoCzIhLmFnZW50LnYxLkxpc3RNY3BSZXNvdXJjZXNTdWNjZXNzSAASMAoFZXJyb3IYAiABKAsyHy5hZ2VudC52MS5MaXN0TWNwUmVzb3VyY2VzRXJyb3JIABI2CghyZWplY3RlZBgDIAEoCzIiLmFnZW50LnYxLkxpc3RNY3BSZXNvdXJjZXNSZWplY3RlZEgAQggKBnJlc3VsdCK9AgomTGlzdE1jcFJlc291cmNlc0V4ZWNSZXN1bHRfTWNwUmVzb3VyY2USCwoDdXJpGAEgASgJEhEKBG5hbWUYAiABKAlIAIgBARIYCgtkZXNjcmlwdGlvbhgDIAEoCUgBiAEBEhYKCW1pbWVfdHlwZRgEIAEoCUgCiAEBEg4KBnNlcnZlchgFIAEoCRJWCgthbm5vdGF0aW9ucxgGIAMoCzJBLmFnZW50LnYxLkxpc3RNY3BSZXNvdXJjZXNFeGVjUmVzdWx0X01jcFJlc291cmNlLkFubm90YXRpb25zRW50cnkaMgoQQW5ub3RhdGlvbnNFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBQgcKBV9uYW1lQg4KDF9kZXNjcmlwdGlvbkIMCgpfbWltZV90eXBlIl4KF0xpc3RNY3BSZXNvdXJjZXNTdWNjZXNzEkMKCXJlc291cmNlcxgBIAMoCzIwLmFnZW50LnYxLkxpc3RNY3BSZXNvdXJjZXNFeGVjUmVzdWx0X01jcFJlc291cmNlIiYKFUxpc3RNY3BSZXNvdXJjZXNFcnJvchINCgVlcnJvchgBIAEoCSIqChhMaXN0TWNwUmVzb3VyY2VzUmVqZWN0ZWQSDgoGcmVhc29uGAEgASgJImQKF1JlYWRNY3BSZXNvdXJjZUV4ZWNBcmdzEg4KBnNlcnZlchgBIAEoCRILCgN1cmkYAiABKAkSGgoNZG93bmxvYWRfcGF0aBgDIAEoCUgAiAEBQhAKDl9kb3dubG9hZF9wYXRoIvoBChlSZWFkTWNwUmVzb3VyY2VFeGVjUmVzdWx0EjMKB3N1Y2Nlc3MYASABKAsyIC5hZ2VudC52MS5SZWFkTWNwUmVzb3VyY2VTdWNjZXNzSAASLwoFZXJyb3IYAiABKAsyHi5hZ2VudC52MS5SZWFkTWNwUmVzb3VyY2VFcnJvckgAEjUKCHJlamVjdGVkGAMgASgLMiEuYWdlbnQudjEuUmVhZE1jcFJlc291cmNlUmVqZWN0ZWRIABI2Cglub3RfZm91bmQYBCABKAsyIS5hZ2VudC52MS5SZWFkTWNwUmVzb3VyY2VOb3RGb3VuZEgAQggKBnJlc3VsdCLmAgoWUmVhZE1jcFJlc291cmNlU3VjY2VzcxILCgN1cmkYASABKAkSEQoEbmFtZRgCIAEoCUgBiAEBEhgKC2Rlc2NyaXB0aW9uGAMgASgJSAKIAQESFgoJbWltZV90eXBlGAQgASgJSAOIAQESRgoLYW5ub3RhdGlvbnMYByADKAsyMS5hZ2VudC52MS5SZWFkTWNwUmVzb3VyY2VTdWNjZXNzLkFubm90YXRpb25zRW50cnkSGgoNZG93bmxvYWRfcGF0aBgIIAEoCUgEiAEBEg4KBHRleHQYBSABKAlIABIOCgRibG9iGAYgASgMSAAaMgoQQW5ub3RhdGlvbnNFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBQgkKB2NvbnRlbnRCBwoFX25hbWVCDgoMX2Rlc2NyaXB0aW9uQgwKCl9taW1lX3R5cGVCEAoOX2Rvd25sb2FkX3BhdGgiMgoUUmVhZE1jcFJlc291cmNlRXJyb3ISCwoDdXJpGAEgASgJEg0KBWVycm9yGAIgASgJIjYKF1JlYWRNY3BSZXNvdXJjZVJlamVjdGVkEgsKA3VyaRgBIAEoCRIOCgZyZWFzb24YAiABKAkiJgoXUmVhZE1jcFJlc291cmNlTm90Rm91bmQSCwoDdXJpGAEgASgJInwKEU1jcFRvb2xEZWZpbml0aW9uEgwKBG5hbWUYASABKAkSGwoTcHJvdmlkZXJfaWRlbnRpZmllchgEIAEoCRIRCgl0b29sX25hbWUYBSABKAkSEwoLZGVzY3JpcHRpb24YAiABKAkSFAoMaW5wdXRfc2NoZW1hGAMgASgMIjoKCE1jcFRvb2xzEi4KCW1jcF90b29scxgBIAMoCzIbLmFnZW50LnYxLk1jcFRvb2xEZWZpbml0aW9uIjwKD01jcEluc3RydWN0aW9ucxITCgtzZXJ2ZXJfbmFtZRgBIAEoCRIUCgxpbnN0cnVjdGlvbnMYAiABKAki1wEKDU1jcERlc2NyaXB0b3ISEwoLc2VydmVyX25hbWUYASABKAkSGQoRc2VydmVyX2lkZW50aWZpZXIYAiABKAkSGAoLZm9sZGVyX3BhdGgYAyABKAlIAIgBARIkChdzZXJ2ZXJfdXNlX2luc3RydWN0aW9ucxgEIAEoCUgBiAEBEioKBXRvb2xzGAUgAygLMhsuYWdlbnQudjEuTWNwVG9vbERlc2NyaXB0b3JCDgoMX2ZvbGRlcl9wYXRoQhoKGF9zZXJ2ZXJfdXNlX2luc3RydWN0aW9ucyJYChFNY3BUb29sRGVzY3JpcHRvchIRCgl0b29sX25hbWUYASABKAkSHAoPZGVmaW5pdGlvbl9wYXRoGAIgASgJSACIAQFCEgoQX2RlZmluaXRpb25fcGF0aCJ4ChRNY3BGaWxlU3lzdGVtT3B0aW9ucxIPCgdlbmFibGVkGAEgASgIEh0KFXdvcmtzcGFjZV9wcm9qZWN0X2RpchgCIAEoCRIwCg9tY3BfZGVzY3JpcHRvcnMYAyADKAsyFy5hZ2VudC52MS5NY3BEZXNjcmlwdG9yIi4KCFJlYWRBcmdzEgwKBHBhdGgYASABKAkSFAoMdG9vbF9jYWxsX2lkGAIgASgJIrgCCgpSZWFkUmVzdWx0EigKB3N1Y2Nlc3MYASABKAsyFS5hZ2VudC52MS5SZWFkU3VjY2Vzc0gAEiQKBWVycm9yGAIgASgLMhMuYWdlbnQudjEuUmVhZEVycm9ySAASKgoIcmVqZWN0ZWQYAyABKAsyFi5hZ2VudC52MS5SZWFkUmVqZWN0ZWRIABI0Cg5maWxlX25vdF9mb3VuZBgEIAEoCzIaLmFnZW50LnYxLlJlYWRGaWxlTm90Rm91bmRIABI7ChFwZXJtaXNzaW9uX2RlbmllZBgFIAEoCzIeLmFnZW50LnYxLlJlYWRQZXJtaXNzaW9uRGVuaWVkSAASMQoMaW52YWxpZF9maWxlGAYgASgLMhkuYWdlbnQudjEuUmVhZEludmFsaWRGaWxlSABCCAoGcmVzdWx0IrMBCgtSZWFkU3VjY2VzcxIMCgRwYXRoGAEgASgJEhMKC3RvdGFsX2xpbmVzGAMgASgFEhEKCWZpbGVfc2l6ZRgEIAEoAxIRCgl0cnVuY2F0ZWQYBiABKAgSGwoOb3V0cHV0X2Jsb2JfaWQYByABKAxIAYgBARIRCgdjb250ZW50GAIgASgJSAASDgoEZGF0YRgFIAEoDEgAQggKBm91dHB1dEIRCg9fb3V0cHV0X2Jsb2JfaWQiKAoJUmVhZEVycm9yEgwKBHBhdGgYASABKAkSDQoFZXJyb3IYAiABKAkiLAoMUmVhZFJlamVjdGVkEgwKBHBhdGgYASABKAkSDgoGcmVhc29uGAIgASgJIiAKEFJlYWRGaWxlTm90Rm91bmQSDAoEcGF0aBgBIAEoCSIkChRSZWFkUGVybWlzc2lvbkRlbmllZBIMCgRwYXRoGAEgASgJIi8KD1JlYWRJbnZhbGlkRmlsZRIMCgRwYXRoGAEgASgJEg4KBnJlYXNvbhgCIAEoCSJeCgxSZWFkVG9vbENhbGwSJAoEYXJncxgBIAEoCzIWLmFnZW50LnYxLlJlYWRUb29sQXJncxIoCgZyZXN1bHQYAiABKAsyGC5hZ2VudC52MS5SZWFkVG9vbFJlc3VsdCJaCgxSZWFkVG9vbEFyZ3MSDAoEcGF0aBgBIAEoCRITCgZvZmZzZXQYAiABKAVIAIgBARISCgVsaW1pdBgDIAEoBUgBiAEBQgkKB19vZmZzZXRCCAoGX2xpbWl0InIKDlJlYWRUb29sUmVzdWx0EiwKB3N1Y2Nlc3MYASABKAsyGS5hZ2VudC52MS5SZWFkVG9vbFN1Y2Nlc3NIABIoCgVlcnJvchgCIAEoCzIXLmFnZW50LnYxLlJlYWRUb29sRXJyb3JIAEIICgZyZXN1bHQiMQoJUmVhZFJhbmdlEhIKCnN0YXJ0X2xpbmUYASABKA0SEAoIZW5kX2xpbmUYAiABKA0ijgIKD1JlYWRUb29sU3VjY2VzcxIQCghpc19lbXB0eRgCIAEoCBIWCg5leGNlZWRlZF9saW1pdBgDIAEoCBITCgt0b3RhbF9saW5lcxgEIAEoDRIRCglmaWxlX3NpemUYBSABKA0SDAoEcGF0aBgHIAEoCRIsCgpyZWFkX3JhbmdlGAggASgLMhMuYWdlbnQudjEuUmVhZFJhbmdlSAGIAQESEQoHY29udGVudBgBIAEoCUgAEg4KBGRhdGEYBiABKAxIABIWCgxkYXRhX2Jsb2JfaWQYCSABKAxIABIZCg9jb250ZW50X2Jsb2JfaWQYCiABKAxIAEIICgZvdXRwdXRCDQoLX3JlYWRfcmFuZ2UiJgoNUmVhZFRvb2xFcnJvchIVCg1lcnJvcl9tZXNzYWdlGAEgASgJImoKEFJlY29yZFNjcmVlbkFyZ3MSDAoEbW9kZRgBIAEoBRIUCgx0b29sX2NhbGxfaWQYAiABKAkSHQoQc2F2ZV9hc19maWxlbmFtZRgDIAEoCUgAiAEBQhMKEV9zYXZlX2FzX2ZpbGVuYW1lIokCChJSZWNvcmRTY3JlZW5SZXN1bHQSOwoNc3RhcnRfc3VjY2VzcxgBIAEoCzIiLmFnZW50LnYxLlJlY29yZFNjcmVlblN0YXJ0U3VjY2Vzc0gAEjkKDHNhdmVfc3VjY2VzcxgCIAEoCzIhLmFnZW50LnYxLlJlY29yZFNjcmVlblNhdmVTdWNjZXNzSAASPwoPZGlzY2FyZF9zdWNjZXNzGAMgASgLMiQuYWdlbnQudjEuUmVjb3JkU2NyZWVuRGlzY2FyZFN1Y2Nlc3NIABIwCgdmYWlsdXJlGAQgASgLMh0uYWdlbnQudjEuUmVjb3JkU2NyZWVuRmFpbHVyZUgAQggKBnJlc3VsdCJnChhSZWNvcmRTY3JlZW5TdGFydFN1Y2Nlc3MSJQodd2FzX3ByaW9yX3JlY29yZGluZ19jYW5jZWxsZWQYASABKAgSJAocd2FzX3NhdmVfYXNfZmlsZW5hbWVfaWdub3JlZBgCIAEoCCKgAQoXUmVjb3JkU2NyZWVuU2F2ZVN1Y2Nlc3MSDAoEcGF0aBgBIAEoCRIdChVyZWNvcmRpbmdfZHVyYXRpb25fbXMYAiABKAMSMAojcmVxdWVzdGVkX2ZpbGVfcGF0aF9yZWplY3RlZF9yZWFzb24YAyABKAVIAIgBAUImCiRfcmVxdWVzdGVkX2ZpbGVfcGF0aF9yZWplY3RlZF9yZWFzb24iHAoaUmVjb3JkU2NyZWVuRGlzY2FyZFN1Y2Nlc3MiJAoTUmVjb3JkU2NyZWVuRmFpbHVyZRINCgVlcnJvchgBIAEoCSI2ChNDdXJzb3JQYWNrYWdlUHJvbXB0EgwKBG5hbWUYASABKAkSEQoJZmlsZV9wYXRoGAIgASgJIuIBCg1DdXJzb3JQYWNrYWdlEgwKBG5hbWUYASABKAkSEwoLZGVzY3JpcHRpb24YAiABKAkSEwoLZm9sZGVyX3BhdGgYAyABKAkSDwoHZW5hYmxlZBgEIAEoCBIYCgtwYXJzZV9lcnJvchgFIAEoCUgAiAEBEi4KB3Byb21wdHMYBiADKAsyHS5hZ2VudC52MS5DdXJzb3JQYWNrYWdlUHJvbXB0EhgKEHJlYWRtZV9maWxlX3BhdGgYByABKAkSFAoMcGFja2FnZV90eXBlGAggASgFQg4KDF9wYXJzZV9lcnJvciKrAgoWUmVwb3NpdG9yeUluZGV4aW5nSW5mbxIfChdyZWxhdGl2ZV93b3Jrc3BhY2VfcGF0aBgBIAEoCRITCgtyZW1vdGVfdXJscxgCIAMoCRIUCgxyZW1vdGVfbmFtZXMYAyADKAkSEQoJcmVwb19uYW1lGAQgASgJEhIKCnJlcG9fb3duZXIYBSABKAkSEgoKaXNfdHJhY2tlZBgGIAEoCBIQCghpc19sb2NhbBgHIAEoCBImChlvcnRob2dvbmFsX3RyYW5zZm9ybV9zZWVkGAggASgBSACIAQESFQoNd29ya3NwYWNlX3VyaRgJIAEoCRIbChNwYXRoX2VuY3J5cHRpb25fa2V5GAogASgJQhwKGl9vcnRob2dvbmFsX3RyYW5zZm9ybV9zZWVkInQKElJlcXVlc3RDb250ZXh0QXJncxIdChBub3Rlc19zZXNzaW9uX2lkGAIgASgJSACIAQESGQoMd29ya3NwYWNlX2lkGAMgASgJSAGIAQFCEwoRX25vdGVzX3Nlc3Npb25faWRCDwoNX3dvcmtzcGFjZV9pZCK6AQoUUmVxdWVzdENvbnRleHRSZXN1bHQSMgoHc3VjY2VzcxgBIAEoCzIfLmFnZW50LnYxLlJlcXVlc3RDb250ZXh0U3VjY2Vzc0gAEi4KBWVycm9yGAIgASgLMh0uYWdlbnQudjEuUmVxdWVzdENvbnRleHRFcnJvckgAEjQKCHJlamVjdGVkGAMgASgLMiAuYWdlbnQudjEuUmVxdWVzdENvbnRleHRSZWplY3RlZEgAQggKBnJlc3VsdCJKChVSZXF1ZXN0Q29udGV4dFN1Y2Nlc3MSMQoPcmVxdWVzdF9jb250ZXh0GAEgASgLMhguYWdlbnQudjEuUmVxdWVzdENvbnRleHQiJAoTUmVxdWVzdENvbnRleHRFcnJvchINCgVlcnJvchgBIAEoCSIoChZSZXF1ZXN0Q29udGV4dFJlamVjdGVkEg4KBnJlYXNvbhgBIAEoCSLCAQoKSW1hZ2VQcm90bxIMCgRkYXRhGAEgASgMEgwKBHV1aWQYAiABKAkSDAoEcGF0aBgDIAEoCRIxCglkaW1lbnNpb24YBCABKAsyHi5hZ2VudC52MS5JbWFnZVByb3RvX0RpbWVuc2lvbhImChl0YXNrX3NwZWNpZmljX2Rlc2NyaXB0aW9uGAYgASgJSACIAQESEQoJbWltZV90eXBlGAcgASgJQhwKGl90YXNrX3NwZWNpZmljX2Rlc2NyaXB0aW9uIjUKFEltYWdlUHJvdG9fRGltZW5zaW9uEg0KBXdpZHRoGAEgASgFEg4KBmhlaWdodBgCIAEoBSJoCgtHaXRSZXBvSW5mbxIMCgRwYXRoGAEgASgJEg4KBnN0YXR1cxgCIAEoCRITCgticmFuY2hfbmFtZRgDIAEoCRIXCgpyZW1vdGVfdXJsGAQgASgJSACIAQFCDQoLX3JlbW90ZV91cmwimwIKEVJlcXVlc3RDb250ZXh0RW52EhIKCm9zX3ZlcnNpb24YASABKAkSFwoPd29ya3NwYWNlX3BhdGhzGAIgAygJEg0KBXNoZWxsGAMgASgJEhcKD3NhbmRib3hfZW5hYmxlZBgFIAEoCBIYChB0ZXJtaW5hbHNfZm9sZGVyGAcgASgJEiEKGWFnZW50X3NoYXJlZF9ub3Rlc19mb2xkZXIYCCABKAkSJwofYWdlbnRfY29udmVyc2F0aW9uX25vdGVzX2ZvbGRlchgJIAEoCRIRCgl0aW1lX3pvbmUYCiABKAkSFgoOcHJvamVjdF9mb2xkZXIYCyABKAkSIAoYYWdlbnRfdHJhbnNjcmlwdHNfZm9sZGVyGAwgASgJIjwKD0RlYnVnTW9kZUNvbmZpZxIQCghsb2dfcGF0aBgBIAEoCRIXCg9zZXJ2ZXJfZW5kcG9pbnQYAiABKAkitAEKD1NraWxsRGVzY3JpcHRvchIMCgRuYW1lGAEgASgJEhMKC2Rlc2NyaXB0aW9uGAIgASgJEhMKC2ZvbGRlcl9wYXRoGAMgASgJEg8KB2VuYWJsZWQYBCABKAgSGAoLcGFyc2VfZXJyb3IYBSABKAlIAIgBARIYChByZWFkbWVfZmlsZV9wYXRoGAYgASgJEhQKDHBhY2thZ2VfdHlwZRgHIAEoBUIOCgxfcGFyc2VfZXJyb3IiRAoMU2tpbGxPcHRpb25zEjQKEXNraWxsX2Rlc2NyaXB0b3JzGAEgAygLMhkuYWdlbnQudjEuU2tpbGxEZXNjcmlwdG9yIvYICg5SZXF1ZXN0Q29udGV4dBIjCgVydWxlcxgCIAMoCzIULmFnZW50LnYxLkN1cnNvclJ1bGUSKAoDZW52GAQgASgLMhsuYWdlbnQudjEuUmVxdWVzdENvbnRleHRFbnYSOQoPcmVwb3NpdG9yeV9pbmZvGAYgAygLMiAuYWdlbnQudjEuUmVwb3NpdG9yeUluZGV4aW5nSW5mbxIqCgV0b29scxgHIAMoCzIbLmFnZW50LnYxLk1jcFRvb2xEZWZpbml0aW9uEicKGmNvbnZlcnNhdGlvbl9ub3Rlc19saXN0aW5nGAggASgJSACIAQESIQoUc2hhcmVkX25vdGVzX2xpc3RpbmcYCSABKAlIAYgBARIoCglnaXRfcmVwb3MYCyADKAsyFS5hZ2VudC52MS5HaXRSZXBvSW5mbxI2Cg9wcm9qZWN0X2xheW91dHMYDSADKAsyHS5hZ2VudC52MS5Mc0RpcmVjdG9yeVRyZWVOb2RlEjMKEG1jcF9pbnN0cnVjdGlvbnMYDiADKAsyGS5hZ2VudC52MS5NY3BJbnN0cnVjdGlvbnMSOQoRZGVidWdfbW9kZV9jb25maWcYDyABKAsyGS5hZ2VudC52MS5EZWJ1Z01vZGVDb25maWdIAogBARIXCgpjbG91ZF9ydWxlGBAgASgJSAOIAQESHwoSd2ViX3NlYXJjaF9lbmFibGVkGBEgASgISASIAQESMgoNc2tpbGxfb3B0aW9ucxgSIAEoCzIWLmFnZW50LnYxLlNraWxsT3B0aW9uc0gFiAEBEi4KIXJlcG9zaXRvcnlfaW5mb19zaG91bGRfcXVlcnlfcHJvZBgTIAEoCEgGiAEBEkEKDWZpbGVfY29udGVudHMYFCADKAsyKi5hZ2VudC52MS5SZXF1ZXN0Q29udGV4dC5GaWxlQ29udGVudHNFbnRyeRIgChN1c2VyX2ludGVudF9zdW1tYXJ5GBUgASgJSAeIAQESMgoQY3VzdG9tX3N1YmFnZW50cxgWIAMoCzIYLmFnZW50LnYxLkN1c3RvbVN1YmFnZW50EkQKF21jcF9maWxlX3N5c3RlbV9vcHRpb25zGBcgASgLMh4uYWdlbnQudjEuTWNwRmlsZVN5c3RlbU9wdGlvbnNICIgBARozChFGaWxlQ29udGVudHNFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBQh0KG19jb252ZXJzYXRpb25fbm90ZXNfbGlzdGluZ0IXChVfc2hhcmVkX25vdGVzX2xpc3RpbmdCFAoSX2RlYnVnX21vZGVfY29uZmlnQg0KC19jbG91ZF9ydWxlQhUKE193ZWJfc2VhcmNoX2VuYWJsZWRCEAoOX3NraWxsX29wdGlvbnNCJAoiX3JlcG9zaXRvcnlfaW5mb19zaG91bGRfcXVlcnlfcHJvZEIWChRfdXNlcl9pbnRlbnRfc3VtbWFyeUIaChhfbWNwX2ZpbGVfc3lzdGVtX29wdGlvbnMisgIKDVNhbmRib3hQb2xpY3kSDAoEdHlwZRgBIAEoBRIbCg5uZXR3b3JrX2FjY2VzcxgCIAEoCEgAiAEBEiIKGmFkZGl0aW9uYWxfcmVhZHdyaXRlX3BhdGhzGAMgAygJEiEKGWFkZGl0aW9uYWxfcmVhZG9ubHlfcGF0aHMYBCADKAkSHQoQZGVidWdfb3V0cHV0X2RpchgFIAEoCUgBiAEBEh0KEGJsb2NrX2dpdF93cml0ZXMYBiABKAhIAogBARIeChFkaXNhYmxlX3RtcF93cml0ZRgHIAEoCEgDiAEBQhEKD19uZXR3b3JrX2FjY2Vzc0ITChFfZGVidWdfb3V0cHV0X2RpckITChFfYmxvY2tfZ2l0X3dyaXRlc0IUChJfZGlzYWJsZV90bXBfd3JpdGUi7wEKDVNlbGVjdGVkSW1hZ2USDAoEdXVpZBgCIAEoCRIMCgRwYXRoGAMgASgJEjQKCWRpbWVuc2lvbhgEIAEoCzIhLmFnZW50LnYxLlNlbGVjdGVkSW1hZ2VfRGltZW5zaW9uEhEKCW1pbWVfdHlwZRgHIAEoCRIRCgdibG9iX2lkGAEgASgMSAASDgoEZGF0YRgIIAEoDEgAEkMKEWJsb2JfaWRfd2l0aF9kYXRhGAkgASgLMiYuYWdlbnQudjEuU2VsZWN0ZWRJbWFnZV9CbG9iSWRXaXRoRGF0YUgAQhEKD2RhdGFfb3JfYmxvYl9pZCI9ChxTZWxlY3RlZEltYWdlX0Jsb2JJZFdpdGhEYXRhEg8KB2Jsb2JfaWQYASABKAwSDAoEZGF0YRgCIAEoDCI4ChdTZWxlY3RlZEltYWdlX0RpbWVuc2lvbhINCgV3aWR0aBgBIAEoBRIOCgZoZWlnaHQYAiABKAUiSQoRRXh0cmFDb250ZXh0RW50cnkSDgoEZGF0YRgBIAEoCUgAEhEKB2Jsb2JfaWQYAiABKAxIAEIRCg9kYXRhX29yX2Jsb2JfaWQiWwoMU2VsZWN0ZWRGaWxlEg8KB2NvbnRlbnQYASABKAkSDAoEcGF0aBgCIAEoCRIaCg1yZWxhdGl2ZV9wYXRoGAMgASgJSACIAQFCEAoOX3JlbGF0aXZlX3BhdGgihAEKFVNlbGVjdGVkQ29kZVNlbGVjdGlvbhIPCgdjb250ZW50GAEgASgJEgwKBHBhdGgYAiABKAkSGgoNcmVsYXRpdmVfcGF0aBgDIAEoCUgAiAEBEh4KBXJhbmdlGAQgASgLMg8uYWdlbnQudjEuUmFuZ2VCEAoOX3JlbGF0aXZlX3BhdGgiXQoQU2VsZWN0ZWRUZXJtaW5hbBIPCgdjb250ZW50GAEgASgJEhIKBXRpdGxlGAIgASgJSACIAQESEQoEcGF0aBgDIAEoCUgBiAEBQggKBl90aXRsZUIHCgVfcGF0aCKGAQoZU2VsZWN0ZWRUZXJtaW5hbFNlbGVjdGlvbhIPCgdjb250ZW50GAEgASgJEhIKBXRpdGxlGAIgASgJSACIAQESEQoEcGF0aBgDIAEoCUgBiAEBEh4KBXJhbmdlGAQgASgLMg8uYWdlbnQudjEuUmFuZ2VCCAoGX3RpdGxlQgcKBV9wYXRoIoMBCg5TZWxlY3RlZEZvbGRlchIMCgRwYXRoGAEgASgJEhoKDXJlbGF0aXZlX3BhdGgYAiABKAlIAIgBARI1Cg5kaXJlY3RvcnlfdHJlZRgDIAEoCzIdLmFnZW50LnYxLkxzRGlyZWN0b3J5VHJlZU5vZGVCEAoOX3JlbGF0aXZlX3BhdGginwEKFFNlbGVjdGVkRXh0ZXJuYWxMaW5rEgsKA3VybBgBIAEoCRIMCgR1dWlkGAIgASgJEhgKC3BkZl9jb250ZW50GAMgASgJSACIAQESEwoGaXNfcGRmGAQgASgISAGIAQESFQoIZmlsZW5hbWUYBSABKAlIAogBAUIOCgxfcGRmX2NvbnRlbnRCCQoHX2lzX3BkZkILCglfZmlsZW5hbWUiOAoSU2VsZWN0ZWRDdXJzb3JSdWxlEiIKBHJ1bGUYASABKAsyFC5hZ2VudC52MS5DdXJzb3JSdWxlIiIKD1NlbGVjdGVkR2l0RGlmZhIPCgdjb250ZW50GAEgASgJIjIKH1NlbGVjdGVkR2l0RGlmZkZyb21CcmFuY2hUb01haW4SDwoHY29udGVudBgBIAEoCSJpChFTZWxlY3RlZEdpdENvbW1pdBILCgNzaGEYASABKAkSDwoHbWVzc2FnZRgCIAEoCRIYCgtkZXNjcmlwdGlvbhgDIAEoCUgAiAEBEgwKBGRpZmYYBCABKAlCDgoMX2Rlc2NyaXB0aW9uIt0BChNTZWxlY3RlZFB1bGxSZXF1ZXN0Eg4KBm51bWJlchgBIAEoBRILCgN1cmwYAiABKAkSEgoFdGl0bGUYAyABKAlIAIgBARITCgtmb2xkZXJfcGF0aBgEIAEoCRIZCgxzdW1tYXJ5X2pzb24YBSABKAlIAYgBARIYCgtkZXNjcmlwdGlvbhgGIAEoCUgCiAEBEhQKB2Jsb2JfaWQYByABKAxIA4gBAUIICgZfdGl0bGVCDwoNX3N1bW1hcnlfanNvbkIOCgxfZGVzY3JpcHRpb25CCgoIX2Jsb2JfaWQiswEKGlNlbGVjdGVkR2l0UFJEaWZmU2VsZWN0aW9uEg4KBnByX3VybBgBIAEoCRIRCglmaWxlX3BhdGgYAiABKAkSEgoKc3RhcnRfbGluZRgDIAEoBRIQCghlbmRfbGluZRgEIAEoBRIZCgxkaWZmX2NvbnRlbnQYBSABKAlIAIgBARIUCgdibG9iX2lkGAYgASgMSAGIAQFCDwoNX2RpZmZfY29udGVudEIKCghfYmxvYl9pZCI2ChVTZWxlY3RlZEN1cnNvckNvbW1hbmQSDAoEbmFtZRgBIAEoCRIPCgdjb250ZW50GAIgASgJIjUKFVNlbGVjdGVkRG9jdW1lbnRhdGlvbhIOCgZkb2NfaWQYASABKAkSDAoEbmFtZRgCIAEoCSIyChBTZWxlY3RlZFBhc3RDaGF0EhAKCGFnZW50X2lkGAEgASgJEgwKBG5hbWUYAiABKAkiqwEKCUNhbGxGcmFtZRIaCg1mdW5jdGlvbl9uYW1lGAEgASgJSACIAQESEAoDdXJsGAIgASgJSAGIAQESGAoLbGluZV9udW1iZXIYAyABKAVIAogBARIaCg1jb2x1bW5fbnVtYmVyGAQgASgFSAOIAQFCEAoOX2Z1bmN0aW9uX25hbWVCBgoEX3VybEIOCgxfbGluZV9udW1iZXJCEAoOX2NvbHVtbl9udW1iZXIiaAoKU3RhY2tUcmFjZRIoCgtjYWxsX2ZyYW1lcxgBIAMoCzITLmFnZW50LnYxLkNhbGxGcmFtZRIcCg9yYXdfc3RhY2tfdHJhY2UYAiABKAlIAIgBAUISChBfcmF3X3N0YWNrX3RyYWNlIuQBChJTZWxlY3RlZENvbnNvbGVMb2cSDwoHbWVzc2FnZRgBIAEoCRIRCgl0aW1lc3RhbXAYAiABKAESDQoFbGV2ZWwYAyABKAkSEwoLY2xpZW50X25hbWUYBCABKAkSEgoKc2Vzc2lvbl9pZBgFIAEoCRIuCgtzdGFja190cmFjZRgGIAEoCzIULmFnZW50LnYxLlN0YWNrVHJhY2VIAIgBARIdChBvYmplY3RfZGF0YV9qc29uGAcgASgJSAGIAQFCDgoMX3N0YWNrX3RyYWNlQhMKEV9vYmplY3RfZGF0YV9qc29uIroBChFTZWxlY3RlZFVJRWxlbWVudBIPCgdlbGVtZW50GAEgASgJEg0KBXhwYXRoGAIgASgJEhQKDHRleHRfY29udGVudBgDIAEoCRINCgVleHRyYRgEIAEoCRIWCgljb21wb25lbnQYBSABKAlIAIgBARIhChRjb21wb25lbnRfcHJvcHNfanNvbhgGIAEoCUgBiAEBQgwKCl9jb21wb25lbnRCFwoVX2NvbXBvbmVudF9wcm9wc19qc29uIiAKEFNlbGVjdGVkU3ViYWdlbnQSDAoEbmFtZRgBIAEoCSKCCgoPU2VsZWN0ZWRDb250ZXh0EjAKD3NlbGVjdGVkX2ltYWdlcxgBIAMoCzIXLmFnZW50LnYxLlNlbGVjdGVkSW1hZ2USPAoSaW52b2NhdGlvbl9jb250ZXh0GAIgASgLMhsuYWdlbnQudjEuSW52b2NhdGlvbkNvbnRleHRIAIgBARIVCg1leHRyYV9jb250ZXh0GAMgAygJEjoKFWV4dHJhX2NvbnRleHRfZW50cmllcxgQIAMoCzIbLmFnZW50LnYxLkV4dHJhQ29udGV4dEVudHJ5EiUKBWZpbGVzGAQgAygLMhYuYWdlbnQudjEuU2VsZWN0ZWRGaWxlEjgKD2NvZGVfc2VsZWN0aW9ucxgFIAMoCzIfLmFnZW50LnYxLlNlbGVjdGVkQ29kZVNlbGVjdGlvbhItCgl0ZXJtaW5hbHMYBiADKAsyGi5hZ2VudC52MS5TZWxlY3RlZFRlcm1pbmFsEkAKE3Rlcm1pbmFsX3NlbGVjdGlvbnMYByADKAsyIy5hZ2VudC52MS5TZWxlY3RlZFRlcm1pbmFsU2VsZWN0aW9uEikKB2ZvbGRlcnMYCCADKAsyGC5hZ2VudC52MS5TZWxlY3RlZEZvbGRlchI2Cg5leHRlcm5hbF9saW5rcxgJIAMoCzIeLmFnZW50LnYxLlNlbGVjdGVkRXh0ZXJuYWxMaW5rEjIKDGN1cnNvcl9ydWxlcxgKIAMoCzIcLmFnZW50LnYxLlNlbGVjdGVkQ3Vyc29yUnVsZRIwCghnaXRfZGlmZhgSIAEoCzIZLmFnZW50LnYxLlNlbGVjdGVkR2l0RGlmZkgBiAEBElQKHGdpdF9kaWZmX2Zyb21fYnJhbmNoX3RvX21haW4YCyABKAsyKS5hZ2VudC52MS5TZWxlY3RlZEdpdERpZmZGcm9tQnJhbmNoVG9NYWluSAKIAQESOAoPY3Vyc29yX2NvbW1hbmRzGAwgAygLMh8uYWdlbnQudjEuU2VsZWN0ZWRDdXJzb3JDb21tYW5kEjcKDmRvY3VtZW50YXRpb25zGA0gAygLMh8uYWdlbnQudjEuU2VsZWN0ZWREb2N1bWVudGF0aW9uEjAKC3VpX2VsZW1lbnRzGA4gAygLMhsuYWdlbnQudjEuU2VsZWN0ZWRVSUVsZW1lbnQSMgoMY29uc29sZV9sb2dzGA8gAygLMhwuYWdlbnQudjEuU2VsZWN0ZWRDb25zb2xlTG9nEjAKC2dpdF9jb21taXRzGBEgAygLMhsuYWdlbnQudjEuU2VsZWN0ZWRHaXRDb21taXQSLgoKcGFzdF9jaGF0cxgTIAMoCzIaLmFnZW50LnYxLlNlbGVjdGVkUGFzdENoYXQSRAoWZ2l0X3ByX2RpZmZfc2VsZWN0aW9ucxgUIAMoCzIkLmFnZW50LnYxLlNlbGVjdGVkR2l0UFJEaWZmU2VsZWN0aW9uEj0KFnNlbGVjdGVkX3B1bGxfcmVxdWVzdHMYFSADKAsyHS5hZ2VudC52MS5TZWxlY3RlZFB1bGxSZXF1ZXN0EjYKEnNlbGVjdGVkX3N1YmFnZW50cxgWIAMoCzIaLmFnZW50LnYxLlNlbGVjdGVkU3ViYWdlbnRCFQoTX2ludm9jYXRpb25fY29udGV4dEILCglfZ2l0X2RpZmZCHwodX2dpdF9kaWZmX2Zyb21fYnJhbmNoX3RvX21haW4i5QEKEUludm9jYXRpb25Db250ZXh0Ej8KDHNsYWNrX3RocmVhZBgBIAEoCzInLmFnZW50LnYxLkludm9jYXRpb25Db250ZXh0X1NsYWNrVGhyZWFkSAASOQoJZ2l0aHViX3ByGAIgASgLMiQuYWdlbnQudjEuSW52b2NhdGlvbkNvbnRleHRfR2l0aHViUFJIABI5CglpZGVfc3RhdGUYAyABKAsyJC5hZ2VudC52MS5JbnZvY2F0aW9uQ29udGV4dF9JZGVTdGF0ZUgAEhEKB2Jsb2JfaWQYCiABKAxIAEIGCgRkYXRhIrsBCh1JbnZvY2F0aW9uQ29udGV4dF9TbGFja1RocmVhZBIOCgZ0aHJlYWQYASABKAkSGQoMY2hhbm5lbF9uYW1lGAIgASgJSACIAQESHAoPY2hhbm5lbF9wdXJwb3NlGAMgASgJSAGIAQESGgoNY2hhbm5lbF90b3BpYxgEIAEoCUgCiAEBQg8KDV9jaGFubmVsX25hbWVCEgoQX2NoYW5uZWxfcHVycG9zZUIQCg5fY2hhbm5lbF90b3BpYyJ8ChpJbnZvY2F0aW9uQ29udGV4dF9HaXRodWJQUhINCgV0aXRsZRgBIAEoCRITCgtkZXNjcmlwdGlvbhgCIAEoCRIQCghjb21tZW50cxgDIAEoCRIYCgtjaV9mYWlsdXJlcxgEIAEoCUgAiAEBQg4KDF9jaV9mYWlsdXJlcyL+AQoaSW52b2NhdGlvbkNvbnRleHRfSWRlU3RhdGUSQAoNdmlzaWJsZV9maWxlcxgBIAMoCzIpLmFnZW50LnYxLkludm9jYXRpb25Db250ZXh0X0lkZVN0YXRlX0ZpbGUSSAoVcmVjZW50bHlfdmlld2VkX2ZpbGVzGAIgAygLMikuYWdlbnQudjEuSW52b2NhdGlvbkNvbnRleHRfSWRlU3RhdGVfRmlsZRJUChRjdXJyZW50bHlfdmlld2VkX3BycxgDIAMoCzI2LmFnZW50LnYxLkludm9jYXRpb25Db250ZXh0X0lkZVN0YXRlX1ZpZXdlZFB1bGxSZXF1ZXN0Io4CCh9JbnZvY2F0aW9uQ29udGV4dF9JZGVTdGF0ZV9GaWxlEgwKBHBhdGgYASABKAkSGgoNcmVsYXRpdmVfcGF0aBgCIAEoCUgAiAEBElYKD2N1cnNvcl9wb3NpdGlvbhgDIAEoCzI4LmFnZW50LnYxLkludm9jYXRpb25Db250ZXh0X0lkZVN0YXRlX0ZpbGVfQ3Vyc29yUG9zaXRpb25IAYgBARITCgt0b3RhbF9saW5lcxgEIAEoBRIbCg5hY3RpdmVfY29tbWFuZBgFIAEoCUgCiAEBQhAKDl9yZWxhdGl2ZV9wYXRoQhIKEF9jdXJzb3JfcG9zaXRpb25CEQoPX2FjdGl2ZV9jb21tYW5kIkwKLkludm9jYXRpb25Db250ZXh0X0lkZVN0YXRlX0ZpbGVfQ3Vyc29yUG9zaXRpb24SDAoEbGluZRgBIAEoBRIMCgR0ZXh0GAIgASgJIukBCixJbnZvY2F0aW9uQ29udGV4dF9JZGVTdGF0ZV9WaWV3ZWRQdWxsUmVxdWVzdBIOCgZudW1iZXIYASABKAUSCwoDdXJsGAIgASgJEhIKBXRpdGxlGAMgASgJSACIAQESGAoLZm9sZGVyX3BhdGgYBCABKAlIAYgBARIZCgxzdW1tYXJ5X2pzb24YBSABKAlIAogBARIYCgtkZXNjcmlwdGlvbhgGIAEoCUgDiAEBQggKBl90aXRsZUIOCgxfZm9sZGVyX3BhdGhCDwoNX3N1bW1hcnlfanNvbkIOCgxfZGVzY3JpcHRpb24iSAoWU2V0dXBWbUVudmlyb25tZW50QXJncxIXCg9pbnN0YWxsX2NvbW1hbmQYAiABKAkSFQoNc3RhcnRfY29tbWFuZBgDIAEoCSJcChhTZXR1cFZtRW52aXJvbm1lbnRSZXN1bHQSNgoHc3VjY2VzcxgBIAEoCzIjLmFnZW50LnYxLlNldHVwVm1FbnZpcm9ubWVudFN1Y2Nlc3NIAEIICgZyZXN1bHQiGwoZU2V0dXBWbUVudmlyb25tZW50U3VjY2VzcyKAAQoaU2V0dXBWbUVudmlyb25tZW50VG9vbENhbGwSLgoEYXJncxgBIAEoCzIgLmFnZW50LnYxLlNldHVwVm1FbnZpcm9ubWVudEFyZ3MSMgoGcmVzdWx0GAIgASgLMiIuYWdlbnQudjEuU2V0dXBWbUVudmlyb25tZW50UmVzdWx0IsABChlTaGVsbENvbW1hbmRQYXJzaW5nUmVzdWx0EhYKDnBhcnNpbmdfZmFpbGVkGAEgASgIElIKE2V4ZWN1dGFibGVfY29tbWFuZHMYAiADKAsyNS5hZ2VudC52MS5TaGVsbENvbW1hbmRQYXJzaW5nUmVzdWx0X0V4ZWN1dGFibGVDb21tYW5kEhUKDWhhc19yZWRpcmVjdHMYAyABKAgSIAoYaGFzX2NvbW1hbmRfc3Vic3RpdHV0aW9uGAQgASgIIk0KLlNoZWxsQ29tbWFuZFBhcnNpbmdSZXN1bHRfRXhlY3V0YWJsZUNvbW1hbmRBcmcSDAoEdHlwZRgBIAEoCRINCgV2YWx1ZRgCIAEoCSKWAQorU2hlbGxDb21tYW5kUGFyc2luZ1Jlc3VsdF9FeGVjdXRhYmxlQ29tbWFuZBIMCgRuYW1lGAEgASgJEkYKBGFyZ3MYAiADKAsyOC5hZ2VudC52MS5TaGVsbENvbW1hbmRQYXJzaW5nUmVzdWx0X0V4ZWN1dGFibGVDb21tYW5kQXJnEhEKCWZ1bGxfdGV4dBgDIAEoCSKIBAoJU2hlbGxBcmdzEg8KB2NvbW1hbmQYASABKAkSGQoRd29ya2luZ19kaXJlY3RvcnkYAiABKAkSDwoHdGltZW91dBgDIAEoBRIUCgx0b29sX2NhbGxfaWQYBCABKAkSFwoPc2ltcGxlX2NvbW1hbmRzGAUgAygJEhoKEmhhc19pbnB1dF9yZWRpcmVjdBgGIAEoCBIbChNoYXNfb3V0cHV0X3JlZGlyZWN0GAcgASgIEjsKDnBhcnNpbmdfcmVzdWx0GAggASgLMiMuYWdlbnQudjEuU2hlbGxDb21tYW5kUGFyc2luZ1Jlc3VsdBI+ChhyZXF1ZXN0ZWRfc2FuZGJveF9wb2xpY3kYCSABKAsyFy5hZ2VudC52MS5TYW5kYm94UG9saWN5SACIAQESKAobZmlsZV9vdXRwdXRfdGhyZXNob2xkX2J5dGVzGAogASgESAGIAQESFQoNaXNfYmFja2dyb3VuZBgLIAEoCBIVCg1za2lwX2FwcHJvdmFsGAwgASgIEhgKEHRpbWVvdXRfYmVoYXZpb3IYDSABKAUSGQoMaGFyZF90aW1lb3V0GA4gASgFSAKIAQFCGwoZX3JlcXVlc3RlZF9zYW5kYm94X3BvbGljeUIeChxfZmlsZV9vdXRwdXRfdGhyZXNob2xkX2J5dGVzQg8KDV9oYXJkX3RpbWVvdXQi+gMKC1NoZWxsUmVzdWx0EjQKDnNhbmRib3hfcG9saWN5GGUgASgLMhcuYWdlbnQudjEuU2FuZGJveFBvbGljeUgBiAEBEhoKDWlzX2JhY2tncm91bmQYZiABKAhIAogBARIdChB0ZXJtaW5hbHNfZm9sZGVyGGcgASgJSAOIAQESEAoDcGlkGGggASgNSASIAQESKQoHc3VjY2VzcxgBIAEoCzIWLmFnZW50LnYxLlNoZWxsU3VjY2Vzc0gAEikKB2ZhaWx1cmUYAiABKAsyFi5hZ2VudC52MS5TaGVsbEZhaWx1cmVIABIpCgd0aW1lb3V0GAMgASgLMhYuYWdlbnQudjEuU2hlbGxUaW1lb3V0SAASKwoIcmVqZWN0ZWQYBCABKAsyFy5hZ2VudC52MS5TaGVsbFJlamVjdGVkSAASMAoLc3Bhd25fZXJyb3IYBSABKAsyGS5hZ2VudC52MS5TaGVsbFNwYXduRXJyb3JIABI8ChFwZXJtaXNzaW9uX2RlbmllZBgHIAEoCzIfLmFnZW50LnYxLlNoZWxsUGVybWlzc2lvbkRlbmllZEgAQggKBnJlc3VsdEIRCg9fc2FuZGJveF9wb2xpY3lCEAoOX2lzX2JhY2tncm91bmRCEwoRX3Rlcm1pbmFsc19mb2xkZXJCBgoEX3BpZCIhChFTaGVsbFN0cmVhbVN0ZG91dBIMCgRkYXRhGAEgASgJIiEKEVNoZWxsU3RyZWFtU3RkZXJyEgwKBGRhdGEYASABKAkitQEKD1NoZWxsU3RyZWFtRXhpdBIMCgRjb2RlGAEgASgNEgsKA2N3ZBgCIAEoCRI2Cg9vdXRwdXRfbG9jYXRpb24YAyABKAsyGC5hZ2VudC52MS5PdXRwdXRMb2NhdGlvbkgAiAEBEg8KB2Fib3J0ZWQYBCABKAgSGQoMYWJvcnRfcmVhc29uGAUgASgFSAGIAQFCEgoQX291dHB1dF9sb2NhdGlvbkIPCg1fYWJvcnRfcmVhc29uIlsKEFNoZWxsU3RyZWFtU3RhcnQSNAoOc2FuZGJveF9wb2xpY3kYASABKAsyFy5hZ2VudC52MS5TYW5kYm94UG9saWN5SACIAQFCEQoPX3NhbmRib3hfcG9saWN5IpkBChdTaGVsbFN0cmVhbUJhY2tncm91bmRlZBIQCghzaGVsbF9pZBgBIAEoDRIPCgdjb21tYW5kGAIgASgJEhkKEXdvcmtpbmdfZGlyZWN0b3J5GAMgASgJEhAKA3BpZBgEIAEoDUgAiAEBEhcKCm1zX3RvX3dhaXQYBSABKAVIAYgBAUIGCgRfcGlkQg0KC19tc190b193YWl0IvICCgtTaGVsbFN0cmVhbRItCgZzdGRvdXQYASABKAsyGy5hZ2VudC52MS5TaGVsbFN0cmVhbVN0ZG91dEgAEi0KBnN0ZGVychgCIAEoCzIbLmFnZW50LnYxLlNoZWxsU3RyZWFtU3RkZXJySAASKQoEZXhpdBgDIAEoCzIZLmFnZW50LnYxLlNoZWxsU3RyZWFtRXhpdEgAEisKBXN0YXJ0GAQgASgLMhouYWdlbnQudjEuU2hlbGxTdHJlYW1TdGFydEgAEisKCHJlamVjdGVkGAUgASgLMhcuYWdlbnQudjEuU2hlbGxSZWplY3RlZEgAEjwKEXBlcm1pc3Npb25fZGVuaWVkGAYgASgLMh8uYWdlbnQudjEuU2hlbGxQZXJtaXNzaW9uRGVuaWVkSAASOQoMYmFja2dyb3VuZGVkGAcgASgLMiEuYWdlbnQudjEuU2hlbGxTdHJlYW1CYWNrZ3JvdW5kZWRIAEIHCgVldmVudCJLCg5PdXRwdXRMb2NhdGlvbhIRCglmaWxlX3BhdGgYASABKAkSEgoKc2l6ZV9ieXRlcxgCIAEoAxISCgpsaW5lX2NvdW50GAMgASgDIv8CCgxTaGVsbFN1Y2Nlc3MSDwoHY29tbWFuZBgBIAEoCRIZChF3b3JraW5nX2RpcmVjdG9yeRgCIAEoCRIRCglleGl0X2NvZGUYAyABKAUSDgoGc2lnbmFsGAQgASgJEg4KBnN0ZG91dBgFIAEoCRIOCgZzdGRlcnIYBiABKAkSFgoOZXhlY3V0aW9uX3RpbWUYByABKAUSNgoPb3V0cHV0X2xvY2F0aW9uGAggASgLMhguYWdlbnQudjEuT3V0cHV0TG9jYXRpb25IAIgBARIVCghzaGVsbF9pZBgJIAEoDUgBiAEBEh8KEmludGVybGVhdmVkX291dHB1dBgKIAEoCUgCiAEBEhAKA3BpZBgLIAEoDUgDiAEBEhcKCm1zX3RvX3dhaXQYDCABKAVIBIgBAUISChBfb3V0cHV0X2xvY2F0aW9uQgsKCV9zaGVsbF9pZEIVChNfaW50ZXJsZWF2ZWRfb3V0cHV0QgYKBF9waWRCDQoLX21zX3RvX3dhaXQi1gIKDFNoZWxsRmFpbHVyZRIPCgdjb21tYW5kGAEgASgJEhkKEXdvcmtpbmdfZGlyZWN0b3J5GAIgASgJEhEKCWV4aXRfY29kZRgDIAEoBRIOCgZzaWduYWwYBCABKAkSDgoGc3Rkb3V0GAUgASgJEg4KBnN0ZGVychgGIAEoCRIWCg5leGVjdXRpb25fdGltZRgHIAEoBRI2Cg9vdXRwdXRfbG9jYXRpb24YCCABKAsyGC5hZ2VudC52MS5PdXRwdXRMb2NhdGlvbkgAiAEBEh8KEmludGVybGVhdmVkX291dHB1dBgJIAEoCUgBiAEBEhkKDGFib3J0X3JlYXNvbhgKIAEoBUgCiAEBEg8KB2Fib3J0ZWQYCyABKAhCEgoQX291dHB1dF9sb2NhdGlvbkIVChNfaW50ZXJsZWF2ZWRfb3V0cHV0Qg8KDV9hYm9ydF9yZWFzb24iTgoMU2hlbGxUaW1lb3V0Eg8KB2NvbW1hbmQYASABKAkSGQoRd29ya2luZ19kaXJlY3RvcnkYAiABKAkSEgoKdGltZW91dF9tcxgDIAEoBSJgCg1TaGVsbFJlamVjdGVkEg8KB2NvbW1hbmQYASABKAkSGQoRd29ya2luZ19kaXJlY3RvcnkYAiABKAkSDgoGcmVhc29uGAMgASgJEhMKC2lzX3JlYWRvbmx5GAQgASgIImcKFVNoZWxsUGVybWlzc2lvbkRlbmllZBIPCgdjb21tYW5kGAEgASgJEhkKEXdvcmtpbmdfZGlyZWN0b3J5GAIgASgJEg0KBWVycm9yGAMgASgJEhMKC2lzX3JlYWRvbmx5GAQgASgIIkwKD1NoZWxsU3Bhd25FcnJvchIPCgdjb21tYW5kGAEgASgJEhkKEXdvcmtpbmdfZGlyZWN0b3J5GAIgASgJEg0KBWVycm9yGAMgASgJIkAKElNoZWxsUGFydGlhbFJlc3VsdBIUCgxzdGRvdXRfZGVsdGEYASABKAkSFAoMc3RkZXJyX2RlbHRhGAIgASgJIlkKDVNoZWxsVG9vbENhbGwSIQoEYXJncxgBIAEoCzITLmFnZW50LnYxLlNoZWxsQXJncxIlCgZyZXN1bHQYAiABKAsyFS5hZ2VudC52MS5TaGVsbFJlc3VsdCIrChhTaGVsbFRvb2xDYWxsU3Rkb3V0RGVsdGESDwoHY29udGVudBgBIAEoCSIrChhTaGVsbFRvb2xDYWxsU3RkZXJyRGVsdGESDwoHY29udGVudBgBIAEoCSKJAQoSU2hlbGxUb29sQ2FsbERlbHRhEjQKBnN0ZG91dBgBIAEoCzIiLmFnZW50LnYxLlNoZWxsVG9vbENhbGxTdGRvdXREZWx0YUgAEjQKBnN0ZGVychgCIAEoCzIiLmFnZW50LnYxLlNoZWxsVG9vbENhbGxTdGRlcnJEZWx0YUgAQgcKBWRlbHRhIu0BCgxTdWJhZ2VudFR5cGUSOAoLdW5zcGVjaWZpZWQYASABKAsyIS5hZ2VudC52MS5TdWJhZ2VudFR5cGVVbnNwZWNpZmllZEgAEjkKDGNvbXB1dGVyX3VzZRgCIAEoCzIhLmFnZW50LnYxLlN1YmFnZW50VHlwZUNvbXB1dGVyVXNlSAASLgoGY3VzdG9tGAMgASgLMhwuYWdlbnQudjEuU3ViYWdlbnRUeXBlQ3VzdG9tSAASMAoHZXhwbG9yZRgEIAEoCzIdLmFnZW50LnYxLlN1YmFnZW50VHlwZUV4cGxvcmVIAEIGCgR0eXBlIhkKF1N1YmFnZW50VHlwZVVuc3BlY2lmaWVkIhkKF1N1YmFnZW50VHlwZUNvbXB1dGVyVXNlIhUKE1N1YmFnZW50VHlwZUV4cGxvcmUiIgoSU3ViYWdlbnRUeXBlQ3VzdG9tEgwKBG5hbWUYASABKAkijQEKDkN1c3RvbVN1YmFnZW50EhEKCWZ1bGxfcGF0aBgBIAEoCRIMCgRuYW1lGAIgASgJEhMKC2Rlc2NyaXB0aW9uGAMgASgJEg0KBXRvb2xzGAQgAygJEg0KBW1vZGVsGAUgASgJEg4KBnByb21wdBgGIAEoCRIXCg9wZXJtaXNzaW9uX21vZGUYByABKAUiaAoOU3dpdGNoTW9kZUFyZ3MSFgoOdGFyZ2V0X21vZGVfaWQYASABKAkSGAoLZXhwbGFuYXRpb24YAiABKAlIAIgBARIUCgx0b29sX2NhbGxfaWQYAyABKAlCDgoMX2V4cGxhbmF0aW9uIqoBChBTd2l0Y2hNb2RlUmVzdWx0Ei4KB3N1Y2Nlc3MYASABKAsyGy5hZ2VudC52MS5Td2l0Y2hNb2RlU3VjY2Vzc0gAEioKBWVycm9yGAIgASgLMhkuYWdlbnQudjEuU3dpdGNoTW9kZUVycm9ySAASMAoIcmVqZWN0ZWQYAyABKAsyHC5hZ2VudC52MS5Td2l0Y2hNb2RlUmVqZWN0ZWRIAEIICgZyZXN1bHQiPQoRU3dpdGNoTW9kZVN1Y2Nlc3MSFAoMZnJvbV9tb2RlX2lkGAEgASgJEhIKCnRvX21vZGVfaWQYAiABKAkiIAoPU3dpdGNoTW9kZUVycm9yEg0KBWVycm9yGAEgASgJIiQKElN3aXRjaE1vZGVSZWplY3RlZBIOCgZyZWFzb24YASABKAkiaAoSU3dpdGNoTW9kZVRvb2xDYWxsEiYKBGFyZ3MYASABKAsyGC5hZ2VudC52MS5Td2l0Y2hNb2RlQXJncxIqCgZyZXN1bHQYAiABKAsyGi5hZ2VudC52MS5Td2l0Y2hNb2RlUmVzdWx0IkAKFlN3aXRjaE1vZGVSZXF1ZXN0UXVlcnkSJgoEYXJncxgBIAEoCzIYLmFnZW50LnYxLlN3aXRjaE1vZGVBcmdzIqkBChlTd2l0Y2hNb2RlUmVxdWVzdFJlc3BvbnNlEkAKCGFwcHJvdmVkGAEgASgLMiwuYWdlbnQudjEuU3dpdGNoTW9kZVJlcXVlc3RSZXNwb25zZV9BcHByb3ZlZEgAEkAKCHJlamVjdGVkGAIgASgLMiwuYWdlbnQudjEuU3dpdGNoTW9kZVJlcXVlc3RSZXNwb25zZV9SZWplY3RlZEgAQggKBnJlc3VsdCIkCiJTd2l0Y2hNb2RlUmVxdWVzdFJlc3BvbnNlX0FwcHJvdmVkIjQKIlN3aXRjaE1vZGVSZXF1ZXN0UmVzcG9uc2VfUmVqZWN0ZWQSDgoGcmVhc29uGAEgASgJInUKCFRvZG9JdGVtEgoKAmlkGAEgASgJEg8KB2NvbnRlbnQYAiABKAkSDgoGc3RhdHVzGAMgASgFEhIKCmNyZWF0ZWRfYXQYBCABKAMSEgoKdXBkYXRlZF9hdBgFIAEoAxIUCgxkZXBlbmRlbmNpZXMYBiADKAkiawoTVXBkYXRlVG9kb3NUb29sQ2FsbBInCgRhcmdzGAEgASgLMhkuYWdlbnQudjEuVXBkYXRlVG9kb3NBcmdzEisKBnJlc3VsdBgCIAEoCzIbLmFnZW50LnYxLlVwZGF0ZVRvZG9zUmVzdWx0IkMKD1VwZGF0ZVRvZG9zQXJncxIhCgV0b2RvcxgBIAMoCzISLmFnZW50LnYxLlRvZG9JdGVtEg0KBW1lcmdlGAIgASgIInsKEVVwZGF0ZVRvZG9zUmVzdWx0Ei8KB3N1Y2Nlc3MYASABKAsyHC5hZ2VudC52MS5VcGRhdGVUb2Rvc1N1Y2Nlc3NIABIrCgVlcnJvchgCIAEoCzIaLmFnZW50LnYxLlVwZGF0ZVRvZG9zRXJyb3JIAEIICgZyZXN1bHQiXwoSVXBkYXRlVG9kb3NTdWNjZXNzEiEKBXRvZG9zGAEgAygLMhIuYWdlbnQudjEuVG9kb0l0ZW0SEwoLdG90YWxfY291bnQYAiABKAUSEQoJd2FzX21lcmdlGAMgASgIIiEKEFVwZGF0ZVRvZG9zRXJyb3ISDQoFZXJyb3IYASABKAkiZQoRUmVhZFRvZG9zVG9vbENhbGwSJQoEYXJncxgBIAEoCzIXLmFnZW50LnYxLlJlYWRUb2Rvc0FyZ3MSKQoGcmVzdWx0GAIgASgLMhkuYWdlbnQudjEuUmVhZFRvZG9zUmVzdWx0IjkKDVJlYWRUb2Rvc0FyZ3MSFQoNc3RhdHVzX2ZpbHRlchgBIAMoBRIRCglpZF9maWx0ZXIYAiADKAkidQoPUmVhZFRvZG9zUmVzdWx0Ei0KB3N1Y2Nlc3MYASABKAsyGi5hZ2VudC52MS5SZWFkVG9kb3NTdWNjZXNzSAASKQoFZXJyb3IYAiABKAsyGC5hZ2VudC52MS5SZWFkVG9kb3NFcnJvckgAQggKBnJlc3VsdCJKChBSZWFkVG9kb3NTdWNjZXNzEiEKBXRvZG9zGAEgAygLMhIuYWdlbnQudjEuVG9kb0l0ZW0SEwoLdG90YWxfY291bnQYAiABKAUiHwoOUmVhZFRvZG9zRXJyb3ISDQoFZXJyb3IYASABKAkiSwoFUmFuZ2USIQoFc3RhcnQYASABKAsyEi5hZ2VudC52MS5Qb3NpdGlvbhIfCgNlbmQYAiABKAsyEi5hZ2VudC52MS5Qb3NpdGlvbiIoCghQb3NpdGlvbhIMCgRsaW5lGAEgASgNEg4KBmNvbHVtbhgCIAEoDSIYCgVFcnJvchIPCgdtZXNzYWdlGAEgASgJIjoKDVdlYlNlYXJjaEFyZ3MSEwoLc2VhcmNoX3Rlcm0YASABKAkSFAoMdG9vbF9jYWxsX2lkGAIgASgJIqYBCg9XZWJTZWFyY2hSZXN1bHQSLQoHc3VjY2VzcxgBIAEoCzIaLmFnZW50LnYxLldlYlNlYXJjaFN1Y2Nlc3NIABIpCgVlcnJvchgCIAEoCzIYLmFnZW50LnYxLldlYlNlYXJjaEVycm9ySAASLwoIcmVqZWN0ZWQYAyABKAsyGy5hZ2VudC52MS5XZWJTZWFyY2hSZWplY3RlZEgAQggKBnJlc3VsdCJEChBXZWJTZWFyY2hTdWNjZXNzEjAKCnJlZmVyZW5jZXMYASADKAsyHC5hZ2VudC52MS5XZWJTZWFyY2hSZWZlcmVuY2UiHwoOV2ViU2VhcmNoRXJyb3ISDQoFZXJyb3IYASABKAkiIwoRV2ViU2VhcmNoUmVqZWN0ZWQSDgoGcmVhc29uGAEgASgJIj8KEldlYlNlYXJjaFJlZmVyZW5jZRINCgV0aXRsZRgBIAEoCRILCgN1cmwYAiABKAkSDQoFY2h1bmsYAyABKAkiZQoRV2ViU2VhcmNoVG9vbENhbGwSJQoEYXJncxgBIAEoCzIXLmFnZW50LnYxLldlYlNlYXJjaEFyZ3MSKQoGcmVzdWx0GAIgASgLMhkuYWdlbnQudjEuV2ViU2VhcmNoUmVzdWx0Ij4KFVdlYlNlYXJjaFJlcXVlc3RRdWVyeRIlCgRhcmdzGAEgASgLMhcuYWdlbnQudjEuV2ViU2VhcmNoQXJncyKmAQoYV2ViU2VhcmNoUmVxdWVzdFJlc3BvbnNlEj8KCGFwcHJvdmVkGAEgASgLMisuYWdlbnQudjEuV2ViU2VhcmNoUmVxdWVzdFJlc3BvbnNlX0FwcHJvdmVkSAASPwoIcmVqZWN0ZWQYAiABKAsyKy5hZ2VudC52MS5XZWJTZWFyY2hSZXF1ZXN0UmVzcG9uc2VfUmVqZWN0ZWRIAEIICgZyZXN1bHQiIwohV2ViU2VhcmNoUmVxdWVzdFJlc3BvbnNlX0FwcHJvdmVkIjMKIVdlYlNlYXJjaFJlcXVlc3RSZXNwb25zZV9SZWplY3RlZBIOCgZyZWFzb24YASABKAkifwoJV3JpdGVBcmdzEgwKBHBhdGgYASABKAkSEQoJZmlsZV90ZXh0GAIgASgJEhQKDHRvb2xfY2FsbF9pZBgDIAEoCRInCh9yZXR1cm5fZmlsZV9jb250ZW50X2FmdGVyX3dyaXRlGAQgASgIEhIKCmZpbGVfYnl0ZXMYBSABKAwigAIKC1dyaXRlUmVzdWx0EikKB3N1Y2Nlc3MYASABKAsyFi5hZ2VudC52MS5Xcml0ZVN1Y2Nlc3NIABI8ChFwZXJtaXNzaW9uX2RlbmllZBgDIAEoCzIfLmFnZW50LnYxLldyaXRlUGVybWlzc2lvbkRlbmllZEgAEioKCG5vX3NwYWNlGAQgASgLMhYuYWdlbnQudjEuV3JpdGVOb1NwYWNlSAASJQoFZXJyb3IYBSABKAsyFC5hZ2VudC52MS5Xcml0ZUVycm9ySAASKwoIcmVqZWN0ZWQYBiABKAsyFy5hZ2VudC52MS5Xcml0ZVJlamVjdGVkSABCCAoGcmVzdWx0IooBCgxXcml0ZVN1Y2Nlc3MSDAoEcGF0aBgBIAEoCRIVCg1saW5lc19jcmVhdGVkGAIgASgFEhEKCWZpbGVfc2l6ZRgDIAEoBRIlChhmaWxlX2NvbnRlbnRfYWZ0ZXJfd3JpdGUYBCABKAlIAIgBAUIbChlfZmlsZV9jb250ZW50X2FmdGVyX3dyaXRlIm8KFVdyaXRlUGVybWlzc2lvbkRlbmllZBIMCgRwYXRoGAEgASgJEhEKCWRpcmVjdG9yeRgCIAEoCRIRCglvcGVyYXRpb24YAyABKAkSDQoFZXJyb3IYBCABKAkSEwoLaXNfcmVhZG9ubHkYBSABKAgiHAoMV3JpdGVOb1NwYWNlEgwKBHBhdGgYASABKAkiKQoKV3JpdGVFcnJvchIMCgRwYXRoGAEgASgJEg0KBWVycm9yGAIgASgJIi0KDVdyaXRlUmVqZWN0ZWQSDAoEcGF0aBgBIAEoCRIOCgZyZWFzb24YAiABKAkigwEKF0Jvb3RzdHJhcFN0YXRzaWdSZXF1ZXN0Eh4KEWlnbm9yZV9kZXZfc3RhdHVzGAEgASgISACIAQESHQoQb3BlcmF0aW5nX3N5c3RlbRgCIAEoBUgBiAEBQhQKEl9pZ25vcmVfZGV2X3N0YXR1c0ITChFfb3BlcmF0aW5nX3N5c3RlbSIOCgxQaW5nUmVzcG9uc2UitwEKC0V4ZWNSZXF1ZXN0Eg8KB2NvbW1hbmQYASABKAkSEAoDY3dkGAIgASgJSACIAQESDAoEYXJncxgDIAMoCRI7CgtlbnZpcm9ubWVudBgEIAMoCzImLmFnZW50LnYxLkV4ZWNSZXF1ZXN0LkVudmlyb25tZW50RW50cnkaMgoQRW52aXJvbm1lbnRFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBQgYKBF9jd2QioAEKDEV4ZWNSZXNwb25zZRItCgxzdGRvdXRfZXZlbnQYASABKAsyFS5hZ2VudC52MS5TdGRvdXRFdmVudEgAEi0KDHN0ZGVycl9ldmVudBgCIAEoCzIVLmFnZW50LnYxLlN0ZGVyckV2ZW50SAASKQoKZXhpdF9ldmVudBgDIAEoCzITLmFnZW50LnYxLkV4aXRFdmVudEgAQgcKBWV2ZW50IhsKC1N0ZG91dEV2ZW50EgwKBGRhdGEYASABKAkiGwoLU3RkZXJyRXZlbnQSDAoEZGF0YRgBIAEoCSIeCglFeGl0RXZlbnQSEQoJZXhpdF9jb2RlGAEgASgFIiMKE1JlYWRUZXh0RmlsZVJlcXVlc3QSDAoEcGF0aBgBIAEoCSInChRSZWFkVGV4dEZpbGVSZXNwb25zZRIPCgdjb250ZW50GAEgASgJIjUKFFdyaXRlVGV4dEZpbGVSZXF1ZXN0EgwKBHBhdGgYASABKAkSDwoHY29udGVudBgCIAEoCSIXChVXcml0ZVRleHRGaWxlUmVzcG9uc2UiJQoVUmVhZEJpbmFyeUZpbGVSZXF1ZXN0EgwKBHBhdGgYASABKAkiKQoWUmVhZEJpbmFyeUZpbGVSZXNwb25zZRIPCgdjb250ZW50GAEgASgMIjcKFldyaXRlQmluYXJ5RmlsZVJlcXVlc3QSDAoEcGF0aBgBIAEoCRIPCgdjb250ZW50GAIgASgMIhkKF1dyaXRlQmluYXJ5RmlsZVJlc3BvbnNlIkUKHkdldFdvcmtzcGFjZUNoYW5nZXNIYXNoUmVxdWVzdBIRCglyb290X3BhdGgYASABKAkSEAoIYmFzZV9yZWYYAiABKAkiLwofR2V0V29ya3NwYWNlQ2hhbmdlc0hhc2hSZXNwb25zZRIMCgRoYXNoGAEgASgJIlAKH1JlZnJlc2hHaXRodWJBY2Nlc3NUb2tlblJlcXVlc3QSGwoTZ2l0aHViX2FjY2Vzc190b2tlbhgBIAEoCRIQCghob3N0bmFtZRgCIAEoCSIiCiBSZWZyZXNoR2l0aHViQWNjZXNzVG9rZW5SZXNwb25zZSJXCh1XYXJtUmVtb3RlQWNjZXNzU2VydmVyUmVxdWVzdBIOCgZjb21taXQYASABKAkSDAoEcG9ydBgCIAEoBRIYChBjb25uZWN0aW9uX3Rva2VuGAMgASgJIiAKHldhcm1SZW1vdGVBY2Nlc3NTZXJ2ZXJSZXNwb25zZSIWChRMaXN0QXJ0aWZhY3RzUmVxdWVzdCKKAgoWQXJ0aWZhY3RVcGxvYWRNZXRhZGF0YRIVCg1hYnNvbHV0ZV9wYXRoGAEgASgJEhIKCnNpemVfYnl0ZXMYAiABKAQSGgoSdXBkYXRlZF9hdF91bml4X21zGAMgASgDEg4KBnN0YXR1cxgEIAEoBRIWCg5ieXRlc191cGxvYWRlZBgFIAEoBBISCgpsYXN0X2Vycm9yGAYgASgJEhcKD3VwbG9hZF9hdHRlbXB0cxgHIAEoDRIfChdsYXN0X3N0YXJ0ZWRfYXRfdW5peF9tcxgIIAEoAxIgChhsYXN0X2ZpbmlzaGVkX2F0X3VuaXhfbXMYCSABKAMSEQoJdXBsb2FkX2lkGAogASgJIkwKFUxpc3RBcnRpZmFjdHNSZXNwb25zZRIzCglhcnRpZmFjdHMYASADKAsyIC5hZ2VudC52MS5BcnRpZmFjdFVwbG9hZE1ldGFkYXRhIk4KFlVwbG9hZEFydGlmYWN0c1JlcXVlc3QSNAoHdXBsb2FkcxgBIAMoCzIjLmFnZW50LnYxLkFydGlmYWN0VXBsb2FkSW5zdHJ1Y3Rpb24i1wIKGUFydGlmYWN0VXBsb2FkSW5zdHJ1Y3Rpb24SFQoNYWJzb2x1dGVfcGF0aBgBIAEoCRISCgp1cGxvYWRfdXJsGAIgASgJEg4KBm1ldGhvZBgDIAEoCRJBCgdoZWFkZXJzGAQgAygLMjAuYWdlbnQudjEuQXJ0aWZhY3RVcGxvYWRJbnN0cnVjdGlvbi5IZWFkZXJzRW50cnkSGQoMY29udGVudF90eXBlGAUgASgJSACIAQESHQoQc2xhY2tfdXBsb2FkX3VybBgGIAEoCUgBiAEBEhoKDXNsYWNrX2ZpbGVfaWQYByABKAlIAogBARouCgxIZWFkZXJzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4AUIPCg1fY29udGVudF90eXBlQhMKEV9zbGFja191cGxvYWRfdXJsQhAKDl9zbGFja19maWxlX2lkIoQBChxBcnRpZmFjdFVwbG9hZERpc3BhdGNoUmVzdWx0EhUKDWFic29sdXRlX3BhdGgYASABKAkSDgoGc3RhdHVzGAIgASgFEg8KB21lc3NhZ2UYAyABKAkSGgoNc2xhY2tfZmlsZV9pZBgEIAEoCUgAiAEBQhAKDl9zbGFja19maWxlX2lkIlIKF1VwbG9hZEFydGlmYWN0c1Jlc3BvbnNlEjcKB3Jlc3VsdHMYASADKAsyJi5hZ2VudC52MS5BcnRpZmFjdFVwbG9hZERpc3BhdGNoUmVzdWx0IhwKGkdldE1jcFJlZnJlc2hUb2tlbnNSZXF1ZXN0IqUBChtHZXRNY3BSZWZyZXNoVG9rZW5zUmVzcG9uc2USUAoOcmVmcmVzaF90b2tlbnMYASADKAsyOC5hZ2VudC52MS5HZXRNY3BSZWZyZXNoVG9rZW5zUmVzcG9uc2UuUmVmcmVzaFRva2Vuc0VudHJ5GjQKElJlZnJlc2hUb2tlbnNFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIqMBCiFVcGRhdGVFbnZpcm9ubWVudFZhcmlhYmxlc1JlcXVlc3QSQQoDZW52GAEgAygLMjQuYWdlbnQudjEuVXBkYXRlRW52aXJvbm1lbnRWYXJpYWJsZXNSZXF1ZXN0LkVudkVudHJ5Eg8KB3JlcGxhY2UYAiABKAgaKgoIRW52RW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASJGCiJVcGRhdGVFbnZpcm9ubWVudFZhcmlhYmxlc1Jlc3BvbnNlEg8KB2FwcGxpZWQYASABKA0SDwoHcmVtb3ZlZBgCIAEoDSKDAQoSTWNwT0F1dGhTdG9yZWREYXRhEhUKDXJlZnJlc2hfdG9rZW4YASABKAkSEQoJY2xpZW50X2lkGAIgASgJEhoKDWNsaWVudF9zZWNyZXQYAyABKAlIAIgBARIVCg1yZWRpcmVjdF91cmlzGAQgAygJQhAKDl9jbGllbnRfc2VjcmV0Ik4KBUZyYW1lEgoKAmlkGAEgASgJEg4KBm1ldGhvZBgCIAEoCRIMCgRkYXRhGAMgASgMEgwKBGtpbmQYBCABKAUSDQoFZXJyb3IYBSABKAkiBwoFRW1wdHkiIwoNQmlkaVJlcXVlc3RJZBISCgpyZXF1ZXN0X2lkGAEgASgJKogBCh1BcHBsaWVkQWdlbnRDaGFuZ2VfQ2hhbmdlVHlwZRIbChdDSEFOR0VfVFlQRV9VTlNQRUNJRklFRBAAEhcKE0NIQU5HRV9UWVBFX0NSRUFURUQQARIYChRDSEFOR0VfVFlQRV9NT0RJRklFRBACEhcKE0NIQU5HRV9UWVBFX0RFTEVURUQQAyqkAQoLTW91c2VCdXR0b24SHAoYTU9VU0VfQlVUVE9OX1VOU1BFQ0lGSUVEEAASFQoRTU9VU0VfQlVUVE9OX0xFRlQQARIWChJNT1VTRV9CVVRUT05fUklHSFQQAhIXChNNT1VTRV9CVVRUT05fTUlERExFEAMSFQoRTU9VU0VfQlVUVE9OX0JBQ0sQBBIYChRNT1VTRV9CVVRUT05fRk9SV0FSRBAFKp4BCg9TY3JvbGxEaXJlY3Rpb24SIAocU0NST0xMX0RJUkVDVElPTl9VTlNQRUNJRklFRBAAEhcKE1NDUk9MTF9ESVJFQ1RJT05fVVAQARIZChVTQ1JPTExfRElSRUNUSU9OX0RPV04QAhIZChVTQ1JPTExfRElSRUNUSU9OX0xFRlQQAxIaChZTQ1JPTExfRElSRUNUSU9OX1JJR0hUEAQqcAoQQ3Vyc29yUnVsZVNvdXJjZRIiCh5DVVJTT1JfUlVMRV9TT1VSQ0VfVU5TUEVDSUZJRUQQABIbChdDVVJTT1JfUlVMRV9TT1VSQ0VfVEVBTRABEhsKF0NVUlNPUl9SVUxFX1NPVVJDRV9VU0VSEAIqvAEKEkRpYWdub3N0aWNTZXZlcml0eRIjCh9ESUFHTk9TVElDX1NFVkVSSVRZX1VOU1BFQ0lGSUVEEAASHQoZRElBR05PU1RJQ19TRVZFUklUWV9FUlJPUhABEh8KG0RJQUdOT1NUSUNfU0VWRVJJVFlfV0FSTklORxACEiMKH0RJQUdOT1NUSUNfU0VWRVJJVFlfSU5GT1JNQVRJT04QAxIcChhESUFHTk9TVElDX1NFVkVSSVRZX0hJTlQQBCqcAQoNUmVjb3JkaW5nTW9kZRIeChpSRUNPUkRJTkdfTU9ERV9VTlNQRUNJRklFRBAAEiIKHlJFQ09SRElOR19NT0RFX1NUQVJUX1JFQ09SRElORxABEiEKHVJFQ09SRElOR19NT0RFX1NBVkVfUkVDT1JESU5HEAISJAogUkVDT1JESU5HX01PREVfRElTQ0FSRF9SRUNPUkRJTkcQAyqTAQofUmVxdWVzdGVkRmlsZVBhdGhSZWplY3RlZFJlYXNvbhIzCi9SRVFVRVNURURfRklMRV9QQVRIX1JFSkVDVEVEX1JFQVNPTl9VTlNQRUNJRklFRBAAEjsKN1JFUVVFU1RFRF9GSUxFX1BBVEhfUkVKRUNURURfUkVBU09OX1NMQVNIRVNfTk9UX0FMTE9XRUQQASqtAQoLUGFja2FnZVR5cGUSHAoYUEFDS0FHRV9UWVBFX1VOU1BFQ0lGSUVEEAASHwobUEFDS0FHRV9UWVBFX0NVUlNPUl9QUk9KRUNUEAESIAocUEFDS0FHRV9UWVBFX0NVUlNPUl9QRVJTT05BTBACEh0KGVBBQ0tBR0VfVFlQRV9DTEFVREVfU0tJTEwQAxIeChpQQUNLQUdFX1RZUEVfQ0xBVURFX1BMVUdJThAEKn0KElNhbmRib3hQb2xpY3lfVHlwZRIUChBUWVBFX1VOU1BFQ0lGSUVEEAASFgoSVFlQRV9JTlNFQ1VSRV9OT05FEAESHAoYVFlQRV9XT1JLU1BBQ0VfUkVBRFdSSVRFEAISGwoXVFlQRV9XT1JLU1BBQ0VfUkVBRE9OTFkQAypxCg9UaW1lb3V0QmVoYXZpb3ISIAocVElNRU9VVF9CRUhBVklPUl9VTlNQRUNJRklFRBAAEhsKF1RJTUVPVVRfQkVIQVZJT1JfQ0FOQ0VMEAESHwobVElNRU9VVF9CRUhBVklPUl9CQUNLR1JPVU5EEAIqeQoQU2hlbGxBYm9ydFJlYXNvbhIiCh5TSEVMTF9BQk9SVF9SRUFTT05fVU5TUEVDSUZJRUQQABIhCh1TSEVMTF9BQk9SVF9SRUFTT05fVVNFUl9BQk9SVBABEh4KGlNIRUxMX0FCT1JUX1JFQVNPTl9USU1FT1VUEAIqqgEKHEN1c3RvbVN1YmFnZW50UGVybWlzc2lvbk1vZGUSLworQ1VTVE9NX1NVQkFHRU5UX1BFUk1JU1NJT05fTU9ERV9VTlNQRUNJRklFRBAAEisKJ0NVU1RPTV9TVUJBR0VOVF9QRVJNSVNTSU9OX01PREVfREVGQVVMVBABEiwKKENVU1RPTV9TVUJBR0VOVF9QRVJNSVNTSU9OX01PREVfUkVBRE9OTFkQAiqVAQoKVG9kb1N0YXR1cxIbChdUT0RPX1NUQVRVU19VTlNQRUNJRklFRBAAEhcKE1RPRE9fU1RBVFVTX1BFTkRJTkcQARIbChdUT0RPX1NUQVRVU19JTl9QUk9HUkVTUxACEhkKFVRPRE9fU1RBVFVTX0NPTVBMRVRFRBADEhkKFVRPRE9fU1RBVFVTX0NBTkNFTExFRBAEKmYKCENsaWVudE9TEhkKFUNMSUVOVF9PU19VTlNQRUNJRklFRBAAEhUKEUNMSUVOVF9PU19XSU5ET1dTEAESEwoPQ0xJRU5UX09TX01BQ09TEAISEwoPQ0xJRU5UX09TX0xJTlVYEAMq7AEKHEFydGlmYWN0VXBsb2FkRGlzcGF0Y2hTdGF0dXMSLworQVJUSUZBQ1RfVVBMT0FEX0RJU1BBVENIX1NUQVRVU19VTlNQRUNJRklFRBAAEiwKKEFSVElGQUNUX1VQTE9BRF9ESVNQQVRDSF9TVEFUVVNfQUNDRVBURUQQARIsCihBUlRJRkFDVF9VUExPQURfRElTUEFUQ0hfU1RBVFVTX1JFSkVDVEVEEAISPwo7QVJUSUZBQ1RfVVBMT0FEX0RJU1BBVENIX1NUQVRVU19TS0lQUEVEX0FMUkVBRFlfSU5fUFJPR1JFU1MQAypXCgpGcmFtZV9LaW5kEhQKEEtJTkRfVU5TUEVDSUZJRUQQABIQCgxLSU5EX1JFUVVFU1QQARIRCg1LSU5EX1JFU1BPTlNFEAISDgoKS0lORF9FUlJPUhADKrACChdCdWdib3REZWVwbGlua0V2ZW50S2luZBIqCiZCVUdCT1RfREVFUExJTktfRVZFTlRfS0lORF9VTlNQRUNJRklFRBAAEiYKIkJVR0JPVF9ERUVQTElOS19FVkVOVF9LSU5EX0NMSUNLRUQQARIzCi9CVUdCT1RfREVFUExJTktfRVZFTlRfS0lORF9IQU5ETEVEX0RJQUxPR19TSE9XThACEjMKL0JVR0JPVF9ERUVQTElOS19FVkVOVF9LSU5EX0hBTkRMRURfQ0hBVF9DUkVBVEVEEAMSJAogQlVHQk9UX0RFRVBMSU5LX0VWRU5UX0tJTkRfRVJST1IQBBIxCi1CVUdCT1RfREVFUExJTktfRVZFTlRfS0lORF9IQU5ETEVEX0ZJWF9JTl9XRUIQBTKHBAoMQWdlbnRTZXJ2aWNlEkEKA1J1bhIcLmFnZW50LnYxLkFnZW50Q2xpZW50TWVzc2FnZRocLmFnZW50LnYxLkFnZW50U2VydmVyTWVzc2FnZRI/CgZSdW5TU0USFy5hZ2VudC52MS5CaWRpUmVxdWVzdElkGhwuYWdlbnQudjEuQWdlbnRTZXJ2ZXJNZXNzYWdlEkQKCU5hbWVBZ2VudBIaLmFnZW50LnYxLk5hbWVBZ2VudFJlcXVlc3QaGy5hZ2VudC52MS5OYW1lQWdlbnRSZXNwb25zZRJWCg9HZXRVc2FibGVNb2RlbHMSIC5hZ2VudC52MS5HZXRVc2FibGVNb2RlbHNSZXF1ZXN0GiEuYWdlbnQudjEuR2V0VXNhYmxlTW9kZWxzUmVzcG9uc2USaAoVR2V0RGVmYXVsdE1vZGVsRm9yQ2xpEiYuYWdlbnQudjEuR2V0RGVmYXVsdE1vZGVsRm9yQ2xpUmVxdWVzdBonLmFnZW50LnYxLkdldERlZmF1bHRNb2RlbEZvckNsaVJlc3BvbnNlEmsKFkdldEFsbG93ZWRNb2RlbEludGVudHMSJy5hZ2VudC52MS5HZXRBbGxvd2VkTW9kZWxJbnRlbnRzUmVxdWVzdBooLmFnZW50LnYxLkdldEFsbG93ZWRNb2RlbEludGVudHNSZXNwb25zZTK1CAoOQ29udHJvbFNlcnZpY2USTQoMUmVhZFRleHRGaWxlEh0uYWdlbnQudjEuUmVhZFRleHRGaWxlUmVxdWVzdBoeLmFnZW50LnYxLlJlYWRUZXh0RmlsZVJlc3BvbnNlElAKDVdyaXRlVGV4dEZpbGUSHi5hZ2VudC52MS5Xcml0ZVRleHRGaWxlUmVxdWVzdBofLmFnZW50LnYxLldyaXRlVGV4dEZpbGVSZXNwb25zZRJTCg5SZWFkQmluYXJ5RmlsZRIfLmFnZW50LnYxLlJlYWRCaW5hcnlGaWxlUmVxdWVzdBogLmFnZW50LnYxLlJlYWRCaW5hcnlGaWxlUmVzcG9uc2USVgoPV3JpdGVCaW5hcnlGaWxlEiAuYWdlbnQudjEuV3JpdGVCaW5hcnlGaWxlUmVxdWVzdBohLmFnZW50LnYxLldyaXRlQmluYXJ5RmlsZVJlc3BvbnNlEm4KF0dldFdvcmtzcGFjZUNoYW5nZXNIYXNoEiguYWdlbnQudjEuR2V0V29ya3NwYWNlQ2hhbmdlc0hhc2hSZXF1ZXN0GikuYWdlbnQudjEuR2V0V29ya3NwYWNlQ2hhbmdlc0hhc2hSZXNwb25zZRJxChhSZWZyZXNoR2l0aHViQWNjZXNzVG9rZW4SKS5hZ2VudC52MS5SZWZyZXNoR2l0aHViQWNjZXNzVG9rZW5SZXF1ZXN0GiouYWdlbnQudjEuUmVmcmVzaEdpdGh1YkFjY2Vzc1Rva2VuUmVzcG9uc2USawoWV2FybVJlbW90ZUFjY2Vzc1NlcnZlchInLmFnZW50LnYxLldhcm1SZW1vdGVBY2Nlc3NTZXJ2ZXJSZXF1ZXN0GiguYWdlbnQudjEuV2FybVJlbW90ZUFjY2Vzc1NlcnZlclJlc3BvbnNlElAKDUxpc3RBcnRpZmFjdHMSHi5hZ2VudC52MS5MaXN0QXJ0aWZhY3RzUmVxdWVzdBofLmFnZW50LnYxLkxpc3RBcnRpZmFjdHNSZXNwb25zZRJWCg9VcGxvYWRBcnRpZmFjdHMSIC5hZ2VudC52MS5VcGxvYWRBcnRpZmFjdHNSZXF1ZXN0GiEuYWdlbnQudjEuVXBsb2FkQXJ0aWZhY3RzUmVzcG9uc2USYgoTR2V0TWNwUmVmcmVzaFRva2VucxIkLmFnZW50LnYxLkdldE1jcFJlZnJlc2hUb2tlbnNSZXF1ZXN0GiUuYWdlbnQudjEuR2V0TWNwUmVmcmVzaFRva2Vuc1Jlc3BvbnNlEncKGlVwZGF0ZUVudmlyb25tZW50VmFyaWFibGVzEisuYWdlbnQudjEuVXBkYXRlRW52aXJvbm1lbnRWYXJpYWJsZXNSZXF1ZXN0GiwuYWdlbnQudjEuVXBkYXRlRW52aXJvbm1lbnRWYXJpYWJsZXNSZXNwb25zZTINCgtFeGVjU2VydmljZTJRCiJQcml2YXRlV29ya2VyQnJpZGdlRXh0ZXJuYWxTZXJ2aWNlEisKB0Nvbm5lY3QSDy5hZ2VudC52MS5GcmFtZRoPLmFnZW50LnYxLkZyYW1lMngKEExpZmVjeWNsZVNlcnZpY2USMQoNUmVzZXRJbnN0YW5jZRIPLmFnZW50LnYxLkVtcHR5Gg8uYWdlbnQudjEuRW1wdHkSMQoNUmVuZXdJbnN0YW5jZRIPLmFnZW50LnYxLkVtcHR5Gg8uYWdlbnQudjEuRW1wdHliBnByb3RvMw", + ); + +/** + * @generated from message agent.v1.GlobToolResult + */ +export type GlobToolResult = Message<"agent.v1.GlobToolResult"> & { + /** + * @generated from oneof agent.v1.GlobToolResult.result + */ + result: + | { + /** + * @generated from field: agent.v1.GlobToolSuccess success = 1; + */ + value: GlobToolSuccess; + case: "success"; + } + | { + /** + * @generated from field: agent.v1.GlobToolError error = 2; + */ + value: GlobToolError; + case: "error"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.GlobToolResult. + * Use `create(GlobToolResultSchema)` to create a new message. + */ +export const GlobToolResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 0); + +/** + * @generated from message agent.v1.GlobToolError + */ +export type GlobToolError = Message<"agent.v1.GlobToolError"> & { + /** + * @generated from field: string error = 1; + */ + error: string; +}; + +/** + * Describes the message agent.v1.GlobToolError. + * Use `create(GlobToolErrorSchema)` to create a new message. + */ +export const GlobToolErrorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 1); + +/** + * Only file results are needed for this tool + * + * @generated from message agent.v1.GlobToolSuccess + */ +export type GlobToolSuccess = Message<"agent.v1.GlobToolSuccess"> & { + /** + * @generated from field: string pattern = 1; + */ + pattern: string; + + /** + * @generated from field: string path = 2; + */ + path: string; + + /** + * @generated from field: repeated string files = 3; + */ + files: string[]; + + /** + * @generated from field: int32 total_files = 4; + */ + totalFiles: number; + + /** + * @generated from field: bool client_truncated = 5; + */ + clientTruncated: boolean; + + /** + * @generated from field: bool ripgrep_truncated = 6; + */ + ripgrepTruncated: boolean; +}; + +/** + * Describes the message agent.v1.GlobToolSuccess. + * Use `create(GlobToolSuccessSchema)` to create a new message. + */ +export const GlobToolSuccessSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 2); + +/** + * @generated from message agent.v1.GlobToolCall + */ +export type GlobToolCall = Message<"agent.v1.GlobToolCall"> & { + /** + * @generated from field: bytes args = 1; + */ + args: Uint8Array; + + /** + * @generated from field: agent.v1.GlobToolResult result = 2; + */ + result?: GlobToolResult; +}; + +/** + * Describes the message agent.v1.GlobToolCall. + * Use `create(GlobToolCallSchema)` to create a new message. + */ +export const GlobToolCallSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 3); + +/** + * @generated from message agent.v1.ReadLintsToolCall + */ +export type ReadLintsToolCall = Message<"agent.v1.ReadLintsToolCall"> & { + /** + * @generated from field: agent.v1.ReadLintsToolArgs args = 1; + */ + args?: ReadLintsToolArgs; + + /** + * @generated from field: agent.v1.ReadLintsToolResult result = 2; + */ + result?: ReadLintsToolResult; +}; + +/** + * Describes the message agent.v1.ReadLintsToolCall. + * Use `create(ReadLintsToolCallSchema)` to create a new message. + */ +export const ReadLintsToolCallSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 4); + +/** + * @generated from message agent.v1.ReadLintsToolArgs + */ +export type ReadLintsToolArgs = Message<"agent.v1.ReadLintsToolArgs"> & { + /** + * @generated from field: repeated string paths = 1; + */ + paths: string[]; +}; + +/** + * Describes the message agent.v1.ReadLintsToolArgs. + * Use `create(ReadLintsToolArgsSchema)` to create a new message. + */ +export const ReadLintsToolArgsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 5); + +/** + * @generated from message agent.v1.ReadLintsToolResult + */ +export type ReadLintsToolResult = Message<"agent.v1.ReadLintsToolResult"> & { + /** + * @generated from oneof agent.v1.ReadLintsToolResult.result + */ + result: + | { + /** + * @generated from field: agent.v1.ReadLintsToolSuccess success = 1; + */ + value: ReadLintsToolSuccess; + case: "success"; + } + | { + /** + * @generated from field: agent.v1.ReadLintsToolError error = 2; + */ + value: ReadLintsToolError; + case: "error"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.ReadLintsToolResult. + * Use `create(ReadLintsToolResultSchema)` to create a new message. + */ +export const ReadLintsToolResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 6); + +/** + * @generated from message agent.v1.ReadLintsToolSuccess + */ +export type ReadLintsToolSuccess = Message<"agent.v1.ReadLintsToolSuccess"> & { + /** + * @generated from field: repeated agent.v1.FileDiagnostics file_diagnostics = 1; + */ + fileDiagnostics: FileDiagnostics[]; + + /** + * @generated from field: int32 total_files = 2; + */ + totalFiles: number; + + /** + * @generated from field: int32 total_diagnostics = 3; + */ + totalDiagnostics: number; +}; + +/** + * Describes the message agent.v1.ReadLintsToolSuccess. + * Use `create(ReadLintsToolSuccessSchema)` to create a new message. + */ +export const ReadLintsToolSuccessSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 7); + +/** + * @generated from message agent.v1.FileDiagnostics + */ +export type FileDiagnostics = Message<"agent.v1.FileDiagnostics"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: repeated agent.v1.DiagnosticItem diagnostics = 2; + */ + diagnostics: DiagnosticItem[]; + + /** + * @generated from field: int32 diagnostics_count = 3; + */ + diagnosticsCount: number; +}; + +/** + * Describes the message agent.v1.FileDiagnostics. + * Use `create(FileDiagnosticsSchema)` to create a new message. + */ +export const FileDiagnosticsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 8); + +/** + * @generated from message agent.v1.DiagnosticItem + */ +export type DiagnosticItem = Message<"agent.v1.DiagnosticItem"> & { + /** + * @generated from field: agent.v1.DiagnosticSeverity severity = 1; + */ + severity: DiagnosticSeverity; + + /** + * @generated from field: agent.v1.DiagnosticRange range = 2; + */ + range?: DiagnosticRange; + + /** + * @generated from field: string message = 3; + */ + message: string; + + /** + * @generated from field: string source = 4; + */ + source: string; + + /** + * @generated from field: string code = 5; + */ + code: string; + + /** + * @generated from field: bool is_stale = 6; + */ + isStale: boolean; +}; + +/** + * Describes the message agent.v1.DiagnosticItem. + * Use `create(DiagnosticItemSchema)` to create a new message. + */ +export const DiagnosticItemSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 9); + +/** + * @generated from message agent.v1.DiagnosticRange + */ +export type DiagnosticRange = Message<"agent.v1.DiagnosticRange"> & { + /** + * @generated from field: agent.v1.Position start = 1; + */ + start?: Position; + + /** + * @generated from field: agent.v1.Position end = 2; + */ + end?: Position; +}; + +/** + * Describes the message agent.v1.DiagnosticRange. + * Use `create(DiagnosticRangeSchema)` to create a new message. + */ +export const DiagnosticRangeSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 10); + +/** + * @generated from message agent.v1.ReadLintsToolError + */ +export type ReadLintsToolError = Message<"agent.v1.ReadLintsToolError"> & { + /** + * @generated from field: string error_message = 1; + */ + errorMessage: string; +}; + +/** + * Describes the message agent.v1.ReadLintsToolError. + * Use `create(ReadLintsToolErrorSchema)` to create a new message. + */ +export const ReadLintsToolErrorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 11); + +/** + * @generated from message agent.v1.McpToolError + */ +export type McpToolError = Message<"agent.v1.McpToolError"> & { + /** + * @generated from field: string error = 1; + */ + error: string; +}; + +/** + * Describes the message agent.v1.McpToolError. + * Use `create(McpToolErrorSchema)` to create a new message. + */ +export const McpToolErrorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 12); + +/** + * Result for MCP tool calls (separate from exec results) + * + * @generated from message agent.v1.McpToolResult + */ +export type McpToolResult = Message<"agent.v1.McpToolResult"> & { + /** + * @generated from oneof agent.v1.McpToolResult.result + */ + result: + | { + /** + * @generated from field: agent.v1.McpSuccess success = 1; + */ + value: McpSuccess; + case: "success"; + } + | { + /** + * @generated from field: agent.v1.McpToolError error = 2; + */ + value: McpToolError; + case: "error"; + } + | { + /** + * @generated from field: agent.v1.McpRejected rejected = 3; + */ + value: McpRejected; + case: "rejected"; + } + | { + /** + * @generated from field: agent.v1.McpPermissionDenied permission_denied = 4; + */ + value: McpPermissionDenied; + case: "permissionDenied"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.McpToolResult. + * Use `create(McpToolResultSchema)` to create a new message. + */ +export const McpToolResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 13); + +/** + * @generated from message agent.v1.McpToolCall + */ +export type McpToolCall = Message<"agent.v1.McpToolCall"> & { + /** + * @generated from field: agent.v1.McpArgs args = 1; + */ + args?: McpArgs; + + /** + * @generated from field: agent.v1.McpToolResult result = 2; + */ + result?: McpToolResult; +}; + +/** + * Describes the message agent.v1.McpToolCall. + * Use `create(McpToolCallSchema)` to create a new message. + */ +export const McpToolCallSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 14); + +/** + * @generated from message agent.v1.SemSearchToolCall + */ +export type SemSearchToolCall = Message<"agent.v1.SemSearchToolCall"> & { + /** + * @generated from field: agent.v1.SemSearchToolArgs args = 1; + */ + args?: SemSearchToolArgs; + + /** + * @generated from field: agent.v1.SemSearchToolResult result = 2; + */ + result?: SemSearchToolResult; +}; + +/** + * Describes the message agent.v1.SemSearchToolCall. + * Use `create(SemSearchToolCallSchema)` to create a new message. + */ +export const SemSearchToolCallSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 15); + +/** + * @generated from message agent.v1.SemSearchToolArgs + */ +export type SemSearchToolArgs = Message<"agent.v1.SemSearchToolArgs"> & { + /** + * @generated from field: string query = 1; + */ + query: string; + + /** + * @generated from field: repeated string target_directories = 2; + */ + targetDirectories: string[]; + + /** + * @generated from field: string explanation = 3; + */ + explanation: string; +}; + +/** + * Describes the message agent.v1.SemSearchToolArgs. + * Use `create(SemSearchToolArgsSchema)` to create a new message. + */ +export const SemSearchToolArgsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 16); + +/** + * @generated from message agent.v1.SemSearchToolResult + */ +export type SemSearchToolResult = Message<"agent.v1.SemSearchToolResult"> & { + /** + * @generated from oneof agent.v1.SemSearchToolResult.result + */ + result: + | { + /** + * @generated from field: agent.v1.SemSearchToolSuccess success = 1; + */ + value: SemSearchToolSuccess; + case: "success"; + } + | { + /** + * @generated from field: agent.v1.SemSearchToolError error = 2; + */ + value: SemSearchToolError; + case: "error"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.SemSearchToolResult. + * Use `create(SemSearchToolResultSchema)` to create a new message. + */ +export const SemSearchToolResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 17); + +/** + * @generated from message agent.v1.SemSearchToolSuccess + */ +export type SemSearchToolSuccess = Message<"agent.v1.SemSearchToolSuccess"> & { + /** + * @generated from field: string results = 1; + */ + results: string; + + /** + * @generated from field: repeated bytes code_results = 2; + */ + codeResults: Uint8Array[]; +}; + +/** + * Describes the message agent.v1.SemSearchToolSuccess. + * Use `create(SemSearchToolSuccessSchema)` to create a new message. + */ +export const SemSearchToolSuccessSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 18); + +/** + * @generated from message agent.v1.SemSearchToolError + */ +export type SemSearchToolError = Message<"agent.v1.SemSearchToolError"> & { + /** + * @generated from field: string error_message = 1; + */ + errorMessage: string; +}; + +/** + * Describes the message agent.v1.SemSearchToolError. + * Use `create(SemSearchToolErrorSchema)` to create a new message. + */ +export const SemSearchToolErrorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 19); + +/** + * @generated from message agent.v1.ListMcpResourcesToolCall + */ +export type ListMcpResourcesToolCall = Message<"agent.v1.ListMcpResourcesToolCall"> & { + /** + * @generated from field: agent.v1.ListMcpResourcesExecArgs args = 1; + */ + args?: ListMcpResourcesExecArgs; + + /** + * @generated from field: agent.v1.ListMcpResourcesExecResult result = 2; + */ + result?: ListMcpResourcesExecResult; +}; + +/** + * Describes the message agent.v1.ListMcpResourcesToolCall. + * Use `create(ListMcpResourcesToolCallSchema)` to create a new message. + */ +export const ListMcpResourcesToolCallSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 20); + +/** + * @generated from message agent.v1.ReadMcpResourceToolCall + */ +export type ReadMcpResourceToolCall = Message<"agent.v1.ReadMcpResourceToolCall"> & { + /** + * @generated from field: agent.v1.ReadMcpResourceExecArgs args = 1; + */ + args?: ReadMcpResourceExecArgs; + + /** + * @generated from field: agent.v1.ReadMcpResourceExecResult result = 2; + */ + result?: ReadMcpResourceExecResult; +}; + +/** + * Describes the message agent.v1.ReadMcpResourceToolCall. + * Use `create(ReadMcpResourceToolCallSchema)` to create a new message. + */ +export const ReadMcpResourceToolCallSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 21); + +/** + * @generated from message agent.v1.FetchToolCall + */ +export type FetchToolCall = Message<"agent.v1.FetchToolCall"> & { + /** + * @generated from field: agent.v1.FetchArgs args = 1; + */ + args?: FetchArgs; + + /** + * @generated from field: agent.v1.FetchResult result = 2; + */ + result?: FetchResult; +}; + +/** + * Describes the message agent.v1.FetchToolCall. + * Use `create(FetchToolCallSchema)` to create a new message. + */ +export const FetchToolCallSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 22); + +/** + * @generated from message agent.v1.RecordScreenToolCall + */ +export type RecordScreenToolCall = Message<"agent.v1.RecordScreenToolCall"> & { + /** + * @generated from field: agent.v1.RecordScreenArgs args = 1; + */ + args?: RecordScreenArgs; + + /** + * @generated from field: agent.v1.RecordScreenResult result = 2; + */ + result?: RecordScreenResult; +}; + +/** + * Describes the message agent.v1.RecordScreenToolCall. + * Use `create(RecordScreenToolCallSchema)` to create a new message. + */ +export const RecordScreenToolCallSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 23); + +/** + * @generated from message agent.v1.WriteShellStdinToolCall + */ +export type WriteShellStdinToolCall = Message<"agent.v1.WriteShellStdinToolCall"> & { + /** + * @generated from field: agent.v1.WriteShellStdinArgs args = 1; + */ + args?: WriteShellStdinArgs; + + /** + * @generated from field: agent.v1.WriteShellStdinResult result = 2; + */ + result?: WriteShellStdinResult; +}; + +/** + * Describes the message agent.v1.WriteShellStdinToolCall. + * Use `create(WriteShellStdinToolCallSchema)` to create a new message. + */ +export const WriteShellStdinToolCallSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 24); + +/** + * @generated from message agent.v1.ReflectArgs + */ +export type ReflectArgs = Message<"agent.v1.ReflectArgs"> & { + /** + * @generated from field: string unexpected_action_outcomes = 1; + */ + unexpectedActionOutcomes: string; + + /** + * @generated from field: string relevant_instructions = 2; + */ + relevantInstructions: string; + + /** + * @generated from field: string scenario_analysis = 3; + */ + scenarioAnalysis: string; + + /** + * @generated from field: string critical_synthesis = 4; + */ + criticalSynthesis: string; + + /** + * @generated from field: string next_steps = 5; + */ + nextSteps: string; + + /** + * @generated from field: string tool_call_id = 6; + */ + toolCallId: string; +}; + +/** + * Describes the message agent.v1.ReflectArgs. + * Use `create(ReflectArgsSchema)` to create a new message. + */ +export const ReflectArgsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 25); + +/** + * @generated from message agent.v1.ReflectResult + */ +export type ReflectResult = Message<"agent.v1.ReflectResult"> & { + /** + * @generated from oneof agent.v1.ReflectResult.result + */ + result: + | { + /** + * @generated from field: agent.v1.ReflectSuccess success = 1; + */ + value: ReflectSuccess; + case: "success"; + } + | { + /** + * @generated from field: agent.v1.ReflectError error = 2; + */ + value: ReflectError; + case: "error"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.ReflectResult. + * Use `create(ReflectResultSchema)` to create a new message. + */ +export const ReflectResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 26); + +/** + * @generated from message agent.v1.ReflectSuccess + */ +export type ReflectSuccess = Message<"agent.v1.ReflectSuccess"> & {}; + +/** + * Describes the message agent.v1.ReflectSuccess. + * Use `create(ReflectSuccessSchema)` to create a new message. + */ +export const ReflectSuccessSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 27); + +/** + * @generated from message agent.v1.ReflectError + */ +export type ReflectError = Message<"agent.v1.ReflectError"> & { + /** + * @generated from field: string error = 1; + */ + error: string; +}; + +/** + * Describes the message agent.v1.ReflectError. + * Use `create(ReflectErrorSchema)` to create a new message. + */ +export const ReflectErrorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 28); + +/** + * @generated from message agent.v1.ReflectToolCall + */ +export type ReflectToolCall = Message<"agent.v1.ReflectToolCall"> & { + /** + * @generated from field: agent.v1.ReflectArgs args = 1; + */ + args?: ReflectArgs; + + /** + * @generated from field: agent.v1.ReflectResult result = 2; + */ + result?: ReflectResult; +}; + +/** + * Describes the message agent.v1.ReflectToolCall. + * Use `create(ReflectToolCallSchema)` to create a new message. + */ +export const ReflectToolCallSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 29); + +/** + * @generated from message agent.v1.StartGrindExecutionArgs + */ +export type StartGrindExecutionArgs = Message<"agent.v1.StartGrindExecutionArgs"> & { + /** + * Optional explanation for why the agent is requesting to begin executing. + * + * @generated from field: optional string explanation = 1; + */ + explanation?: string; + + /** + * @generated from field: string tool_call_id = 2; + */ + toolCallId: string; +}; + +/** + * Describes the message agent.v1.StartGrindExecutionArgs. + * Use `create(StartGrindExecutionArgsSchema)` to create a new message. + */ +export const StartGrindExecutionArgsSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 30); + +/** + * @generated from message agent.v1.StartGrindExecutionResult + */ +export type StartGrindExecutionResult = Message<"agent.v1.StartGrindExecutionResult"> & { + /** + * @generated from oneof agent.v1.StartGrindExecutionResult.result + */ + result: + | { + /** + * @generated from field: agent.v1.StartGrindExecutionSuccess success = 1; + */ + value: StartGrindExecutionSuccess; + case: "success"; + } + | { + /** + * @generated from field: agent.v1.StartGrindExecutionError error = 2; + */ + value: StartGrindExecutionError; + case: "error"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.StartGrindExecutionResult. + * Use `create(StartGrindExecutionResultSchema)` to create a new message. + */ +export const StartGrindExecutionResultSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 31); + +/** + * @generated from message agent.v1.StartGrindExecutionSuccess + */ +export type StartGrindExecutionSuccess = Message<"agent.v1.StartGrindExecutionSuccess"> & {}; + +/** + * Describes the message agent.v1.StartGrindExecutionSuccess. + * Use `create(StartGrindExecutionSuccessSchema)` to create a new message. + */ +export const StartGrindExecutionSuccessSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 32); + +/** + * @generated from message agent.v1.StartGrindExecutionError + */ +export type StartGrindExecutionError = Message<"agent.v1.StartGrindExecutionError"> & { + /** + * @generated from field: string error = 1; + */ + error: string; +}; + +/** + * Describes the message agent.v1.StartGrindExecutionError. + * Use `create(StartGrindExecutionErrorSchema)` to create a new message. + */ +export const StartGrindExecutionErrorSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 33); + +/** + * @generated from message agent.v1.StartGrindExecutionToolCall + */ +export type StartGrindExecutionToolCall = Message<"agent.v1.StartGrindExecutionToolCall"> & { + /** + * @generated from field: agent.v1.StartGrindExecutionArgs args = 1; + */ + args?: StartGrindExecutionArgs; + + /** + * @generated from field: agent.v1.StartGrindExecutionResult result = 2; + */ + result?: StartGrindExecutionResult; +}; + +/** + * Describes the message agent.v1.StartGrindExecutionToolCall. + * Use `create(StartGrindExecutionToolCallSchema)` to create a new message. + */ +export const StartGrindExecutionToolCallSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 34); + +/** + * @generated from message agent.v1.StartGrindPlanningArgs + */ +export type StartGrindPlanningArgs = Message<"agent.v1.StartGrindPlanningArgs"> & { + /** + * Optional explanation for why the agent is requesting to return to planning. + * + * @generated from field: optional string explanation = 1; + */ + explanation?: string; + + /** + * @generated from field: string tool_call_id = 2; + */ + toolCallId: string; +}; + +/** + * Describes the message agent.v1.StartGrindPlanningArgs. + * Use `create(StartGrindPlanningArgsSchema)` to create a new message. + */ +export const StartGrindPlanningArgsSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 35); + +/** + * @generated from message agent.v1.StartGrindPlanningResult + */ +export type StartGrindPlanningResult = Message<"agent.v1.StartGrindPlanningResult"> & { + /** + * @generated from oneof agent.v1.StartGrindPlanningResult.result + */ + result: + | { + /** + * @generated from field: agent.v1.StartGrindPlanningSuccess success = 1; + */ + value: StartGrindPlanningSuccess; + case: "success"; + } + | { + /** + * @generated from field: agent.v1.StartGrindPlanningError error = 2; + */ + value: StartGrindPlanningError; + case: "error"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.StartGrindPlanningResult. + * Use `create(StartGrindPlanningResultSchema)` to create a new message. + */ +export const StartGrindPlanningResultSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 36); + +/** + * @generated from message agent.v1.StartGrindPlanningSuccess + */ +export type StartGrindPlanningSuccess = Message<"agent.v1.StartGrindPlanningSuccess"> & {}; + +/** + * Describes the message agent.v1.StartGrindPlanningSuccess. + * Use `create(StartGrindPlanningSuccessSchema)` to create a new message. + */ +export const StartGrindPlanningSuccessSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 37); + +/** + * @generated from message agent.v1.StartGrindPlanningError + */ +export type StartGrindPlanningError = Message<"agent.v1.StartGrindPlanningError"> & { + /** + * @generated from field: string error = 1; + */ + error: string; +}; + +/** + * Describes the message agent.v1.StartGrindPlanningError. + * Use `create(StartGrindPlanningErrorSchema)` to create a new message. + */ +export const StartGrindPlanningErrorSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 38); + +/** + * @generated from message agent.v1.StartGrindPlanningToolCall + */ +export type StartGrindPlanningToolCall = Message<"agent.v1.StartGrindPlanningToolCall"> & { + /** + * @generated from field: agent.v1.StartGrindPlanningArgs args = 1; + */ + args?: StartGrindPlanningArgs; + + /** + * @generated from field: agent.v1.StartGrindPlanningResult result = 2; + */ + result?: StartGrindPlanningResult; +}; + +/** + * Describes the message agent.v1.StartGrindPlanningToolCall. + * Use `create(StartGrindPlanningToolCallSchema)` to create a new message. + */ +export const StartGrindPlanningToolCallSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 39); + +/** + * var AgentMode; (function (AgentMode) { AgentMode[AgentMode["UNSPECIFIED"] = 0] = "UNSPECIFIED"; AgentMode[AgentMode["AGENT"] = 1] = "AGENT"; AgentMode[AgentMode["ASK"] = 2] = "ASK"; AgentMode[AgentMode["PLAN"] = 3] = "PLAN"; AgentMode[AgentMode["DEBUG"] = 4] = "DEBUG"; AgentMode[AgentMode["TRIAGE"] = 5] = "TRIAGE"; AgentMode[AgentMode["PROJECT"] = 6] = "PROJECT"; })(AgentMode || (AgentMode = {})); // Retrieve enum metadata with: proto3.getEnumType(AgentMode) proto3/* int32 *\/.C.util.setEnumType(AgentMode, "agent.v1.AgentMode", [ { no: 0, name: "AGENT_MODE_UNSPECIFIED" }, { no: 1, name: "AGENT_MODE_AGENT" }, { no: 2, name: "AGENT_MODE_ASK" }, { no: 3, name: "AGENT_MODE_PLAN" }, { no: 4, name: "AGENT_MODE_DEBUG" }, { no: 5, name: "AGENT_MODE_TRIAGE" }, { no: 6, name: "AGENT_MODE_PROJECT" }, ]); + * + * @generated from message agent.v1.TaskArgs + */ +export type TaskArgs = Message<"agent.v1.TaskArgs"> & { + /** + * @generated from field: string description = 1; + */ + description: string; + + /** + * @generated from field: string prompt = 2; + */ + prompt: string; + + /** + * @generated from field: agent.v1.SubagentType subagent_type = 3; + */ + subagentType?: SubagentType; + + /** + * @generated from field: optional string model = 4; + */ + model?: string; + + /** + * @generated from field: optional string resume = 5; + */ + resume?: string; +}; + +/** + * Describes the message agent.v1.TaskArgs. + * Use `create(TaskArgsSchema)` to create a new message. + */ +export const TaskArgsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 40); + +/** + * @generated from message agent.v1.TaskSuccess + */ +export type TaskSuccess = Message<"agent.v1.TaskSuccess"> & { + /** + * @generated from field: repeated agent.v1.ConversationStep conversation_steps = 1; + */ + conversationSteps: ConversationStep[]; + + /** + * @generated from field: optional string agent_id = 2; + */ + agentId?: string; + + /** + * @generated from field: bool is_background = 3; + */ + isBackground: boolean; + + /** + * @generated from field: optional uint64 duration_ms = 4; + */ + durationMs?: bigint; +}; + +/** + * Describes the message agent.v1.TaskSuccess. + * Use `create(TaskSuccessSchema)` to create a new message. + */ +export const TaskSuccessSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 41); + +/** + * @generated from message agent.v1.TaskError + */ +export type TaskError = Message<"agent.v1.TaskError"> & { + /** + * @generated from field: string error = 1; + */ + error: string; +}; + +/** + * Describes the message agent.v1.TaskError. + * Use `create(TaskErrorSchema)` to create a new message. + */ +export const TaskErrorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 42); + +/** + * @generated from message agent.v1.TaskResult + */ +export type TaskResult = Message<"agent.v1.TaskResult"> & { + /** + * @generated from oneof agent.v1.TaskResult.result + */ + result: + | { + /** + * @generated from field: agent.v1.TaskSuccess success = 1; + */ + value: TaskSuccess; + case: "success"; + } + | { + /** + * @generated from field: agent.v1.TaskError error = 2; + */ + value: TaskError; + case: "error"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.TaskResult. + * Use `create(TaskResultSchema)` to create a new message. + */ +export const TaskResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 43); + +/** + * @generated from message agent.v1.TaskToolCall + */ +export type TaskToolCall = Message<"agent.v1.TaskToolCall"> & { + /** + * @generated from field: agent.v1.TaskArgs args = 1; + */ + args?: TaskArgs; + + /** + * @generated from field: agent.v1.TaskResult result = 2; + */ + result?: TaskResult; +}; + +/** + * Describes the message agent.v1.TaskToolCall. + * Use `create(TaskToolCallSchema)` to create a new message. + */ +export const TaskToolCallSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 44); + +/** + * @generated from message agent.v1.TaskToolCallDelta + */ +export type TaskToolCallDelta = Message<"agent.v1.TaskToolCallDelta"> & { + /** + * @generated from field: agent.v1.InteractionUpdate interaction_update = 1; + */ + interactionUpdate?: InteractionUpdate; +}; + +/** + * Describes the message agent.v1.TaskToolCallDelta. + * Use `create(TaskToolCallDeltaSchema)` to create a new message. + */ +export const TaskToolCallDeltaSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 45); + +/** + * Tool messages (from tool.proto) + * + * @generated from message agent.v1.ToolCall + */ +export type ToolCall = Message<"agent.v1.ToolCall"> & { + /** + * @generated from oneof agent.v1.ToolCall.tool + */ + tool: + | { + /** + * @generated from field: agent.v1.ShellToolCall shell_tool_call = 1; + */ + value: ShellToolCall; + case: "shellToolCall"; + } + | { + /** + * @generated from field: agent.v1.DeleteToolCall delete_tool_call = 3; + */ + value: DeleteToolCall; + case: "deleteToolCall"; + } + | { + /** + * @generated from field: agent.v1.GlobToolCall glob_tool_call = 4; + */ + value: GlobToolCall; + case: "globToolCall"; + } + | { + /** + * @generated from field: agent.v1.GrepToolCall grep_tool_call = 5; + */ + value: GrepToolCall; + case: "grepToolCall"; + } + | { + /** + * @generated from field: agent.v1.ReadToolCall read_tool_call = 8; + */ + value: ReadToolCall; + case: "readToolCall"; + } + | { + /** + * @generated from field: agent.v1.UpdateTodosToolCall update_todos_tool_call = 9; + */ + value: UpdateTodosToolCall; + case: "updateTodosToolCall"; + } + | { + /** + * @generated from field: agent.v1.ReadTodosToolCall read_todos_tool_call = 10; + */ + value: ReadTodosToolCall; + case: "readTodosToolCall"; + } + | { + /** + * @generated from field: agent.v1.EditToolCall edit_tool_call = 12; + */ + value: EditToolCall; + case: "editToolCall"; + } + | { + /** + * @generated from field: agent.v1.LsToolCall ls_tool_call = 13; + */ + value: LsToolCall; + case: "lsToolCall"; + } + | { + /** + * @generated from field: agent.v1.ReadLintsToolCall read_lints_tool_call = 14; + */ + value: ReadLintsToolCall; + case: "readLintsToolCall"; + } + | { + /** + * @generated from field: agent.v1.McpToolCall mcp_tool_call = 15; + */ + value: McpToolCall; + case: "mcpToolCall"; + } + | { + /** + * @generated from field: agent.v1.SemSearchToolCall sem_search_tool_call = 16; + */ + value: SemSearchToolCall; + case: "semSearchToolCall"; + } + | { + /** + * @generated from field: agent.v1.CreatePlanToolCall create_plan_tool_call = 17; + */ + value: CreatePlanToolCall; + case: "createPlanToolCall"; + } + | { + /** + * @generated from field: agent.v1.WebSearchToolCall web_search_tool_call = 18; + */ + value: WebSearchToolCall; + case: "webSearchToolCall"; + } + | { + /** + * @generated from field: agent.v1.TaskToolCall task_tool_call = 19; + */ + value: TaskToolCall; + case: "taskToolCall"; + } + | { + /** + * @generated from field: agent.v1.ListMcpResourcesToolCall list_mcp_resources_tool_call = 20; + */ + value: ListMcpResourcesToolCall; + case: "listMcpResourcesToolCall"; + } + | { + /** + * @generated from field: agent.v1.ReadMcpResourceToolCall read_mcp_resource_tool_call = 21; + */ + value: ReadMcpResourceToolCall; + case: "readMcpResourceToolCall"; + } + | { + /** + * @generated from field: agent.v1.ApplyAgentDiffToolCall apply_agent_diff_tool_call = 22; + */ + value: ApplyAgentDiffToolCall; + case: "applyAgentDiffToolCall"; + } + | { + /** + * @generated from field: agent.v1.AskQuestionToolCall ask_question_tool_call = 23; + */ + value: AskQuestionToolCall; + case: "askQuestionToolCall"; + } + | { + /** + * @generated from field: agent.v1.FetchToolCall fetch_tool_call = 24; + */ + value: FetchToolCall; + case: "fetchToolCall"; + } + | { + /** + * @generated from field: agent.v1.SwitchModeToolCall switch_mode_tool_call = 25; + */ + value: SwitchModeToolCall; + case: "switchModeToolCall"; + } + | { + /** + * @generated from field: agent.v1.ExaSearchToolCall exa_search_tool_call = 26; + */ + value: ExaSearchToolCall; + case: "exaSearchToolCall"; + } + | { + /** + * @generated from field: agent.v1.ExaFetchToolCall exa_fetch_tool_call = 27; + */ + value: ExaFetchToolCall; + case: "exaFetchToolCall"; + } + | { + /** + * @generated from field: agent.v1.GenerateImageToolCall generate_image_tool_call = 28; + */ + value: GenerateImageToolCall; + case: "generateImageToolCall"; + } + | { + /** + * @generated from field: agent.v1.RecordScreenToolCall record_screen_tool_call = 29; + */ + value: RecordScreenToolCall; + case: "recordScreenToolCall"; + } + | { + /** + * @generated from field: agent.v1.ComputerUseToolCall computer_use_tool_call = 30; + */ + value: ComputerUseToolCall; + case: "computerUseToolCall"; + } + | { + /** + * @generated from field: agent.v1.WriteShellStdinToolCall write_shell_stdin_tool_call = 31; + */ + value: WriteShellStdinToolCall; + case: "writeShellStdinToolCall"; + } + | { + /** + * @generated from field: agent.v1.ReflectToolCall reflect_tool_call = 32; + */ + value: ReflectToolCall; + case: "reflectToolCall"; + } + | { + /** + * @generated from field: agent.v1.SetupVmEnvironmentToolCall setup_vm_environment_tool_call = 33; + */ + value: SetupVmEnvironmentToolCall; + case: "setupVmEnvironmentToolCall"; + } + | { + /** + * @generated from field: agent.v1.TruncatedToolCall truncated_tool_call = 34; + */ + value: TruncatedToolCall; + case: "truncatedToolCall"; + } + | { + /** + * @generated from field: agent.v1.StartGrindExecutionToolCall start_grind_execution_tool_call = 35; + */ + value: StartGrindExecutionToolCall; + case: "startGrindExecutionToolCall"; + } + | { + /** + * @generated from field: agent.v1.StartGrindPlanningToolCall start_grind_planning_tool_call = 36; + */ + value: StartGrindPlanningToolCall; + case: "startGrindPlanningToolCall"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.ToolCall. + * Use `create(ToolCallSchema)` to create a new message. + */ +export const ToolCallSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 46); + +/** + * @generated from message agent.v1.TruncatedToolCallArgs + */ +export type TruncatedToolCallArgs = Message<"agent.v1.TruncatedToolCallArgs"> & {}; + +/** + * Describes the message agent.v1.TruncatedToolCallArgs. + * Use `create(TruncatedToolCallArgsSchema)` to create a new message. + */ +export const TruncatedToolCallArgsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 47); + +/** + * @generated from message agent.v1.TruncatedToolCallSuccess + */ +export type TruncatedToolCallSuccess = Message<"agent.v1.TruncatedToolCallSuccess"> & {}; + +/** + * Describes the message agent.v1.TruncatedToolCallSuccess. + * Use `create(TruncatedToolCallSuccessSchema)` to create a new message. + */ +export const TruncatedToolCallSuccessSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 48); + +/** + * @generated from message agent.v1.TruncatedToolCallError + */ +export type TruncatedToolCallError = Message<"agent.v1.TruncatedToolCallError"> & { + /** + * @generated from field: string error = 1; + */ + error: string; +}; + +/** + * Describes the message agent.v1.TruncatedToolCallError. + * Use `create(TruncatedToolCallErrorSchema)` to create a new message. + */ +export const TruncatedToolCallErrorSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 49); + +/** + * @generated from message agent.v1.TruncatedToolCallResult + */ +export type TruncatedToolCallResult = Message<"agent.v1.TruncatedToolCallResult"> & { + /** + * @generated from oneof agent.v1.TruncatedToolCallResult.result + */ + result: + | { + /** + * @generated from field: agent.v1.TruncatedToolCallSuccess success = 1; + */ + value: TruncatedToolCallSuccess; + case: "success"; + } + | { + /** + * @generated from field: agent.v1.TruncatedToolCallError error = 2; + */ + value: TruncatedToolCallError; + case: "error"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.TruncatedToolCallResult. + * Use `create(TruncatedToolCallResultSchema)` to create a new message. + */ +export const TruncatedToolCallResultSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 50); + +/** + * Placeholder for tool calls that were truncated due to size limits. + * + * @generated from message agent.v1.TruncatedToolCall + */ +export type TruncatedToolCall = Message<"agent.v1.TruncatedToolCall"> & { + /** + * @generated from field: bytes original_step_blob_id = 1; + */ + originalStepBlobId: Uint8Array; + + /** + * unused, just matches the discriminated union for other tool calls + * + * @generated from field: agent.v1.TruncatedToolCallArgs args = 2; + */ + args?: TruncatedToolCallArgs; + + /** + * @generated from field: agent.v1.TruncatedToolCallResult result = 3; + */ + result?: TruncatedToolCallResult; +}; + +/** + * Describes the message agent.v1.TruncatedToolCall. + * Use `create(TruncatedToolCallSchema)` to create a new message. + */ +export const TruncatedToolCallSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 51); + +/** + * @generated from message agent.v1.ToolCallDelta + */ +export type ToolCallDelta = Message<"agent.v1.ToolCallDelta"> & { + /** + * @generated from oneof agent.v1.ToolCallDelta.delta + */ + delta: + | { + /** + * @generated from field: agent.v1.ShellToolCallDelta shell_tool_call_delta = 1; + */ + value: ShellToolCallDelta; + case: "shellToolCallDelta"; + } + | { + /** + * @generated from field: agent.v1.TaskToolCallDelta task_tool_call_delta = 2; + */ + value: TaskToolCallDelta; + case: "taskToolCallDelta"; + } + | { + /** + * @generated from field: agent.v1.EditToolCallDelta edit_tool_call_delta = 3; + */ + value: EditToolCallDelta; + case: "editToolCallDelta"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.ToolCallDelta. + * Use `create(ToolCallDeltaSchema)` to create a new message. + */ +export const ToolCallDeltaSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 52); + +/** + * @generated from message agent.v1.ConversationStep + */ +export type ConversationStep = Message<"agent.v1.ConversationStep"> & { + /** + * @generated from oneof agent.v1.ConversationStep.message + */ + message: + | { + /** + * @generated from field: agent.v1.AssistantMessage assistant_message = 1; + */ + value: AssistantMessage; + case: "assistantMessage"; + } + | { + /** + * @generated from field: agent.v1.ToolCall tool_call = 2; + */ + value: ToolCall; + case: "toolCall"; + } + | { + /** + * @generated from field: agent.v1.ThinkingMessage thinking_message = 3; + */ + value: ThinkingMessage; + case: "thinkingMessage"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.ConversationStep. + * Use `create(ConversationStepSchema)` to create a new message. + */ +export const ConversationStepSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 53); + +/** + * @generated from message agent.v1.ConversationAction + */ +export type ConversationAction = Message<"agent.v1.ConversationAction"> & { + /** + * @generated from oneof agent.v1.ConversationAction.action + */ + action: + | { + /** + * @generated from field: agent.v1.UserMessageAction user_message_action = 1; + */ + value: UserMessageAction; + case: "userMessageAction"; + } + | { + /** + * @generated from field: agent.v1.ResumeAction resume_action = 2; + */ + value: ResumeAction; + case: "resumeAction"; + } + | { + /** + * @generated from field: agent.v1.CancelAction cancel_action = 3; + */ + value: CancelAction; + case: "cancelAction"; + } + | { + /** + * @generated from field: agent.v1.SummarizeAction summarize_action = 4; + */ + value: SummarizeAction; + case: "summarizeAction"; + } + | { + /** + * @generated from field: agent.v1.ShellCommandAction shell_command_action = 5; + */ + value: ShellCommandAction; + case: "shellCommandAction"; + } + | { + /** + * @generated from field: agent.v1.StartPlanAction start_plan_action = 6; + */ + value: StartPlanAction; + case: "startPlanAction"; + } + | { + /** + * @generated from field: agent.v1.ExecutePlanAction execute_plan_action = 7; + */ + value: ExecutePlanAction; + case: "executePlanAction"; + } + | { + /** + * @generated from field: agent.v1.AsyncAskQuestionCompletionAction async_ask_question_completion_action = 8; + */ + value: AsyncAskQuestionCompletionAction; + case: "asyncAskQuestionCompletionAction"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.ConversationAction. + * Use `create(ConversationActionSchema)` to create a new message. + */ +export const ConversationActionSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 54); + +/** + * @generated from message agent.v1.UserMessageAction + */ +export type UserMessageAction = Message<"agent.v1.UserMessageAction"> & { + /** + * @generated from field: agent.v1.UserMessage user_message = 1; + */ + userMessage?: UserMessage; + + /** + * @generated from field: agent.v1.RequestContext request_context = 2; + */ + requestContext?: RequestContext; + + /** + * @generated from field: optional bool send_to_interaction_listener = 3; + */ + sendToInteractionListener?: boolean; +}; + +/** + * Describes the message agent.v1.UserMessageAction. + * Use `create(UserMessageActionSchema)` to create a new message. + */ +export const UserMessageActionSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 55); + +/** + * @generated from message agent.v1.CancelAction + */ +export type CancelAction = Message<"agent.v1.CancelAction"> & {}; + +/** + * Describes the message agent.v1.CancelAction. + * Use `create(CancelActionSchema)` to create a new message. + */ +export const CancelActionSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 56); + +/** + * @generated from message agent.v1.ResumeAction + */ +export type ResumeAction = Message<"agent.v1.ResumeAction"> & { + /** + * @generated from field: agent.v1.RequestContext request_context = 2; + */ + requestContext?: RequestContext; +}; + +/** + * Describes the message agent.v1.ResumeAction. + * Use `create(ResumeActionSchema)` to create a new message. + */ +export const ResumeActionSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 57); + +/** + * @generated from message agent.v1.AsyncAskQuestionCompletionAction + */ +export type AsyncAskQuestionCompletionAction = Message<"agent.v1.AsyncAskQuestionCompletionAction"> & { + /** + * Contains the original tool call ID and the result from the user + * + * @generated from field: string original_tool_call_id = 1; + */ + originalToolCallId: string; + + /** + * @generated from field: agent.v1.AskQuestionArgs original_args = 2; + */ + originalArgs?: AskQuestionArgs; + + /** + * @generated from field: agent.v1.AskQuestionResult result = 3; + */ + result?: AskQuestionResult; +}; + +/** + * Describes the message agent.v1.AsyncAskQuestionCompletionAction. + * Use `create(AsyncAskQuestionCompletionActionSchema)` to create a new message. + */ +export const AsyncAskQuestionCompletionActionSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 58); + +/** + * @generated from message agent.v1.SummarizeAction + */ +export type SummarizeAction = Message<"agent.v1.SummarizeAction"> & {}; + +/** + * Describes the message agent.v1.SummarizeAction. + * Use `create(SummarizeActionSchema)` to create a new message. + */ +export const SummarizeActionSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 59); + +/** + * @generated from message agent.v1.ShellCommandAction + */ +export type ShellCommandAction = Message<"agent.v1.ShellCommandAction"> & { + /** + * @generated from field: agent.v1.ShellCommand shell_command = 1; + */ + shellCommand?: ShellCommand; + + /** + * unique identifier for preemptive exec attachment + * + * @generated from field: string exec_id = 2; + */ + execId: string; +}; + +/** + * Describes the message agent.v1.ShellCommandAction. + * Use `create(ShellCommandActionSchema)` to create a new message. + */ +export const ShellCommandActionSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 60); + +/** + * @generated from message agent.v1.StartPlanAction + */ +export type StartPlanAction = Message<"agent.v1.StartPlanAction"> & { + /** + * @generated from field: agent.v1.UserMessage user_message = 1; + */ + userMessage?: UserMessage; + + /** + * @generated from field: agent.v1.RequestContext request_context = 2; + */ + requestContext?: RequestContext; + + /** + * @generated from field: bool is_spec = 3; + */ + isSpec: boolean; +}; + +/** + * Describes the message agent.v1.StartPlanAction. + * Use `create(StartPlanActionSchema)` to create a new message. + */ +export const StartPlanActionSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 61); + +/** + * @generated from message agent.v1.ExecutePlanAction + */ +export type ExecutePlanAction = Message<"agent.v1.ExecutePlanAction"> & { + /** + * @generated from field: agent.v1.RequestContext request_context = 1; + */ + requestContext?: RequestContext; + + /** + * @generated from field: optional agent.v1.ConversationPlan plan = 2; + */ + plan?: ConversationPlan; + + /** + * e.g., "cursor-plan://composerId/plan.md" + * + * @generated from field: optional string plan_file_uri = 3; + */ + planFileUri?: string; + + /** + * The actual plan content from the file + * + * @generated from field: optional string plan_file_content = 4; + */ + planFileContent?: string; +}; + +/** + * Describes the message agent.v1.ExecutePlanAction. + * Use `create(ExecutePlanActionSchema)` to create a new message. + */ +export const ExecutePlanActionSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 62); + +/** + * @generated from message agent.v1.UserMessage + */ +export type UserMessage = Message<"agent.v1.UserMessage"> & { + /** + * @generated from field: string text = 1; + */ + text: string; + + /** + * @generated from field: string message_id = 2; + */ + messageId: string; + + /** + * @generated from field: optional agent.v1.SelectedContext selected_context = 3; + */ + selectedContext?: SelectedContext; + + /** + * @generated from field: int32 mode = 4; + */ + mode: number; + + /** + * @generated from field: optional bool is_simulated_msg = 5; + */ + isSimulatedMsg?: boolean; + + /** + * @generated from field: optional string best_of_n_group_id = 6; + */ + bestOfNGroupId?: string; + + /** + * @generated from field: optional bool try_use_best_of_n_promotion = 7; + */ + tryUseBestOfNPromotion?: boolean; + + /** + * @generated from field: optional string rich_text = 8; + */ + richText?: string; +}; + +/** + * Describes the message agent.v1.UserMessage. + * Use `create(UserMessageSchema)` to create a new message. + */ +export const UserMessageSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 63); + +/** + * @generated from message agent.v1.AssistantMessage + */ +export type AssistantMessage = Message<"agent.v1.AssistantMessage"> & { + /** + * @generated from field: string text = 1; + */ + text: string; +}; + +/** + * Describes the message agent.v1.AssistantMessage. + * Use `create(AssistantMessageSchema)` to create a new message. + */ +export const AssistantMessageSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 64); + +/** + * @generated from message agent.v1.ThinkingMessage + */ +export type ThinkingMessage = Message<"agent.v1.ThinkingMessage"> & { + /** + * @generated from field: string text = 1; + */ + text: string; + + /** + * @generated from field: uint32 duration_ms = 2; + */ + durationMs: number; +}; + +/** + * Describes the message agent.v1.ThinkingMessage. + * Use `create(ThinkingMessageSchema)` to create a new message. + */ +export const ThinkingMessageSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 65); + +/** + * @generated from message agent.v1.ShellCommand + */ +export type ShellCommand = Message<"agent.v1.ShellCommand"> & { + /** + * @generated from field: string command = 1; + */ + command: string; +}; + +/** + * Describes the message agent.v1.ShellCommand. + * Use `create(ShellCommandSchema)` to create a new message. + */ +export const ShellCommandSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 66); + +/** + * @generated from message agent.v1.ShellOutput + */ +export type ShellOutput = Message<"agent.v1.ShellOutput"> & { + /** + * @generated from field: string stdout = 1; + */ + stdout: string; + + /** + * @generated from field: string stderr = 2; + */ + stderr: string; + + /** + * @generated from field: int32 exit_code = 3; + */ + exitCode: number; +}; + +/** + * Describes the message agent.v1.ShellOutput. + * Use `create(ShellOutputSchema)` to create a new message. + */ +export const ShellOutputSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 67); + +/** + * @generated from message agent.v1.ConversationTurn + */ +export type ConversationTurn = Message<"agent.v1.ConversationTurn"> & { + /** + * @generated from oneof agent.v1.ConversationTurn.turn + */ + turn: + | { + /** + * @generated from field: agent.v1.AgentConversationTurn agent_conversation_turn = 1; + */ + value: AgentConversationTurn; + case: "agentConversationTurn"; + } + | { + /** + * @generated from field: agent.v1.ShellConversationTurn shell_conversation_turn = 2; + */ + value: ShellConversationTurn; + case: "shellConversationTurn"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.ConversationTurn. + * Use `create(ConversationTurnSchema)` to create a new message. + */ +export const ConversationTurnSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 68); + +/** + * @generated from message agent.v1.ConversationPlan + */ +export type ConversationPlan = Message<"agent.v1.ConversationPlan"> & { + /** + * @generated from field: string plan = 1; + */ + plan: string; +}; + +/** + * Describes the message agent.v1.ConversationPlan. + * Use `create(ConversationPlanSchema)` to create a new message. + */ +export const ConversationPlanSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 69); + +/** + * @generated from message agent.v1.ConversationTurnStructure + */ +export type ConversationTurnStructure = Message<"agent.v1.ConversationTurnStructure"> & { + /** + * @generated from oneof agent.v1.ConversationTurnStructure.turn + */ + turn: + | { + /** + * @generated from field: agent.v1.AgentConversationTurnStructure agent_conversation_turn = 1; + */ + value: AgentConversationTurnStructure; + case: "agentConversationTurn"; + } + | { + /** + * @generated from field: agent.v1.ShellConversationTurnStructure shell_conversation_turn = 2; + */ + value: ShellConversationTurnStructure; + case: "shellConversationTurn"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.ConversationTurnStructure. + * Use `create(ConversationTurnStructureSchema)` to create a new message. + */ +export const ConversationTurnStructureSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 70); + +/** + * @generated from message agent.v1.AgentConversationTurn + */ +export type AgentConversationTurn = Message<"agent.v1.AgentConversationTurn"> & { + /** + * @generated from field: agent.v1.UserMessage user_message = 1; + */ + userMessage?: UserMessage; + + /** + * @generated from field: repeated agent.v1.ConversationStep steps = 2; + */ + steps: ConversationStep[]; + + /** + * The request ID associated with this turn, used for analytics tracking + * + * @generated from field: optional string request_id = 3; + */ + requestId?: string; +}; + +/** + * Describes the message agent.v1.AgentConversationTurn. + * Use `create(AgentConversationTurnSchema)` to create a new message. + */ +export const AgentConversationTurnSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 71); + +/** + * @generated from message agent.v1.AgentConversationTurnStructure + */ +export type AgentConversationTurnStructure = Message<"agent.v1.AgentConversationTurnStructure"> & { + /** + * @generated from field: bytes user_message = 1; + */ + userMessage: Uint8Array; + + /** + * @generated from field: repeated bytes steps = 2; + */ + steps: Uint8Array[]; + + /** + * The request ID associated with this turn, used for analytics tracking + * + * @generated from field: optional string request_id = 3; + */ + requestId?: string; +}; + +/** + * Describes the message agent.v1.AgentConversationTurnStructure. + * Use `create(AgentConversationTurnStructureSchema)` to create a new message. + */ +export const AgentConversationTurnStructureSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 72); + +/** + * @generated from message agent.v1.ShellConversationTurn + */ +export type ShellConversationTurn = Message<"agent.v1.ShellConversationTurn"> & { + /** + * @generated from field: agent.v1.ShellCommand shell_command = 1; + */ + shellCommand?: ShellCommand; + + /** + * @generated from field: agent.v1.ShellOutput shell_output = 2; + */ + shellOutput?: ShellOutput; +}; + +/** + * Describes the message agent.v1.ShellConversationTurn. + * Use `create(ShellConversationTurnSchema)` to create a new message. + */ +export const ShellConversationTurnSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 73); + +/** + * @generated from message agent.v1.ShellConversationTurnStructure + */ +export type ShellConversationTurnStructure = Message<"agent.v1.ShellConversationTurnStructure"> & { + /** + * @generated from field: bytes shell_command = 1; + */ + shellCommand: Uint8Array; + + /** + * @generated from field: bytes shell_output = 2; + */ + shellOutput: Uint8Array; +}; + +/** + * Describes the message agent.v1.ShellConversationTurnStructure. + * Use `create(ShellConversationTurnStructureSchema)` to create a new message. + */ +export const ShellConversationTurnStructureSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 74); + +/** + * @generated from message agent.v1.ConversationSummary + */ +export type ConversationSummary = Message<"agent.v1.ConversationSummary"> & { + /** + * @generated from field: string summary = 1; + */ + summary: string; +}; + +/** + * Describes the message agent.v1.ConversationSummary. + * Use `create(ConversationSummarySchema)` to create a new message. + */ +export const ConversationSummarySchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 75); + +/** + * @generated from message agent.v1.ConversationSummaryArchive + */ +export type ConversationSummaryArchive = Message<"agent.v1.ConversationSummaryArchive"> & { + /** + * @generated from field: repeated bytes summarized_messages = 1; + */ + summarizedMessages: Uint8Array[]; + + /** + * @generated from field: string summary = 2; + */ + summary: string; + + /** + * @generated from field: uint32 window_tail = 3; + */ + windowTail: number; + + /** + * @generated from field: bytes summary_message = 4; + */ + summaryMessage: Uint8Array; +}; + +/** + * Describes the message agent.v1.ConversationSummaryArchive. + * Use `create(ConversationSummaryArchiveSchema)` to create a new message. + */ +export const ConversationSummaryArchiveSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 76); + +/** + * @generated from message agent.v1.ConversationTokenDetails + */ +export type ConversationTokenDetails = Message<"agent.v1.ConversationTokenDetails"> & { + /** + * @generated from field: uint32 used_tokens = 1; + */ + usedTokens: number; + + /** + * @generated from field: uint32 max_tokens = 2; + */ + maxTokens: number; +}; + +/** + * Describes the message agent.v1.ConversationTokenDetails. + * Use `create(ConversationTokenDetailsSchema)` to create a new message. + */ +export const ConversationTokenDetailsSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 77); + +/** + * @generated from message agent.v1.FileState + */ +export type FileState = Message<"agent.v1.FileState"> & { + /** + * Optional content. If not set or undefined, the file is considered deleted. + * + * @generated from field: optional string content = 1; + */ + content?: string; + + /** + * Optional initial content captured when the file was first tracked. If not set or undefined, the file did not exist when tracking began. + * + * @generated from field: optional string initial_content = 2; + */ + initialContent?: string; +}; + +/** + * Describes the message agent.v1.FileState. + * Use `create(FileStateSchema)` to create a new message. + */ +export const FileStateSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 78); + +/** + * @generated from message agent.v1.FileStateStructure + */ +export type FileStateStructure = Message<"agent.v1.FileStateStructure"> & { + /** + * Optional content. If not set or undefined, the file is considered deleted. + * + * @generated from field: optional bytes content = 1; + */ + content?: Uint8Array; + + /** + * Optional initial content captured when the file was first tracked. If not set or undefined, the file did not exist when tracking began. + * + * @generated from field: optional bytes initial_content = 2; + */ + initialContent?: Uint8Array; +}; + +/** + * Describes the message agent.v1.FileStateStructure. + * Use `create(FileStateStructureSchema)` to create a new message. + */ +export const FileStateStructureSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 79); + +/** + * @generated from message agent.v1.StepTiming + */ +export type StepTiming = Message<"agent.v1.StepTiming"> & { + /** + * @generated from field: uint64 duration_ms = 1; + */ + durationMs: bigint; + + /** + * @generated from field: uint64 timestamp_ms = 2; + */ + timestampMs: bigint; +}; + +/** + * Describes the message agent.v1.StepTiming. + * Use `create(StepTimingSchema)` to create a new message. + */ +export const StepTimingSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 80); + +/** + * @generated from message agent.v1.ConversationState + */ +export type ConversationState = Message<"agent.v1.ConversationState"> & { + /** + * @generated from field: repeated string root_prompt_messages_json = 1; + */ + rootPromptMessagesJson: string[]; + + /** + * @generated from field: repeated agent.v1.ConversationTurn turns = 8; + */ + turns: ConversationTurn[]; + + /** + * @generated from field: repeated agent.v1.TodoItem todos = 3; + */ + todos: TodoItem[]; + + /** + * Raw JSON stringified tool-call content parts awaiting execution + * + * @generated from field: repeated string pending_tool_calls = 4; + */ + pendingToolCalls: string[]; + + /** + * @generated from field: agent.v1.ConversationTokenDetails token_details = 5; + */ + tokenDetails?: ConversationTokenDetails; + + /** + * only for when the user explicitly asks for a summary through the summary action + * + * @generated from field: optional agent.v1.ConversationSummary summary = 6; + */ + summary?: ConversationSummary; + + /** + * @generated from field: optional agent.v1.ConversationPlan plan = 7; + */ + plan?: ConversationPlan; + + /** + * @generated from field: optional agent.v1.ConversationSummaryArchive summary_archive = 9; + */ + summaryArchive?: ConversationSummaryArchive; + + /** + * Deprecated, use summary_archives instead @deprecated summaryArchive; + * + * @generated from field: map file_states = 10; + */ + fileStates: { [key: string]: FileState }; + + /** + * @generated from field: repeated agent.v1.ConversationSummaryArchive summary_archives = 11; + */ + summaryArchives: ConversationSummaryArchive[]; +}; + +/** + * Describes the message agent.v1.ConversationState. + * Use `create(ConversationStateSchema)` to create a new message. + */ +export const ConversationStateSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 81); + +/** + * @generated from message agent.v1.SubagentPersistedState + */ +export type SubagentPersistedState = Message<"agent.v1.SubagentPersistedState"> & { + /** + * The subagent's conversation state structure + * + * @generated from field: agent.v1.ConversationStateStructure conversation_state = 1; + */ + conversationState?: ConversationStateStructure; + + /** + * Timestamp when this subagent was first created + * + * @generated from field: uint64 created_timestamp_ms = 2; + */ + createdTimestampMs: bigint; + + /** + * Timestamp when this subagent was last used (by task tool call) + * + * @generated from field: uint64 last_used_timestamp_ms = 3; + */ + lastUsedTimestampMs: bigint; + + /** + * The subagent type (e.g., computerUse, custom with name) + * + * @generated from field: agent.v1.SubagentType subagent_type = 4; + */ + subagentType?: SubagentType; +}; + +/** + * Describes the message agent.v1.SubagentPersistedState. + * Use `create(SubagentPersistedStateSchema)` to create a new message. + */ +export const SubagentPersistedStateSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 82); + +/** + * @generated from message agent.v1.ConversationStateStructure + */ +export type ConversationStateStructure = Message<"agent.v1.ConversationStateStructure"> & { + /** + * @generated from field: repeated bytes turns_old = 2; + */ + turnsOld: Uint8Array[]; + + /** + * @deprecated turnsOld = []; + * + * @generated from field: repeated bytes root_prompt_messages_json = 1; + */ + rootPromptMessagesJson: Uint8Array[]; + + /** + * @generated from field: repeated bytes turns = 8; + */ + turns: Uint8Array[]; + + /** + * @generated from field: repeated bytes todos = 3; + */ + todos: Uint8Array[]; + + /** + * Raw JSON stringified tool-call content parts awaiting execution + * + * @generated from field: repeated string pending_tool_calls = 4; + */ + pendingToolCalls: string[]; + + /** + * @generated from field: agent.v1.ConversationTokenDetails token_details = 5; + */ + tokenDetails?: ConversationTokenDetails; + + /** + * only for when the user explicitly asks for a summary through the summary action + * + * @generated from field: optional bytes summary = 6; + */ + summary?: Uint8Array; + + /** + * @generated from field: optional bytes plan = 7; + */ + plan?: Uint8Array; + + /** + * @generated from field: repeated string previous_workspace_uris = 9; + */ + previousWorkspaceUris: string[]; + + /** + * Current mode of the conversation + * + * @generated from field: optional int32 mode = 10; + */ + mode?: number; + + /** + * @generated from field: optional bytes summary_archive = 11; + */ + summaryArchive?: Uint8Array; + + /** + * @generated from field: map file_states = 12; + */ + fileStates: { [key: string]: Uint8Array }; + + /** + * Deprecated, use summary_archives instead @deprecated summaryArchive; Map of file paths to their latest content (stored as blob IDs in the KV store) Each blob contains a serialized FileState message @deprecated fileStates = {}; Map of file paths to their latest content (stored as FileStateStructure) + * + * @generated from field: map file_states_v2 = 15; + */ + fileStatesV2: { [key: string]: FileStateStructure }; + + /** + * @generated from field: repeated bytes summary_archives = 13; + */ + summaryArchives: Uint8Array[]; + + /** + * @generated from field: repeated agent.v1.StepTiming turn_timings = 14; + */ + turnTimings: StepTiming[]; + + /** + * Subagent resume tracking Map of subagent ID to the persisted subagent state (stored inline) + * + * @generated from field: map subagent_states = 16; + */ + subagentStates: { [key: string]: SubagentPersistedState }; + + /** + * Count of self-summaries generated for this conversation + * + * @generated from field: uint32 self_summary_count = 17; + */ + selfSummaryCount: number; + + /** + * Set of file paths that have been read during this conversation + * + * @generated from field: repeated string read_paths = 18; + */ + readPaths: string[]; +}; + +/** + * Describes the message agent.v1.ConversationStateStructure. + * Use `create(ConversationStateStructureSchema)` to create a new message. + */ +export const ConversationStateStructureSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 83); + +/** + * @generated from message agent.v1.ThinkingDetails + */ +export type ThinkingDetails = Message<"agent.v1.ThinkingDetails"> & {}; + +/** + * Describes the message agent.v1.ThinkingDetails. + * Use `create(ThinkingDetailsSchema)` to create a new message. + */ +export const ThinkingDetailsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 84); + +/** + * @generated from message agent.v1.ApiKeyCredentials + */ +export type ApiKeyCredentials = Message<"agent.v1.ApiKeyCredentials"> & { + /** + * @generated from field: string api_key = 1; + */ + apiKey: string; + + /** + * For OpenAI-compatible endpoints + * + * @generated from field: optional string base_url = 2; + */ + baseUrl?: string; +}; + +/** + * Describes the message agent.v1.ApiKeyCredentials. + * Use `create(ApiKeyCredentialsSchema)` to create a new message. + */ +export const ApiKeyCredentialsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 85); + +/** + * @generated from message agent.v1.AzureCredentials + */ +export type AzureCredentials = Message<"agent.v1.AzureCredentials"> & { + /** + * @generated from field: string api_key = 1; + */ + apiKey: string; + + /** + * @generated from field: string base_url = 2; + */ + baseUrl: string; + + /** + * @generated from field: string deployment = 3; + */ + deployment: string; +}; + +/** + * Describes the message agent.v1.AzureCredentials. + * Use `create(AzureCredentialsSchema)` to create a new message. + */ +export const AzureCredentialsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 86); + +/** + * @generated from message agent.v1.BedrockCredentials + */ +export type BedrockCredentials = Message<"agent.v1.BedrockCredentials"> & { + /** + * @generated from field: string access_key = 1; + */ + accessKey: string; + + /** + * @generated from field: string secret_key = 2; + */ + secretKey: string; + + /** + * @generated from field: string region = 3; + */ + region: string; + + /** + * @generated from field: optional string session_token = 4; + */ + sessionToken?: string; +}; + +/** + * Describes the message agent.v1.BedrockCredentials. + * Use `create(BedrockCredentialsSchema)` to create a new message. + */ +export const BedrockCredentialsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 87); + +/** + * @generated from message agent.v1.ModelDetails + */ +export type ModelDetails = Message<"agent.v1.ModelDetails"> & { + /** + * @generated from field: string model_id = 1; + */ + modelId: string; + + /** + * @generated from field: string display_model_id = 3; + */ + displayModelId: string; + + /** + * @generated from field: string display_name = 4; + */ + displayName: string; + + /** + * @generated from field: string display_name_short = 5; + */ + displayNameShort: string; + + /** + * @generated from field: repeated string aliases = 6; + */ + aliases: string[]; + + /** + * @generated from field: optional agent.v1.ThinkingDetails thinking_details = 2; + */ + thinkingDetails?: ThinkingDetails; + + /** + * @generated from field: optional bool max_mode = 7; + */ + maxMode?: boolean; + + /** + * @generated from oneof agent.v1.ModelDetails.credentials + */ + credentials: + | { + /** + * @generated from field: agent.v1.ApiKeyCredentials api_key_credentials = 8; + */ + value: ApiKeyCredentials; + case: "apiKeyCredentials"; + } + | { + /** + * @generated from field: agent.v1.AzureCredentials azure_credentials = 9; + */ + value: AzureCredentials; + case: "azureCredentials"; + } + | { + /** + * @generated from field: agent.v1.BedrockCredentials bedrock_credentials = 10; + */ + value: BedrockCredentials; + case: "bedrockCredentials"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.ModelDetails. + * Use `create(ModelDetailsSchema)` to create a new message. + */ +export const ModelDetailsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 88); + +/** + * @generated from message agent.v1.RequestedModel + */ +export type RequestedModel = Message<"agent.v1.RequestedModel"> & { + /** + * @generated from field: string model_id = 1; + */ + modelId: string; + + /** + * @generated from field: bool max_mode = 2; + */ + maxMode: boolean; + + /** + * @generated from field: repeated agent.v1.RequestedModel_ModelParameterbytes parameters = 3; + */ + parameters: RequestedModel_ModelParameterbytes[]; + + /** + * @generated from oneof agent.v1.RequestedModel.credentials + */ + credentials: + | { + /** + * @generated from field: agent.v1.ApiKeyCredentials api_key_credentials = 4; + */ + value: ApiKeyCredentials; + case: "apiKeyCredentials"; + } + | { + /** + * @generated from field: agent.v1.AzureCredentials azure_credentials = 5; + */ + value: AzureCredentials; + case: "azureCredentials"; + } + | { + /** + * @generated from field: agent.v1.BedrockCredentials bedrock_credentials = 6; + */ + value: BedrockCredentials; + case: "bedrockCredentials"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.RequestedModel. + * Use `create(RequestedModelSchema)` to create a new message. + */ +export const RequestedModelSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 89); + +/** + * @generated from message agent.v1.RequestedModel_ModelParameterbytes + */ +export type RequestedModel_ModelParameterbytes = Message<"agent.v1.RequestedModel_ModelParameterbytes"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * All paramters are encoded as strings. For boolean parameters, the value is either "true" or "false". For enum parameters, the value is one of the values in the enum. + * + * @generated from field: string value = 2; + */ + value: string; +}; + +/** + * Describes the message agent.v1.RequestedModel_ModelParameterbytes. + * Use `create(RequestedModel_ModelParameterbytesSchema)` to create a new message. + */ +export const RequestedModel_ModelParameterbytesSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 90); + +/** + * @generated from message agent.v1.AgentRunRequest + */ +export type AgentRunRequest = Message<"agent.v1.AgentRunRequest"> & { + /** + * @generated from field: agent.v1.ConversationStateStructure conversation_state = 1; + */ + conversationState?: ConversationStateStructure; + + /** + * @generated from field: agent.v1.ConversationAction action = 2; + */ + action?: ConversationAction; + + /** + * TODO: Today we use model_details, but we are getting ready to deprecate that and use requested_model instead. + * + * @generated from field: agent.v1.ModelDetails model_details = 3; + */ + modelDetails?: ModelDetails; + + /** + * @generated from field: optional agent.v1.RequestedModel requested_model = 9; + */ + requestedModel?: RequestedModel; + + /** + * @generated from field: agent.v1.McpTools mcp_tools = 4; + */ + mcpTools?: McpTools; + + /** + * @generated from field: optional string conversation_id = 5; + */ + conversationId?: string; + + /** + * @generated from field: optional agent.v1.McpFileSystemOptions mcp_file_system_options = 6; + */ + mcpFileSystemOptions?: McpFileSystemOptions; + + /** + * Deprecated, use the one in RequestContext message in request_context_exec.proto instead + * + * @generated from field: optional agent.v1.SkillOptions skill_options = 7; + */ + skillOptions?: SkillOptions; + + /** + * Custom system prompt override. Allowlisted for specific teams only. + * + * @generated from field: optional string custom_system_prompt = 8; + */ + customSystemPrompt?: string; +}; + +/** + * Describes the message agent.v1.AgentRunRequest. + * Use `create(AgentRunRequestSchema)` to create a new message. + */ +export const AgentRunRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 91); + +/** + * @generated from message agent.v1.TextDeltaUpdate + */ +export type TextDeltaUpdate = Message<"agent.v1.TextDeltaUpdate"> & { + /** + * @generated from field: string text = 1; + */ + text: string; +}; + +/** + * Describes the message agent.v1.TextDeltaUpdate. + * Use `create(TextDeltaUpdateSchema)` to create a new message. + */ +export const TextDeltaUpdateSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 92); + +/** + * @generated from message agent.v1.ToolCallStartedUpdate + */ +export type ToolCallStartedUpdate = Message<"agent.v1.ToolCallStartedUpdate"> & { + /** + * @generated from field: string call_id = 1; + */ + callId: string; + + /** + * @generated from field: agent.v1.ToolCall tool_call = 2; + */ + toolCall?: ToolCall; + + /** + * groups tool calls that originate from the same model provider call + * + * @generated from field: string model_call_id = 3; + */ + modelCallId: string; +}; + +/** + * Describes the message agent.v1.ToolCallStartedUpdate. + * Use `create(ToolCallStartedUpdateSchema)` to create a new message. + */ +export const ToolCallStartedUpdateSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 93); + +/** + * @generated from message agent.v1.ToolCallCompletedUpdate + */ +export type ToolCallCompletedUpdate = Message<"agent.v1.ToolCallCompletedUpdate"> & { + /** + * @generated from field: string call_id = 1; + */ + callId: string; + + /** + * @generated from field: agent.v1.ToolCall tool_call = 2; + */ + toolCall?: ToolCall; + + /** + * groups tool calls that originate from the same model provider call + * + * @generated from field: string model_call_id = 3; + */ + modelCallId: string; +}; + +/** + * Describes the message agent.v1.ToolCallCompletedUpdate. + * Use `create(ToolCallCompletedUpdateSchema)` to create a new message. + */ +export const ToolCallCompletedUpdateSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 94); + +/** + * @generated from message agent.v1.ToolCallDeltaUpdate + */ +export type ToolCallDeltaUpdate = Message<"agent.v1.ToolCallDeltaUpdate"> & { + /** + * @generated from field: string call_id = 1; + */ + callId: string; + + /** + * @generated from field: agent.v1.ToolCallDelta tool_call_delta = 2; + */ + toolCallDelta?: ToolCallDelta; + + /** + * groups tool calls that originate from the same model provider call + * + * @generated from field: string model_call_id = 3; + */ + modelCallId: string; +}; + +/** + * Describes the message agent.v1.ToolCallDeltaUpdate. + * Use `create(ToolCallDeltaUpdateSchema)` to create a new message. + */ +export const ToolCallDeltaUpdateSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 95); + +/** + * Streaming update for partial tool call arguments + * + * @generated from message agent.v1.PartialToolCallUpdate + */ +export type PartialToolCallUpdate = Message<"agent.v1.PartialToolCallUpdate"> & { + /** + * @generated from field: string call_id = 1; + */ + callId: string; + + /** + * @generated from field: agent.v1.ToolCall tool_call = 2; + */ + toolCall?: ToolCall; + + /** + * Aggregated args text so far (as JSON text). May be incomplete until final tool call. + * + * @generated from field: string args_text_delta = 3; + */ + argsTextDelta: string; + + /** + * groups tool calls that originate from the same model provider call + * + * @generated from field: string model_call_id = 4; + */ + modelCallId: string; +}; + +/** + * Describes the message agent.v1.PartialToolCallUpdate. + * Use `create(PartialToolCallUpdateSchema)` to create a new message. + */ +export const PartialToolCallUpdateSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 96); + +/** + * @generated from message agent.v1.ThinkingDeltaUpdate + */ +export type ThinkingDeltaUpdate = Message<"agent.v1.ThinkingDeltaUpdate"> & { + /** + * @generated from field: string text = 1; + */ + text: string; +}; + +/** + * Describes the message agent.v1.ThinkingDeltaUpdate. + * Use `create(ThinkingDeltaUpdateSchema)` to create a new message. + */ +export const ThinkingDeltaUpdateSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 97); + +/** + * @generated from message agent.v1.ThinkingCompletedUpdate + */ +export type ThinkingCompletedUpdate = Message<"agent.v1.ThinkingCompletedUpdate"> & { + /** + * @generated from field: int32 thinking_duration_ms = 1; + */ + thinkingDurationMs: number; +}; + +/** + * Describes the message agent.v1.ThinkingCompletedUpdate. + * Use `create(ThinkingCompletedUpdateSchema)` to create a new message. + */ +export const ThinkingCompletedUpdateSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 98); + +/** + * @generated from message agent.v1.TokenDeltaUpdate + */ +export type TokenDeltaUpdate = Message<"agent.v1.TokenDeltaUpdate"> & { + /** + * @generated from field: int32 tokens = 1; + */ + tokens: number; +}; + +/** + * Describes the message agent.v1.TokenDeltaUpdate. + * Use `create(TokenDeltaUpdateSchema)` to create a new message. + */ +export const TokenDeltaUpdateSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 99); + +/** + * @generated from message agent.v1.SummaryUpdate + */ +export type SummaryUpdate = Message<"agent.v1.SummaryUpdate"> & { + /** + * @generated from field: string summary = 1; + */ + summary: string; +}; + +/** + * Describes the message agent.v1.SummaryUpdate. + * Use `create(SummaryUpdateSchema)` to create a new message. + */ +export const SummaryUpdateSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 100); + +/** + * @generated from message agent.v1.SummaryStartedUpdate + */ +export type SummaryStartedUpdate = Message<"agent.v1.SummaryStartedUpdate"> & {}; + +/** + * Describes the message agent.v1.SummaryStartedUpdate. + * Use `create(SummaryStartedUpdateSchema)` to create a new message. + */ +export const SummaryStartedUpdateSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 101); + +/** + * @generated from message agent.v1.HeartbeatUpdate + */ +export type HeartbeatUpdate = Message<"agent.v1.HeartbeatUpdate"> & {}; + +/** + * Describes the message agent.v1.HeartbeatUpdate. + * Use `create(HeartbeatUpdateSchema)` to create a new message. + */ +export const HeartbeatUpdateSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 102); + +/** + * @generated from message agent.v1.SummaryCompletedUpdate + */ +export type SummaryCompletedUpdate = Message<"agent.v1.SummaryCompletedUpdate"> & {}; + +/** + * Describes the message agent.v1.SummaryCompletedUpdate. + * Use `create(SummaryCompletedUpdateSchema)` to create a new message. + */ +export const SummaryCompletedUpdateSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 103); + +/** + * @generated from message agent.v1.ShellOutputDeltaUpdate + */ +export type ShellOutputDeltaUpdate = Message<"agent.v1.ShellOutputDeltaUpdate"> & { + /** + * @generated from oneof agent.v1.ShellOutputDeltaUpdate.event + */ + event: + | { + /** + * @generated from field: agent.v1.ShellStreamStdout stdout = 1; + */ + value: ShellStreamStdout; + case: "stdout"; + } + | { + /** + * @generated from field: agent.v1.ShellStreamStderr stderr = 2; + */ + value: ShellStreamStderr; + case: "stderr"; + } + | { + /** + * @generated from field: agent.v1.ShellStreamExit exit = 3; + */ + value: ShellStreamExit; + case: "exit"; + } + | { + /** + * @generated from field: agent.v1.ShellStreamStart start = 4; + */ + value: ShellStreamStart; + case: "start"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.ShellOutputDeltaUpdate. + * Use `create(ShellOutputDeltaUpdateSchema)` to create a new message. + */ +export const ShellOutputDeltaUpdateSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 104); + +/** + * @generated from message agent.v1.TurnEndedUpdate + */ +export type TurnEndedUpdate = Message<"agent.v1.TurnEndedUpdate"> & {}; + +/** + * Describes the message agent.v1.TurnEndedUpdate. + * Use `create(TurnEndedUpdateSchema)` to create a new message. + */ +export const TurnEndedUpdateSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 105); + +/** + * Only: user message appended update + * + * @generated from message agent.v1.UserMessageAppendedUpdate + */ +export type UserMessageAppendedUpdate = Message<"agent.v1.UserMessageAppendedUpdate"> & { + /** + * @generated from field: agent.v1.UserMessage user_message = 1; + */ + userMessage?: UserMessage; +}; + +/** + * Describes the message agent.v1.UserMessageAppendedUpdate. + * Use `create(UserMessageAppendedUpdateSchema)` to create a new message. + */ +export const UserMessageAppendedUpdateSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 106); + +/** + * @generated from message agent.v1.StepStartedUpdate + */ +export type StepStartedUpdate = Message<"agent.v1.StepStartedUpdate"> & { + /** + * @generated from field: uint64 step_id = 1; + */ + stepId: bigint; +}; + +/** + * Describes the message agent.v1.StepStartedUpdate. + * Use `create(StepStartedUpdateSchema)` to create a new message. + */ +export const StepStartedUpdateSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 107); + +/** + * @generated from message agent.v1.StepCompletedUpdate + */ +export type StepCompletedUpdate = Message<"agent.v1.StepCompletedUpdate"> & { + /** + * @generated from field: uint64 step_id = 1; + */ + stepId: bigint; + + /** + * @generated from field: int64 step_duration_ms = 2; + */ + stepDurationMs: bigint; +}; + +/** + * Describes the message agent.v1.StepCompletedUpdate. + * Use `create(StepCompletedUpdateSchema)` to create a new message. + */ +export const StepCompletedUpdateSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 108); + +/** + * @generated from message agent.v1.InteractionUpdate + */ +export type InteractionUpdate = Message<"agent.v1.InteractionUpdate"> & { + /** + * @generated from oneof agent.v1.InteractionUpdate.message + */ + message: + | { + /** + * @generated from field: agent.v1.TextDeltaUpdate text_delta = 1; + */ + value: TextDeltaUpdate; + case: "textDelta"; + } + | { + /** + * @generated from field: agent.v1.PartialToolCallUpdate partial_tool_call = 7; + */ + value: PartialToolCallUpdate; + case: "partialToolCall"; + } + | { + /** + * @generated from field: agent.v1.ToolCallDeltaUpdate tool_call_delta = 15; + */ + value: ToolCallDeltaUpdate; + case: "toolCallDelta"; + } + | { + /** + * @generated from field: agent.v1.ToolCallStartedUpdate tool_call_started = 2; + */ + value: ToolCallStartedUpdate; + case: "toolCallStarted"; + } + | { + /** + * @generated from field: agent.v1.ToolCallCompletedUpdate tool_call_completed = 3; + */ + value: ToolCallCompletedUpdate; + case: "toolCallCompleted"; + } + | { + /** + * @generated from field: agent.v1.ThinkingDeltaUpdate thinking_delta = 4; + */ + value: ThinkingDeltaUpdate; + case: "thinkingDelta"; + } + | { + /** + * @generated from field: agent.v1.ThinkingCompletedUpdate thinking_completed = 5; + */ + value: ThinkingCompletedUpdate; + case: "thinkingCompleted"; + } + | { + /** + * @generated from field: agent.v1.UserMessageAppendedUpdate user_message_appended = 6; + */ + value: UserMessageAppendedUpdate; + case: "userMessageAppended"; + } + | { + /** + * @generated from field: agent.v1.TokenDeltaUpdate token_delta = 8; + */ + value: TokenDeltaUpdate; + case: "tokenDelta"; + } + | { + /** + * @generated from field: agent.v1.SummaryUpdate summary = 9; + */ + value: SummaryUpdate; + case: "summary"; + } + | { + /** + * @generated from field: agent.v1.SummaryStartedUpdate summary_started = 10; + */ + value: SummaryStartedUpdate; + case: "summaryStarted"; + } + | { + /** + * @generated from field: agent.v1.SummaryCompletedUpdate summary_completed = 11; + */ + value: SummaryCompletedUpdate; + case: "summaryCompleted"; + } + | { + /** + * @generated from field: agent.v1.ShellOutputDeltaUpdate shell_output_delta = 12; + */ + value: ShellOutputDeltaUpdate; + case: "shellOutputDelta"; + } + | { + /** + * @generated from field: agent.v1.HeartbeatUpdate heartbeat = 13; + */ + value: HeartbeatUpdate; + case: "heartbeat"; + } + | { + /** + * @generated from field: agent.v1.TurnEndedUpdate turn_ended = 14; + */ + value: TurnEndedUpdate; + case: "turnEnded"; + } + | { + /** + * @generated from field: agent.v1.StepStartedUpdate step_started = 16; + */ + value: StepStartedUpdate; + case: "stepStarted"; + } + | { + /** + * @generated from field: agent.v1.StepCompletedUpdate step_completed = 17; + */ + value: StepCompletedUpdate; + case: "stepCompleted"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.InteractionUpdate. + * Use `create(InteractionUpdateSchema)` to create a new message. + */ +export const InteractionUpdateSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 109); + +/** + * Interaction query messages for bidirectional communication + * + * @generated from message agent.v1.InteractionQuery + */ +export type InteractionQuery = Message<"agent.v1.InteractionQuery"> & { + /** + * @generated from field: uint32 id = 1; + */ + id: number; + + /** + * @generated from oneof agent.v1.InteractionQuery.query + */ + query: + | { + /** + * @generated from field: agent.v1.WebSearchRequestQuery web_search_request_query = 2; + */ + value: WebSearchRequestQuery; + case: "webSearchRequestQuery"; + } + | { + /** + * @generated from field: agent.v1.AskQuestionInteractionQuery ask_question_interaction_query = 3; + */ + value: AskQuestionInteractionQuery; + case: "askQuestionInteractionQuery"; + } + | { + /** + * @generated from field: agent.v1.SwitchModeRequestQuery switch_mode_request_query = 4; + */ + value: SwitchModeRequestQuery; + case: "switchModeRequestQuery"; + } + | { + /** + * @generated from field: agent.v1.ExaSearchRequestQuery exa_search_request_query = 5; + */ + value: ExaSearchRequestQuery; + case: "exaSearchRequestQuery"; + } + | { + /** + * @generated from field: agent.v1.ExaFetchRequestQuery exa_fetch_request_query = 6; + */ + value: ExaFetchRequestQuery; + case: "exaFetchRequestQuery"; + } + | { + /** + * @generated from field: agent.v1.CreatePlanRequestQuery create_plan_request_query = 7; + */ + value: CreatePlanRequestQuery; + case: "createPlanRequestQuery"; + } + | { + /** + * @generated from field: agent.v1.SetupVmEnvironmentArgs setup_vm_environment_args = 8; + */ + value: SetupVmEnvironmentArgs; + case: "setupVmEnvironmentArgs"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.InteractionQuery. + * Use `create(InteractionQuerySchema)` to create a new message. + */ +export const InteractionQuerySchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 110); + +/** + * @generated from message agent.v1.InteractionResponse + */ +export type InteractionResponse = Message<"agent.v1.InteractionResponse"> & { + /** + * @generated from field: uint32 id = 1; + */ + id: number; + + /** + * @generated from oneof agent.v1.InteractionResponse.result + */ + result: + | { + /** + * @generated from field: agent.v1.WebSearchRequestResponse web_search_request_response = 2; + */ + value: WebSearchRequestResponse; + case: "webSearchRequestResponse"; + } + | { + /** + * @generated from field: agent.v1.AskQuestionInteractionResponse ask_question_interaction_response = 3; + */ + value: AskQuestionInteractionResponse; + case: "askQuestionInteractionResponse"; + } + | { + /** + * @generated from field: agent.v1.SwitchModeRequestResponse switch_mode_request_response = 4; + */ + value: SwitchModeRequestResponse; + case: "switchModeRequestResponse"; + } + | { + /** + * @generated from field: agent.v1.ExaSearchRequestResponse exa_search_request_response = 5; + */ + value: ExaSearchRequestResponse; + case: "exaSearchRequestResponse"; + } + | { + /** + * @generated from field: agent.v1.ExaFetchRequestResponse exa_fetch_request_response = 6; + */ + value: ExaFetchRequestResponse; + case: "exaFetchRequestResponse"; + } + | { + /** + * @generated from field: agent.v1.CreatePlanRequestResponse create_plan_request_response = 7; + */ + value: CreatePlanRequestResponse; + case: "createPlanRequestResponse"; + } + | { + /** + * @generated from field: agent.v1.SetupVmEnvironmentResult setup_vm_environment_result = 8; + */ + value: SetupVmEnvironmentResult; + case: "setupVmEnvironmentResult"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.InteractionResponse. + * Use `create(InteractionResponseSchema)` to create a new message. + */ +export const InteractionResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 111); + +/** + * @generated from message agent.v1.AskQuestionInteractionQuery + */ +export type AskQuestionInteractionQuery = Message<"agent.v1.AskQuestionInteractionQuery"> & { + /** + * @generated from field: agent.v1.AskQuestionArgs args = 1; + */ + args?: AskQuestionArgs; + + /** + * @generated from field: string tool_call_id = 2; + */ + toolCallId: string; +}; + +/** + * Describes the message agent.v1.AskQuestionInteractionQuery. + * Use `create(AskQuestionInteractionQuerySchema)` to create a new message. + */ +export const AskQuestionInteractionQuerySchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 112); + +/** + * @generated from message agent.v1.AskQuestionInteractionResponse + */ +export type AskQuestionInteractionResponse = Message<"agent.v1.AskQuestionInteractionResponse"> & { + /** + * @generated from field: agent.v1.AskQuestionResult result = 1; + */ + result?: AskQuestionResult; +}; + +/** + * Describes the message agent.v1.AskQuestionInteractionResponse. + * Use `create(AskQuestionInteractionResponseSchema)` to create a new message. + */ +export const AskQuestionInteractionResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 113); + +/** + * @generated from message agent.v1.ClientHeartbeat + */ +export type ClientHeartbeat = Message<"agent.v1.ClientHeartbeat"> & {}; + +/** + * Describes the message agent.v1.ClientHeartbeat. + * Use `create(ClientHeartbeatSchema)` to create a new message. + */ +export const ClientHeartbeatSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 114); + +/** + * Prewarm request - sent before the actual action to prepare the backend Contains all config needed for auth, model routing, and session building The actual ConversationAction is sent separately after prewarming completes + * + * @generated from message agent.v1.PrewarmRequest + */ +export type PrewarmRequest = Message<"agent.v1.PrewarmRequest"> & { + /** + * @generated from field: agent.v1.ModelDetails model_details = 1; + */ + modelDetails?: ModelDetails; + + /** + * @generated from field: optional agent.v1.RequestedModel requested_model = 9; + */ + requestedModel?: RequestedModel; + + /** + * @generated from field: optional string conversation_id = 2; + */ + conversationId?: string; + + /** + * @generated from field: agent.v1.ConversationStateStructure conversation_state = 3; + */ + conversationState?: ConversationStateStructure; + + /** + * @generated from field: agent.v1.McpTools mcp_tools = 4; + */ + mcpTools?: McpTools; + + /** + * @generated from field: optional agent.v1.McpFileSystemOptions mcp_file_system_options = 5; + */ + mcpFileSystemOptions?: McpFileSystemOptions; + + /** + * Best-of-N context for usage billing (same fields as UserMessage) + * + * @generated from field: optional string best_of_n_group_id = 6; + */ + bestOfNGroupId?: string; + + /** + * @generated from field: optional bool try_use_best_of_n_promotion = 7; + */ + tryUseBestOfNPromotion?: boolean; + + /** + * Custom system prompt override. Allowlisted for specific teams only. + * + * @generated from field: optional string custom_system_prompt = 8; + */ + customSystemPrompt?: string; +}; + +/** + * Describes the message agent.v1.PrewarmRequest. + * Use `create(PrewarmRequestSchema)` to create a new message. + */ +export const PrewarmRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 115); + +/** + * @generated from message agent.v1.ExecServerAbort + */ +export type ExecServerAbort = Message<"agent.v1.ExecServerAbort"> & { + /** + * @generated from field: uint32 id = 1; + */ + id: number; +}; + +/** + * Describes the message agent.v1.ExecServerAbort. + * Use `create(ExecServerAbortSchema)` to create a new message. + */ +export const ExecServerAbortSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 116); + +/** + * @generated from message agent.v1.ExecServerControlMessage + */ +export type ExecServerControlMessage = Message<"agent.v1.ExecServerControlMessage"> & { + /** + * @generated from oneof agent.v1.ExecServerControlMessage.message + */ + message: + | { + /** + * @generated from field: agent.v1.ExecServerAbort abort = 1; + */ + value: ExecServerAbort; + case: "abort"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.ExecServerControlMessage. + * Use `create(ExecServerControlMessageSchema)` to create a new message. + */ +export const ExecServerControlMessageSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 117); + +/** + * @generated from message agent.v1.AgentClientMessage + */ +export type AgentClientMessage = Message<"agent.v1.AgentClientMessage"> & { + /** + * @generated from oneof agent.v1.AgentClientMessage.message + */ + message: + | { + /** + * @generated from field: agent.v1.AgentRunRequest run_request = 1; + */ + value: AgentRunRequest; + case: "runRequest"; + } + | { + /** + * @generated from field: agent.v1.ExecClientMessage exec_client_message = 2; + */ + value: ExecClientMessage; + case: "execClientMessage"; + } + | { + /** + * @generated from field: agent.v1.ExecClientControlMessage exec_client_control_message = 5; + */ + value: ExecClientControlMessage; + case: "execClientControlMessage"; + } + | { + /** + * @generated from field: agent.v1.KvClientMessage kv_client_message = 3; + */ + value: KvClientMessage; + case: "kvClientMessage"; + } + | { + /** + * @generated from field: agent.v1.ConversationAction conversation_action = 4; + */ + value: ConversationAction; + case: "conversationAction"; + } + | { + /** + * @generated from field: agent.v1.InteractionResponse interaction_response = 6; + */ + value: InteractionResponse; + case: "interactionResponse"; + } + | { + /** + * @generated from field: agent.v1.ClientHeartbeat client_heartbeat = 7; + */ + value: ClientHeartbeat; + case: "clientHeartbeat"; + } + | { + /** + * @generated from field: agent.v1.PrewarmRequest prewarm_request = 8; + */ + value: PrewarmRequest; + case: "prewarmRequest"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.AgentClientMessage. + * Use `create(AgentClientMessageSchema)` to create a new message. + */ +export const AgentClientMessageSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 118); + +/** + * @generated from message agent.v1.AgentServerMessage + */ +export type AgentServerMessage = Message<"agent.v1.AgentServerMessage"> & { + /** + * @generated from oneof agent.v1.AgentServerMessage.message + */ + message: + | { + /** + * @generated from field: agent.v1.InteractionUpdate interaction_update = 1; + */ + value: InteractionUpdate; + case: "interactionUpdate"; + } + | { + /** + * @generated from field: agent.v1.ExecServerMessage exec_server_message = 2; + */ + value: ExecServerMessage; + case: "execServerMessage"; + } + | { + /** + * @generated from field: agent.v1.ExecServerControlMessage exec_server_control_message = 5; + */ + value: ExecServerControlMessage; + case: "execServerControlMessage"; + } + | { + /** + * @generated from field: agent.v1.ConversationStateStructure conversation_checkpoint_update = 3; + */ + value: ConversationStateStructure; + case: "conversationCheckpointUpdate"; + } + | { + /** + * @generated from field: agent.v1.KvServerMessage kv_server_message = 4; + */ + value: KvServerMessage; + case: "kvServerMessage"; + } + | { + /** + * @generated from field: agent.v1.InteractionQuery interaction_query = 7; + */ + value: InteractionQuery; + case: "interactionQuery"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.AgentServerMessage. + * Use `create(AgentServerMessageSchema)` to create a new message. + */ +export const AgentServerMessageSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 119); + +/** + * New unary API for naming an agent from a user message + * + * @generated from message agent.v1.NameAgentRequest + */ +export type NameAgentRequest = Message<"agent.v1.NameAgentRequest"> & { + /** + * @generated from field: string user_message = 1; + */ + userMessage: string; +}; + +/** + * Describes the message agent.v1.NameAgentRequest. + * Use `create(NameAgentRequestSchema)` to create a new message. + */ +export const NameAgentRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 120); + +/** + * @generated from message agent.v1.NameAgentResponse + */ +export type NameAgentResponse = Message<"agent.v1.NameAgentResponse"> & { + /** + * @generated from field: string name = 1; + */ + name: string; +}; + +/** + * Describes the message agent.v1.NameAgentResponse. + * Use `create(NameAgentResponseSchema)` to create a new message. + */ +export const NameAgentResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 121); + +/** + * @generated from message agent.v1.GetUsableModelsRequest + */ +export type GetUsableModelsRequest = Message<"agent.v1.GetUsableModelsRequest"> & { + /** + * Not used right now, but can use to populate info about custom models the user passes in that we don't send down by default + * + * @generated from field: repeated string custom_model_ids = 1; + */ + customModelIds: string[]; +}; + +/** + * Describes the message agent.v1.GetUsableModelsRequest. + * Use `create(GetUsableModelsRequestSchema)` to create a new message. + */ +export const GetUsableModelsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 122); + +/** + * @generated from message agent.v1.GetUsableModelsResponse + */ +export type GetUsableModelsResponse = Message<"agent.v1.GetUsableModelsResponse"> & { + /** + * @generated from field: repeated agent.v1.ModelDetails models = 1; + */ + models: ModelDetails[]; +}; + +/** + * Describes the message agent.v1.GetUsableModelsResponse. + * Use `create(GetUsableModelsResponseSchema)` to create a new message. + */ +export const GetUsableModelsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 123); + +/** + * @generated from message agent.v1.GetDefaultModelForCliRequest + */ +export type GetDefaultModelForCliRequest = Message<"agent.v1.GetDefaultModelForCliRequest"> & {}; + +/** + * Describes the message agent.v1.GetDefaultModelForCliRequest. + * Use `create(GetDefaultModelForCliRequestSchema)` to create a new message. + */ +export const GetDefaultModelForCliRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 124); + +/** + * @generated from message agent.v1.GetDefaultModelForCliResponse + */ +export type GetDefaultModelForCliResponse = Message<"agent.v1.GetDefaultModelForCliResponse"> & { + /** + * @generated from field: agent.v1.ModelDetails model = 1; + */ + model?: ModelDetails; +}; + +/** + * Describes the message agent.v1.GetDefaultModelForCliResponse. + * Use `create(GetDefaultModelForCliResponseSchema)` to create a new message. + */ +export const GetDefaultModelForCliResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 125); + +/** + * Internal endpoint: returns all allowed model intents for devs + * + * @generated from message agent.v1.GetAllowedModelIntentsRequest + */ +export type GetAllowedModelIntentsRequest = Message<"agent.v1.GetAllowedModelIntentsRequest"> & {}; + +/** + * Describes the message agent.v1.GetAllowedModelIntentsRequest. + * Use `create(GetAllowedModelIntentsRequestSchema)` to create a new message. + */ +export const GetAllowedModelIntentsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 126); + +/** + * @generated from message agent.v1.GetAllowedModelIntentsResponse + */ +export type GetAllowedModelIntentsResponse = Message<"agent.v1.GetAllowedModelIntentsResponse"> & { + /** + * @generated from field: repeated string model_intents = 1; + */ + modelIntents: string[]; +}; + +/** + * Describes the message agent.v1.GetAllowedModelIntentsResponse. + * Use `create(GetAllowedModelIntentsResponseSchema)` to create a new message. + */ +export const GetAllowedModelIntentsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 127); + +/** + * IDE state persistence for clients (CLI / VSCode integration) Mirrors a subset of aiserver.v1.ConversationMessage.IdeEditorsState, but only contains the recently viewed files and avoids any deprecated fields. + * + * @generated from message agent.v1.IdeEditorsStateFile + */ +export type IdeEditorsStateFile = Message<"agent.v1.IdeEditorsStateFile"> & { + /** + * @generated from field: string relative_path = 1; + */ + relativePath: string; + + /** + * @generated from field: string absolute_path = 2; + */ + absolutePath: string; + + /** + * @generated from field: optional bool is_currently_focused = 3; + */ + isCurrentlyFocused?: boolean; + + /** + * @generated from field: optional int32 current_line_number = 4; + */ + currentLineNumber?: number; + + /** + * @generated from field: optional string current_line_text = 5; + */ + currentLineText?: string; + + /** + * @generated from field: optional int32 line_count = 6; + */ + lineCount?: number; +}; + +/** + * Describes the message agent.v1.IdeEditorsStateFile. + * Use `create(IdeEditorsStateFileSchema)` to create a new message. + */ +export const IdeEditorsStateFileSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 128); + +/** + * @generated from message agent.v1.IdeEditorsStateLite + */ +export type IdeEditorsStateLite = Message<"agent.v1.IdeEditorsStateLite"> & { + /** + * @generated from field: repeated agent.v1.IdeEditorsStateFile recently_viewed_files = 1; + */ + recentlyViewedFiles: IdeEditorsStateFile[]; +}; + +/** + * Describes the message agent.v1.IdeEditorsStateLite. + * Use `create(IdeEditorsStateLiteSchema)` to create a new message. + */ +export const IdeEditorsStateLiteSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 129); + +/** + * @generated from message agent.v1.ApplyAgentDiffToolCall + */ +export type ApplyAgentDiffToolCall = Message<"agent.v1.ApplyAgentDiffToolCall"> & { + /** + * @generated from field: agent.v1.ApplyAgentDiffArgs args = 1; + */ + args?: ApplyAgentDiffArgs; + + /** + * @generated from field: agent.v1.ApplyAgentDiffResult result = 2; + */ + result?: ApplyAgentDiffResult; +}; + +/** + * Describes the message agent.v1.ApplyAgentDiffToolCall. + * Use `create(ApplyAgentDiffToolCallSchema)` to create a new message. + */ +export const ApplyAgentDiffToolCallSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 130); + +/** + * @generated from message agent.v1.ApplyAgentDiffArgs + */ +export type ApplyAgentDiffArgs = Message<"agent.v1.ApplyAgentDiffArgs"> & { + /** + * @generated from field: string agent_id = 1; + */ + agentId: string; +}; + +/** + * Describes the message agent.v1.ApplyAgentDiffArgs. + * Use `create(ApplyAgentDiffArgsSchema)` to create a new message. + */ +export const ApplyAgentDiffArgsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 131); + +/** + * @generated from message agent.v1.ApplyAgentDiffResult + */ +export type ApplyAgentDiffResult = Message<"agent.v1.ApplyAgentDiffResult"> & { + /** + * @generated from oneof agent.v1.ApplyAgentDiffResult.result + */ + result: + | { + /** + * @generated from field: agent.v1.ApplyAgentDiffSuccess success = 1; + */ + value: ApplyAgentDiffSuccess; + case: "success"; + } + | { + /** + * @generated from field: agent.v1.ApplyAgentDiffError error = 2; + */ + value: ApplyAgentDiffError; + case: "error"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.ApplyAgentDiffResult. + * Use `create(ApplyAgentDiffResultSchema)` to create a new message. + */ +export const ApplyAgentDiffResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 132); + +/** + * @generated from message agent.v1.ApplyAgentDiffSuccess + */ +export type ApplyAgentDiffSuccess = Message<"agent.v1.ApplyAgentDiffSuccess"> & { + /** + * @generated from field: repeated agent.v1.AppliedAgentChange applied_changes = 1; + */ + appliedChanges: AppliedAgentChange[]; +}; + +/** + * Describes the message agent.v1.ApplyAgentDiffSuccess. + * Use `create(ApplyAgentDiffSuccessSchema)` to create a new message. + */ +export const ApplyAgentDiffSuccessSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 133); + +/** + * @generated from message agent.v1.AppliedAgentChange + */ +export type AppliedAgentChange = Message<"agent.v1.AppliedAgentChange"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: int32 change_type = 2; + */ + changeType: number; + + /** + * @generated from field: optional string before_content = 3; + */ + beforeContent?: string; + + /** + * @generated from field: optional string after_content = 4; + */ + afterContent?: string; + + /** + * @generated from field: optional string error = 5; + */ + error?: string; + + /** + * Detailed result message from the execution (e.g., "Successfully deleted file: path (123 bytes)") + * + * @generated from field: optional string message_for_model = 6; + */ + messageForModel?: string; +}; + +/** + * Describes the message agent.v1.AppliedAgentChange. + * Use `create(AppliedAgentChangeSchema)` to create a new message. + */ +export const AppliedAgentChangeSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 134); + +/** + * @generated from message agent.v1.ApplyAgentDiffError + */ +export type ApplyAgentDiffError = Message<"agent.v1.ApplyAgentDiffError"> & { + /** + * @generated from field: string error = 1; + */ + error: string; + + /** + * @generated from field: repeated agent.v1.AppliedAgentChange applied_changes = 2; + */ + appliedChanges: AppliedAgentChange[]; +}; + +/** + * Describes the message agent.v1.ApplyAgentDiffError. + * Use `create(ApplyAgentDiffErrorSchema)` to create a new message. + */ +export const ApplyAgentDiffErrorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 135); + +/** + * @generated from message agent.v1.AskQuestionToolCall + */ +export type AskQuestionToolCall = Message<"agent.v1.AskQuestionToolCall"> & { + /** + * @generated from field: agent.v1.AskQuestionArgs args = 1; + */ + args?: AskQuestionArgs; + + /** + * @generated from field: agent.v1.AskQuestionResult result = 2; + */ + result?: AskQuestionResult; +}; + +/** + * Describes the message agent.v1.AskQuestionToolCall. + * Use `create(AskQuestionToolCallSchema)` to create a new message. + */ +export const AskQuestionToolCallSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 136); + +/** + * @generated from message agent.v1.AskQuestionArgs + */ +export type AskQuestionArgs = Message<"agent.v1.AskQuestionArgs"> & { + /** + * optional form title + * + * @generated from field: string title = 1; + */ + title: string; + + /** + * 1+ questions + * + * @generated from field: repeated agent.v1.AskQuestionArgs_Question questions = 2; + */ + questions: AskQuestionArgs_Question[]; + + /** + * if true, return immediately with async marker instead of blocking + * + * @generated from field: bool run_async = 5; + */ + runAsync: boolean; + + /** + * if set, indicates this is a synthetic completion for the original async tool call with this ID + * + * @generated from field: string async_original_tool_call_id = 6; + */ + asyncOriginalToolCallId: string; +}; + +/** + * Describes the message agent.v1.AskQuestionArgs. + * Use `create(AskQuestionArgsSchema)` to create a new message. + */ +export const AskQuestionArgsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 137); + +/** + * @generated from message agent.v1.AskQuestionArgs_Question + */ +export type AskQuestionArgs_Question = Message<"agent.v1.AskQuestionArgs_Question"> & { + /** + * unique, model-provided + * + * @generated from field: string id = 1; + */ + id: string; + + /** + * the question text + * + * @generated from field: string prompt = 2; + */ + prompt: string; + + /** + * choices + * + * @generated from field: repeated agent.v1.AskQuestionArgs_Option options = 3; + */ + options: AskQuestionArgs_Option[]; + + /** + * multi-select vs single-select + * + * @generated from field: bool allow_multiple = 4; + */ + allowMultiple: boolean; +}; + +/** + * Describes the message agent.v1.AskQuestionArgs_Question. + * Use `create(AskQuestionArgs_QuestionSchema)` to create a new message. + */ +export const AskQuestionArgs_QuestionSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 138); + +/** + * @generated from message agent.v1.AskQuestionArgs_Option + */ +export type AskQuestionArgs_Option = Message<"agent.v1.AskQuestionArgs_Option"> & { + /** + * stable option id + * + * @generated from field: string id = 1; + */ + id: string; + + /** + * display text + * + * @generated from field: string label = 2; + */ + label: string; +}; + +/** + * Describes the message agent.v1.AskQuestionArgs_Option. + * Use `create(AskQuestionArgs_OptionSchema)` to create a new message. + */ +export const AskQuestionArgs_OptionSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 139); + +/** + * Marker indicating that questions have been sent asynchronously Answers will arrive later as a separate ask_question tool call + * + * @generated from message agent.v1.AskQuestionAsync + */ +export type AskQuestionAsync = Message<"agent.v1.AskQuestionAsync"> & {}; + +/** + * Describes the message agent.v1.AskQuestionAsync. + * Use `create(AskQuestionAsyncSchema)` to create a new message. + */ +export const AskQuestionAsyncSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 140); + +/** + * @generated from message agent.v1.AskQuestionResult + */ +export type AskQuestionResult = Message<"agent.v1.AskQuestionResult"> & { + /** + * @generated from oneof agent.v1.AskQuestionResult.result + */ + result: + | { + /** + * @generated from field: agent.v1.AskQuestionSuccess success = 1; + */ + value: AskQuestionSuccess; + case: "success"; + } + | { + /** + * @generated from field: agent.v1.AskQuestionError error = 2; + */ + value: AskQuestionError; + case: "error"; + } + | { + /** + * @generated from field: agent.v1.AskQuestionRejected rejected = 3; + */ + value: AskQuestionRejected; + case: "rejected"; + } + | { + /** + * @generated from field: agent.v1.AskQuestionAsync async = 4; + */ + value: AskQuestionAsync; + case: "async"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.AskQuestionResult. + * Use `create(AskQuestionResultSchema)` to create a new message. + */ +export const AskQuestionResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 141); + +/** + * @generated from message agent.v1.AskQuestionSuccess + */ +export type AskQuestionSuccess = Message<"agent.v1.AskQuestionSuccess"> & { + /** + * @generated from field: repeated agent.v1.AskQuestionSuccess_Answer answers = 1; + */ + answers: AskQuestionSuccess_Answer[]; +}; + +/** + * Describes the message agent.v1.AskQuestionSuccess. + * Use `create(AskQuestionSuccessSchema)` to create a new message. + */ +export const AskQuestionSuccessSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 142); + +/** + * @generated from message agent.v1.AskQuestionSuccess_Answer + */ +export type AskQuestionSuccess_Answer = Message<"agent.v1.AskQuestionSuccess_Answer"> & { + /** + * @generated from field: string question_id = 1; + */ + questionId: string; + + /** + * empty if unanswered + * + * @generated from field: repeated string selected_option_ids = 2; + */ + selectedOptionIds: string[]; +}; + +/** + * Describes the message agent.v1.AskQuestionSuccess_Answer. + * Use `create(AskQuestionSuccess_AnswerSchema)` to create a new message. + */ +export const AskQuestionSuccess_AnswerSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 143); + +/** + * @generated from message agent.v1.AskQuestionError + */ +export type AskQuestionError = Message<"agent.v1.AskQuestionError"> & { + /** + * @generated from field: string error_message = 1; + */ + errorMessage: string; +}; + +/** + * Describes the message agent.v1.AskQuestionError. + * Use `create(AskQuestionErrorSchema)` to create a new message. + */ +export const AskQuestionErrorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 144); + +/** + * @generated from message agent.v1.AskQuestionRejected + */ +export type AskQuestionRejected = Message<"agent.v1.AskQuestionRejected"> & { + /** + * @generated from field: string reason = 1; + */ + reason: string; +}; + +/** + * Describes the message agent.v1.AskQuestionRejected. + * Use `create(AskQuestionRejectedSchema)` to create a new message. + */ +export const AskQuestionRejectedSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 145); + +/** + * @generated from message agent.v1.BackgroundShellSpawnArgs + */ +export type BackgroundShellSpawnArgs = Message<"agent.v1.BackgroundShellSpawnArgs"> & { + /** + * @generated from field: string command = 1; + */ + command: string; + + /** + * @generated from field: string working_directory = 2; + */ + workingDirectory: string; + + /** + * @generated from field: string tool_call_id = 3; + */ + toolCallId: string; + + /** + * @generated from field: agent.v1.ShellCommandParsingResult parsing_result = 4; + */ + parsingResult?: ShellCommandParsingResult; + + /** + * @generated from field: optional agent.v1.SandboxPolicy sandbox_policy = 5; + */ + sandboxPolicy?: SandboxPolicy; + + /** + * @generated from field: bool enable_write_shell_stdin_tool = 6; + */ + enableWriteShellStdinTool: boolean; +}; + +/** + * Describes the message agent.v1.BackgroundShellSpawnArgs. + * Use `create(BackgroundShellSpawnArgsSchema)` to create a new message. + */ +export const BackgroundShellSpawnArgsSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 146); + +/** + * Result of spawning a background shell + * + * @generated from message agent.v1.BackgroundShellSpawnResult + */ +export type BackgroundShellSpawnResult = Message<"agent.v1.BackgroundShellSpawnResult"> & { + /** + * @generated from oneof agent.v1.BackgroundShellSpawnResult.result + */ + result: + | { + /** + * @generated from field: agent.v1.BackgroundShellSpawnSuccess success = 1; + */ + value: BackgroundShellSpawnSuccess; + case: "success"; + } + | { + /** + * @generated from field: agent.v1.BackgroundShellSpawnError error = 2; + */ + value: BackgroundShellSpawnError; + case: "error"; + } + | { + /** + * @generated from field: agent.v1.ShellRejected rejected = 3; + */ + value: ShellRejected; + case: "rejected"; + } + | { + /** + * @generated from field: agent.v1.ShellPermissionDenied permission_denied = 4; + */ + value: ShellPermissionDenied; + case: "permissionDenied"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.BackgroundShellSpawnResult. + * Use `create(BackgroundShellSpawnResultSchema)` to create a new message. + */ +export const BackgroundShellSpawnResultSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 147); + +/** + * @generated from message agent.v1.BackgroundShellSpawnSuccess + */ +export type BackgroundShellSpawnSuccess = Message<"agent.v1.BackgroundShellSpawnSuccess"> & { + /** + * @generated from field: uint32 shell_id = 1; + */ + shellId: number; + + /** + * @generated from field: string command = 2; + */ + command: string; + + /** + * @generated from field: string working_directory = 3; + */ + workingDirectory: string; + + /** + * Process ID of the spawned shell + * + * @generated from field: optional uint32 pid = 4; + */ + pid?: number; +}; + +/** + * Describes the message agent.v1.BackgroundShellSpawnSuccess. + * Use `create(BackgroundShellSpawnSuccessSchema)` to create a new message. + */ +export const BackgroundShellSpawnSuccessSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 148); + +/** + * @generated from message agent.v1.BackgroundShellSpawnError + */ +export type BackgroundShellSpawnError = Message<"agent.v1.BackgroundShellSpawnError"> & { + /** + * @generated from field: string command = 1; + */ + command: string; + + /** + * @generated from field: string working_directory = 2; + */ + workingDirectory: string; + + /** + * @generated from field: string error = 3; + */ + error: string; +}; + +/** + * Describes the message agent.v1.BackgroundShellSpawnError. + * Use `create(BackgroundShellSpawnErrorSchema)` to create a new message. + */ +export const BackgroundShellSpawnErrorSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 149); + +/** + * @generated from message agent.v1.WriteShellStdinArgs + */ +export type WriteShellStdinArgs = Message<"agent.v1.WriteShellStdinArgs"> & { + /** + * @generated from field: uint32 shell_id = 1; + */ + shellId: number; + + /** + * @generated from field: string chars = 2; + */ + chars: string; +}; + +/** + * Describes the message agent.v1.WriteShellStdinArgs. + * Use `create(WriteShellStdinArgsSchema)` to create a new message. + */ +export const WriteShellStdinArgsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 150); + +/** + * @generated from message agent.v1.WriteShellStdinResult + */ +export type WriteShellStdinResult = Message<"agent.v1.WriteShellStdinResult"> & { + /** + * @generated from oneof agent.v1.WriteShellStdinResult.result + */ + result: + | { + /** + * @generated from field: agent.v1.WriteShellStdinSuccess success = 1; + */ + value: WriteShellStdinSuccess; + case: "success"; + } + | { + /** + * @generated from field: agent.v1.WriteShellStdinError error = 2; + */ + value: WriteShellStdinError; + case: "error"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.WriteShellStdinResult. + * Use `create(WriteShellStdinResultSchema)` to create a new message. + */ +export const WriteShellStdinResultSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 151); + +/** + * @generated from message agent.v1.WriteShellStdinSuccess + */ +export type WriteShellStdinSuccess = Message<"agent.v1.WriteShellStdinSuccess"> & { + /** + * @generated from field: uint32 shell_id = 1; + */ + shellId: number; + + /** + * @generated from field: uint32 terminal_file_length_before_input_written = 2; + */ + terminalFileLengthBeforeInputWritten: number; +}; + +/** + * Describes the message agent.v1.WriteShellStdinSuccess. + * Use `create(WriteShellStdinSuccessSchema)` to create a new message. + */ +export const WriteShellStdinSuccessSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 152); + +/** + * @generated from message agent.v1.WriteShellStdinError + */ +export type WriteShellStdinError = Message<"agent.v1.WriteShellStdinError"> & { + /** + * @generated from field: string error = 1; + */ + error: string; +}; + +/** + * Describes the message agent.v1.WriteShellStdinError. + * Use `create(WriteShellStdinErrorSchema)` to create a new message. + */ +export const WriteShellStdinErrorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 153); + +/** + * @generated from message agent.v1.Coordinate + */ +export type Coordinate = Message<"agent.v1.Coordinate"> & { + /** + * @generated from field: int32 x = 1; + */ + x: number; + + /** + * @generated from field: int32 y = 2; + */ + y: number; +}; + +/** + * Describes the message agent.v1.Coordinate. + * Use `create(CoordinateSchema)` to create a new message. + */ +export const CoordinateSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 154); + +/** + * Arguments for the computer-use tool + * + * @generated from message agent.v1.ComputerUseArgs + */ +export type ComputerUseArgs = Message<"agent.v1.ComputerUseArgs"> & { + /** + * @generated from field: string tool_call_id = 1; + */ + toolCallId: string; + + /** + * @generated from field: repeated agent.v1.ComputerUseAction actions = 2; + */ + actions: ComputerUseAction[]; +}; + +/** + * Describes the message agent.v1.ComputerUseArgs. + * Use `create(ComputerUseArgsSchema)` to create a new message. + */ +export const ComputerUseArgsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 155); + +/** + * A single computer-use action. This is our internal canonical representation. Provider-specific formats are converted to this by adapters. + * + * @generated from message agent.v1.ComputerUseAction + */ +export type ComputerUseAction = Message<"agent.v1.ComputerUseAction"> & { + /** + * @generated from oneof agent.v1.ComputerUseAction.action + */ + action: + | { + /** + * @generated from field: agent.v1.MouseMoveAction mouse_move = 1; + */ + value: MouseMoveAction; + case: "mouseMove"; + } + | { + /** + * @generated from field: agent.v1.ClickAction click = 2; + */ + value: ClickAction; + case: "click"; + } + | { + /** + * @generated from field: agent.v1.MouseDownAction mouse_down = 3; + */ + value: MouseDownAction; + case: "mouseDown"; + } + | { + /** + * @generated from field: agent.v1.MouseUpAction mouse_up = 4; + */ + value: MouseUpAction; + case: "mouseUp"; + } + | { + /** + * @generated from field: agent.v1.DragAction drag = 5; + */ + value: DragAction; + case: "drag"; + } + | { + /** + * @generated from field: agent.v1.ScrollAction scroll = 6; + */ + value: ScrollAction; + case: "scroll"; + } + | { + /** + * @generated from field: agent.v1.TypeAction type = 7; + */ + value: TypeAction; + case: "type"; + } + | { + /** + * @generated from field: agent.v1.KeyAction key = 8; + */ + value: KeyAction; + case: "key"; + } + | { + /** + * @generated from field: agent.v1.WaitAction wait = 9; + */ + value: WaitAction; + case: "wait"; + } + | { + /** + * @generated from field: agent.v1.ScreenshotAction screenshot = 10; + */ + value: ScreenshotAction; + case: "screenshot"; + } + | { + /** + * @generated from field: agent.v1.CursorPositionAction cursor_position = 11; + */ + value: CursorPositionAction; + case: "cursorPosition"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.ComputerUseAction. + * Use `create(ComputerUseActionSchema)` to create a new message. + */ +export const ComputerUseActionSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 156); + +/** + * Move mouse to coordinate (required) + * + * @generated from message agent.v1.MouseMoveAction + */ +export type MouseMoveAction = Message<"agent.v1.MouseMoveAction"> & { + /** + * @generated from field: agent.v1.Coordinate coordinate = 1; + */ + coordinate?: Coordinate; +}; + +/** + * Describes the message agent.v1.MouseMoveAction. + * Use `create(MouseMoveActionSchema)` to create a new message. + */ +export const MouseMoveActionSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 157); + +/** + * Unified click action - coordinate: optional, clicks at current cursor if omitted - button: which mouse button (default: LEFT) - count: click count (1=single, 2=double, 3=triple, default: 1) - modifier_keys: optional, held during click (e.g., "ctrl", "shift", "ctrl+shift") + * + * @generated from message agent.v1.ClickAction + */ +export type ClickAction = Message<"agent.v1.ClickAction"> & { + /** + * @generated from field: optional agent.v1.Coordinate coordinate = 1; + */ + coordinate?: Coordinate; + + /** + * @generated from field: int32 button = 2; + */ + button: number; + + /** + * @generated from field: int32 count = 3; + */ + count: number; + + /** + * @generated from field: optional string modifier_keys = 4; + */ + modifierKeys?: string; +}; + +/** + * Describes the message agent.v1.ClickAction. + * Use `create(ClickActionSchema)` to create a new message. + */ +export const ClickActionSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 158); + +/** + * Press mouse button down (for fine-grained drag control) + * + * @generated from message agent.v1.MouseDownAction + */ +export type MouseDownAction = Message<"agent.v1.MouseDownAction"> & { + /** + * @generated from field: int32 button = 1; + */ + button: number; +}; + +/** + * Describes the message agent.v1.MouseDownAction. + * Use `create(MouseDownActionSchema)` to create a new message. + */ +export const MouseDownActionSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 159); + +/** + * Release mouse button (for fine-grained drag control) + * + * @generated from message agent.v1.MouseUpAction + */ +export type MouseUpAction = Message<"agent.v1.MouseUpAction"> & { + /** + * @generated from field: int32 button = 1; + */ + button: number; +}; + +/** + * Describes the message agent.v1.MouseUpAction. + * Use `create(MouseUpActionSchema)` to create a new message. + */ +export const MouseUpActionSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 160); + +/** + * Drag action - path of coordinates (at least 2 points: [start, ..., end]) + * + * @generated from message agent.v1.DragAction + */ +export type DragAction = Message<"agent.v1.DragAction"> & { + /** + * @generated from field: repeated agent.v1.Coordinate path = 1; + */ + path: Coordinate[]; + + /** + * @generated from field: int32 button = 2; + */ + button: number; +}; + +/** + * Describes the message agent.v1.DragAction. + * Use `create(DragActionSchema)` to create a new message. + */ +export const DragActionSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 161); + +/** + * Scroll action - coordinate: optional, scrolls at current cursor if omitted - direction: scroll direction (required) - amount: number of scroll "clicks" (default: 3) - modifier_keys: optional, held during scroll (e.g., "ctrl" for zoom) + * + * @generated from message agent.v1.ScrollAction + */ +export type ScrollAction = Message<"agent.v1.ScrollAction"> & { + /** + * @generated from field: optional agent.v1.Coordinate coordinate = 1; + */ + coordinate?: Coordinate; + + /** + * @generated from field: int32 direction = 2; + */ + direction: number; + + /** + * @generated from field: int32 amount = 3; + */ + amount: number; + + /** + * @generated from field: optional string modifier_keys = 4; + */ + modifierKeys?: string; +}; + +/** + * Describes the message agent.v1.ScrollAction. + * Use `create(ScrollActionSchema)` to create a new message. + */ +export const ScrollActionSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 162); + +/** + * Type text + * + * @generated from message agent.v1.TypeAction + */ +export type TypeAction = Message<"agent.v1.TypeAction"> & { + /** + * @generated from field: string text = 1; + */ + text: string; +}; + +/** + * Describes the message agent.v1.TypeAction. + * Use `create(TypeActionSchema)` to create a new message. + */ +export const TypeActionSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 163); + +/** + * Press key or key combination (xdotool-style: "ctrl+a", "Return", "Alt+Left") If hold_duration_ms is set, holds the key for that duration + * + * @generated from message agent.v1.KeyAction + */ +export type KeyAction = Message<"agent.v1.KeyAction"> & { + /** + * @generated from field: string key = 1; + */ + key: string; + + /** + * @generated from field: optional int32 hold_duration_ms = 2; + */ + holdDurationMs?: number; +}; + +/** + * Describes the message agent.v1.KeyAction. + * Use `create(KeyActionSchema)` to create a new message. + */ +export const KeyActionSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 164); + +/** + * Wait for a duration + * + * @generated from message agent.v1.WaitAction + */ +export type WaitAction = Message<"agent.v1.WaitAction"> & { + /** + * @generated from field: int32 duration_ms = 1; + */ + durationMs: number; +}; + +/** + * Describes the message agent.v1.WaitAction. + * Use `create(WaitActionSchema)` to create a new message. + */ +export const WaitActionSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 165); + +/** + * Take a screenshot + * + * @generated from message agent.v1.ScreenshotAction + */ +export type ScreenshotAction = Message<"agent.v1.ScreenshotAction"> & {}; + +/** + * Describes the message agent.v1.ScreenshotAction. + * Use `create(ScreenshotActionSchema)` to create a new message. + */ +export const ScreenshotActionSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 166); + +/** + * Get current cursor position + * + * @generated from message agent.v1.CursorPositionAction + */ +export type CursorPositionAction = Message<"agent.v1.CursorPositionAction"> & {}; + +/** + * Describes the message agent.v1.CursorPositionAction. + * Use `create(CursorPositionActionSchema)` to create a new message. + */ +export const CursorPositionActionSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 167); + +/** + * Result of computer-use execution + * + * @generated from message agent.v1.ComputerUseResult + */ +export type ComputerUseResult = Message<"agent.v1.ComputerUseResult"> & { + /** + * @generated from oneof agent.v1.ComputerUseResult.result + */ + result: + | { + /** + * @generated from field: agent.v1.ComputerUseSuccess success = 1; + */ + value: ComputerUseSuccess; + case: "success"; + } + | { + /** + * @generated from field: agent.v1.ComputerUseError error = 2; + */ + value: ComputerUseError; + case: "error"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.ComputerUseResult. + * Use `create(ComputerUseResultSchema)` to create a new message. + */ +export const ComputerUseResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 168); + +/** + * @generated from message agent.v1.ComputerUseSuccess + */ +export type ComputerUseSuccess = Message<"agent.v1.ComputerUseSuccess"> & { + /** + * @generated from field: int32 action_count = 1; + */ + actionCount: number; + + /** + * @generated from field: int32 duration_ms = 2; + */ + durationMs: number; + + /** + * Base64 WebP at API resolution + * + * @generated from field: optional string screenshot = 3; + */ + screenshot?: string; + + /** + * @generated from field: optional string log = 4; + */ + log?: string; + + /** + * @generated from field: optional string screenshot_path = 5; + */ + screenshotPath?: string; + + /** + * In API resolution + * + * @generated from field: optional agent.v1.Coordinate cursor_position = 6; + */ + cursorPosition?: Coordinate; +}; + +/** + * Describes the message agent.v1.ComputerUseSuccess. + * Use `create(ComputerUseSuccessSchema)` to create a new message. + */ +export const ComputerUseSuccessSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 169); + +/** + * @generated from message agent.v1.ComputerUseError + */ +export type ComputerUseError = Message<"agent.v1.ComputerUseError"> & { + /** + * @generated from field: string error = 1; + */ + error: string; + + /** + * @generated from field: int32 action_count = 2; + */ + actionCount: number; + + /** + * @generated from field: int32 duration_ms = 3; + */ + durationMs: number; + + /** + * @generated from field: optional string log = 4; + */ + log?: string; + + /** + * Base64 WebP of screen state at error time + * + * @generated from field: optional string screenshot = 5; + */ + screenshot?: string; + + /** + * Path where screenshot was saved + * + * @generated from field: optional string screenshot_path = 6; + */ + screenshotPath?: string; +}; + +/** + * Describes the message agent.v1.ComputerUseError. + * Use `create(ComputerUseErrorSchema)` to create a new message. + */ +export const ComputerUseErrorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 170); + +/** + * @generated from message agent.v1.ComputerUseToolCall + */ +export type ComputerUseToolCall = Message<"agent.v1.ComputerUseToolCall"> & { + /** + * @generated from field: agent.v1.ComputerUseArgs args = 1; + */ + args?: ComputerUseArgs; + + /** + * @generated from field: agent.v1.ComputerUseResult result = 2; + */ + result?: ComputerUseResult; +}; + +/** + * Describes the message agent.v1.ComputerUseToolCall. + * Use `create(ComputerUseToolCallSchema)` to create a new message. + */ +export const ComputerUseToolCallSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 171); + +/** + * @generated from message agent.v1.CreatePlanToolCall + */ +export type CreatePlanToolCall = Message<"agent.v1.CreatePlanToolCall"> & { + /** + * @generated from field: agent.v1.CreatePlanArgs args = 1; + */ + args?: CreatePlanArgs; + + /** + * @generated from field: agent.v1.CreatePlanResult result = 2; + */ + result?: CreatePlanResult; +}; + +/** + * Describes the message agent.v1.CreatePlanToolCall. + * Use `create(CreatePlanToolCallSchema)` to create a new message. + */ +export const CreatePlanToolCallSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 172); + +/** + * A phase groups related todos together for project-mode plans + * + * @generated from message agent.v1.Phase + */ +export type Phase = Message<"agent.v1.Phase"> & { + /** + * @generated from field: string name = 1; + */ + name: string; + + /** + * @generated from field: repeated agent.v1.TodoItem todos = 2; + */ + todos: TodoItem[]; +}; + +/** + * Describes the message agent.v1.Phase. + * Use `create(PhaseSchema)` to create a new message. + */ +export const PhaseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 173); + +/** + * @generated from message agent.v1.CreatePlanArgs + */ +export type CreatePlanArgs = Message<"agent.v1.CreatePlanArgs"> & { + /** + * @generated from field: string plan = 1; + */ + plan: string; + + /** + * @generated from field: repeated agent.v1.TodoItem todos = 2; + */ + todos: TodoItem[]; + + /** + * @generated from field: string overview = 3; + */ + overview: string; + + /** + * @generated from field: string name = 4; + */ + name: string; + + /** + * When true, uses phases instead of flat todos (mutually exclusive) + * + * @generated from field: bool is_project = 5; + */ + isProject: boolean; + + /** + * Implementation phases (only valid when is_project=true) + * + * @generated from field: repeated agent.v1.Phase phases = 6; + */ + phases: Phase[]; +}; + +/** + * Describes the message agent.v1.CreatePlanArgs. + * Use `create(CreatePlanArgsSchema)` to create a new message. + */ +export const CreatePlanArgsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 174); + +/** + * @generated from message agent.v1.CreatePlanResult + */ +export type CreatePlanResult = Message<"agent.v1.CreatePlanResult"> & { + /** + * URI of the plan file (returned when file_based_plan_edits is enabled) + * + * @generated from field: string plan_uri = 3; + */ + planUri: string; + + /** + * @generated from oneof agent.v1.CreatePlanResult.result + */ + result: + | { + /** + * @generated from field: agent.v1.CreatePlanSuccess success = 1; + */ + value: CreatePlanSuccess; + case: "success"; + } + | { + /** + * @generated from field: agent.v1.CreatePlanError error = 2; + */ + value: CreatePlanError; + case: "error"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.CreatePlanResult. + * Use `create(CreatePlanResultSchema)` to create a new message. + */ +export const CreatePlanResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 175); + +/** + * @generated from message agent.v1.CreatePlanSuccess + */ +export type CreatePlanSuccess = Message<"agent.v1.CreatePlanSuccess"> & {}; + +/** + * Describes the message agent.v1.CreatePlanSuccess. + * Use `create(CreatePlanSuccessSchema)` to create a new message. + */ +export const CreatePlanSuccessSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 176); + +/** + * @generated from message agent.v1.CreatePlanError + */ +export type CreatePlanError = Message<"agent.v1.CreatePlanError"> & { + /** + * @generated from field: string error = 1; + */ + error: string; +}; + +/** + * Describes the message agent.v1.CreatePlanError. + * Use `create(CreatePlanErrorSchema)` to create a new message. + */ +export const CreatePlanErrorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 177); + +/** + * Query sent from server to client to create a plan file + * + * @generated from message agent.v1.CreatePlanRequestQuery + */ +export type CreatePlanRequestQuery = Message<"agent.v1.CreatePlanRequestQuery"> & { + /** + * @generated from field: agent.v1.CreatePlanArgs args = 1; + */ + args?: CreatePlanArgs; + + /** + * @generated from field: string tool_call_id = 2; + */ + toolCallId: string; +}; + +/** + * Describes the message agent.v1.CreatePlanRequestQuery. + * Use `create(CreatePlanRequestQuerySchema)` to create a new message. + */ +export const CreatePlanRequestQuerySchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 178); + +/** + * Response from client with the created plan URI + * + * @generated from message agent.v1.CreatePlanRequestResponse + */ +export type CreatePlanRequestResponse = Message<"agent.v1.CreatePlanRequestResponse"> & { + /** + * @generated from field: agent.v1.CreatePlanResult result = 1; + */ + result?: CreatePlanResult; +}; + +/** + * Describes the message agent.v1.CreatePlanRequestResponse. + * Use `create(CreatePlanRequestResponseSchema)` to create a new message. + */ +export const CreatePlanRequestResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 179); + +/** + * @generated from message agent.v1.CursorRuleTypeGlobal + */ +export type CursorRuleTypeGlobal = Message<"agent.v1.CursorRuleTypeGlobal"> & {}; + +/** + * Describes the message agent.v1.CursorRuleTypeGlobal. + * Use `create(CursorRuleTypeGlobalSchema)` to create a new message. + */ +export const CursorRuleTypeGlobalSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 180); + +/** + * @generated from message agent.v1.CursorRuleTypeFileGlobs + */ +export type CursorRuleTypeFileGlobs = Message<"agent.v1.CursorRuleTypeFileGlobs"> & { + /** + * @generated from field: repeated string globs = 1; + */ + globs: string[]; +}; + +/** + * Describes the message agent.v1.CursorRuleTypeFileGlobs. + * Use `create(CursorRuleTypeFileGlobsSchema)` to create a new message. + */ +export const CursorRuleTypeFileGlobsSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 181); + +/** + * @generated from message agent.v1.CursorRuleTypeAgentFetched + */ +export type CursorRuleTypeAgentFetched = Message<"agent.v1.CursorRuleTypeAgentFetched"> & { + /** + * @generated from field: string description = 1; + */ + description: string; +}; + +/** + * Describes the message agent.v1.CursorRuleTypeAgentFetched. + * Use `create(CursorRuleTypeAgentFetchedSchema)` to create a new message. + */ +export const CursorRuleTypeAgentFetchedSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 182); + +/** + * @generated from message agent.v1.CursorRuleTypeManuallyAttached + */ +export type CursorRuleTypeManuallyAttached = Message<"agent.v1.CursorRuleTypeManuallyAttached"> & {}; + +/** + * Describes the message agent.v1.CursorRuleTypeManuallyAttached. + * Use `create(CursorRuleTypeManuallyAttachedSchema)` to create a new message. + */ +export const CursorRuleTypeManuallyAttachedSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 183); + +/** + * @generated from message agent.v1.CursorRuleType + */ +export type CursorRuleType = Message<"agent.v1.CursorRuleType"> & { + /** + * @generated from oneof agent.v1.CursorRuleType.type + */ + type: + | { + /** + * @generated from field: agent.v1.CursorRuleTypeGlobal global = 1; + */ + value: CursorRuleTypeGlobal; + case: "global"; + } + | { + /** + * @generated from field: agent.v1.CursorRuleTypeFileGlobs file_globbed = 2; + */ + value: CursorRuleTypeFileGlobs; + case: "fileGlobbed"; + } + | { + /** + * @generated from field: agent.v1.CursorRuleTypeAgentFetched agent_fetched = 3; + */ + value: CursorRuleTypeAgentFetched; + case: "agentFetched"; + } + | { + /** + * @generated from field: agent.v1.CursorRuleTypeManuallyAttached manually_attached = 4; + */ + value: CursorRuleTypeManuallyAttached; + case: "manuallyAttached"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.CursorRuleType. + * Use `create(CursorRuleTypeSchema)` to create a new message. + */ +export const CursorRuleTypeSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 184); + +/** + * @generated from message agent.v1.CursorRule + */ +export type CursorRule = Message<"agent.v1.CursorRule"> & { + /** + * absolute path to the .mdc file + * + * @generated from field: string full_path = 1; + */ + fullPath: string; + + /** + * rule body, trimmed to reasonable size if needed by client + * + * @generated from field: string content = 2; + */ + content: string; + + /** + * classification of rule + * + * @generated from field: agent.v1.CursorRuleType type = 3; + */ + type?: CursorRuleType; + + /** + * source of the rule + * + * @generated from field: int32 source = 4; + */ + source: number; + + /** + * Git remote origin URL for the repository containing this rule, if available. Normalized to host/path format (e.g., "github.com/owner/repo"). + * + * @generated from field: optional string git_remote_origin = 5; + */ + gitRemoteOrigin?: string; + + /** + * @generated from field: optional string parse_error = 6; + */ + parseError?: string; +}; + +/** + * Describes the message agent.v1.CursorRule. + * Use `create(CursorRuleSchema)` to create a new message. + */ +export const CursorRuleSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 185); + +/** + * @generated from message agent.v1.DeleteArgs + */ +export type DeleteArgs = Message<"agent.v1.DeleteArgs"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string tool_call_id = 2; + */ + toolCallId: string; +}; + +/** + * Describes the message agent.v1.DeleteArgs. + * Use `create(DeleteArgsSchema)` to create a new message. + */ +export const DeleteArgsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 186); + +/** + * @generated from message agent.v1.DeleteResult + */ +export type DeleteResult = Message<"agent.v1.DeleteResult"> & { + /** + * @generated from oneof agent.v1.DeleteResult.result + */ + result: + | { + /** + * @generated from field: agent.v1.DeleteSuccess success = 1; + */ + value: DeleteSuccess; + case: "success"; + } + | { + /** + * @generated from field: agent.v1.DeleteFileNotFound file_not_found = 2; + */ + value: DeleteFileNotFound; + case: "fileNotFound"; + } + | { + /** + * @generated from field: agent.v1.DeleteNotFile not_file = 3; + */ + value: DeleteNotFile; + case: "notFile"; + } + | { + /** + * @generated from field: agent.v1.DeletePermissionDenied permission_denied = 4; + */ + value: DeletePermissionDenied; + case: "permissionDenied"; + } + | { + /** + * @generated from field: agent.v1.DeleteFileBusy file_busy = 5; + */ + value: DeleteFileBusy; + case: "fileBusy"; + } + | { + /** + * @generated from field: agent.v1.DeleteRejected rejected = 6; + */ + value: DeleteRejected; + case: "rejected"; + } + | { + /** + * @generated from field: agent.v1.DeleteError error = 7; + */ + value: DeleteError; + case: "error"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.DeleteResult. + * Use `create(DeleteResultSchema)` to create a new message. + */ +export const DeleteResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 187); + +/** + * @generated from message agent.v1.DeleteSuccess + */ +export type DeleteSuccess = Message<"agent.v1.DeleteSuccess"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string deleted_file = 2; + */ + deletedFile: string; + + /** + * @generated from field: int64 file_size = 3; + */ + fileSize: bigint; + + /** + * @generated from field: string prev_content = 4; + */ + prevContent: string; +}; + +/** + * Describes the message agent.v1.DeleteSuccess. + * Use `create(DeleteSuccessSchema)` to create a new message. + */ +export const DeleteSuccessSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 188); + +/** + * @generated from message agent.v1.DeleteFileNotFound + */ +export type DeleteFileNotFound = Message<"agent.v1.DeleteFileNotFound"> & { + /** + * @generated from field: string path = 1; + */ + path: string; +}; + +/** + * Describes the message agent.v1.DeleteFileNotFound. + * Use `create(DeleteFileNotFoundSchema)` to create a new message. + */ +export const DeleteFileNotFoundSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 189); + +/** + * @generated from message agent.v1.DeleteNotFile + */ +export type DeleteNotFile = Message<"agent.v1.DeleteNotFile"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * "directory" or "other" + * + * @generated from field: string actual_type = 2; + */ + actualType: string; +}; + +/** + * Describes the message agent.v1.DeleteNotFile. + * Use `create(DeleteNotFileSchema)` to create a new message. + */ +export const DeleteNotFileSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 190); + +/** + * @generated from message agent.v1.DeletePermissionDenied + */ +export type DeletePermissionDenied = Message<"agent.v1.DeletePermissionDenied"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string client_visible_error = 2; + */ + clientVisibleError: string; + + /** + * @generated from field: bool is_readonly = 3; + */ + isReadonly: boolean; +}; + +/** + * Describes the message agent.v1.DeletePermissionDenied. + * Use `create(DeletePermissionDeniedSchema)` to create a new message. + */ +export const DeletePermissionDeniedSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 191); + +/** + * @generated from message agent.v1.DeleteFileBusy + */ +export type DeleteFileBusy = Message<"agent.v1.DeleteFileBusy"> & { + /** + * @generated from field: string path = 1; + */ + path: string; +}; + +/** + * Describes the message agent.v1.DeleteFileBusy. + * Use `create(DeleteFileBusySchema)` to create a new message. + */ +export const DeleteFileBusySchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 192); + +/** + * @generated from message agent.v1.DeleteRejected + */ +export type DeleteRejected = Message<"agent.v1.DeleteRejected"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string reason = 2; + */ + reason: string; +}; + +/** + * Describes the message agent.v1.DeleteRejected. + * Use `create(DeleteRejectedSchema)` to create a new message. + */ +export const DeleteRejectedSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 193); + +/** + * @generated from message agent.v1.DeleteError + */ +export type DeleteError = Message<"agent.v1.DeleteError"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string error = 2; + */ + error: string; +}; + +/** + * Describes the message agent.v1.DeleteError. + * Use `create(DeleteErrorSchema)` to create a new message. + */ +export const DeleteErrorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 194); + +/** + * @generated from message agent.v1.DeleteToolCall + */ +export type DeleteToolCall = Message<"agent.v1.DeleteToolCall"> & { + /** + * @generated from field: agent.v1.DeleteArgs args = 1; + */ + args?: DeleteArgs; + + /** + * @generated from field: agent.v1.DeleteResult result = 2; + */ + result?: DeleteResult; +}; + +/** + * Describes the message agent.v1.DeleteToolCall. + * Use `create(DeleteToolCallSchema)` to create a new message. + */ +export const DeleteToolCallSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 195); + +/** + * @generated from message agent.v1.DiagnosticsArgs + */ +export type DiagnosticsArgs = Message<"agent.v1.DiagnosticsArgs"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string tool_call_id = 2; + */ + toolCallId: string; +}; + +/** + * Describes the message agent.v1.DiagnosticsArgs. + * Use `create(DiagnosticsArgsSchema)` to create a new message. + */ +export const DiagnosticsArgsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 196); + +/** + * @generated from message agent.v1.DiagnosticsResult + */ +export type DiagnosticsResult = Message<"agent.v1.DiagnosticsResult"> & { + /** + * @generated from oneof agent.v1.DiagnosticsResult.result + */ + result: + | { + /** + * @generated from field: agent.v1.DiagnosticsSuccess success = 1; + */ + value: DiagnosticsSuccess; + case: "success"; + } + | { + /** + * @generated from field: agent.v1.DiagnosticsError error = 2; + */ + value: DiagnosticsError; + case: "error"; + } + | { + /** + * @generated from field: agent.v1.DiagnosticsRejected rejected = 3; + */ + value: DiagnosticsRejected; + case: "rejected"; + } + | { + /** + * @generated from field: agent.v1.DiagnosticsFileNotFound file_not_found = 4; + */ + value: DiagnosticsFileNotFound; + case: "fileNotFound"; + } + | { + /** + * @generated from field: agent.v1.DiagnosticsPermissionDenied permission_denied = 5; + */ + value: DiagnosticsPermissionDenied; + case: "permissionDenied"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.DiagnosticsResult. + * Use `create(DiagnosticsResultSchema)` to create a new message. + */ +export const DiagnosticsResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 197); + +/** + * @generated from message agent.v1.DiagnosticsSuccess + */ +export type DiagnosticsSuccess = Message<"agent.v1.DiagnosticsSuccess"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: repeated agent.v1.Diagnostic diagnostics = 2; + */ + diagnostics: Diagnostic[]; + + /** + * @generated from field: int32 total_diagnostics = 3; + */ + totalDiagnostics: number; +}; + +/** + * Describes the message agent.v1.DiagnosticsSuccess. + * Use `create(DiagnosticsSuccessSchema)` to create a new message. + */ +export const DiagnosticsSuccessSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 198); + +/** + * @generated from message agent.v1.Diagnostic + */ +export type Diagnostic = Message<"agent.v1.Diagnostic"> & { + /** + * @generated from field: int32 severity = 1; + */ + severity: number; + + /** + * @generated from field: agent.v1.Range range = 2; + */ + range?: Range; + + /** + * @generated from field: string message = 3; + */ + message: string; + + /** + * @generated from field: string source = 4; + */ + source: string; + + /** + * @generated from field: string code = 5; + */ + code: string; + + /** + * @generated from field: bool is_stale = 6; + */ + isStale: boolean; +}; + +/** + * Describes the message agent.v1.Diagnostic. + * Use `create(DiagnosticSchema)` to create a new message. + */ +export const DiagnosticSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 199); + +/** + * @generated from message agent.v1.DiagnosticsError + */ +export type DiagnosticsError = Message<"agent.v1.DiagnosticsError"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string error = 2; + */ + error: string; +}; + +/** + * Describes the message agent.v1.DiagnosticsError. + * Use `create(DiagnosticsErrorSchema)` to create a new message. + */ +export const DiagnosticsErrorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 200); + +/** + * @generated from message agent.v1.DiagnosticsRejected + */ +export type DiagnosticsRejected = Message<"agent.v1.DiagnosticsRejected"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string reason = 2; + */ + reason: string; +}; + +/** + * Describes the message agent.v1.DiagnosticsRejected. + * Use `create(DiagnosticsRejectedSchema)` to create a new message. + */ +export const DiagnosticsRejectedSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 201); + +/** + * @generated from message agent.v1.DiagnosticsFileNotFound + */ +export type DiagnosticsFileNotFound = Message<"agent.v1.DiagnosticsFileNotFound"> & { + /** + * @generated from field: string path = 1; + */ + path: string; +}; + +/** + * Describes the message agent.v1.DiagnosticsFileNotFound. + * Use `create(DiagnosticsFileNotFoundSchema)` to create a new message. + */ +export const DiagnosticsFileNotFoundSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 202); + +/** + * @generated from message agent.v1.DiagnosticsPermissionDenied + */ +export type DiagnosticsPermissionDenied = Message<"agent.v1.DiagnosticsPermissionDenied"> & { + /** + * @generated from field: string path = 1; + */ + path: string; +}; + +/** + * Describes the message agent.v1.DiagnosticsPermissionDenied. + * Use `create(DiagnosticsPermissionDeniedSchema)` to create a new message. + */ +export const DiagnosticsPermissionDeniedSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 203); + +/** + * @generated from message agent.v1.EditArgs + */ +export type EditArgs = Message<"agent.v1.EditArgs"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: optional string stream_content = 6; + */ + streamContent?: string; +}; + +/** + * Describes the message agent.v1.EditArgs. + * Use `create(EditArgsSchema)` to create a new message. + */ +export const EditArgsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 204); + +/** + * @generated from message agent.v1.EditResult + */ +export type EditResult = Message<"agent.v1.EditResult"> & { + /** + * @generated from oneof agent.v1.EditResult.result + */ + result: + | { + /** + * @generated from field: agent.v1.EditSuccess success = 1; + */ + value: EditSuccess; + case: "success"; + } + | { + /** + * @generated from field: agent.v1.EditFileNotFound file_not_found = 2; + */ + value: EditFileNotFound; + case: "fileNotFound"; + } + | { + /** + * @generated from field: agent.v1.EditReadPermissionDenied read_permission_denied = 3; + */ + value: EditReadPermissionDenied; + case: "readPermissionDenied"; + } + | { + /** + * @generated from field: agent.v1.EditWritePermissionDenied write_permission_denied = 4; + */ + value: EditWritePermissionDenied; + case: "writePermissionDenied"; + } + | { + /** + * @generated from field: agent.v1.EditRejected rejected = 6; + */ + value: EditRejected; + case: "rejected"; + } + | { + /** + * @generated from field: agent.v1.EditError error = 7; + */ + value: EditError; + case: "error"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.EditResult. + * Use `create(EditResultSchema)` to create a new message. + */ +export const EditResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 205); + +/** + * @generated from message agent.v1.EditSuccess + */ +export type EditSuccess = Message<"agent.v1.EditSuccess"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: optional int32 lines_added = 3; + */ + linesAdded?: number; + + /** + * @generated from field: optional int32 lines_removed = 4; + */ + linesRemoved?: number; + + /** + * Concatenated chunk diff strings separated by "\n...\n" + * + * @generated from field: optional string diff_string = 5; + */ + diffString?: string; + + /** + * undefined if file didn't exist before the edit + * + * @generated from field: optional string before_full_file_content = 6; + */ + beforeFullFileContent?: string; + + /** + * @generated from field: string after_full_file_content = 7; + */ + afterFullFileContent: string; + + /** + * Formatted message for display to model (resultForModel from EditTransformResult) + * + * @generated from field: optional string message = 8; + */ + message?: string; +}; + +/** + * Describes the message agent.v1.EditSuccess. + * Use `create(EditSuccessSchema)` to create a new message. + */ +export const EditSuccessSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 206); + +/** + * @generated from message agent.v1.EditFileNotFound + */ +export type EditFileNotFound = Message<"agent.v1.EditFileNotFound"> & { + /** + * @generated from field: string path = 1; + */ + path: string; +}; + +/** + * Describes the message agent.v1.EditFileNotFound. + * Use `create(EditFileNotFoundSchema)` to create a new message. + */ +export const EditFileNotFoundSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 207); + +/** + * @generated from message agent.v1.EditReadPermissionDenied + */ +export type EditReadPermissionDenied = Message<"agent.v1.EditReadPermissionDenied"> & { + /** + * @generated from field: string path = 1; + */ + path: string; +}; + +/** + * Describes the message agent.v1.EditReadPermissionDenied. + * Use `create(EditReadPermissionDeniedSchema)` to create a new message. + */ +export const EditReadPermissionDeniedSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 208); + +/** + * @generated from message agent.v1.EditWritePermissionDenied + */ +export type EditWritePermissionDenied = Message<"agent.v1.EditWritePermissionDenied"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string error = 2; + */ + error: string; + + /** + * @generated from field: bool is_readonly = 3; + */ + isReadonly: boolean; +}; + +/** + * Describes the message agent.v1.EditWritePermissionDenied. + * Use `create(EditWritePermissionDeniedSchema)` to create a new message. + */ +export const EditWritePermissionDeniedSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 209); + +/** + * @generated from message agent.v1.EditRejected + */ +export type EditRejected = Message<"agent.v1.EditRejected"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string reason = 2; + */ + reason: string; +}; + +/** + * Describes the message agent.v1.EditRejected. + * Use `create(EditRejectedSchema)` to create a new message. + */ +export const EditRejectedSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 210); + +/** + * @generated from message agent.v1.EditError + */ +export type EditError = Message<"agent.v1.EditError"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string error = 2; + */ + error: string; + + /** + * @generated from field: optional string model_visible_error = 5; + */ + modelVisibleError?: string; +}; + +/** + * Describes the message agent.v1.EditError. + * Use `create(EditErrorSchema)` to create a new message. + */ +export const EditErrorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 211); + +/** + * @generated from message agent.v1.EditToolCall + */ +export type EditToolCall = Message<"agent.v1.EditToolCall"> & { + /** + * @generated from field: agent.v1.EditArgs args = 1; + */ + args?: EditArgs; + + /** + * @generated from field: agent.v1.EditResult result = 2; + */ + result?: EditResult; +}; + +/** + * Describes the message agent.v1.EditToolCall. + * Use `create(EditToolCallSchema)` to create a new message. + */ +export const EditToolCallSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 212); + +/** + * @generated from message agent.v1.EditToolCallDelta + */ +export type EditToolCallDelta = Message<"agent.v1.EditToolCallDelta"> & { + /** + * @generated from field: string stream_content_delta = 1; + */ + streamContentDelta: string; +}; + +/** + * Describes the message agent.v1.EditToolCallDelta. + * Use `create(EditToolCallDeltaSchema)` to create a new message. + */ +export const EditToolCallDeltaSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 213); + +/** + * @generated from message agent.v1.ExaFetchArgs + */ +export type ExaFetchArgs = Message<"agent.v1.ExaFetchArgs"> & { + /** + * @generated from field: repeated string ids = 1; + */ + ids: string[]; + + /** + * @generated from field: string tool_call_id = 2; + */ + toolCallId: string; +}; + +/** + * Describes the message agent.v1.ExaFetchArgs. + * Use `create(ExaFetchArgsSchema)` to create a new message. + */ +export const ExaFetchArgsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 214); + +/** + * @generated from message agent.v1.ExaFetchResult + */ +export type ExaFetchResult = Message<"agent.v1.ExaFetchResult"> & { + /** + * @generated from oneof agent.v1.ExaFetchResult.result + */ + result: + | { + /** + * @generated from field: agent.v1.ExaFetchSuccess success = 1; + */ + value: ExaFetchSuccess; + case: "success"; + } + | { + /** + * @generated from field: agent.v1.ExaFetchError error = 2; + */ + value: ExaFetchError; + case: "error"; + } + | { + /** + * @generated from field: agent.v1.ExaFetchRejected rejected = 3; + */ + value: ExaFetchRejected; + case: "rejected"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.ExaFetchResult. + * Use `create(ExaFetchResultSchema)` to create a new message. + */ +export const ExaFetchResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 215); + +/** + * @generated from message agent.v1.ExaFetchSuccess + */ +export type ExaFetchSuccess = Message<"agent.v1.ExaFetchSuccess"> & { + /** + * @generated from field: repeated agent.v1.ExaFetchContent contents = 1; + */ + contents: ExaFetchContent[]; +}; + +/** + * Describes the message agent.v1.ExaFetchSuccess. + * Use `create(ExaFetchSuccessSchema)` to create a new message. + */ +export const ExaFetchSuccessSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 216); + +/** + * @generated from message agent.v1.ExaFetchError + */ +export type ExaFetchError = Message<"agent.v1.ExaFetchError"> & { + /** + * @generated from field: string error = 1; + */ + error: string; +}; + +/** + * Describes the message agent.v1.ExaFetchError. + * Use `create(ExaFetchErrorSchema)` to create a new message. + */ +export const ExaFetchErrorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 217); + +/** + * @generated from message agent.v1.ExaFetchRejected + */ +export type ExaFetchRejected = Message<"agent.v1.ExaFetchRejected"> & { + /** + * @generated from field: string reason = 1; + */ + reason: string; +}; + +/** + * Describes the message agent.v1.ExaFetchRejected. + * Use `create(ExaFetchRejectedSchema)` to create a new message. + */ +export const ExaFetchRejectedSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 218); + +/** + * @generated from message agent.v1.ExaFetchContent + */ +export type ExaFetchContent = Message<"agent.v1.ExaFetchContent"> & { + /** + * @generated from field: string title = 1; + */ + title: string; + + /** + * @generated from field: string url = 2; + */ + url: string; + + /** + * @generated from field: string text = 3; + */ + text: string; + + /** + * @generated from field: string published_date = 4; + */ + publishedDate: string; +}; + +/** + * Describes the message agent.v1.ExaFetchContent. + * Use `create(ExaFetchContentSchema)` to create a new message. + */ +export const ExaFetchContentSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 219); + +/** + * @generated from message agent.v1.ExaFetchToolCall + */ +export type ExaFetchToolCall = Message<"agent.v1.ExaFetchToolCall"> & { + /** + * @generated from field: agent.v1.ExaFetchArgs args = 1; + */ + args?: ExaFetchArgs; + + /** + * @generated from field: agent.v1.ExaFetchResult result = 2; + */ + result?: ExaFetchResult; +}; + +/** + * Describes the message agent.v1.ExaFetchToolCall. + * Use `create(ExaFetchToolCallSchema)` to create a new message. + */ +export const ExaFetchToolCallSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 220); + +/** + * @generated from message agent.v1.ExaFetchRequestQuery + */ +export type ExaFetchRequestQuery = Message<"agent.v1.ExaFetchRequestQuery"> & { + /** + * @generated from field: agent.v1.ExaFetchArgs args = 1; + */ + args?: ExaFetchArgs; +}; + +/** + * Describes the message agent.v1.ExaFetchRequestQuery. + * Use `create(ExaFetchRequestQuerySchema)` to create a new message. + */ +export const ExaFetchRequestQuerySchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 221); + +/** + * @generated from message agent.v1.ExaFetchRequestResponse + */ +export type ExaFetchRequestResponse = Message<"agent.v1.ExaFetchRequestResponse"> & { + /** + * @generated from oneof agent.v1.ExaFetchRequestResponse.result + */ + result: + | { + /** + * @generated from field: agent.v1.ExaFetchRequestResponse_Approved approved = 1; + */ + value: ExaFetchRequestResponse_Approved; + case: "approved"; + } + | { + /** + * @generated from field: agent.v1.ExaFetchRequestResponse_Rejected rejected = 2; + */ + value: ExaFetchRequestResponse_Rejected; + case: "rejected"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.ExaFetchRequestResponse. + * Use `create(ExaFetchRequestResponseSchema)` to create a new message. + */ +export const ExaFetchRequestResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 222); + +/** + * @generated from message agent.v1.ExaFetchRequestResponse_Approved + */ +export type ExaFetchRequestResponse_Approved = Message<"agent.v1.ExaFetchRequestResponse_Approved"> & {}; + +/** + * Describes the message agent.v1.ExaFetchRequestResponse_Approved. + * Use `create(ExaFetchRequestResponse_ApprovedSchema)` to create a new message. + */ +export const ExaFetchRequestResponse_ApprovedSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 223); + +/** + * @generated from message agent.v1.ExaFetchRequestResponse_Rejected + */ +export type ExaFetchRequestResponse_Rejected = Message<"agent.v1.ExaFetchRequestResponse_Rejected"> & { + /** + * @generated from field: string reason = 1; + */ + reason: string; +}; + +/** + * Describes the message agent.v1.ExaFetchRequestResponse_Rejected. + * Use `create(ExaFetchRequestResponse_RejectedSchema)` to create a new message. + */ +export const ExaFetchRequestResponse_RejectedSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 224); + +/** + * @generated from message agent.v1.ExaSearchArgs + */ +export type ExaSearchArgs = Message<"agent.v1.ExaSearchArgs"> & { + /** + * @generated from field: string query = 1; + */ + query: string; + + /** + * "auto", "neural", or "keyword" + * + * @generated from field: string type = 2; + */ + type: string; + + /** + * @generated from field: int32 num_results = 3; + */ + numResults: number; + + /** + * @generated from field: string tool_call_id = 4; + */ + toolCallId: string; +}; + +/** + * Describes the message agent.v1.ExaSearchArgs. + * Use `create(ExaSearchArgsSchema)` to create a new message. + */ +export const ExaSearchArgsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 225); + +/** + * @generated from message agent.v1.ExaSearchResult + */ +export type ExaSearchResult = Message<"agent.v1.ExaSearchResult"> & { + /** + * @generated from oneof agent.v1.ExaSearchResult.result + */ + result: + | { + /** + * @generated from field: agent.v1.ExaSearchSuccess success = 1; + */ + value: ExaSearchSuccess; + case: "success"; + } + | { + /** + * @generated from field: agent.v1.ExaSearchError error = 2; + */ + value: ExaSearchError; + case: "error"; + } + | { + /** + * @generated from field: agent.v1.ExaSearchRejected rejected = 3; + */ + value: ExaSearchRejected; + case: "rejected"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.ExaSearchResult. + * Use `create(ExaSearchResultSchema)` to create a new message. + */ +export const ExaSearchResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 226); + +/** + * @generated from message agent.v1.ExaSearchSuccess + */ +export type ExaSearchSuccess = Message<"agent.v1.ExaSearchSuccess"> & { + /** + * @generated from field: repeated agent.v1.ExaSearchReference references = 1; + */ + references: ExaSearchReference[]; +}; + +/** + * Describes the message agent.v1.ExaSearchSuccess. + * Use `create(ExaSearchSuccessSchema)` to create a new message. + */ +export const ExaSearchSuccessSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 227); + +/** + * @generated from message agent.v1.ExaSearchError + */ +export type ExaSearchError = Message<"agent.v1.ExaSearchError"> & { + /** + * @generated from field: string error = 1; + */ + error: string; +}; + +/** + * Describes the message agent.v1.ExaSearchError. + * Use `create(ExaSearchErrorSchema)` to create a new message. + */ +export const ExaSearchErrorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 228); + +/** + * @generated from message agent.v1.ExaSearchRejected + */ +export type ExaSearchRejected = Message<"agent.v1.ExaSearchRejected"> & { + /** + * @generated from field: string reason = 1; + */ + reason: string; +}; + +/** + * Describes the message agent.v1.ExaSearchRejected. + * Use `create(ExaSearchRejectedSchema)` to create a new message. + */ +export const ExaSearchRejectedSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 229); + +/** + * @generated from message agent.v1.ExaSearchReference + */ +export type ExaSearchReference = Message<"agent.v1.ExaSearchReference"> & { + /** + * @generated from field: string title = 1; + */ + title: string; + + /** + * @generated from field: string url = 2; + */ + url: string; + + /** + * @generated from field: string text = 3; + */ + text: string; + + /** + * @generated from field: string published_date = 4; + */ + publishedDate: string; +}; + +/** + * Describes the message agent.v1.ExaSearchReference. + * Use `create(ExaSearchReferenceSchema)` to create a new message. + */ +export const ExaSearchReferenceSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 230); + +/** + * @generated from message agent.v1.ExaSearchToolCall + */ +export type ExaSearchToolCall = Message<"agent.v1.ExaSearchToolCall"> & { + /** + * @generated from field: agent.v1.ExaSearchArgs args = 1; + */ + args?: ExaSearchArgs; + + /** + * @generated from field: agent.v1.ExaSearchResult result = 2; + */ + result?: ExaSearchResult; +}; + +/** + * Describes the message agent.v1.ExaSearchToolCall. + * Use `create(ExaSearchToolCallSchema)` to create a new message. + */ +export const ExaSearchToolCallSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 231); + +/** + * @generated from message agent.v1.ExaSearchRequestQuery + */ +export type ExaSearchRequestQuery = Message<"agent.v1.ExaSearchRequestQuery"> & { + /** + * @generated from field: agent.v1.ExaSearchArgs args = 1; + */ + args?: ExaSearchArgs; +}; + +/** + * Describes the message agent.v1.ExaSearchRequestQuery. + * Use `create(ExaSearchRequestQuerySchema)` to create a new message. + */ +export const ExaSearchRequestQuerySchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 232); + +/** + * @generated from message agent.v1.ExaSearchRequestResponse + */ +export type ExaSearchRequestResponse = Message<"agent.v1.ExaSearchRequestResponse"> & { + /** + * @generated from oneof agent.v1.ExaSearchRequestResponse.result + */ + result: + | { + /** + * @generated from field: agent.v1.ExaSearchRequestResponse_Approved approved = 1; + */ + value: ExaSearchRequestResponse_Approved; + case: "approved"; + } + | { + /** + * @generated from field: agent.v1.ExaSearchRequestResponse_Rejected rejected = 2; + */ + value: ExaSearchRequestResponse_Rejected; + case: "rejected"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.ExaSearchRequestResponse. + * Use `create(ExaSearchRequestResponseSchema)` to create a new message. + */ +export const ExaSearchRequestResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 233); + +/** + * @generated from message agent.v1.ExaSearchRequestResponse_Approved + */ +export type ExaSearchRequestResponse_Approved = Message<"agent.v1.ExaSearchRequestResponse_Approved"> & {}; + +/** + * Describes the message agent.v1.ExaSearchRequestResponse_Approved. + * Use `create(ExaSearchRequestResponse_ApprovedSchema)` to create a new message. + */ +export const ExaSearchRequestResponse_ApprovedSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 234); + +/** + * @generated from message agent.v1.ExaSearchRequestResponse_Rejected + */ +export type ExaSearchRequestResponse_Rejected = Message<"agent.v1.ExaSearchRequestResponse_Rejected"> & { + /** + * @generated from field: string reason = 1; + */ + reason: string; +}; + +/** + * Describes the message agent.v1.ExaSearchRequestResponse_Rejected. + * Use `create(ExaSearchRequestResponse_RejectedSchema)` to create a new message. + */ +export const ExaSearchRequestResponse_RejectedSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 235); + +/** + * @generated from message agent.v1.ExecClientStreamClose + */ +export type ExecClientStreamClose = Message<"agent.v1.ExecClientStreamClose"> & { + /** + * @generated from field: uint32 id = 1; + */ + id: number; +}; + +/** + * Describes the message agent.v1.ExecClientStreamClose. + * Use `create(ExecClientStreamCloseSchema)` to create a new message. + */ +export const ExecClientStreamCloseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 236); + +/** + * @generated from message agent.v1.ExecClientThrow + */ +export type ExecClientThrow = Message<"agent.v1.ExecClientThrow"> & { + /** + * @generated from field: uint32 id = 1; + */ + id: number; + + /** + * @generated from field: string error = 2; + */ + error: string; + + /** + * @generated from field: optional string stack_trace = 3; + */ + stackTrace?: string; +}; + +/** + * Describes the message agent.v1.ExecClientThrow. + * Use `create(ExecClientThrowSchema)` to create a new message. + */ +export const ExecClientThrowSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 237); + +/** + * @generated from message agent.v1.ExecClientHeartbeat + */ +export type ExecClientHeartbeat = Message<"agent.v1.ExecClientHeartbeat"> & { + /** + * @generated from field: uint32 id = 1; + */ + id: number; +}; + +/** + * Describes the message agent.v1.ExecClientHeartbeat. + * Use `create(ExecClientHeartbeatSchema)` to create a new message. + */ +export const ExecClientHeartbeatSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 238); + +/** + * @generated from message agent.v1.ExecClientControlMessage + */ +export type ExecClientControlMessage = Message<"agent.v1.ExecClientControlMessage"> & { + /** + * @generated from oneof agent.v1.ExecClientControlMessage.message + */ + message: + | { + /** + * @generated from field: agent.v1.ExecClientStreamClose stream_close = 1; + */ + value: ExecClientStreamClose; + case: "streamClose"; + } + | { + /** + * @generated from field: agent.v1.ExecClientThrow throw = 2; + */ + value: ExecClientThrow; + case: "throw"; + } + | { + /** + * @generated from field: agent.v1.ExecClientHeartbeat heartbeat = 3; + */ + value: ExecClientHeartbeat; + case: "heartbeat"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.ExecClientControlMessage. + * Use `create(ExecClientControlMessageSchema)` to create a new message. + */ +export const ExecClientControlMessageSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 239); + +/** + * Simplified span context for tracing exec calls + * + * @generated from message agent.v1.SpanContext + */ +export type SpanContext = Message<"agent.v1.SpanContext"> & { + /** + * Trace identifier (128-bit as hex string, same for all spans in a trace) + * + * @generated from field: string trace_id = 1; + */ + traceId: string; + + /** + * Unique span identifier (64-bit as hex string) + * + * @generated from field: string span_id = 2; + */ + spanId: string; + + /** + * Trace flags bit field following OTEL SPAN_FLAGS_* semantics + * + * @generated from field: optional uint32 trace_flags = 3; + */ + traceFlags?: number; + + /** + * W3C trace-state header string (optional) + * + * @generated from field: optional string trace_state = 4; + */ + traceState?: string; +}; + +/** + * Describes the message agent.v1.SpanContext. + * Use `create(SpanContextSchema)` to create a new message. + */ +export const SpanContextSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 240); + +/** + * Empty abort message for aborting running execs + * + * @generated from message agent.v1.AbortArgs + */ +export type AbortArgs = Message<"agent.v1.AbortArgs"> & {}; + +/** + * Describes the message agent.v1.AbortArgs. + * Use `create(AbortArgsSchema)` to create a new message. + */ +export const AbortArgsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 241); + +/** + * @generated from message agent.v1.AbortResult + */ +export type AbortResult = Message<"agent.v1.AbortResult"> & {}; + +/** + * Describes the message agent.v1.AbortResult. + * Use `create(AbortResultSchema)` to create a new message. + */ +export const AbortResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 242); + +/** + * @generated from message agent.v1.ExecServerMessage + */ +export type ExecServerMessage = Message<"agent.v1.ExecServerMessage"> & { + /** + * @generated from field: uint32 id = 1; + */ + id: number; + + /** + * Optional exec ID for attachable executions + * + * @generated from field: string exec_id = 15; + */ + execId: string; + + /** + * Optional parent span context for tracing + * + * @generated from field: optional agent.v1.SpanContext span_context = 19; + */ + spanContext?: SpanContext; + + /** + * @generated from oneof agent.v1.ExecServerMessage.message + */ + message: + | { + /** + * @generated from field: agent.v1.ShellArgs shell_args = 2; + */ + value: ShellArgs; + case: "shellArgs"; + } + | { + /** + * @generated from field: agent.v1.WriteArgs write_args = 3; + */ + value: WriteArgs; + case: "writeArgs"; + } + | { + /** + * @generated from field: agent.v1.DeleteArgs delete_args = 4; + */ + value: DeleteArgs; + case: "deleteArgs"; + } + | { + /** + * @generated from field: agent.v1.GrepArgs grep_args = 5; + */ + value: GrepArgs; + case: "grepArgs"; + } + | { + /** + * @generated from field: agent.v1.ReadArgs read_args = 7; + */ + value: ReadArgs; + case: "readArgs"; + } + | { + /** + * @generated from field: agent.v1.LsArgs ls_args = 8; + */ + value: LsArgs; + case: "lsArgs"; + } + | { + /** + * @generated from field: agent.v1.DiagnosticsArgs diagnostics_args = 9; + */ + value: DiagnosticsArgs; + case: "diagnosticsArgs"; + } + | { + /** + * @generated from field: agent.v1.RequestContextArgs request_context_args = 10; + */ + value: RequestContextArgs; + case: "requestContextArgs"; + } + | { + /** + * @generated from field: agent.v1.McpArgs mcp_args = 11; + */ + value: McpArgs; + case: "mcpArgs"; + } + | { + /** + * @generated from field: agent.v1.ShellArgs shell_stream_args = 14; + */ + value: ShellArgs; + case: "shellStreamArgs"; + } + | { + /** + * @generated from field: agent.v1.BackgroundShellSpawnArgs background_shell_spawn_args = 16; + */ + value: BackgroundShellSpawnArgs; + case: "backgroundShellSpawnArgs"; + } + | { + /** + * @generated from field: agent.v1.ListMcpResourcesExecArgs list_mcp_resources_exec_args = 17; + */ + value: ListMcpResourcesExecArgs; + case: "listMcpResourcesExecArgs"; + } + | { + /** + * @generated from field: agent.v1.ReadMcpResourceExecArgs read_mcp_resource_exec_args = 18; + */ + value: ReadMcpResourceExecArgs; + case: "readMcpResourceExecArgs"; + } + | { + /** + * @generated from field: agent.v1.FetchArgs fetch_args = 20; + */ + value: FetchArgs; + case: "fetchArgs"; + } + | { + /** + * @generated from field: agent.v1.RecordScreenArgs record_screen_args = 21; + */ + value: RecordScreenArgs; + case: "recordScreenArgs"; + } + | { + /** + * @generated from field: agent.v1.ComputerUseArgs computer_use_args = 22; + */ + value: ComputerUseArgs; + case: "computerUseArgs"; + } + | { + /** + * @generated from field: agent.v1.WriteShellStdinArgs write_shell_stdin_args = 23; + */ + value: WriteShellStdinArgs; + case: "writeShellStdinArgs"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.ExecServerMessage. + * Use `create(ExecServerMessageSchema)` to create a new message. + */ +export const ExecServerMessageSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 243); + +/** + * @generated from message agent.v1.ExecClientMessage + */ +export type ExecClientMessage = Message<"agent.v1.ExecClientMessage"> & { + /** + * @generated from field: uint32 id = 1; + */ + id: number; + + /** + * Optional exec ID for attachable executions + * + * @generated from field: string exec_id = 15; + */ + execId: string; + + /** + * @generated from oneof agent.v1.ExecClientMessage.message + */ + message: + | { + /** + * @generated from field: agent.v1.ShellResult shell_result = 2; + */ + value: ShellResult; + case: "shellResult"; + } + | { + /** + * @generated from field: agent.v1.WriteResult write_result = 3; + */ + value: WriteResult; + case: "writeResult"; + } + | { + /** + * @generated from field: agent.v1.DeleteResult delete_result = 4; + */ + value: DeleteResult; + case: "deleteResult"; + } + | { + /** + * @generated from field: agent.v1.GrepResult grep_result = 5; + */ + value: GrepResult; + case: "grepResult"; + } + | { + /** + * @generated from field: agent.v1.ReadResult read_result = 7; + */ + value: ReadResult; + case: "readResult"; + } + | { + /** + * @generated from field: agent.v1.LsResult ls_result = 8; + */ + value: LsResult; + case: "lsResult"; + } + | { + /** + * @generated from field: agent.v1.DiagnosticsResult diagnostics_result = 9; + */ + value: DiagnosticsResult; + case: "diagnosticsResult"; + } + | { + /** + * @generated from field: agent.v1.RequestContextResult request_context_result = 10; + */ + value: RequestContextResult; + case: "requestContextResult"; + } + | { + /** + * @generated from field: agent.v1.McpResult mcp_result = 11; + */ + value: McpResult; + case: "mcpResult"; + } + | { + /** + * @generated from field: agent.v1.ShellStream shell_stream = 14; + */ + value: ShellStream; + case: "shellStream"; + } + | { + /** + * @generated from field: agent.v1.BackgroundShellSpawnResult background_shell_spawn_result = 16; + */ + value: BackgroundShellSpawnResult; + case: "backgroundShellSpawnResult"; + } + | { + /** + * @generated from field: agent.v1.ListMcpResourcesExecResult list_mcp_resources_exec_result = 17; + */ + value: ListMcpResourcesExecResult; + case: "listMcpResourcesExecResult"; + } + | { + /** + * @generated from field: agent.v1.ReadMcpResourceExecResult read_mcp_resource_exec_result = 18; + */ + value: ReadMcpResourceExecResult; + case: "readMcpResourceExecResult"; + } + | { + /** + * @generated from field: agent.v1.FetchResult fetch_result = 20; + */ + value: FetchResult; + case: "fetchResult"; + } + | { + /** + * @generated from field: agent.v1.RecordScreenResult record_screen_result = 21; + */ + value: RecordScreenResult; + case: "recordScreenResult"; + } + | { + /** + * @generated from field: agent.v1.ComputerUseResult computer_use_result = 22; + */ + value: ComputerUseResult; + case: "computerUseResult"; + } + | { + /** + * @generated from field: agent.v1.WriteShellStdinResult write_shell_stdin_result = 23; + */ + value: WriteShellStdinResult; + case: "writeShellStdinResult"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.ExecClientMessage. + * Use `create(ExecClientMessageSchema)` to create a new message. + */ +export const ExecClientMessageSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 244); + +/** + * @generated from message agent.v1.FetchArgs + */ +export type FetchArgs = Message<"agent.v1.FetchArgs"> & { + /** + * @generated from field: string url = 1; + */ + url: string; + + /** + * @generated from field: string tool_call_id = 2; + */ + toolCallId: string; +}; + +/** + * Describes the message agent.v1.FetchArgs. + * Use `create(FetchArgsSchema)` to create a new message. + */ +export const FetchArgsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 245); + +/** + * @generated from message agent.v1.FetchResult + */ +export type FetchResult = Message<"agent.v1.FetchResult"> & { + /** + * @generated from oneof agent.v1.FetchResult.result + */ + result: + | { + /** + * @generated from field: agent.v1.FetchSuccess success = 1; + */ + value: FetchSuccess; + case: "success"; + } + | { + /** + * @generated from field: agent.v1.FetchError error = 2; + */ + value: FetchError; + case: "error"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.FetchResult. + * Use `create(FetchResultSchema)` to create a new message. + */ +export const FetchResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 246); + +/** + * @generated from message agent.v1.FetchSuccess + */ +export type FetchSuccess = Message<"agent.v1.FetchSuccess"> & { + /** + * @generated from field: string url = 1; + */ + url: string; + + /** + * @generated from field: string content = 2; + */ + content: string; + + /** + * @generated from field: int32 status_code = 3; + */ + statusCode: number; + + /** + * @generated from field: string content_type = 4; + */ + contentType: string; +}; + +/** + * Describes the message agent.v1.FetchSuccess. + * Use `create(FetchSuccessSchema)` to create a new message. + */ +export const FetchSuccessSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 247); + +/** + * @generated from message agent.v1.FetchError + */ +export type FetchError = Message<"agent.v1.FetchError"> & { + /** + * @generated from field: string url = 1; + */ + url: string; + + /** + * @generated from field: string error = 2; + */ + error: string; +}; + +/** + * Describes the message agent.v1.FetchError. + * Use `create(FetchErrorSchema)` to create a new message. + */ +export const FetchErrorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 248); + +/** + * @generated from message agent.v1.GenerateImageArgs + */ +export type GenerateImageArgs = Message<"agent.v1.GenerateImageArgs"> & { + /** + * @generated from field: string description = 1; + */ + description: string; + + /** + * @generated from field: optional string file_path = 2; + */ + filePath?: string; + + /** + * Optional paths to reference images to use as input for image-to-image generation + * + * @generated from field: repeated string reference_image_paths = 5; + */ + referenceImagePaths: string[]; +}; + +/** + * Describes the message agent.v1.GenerateImageArgs. + * Use `create(GenerateImageArgsSchema)` to create a new message. + */ +export const GenerateImageArgsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 249); + +/** + * @generated from message agent.v1.GenerateImageResult + */ +export type GenerateImageResult = Message<"agent.v1.GenerateImageResult"> & { + /** + * @generated from oneof agent.v1.GenerateImageResult.result + */ + result: + | { + /** + * @generated from field: agent.v1.GenerateImageSuccess success = 1; + */ + value: GenerateImageSuccess; + case: "success"; + } + | { + /** + * @generated from field: agent.v1.GenerateImageError error = 2; + */ + value: GenerateImageError; + case: "error"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.GenerateImageResult. + * Use `create(GenerateImageResultSchema)` to create a new message. + */ +export const GenerateImageResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 250); + +/** + * @generated from message agent.v1.GenerateImageSuccess + */ +export type GenerateImageSuccess = Message<"agent.v1.GenerateImageSuccess"> & { + /** + * Actual file path where the image was saved (e.g., /path/to/project/assets/image.png) + * + * @generated from field: string file_path = 1; + */ + filePath: string; + + /** + * Base64-encoded image data + * + * @generated from field: string image_data = 2; + */ + imageData: string; +}; + +/** + * Describes the message agent.v1.GenerateImageSuccess. + * Use `create(GenerateImageSuccessSchema)` to create a new message. + */ +export const GenerateImageSuccessSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 251); + +/** + * @generated from message agent.v1.GenerateImageError + */ +export type GenerateImageError = Message<"agent.v1.GenerateImageError"> & { + /** + * @generated from field: string error = 1; + */ + error: string; +}; + +/** + * Describes the message agent.v1.GenerateImageError. + * Use `create(GenerateImageErrorSchema)` to create a new message. + */ +export const GenerateImageErrorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 252); + +/** + * @generated from message agent.v1.GenerateImageToolCall + */ +export type GenerateImageToolCall = Message<"agent.v1.GenerateImageToolCall"> & { + /** + * @generated from field: agent.v1.GenerateImageArgs args = 1; + */ + args?: GenerateImageArgs; + + /** + * @generated from field: agent.v1.GenerateImageResult result = 2; + */ + result?: GenerateImageResult; +}; + +/** + * Describes the message agent.v1.GenerateImageToolCall. + * Use `create(GenerateImageToolCallSchema)` to create a new message. + */ +export const GenerateImageToolCallSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 253); + +/** + * @generated from message agent.v1.GrepArgs + */ +export type GrepArgs = Message<"agent.v1.GrepArgs"> & { + /** + * @generated from field: string pattern = 1; + */ + pattern: string; + + /** + * @generated from field: optional string path = 2; + */ + path?: string; + + /** + * @generated from field: optional string glob = 3; + */ + glob?: string; + + /** + * "content", "files_with_matches", "count" + * + * @generated from field: optional string output_mode = 4; + */ + outputMode?: string; + + /** + * @generated from field: optional int32 context_before = 5; + */ + contextBefore?: number; + + /** + * @generated from field: optional int32 context_after = 6; + */ + contextAfter?: number; + + /** + * @generated from field: optional int32 context = 7; + */ + context?: number; + + /** + * @generated from field: optional bool case_insensitive = 8; + */ + caseInsensitive?: boolean; + + /** + * --type + * + * @generated from field: optional string type = 9; + */ + type?: string; + + /** + * | head -N + * + * @generated from field: optional int32 head_limit = 10; + */ + headLimit?: number; + + /** + * -U --multiline-dotall + * + * @generated from field: optional bool multiline = 11; + */ + multiline?: boolean; + + /** + * --sort: "none", "path", "modified", "accessed", "created" + * + * @generated from field: optional string sort = 12; + */ + sort?: string; + + /** + * if false, use --sortr for reverse sort + * + * @generated from field: optional bool sort_ascending = 13; + */ + sortAscending?: boolean; + + /** + * @generated from field: string tool_call_id = 14; + */ + toolCallId: string; + + /** + * @generated from field: optional agent.v1.SandboxPolicy sandbox_policy = 15; + */ + sandboxPolicy?: SandboxPolicy; +}; + +/** + * Describes the message agent.v1.GrepArgs. + * Use `create(GrepArgsSchema)` to create a new message. + */ +export const GrepArgsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 254); + +/** + * @generated from message agent.v1.GrepResult + */ +export type GrepResult = Message<"agent.v1.GrepResult"> & { + /** + * @generated from oneof agent.v1.GrepResult.result + */ + result: + | { + /** + * @generated from field: agent.v1.GrepSuccess success = 1; + */ + value: GrepSuccess; + case: "success"; + } + | { + /** + * @generated from field: agent.v1.GrepError error = 2; + */ + value: GrepError; + case: "error"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.GrepResult. + * Use `create(GrepResultSchema)` to create a new message. + */ +export const GrepResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 255); + +/** + * @generated from message agent.v1.GrepError + */ +export type GrepError = Message<"agent.v1.GrepError"> & { + /** + * @generated from field: string error = 1; + */ + error: string; +}; + +/** + * Describes the message agent.v1.GrepError. + * Use `create(GrepErrorSchema)` to create a new message. + */ +export const GrepErrorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 256); + +/** + * @generated from message agent.v1.GrepSuccess + */ +export type GrepSuccess = Message<"agent.v1.GrepSuccess"> & { + /** + * @generated from field: string pattern = 1; + */ + pattern: string; + + /** + * @generated from field: string path = 2; + */ + path: string; + + /** + * "content", "files_with_matches", or "count" + * + * @generated from field: string output_mode = 3; + */ + outputMode: string; + + /** + * @generated from field: map workspace_results = 4; + */ + workspaceResults: { [key: string]: GrepUnionResult }; + + /** + * @generated from field: optional agent.v1.GrepUnionResult active_editor_result = 5; + */ + activeEditorResult?: GrepUnionResult; +}; + +/** + * Describes the message agent.v1.GrepSuccess. + * Use `create(GrepSuccessSchema)` to create a new message. + */ +export const GrepSuccessSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 257); + +/** + * @generated from message agent.v1.GrepUnionResult + */ +export type GrepUnionResult = Message<"agent.v1.GrepUnionResult"> & { + /** + * @generated from oneof agent.v1.GrepUnionResult.result + */ + result: + | { + /** + * @generated from field: agent.v1.GrepCountResult count = 1; + */ + value: GrepCountResult; + case: "count"; + } + | { + /** + * @generated from field: agent.v1.GrepFilesResult files = 2; + */ + value: GrepFilesResult; + case: "files"; + } + | { + /** + * @generated from field: agent.v1.GrepContentResult content = 3; + */ + value: GrepContentResult; + case: "content"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.GrepUnionResult. + * Use `create(GrepUnionResultSchema)` to create a new message. + */ +export const GrepUnionResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 258); + +/** + * @generated from message agent.v1.GrepCountResult + */ +export type GrepCountResult = Message<"agent.v1.GrepCountResult"> & { + /** + * ordered by relevance + * + * @generated from field: repeated agent.v1.GrepFileCount counts = 1; + */ + counts: GrepFileCount[]; + + /** + * The total count of files that the client found from the ripgrep call This is a lower bound if we truncated the output from the ripgrep call itself, but is accurate if client_truncated is true (but may be more than the number of files returned to the server) + * + * @generated from field: int32 total_files = 2; + */ + totalFiles: number; + + /** + * The total count of matches that the client found from the ripgrep call This is a lower bound if we truncated the output from the ripgrep call itself, but is accurate if client_truncated is true (but may be more than the number of matches returned to the server) + * + * @generated from field: int32 total_matches = 3; + */ + totalMatches: number; + + /** + * true if the client truncated the output sent to the server + * + * @generated from field: bool client_truncated = 4; + */ + clientTruncated: boolean; + + /** + * true if we truncated the output from the ripgrep call itself + * + * @generated from field: bool ripgrep_truncated = 5; + */ + ripgrepTruncated: boolean; +}; + +/** + * Describes the message agent.v1.GrepCountResult. + * Use `create(GrepCountResultSchema)` to create a new message. + */ +export const GrepCountResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 259); + +/** + * @generated from message agent.v1.GrepFileCount + */ +export type GrepFileCount = Message<"agent.v1.GrepFileCount"> & { + /** + * @generated from field: string file = 1; + */ + file: string; + + /** + * @generated from field: int32 count = 2; + */ + count: number; +}; + +/** + * Describes the message agent.v1.GrepFileCount. + * Use `create(GrepFileCountSchema)` to create a new message. + */ +export const GrepFileCountSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 260); + +/** + * @generated from message agent.v1.GrepFilesResult + */ +export type GrepFilesResult = Message<"agent.v1.GrepFilesResult"> & { + /** + * ordered by relevance + * + * @generated from field: repeated string files = 1; + */ + files: string[]; + + /** + * The total count of files that the client found from the ripgrep call This is a lower bound if we truncated the output from the ripgrep call itself, but is accurate if client_truncated is true (but may be more than the number of files returned to the server) + * + * @generated from field: int32 total_files = 2; + */ + totalFiles: number; + + /** + * true if the client truncated the output sent to the server + * + * @generated from field: bool client_truncated = 3; + */ + clientTruncated: boolean; + + /** + * true if we truncated the output from the ripgrep call itself + * + * @generated from field: bool ripgrep_truncated = 4; + */ + ripgrepTruncated: boolean; +}; + +/** + * Describes the message agent.v1.GrepFilesResult. + * Use `create(GrepFilesResultSchema)` to create a new message. + */ +export const GrepFilesResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 261); + +/** + * @generated from message agent.v1.GrepContentResult + */ +export type GrepContentResult = Message<"agent.v1.GrepContentResult"> & { + /** + * ordered by relevance + * + * @generated from field: repeated agent.v1.GrepFileMatch matches = 1; + */ + matches: GrepFileMatch[]; + + /** + * The total count of lines that the client found from the ripgrep call This is a lower bound if we truncated the output from the ripgrep call itself, but is accurate if client_truncated is true (but may be more than the number of lines returned to the server) + * + * @generated from field: int32 total_lines = 2; + */ + totalLines: number; + + /** + * The total count of matches that the client found from the ripgrep call This is a lower bound if we truncated the output from the ripgrep call itself, but is accurate if client_truncated is true (but may be more than the number of matches returned to the server) + * + * @generated from field: int32 total_matched_lines = 3; + */ + totalMatchedLines: number; + + /** + * true if the client truncated the output sent to the server + * + * @generated from field: bool client_truncated = 4; + */ + clientTruncated: boolean; + + /** + * true if we truncated the output from the ripgrep call itself + * + * @generated from field: bool ripgrep_truncated = 5; + */ + ripgrepTruncated: boolean; +}; + +/** + * Describes the message agent.v1.GrepContentResult. + * Use `create(GrepContentResultSchema)` to create a new message. + */ +export const GrepContentResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 262); + +/** + * @generated from message agent.v1.GrepFileMatch + */ +export type GrepFileMatch = Message<"agent.v1.GrepFileMatch"> & { + /** + * @generated from field: string file = 1; + */ + file: string; + + /** + * @generated from field: repeated agent.v1.GrepContentMatch matches = 2; + */ + matches: GrepContentMatch[]; +}; + +/** + * Describes the message agent.v1.GrepFileMatch. + * Use `create(GrepFileMatchSchema)` to create a new message. + */ +export const GrepFileMatchSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 263); + +/** + * @generated from message agent.v1.GrepContentMatch + */ +export type GrepContentMatch = Message<"agent.v1.GrepContentMatch"> & { + /** + * @generated from field: int32 line_number = 1; + */ + lineNumber: number; + + /** + * @generated from field: string content = 2; + */ + content: string; + + /** + * @generated from field: bool content_truncated = 3; + */ + contentTruncated: boolean; + + /** + * true for context lines (-A/B/C) + * + * @generated from field: bool is_context_line = 4; + */ + isContextLine: boolean; +}; + +/** + * Describes the message agent.v1.GrepContentMatch. + * Use `create(GrepContentMatchSchema)` to create a new message. + */ +export const GrepContentMatchSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 264); + +/** + * @generated from message agent.v1.GrepStream + */ +export type GrepStream = Message<"agent.v1.GrepStream"> & { + /** + * @generated from field: string pattern = 1; + */ + pattern: string; +}; + +/** + * Describes the message agent.v1.GrepStream. + * Use `create(GrepStreamSchema)` to create a new message. + */ +export const GrepStreamSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 265); + +/** + * @generated from message agent.v1.GrepToolCall + */ +export type GrepToolCall = Message<"agent.v1.GrepToolCall"> & { + /** + * @generated from field: agent.v1.GrepArgs args = 1; + */ + args?: GrepArgs; + + /** + * @generated from field: agent.v1.GrepResult result = 2; + */ + result?: GrepResult; +}; + +/** + * Describes the message agent.v1.GrepToolCall. + * Use `create(GrepToolCallSchema)` to create a new message. + */ +export const GrepToolCallSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 266); + +/** + * @generated from message agent.v1.GetBlobArgs + */ +export type GetBlobArgs = Message<"agent.v1.GetBlobArgs"> & { + /** + * @generated from field: bytes blob_id = 1; + */ + blobId: Uint8Array; +}; + +/** + * Describes the message agent.v1.GetBlobArgs. + * Use `create(GetBlobArgsSchema)` to create a new message. + */ +export const GetBlobArgsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 267); + +/** + * @generated from message agent.v1.GetBlobResult + */ +export type GetBlobResult = Message<"agent.v1.GetBlobResult"> & { + /** + * @generated from field: optional bytes blob_data = 1; + */ + blobData?: Uint8Array; +}; + +/** + * Describes the message agent.v1.GetBlobResult. + * Use `create(GetBlobResultSchema)` to create a new message. + */ +export const GetBlobResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 268); + +/** + * @generated from message agent.v1.SetBlobArgs + */ +export type SetBlobArgs = Message<"agent.v1.SetBlobArgs"> & { + /** + * @generated from field: bytes blob_id = 1; + */ + blobId: Uint8Array; + + /** + * @generated from field: bytes blob_data = 2; + */ + blobData: Uint8Array; +}; + +/** + * Describes the message agent.v1.SetBlobArgs. + * Use `create(SetBlobArgsSchema)` to create a new message. + */ +export const SetBlobArgsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 269); + +/** + * @generated from message agent.v1.SetBlobResult + */ +export type SetBlobResult = Message<"agent.v1.SetBlobResult"> & { + /** + * @generated from field: optional agent.v1.Error error = 1; + */ + error?: Error; +}; + +/** + * Describes the message agent.v1.SetBlobResult. + * Use `create(SetBlobResultSchema)` to create a new message. + */ +export const SetBlobResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 270); + +/** + * @generated from message agent.v1.KvServerMessage + */ +export type KvServerMessage = Message<"agent.v1.KvServerMessage"> & { + /** + * @generated from field: uint32 id = 1; + */ + id: number; + + /** + * Span context for distributed tracing + * + * @generated from field: optional agent.v1.SpanContext span_context = 4; + */ + spanContext?: SpanContext; + + /** + * @generated from oneof agent.v1.KvServerMessage.message + */ + message: + | { + /** + * @generated from field: agent.v1.GetBlobArgs get_blob_args = 2; + */ + value: GetBlobArgs; + case: "getBlobArgs"; + } + | { + /** + * @generated from field: agent.v1.SetBlobArgs set_blob_args = 3; + */ + value: SetBlobArgs; + case: "setBlobArgs"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.KvServerMessage. + * Use `create(KvServerMessageSchema)` to create a new message. + */ +export const KvServerMessageSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 271); + +/** + * @generated from message agent.v1.KvClientMessage + */ +export type KvClientMessage = Message<"agent.v1.KvClientMessage"> & { + /** + * @generated from field: uint32 id = 1; + */ + id: number; + + /** + * @generated from oneof agent.v1.KvClientMessage.message + */ + message: + | { + /** + * @generated from field: agent.v1.GetBlobResult get_blob_result = 2; + */ + value: GetBlobResult; + case: "getBlobResult"; + } + | { + /** + * @generated from field: agent.v1.SetBlobResult set_blob_result = 3; + */ + value: SetBlobResult; + case: "setBlobResult"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.KvClientMessage. + * Use `create(KvClientMessageSchema)` to create a new message. + */ +export const KvClientMessageSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 272); + +/** + * @generated from message agent.v1.LsArgs + */ +export type LsArgs = Message<"agent.v1.LsArgs"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: repeated string ignore = 2; + */ + ignore: string[]; + + /** + * @generated from field: string tool_call_id = 3; + */ + toolCallId: string; + + /** + * @generated from field: optional agent.v1.SandboxPolicy sandbox_policy = 4; + */ + sandboxPolicy?: SandboxPolicy; + + /** + * defaults to 5000ms + * + * @generated from field: optional uint32 timeout_ms = 5; + */ + timeoutMs?: number; +}; + +/** + * Describes the message agent.v1.LsArgs. + * Use `create(LsArgsSchema)` to create a new message. + */ +export const LsArgsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 273); + +/** + * @generated from message agent.v1.LsResult + */ +export type LsResult = Message<"agent.v1.LsResult"> & { + /** + * @generated from oneof agent.v1.LsResult.result + */ + result: + | { + /** + * @generated from field: agent.v1.LsSuccess success = 1; + */ + value: LsSuccess; + case: "success"; + } + | { + /** + * @generated from field: agent.v1.LsError error = 2; + */ + value: LsError; + case: "error"; + } + | { + /** + * @generated from field: agent.v1.LsRejected rejected = 3; + */ + value: LsRejected; + case: "rejected"; + } + | { + /** + * @generated from field: agent.v1.LsTimeout timeout = 4; + */ + value: LsTimeout; + case: "timeout"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.LsResult. + * Use `create(LsResultSchema)` to create a new message. + */ +export const LsResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 274); + +/** + * @generated from message agent.v1.LsSuccess + */ +export type LsSuccess = Message<"agent.v1.LsSuccess"> & { + /** + * @generated from field: agent.v1.LsDirectoryTreeNode directory_tree_root = 1; + */ + directoryTreeRoot?: LsDirectoryTreeNode; +}; + +/** + * Describes the message agent.v1.LsSuccess. + * Use `create(LsSuccessSchema)` to create a new message. + */ +export const LsSuccessSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 275); + +/** + * @generated from message agent.v1.LsDirectoryTreeNode + */ +export type LsDirectoryTreeNode = Message<"agent.v1.LsDirectoryTreeNode"> & { + /** + * @generated from field: string abs_path = 1; + */ + absPath: string; + + /** + * @generated from field: repeated agent.v1.LsDirectoryTreeNode children_dirs = 2; + */ + childrenDirs: LsDirectoryTreeNode[]; + + /** + * @generated from field: repeated agent.v1.LsDirectoryTreeNode_File children_files = 3; + */ + childrenFiles: LsDirectoryTreeNode_File[]; + + /** + * Proto doesn't allow repeated fields to be optional, so in case of empty children arrays, this fields indicates if it happens: `true` - because directory really doesn't have any children `false` - because we stopped traversal before getting to its children + * + * @generated from field: bool children_were_processed = 4; + */ + childrenWereProcessed: boolean; + + /** + * Count of extensions in the full sub-tree + * + * @generated from field: map full_subtree_extension_counts = 5; + */ + fullSubtreeExtensionCounts: { [key: string]: number }; + + /** + * @generated from field: int32 num_files = 6; + */ + numFiles: number; +}; + +/** + * Describes the message agent.v1.LsDirectoryTreeNode. + * Use `create(LsDirectoryTreeNodeSchema)` to create a new message. + */ +export const LsDirectoryTreeNodeSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 276); + +/** + * @generated from message agent.v1.LsDirectoryTreeNode_File + */ +export type LsDirectoryTreeNode_File = Message<"agent.v1.LsDirectoryTreeNode_File"> & { + /** + * @generated from field: string name = 1; + */ + name: string; + + /** + * @generated from field: optional agent.v1.TerminalMetadata terminal_metadata = 2; + */ + terminalMetadata?: TerminalMetadata; +}; + +/** + * Describes the message agent.v1.LsDirectoryTreeNode_File. + * Use `create(LsDirectoryTreeNode_FileSchema)` to create a new message. + */ +export const LsDirectoryTreeNode_FileSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 277); + +/** + * @generated from message agent.v1.LsError + */ +export type LsError = Message<"agent.v1.LsError"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string error = 2; + */ + error: string; +}; + +/** + * Describes the message agent.v1.LsError. + * Use `create(LsErrorSchema)` to create a new message. + */ +export const LsErrorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 278); + +/** + * @generated from message agent.v1.LsRejected + */ +export type LsRejected = Message<"agent.v1.LsRejected"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string reason = 2; + */ + reason: string; +}; + +/** + * Describes the message agent.v1.LsRejected. + * Use `create(LsRejectedSchema)` to create a new message. + */ +export const LsRejectedSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 279); + +/** + * Returned when ls operation timed out. Contains partial results gathered before timeout. + * + * @generated from message agent.v1.LsTimeout + */ +export type LsTimeout = Message<"agent.v1.LsTimeout"> & { + /** + * @generated from field: agent.v1.LsDirectoryTreeNode directory_tree_root = 1; + */ + directoryTreeRoot?: LsDirectoryTreeNode; +}; + +/** + * Describes the message agent.v1.LsTimeout. + * Use `create(LsTimeoutSchema)` to create a new message. + */ +export const LsTimeoutSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 280); + +/** + * @generated from message agent.v1.TerminalMetadata + */ +export type TerminalMetadata = Message<"agent.v1.TerminalMetadata"> & { + /** + * @generated from field: optional string cwd = 1; + */ + cwd?: string; + + /** + * @generated from field: repeated agent.v1.TerminalMetadata_Command last_commands = 2; + */ + lastCommands: TerminalMetadata_Command[]; + + /** + * @generated from field: optional int64 last_modified_ms = 3; + */ + lastModifiedMs?: bigint; + + /** + * @generated from field: optional agent.v1.TerminalMetadata_Command current_command = 4; + */ + currentCommand?: TerminalMetadata_Command; +}; + +/** + * Describes the message agent.v1.TerminalMetadata. + * Use `create(TerminalMetadataSchema)` to create a new message. + */ +export const TerminalMetadataSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 281); + +/** + * @generated from message agent.v1.TerminalMetadata_Command + */ +export type TerminalMetadata_Command = Message<"agent.v1.TerminalMetadata_Command"> & { + /** + * @generated from field: string command = 1; + */ + command: string; + + /** + * @generated from field: optional int32 exit_code = 2; + */ + exitCode?: number; + + /** + * @generated from field: optional int64 timestamp_ms = 3; + */ + timestampMs?: bigint; + + /** + * @generated from field: optional int64 duration_ms = 4; + */ + durationMs?: bigint; +}; + +/** + * Describes the message agent.v1.TerminalMetadata_Command. + * Use `create(TerminalMetadata_CommandSchema)` to create a new message. + */ +export const TerminalMetadata_CommandSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 282); + +/** + * @generated from message agent.v1.LsToolCall + */ +export type LsToolCall = Message<"agent.v1.LsToolCall"> & { + /** + * @generated from field: agent.v1.LsArgs args = 1; + */ + args?: LsArgs; + + /** + * @generated from field: agent.v1.LsResult result = 2; + */ + result?: LsResult; +}; + +/** + * Describes the message agent.v1.LsToolCall. + * Use `create(LsToolCallSchema)` to create a new message. + */ +export const LsToolCallSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 283); + +/** + * @generated from message agent.v1.McpArgs + */ +export type McpArgs = Message<"agent.v1.McpArgs"> & { + /** + * @generated from field: string name = 1; + */ + name: string; + + /** + * @generated from field: map args = 2; + */ + args: { [key: string]: Uint8Array }; + + /** + * @generated from field: string tool_call_id = 3; + */ + toolCallId: string; + + /** + * @generated from field: string provider_identifier = 4; + */ + providerIdentifier: string; + + /** + * @generated from field: string tool_name = 5; + */ + toolName: string; +}; + +/** + * Describes the message agent.v1.McpArgs. + * Use `create(McpArgsSchema)` to create a new message. + */ +export const McpArgsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 284); + +/** + * @generated from message agent.v1.McpResult + */ +export type McpResult = Message<"agent.v1.McpResult"> & { + /** + * @generated from oneof agent.v1.McpResult.result + */ + result: + | { + /** + * @generated from field: agent.v1.McpSuccess success = 1; + */ + value: McpSuccess; + case: "success"; + } + | { + /** + * @generated from field: agent.v1.McpError error = 2; + */ + value: McpError; + case: "error"; + } + | { + /** + * @generated from field: agent.v1.McpRejected rejected = 3; + */ + value: McpRejected; + case: "rejected"; + } + | { + /** + * @generated from field: agent.v1.McpPermissionDenied permission_denied = 4; + */ + value: McpPermissionDenied; + case: "permissionDenied"; + } + | { + /** + * @generated from field: agent.v1.McpToolNotFound tool_not_found = 5; + */ + value: McpToolNotFound; + case: "toolNotFound"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.McpResult. + * Use `create(McpResultSchema)` to create a new message. + */ +export const McpResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 285); + +/** + * @generated from message agent.v1.McpToolNotFound + */ +export type McpToolNotFound = Message<"agent.v1.McpToolNotFound"> & { + /** + * @generated from field: string name = 1; + */ + name: string; + + /** + * @generated from field: repeated string available_tools = 2; + */ + availableTools: string[]; +}; + +/** + * Describes the message agent.v1.McpToolNotFound. + * Use `create(McpToolNotFoundSchema)` to create a new message. + */ +export const McpToolNotFoundSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 286); + +/** + * Text content item + * + * @generated from message agent.v1.McpTextContent + */ +export type McpTextContent = Message<"agent.v1.McpTextContent"> & { + /** + * @generated from field: string text = 1; + */ + text: string; + + /** + * Optional file location for large outputs + * + * @generated from field: optional agent.v1.OutputLocation output_location = 2; + */ + outputLocation?: OutputLocation; +}; + +/** + * Describes the message agent.v1.McpTextContent. + * Use `create(McpTextContentSchema)` to create a new message. + */ +export const McpTextContentSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 287); + +/** + * Image content item + * + * @generated from message agent.v1.McpImageContent + */ +export type McpImageContent = Message<"agent.v1.McpImageContent"> & { + /** + * Raw bytes of the image. In JSON, this will be base64-encoded. + * + * @generated from field: bytes data = 1; + */ + data: Uint8Array; + + /** + * Optional MIME type, e.g. "image/png" + * + * @generated from field: string mime_type = 2; + */ + mimeType: string; +}; + +/** + * Describes the message agent.v1.McpImageContent. + * Use `create(McpImageContentSchema)` to create a new message. + */ +export const McpImageContentSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 288); + +/** + * A single tool result content item: either text or image + * + * @generated from message agent.v1.McpToolResultContentItem + */ +export type McpToolResultContentItem = Message<"agent.v1.McpToolResultContentItem"> & { + /** + * @generated from oneof agent.v1.McpToolResultContentItem.content + */ + content: + | { + /** + * @generated from field: agent.v1.McpTextContent text = 1; + */ + value: McpTextContent; + case: "text"; + } + | { + /** + * @generated from field: agent.v1.McpImageContent image = 2; + */ + value: McpImageContent; + case: "image"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.McpToolResultContentItem. + * Use `create(McpToolResultContentItemSchema)` to create a new message. + */ +export const McpToolResultContentItemSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 289); + +/** + * Equivalent to the requested McpToolResult TypeScript type + * + * @generated from message agent.v1.McpSuccess + */ +export type McpSuccess = Message<"agent.v1.McpSuccess"> & { + /** + * @generated from field: repeated agent.v1.McpToolResultContentItem content = 1; + */ + content: McpToolResultContentItem[]; + + /** + * @generated from field: bool is_error = 2; + */ + isError: boolean; +}; + +/** + * Describes the message agent.v1.McpSuccess. + * Use `create(McpSuccessSchema)` to create a new message. + */ +export const McpSuccessSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 290); + +/** + * @generated from message agent.v1.McpError + */ +export type McpError = Message<"agent.v1.McpError"> & { + /** + * @generated from field: string error = 1; + */ + error: string; +}; + +/** + * Describes the message agent.v1.McpError. + * Use `create(McpErrorSchema)` to create a new message. + */ +export const McpErrorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 291); + +/** + * @generated from message agent.v1.McpRejected + */ +export type McpRejected = Message<"agent.v1.McpRejected"> & { + /** + * @generated from field: string reason = 1; + */ + reason: string; + + /** + * @generated from field: bool is_readonly = 2; + */ + isReadonly: boolean; +}; + +/** + * Describes the message agent.v1.McpRejected. + * Use `create(McpRejectedSchema)` to create a new message. + */ +export const McpRejectedSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 292); + +/** + * @generated from message agent.v1.McpPermissionDenied + */ +export type McpPermissionDenied = Message<"agent.v1.McpPermissionDenied"> & { + /** + * @generated from field: string error = 1; + */ + error: string; + + /** + * @generated from field: bool is_readonly = 2; + */ + isReadonly: boolean; +}; + +/** + * Describes the message agent.v1.McpPermissionDenied. + * Use `create(McpPermissionDeniedSchema)` to create a new message. + */ +export const McpPermissionDeniedSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 293); + +/** + * List MCP resources exec args + * + * @generated from message agent.v1.ListMcpResourcesExecArgs + */ +export type ListMcpResourcesExecArgs = Message<"agent.v1.ListMcpResourcesExecArgs"> & { + /** + * Optional server name to filter resources by + * + * @generated from field: optional string server = 1; + */ + server?: string; +}; + +/** + * Describes the message agent.v1.ListMcpResourcesExecArgs. + * Use `create(ListMcpResourcesExecArgsSchema)` to create a new message. + */ +export const ListMcpResourcesExecArgsSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 294); + +/** + * List MCP resources exec result + * + * @generated from message agent.v1.ListMcpResourcesExecResult + */ +export type ListMcpResourcesExecResult = Message<"agent.v1.ListMcpResourcesExecResult"> & { + /** + * @generated from oneof agent.v1.ListMcpResourcesExecResult.result + */ + result: + | { + /** + * @generated from field: agent.v1.ListMcpResourcesSuccess success = 1; + */ + value: ListMcpResourcesSuccess; + case: "success"; + } + | { + /** + * @generated from field: agent.v1.ListMcpResourcesError error = 2; + */ + value: ListMcpResourcesError; + case: "error"; + } + | { + /** + * @generated from field: agent.v1.ListMcpResourcesRejected rejected = 3; + */ + value: ListMcpResourcesRejected; + case: "rejected"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.ListMcpResourcesExecResult. + * Use `create(ListMcpResourcesExecResultSchema)` to create a new message. + */ +export const ListMcpResourcesExecResultSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 295); + +/** + * @generated from message agent.v1.ListMcpResourcesExecResult_McpResource + */ +export type ListMcpResourcesExecResult_McpResource = Message<"agent.v1.ListMcpResourcesExecResult_McpResource"> & { + /** + * @generated from field: string uri = 1; + */ + uri: string; + + /** + * @generated from field: optional string name = 2; + */ + name?: string; + + /** + * @generated from field: optional string description = 3; + */ + description?: string; + + /** + * @generated from field: optional string mime_type = 4; + */ + mimeType?: string; + + /** + * Server name that provides this resource + * + * @generated from field: string server = 5; + */ + server: string; + + /** + * Additional metadata + * + * @generated from field: map annotations = 6; + */ + annotations: { [key: string]: string }; +}; + +/** + * Describes the message agent.v1.ListMcpResourcesExecResult_McpResource. + * Use `create(ListMcpResourcesExecResult_McpResourceSchema)` to create a new message. + */ +export const ListMcpResourcesExecResult_McpResourceSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 296); + +/** + * @generated from message agent.v1.ListMcpResourcesSuccess + */ +export type ListMcpResourcesSuccess = Message<"agent.v1.ListMcpResourcesSuccess"> & { + /** + * @generated from field: repeated agent.v1.ListMcpResourcesExecResult_McpResource resources = 1; + */ + resources: ListMcpResourcesExecResult_McpResource[]; +}; + +/** + * Describes the message agent.v1.ListMcpResourcesSuccess. + * Use `create(ListMcpResourcesSuccessSchema)` to create a new message. + */ +export const ListMcpResourcesSuccessSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 297); + +/** + * @generated from message agent.v1.ListMcpResourcesError + */ +export type ListMcpResourcesError = Message<"agent.v1.ListMcpResourcesError"> & { + /** + * @generated from field: string error = 1; + */ + error: string; +}; + +/** + * Describes the message agent.v1.ListMcpResourcesError. + * Use `create(ListMcpResourcesErrorSchema)` to create a new message. + */ +export const ListMcpResourcesErrorSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 298); + +/** + * @generated from message agent.v1.ListMcpResourcesRejected + */ +export type ListMcpResourcesRejected = Message<"agent.v1.ListMcpResourcesRejected"> & { + /** + * @generated from field: string reason = 1; + */ + reason: string; +}; + +/** + * Describes the message agent.v1.ListMcpResourcesRejected. + * Use `create(ListMcpResourcesRejectedSchema)` to create a new message. + */ +export const ListMcpResourcesRejectedSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 299); + +/** + * Read MCP resource exec args + * + * @generated from message agent.v1.ReadMcpResourceExecArgs + */ +export type ReadMcpResourceExecArgs = Message<"agent.v1.ReadMcpResourceExecArgs"> & { + /** + * Required server name + * + * @generated from field: string server = 1; + */ + server: string; + + /** + * Required resource URI + * + * @generated from field: string uri = 2; + */ + uri: string; + + /** + * Optional: when set, the resource will be downloaded to this path relative to the workspace, and the content will not be returned to the model. + * + * @generated from field: optional string download_path = 3; + */ + downloadPath?: string; +}; + +/** + * Describes the message agent.v1.ReadMcpResourceExecArgs. + * Use `create(ReadMcpResourceExecArgsSchema)` to create a new message. + */ +export const ReadMcpResourceExecArgsSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 300); + +/** + * Read MCP resource exec result + * + * @generated from message agent.v1.ReadMcpResourceExecResult + */ +export type ReadMcpResourceExecResult = Message<"agent.v1.ReadMcpResourceExecResult"> & { + /** + * @generated from oneof agent.v1.ReadMcpResourceExecResult.result + */ + result: + | { + /** + * @generated from field: agent.v1.ReadMcpResourceSuccess success = 1; + */ + value: ReadMcpResourceSuccess; + case: "success"; + } + | { + /** + * @generated from field: agent.v1.ReadMcpResourceError error = 2; + */ + value: ReadMcpResourceError; + case: "error"; + } + | { + /** + * @generated from field: agent.v1.ReadMcpResourceRejected rejected = 3; + */ + value: ReadMcpResourceRejected; + case: "rejected"; + } + | { + /** + * @generated from field: agent.v1.ReadMcpResourceNotFound not_found = 4; + */ + value: ReadMcpResourceNotFound; + case: "notFound"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.ReadMcpResourceExecResult. + * Use `create(ReadMcpResourceExecResultSchema)` to create a new message. + */ +export const ReadMcpResourceExecResultSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 301); + +/** + * @generated from message agent.v1.ReadMcpResourceSuccess + */ +export type ReadMcpResourceSuccess = Message<"agent.v1.ReadMcpResourceSuccess"> & { + /** + * @generated from field: string uri = 1; + */ + uri: string; + + /** + * @generated from field: optional string name = 2; + */ + name?: string; + + /** + * @generated from field: optional string description = 3; + */ + description?: string; + + /** + * @generated from field: optional string mime_type = 4; + */ + mimeType?: string; + + /** + * Additional metadata + * + * @generated from field: map annotations = 7; + */ + annotations: { [key: string]: string }; + + /** + * If set, resource was downloaded to this path + * + * @generated from field: optional string download_path = 8; + */ + downloadPath?: string; + + /** + * @generated from oneof agent.v1.ReadMcpResourceSuccess.content + */ + content: + | { + /** + * @generated from field: string text = 5; + */ + value: string; + case: "text"; + } + | { + /** + * @generated from field: bytes blob = 6; + */ + value: Uint8Array; + case: "blob"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.ReadMcpResourceSuccess. + * Use `create(ReadMcpResourceSuccessSchema)` to create a new message. + */ +export const ReadMcpResourceSuccessSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 302); + +/** + * @generated from message agent.v1.ReadMcpResourceError + */ +export type ReadMcpResourceError = Message<"agent.v1.ReadMcpResourceError"> & { + /** + * @generated from field: string uri = 1; + */ + uri: string; + + /** + * @generated from field: string error = 2; + */ + error: string; +}; + +/** + * Describes the message agent.v1.ReadMcpResourceError. + * Use `create(ReadMcpResourceErrorSchema)` to create a new message. + */ +export const ReadMcpResourceErrorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 303); + +/** + * @generated from message agent.v1.ReadMcpResourceRejected + */ +export type ReadMcpResourceRejected = Message<"agent.v1.ReadMcpResourceRejected"> & { + /** + * @generated from field: string uri = 1; + */ + uri: string; + + /** + * @generated from field: string reason = 2; + */ + reason: string; +}; + +/** + * Describes the message agent.v1.ReadMcpResourceRejected. + * Use `create(ReadMcpResourceRejectedSchema)` to create a new message. + */ +export const ReadMcpResourceRejectedSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 304); + +/** + * @generated from message agent.v1.ReadMcpResourceNotFound + */ +export type ReadMcpResourceNotFound = Message<"agent.v1.ReadMcpResourceNotFound"> & { + /** + * @generated from field: string uri = 1; + */ + uri: string; +}; + +/** + * Describes the message agent.v1.ReadMcpResourceNotFound. + * Use `create(ReadMcpResourceNotFoundSchema)` to create a new message. + */ +export const ReadMcpResourceNotFoundSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 305); + +/** + * @generated from message agent.v1.McpToolDefinition + */ +export type McpToolDefinition = Message<"agent.v1.McpToolDefinition"> & { + /** + * @generated from field: string name = 1; + */ + name: string; + + /** + * @generated from field: string provider_identifier = 4; + */ + providerIdentifier: string; + + /** + * @generated from field: string tool_name = 5; + */ + toolName: string; + + /** + * @generated from field: string description = 2; + */ + description: string; + + /** + * @generated from field: bytes input_schema = 3; + */ + inputSchema: Uint8Array; +}; + +/** + * Describes the message agent.v1.McpToolDefinition. + * Use `create(McpToolDefinitionSchema)` to create a new message. + */ +export const McpToolDefinitionSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 306); + +/** + * @generated from message agent.v1.McpTools + */ +export type McpTools = Message<"agent.v1.McpTools"> & { + /** + * @generated from field: repeated agent.v1.McpToolDefinition mcp_tools = 1; + */ + mcpTools: McpToolDefinition[]; +}; + +/** + * Describes the message agent.v1.McpTools. + * Use `create(McpToolsSchema)` to create a new message. + */ +export const McpToolsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 307); + +/** + * Represents MCP-provided instructions from a specific server + * + * @generated from message agent.v1.McpInstructions + */ +export type McpInstructions = Message<"agent.v1.McpInstructions"> & { + /** + * @generated from field: string server_name = 1; + */ + serverName: string; + + /** + * @generated from field: string instructions = 2; + */ + instructions: string; +}; + +/** + * Describes the message agent.v1.McpInstructions. + * Use `create(McpInstructionsSchema)` to create a new message. + */ +export const McpInstructionsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 308); + +/** + * @generated from message agent.v1.McpDescriptor + */ +export type McpDescriptor = Message<"agent.v1.McpDescriptor"> & { + /** + * Display name of the MCP server associated with this folder. + * + * @generated from field: string server_name = 1; + */ + serverName: string; + + /** + * @generated from field: string server_identifier = 2; + */ + serverIdentifier: string; + + /** + * Absolute folder path where MCP tool descriptor JSON files are stored. + * + * @generated from field: optional string folder_path = 3; + */ + folderPath?: string; + + /** + * @generated from field: optional string server_use_instructions = 4; + */ + serverUseInstructions?: string; + + /** + * @generated from field: repeated agent.v1.McpToolDescriptor tools = 5; + */ + tools: McpToolDescriptor[]; +}; + +/** + * Describes the message agent.v1.McpDescriptor. + * Use `create(McpDescriptorSchema)` to create a new message. + */ +export const McpDescriptorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 309); + +/** + * @generated from message agent.v1.McpToolDescriptor + */ +export type McpToolDescriptor = Message<"agent.v1.McpToolDescriptor"> & { + /** + * @generated from field: string tool_name = 1; + */ + toolName: string; + + /** + * @generated from field: optional string definition_path = 2; + */ + definitionPath?: string; +}; + +/** + * Describes the message agent.v1.McpToolDescriptor. + * Use `create(McpToolDescriptorSchema)` to create a new message. + */ +export const McpToolDescriptorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 310); + +/** + * @generated from message agent.v1.McpFileSystemOptions + */ +export type McpFileSystemOptions = Message<"agent.v1.McpFileSystemOptions"> & { + /** + * @generated from field: bool enabled = 1; + */ + enabled: boolean; + + /** + * @generated from field: string workspace_project_dir = 2; + */ + workspaceProjectDir: string; + + /** + * @generated from field: repeated agent.v1.McpDescriptor mcp_descriptors = 3; + */ + mcpDescriptors: McpDescriptor[]; +}; + +/** + * Describes the message agent.v1.McpFileSystemOptions. + * Use `create(McpFileSystemOptionsSchema)` to create a new message. + */ +export const McpFileSystemOptionsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 311); + +/** + * @generated from message agent.v1.ReadArgs + */ +export type ReadArgs = Message<"agent.v1.ReadArgs"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string tool_call_id = 2; + */ + toolCallId: string; +}; + +/** + * Describes the message agent.v1.ReadArgs. + * Use `create(ReadArgsSchema)` to create a new message. + */ +export const ReadArgsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 312); + +/** + * @generated from message agent.v1.ReadResult + */ +export type ReadResult = Message<"agent.v1.ReadResult"> & { + /** + * @generated from oneof agent.v1.ReadResult.result + */ + result: + | { + /** + * @generated from field: agent.v1.ReadSuccess success = 1; + */ + value: ReadSuccess; + case: "success"; + } + | { + /** + * @generated from field: agent.v1.ReadError error = 2; + */ + value: ReadError; + case: "error"; + } + | { + /** + * @generated from field: agent.v1.ReadRejected rejected = 3; + */ + value: ReadRejected; + case: "rejected"; + } + | { + /** + * @generated from field: agent.v1.ReadFileNotFound file_not_found = 4; + */ + value: ReadFileNotFound; + case: "fileNotFound"; + } + | { + /** + * @generated from field: agent.v1.ReadPermissionDenied permission_denied = 5; + */ + value: ReadPermissionDenied; + case: "permissionDenied"; + } + | { + /** + * @generated from field: agent.v1.ReadInvalidFile invalid_file = 6; + */ + value: ReadInvalidFile; + case: "invalidFile"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.ReadResult. + * Use `create(ReadResultSchema)` to create a new message. + */ +export const ReadResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 313); + +/** + * @generated from message agent.v1.ReadSuccess + */ +export type ReadSuccess = Message<"agent.v1.ReadSuccess"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: int32 total_lines = 3; + */ + totalLines: number; + + /** + * @generated from field: int64 file_size = 4; + */ + fileSize: bigint; + + /** + * true if the content was truncated due to size limits + * + * @generated from field: bool truncated = 6; + */ + truncated: boolean; + + /** + * Returns blob ID if the output was stored in the blob store. If provided, the output is stored separately from the rest of the tool result, and since it's already in the blob store, it need not be sent back to the client -- reducing bandwidth. + * + * @generated from field: optional bytes output_blob_id = 7; + */ + outputBlobId?: Uint8Array; + + /** + * @generated from oneof agent.v1.ReadSuccess.output + */ + output: + | { + /** + * @generated from field: string content = 2; + */ + value: string; + case: "content"; + } + | { + /** + * @generated from field: bytes data = 5; + */ + value: Uint8Array; + case: "data"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.ReadSuccess. + * Use `create(ReadSuccessSchema)` to create a new message. + */ +export const ReadSuccessSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 314); + +/** + * @generated from message agent.v1.ReadError + */ +export type ReadError = Message<"agent.v1.ReadError"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string error = 2; + */ + error: string; +}; + +/** + * Describes the message agent.v1.ReadError. + * Use `create(ReadErrorSchema)` to create a new message. + */ +export const ReadErrorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 315); + +/** + * @generated from message agent.v1.ReadRejected + */ +export type ReadRejected = Message<"agent.v1.ReadRejected"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string reason = 2; + */ + reason: string; +}; + +/** + * Describes the message agent.v1.ReadRejected. + * Use `create(ReadRejectedSchema)` to create a new message. + */ +export const ReadRejectedSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 316); + +/** + * @generated from message agent.v1.ReadFileNotFound + */ +export type ReadFileNotFound = Message<"agent.v1.ReadFileNotFound"> & { + /** + * @generated from field: string path = 1; + */ + path: string; +}; + +/** + * Describes the message agent.v1.ReadFileNotFound. + * Use `create(ReadFileNotFoundSchema)` to create a new message. + */ +export const ReadFileNotFoundSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 317); + +/** + * @generated from message agent.v1.ReadPermissionDenied + */ +export type ReadPermissionDenied = Message<"agent.v1.ReadPermissionDenied"> & { + /** + * @generated from field: string path = 1; + */ + path: string; +}; + +/** + * Describes the message agent.v1.ReadPermissionDenied. + * Use `create(ReadPermissionDeniedSchema)` to create a new message. + */ +export const ReadPermissionDeniedSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 318); + +/** + * @generated from message agent.v1.ReadInvalidFile + */ +export type ReadInvalidFile = Message<"agent.v1.ReadInvalidFile"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * e.g., "Path is a directory, not a file" + * + * @generated from field: string reason = 2; + */ + reason: string; +}; + +/** + * Describes the message agent.v1.ReadInvalidFile. + * Use `create(ReadInvalidFileSchema)` to create a new message. + */ +export const ReadInvalidFileSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 319); + +/** + * @generated from message agent.v1.ReadToolCall + */ +export type ReadToolCall = Message<"agent.v1.ReadToolCall"> & { + /** + * @generated from field: agent.v1.ReadToolArgs args = 1; + */ + args?: ReadToolArgs; + + /** + * @generated from field: agent.v1.ReadToolResult result = 2; + */ + result?: ReadToolResult; +}; + +/** + * Describes the message agent.v1.ReadToolCall. + * Use `create(ReadToolCallSchema)` to create a new message. + */ +export const ReadToolCallSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 320); + +/** + * @generated from message agent.v1.ReadToolArgs + */ +export type ReadToolArgs = Message<"agent.v1.ReadToolArgs"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: optional int32 offset = 2; + */ + offset?: number; + + /** + * @generated from field: optional int32 limit = 3; + */ + limit?: number; +}; + +/** + * Describes the message agent.v1.ReadToolArgs. + * Use `create(ReadToolArgsSchema)` to create a new message. + */ +export const ReadToolArgsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 321); + +/** + * @generated from message agent.v1.ReadToolResult + */ +export type ReadToolResult = Message<"agent.v1.ReadToolResult"> & { + /** + * @generated from oneof agent.v1.ReadToolResult.result + */ + result: + | { + /** + * @generated from field: agent.v1.ReadToolSuccess success = 1; + */ + value: ReadToolSuccess; + case: "success"; + } + | { + /** + * @generated from field: agent.v1.ReadToolError error = 2; + */ + value: ReadToolError; + case: "error"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.ReadToolResult. + * Use `create(ReadToolResultSchema)` to create a new message. + */ +export const ReadToolResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 322); + +/** + * @generated from message agent.v1.ReadRange + */ +export type ReadRange = Message<"agent.v1.ReadRange"> & { + /** + * @generated from field: uint32 start_line = 1; + */ + startLine: number; + + /** + * @generated from field: uint32 end_line = 2; + */ + endLine: number; +}; + +/** + * Describes the message agent.v1.ReadRange. + * Use `create(ReadRangeSchema)` to create a new message. + */ +export const ReadRangeSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 323); + +/** + * @generated from message agent.v1.ReadToolSuccess + */ +export type ReadToolSuccess = Message<"agent.v1.ReadToolSuccess"> & { + /** + * @generated from field: bool is_empty = 2; + */ + isEmpty: boolean; + + /** + * @generated from field: bool exceeded_limit = 3; + */ + exceededLimit: boolean; + + /** + * @generated from field: uint32 total_lines = 4; + */ + totalLines: number; + + /** + * @generated from field: uint32 file_size = 5; + */ + fileSize: number; + + /** + * @generated from field: string path = 7; + */ + path: string; + + /** + * @generated from field: optional agent.v1.ReadRange read_range = 8; + */ + readRange?: ReadRange; + + /** + * @generated from oneof agent.v1.ReadToolSuccess.output + */ + output: + | { + /** + * @generated from field: string content = 1; + */ + value: string; + case: "content"; + } + | { + /** + * @generated from field: bytes data = 6; + */ + value: Uint8Array; + case: "data"; + } + | { + /** + * @generated from field: bytes data_blob_id = 9; + */ + value: Uint8Array; + case: "dataBlobId"; + } + | { + /** + * @generated from field: bytes content_blob_id = 10; + */ + value: Uint8Array; + case: "contentBlobId"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.ReadToolSuccess. + * Use `create(ReadToolSuccessSchema)` to create a new message. + */ +export const ReadToolSuccessSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 324); + +/** + * @generated from message agent.v1.ReadToolError + */ +export type ReadToolError = Message<"agent.v1.ReadToolError"> & { + /** + * @generated from field: string error_message = 1; + */ + errorMessage: string; +}; + +/** + * Describes the message agent.v1.ReadToolError. + * Use `create(ReadToolErrorSchema)` to create a new message. + */ +export const ReadToolErrorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 325); + +/** + * @generated from message agent.v1.RecordScreenArgs + */ +export type RecordScreenArgs = Message<"agent.v1.RecordScreenArgs"> & { + /** + * @generated from field: int32 mode = 1; + */ + mode: number; + + /** + * @generated from field: string tool_call_id = 2; + */ + toolCallId: string; + + /** + * Custom filename for SAVE_RECORDING mode + * + * @generated from field: optional string save_as_filename = 3; + */ + saveAsFilename?: string; +}; + +/** + * Describes the message agent.v1.RecordScreenArgs. + * Use `create(RecordScreenArgsSchema)` to create a new message. + */ +export const RecordScreenArgsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 326); + +/** + * @generated from message agent.v1.RecordScreenResult + */ +export type RecordScreenResult = Message<"agent.v1.RecordScreenResult"> & { + /** + * @generated from oneof agent.v1.RecordScreenResult.result + */ + result: + | { + /** + * @generated from field: agent.v1.RecordScreenStartSuccess start_success = 1; + */ + value: RecordScreenStartSuccess; + case: "startSuccess"; + } + | { + /** + * @generated from field: agent.v1.RecordScreenSaveSuccess save_success = 2; + */ + value: RecordScreenSaveSuccess; + case: "saveSuccess"; + } + | { + /** + * @generated from field: agent.v1.RecordScreenDiscardSuccess discard_success = 3; + */ + value: RecordScreenDiscardSuccess; + case: "discardSuccess"; + } + | { + /** + * @generated from field: agent.v1.RecordScreenFailure failure = 4; + */ + value: RecordScreenFailure; + case: "failure"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.RecordScreenResult. + * Use `create(RecordScreenResultSchema)` to create a new message. + */ +export const RecordScreenResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 327); + +/** + * @generated from message agent.v1.RecordScreenStartSuccess + */ +export type RecordScreenStartSuccess = Message<"agent.v1.RecordScreenStartSuccess"> & { + /** + * True if a prior recording was cancelled, false otherwise + * + * @generated from field: bool was_prior_recording_cancelled = 1; + */ + wasPriorRecordingCancelled: boolean; + + /** + * True if save_as_filename arg was passed to start tool and ignored + * + * @generated from field: bool was_save_as_filename_ignored = 2; + */ + wasSaveAsFilenameIgnored: boolean; +}; + +/** + * Describes the message agent.v1.RecordScreenStartSuccess. + * Use `create(RecordScreenStartSuccessSchema)` to create a new message. + */ +export const RecordScreenStartSuccessSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 328); + +/** + * @generated from message agent.v1.RecordScreenSaveSuccess + */ +export type RecordScreenSaveSuccess = Message<"agent.v1.RecordScreenSaveSuccess"> & { + /** + * Path to the saved recording file + * + * @generated from field: string path = 1; + */ + path: string; + + /** + * Duration of the recording in milliseconds + * + * @generated from field: int64 recording_duration_ms = 2; + */ + recordingDurationMs: bigint; + + /** + * Set if save_as_filename was invalid and default path was used instead + * + * @generated from field: optional int32 requested_file_path_rejected_reason = 3; + */ + requestedFilePathRejectedReason?: number; +}; + +/** + * Describes the message agent.v1.RecordScreenSaveSuccess. + * Use `create(RecordScreenSaveSuccessSchema)` to create a new message. + */ +export const RecordScreenSaveSuccessSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 329); + +/** + * Empty message - recording discarded successfully + * + * @generated from message agent.v1.RecordScreenDiscardSuccess + */ +export type RecordScreenDiscardSuccess = Message<"agent.v1.RecordScreenDiscardSuccess"> & {}; + +/** + * Describes the message agent.v1.RecordScreenDiscardSuccess. + * Use `create(RecordScreenDiscardSuccessSchema)` to create a new message. + */ +export const RecordScreenDiscardSuccessSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 330); + +/** + * @generated from message agent.v1.RecordScreenFailure + */ +export type RecordScreenFailure = Message<"agent.v1.RecordScreenFailure"> & { + /** + * Error message + * + * @generated from field: string error = 1; + */ + error: string; +}; + +/** + * Describes the message agent.v1.RecordScreenFailure. + * Use `create(RecordScreenFailureSchema)` to create a new message. + */ +export const RecordScreenFailureSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 331); + +/** + * @generated from message agent.v1.CursorPackagePrompt + */ +export type CursorPackagePrompt = Message<"agent.v1.CursorPackagePrompt"> & { + /** + * @generated from field: string name = 1; + */ + name: string; + + /** + * @generated from field: string file_path = 2; + */ + filePath: string; +}; + +/** + * Describes the message agent.v1.CursorPackagePrompt. + * Use `create(CursorPackagePromptSchema)` to create a new message. + */ +export const CursorPackagePromptSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 332); + +/** + * @generated from message agent.v1.CursorPackage + */ +export type CursorPackage = Message<"agent.v1.CursorPackage"> & { + /** + * @generated from field: string name = 1; + */ + name: string; + + /** + * @generated from field: string description = 2; + */ + description: string; + + /** + * @generated from field: string folder_path = 3; + */ + folderPath: string; + + /** + * @generated from field: bool enabled = 4; + */ + enabled: boolean; + + /** + * @generated from field: optional string parse_error = 5; + */ + parseError?: string; + + /** + * @generated from field: repeated agent.v1.CursorPackagePrompt prompts = 6; + */ + prompts: CursorPackagePrompt[]; + + /** + * @generated from field: string readme_file_path = 7; + */ + readmeFilePath: string; + + /** + * @generated from field: int32 package_type = 8; + */ + packageType: number; +}; + +/** + * Describes the message agent.v1.CursorPackage. + * Use `create(CursorPackageSchema)` to create a new message. + */ +export const CursorPackageSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 333); + +/** + * TODO: you should be able to override / configure this list in your .vscode settings not exactly sure what that should look like... but i guess you should be able to specify an override URL because we use URLs for identifying repos and maybe you should be able to specify additional buckets too... like in the jane street case: i guess jane street should have some default buckets + * + * @generated from message agent.v1.RepositoryIndexingInfo + */ +export type RepositoryIndexingInfo = Message<"agent.v1.RepositoryIndexingInfo"> & { + /** + * the relative path in the current workspace this is useful for locating the repo and identifying what repo a given file is in this should be unique for different repositories (I think) + * + * @generated from field: string relative_workspace_path = 1; + */ + relativeWorkspacePath: string; + + /** + * a git repo may have multiple remotes at the server we choose the remote (either origin, or the one we have embedded, or something else) invariant: len(remote_urls) == len(remote_names) + * + * @generated from field: repeated string remote_urls = 2; + */ + remoteUrls: string[]; + + /** + * @generated from field: repeated string remote_names = 3; + */ + remoteNames: string[]; + + /** + * @generated from field: string repo_name = 4; + */ + repoName: string; + + /** + * @generated from field: string repo_owner = 5; + */ + repoOwner: string; + + /** + * @generated from field: bool is_tracked = 6; + */ + isTracked: boolean; + + /** + * If this is local + * + * @generated from field: bool is_local = 7; + */ + isLocal: boolean; + + /** + * the orthogonal transform seed if sent from the client! if the client sends up the transform seed then we use that for the orthogonal transform instead of the value stored in the database + * + * @generated from field: optional double orthogonal_transform_seed = 8; + */ + orthogonalTransformSeed?: number; + + /** + * The encrypted workspace uri for the repository. + * + * @generated from field: string workspace_uri = 9; + */ + workspaceUri: string; + + /** + * The encryption key for partial paths + * + * @generated from field: string path_encryption_key = 10; + */ + pathEncryptionKey: string; +}; + +/** + * Describes the message agent.v1.RepositoryIndexingInfo. + * Use `create(RepositoryIndexingInfoSchema)` to create a new message. + */ +export const RepositoryIndexingInfoSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 334); + +/** + * @generated from message agent.v1.RequestContextArgs + */ +export type RequestContextArgs = Message<"agent.v1.RequestContextArgs"> & { + /** + * @generated from field: optional string notes_session_id = 2; + */ + notesSessionId?: string; + + /** + * @generated from field: optional string workspace_id = 3; + */ + workspaceId?: string; +}; + +/** + * Describes the message agent.v1.RequestContextArgs. + * Use `create(RequestContextArgsSchema)` to create a new message. + */ +export const RequestContextArgsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 335); + +/** + * @generated from message agent.v1.RequestContextResult + */ +export type RequestContextResult = Message<"agent.v1.RequestContextResult"> & { + /** + * @generated from oneof agent.v1.RequestContextResult.result + */ + result: + | { + /** + * @generated from field: agent.v1.RequestContextSuccess success = 1; + */ + value: RequestContextSuccess; + case: "success"; + } + | { + /** + * @generated from field: agent.v1.RequestContextError error = 2; + */ + value: RequestContextError; + case: "error"; + } + | { + /** + * @generated from field: agent.v1.RequestContextRejected rejected = 3; + */ + value: RequestContextRejected; + case: "rejected"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.RequestContextResult. + * Use `create(RequestContextResultSchema)` to create a new message. + */ +export const RequestContextResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 336); + +/** + * @generated from message agent.v1.RequestContextSuccess + */ +export type RequestContextSuccess = Message<"agent.v1.RequestContextSuccess"> & { + /** + * @generated from field: agent.v1.RequestContext request_context = 1; + */ + requestContext?: RequestContext; +}; + +/** + * Describes the message agent.v1.RequestContextSuccess. + * Use `create(RequestContextSuccessSchema)` to create a new message. + */ +export const RequestContextSuccessSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 337); + +/** + * @generated from message agent.v1.RequestContextError + */ +export type RequestContextError = Message<"agent.v1.RequestContextError"> & { + /** + * @generated from field: string error = 1; + */ + error: string; +}; + +/** + * Describes the message agent.v1.RequestContextError. + * Use `create(RequestContextErrorSchema)` to create a new message. + */ +export const RequestContextErrorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 338); + +/** + * @generated from message agent.v1.RequestContextRejected + */ +export type RequestContextRejected = Message<"agent.v1.RequestContextRejected"> & { + /** + * @generated from field: string reason = 1; + */ + reason: string; +}; + +/** + * Describes the message agent.v1.RequestContextRejected. + * Use `create(RequestContextRejectedSchema)` to create a new message. + */ +export const RequestContextRejectedSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 339); + +/** + * same as SelectedImage, but with the data field is the full image data + * + * @generated from message agent.v1.ImageProto + */ +export type ImageProto = Message<"agent.v1.ImageProto"> & { + /** + * @generated from field: bytes data = 1; + */ + data: Uint8Array; + + /** + * @generated from field: string uuid = 2; + */ + uuid: string; + + /** + * @generated from field: string path = 3; + */ + path: string; + + /** + * @generated from field: agent.v1.ImageProto_Dimension dimension = 4; + */ + dimension?: ImageProto_Dimension; + + /** + * @generated from field: optional string task_specific_description = 6; + */ + taskSpecificDescription?: string; + + /** + * @generated from field: string mime_type = 7; + */ + mimeType: string; +}; + +/** + * Describes the message agent.v1.ImageProto. + * Use `create(ImageProtoSchema)` to create a new message. + */ +export const ImageProtoSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 340); + +/** + * @generated from message agent.v1.ImageProto_Dimension + */ +export type ImageProto_Dimension = Message<"agent.v1.ImageProto_Dimension"> & { + /** + * @generated from field: int32 width = 1; + */ + width: number; + + /** + * @generated from field: int32 height = 2; + */ + height: number; +}; + +/** + * Describes the message agent.v1.ImageProto_Dimension. + * Use `create(ImageProto_DimensionSchema)` to create a new message. + */ +export const ImageProto_DimensionSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 341); + +/** + * Git repository information for a workspace + * + * @generated from message agent.v1.GitRepoInfo + */ +export type GitRepoInfo = Message<"agent.v1.GitRepoInfo"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string status = 2; + */ + status: string; + + /** + * @generated from field: string branch_name = 3; + */ + branchName: string; + + /** + * @generated from field: optional string remote_url = 4; + */ + remoteUrl?: string; +}; + +/** + * Describes the message agent.v1.GitRepoInfo. + * Use `create(GitRepoInfoSchema)` to create a new message. + */ +export const GitRepoInfoSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 342); + +/** + * Environment details for system prompt/context + * + * @generated from message agent.v1.RequestContextEnv + */ +export type RequestContextEnv = Message<"agent.v1.RequestContextEnv"> & { + /** + * @generated from field: string os_version = 1; + */ + osVersion: string; + + /** + * @generated from field: repeated string workspace_paths = 2; + */ + workspacePaths: string[]; + + /** + * @generated from field: string shell = 3; + */ + shell: string; + + /** + * @generated from field: bool sandbox_enabled = 5; + */ + sandboxEnabled: boolean; + + /** + * @generated from field: string terminals_folder = 7; + */ + terminalsFolder: string; + + /** + * @generated from field: string agent_shared_notes_folder = 8; + */ + agentSharedNotesFolder: string; + + /** + * @generated from field: string agent_conversation_notes_folder = 9; + */ + agentConversationNotesFolder: string; + + /** + * @generated from field: string time_zone = 10; + */ + timeZone: string; + + /** + * Project-specific folder for storing artifacts, computed client-side as ~/.cursor/projects/{slug}/ + * + * @generated from field: string project_folder = 11; + */ + projectFolder: string; + + /** + * Folder where agent conversation transcripts are stored + * + * @generated from field: string agent_transcripts_folder = 12; + */ + agentTranscriptsFolder: string; +}; + +/** + * Describes the message agent.v1.RequestContextEnv. + * Use `create(RequestContextEnvSchema)` to create a new message. + */ +export const RequestContextEnvSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 343); + +/** + * @generated from message agent.v1.DebugModeConfig + */ +export type DebugModeConfig = Message<"agent.v1.DebugModeConfig"> & { + /** + * @generated from field: string log_path = 1; + */ + logPath: string; + + /** + * @generated from field: string server_endpoint = 2; + */ + serverEndpoint: string; +}; + +/** + * Describes the message agent.v1.DebugModeConfig. + * Use `create(DebugModeConfigSchema)` to create a new message. + */ +export const DebugModeConfigSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 344); + +/** + * @generated from message agent.v1.SkillDescriptor + */ +export type SkillDescriptor = Message<"agent.v1.SkillDescriptor"> & { + /** + * @generated from field: string name = 1; + */ + name: string; + + /** + * @generated from field: string description = 2; + */ + description: string; + + /** + * @generated from field: string folder_path = 3; + */ + folderPath: string; + + /** + * @generated from field: bool enabled = 4; + */ + enabled: boolean; + + /** + * @generated from field: optional string parse_error = 5; + */ + parseError?: string; + + /** + * @generated from field: string readme_file_path = 6; + */ + readmeFilePath: string; + + /** + * @generated from field: int32 package_type = 7; + */ + packageType: number; +}; + +/** + * Describes the message agent.v1.SkillDescriptor. + * Use `create(SkillDescriptorSchema)` to create a new message. + */ +export const SkillDescriptorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 345); + +/** + * @generated from message agent.v1.SkillOptions + */ +export type SkillOptions = Message<"agent.v1.SkillOptions"> & { + /** + * @generated from field: repeated agent.v1.SkillDescriptor skill_descriptors = 1; + */ + skillDescriptors: SkillDescriptor[]; +}; + +/** + * Describes the message agent.v1.SkillOptions. + * Use `create(SkillOptionsSchema)` to create a new message. + */ +export const SkillOptionsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 346); + +/** + * @generated from message agent.v1.RequestContext + */ +export type RequestContext = Message<"agent.v1.RequestContext"> & { + /** + * All rules, categorized by the embedded type + * + * @generated from field: repeated agent.v1.CursorRule rules = 2; + */ + rules: CursorRule[]; + + /** + * @generated from field: agent.v1.RequestContextEnv env = 4; + */ + env?: RequestContextEnv; + + /** + * @generated from field: repeated agent.v1.RepositoryIndexingInfo repository_info = 6; + */ + repositoryInfo: RepositoryIndexingInfo[]; + + /** + * @generated from field: repeated agent.v1.McpToolDefinition tools = 7; + */ + tools: McpToolDefinition[]; + + /** + * @generated from field: optional string conversation_notes_listing = 8; + */ + conversationNotesListing?: string; + + /** + * @generated from field: optional string shared_notes_listing = 9; + */ + sharedNotesListing?: string; + + /** + * @generated from field: repeated agent.v1.GitRepoInfo git_repos = 11; + */ + gitRepos: GitRepoInfo[]; + + /** + * @generated from field: repeated agent.v1.LsDirectoryTreeNode project_layouts = 13; + */ + projectLayouts: LsDirectoryTreeNode[]; + + /** + * @generated from field: repeated agent.v1.McpInstructions mcp_instructions = 14; + */ + mcpInstructions: McpInstructions[]; + + /** + * @generated from field: optional agent.v1.DebugModeConfig debug_mode_config = 15; + */ + debugModeConfig?: DebugModeConfig; + + /** + * @generated from field: optional string cloud_rule = 16; + */ + cloudRule?: string; + + /** + * @generated from field: optional bool web_search_enabled = 17; + */ + webSearchEnabled?: boolean; + + /** + * @generated from field: optional agent.v1.SkillOptions skill_options = 18; + */ + skillOptions?: SkillOptions; + + /** + * @generated from field: optional bool repository_info_should_query_prod = 19; + */ + repositoryInfoShouldQueryProd?: boolean; + + /** + * @generated from field: map file_contents = 20; + */ + fileContents: { [key: string]: string }; + + /** + * Content of the user-intent/index.md file summarizing past conversations + * + * @generated from field: optional string user_intent_summary = 21; + */ + userIntentSummary?: string; + + /** + * Local custom subagent definitions loaded from workspace configuration + * + * @generated from field: repeated agent.v1.CustomSubagent custom_subagents = 22; + */ + customSubagents: CustomSubagent[]; + + /** + * MCP file system options for agent MCP tool descriptor access + * + * @generated from field: optional agent.v1.McpFileSystemOptions mcp_file_system_options = 23; + */ + mcpFileSystemOptions?: McpFileSystemOptions; +}; + +/** + * Describes the message agent.v1.RequestContext. + * Use `create(RequestContextSchema)` to create a new message. + */ +export const RequestContextSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 347); + +/** + * @generated from message agent.v1.SandboxPolicy + */ +export type SandboxPolicy = Message<"agent.v1.SandboxPolicy"> & { + /** + * @generated from field: int32 type = 1; + */ + type: number; + + /** + * @generated from field: optional bool network_access = 2; + */ + networkAccess?: boolean; + + /** + * @generated from field: repeated string additional_readwrite_paths = 3; + */ + additionalReadwritePaths: string[]; + + /** + * @generated from field: repeated string additional_readonly_paths = 4; + */ + additionalReadonlyPaths: string[]; + + /** + * @generated from field: optional string debug_output_dir = 5; + */ + debugOutputDir?: string; + + /** + * @generated from field: optional bool block_git_writes = 6; + */ + blockGitWrites?: boolean; + + /** + * If true, excludes default tmp paths (/tmp/, /private/tmp/, /var/folders/) from the sandbox writable paths. Useful for testing readonly behavior. + * + * @generated from field: optional bool disable_tmp_write = 7; + */ + disableTmpWrite?: boolean; +}; + +/** + * Describes the message agent.v1.SandboxPolicy. + * Use `create(SandboxPolicySchema)` to create a new message. + */ +export const SandboxPolicySchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 348); + +/** + * @generated from message agent.v1.SelectedImage + */ +export type SelectedImage = Message<"agent.v1.SelectedImage"> & { + /** + * @generated from field: string uuid = 2; + */ + uuid: string; + + /** + * @generated from field: string path = 3; + */ + path: string; + + /** + * @generated from field: agent.v1.SelectedImage_Dimension dimension = 4; + */ + dimension?: SelectedImage_Dimension; + + /** + * @generated from field: string mime_type = 7; + */ + mimeType: string; + + /** + * @generated from oneof agent.v1.SelectedImage.data_or_blob_id + */ + dataOrBlobId: + | { + /** + * @generated from field: bytes blob_id = 1; + */ + value: Uint8Array; + case: "blobId"; + } + | { + /** + * @generated from field: bytes data = 8; + */ + value: Uint8Array; + case: "data"; + } + | { + /** + * @generated from field: agent.v1.SelectedImage_BlobIdWithData blob_id_with_data = 9; + */ + value: SelectedImage_BlobIdWithData; + case: "blobIdWithData"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.SelectedImage. + * Use `create(SelectedImageSchema)` to create a new message. + */ +export const SelectedImageSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 349); + +/** + * Contains both blob_id and data together, for when the client has both and wants to populate the server-side cache without re-uploading + * + * @generated from message agent.v1.SelectedImage_BlobIdWithData + */ +export type SelectedImage_BlobIdWithData = Message<"agent.v1.SelectedImage_BlobIdWithData"> & { + /** + * @generated from field: bytes blob_id = 1; + */ + blobId: Uint8Array; + + /** + * @generated from field: bytes data = 2; + */ + data: Uint8Array; +}; + +/** + * Describes the message agent.v1.SelectedImage_BlobIdWithData. + * Use `create(SelectedImage_BlobIdWithDataSchema)` to create a new message. + */ +export const SelectedImage_BlobIdWithDataSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 350); + +/** + * @generated from message agent.v1.SelectedImage_Dimension + */ +export type SelectedImage_Dimension = Message<"agent.v1.SelectedImage_Dimension"> & { + /** + * @generated from field: int32 width = 1; + */ + width: number; + + /** + * @generated from field: int32 height = 2; + */ + height: number; +}; + +/** + * Describes the message agent.v1.SelectedImage_Dimension. + * Use `create(SelectedImage_DimensionSchema)` to create a new message. + */ +export const SelectedImage_DimensionSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 351); + +/** + * Extra context entry that can be stored inline or as a blob reference + * + * @generated from message agent.v1.ExtraContextEntry + */ +export type ExtraContextEntry = Message<"agent.v1.ExtraContextEntry"> & { + /** + * @generated from oneof agent.v1.ExtraContextEntry.data_or_blob_id + */ + dataOrBlobId: + | { + /** + * @generated from field: string data = 1; + */ + value: string; + case: "data"; + } + | { + /** + * @generated from field: bytes blob_id = 2; + */ + value: Uint8Array; + case: "blobId"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.ExtraContextEntry. + * Use `create(ExtraContextEntrySchema)` to create a new message. + */ +export const ExtraContextEntrySchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 352); + +/** + * A selected file from the UI + * + * @generated from message agent.v1.SelectedFile + */ +export type SelectedFile = Message<"agent.v1.SelectedFile"> & { + /** + * @generated from field: string content = 1; + */ + content: string; + + /** + * This is the full path + * + * @generated from field: string path = 2; + */ + path: string; + + /** + * @generated from field: optional string relative_path = 3; + */ + relativePath?: string; +}; + +/** + * Describes the message agent.v1.SelectedFile. + * Use `create(SelectedFileSchema)` to create a new message. + */ +export const SelectedFileSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 353); + +/** + * A selected code selection from the UI + * + * @generated from message agent.v1.SelectedCodeSelection + */ +export type SelectedCodeSelection = Message<"agent.v1.SelectedCodeSelection"> & { + /** + * @generated from field: string content = 1; + */ + content: string; + + /** + * This is the full path + * + * @generated from field: string path = 2; + */ + path: string; + + /** + * @generated from field: optional string relative_path = 3; + */ + relativePath?: string; + + /** + * @generated from field: agent.v1.Range range = 4; + */ + range?: Range; +}; + +/** + * Describes the message agent.v1.SelectedCodeSelection. + * Use `create(SelectedCodeSelectionSchema)` to create a new message. + */ +export const SelectedCodeSelectionSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 354); + +/** + * A selected terminal from the UI + * + * @generated from message agent.v1.SelectedTerminal + */ +export type SelectedTerminal = Message<"agent.v1.SelectedTerminal"> & { + /** + * @generated from field: string content = 1; + */ + content: string; + + /** + * @generated from field: optional string title = 2; + */ + title?: string; + + /** + * @generated from field: optional string path = 3; + */ + path?: string; +}; + +/** + * Describes the message agent.v1.SelectedTerminal. + * Use `create(SelectedTerminalSchema)` to create a new message. + */ +export const SelectedTerminalSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 355); + +/** + * A selected terminal selection from the UI + * + * @generated from message agent.v1.SelectedTerminalSelection + */ +export type SelectedTerminalSelection = Message<"agent.v1.SelectedTerminalSelection"> & { + /** + * @generated from field: string content = 1; + */ + content: string; + + /** + * @generated from field: optional string title = 2; + */ + title?: string; + + /** + * @generated from field: optional string path = 3; + */ + path?: string; + + /** + * @generated from field: agent.v1.Range range = 4; + */ + range?: Range; +}; + +/** + * Describes the message agent.v1.SelectedTerminalSelection. + * Use `create(SelectedTerminalSelectionSchema)` to create a new message. + */ +export const SelectedTerminalSelectionSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 356); + +/** + * A selected folder from the UI + * + * @generated from message agent.v1.SelectedFolder + */ +export type SelectedFolder = Message<"agent.v1.SelectedFolder"> & { + /** + * This is the full path + * + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: optional string relative_path = 2; + */ + relativePath?: string; + + /** + * @generated from field: agent.v1.LsDirectoryTreeNode directory_tree = 3; + */ + directoryTree?: LsDirectoryTreeNode; +}; + +/** + * Describes the message agent.v1.SelectedFolder. + * Use `create(SelectedFolderSchema)` to create a new message. + */ +export const SelectedFolderSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 357); + +/** + * An external link manually attached by the user + * + * @generated from message agent.v1.SelectedExternalLink + */ +export type SelectedExternalLink = Message<"agent.v1.SelectedExternalLink"> & { + /** + * @generated from field: string url = 1; + */ + url: string; + + /** + * @generated from field: string uuid = 2; + */ + uuid: string; + + /** + * For local PDF files Base64-encoded PDF content + * + * @generated from field: optional string pdf_content = 3; + */ + pdfContent?: string; + + /** + * @generated from field: optional bool is_pdf = 4; + */ + isPdf?: boolean; + + /** + * @generated from field: optional string filename = 5; + */ + filename?: string; +}; + +/** + * Describes the message agent.v1.SelectedExternalLink. + * Use `create(SelectedExternalLinkSchema)` to create a new message. + */ +export const SelectedExternalLinkSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 358); + +/** + * A cursor rule manually attached by the user + * + * @generated from message agent.v1.SelectedCursorRule + */ +export type SelectedCursorRule = Message<"agent.v1.SelectedCursorRule"> & { + /** + * @generated from field: agent.v1.CursorRule rule = 1; + */ + rule?: CursorRule; +}; + +/** + * Describes the message agent.v1.SelectedCursorRule. + * Use `create(SelectedCursorRuleSchema)` to create a new message. + */ +export const SelectedCursorRuleSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 359); + +/** + * Git diff (uncommitted changes in working tree) + * + * @generated from message agent.v1.SelectedGitDiff + */ +export type SelectedGitDiff = Message<"agent.v1.SelectedGitDiff"> & { + /** + * Raw git diff output + * + * @generated from field: string content = 1; + */ + content: string; +}; + +/** + * Describes the message agent.v1.SelectedGitDiff. + * Use `create(SelectedGitDiffSchema)` to create a new message. + */ +export const SelectedGitDiffSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 360); + +/** + * Git diff from branch to main + * + * @generated from message agent.v1.SelectedGitDiffFromBranchToMain + */ +export type SelectedGitDiffFromBranchToMain = Message<"agent.v1.SelectedGitDiffFromBranchToMain"> & { + /** + * Raw git diff output + * + * @generated from field: string content = 1; + */ + content: string; +}; + +/** + * Describes the message agent.v1.SelectedGitDiffFromBranchToMain. + * Use `create(SelectedGitDiffFromBranchToMainSchema)` to create a new message. + */ +export const SelectedGitDiffFromBranchToMainSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 361); + +/** + * A git commit manually attached by the user + * + * @generated from message agent.v1.SelectedGitCommit + */ +export type SelectedGitCommit = Message<"agent.v1.SelectedGitCommit"> & { + /** + * @generated from field: string sha = 1; + */ + sha: string; + + /** + * @generated from field: string message = 2; + */ + message: string; + + /** + * @generated from field: optional string description = 3; + */ + description?: string; + + /** + * Raw git diff output for this commit + * + * @generated from field: string diff = 4; + */ + diff: string; +}; + +/** + * Describes the message agent.v1.SelectedGitCommit. + * Use `create(SelectedGitCommitSchema)` to create a new message. + */ +export const SelectedGitCommitSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 362); + +/** + * A pull request manually attached by the user via @mention Uses the same folder structure as ViewedPullRequest for consistency + * + * @generated from message agent.v1.SelectedPullRequest + */ +export type SelectedPullRequest = Message<"agent.v1.SelectedPullRequest"> & { + /** + * @generated from field: int32 number = 1; + */ + number: number; + + /** + * @generated from field: string url = 2; + */ + url: string; + + /** + * @generated from field: optional string title = 3; + */ + title?: string; + + /** + * Path to the folder containing PR details (diffs, metadata, etc.) + * + * @generated from field: string folder_path = 4; + */ + folderPath: string; + + /** + * Summary JSON containing file list and diff sizes (contents of summary.json) + * + * @generated from field: optional string summary_json = 5; + */ + summaryJson?: string; + + /** + * PR description/body + * + * @generated from field: optional string description = 6; + */ + description?: string; + + /** + * If set, other fields are empty and data should be fetched from the blob + * + * @generated from field: optional bytes blob_id = 7; + */ + blobId?: Uint8Array; +}; + +/** + * Describes the message agent.v1.SelectedPullRequest. + * Use `create(SelectedPullRequestSchema)` to create a new message. + */ +export const SelectedPullRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 363); + +/** + * A selection from a pull request diff (for files that may not exist on disk) + * + * @generated from message agent.v1.SelectedGitPRDiffSelection + */ +export type SelectedGitPRDiffSelection = Message<"agent.v1.SelectedGitPRDiffSelection"> & { + /** + * Full URL to the pull request + * + * @generated from field: string pr_url = 1; + */ + prUrl: string; + + /** + * Path to the file within the PR + * + * @generated from field: string file_path = 2; + */ + filePath: string; + + /** + * Start line in the diff + * + * @generated from field: int32 start_line = 3; + */ + startLine: number; + + /** + * End line in the diff + * + * @generated from field: int32 end_line = 4; + */ + endLine: number; + + /** + * The diff content for this file (or selection) + * + * @generated from field: optional string diff_content = 5; + */ + diffContent?: string; + + /** + * If set, other fields are empty and data should be fetched from the blob + * + * @generated from field: optional bytes blob_id = 6; + */ + blobId?: Uint8Array; +}; + +/** + * Describes the message agent.v1.SelectedGitPRDiffSelection. + * Use `create(SelectedGitPRDiffSelectionSchema)` to create a new message. + */ +export const SelectedGitPRDiffSelectionSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 364); + +/** + * A cursor command manually attached by the user + * + * @generated from message agent.v1.SelectedCursorCommand + */ +export type SelectedCursorCommand = Message<"agent.v1.SelectedCursorCommand"> & { + /** + * @generated from field: string name = 1; + */ + name: string; + + /** + * @generated from field: string content = 2; + */ + content: string; +}; + +/** + * Describes the message agent.v1.SelectedCursorCommand. + * Use `create(SelectedCursorCommandSchema)` to create a new message. + */ +export const SelectedCursorCommandSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 365); + +/** + * A documentation manually attached by the user + * + * @generated from message agent.v1.SelectedDocumentation + */ +export type SelectedDocumentation = Message<"agent.v1.SelectedDocumentation"> & { + /** + * @generated from field: string doc_id = 1; + */ + docId: string; + + /** + * @generated from field: string name = 2; + */ + name: string; +}; + +/** + * Describes the message agent.v1.SelectedDocumentation. + * Use `create(SelectedDocumentationSchema)` to create a new message. + */ +export const SelectedDocumentationSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 366); + +/** + * A past chat manually attached by the user (transcript file) + * + * @generated from message agent.v1.SelectedPastChat + */ +export type SelectedPastChat = Message<"agent.v1.SelectedPastChat"> & { + /** + * @generated from field: string agent_id = 1; + */ + agentId: string; + + /** + * @generated from field: string name = 2; + */ + name: string; +}; + +/** + * Describes the message agent.v1.SelectedPastChat. + * Use `create(SelectedPastChatSchema)` to create a new message. + */ +export const SelectedPastChatSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 367); + +/** + * A call frame from a stack trace + * + * @generated from message agent.v1.CallFrame + */ +export type CallFrame = Message<"agent.v1.CallFrame"> & { + /** + * @generated from field: optional string function_name = 1; + */ + functionName?: string; + + /** + * @generated from field: optional string url = 2; + */ + url?: string; + + /** + * @generated from field: optional int32 line_number = 3; + */ + lineNumber?: number; + + /** + * @generated from field: optional int32 column_number = 4; + */ + columnNumber?: number; +}; + +/** + * Describes the message agent.v1.CallFrame. + * Use `create(CallFrameSchema)` to create a new message. + */ +export const CallFrameSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 368); + +/** + * A stack trace + * + * @generated from message agent.v1.StackTrace + */ +export type StackTrace = Message<"agent.v1.StackTrace"> & { + /** + * @generated from field: repeated agent.v1.CallFrame call_frames = 1; + */ + callFrames: CallFrame[]; + + /** + * @generated from field: optional string raw_stack_trace = 2; + */ + rawStackTrace?: string; +}; + +/** + * Describes the message agent.v1.StackTrace. + * Use `create(StackTraceSchema)` to create a new message. + */ +export const StackTraceSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 369); + +/** + * A console log entry from the runtime + * + * @generated from message agent.v1.SelectedConsoleLog + */ +export type SelectedConsoleLog = Message<"agent.v1.SelectedConsoleLog"> & { + /** + * @generated from field: string message = 1; + */ + message: string; + + /** + * * Unix timestamp in milliseconds when this log entry was created + * + * @generated from field: double timestamp = 2; + */ + timestamp: number; + + /** + * @generated from field: string level = 3; + */ + level: string; + + /** + * @generated from field: string client_name = 4; + */ + clientName: string; + + /** + * @generated from field: string session_id = 5; + */ + sessionId: string; + + /** + * @generated from field: optional agent.v1.StackTrace stack_trace = 6; + */ + stackTrace?: StackTrace; + + /** + * @generated from field: optional string object_data_json = 7; + */ + objectDataJson?: string; +}; + +/** + * Describes the message agent.v1.SelectedConsoleLog. + * Use `create(SelectedConsoleLogSchema)` to create a new message. + */ +export const SelectedConsoleLogSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 370); + +/** + * A UI element picked by the user from the runtime + * + * @generated from message agent.v1.SelectedUIElement + */ +export type SelectedUIElement = Message<"agent.v1.SelectedUIElement"> & { + /** + * @generated from field: string element = 1; + */ + element: string; + + /** + * @generated from field: string xpath = 2; + */ + xpath: string; + + /** + * @generated from field: string text_content = 3; + */ + textContent: string; + + /** + * @generated from field: string extra = 4; + */ + extra: string; + + /** + * @generated from field: optional string component = 5; + */ + component?: string; + + /** + * @generated from field: optional string component_props_json = 6; + */ + componentPropsJson?: string; +}; + +/** + * Describes the message agent.v1.SelectedUIElement. + * Use `create(SelectedUIElementSchema)` to create a new message. + */ +export const SelectedUIElementSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 371); + +/** + * A subagent selected by the user from the slash menu + * + * @generated from message agent.v1.SelectedSubagent + */ +export type SelectedSubagent = Message<"agent.v1.SelectedSubagent"> & { + /** + * @generated from field: string name = 1; + */ + name: string; +}; + +/** + * Describes the message agent.v1.SelectedSubagent. + * Use `create(SelectedSubagentSchema)` to create a new message. + */ +export const SelectedSubagentSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 372); + +/** + * Container for selected context from the UI + * + * @generated from message agent.v1.SelectedContext + */ +export type SelectedContext = Message<"agent.v1.SelectedContext"> & { + /** + * @generated from field: repeated agent.v1.SelectedImage selected_images = 1; + */ + selectedImages: SelectedImage[]; + + /** + * @generated from field: optional agent.v1.InvocationContext invocation_context = 2; + */ + invocationContext?: InvocationContext; + + /** + * Temporary hack for IDE-based context (@filename, @Diff, etc.) in background agents only. TODO: remove once proper IDE context format is implemented. + * + * @generated from field: repeated string extra_context = 3; + */ + extraContext: string[]; + + /** + * @generated from field: repeated agent.v1.ExtraContextEntry extra_context_entries = 16; + */ + extraContextEntries: ExtraContextEntry[]; + + /** + * New context types + * + * @generated from field: repeated agent.v1.SelectedFile files = 4; + */ + files: SelectedFile[]; + + /** + * @generated from field: repeated agent.v1.SelectedCodeSelection code_selections = 5; + */ + codeSelections: SelectedCodeSelection[]; + + /** + * @generated from field: repeated agent.v1.SelectedTerminal terminals = 6; + */ + terminals: SelectedTerminal[]; + + /** + * @generated from field: repeated agent.v1.SelectedTerminalSelection terminal_selections = 7; + */ + terminalSelections: SelectedTerminalSelection[]; + + /** + * @generated from field: repeated agent.v1.SelectedFolder folders = 8; + */ + folders: SelectedFolder[]; + + /** + * @generated from field: repeated agent.v1.SelectedExternalLink external_links = 9; + */ + externalLinks: SelectedExternalLink[]; + + /** + * @generated from field: repeated agent.v1.SelectedCursorRule cursor_rules = 10; + */ + cursorRules: SelectedCursorRule[]; + + /** + * @generated from field: optional agent.v1.SelectedGitDiff git_diff = 18; + */ + gitDiff?: SelectedGitDiff; + + /** + * @generated from field: optional agent.v1.SelectedGitDiffFromBranchToMain git_diff_from_branch_to_main = 11; + */ + gitDiffFromBranchToMain?: SelectedGitDiffFromBranchToMain; + + /** + * @generated from field: repeated agent.v1.SelectedCursorCommand cursor_commands = 12; + */ + cursorCommands: SelectedCursorCommand[]; + + /** + * @generated from field: repeated agent.v1.SelectedDocumentation documentations = 13; + */ + documentations: SelectedDocumentation[]; + + /** + * @generated from field: repeated agent.v1.SelectedUIElement ui_elements = 14; + */ + uiElements: SelectedUIElement[]; + + /** + * @generated from field: repeated agent.v1.SelectedConsoleLog console_logs = 15; + */ + consoleLogs: SelectedConsoleLog[]; + + /** + * @generated from field: repeated agent.v1.SelectedGitCommit git_commits = 17; + */ + gitCommits: SelectedGitCommit[]; + + /** + * @generated from field: repeated agent.v1.SelectedPastChat past_chats = 19; + */ + pastChats: SelectedPastChat[]; + + /** + * @generated from field: repeated agent.v1.SelectedGitPRDiffSelection git_pr_diff_selections = 20; + */ + gitPrDiffSelections: SelectedGitPRDiffSelection[]; + + /** + * @generated from field: repeated agent.v1.SelectedPullRequest selected_pull_requests = 21; + */ + selectedPullRequests: SelectedPullRequest[]; + + /** + * @generated from field: repeated agent.v1.SelectedSubagent selected_subagents = 22; + */ + selectedSubagents: SelectedSubagent[]; +}; + +/** + * Describes the message agent.v1.SelectedContext. + * Use `create(SelectedContextSchema)` to create a new message. + */ +export const SelectedContextSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 373); + +/** + * InvocationContext represents the context from the external app/integration that triggered this agent request. + * + * @generated from message agent.v1.InvocationContext + */ +export type InvocationContext = Message<"agent.v1.InvocationContext"> & { + /** + * @generated from oneof agent.v1.InvocationContext.data + */ + data: + | { + /** + * @generated from field: agent.v1.InvocationContext_SlackThread slack_thread = 1; + */ + value: InvocationContext_SlackThread; + case: "slackThread"; + } + | { + /** + * @generated from field: agent.v1.InvocationContext_GithubPR github_pr = 2; + */ + value: InvocationContext_GithubPR; + case: "githubPr"; + } + | { + /** + * @generated from field: agent.v1.InvocationContext_IdeState ide_state = 3; + */ + value: InvocationContext_IdeState; + case: "ideState"; + } + | { + /** + * @generated from field: bytes blob_id = 10; + */ + value: Uint8Array; + case: "blobId"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.InvocationContext. + * Use `create(InvocationContextSchema)` to create a new message. + */ +export const InvocationContextSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 374); + +/** + * @generated from message agent.v1.InvocationContext_SlackThread + */ +export type InvocationContext_SlackThread = Message<"agent.v1.InvocationContext_SlackThread"> & { + /** + * @generated from field: string thread = 1; + */ + thread: string; + + /** + * @generated from field: optional string channel_name = 2; + */ + channelName?: string; + + /** + * @generated from field: optional string channel_purpose = 3; + */ + channelPurpose?: string; + + /** + * @generated from field: optional string channel_topic = 4; + */ + channelTopic?: string; +}; + +/** + * Describes the message agent.v1.InvocationContext_SlackThread. + * Use `create(InvocationContext_SlackThreadSchema)` to create a new message. + */ +export const InvocationContext_SlackThreadSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 375); + +/** + * @generated from message agent.v1.InvocationContext_GithubPR + */ +export type InvocationContext_GithubPR = Message<"agent.v1.InvocationContext_GithubPR"> & { + /** + * @generated from field: string title = 1; + */ + title: string; + + /** + * @generated from field: string description = 2; + */ + description: string; + + /** + * @generated from field: string comments = 3; + */ + comments: string; + + /** + * @generated from field: optional string ci_failures = 4; + */ + ciFailures?: string; +}; + +/** + * Describes the message agent.v1.InvocationContext_GithubPR. + * Use `create(InvocationContext_GithubPRSchema)` to create a new message. + */ +export const InvocationContext_GithubPRSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 376); + +/** + * @generated from message agent.v1.InvocationContext_IdeState + */ +export type InvocationContext_IdeState = Message<"agent.v1.InvocationContext_IdeState"> & { + /** + * @generated from field: repeated agent.v1.InvocationContext_IdeState_File visible_files = 1; + */ + visibleFiles: InvocationContext_IdeState_File[]; + + /** + * @generated from field: repeated agent.v1.InvocationContext_IdeState_File recently_viewed_files = 2; + */ + recentlyViewedFiles: InvocationContext_IdeState_File[]; + + /** + * PRs currently being viewed in the review editor (if any) + * + * @generated from field: repeated agent.v1.InvocationContext_IdeState_ViewedPullRequest currently_viewed_prs = 3; + */ + currentlyViewedPrs: InvocationContext_IdeState_ViewedPullRequest[]; +}; + +/** + * Describes the message agent.v1.InvocationContext_IdeState. + * Use `create(InvocationContext_IdeStateSchema)` to create a new message. + */ +export const InvocationContext_IdeStateSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 377); + +/** + * @generated from message agent.v1.InvocationContext_IdeState_File + */ +export type InvocationContext_IdeState_File = Message<"agent.v1.InvocationContext_IdeState_File"> & { + /** + * This is the full path + * + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: optional string relative_path = 2; + */ + relativePath?: string; + + /** + * Present if file is currently focused + * + * @generated from field: optional agent.v1.InvocationContext_IdeState_File_CursorPosition cursor_position = 3; + */ + cursorPosition?: InvocationContext_IdeState_File_CursorPosition; + + /** + * @generated from field: int32 total_lines = 4; + */ + totalLines: number; + + /** + * Present for terminal files + * + * @generated from field: optional string active_command = 5; + */ + activeCommand?: string; +}; + +/** + * Describes the message agent.v1.InvocationContext_IdeState_File. + * Use `create(InvocationContext_IdeState_FileSchema)` to create a new message. + */ +export const InvocationContext_IdeState_FileSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 378); + +/** + * @generated from message agent.v1.InvocationContext_IdeState_File_CursorPosition + */ +export type InvocationContext_IdeState_File_CursorPosition = + Message<"agent.v1.InvocationContext_IdeState_File_CursorPosition"> & { + /** + * @generated from field: int32 line = 1; + */ + line: number; + + /** + * @generated from field: string text = 2; + */ + text: string; + }; + +/** + * Describes the message agent.v1.InvocationContext_IdeState_File_CursorPosition. + * Use `create(InvocationContext_IdeState_File_CursorPositionSchema)` to create a new message. + */ +export const InvocationContext_IdeState_File_CursorPositionSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 379); + +/** + * Information about a PR currently being viewed in a review editor + * + * @generated from message agent.v1.InvocationContext_IdeState_ViewedPullRequest + */ +export type InvocationContext_IdeState_ViewedPullRequest = + Message<"agent.v1.InvocationContext_IdeState_ViewedPullRequest"> & { + /** + * @generated from field: int32 number = 1; + */ + number: number; + + /** + * @generated from field: string url = 2; + */ + url: string; + + /** + * @generated from field: optional string title = 3; + */ + title?: string; + + /** + * Path to the folder containing PR details (diffs, metadata, etc.) + * + * @generated from field: optional string folder_path = 4; + */ + folderPath?: string; + + /** + * Summary JSON containing file list and diff sizes (contents of summary.json) + * + * @generated from field: optional string summary_json = 5; + */ + summaryJson?: string; + + /** + * PR description/body + * + * @generated from field: optional string description = 6; + */ + description?: string; + }; + +/** + * Describes the message agent.v1.InvocationContext_IdeState_ViewedPullRequest. + * Use `create(InvocationContext_IdeState_ViewedPullRequestSchema)` to create a new message. + */ +export const InvocationContext_IdeState_ViewedPullRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 380); + +/** + * @generated from message agent.v1.SetupVmEnvironmentArgs + */ +export type SetupVmEnvironmentArgs = Message<"agent.v1.SetupVmEnvironmentArgs"> & { + /** + * Command to install runtime dependencies (e.g., "npm install") + * + * @generated from field: string install_command = 2; + */ + installCommand: string; + + /** + * @generated from field: string start_command = 3; + */ + startCommand: string; +}; + +/** + * Describes the message agent.v1.SetupVmEnvironmentArgs. + * Use `create(SetupVmEnvironmentArgsSchema)` to create a new message. + */ +export const SetupVmEnvironmentArgsSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 381); + +/** + * Result of VM environment setup operations + * + * @generated from message agent.v1.SetupVmEnvironmentResult + */ +export type SetupVmEnvironmentResult = Message<"agent.v1.SetupVmEnvironmentResult"> & { + /** + * @generated from oneof agent.v1.SetupVmEnvironmentResult.result + */ + result: + | { + /** + * @generated from field: agent.v1.SetupVmEnvironmentSuccess success = 1; + */ + value: SetupVmEnvironmentSuccess; + case: "success"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.SetupVmEnvironmentResult. + * Use `create(SetupVmEnvironmentResultSchema)` to create a new message. + */ +export const SetupVmEnvironmentResultSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 382); + +/** + * Successful VM environment setup result + * + * @generated from message agent.v1.SetupVmEnvironmentSuccess + */ +export type SetupVmEnvironmentSuccess = Message<"agent.v1.SetupVmEnvironmentSuccess"> & {}; + +/** + * Describes the message agent.v1.SetupVmEnvironmentSuccess. + * Use `create(SetupVmEnvironmentSuccessSchema)` to create a new message. + */ +export const SetupVmEnvironmentSuccessSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 383); + +/** + * Tool call structure for SetupVmEnvironment + * + * @generated from message agent.v1.SetupVmEnvironmentToolCall + */ +export type SetupVmEnvironmentToolCall = Message<"agent.v1.SetupVmEnvironmentToolCall"> & { + /** + * Arguments for the tool call + * + * @generated from field: agent.v1.SetupVmEnvironmentArgs args = 1; + */ + args?: SetupVmEnvironmentArgs; + + /** + * Result of the tool call (populated after execution) + * + * @generated from field: agent.v1.SetupVmEnvironmentResult result = 2; + */ + result?: SetupVmEnvironmentResult; +}; + +/** + * Describes the message agent.v1.SetupVmEnvironmentToolCall. + * Use `create(SetupVmEnvironmentToolCallSchema)` to create a new message. + */ +export const SetupVmEnvironmentToolCallSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 384); + +/** + * @generated from message agent.v1.ShellCommandParsingResult + */ +export type ShellCommandParsingResult = Message<"agent.v1.ShellCommandParsingResult"> & { + /** + * @generated from field: bool parsing_failed = 1; + */ + parsingFailed: boolean; + + /** + * @generated from field: repeated agent.v1.ShellCommandParsingResult_ExecutableCommand executable_commands = 2; + */ + executableCommands: ShellCommandParsingResult_ExecutableCommand[]; + + /** + * @generated from field: bool has_redirects = 3; + */ + hasRedirects: boolean; + + /** + * @generated from field: bool has_command_substitution = 4; + */ + hasCommandSubstitution: boolean; +}; + +/** + * Describes the message agent.v1.ShellCommandParsingResult. + * Use `create(ShellCommandParsingResultSchema)` to create a new message. + */ +export const ShellCommandParsingResultSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 385); + +/** + * @generated from message agent.v1.ShellCommandParsingResult_ExecutableCommandArg + */ +export type ShellCommandParsingResult_ExecutableCommandArg = + Message<"agent.v1.ShellCommandParsingResult_ExecutableCommandArg"> & { + /** + * @generated from field: string type = 1; + */ + type: string; + + /** + * @generated from field: string value = 2; + */ + value: string; + }; + +/** + * Describes the message agent.v1.ShellCommandParsingResult_ExecutableCommandArg. + * Use `create(ShellCommandParsingResult_ExecutableCommandArgSchema)` to create a new message. + */ +export const ShellCommandParsingResult_ExecutableCommandArgSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 386); + +/** + * @generated from message agent.v1.ShellCommandParsingResult_ExecutableCommand + */ +export type ShellCommandParsingResult_ExecutableCommand = + Message<"agent.v1.ShellCommandParsingResult_ExecutableCommand"> & { + /** + * @generated from field: string name = 1; + */ + name: string; + + /** + * @generated from field: repeated agent.v1.ShellCommandParsingResult_ExecutableCommandArg args = 2; + */ + args: ShellCommandParsingResult_ExecutableCommandArg[]; + + /** + * @generated from field: string full_text = 3; + */ + fullText: string; + }; + +/** + * Describes the message agent.v1.ShellCommandParsingResult_ExecutableCommand. + * Use `create(ShellCommandParsingResult_ExecutableCommandSchema)` to create a new message. + */ +export const ShellCommandParsingResult_ExecutableCommandSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 387); + +/** + * @generated from message agent.v1.ShellArgs + */ +export type ShellArgs = Message<"agent.v1.ShellArgs"> & { + /** + * @generated from field: string command = 1; + */ + command: string; + + /** + * @generated from field: string working_directory = 2; + */ + workingDirectory: string; + + /** + * @generated from field: int32 timeout = 3; + */ + timeout: number; + + /** + * @generated from field: string tool_call_id = 4; + */ + toolCallId: string; + + /** + * @generated from field: repeated string simple_commands = 5; + */ + simpleCommands: string[]; + + /** + * @generated from field: bool has_input_redirect = 6; + */ + hasInputRedirect: boolean; + + /** + * @generated from field: bool has_output_redirect = 7; + */ + hasOutputRedirect: boolean; + + /** + * Deprecated: use parsing_result instead @deprecated simpleCommands = []; Deprecated: use parsing_result instead @deprecated hasInputRedirect = false; Deprecated: use parsing_result instead @deprecated hasOutputRedirect = false; + * + * @generated from field: agent.v1.ShellCommandParsingResult parsing_result = 8; + */ + parsingResult?: ShellCommandParsingResult; + + /** + * @generated from field: optional agent.v1.SandboxPolicy requested_sandbox_policy = 9; + */ + requestedSandboxPolicy?: SandboxPolicy; + + /** + * If output size exceeds this threshold (in bytes), write to file instead of inline. If unset or 0, always use inline output. + * + * @generated from field: optional uint64 file_output_threshold_bytes = 10; + */ + fileOutputThresholdBytes?: bigint; + + /** + * @generated from field: bool is_background = 11; + */ + isBackground: boolean; + + /** + * @generated from field: bool skip_approval = 12; + */ + skipApproval: boolean; + + /** + * @generated from field: int32 timeout_behavior = 13; + */ + timeoutBehavior: number; + + /** + * Hard timeout: kill the command after this many ms, even if running in background + * + * @generated from field: optional int32 hard_timeout = 14; + */ + hardTimeout?: number; +}; + +/** + * Describes the message agent.v1.ShellArgs. + * Use `create(ShellArgsSchema)` to create a new message. + */ +export const ShellArgsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 388); + +/** + * @generated from message agent.v1.ShellResult + */ +export type ShellResult = Message<"agent.v1.ShellResult"> & { + /** + * @generated from field: optional agent.v1.SandboxPolicy sandbox_policy = 101; + */ + sandboxPolicy?: SandboxPolicy; + + /** + * Rendering is affected by this flag, pass forward from args. + * + * @generated from field: optional bool is_background = 102; + */ + isBackground?: boolean; + + /** + * Rendering is affected by this flag, pass forward from args. + * + * @generated from field: optional string terminals_folder = 103; + */ + terminalsFolder?: string; + + /** + * Process ID, used for backgrounded shells. + * + * @generated from field: optional uint32 pid = 104; + */ + pid?: number; + + /** + * @generated from oneof agent.v1.ShellResult.result + */ + result: + | { + /** + * @generated from field: agent.v1.ShellSuccess success = 1; + */ + value: ShellSuccess; + case: "success"; + } + | { + /** + * @generated from field: agent.v1.ShellFailure failure = 2; + */ + value: ShellFailure; + case: "failure"; + } + | { + /** + * @generated from field: agent.v1.ShellTimeout timeout = 3; + */ + value: ShellTimeout; + case: "timeout"; + } + | { + /** + * @generated from field: agent.v1.ShellRejected rejected = 4; + */ + value: ShellRejected; + case: "rejected"; + } + | { + /** + * @generated from field: agent.v1.ShellSpawnError spawn_error = 5; + */ + value: ShellSpawnError; + case: "spawnError"; + } + | { + /** + * @generated from field: agent.v1.ShellPermissionDenied permission_denied = 7; + */ + value: ShellPermissionDenied; + case: "permissionDenied"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.ShellResult. + * Use `create(ShellResultSchema)` to create a new message. + */ +export const ShellResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 389); + +/** + * @generated from message agent.v1.ShellStreamStdout + */ +export type ShellStreamStdout = Message<"agent.v1.ShellStreamStdout"> & { + /** + * @generated from field: string data = 1; + */ + data: string; +}; + +/** + * Describes the message agent.v1.ShellStreamStdout. + * Use `create(ShellStreamStdoutSchema)` to create a new message. + */ +export const ShellStreamStdoutSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 390); + +/** + * @generated from message agent.v1.ShellStreamStderr + */ +export type ShellStreamStderr = Message<"agent.v1.ShellStreamStderr"> & { + /** + * @generated from field: string data = 1; + */ + data: string; +}; + +/** + * Describes the message agent.v1.ShellStreamStderr. + * Use `create(ShellStreamStderrSchema)` to create a new message. + */ +export const ShellStreamStderrSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 391); + +/** + * @generated from message agent.v1.ShellStreamExit + */ +export type ShellStreamExit = Message<"agent.v1.ShellStreamExit"> & { + /** + * @generated from field: uint32 code = 1; + */ + code: number; + + /** + * @generated from field: string cwd = 2; + */ + cwd: string; + + /** + * @generated from field: optional agent.v1.OutputLocation output_location = 3; + */ + outputLocation?: OutputLocation; + + /** + * @generated from field: bool aborted = 4; + */ + aborted: boolean; + + /** + * If aborted is true, this field indicates the reason for the abort + * + * @generated from field: optional int32 abort_reason = 5; + */ + abortReason?: number; +}; + +/** + * Describes the message agent.v1.ShellStreamExit. + * Use `create(ShellStreamExitSchema)` to create a new message. + */ +export const ShellStreamExitSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 392); + +/** + * @generated from message agent.v1.ShellStreamStart + */ +export type ShellStreamStart = Message<"agent.v1.ShellStreamStart"> & { + /** + * @generated from field: optional agent.v1.SandboxPolicy sandbox_policy = 1; + */ + sandboxPolicy?: SandboxPolicy; +}; + +/** + * Describes the message agent.v1.ShellStreamStart. + * Use `create(ShellStreamStartSchema)` to create a new message. + */ +export const ShellStreamStartSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 393); + +/** + * @generated from message agent.v1.ShellStreamBackgrounded + */ +export type ShellStreamBackgrounded = Message<"agent.v1.ShellStreamBackgrounded"> & { + /** + * @generated from field: uint32 shell_id = 1; + */ + shellId: number; + + /** + * @generated from field: string command = 2; + */ + command: string; + + /** + * @generated from field: string working_directory = 3; + */ + workingDirectory: string; + + /** + * @generated from field: optional uint32 pid = 4; + */ + pid?: number; + + /** + * The ms_to_wait value that was used for backgrounding, for display purposes + * + * @generated from field: optional int32 ms_to_wait = 5; + */ + msToWait?: number; +}; + +/** + * Describes the message agent.v1.ShellStreamBackgrounded. + * Use `create(ShellStreamBackgroundedSchema)` to create a new message. + */ +export const ShellStreamBackgroundedSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 394); + +/** + * @generated from message agent.v1.ShellStream + */ +export type ShellStream = Message<"agent.v1.ShellStream"> & { + /** + * @generated from oneof agent.v1.ShellStream.event + */ + event: + | { + /** + * @generated from field: agent.v1.ShellStreamStdout stdout = 1; + */ + value: ShellStreamStdout; + case: "stdout"; + } + | { + /** + * @generated from field: agent.v1.ShellStreamStderr stderr = 2; + */ + value: ShellStreamStderr; + case: "stderr"; + } + | { + /** + * @generated from field: agent.v1.ShellStreamExit exit = 3; + */ + value: ShellStreamExit; + case: "exit"; + } + | { + /** + * @generated from field: agent.v1.ShellStreamStart start = 4; + */ + value: ShellStreamStart; + case: "start"; + } + | { + /** + * @generated from field: agent.v1.ShellRejected rejected = 5; + */ + value: ShellRejected; + case: "rejected"; + } + | { + /** + * @generated from field: agent.v1.ShellPermissionDenied permission_denied = 6; + */ + value: ShellPermissionDenied; + case: "permissionDenied"; + } + | { + /** + * @generated from field: agent.v1.ShellStreamBackgrounded backgrounded = 7; + */ + value: ShellStreamBackgrounded; + case: "backgrounded"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.ShellStream. + * Use `create(ShellStreamSchema)` to create a new message. + */ +export const ShellStreamSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 395); + +/** + * @generated from message agent.v1.OutputLocation + */ +export type OutputLocation = Message<"agent.v1.OutputLocation"> & { + /** + * Absolute path to the output file + * + * @generated from field: string file_path = 1; + */ + filePath: string; + + /** + * Size of the output in bytes + * + * @generated from field: int64 size_bytes = 2; + */ + sizeBytes: bigint; + + /** + * Number of lines in the output + * + * @generated from field: int64 line_count = 3; + */ + lineCount: bigint; +}; + +/** + * Describes the message agent.v1.OutputLocation. + * Use `create(OutputLocationSchema)` to create a new message. + */ +export const OutputLocationSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 396); + +/** + * @generated from message agent.v1.ShellSuccess + */ +export type ShellSuccess = Message<"agent.v1.ShellSuccess"> & { + /** + * @generated from field: string command = 1; + */ + command: string; + + /** + * @generated from field: string working_directory = 2; + */ + workingDirectory: string; + + /** + * @generated from field: int32 exit_code = 3; + */ + exitCode: number; + + /** + * @generated from field: string signal = 4; + */ + signal: string; + + /** + * Inline stdout - populated when write_output_to_file is false, empty when true + * + * @generated from field: string stdout = 5; + */ + stdout: string; + + /** + * Inline stderr - populated when write_output_to_file is false, empty when true + * + * @generated from field: string stderr = 6; + */ + stderr: string; + + /** + * @generated from field: int32 execution_time = 7; + */ + executionTime: number; + + /** + * File-based output - populated when write_output_to_file is true (chronologically merged stdout+stderr) + * + * @generated from field: optional agent.v1.OutputLocation output_location = 8; + */ + outputLocation?: OutputLocation; + + /** + * Used by background shell executor + * + * @generated from field: optional uint32 shell_id = 9; + */ + shellId?: number; + + /** + * @generated from field: optional string interleaved_output = 10; + */ + interleavedOutput?: string; + + /** + * Process ID, used for backgrounded shells + * + * @generated from field: optional uint32 pid = 11; + */ + pid?: number; + + /** + * The ms_to_wait value used for backgrounding (for display in result) + * + * @generated from field: optional int32 ms_to_wait = 12; + */ + msToWait?: number; +}; + +/** + * Describes the message agent.v1.ShellSuccess. + * Use `create(ShellSuccessSchema)` to create a new message. + */ +export const ShellSuccessSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 397); + +/** + * @generated from message agent.v1.ShellFailure + */ +export type ShellFailure = Message<"agent.v1.ShellFailure"> & { + /** + * @generated from field: string command = 1; + */ + command: string; + + /** + * @generated from field: string working_directory = 2; + */ + workingDirectory: string; + + /** + * @generated from field: int32 exit_code = 3; + */ + exitCode: number; + + /** + * @generated from field: string signal = 4; + */ + signal: string; + + /** + * Inline stdout - populated when write_output_to_file is false, empty when true + * + * @generated from field: string stdout = 5; + */ + stdout: string; + + /** + * Inline stderr - populated when write_output_to_file is false, empty when true + * + * @generated from field: string stderr = 6; + */ + stderr: string; + + /** + * @generated from field: int32 execution_time = 7; + */ + executionTime: number; + + /** + * File-based output - populated when write_output_to_file is true (chronologically merged stdout+stderr) + * + * @generated from field: optional agent.v1.OutputLocation output_location = 8; + */ + outputLocation?: OutputLocation; + + /** + * @generated from field: optional string interleaved_output = 9; + */ + interleavedOutput?: string; + + /** + * If the command was aborted, this indicates the reason + * + * @generated from field: optional int32 abort_reason = 10; + */ + abortReason?: number; + + /** + * Whether the command was aborted (by user or timeout) + * + * @generated from field: bool aborted = 11; + */ + aborted: boolean; +}; + +/** + * Describes the message agent.v1.ShellFailure. + * Use `create(ShellFailureSchema)` to create a new message. + */ +export const ShellFailureSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 398); + +/** + * @generated from message agent.v1.ShellTimeout + */ +export type ShellTimeout = Message<"agent.v1.ShellTimeout"> & { + /** + * @generated from field: string command = 1; + */ + command: string; + + /** + * @generated from field: string working_directory = 2; + */ + workingDirectory: string; + + /** + * @generated from field: int32 timeout_ms = 3; + */ + timeoutMs: number; +}; + +/** + * Describes the message agent.v1.ShellTimeout. + * Use `create(ShellTimeoutSchema)` to create a new message. + */ +export const ShellTimeoutSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 399); + +/** + * @generated from message agent.v1.ShellRejected + */ +export type ShellRejected = Message<"agent.v1.ShellRejected"> & { + /** + * @generated from field: string command = 1; + */ + command: string; + + /** + * @generated from field: string working_directory = 2; + */ + workingDirectory: string; + + /** + * @generated from field: string reason = 3; + */ + reason: string; + + /** + * @generated from field: bool is_readonly = 4; + */ + isReadonly: boolean; +}; + +/** + * Describes the message agent.v1.ShellRejected. + * Use `create(ShellRejectedSchema)` to create a new message. + */ +export const ShellRejectedSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 400); + +/** + * @generated from message agent.v1.ShellPermissionDenied + */ +export type ShellPermissionDenied = Message<"agent.v1.ShellPermissionDenied"> & { + /** + * @generated from field: string command = 1; + */ + command: string; + + /** + * @generated from field: string working_directory = 2; + */ + workingDirectory: string; + + /** + * @generated from field: string error = 3; + */ + error: string; + + /** + * @generated from field: bool is_readonly = 4; + */ + isReadonly: boolean; +}; + +/** + * Describes the message agent.v1.ShellPermissionDenied. + * Use `create(ShellPermissionDeniedSchema)` to create a new message. + */ +export const ShellPermissionDeniedSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 401); + +/** + * @generated from message agent.v1.ShellSpawnError + */ +export type ShellSpawnError = Message<"agent.v1.ShellSpawnError"> & { + /** + * @generated from field: string command = 1; + */ + command: string; + + /** + * @generated from field: string working_directory = 2; + */ + workingDirectory: string; + + /** + * @generated from field: string error = 3; + */ + error: string; +}; + +/** + * Describes the message agent.v1.ShellSpawnError. + * Use `create(ShellSpawnErrorSchema)` to create a new message. + */ +export const ShellSpawnErrorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 402); + +/** + * @generated from message agent.v1.ShellPartialResult + */ +export type ShellPartialResult = Message<"agent.v1.ShellPartialResult"> & { + /** + * @generated from field: string stdout_delta = 1; + */ + stdoutDelta: string; + + /** + * @generated from field: string stderr_delta = 2; + */ + stderrDelta: string; +}; + +/** + * Describes the message agent.v1.ShellPartialResult. + * Use `create(ShellPartialResultSchema)` to create a new message. + */ +export const ShellPartialResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 403); + +/** + * @generated from message agent.v1.ShellToolCall + */ +export type ShellToolCall = Message<"agent.v1.ShellToolCall"> & { + /** + * @generated from field: agent.v1.ShellArgs args = 1; + */ + args?: ShellArgs; + + /** + * @generated from field: agent.v1.ShellResult result = 2; + */ + result?: ShellResult; +}; + +/** + * Describes the message agent.v1.ShellToolCall. + * Use `create(ShellToolCallSchema)` to create a new message. + */ +export const ShellToolCallSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 404); + +/** + * @generated from message agent.v1.ShellToolCallStdoutDelta + */ +export type ShellToolCallStdoutDelta = Message<"agent.v1.ShellToolCallStdoutDelta"> & { + /** + * @generated from field: string content = 1; + */ + content: string; +}; + +/** + * Describes the message agent.v1.ShellToolCallStdoutDelta. + * Use `create(ShellToolCallStdoutDeltaSchema)` to create a new message. + */ +export const ShellToolCallStdoutDeltaSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 405); + +/** + * @generated from message agent.v1.ShellToolCallStderrDelta + */ +export type ShellToolCallStderrDelta = Message<"agent.v1.ShellToolCallStderrDelta"> & { + /** + * @generated from field: string content = 1; + */ + content: string; +}; + +/** + * Describes the message agent.v1.ShellToolCallStderrDelta. + * Use `create(ShellToolCallStderrDeltaSchema)` to create a new message. + */ +export const ShellToolCallStderrDeltaSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 406); + +/** + * @generated from message agent.v1.ShellToolCallDelta + */ +export type ShellToolCallDelta = Message<"agent.v1.ShellToolCallDelta"> & { + /** + * @generated from oneof agent.v1.ShellToolCallDelta.delta + */ + delta: + | { + /** + * @generated from field: agent.v1.ShellToolCallStdoutDelta stdout = 1; + */ + value: ShellToolCallStdoutDelta; + case: "stdout"; + } + | { + /** + * @generated from field: agent.v1.ShellToolCallStderrDelta stderr = 2; + */ + value: ShellToolCallStderrDelta; + case: "stderr"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.ShellToolCallDelta. + * Use `create(ShellToolCallDeltaSchema)` to create a new message. + */ +export const ShellToolCallDeltaSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 407); + +/** + * @generated from message agent.v1.SubagentType + */ +export type SubagentType = Message<"agent.v1.SubagentType"> & { + /** + * @generated from oneof agent.v1.SubagentType.type + */ + type: + | { + /** + * @generated from field: agent.v1.SubagentTypeUnspecified unspecified = 1; + */ + value: SubagentTypeUnspecified; + case: "unspecified"; + } + | { + /** + * @generated from field: agent.v1.SubagentTypeComputerUse computer_use = 2; + */ + value: SubagentTypeComputerUse; + case: "computerUse"; + } + | { + /** + * @generated from field: agent.v1.SubagentTypeCustom custom = 3; + */ + value: SubagentTypeCustom; + case: "custom"; + } + | { + /** + * @generated from field: agent.v1.SubagentTypeExplore explore = 4; + */ + value: SubagentTypeExplore; + case: "explore"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.SubagentType. + * Use `create(SubagentTypeSchema)` to create a new message. + */ +export const SubagentTypeSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 408); + +/** + * Empty message for unspecified subagent type + * + * @generated from message agent.v1.SubagentTypeUnspecified + */ +export type SubagentTypeUnspecified = Message<"agent.v1.SubagentTypeUnspecified"> & {}; + +/** + * Describes the message agent.v1.SubagentTypeUnspecified. + * Use `create(SubagentTypeUnspecifiedSchema)` to create a new message. + */ +export const SubagentTypeUnspecifiedSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 409); + +/** + * Empty message for computer use subagent type + * + * @generated from message agent.v1.SubagentTypeComputerUse + */ +export type SubagentTypeComputerUse = Message<"agent.v1.SubagentTypeComputerUse"> & {}; + +/** + * Describes the message agent.v1.SubagentTypeComputerUse. + * Use `create(SubagentTypeComputerUseSchema)` to create a new message. + */ +export const SubagentTypeComputerUseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 410); + +/** + * Empty message for explore subagent type (read-only codebase exploration) + * + * @generated from message agent.v1.SubagentTypeExplore + */ +export type SubagentTypeExplore = Message<"agent.v1.SubagentTypeExplore"> & {}; + +/** + * Describes the message agent.v1.SubagentTypeExplore. + * Use `create(SubagentTypeExploreSchema)` to create a new message. + */ +export const SubagentTypeExploreSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 411); + +/** + * Custom subagent type with a name field + * + * @generated from message agent.v1.SubagentTypeCustom + */ +export type SubagentTypeCustom = Message<"agent.v1.SubagentTypeCustom"> & { + /** + * unique identifier of the custom subagent + * + * @generated from field: string name = 1; + */ + name: string; +}; + +/** + * Describes the message agent.v1.SubagentTypeCustom. + * Use `create(SubagentTypeCustomSchema)` to create a new message. + */ +export const SubagentTypeCustomSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 412); + +/** + * Custom subagent definition loaded from local workspace configuration. + * + * @generated from message agent.v1.CustomSubagent + */ +export type CustomSubagent = Message<"agent.v1.CustomSubagent"> & { + /** + * absolute path to the markdown definition file + * + * @generated from field: string full_path = 1; + */ + fullPath: string; + + /** + * unique identifier of the subagent + * + * @generated from field: string name = 2; + */ + name: string; + + /** + * short summary of the agent's specialization + * + * @generated from field: string description = 3; + */ + description: string; + + /** + * list of tool names the subagent can access + * + * @generated from field: repeated string tools = 4; + */ + tools: string[]; + + /** + * preferred model (or "inherit" to use parent's model) + * + * @generated from field: string model = 5; + */ + model: string; + + /** + * full prompt contents from the markdown file + * + * @generated from field: string prompt = 6; + */ + prompt: string; + + /** + * default permission mode for subagent execution + * + * @generated from field: int32 permission_mode = 7; + */ + permissionMode: number; +}; + +/** + * Describes the message agent.v1.CustomSubagent. + * Use `create(CustomSubagentSchema)` to create a new message. + */ +export const CustomSubagentSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 413); + +/** + * @generated from message agent.v1.SwitchModeArgs + */ +export type SwitchModeArgs = Message<"agent.v1.SwitchModeArgs"> & { + /** + * The unified mode id to switch to (agent/chat/plan/spec/debug/triage) + * + * @generated from field: string target_mode_id = 1; + */ + targetModeId: string; + + /** + * Optional explanation for why the mode switch is requested + * + * @generated from field: optional string explanation = 2; + */ + explanation?: string; + + /** + * @generated from field: string tool_call_id = 3; + */ + toolCallId: string; +}; + +/** + * Describes the message agent.v1.SwitchModeArgs. + * Use `create(SwitchModeArgsSchema)` to create a new message. + */ +export const SwitchModeArgsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 414); + +/** + * @generated from message agent.v1.SwitchModeResult + */ +export type SwitchModeResult = Message<"agent.v1.SwitchModeResult"> & { + /** + * @generated from oneof agent.v1.SwitchModeResult.result + */ + result: + | { + /** + * @generated from field: agent.v1.SwitchModeSuccess success = 1; + */ + value: SwitchModeSuccess; + case: "success"; + } + | { + /** + * @generated from field: agent.v1.SwitchModeError error = 2; + */ + value: SwitchModeError; + case: "error"; + } + | { + /** + * @generated from field: agent.v1.SwitchModeRejected rejected = 3; + */ + value: SwitchModeRejected; + case: "rejected"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.SwitchModeResult. + * Use `create(SwitchModeResultSchema)` to create a new message. + */ +export const SwitchModeResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 415); + +/** + * @generated from message agent.v1.SwitchModeSuccess + */ +export type SwitchModeSuccess = Message<"agent.v1.SwitchModeSuccess"> & { + /** + * The mode we switched from + * + * @generated from field: string from_mode_id = 1; + */ + fromModeId: string; + + /** + * The mode we switched to + * + * @generated from field: string to_mode_id = 2; + */ + toModeId: string; +}; + +/** + * Describes the message agent.v1.SwitchModeSuccess. + * Use `create(SwitchModeSuccessSchema)` to create a new message. + */ +export const SwitchModeSuccessSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 416); + +/** + * @generated from message agent.v1.SwitchModeError + */ +export type SwitchModeError = Message<"agent.v1.SwitchModeError"> & { + /** + * @generated from field: string error = 1; + */ + error: string; +}; + +/** + * Describes the message agent.v1.SwitchModeError. + * Use `create(SwitchModeErrorSchema)` to create a new message. + */ +export const SwitchModeErrorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 417); + +/** + * @generated from message agent.v1.SwitchModeRejected + */ +export type SwitchModeRejected = Message<"agent.v1.SwitchModeRejected"> & { + /** + * @generated from field: string reason = 1; + */ + reason: string; +}; + +/** + * Describes the message agent.v1.SwitchModeRejected. + * Use `create(SwitchModeRejectedSchema)` to create a new message. + */ +export const SwitchModeRejectedSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 418); + +/** + * @generated from message agent.v1.SwitchModeToolCall + */ +export type SwitchModeToolCall = Message<"agent.v1.SwitchModeToolCall"> & { + /** + * @generated from field: agent.v1.SwitchModeArgs args = 1; + */ + args?: SwitchModeArgs; + + /** + * @generated from field: agent.v1.SwitchModeResult result = 2; + */ + result?: SwitchModeResult; +}; + +/** + * Describes the message agent.v1.SwitchModeToolCall. + * Use `create(SwitchModeToolCallSchema)` to create a new message. + */ +export const SwitchModeToolCallSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 419); + +/** + * @generated from message agent.v1.SwitchModeRequestQuery + */ +export type SwitchModeRequestQuery = Message<"agent.v1.SwitchModeRequestQuery"> & { + /** + * @generated from field: agent.v1.SwitchModeArgs args = 1; + */ + args?: SwitchModeArgs; +}; + +/** + * Describes the message agent.v1.SwitchModeRequestQuery. + * Use `create(SwitchModeRequestQuerySchema)` to create a new message. + */ +export const SwitchModeRequestQuerySchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 420); + +/** + * @generated from message agent.v1.SwitchModeRequestResponse + */ +export type SwitchModeRequestResponse = Message<"agent.v1.SwitchModeRequestResponse"> & { + /** + * @generated from oneof agent.v1.SwitchModeRequestResponse.result + */ + result: + | { + /** + * @generated from field: agent.v1.SwitchModeRequestResponse_Approved approved = 1; + */ + value: SwitchModeRequestResponse_Approved; + case: "approved"; + } + | { + /** + * @generated from field: agent.v1.SwitchModeRequestResponse_Rejected rejected = 2; + */ + value: SwitchModeRequestResponse_Rejected; + case: "rejected"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.SwitchModeRequestResponse. + * Use `create(SwitchModeRequestResponseSchema)` to create a new message. + */ +export const SwitchModeRequestResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 421); + +/** + * @generated from message agent.v1.SwitchModeRequestResponse_Approved + */ +export type SwitchModeRequestResponse_Approved = Message<"agent.v1.SwitchModeRequestResponse_Approved"> & {}; + +/** + * Describes the message agent.v1.SwitchModeRequestResponse_Approved. + * Use `create(SwitchModeRequestResponse_ApprovedSchema)` to create a new message. + */ +export const SwitchModeRequestResponse_ApprovedSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 422); + +/** + * @generated from message agent.v1.SwitchModeRequestResponse_Rejected + */ +export type SwitchModeRequestResponse_Rejected = Message<"agent.v1.SwitchModeRequestResponse_Rejected"> & { + /** + * @generated from field: string reason = 1; + */ + reason: string; +}; + +/** + * Describes the message agent.v1.SwitchModeRequestResponse_Rejected. + * Use `create(SwitchModeRequestResponse_RejectedSchema)` to create a new message. + */ +export const SwitchModeRequestResponse_RejectedSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 423); + +/** + * @generated from message agent.v1.TodoItem + */ +export type TodoItem = Message<"agent.v1.TodoItem"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string content = 2; + */ + content: string; + + /** + * @generated from field: int32 status = 3; + */ + status: number; + + /** + * @generated from field: int64 created_at = 4; + */ + createdAt: bigint; + + /** + * @generated from field: int64 updated_at = 5; + */ + updatedAt: bigint; + + /** + * IDs of other TODOs this depends on + * + * @generated from field: repeated string dependencies = 6; + */ + dependencies: string[]; +}; + +/** + * Describes the message agent.v1.TodoItem. + * Use `create(TodoItemSchema)` to create a new message. + */ +export const TodoItemSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 424); + +/** + * UpdateTodos tool call + * + * @generated from message agent.v1.UpdateTodosToolCall + */ +export type UpdateTodosToolCall = Message<"agent.v1.UpdateTodosToolCall"> & { + /** + * @generated from field: agent.v1.UpdateTodosArgs args = 1; + */ + args?: UpdateTodosArgs; + + /** + * @generated from field: agent.v1.UpdateTodosResult result = 2; + */ + result?: UpdateTodosResult; +}; + +/** + * Describes the message agent.v1.UpdateTodosToolCall. + * Use `create(UpdateTodosToolCallSchema)` to create a new message. + */ +export const UpdateTodosToolCallSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 425); + +/** + * @generated from message agent.v1.UpdateTodosArgs + */ +export type UpdateTodosArgs = Message<"agent.v1.UpdateTodosArgs"> & { + /** + * @generated from field: repeated agent.v1.TodoItem todos = 1; + */ + todos: TodoItem[]; + + /** + * @generated from field: bool merge = 2; + */ + merge: boolean; +}; + +/** + * Describes the message agent.v1.UpdateTodosArgs. + * Use `create(UpdateTodosArgsSchema)` to create a new message. + */ +export const UpdateTodosArgsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 426); + +/** + * @generated from message agent.v1.UpdateTodosResult + */ +export type UpdateTodosResult = Message<"agent.v1.UpdateTodosResult"> & { + /** + * @generated from oneof agent.v1.UpdateTodosResult.result + */ + result: + | { + /** + * @generated from field: agent.v1.UpdateTodosSuccess success = 1; + */ + value: UpdateTodosSuccess; + case: "success"; + } + | { + /** + * @generated from field: agent.v1.UpdateTodosError error = 2; + */ + value: UpdateTodosError; + case: "error"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.UpdateTodosResult. + * Use `create(UpdateTodosResultSchema)` to create a new message. + */ +export const UpdateTodosResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 427); + +/** + * @generated from message agent.v1.UpdateTodosSuccess + */ +export type UpdateTodosSuccess = Message<"agent.v1.UpdateTodosSuccess"> & { + /** + * @generated from field: repeated agent.v1.TodoItem todos = 1; + */ + todos: TodoItem[]; + + /** + * @generated from field: int32 total_count = 2; + */ + totalCount: number; + + /** + * Whether this was a merge operation (needed for conditional rendering) + * + * @generated from field: bool was_merge = 3; + */ + wasMerge: boolean; +}; + +/** + * Describes the message agent.v1.UpdateTodosSuccess. + * Use `create(UpdateTodosSuccessSchema)` to create a new message. + */ +export const UpdateTodosSuccessSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 428); + +/** + * @generated from message agent.v1.UpdateTodosError + */ +export type UpdateTodosError = Message<"agent.v1.UpdateTodosError"> & { + /** + * @generated from field: string error = 1; + */ + error: string; +}; + +/** + * Describes the message agent.v1.UpdateTodosError. + * Use `create(UpdateTodosErrorSchema)` to create a new message. + */ +export const UpdateTodosErrorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 429); + +/** + * ReadTodos tool call + * + * @generated from message agent.v1.ReadTodosToolCall + */ +export type ReadTodosToolCall = Message<"agent.v1.ReadTodosToolCall"> & { + /** + * @generated from field: agent.v1.ReadTodosArgs args = 1; + */ + args?: ReadTodosArgs; + + /** + * @generated from field: agent.v1.ReadTodosResult result = 2; + */ + result?: ReadTodosResult; +}; + +/** + * Describes the message agent.v1.ReadTodosToolCall. + * Use `create(ReadTodosToolCallSchema)` to create a new message. + */ +export const ReadTodosToolCallSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 430); + +/** + * @generated from message agent.v1.ReadTodosArgs + */ +export type ReadTodosArgs = Message<"agent.v1.ReadTodosArgs"> & { + /** + * Optional: filter by status + * + * @generated from field: repeated int32 status_filter = 1; + */ + statusFilter: number[]; + + /** + * Optional: filter by IDs + * + * @generated from field: repeated string id_filter = 2; + */ + idFilter: string[]; +}; + +/** + * Describes the message agent.v1.ReadTodosArgs. + * Use `create(ReadTodosArgsSchema)` to create a new message. + */ +export const ReadTodosArgsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 431); + +/** + * @generated from message agent.v1.ReadTodosResult + */ +export type ReadTodosResult = Message<"agent.v1.ReadTodosResult"> & { + /** + * @generated from oneof agent.v1.ReadTodosResult.result + */ + result: + | { + /** + * @generated from field: agent.v1.ReadTodosSuccess success = 1; + */ + value: ReadTodosSuccess; + case: "success"; + } + | { + /** + * @generated from field: agent.v1.ReadTodosError error = 2; + */ + value: ReadTodosError; + case: "error"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.ReadTodosResult. + * Use `create(ReadTodosResultSchema)` to create a new message. + */ +export const ReadTodosResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 432); + +/** + * @generated from message agent.v1.ReadTodosSuccess + */ +export type ReadTodosSuccess = Message<"agent.v1.ReadTodosSuccess"> & { + /** + * @generated from field: repeated agent.v1.TodoItem todos = 1; + */ + todos: TodoItem[]; + + /** + * @generated from field: int32 total_count = 2; + */ + totalCount: number; +}; + +/** + * Describes the message agent.v1.ReadTodosSuccess. + * Use `create(ReadTodosSuccessSchema)` to create a new message. + */ +export const ReadTodosSuccessSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 433); + +/** + * @generated from message agent.v1.ReadTodosError + */ +export type ReadTodosError = Message<"agent.v1.ReadTodosError"> & { + /** + * @generated from field: string error = 1; + */ + error: string; +}; + +/** + * Describes the message agent.v1.ReadTodosError. + * Use `create(ReadTodosErrorSchema)` to create a new message. + */ +export const ReadTodosErrorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 434); + +/** + * @generated from message agent.v1.Range + */ +export type Range = Message<"agent.v1.Range"> & { + /** + * @generated from field: agent.v1.Position start = 1; + */ + start?: Position; + + /** + * @generated from field: agent.v1.Position end = 2; + */ + end?: Position; +}; + +/** + * Describes the message agent.v1.Range. + * Use `create(RangeSchema)` to create a new message. + */ +export const RangeSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 435); + +/** + * @generated from message agent.v1.Position + */ +export type Position = Message<"agent.v1.Position"> & { + /** + * @generated from field: uint32 line = 1; + */ + line: number; + + /** + * @generated from field: uint32 column = 2; + */ + column: number; +}; + +/** + * Describes the message agent.v1.Position. + * Use `create(PositionSchema)` to create a new message. + */ +export const PositionSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 436); + +/** + * @generated from message agent.v1.Error + */ +export type Error = Message<"agent.v1.Error"> & { + /** + * @generated from field: string message = 1; + */ + message: string; +}; + +/** + * Describes the message agent.v1.Error. + * Use `create(ErrorSchema)` to create a new message. + */ +export const ErrorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 437); + +/** + * @generated from message agent.v1.WebSearchArgs + */ +export type WebSearchArgs = Message<"agent.v1.WebSearchArgs"> & { + /** + * @generated from field: string search_term = 1; + */ + searchTerm: string; + + /** + * @generated from field: string tool_call_id = 2; + */ + toolCallId: string; +}; + +/** + * Describes the message agent.v1.WebSearchArgs. + * Use `create(WebSearchArgsSchema)` to create a new message. + */ +export const WebSearchArgsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 438); + +/** + * @generated from message agent.v1.WebSearchResult + */ +export type WebSearchResult = Message<"agent.v1.WebSearchResult"> & { + /** + * @generated from oneof agent.v1.WebSearchResult.result + */ + result: + | { + /** + * @generated from field: agent.v1.WebSearchSuccess success = 1; + */ + value: WebSearchSuccess; + case: "success"; + } + | { + /** + * @generated from field: agent.v1.WebSearchError error = 2; + */ + value: WebSearchError; + case: "error"; + } + | { + /** + * @generated from field: agent.v1.WebSearchRejected rejected = 3; + */ + value: WebSearchRejected; + case: "rejected"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.WebSearchResult. + * Use `create(WebSearchResultSchema)` to create a new message. + */ +export const WebSearchResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 439); + +/** + * @generated from message agent.v1.WebSearchSuccess + */ +export type WebSearchSuccess = Message<"agent.v1.WebSearchSuccess"> & { + /** + * @generated from field: repeated agent.v1.WebSearchReference references = 1; + */ + references: WebSearchReference[]; +}; + +/** + * Describes the message agent.v1.WebSearchSuccess. + * Use `create(WebSearchSuccessSchema)` to create a new message. + */ +export const WebSearchSuccessSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 440); + +/** + * @generated from message agent.v1.WebSearchError + */ +export type WebSearchError = Message<"agent.v1.WebSearchError"> & { + /** + * @generated from field: string error = 1; + */ + error: string; +}; + +/** + * Describes the message agent.v1.WebSearchError. + * Use `create(WebSearchErrorSchema)` to create a new message. + */ +export const WebSearchErrorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 441); + +/** + * @generated from message agent.v1.WebSearchRejected + */ +export type WebSearchRejected = Message<"agent.v1.WebSearchRejected"> & { + /** + * @generated from field: string reason = 1; + */ + reason: string; +}; + +/** + * Describes the message agent.v1.WebSearchRejected. + * Use `create(WebSearchRejectedSchema)` to create a new message. + */ +export const WebSearchRejectedSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 442); + +/** + * @generated from message agent.v1.WebSearchReference + */ +export type WebSearchReference = Message<"agent.v1.WebSearchReference"> & { + /** + * @generated from field: string title = 1; + */ + title: string; + + /** + * @generated from field: string url = 2; + */ + url: string; + + /** + * @generated from field: string chunk = 3; + */ + chunk: string; +}; + +/** + * Describes the message agent.v1.WebSearchReference. + * Use `create(WebSearchReferenceSchema)` to create a new message. + */ +export const WebSearchReferenceSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 443); + +/** + * @generated from message agent.v1.WebSearchToolCall + */ +export type WebSearchToolCall = Message<"agent.v1.WebSearchToolCall"> & { + /** + * @generated from field: agent.v1.WebSearchArgs args = 1; + */ + args?: WebSearchArgs; + + /** + * @generated from field: agent.v1.WebSearchResult result = 2; + */ + result?: WebSearchResult; +}; + +/** + * Describes the message agent.v1.WebSearchToolCall. + * Use `create(WebSearchToolCallSchema)` to create a new message. + */ +export const WebSearchToolCallSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 444); + +/** + * @generated from message agent.v1.WebSearchRequestQuery + */ +export type WebSearchRequestQuery = Message<"agent.v1.WebSearchRequestQuery"> & { + /** + * @generated from field: agent.v1.WebSearchArgs args = 1; + */ + args?: WebSearchArgs; +}; + +/** + * Describes the message agent.v1.WebSearchRequestQuery. + * Use `create(WebSearchRequestQuerySchema)` to create a new message. + */ +export const WebSearchRequestQuerySchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 445); + +/** + * @generated from message agent.v1.WebSearchRequestResponse + */ +export type WebSearchRequestResponse = Message<"agent.v1.WebSearchRequestResponse"> & { + /** + * @generated from oneof agent.v1.WebSearchRequestResponse.result + */ + result: + | { + /** + * @generated from field: agent.v1.WebSearchRequestResponse_Approved approved = 1; + */ + value: WebSearchRequestResponse_Approved; + case: "approved"; + } + | { + /** + * @generated from field: agent.v1.WebSearchRequestResponse_Rejected rejected = 2; + */ + value: WebSearchRequestResponse_Rejected; + case: "rejected"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.WebSearchRequestResponse. + * Use `create(WebSearchRequestResponseSchema)` to create a new message. + */ +export const WebSearchRequestResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 446); + +/** + * @generated from message agent.v1.WebSearchRequestResponse_Approved + */ +export type WebSearchRequestResponse_Approved = Message<"agent.v1.WebSearchRequestResponse_Approved"> & {}; + +/** + * Describes the message agent.v1.WebSearchRequestResponse_Approved. + * Use `create(WebSearchRequestResponse_ApprovedSchema)` to create a new message. + */ +export const WebSearchRequestResponse_ApprovedSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 447); + +/** + * @generated from message agent.v1.WebSearchRequestResponse_Rejected + */ +export type WebSearchRequestResponse_Rejected = Message<"agent.v1.WebSearchRequestResponse_Rejected"> & { + /** + * @generated from field: string reason = 1; + */ + reason: string; +}; + +/** + * Describes the message agent.v1.WebSearchRequestResponse_Rejected. + * Use `create(WebSearchRequestResponse_RejectedSchema)` to create a new message. + */ +export const WebSearchRequestResponse_RejectedSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 448); + +/** + * @generated from message agent.v1.WriteArgs + */ +export type WriteArgs = Message<"agent.v1.WriteArgs"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string file_text = 2; + */ + fileText: string; + + /** + * @generated from field: string tool_call_id = 3; + */ + toolCallId: string; + + /** + * @generated from field: bool return_file_content_after_write = 4; + */ + returnFileContentAfterWrite: boolean; + + /** + * Raw binary data to write. When set, file_text is ignored and the bytes are written directly without any text processing (e.g., line ending normalization). + * + * @generated from field: bytes file_bytes = 5; + */ + fileBytes: Uint8Array; +}; + +/** + * Describes the message agent.v1.WriteArgs. + * Use `create(WriteArgsSchema)` to create a new message. + */ +export const WriteArgsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 449); + +/** + * @generated from message agent.v1.WriteResult + */ +export type WriteResult = Message<"agent.v1.WriteResult"> & { + /** + * @generated from oneof agent.v1.WriteResult.result + */ + result: + | { + /** + * @generated from field: agent.v1.WriteSuccess success = 1; + */ + value: WriteSuccess; + case: "success"; + } + | { + /** + * @generated from field: agent.v1.WritePermissionDenied permission_denied = 3; + */ + value: WritePermissionDenied; + case: "permissionDenied"; + } + | { + /** + * @generated from field: agent.v1.WriteNoSpace no_space = 4; + */ + value: WriteNoSpace; + case: "noSpace"; + } + | { + /** + * @generated from field: agent.v1.WriteError error = 5; + */ + value: WriteError; + case: "error"; + } + | { + /** + * @generated from field: agent.v1.WriteRejected rejected = 6; + */ + value: WriteRejected; + case: "rejected"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.WriteResult. + * Use `create(WriteResultSchema)` to create a new message. + */ +export const WriteResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 450); + +/** + * @generated from message agent.v1.WriteSuccess + */ +export type WriteSuccess = Message<"agent.v1.WriteSuccess"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: int32 lines_created = 2; + */ + linesCreated: number; + + /** + * @generated from field: int32 file_size = 3; + */ + fileSize: number; + + /** + * @generated from field: optional string file_content_after_write = 4; + */ + fileContentAfterWrite?: string; +}; + +/** + * Describes the message agent.v1.WriteSuccess. + * Use `create(WriteSuccessSchema)` to create a new message. + */ +export const WriteSuccessSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 451); + +/** + * @generated from message agent.v1.WritePermissionDenied + */ +export type WritePermissionDenied = Message<"agent.v1.WritePermissionDenied"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string directory = 2; + */ + directory: string; + + /** + * "create_directory" or "create_file" + * + * @generated from field: string operation = 3; + */ + operation: string; + + /** + * @generated from field: string error = 4; + */ + error: string; + + /** + * @generated from field: bool is_readonly = 5; + */ + isReadonly: boolean; +}; + +/** + * Describes the message agent.v1.WritePermissionDenied. + * Use `create(WritePermissionDeniedSchema)` to create a new message. + */ +export const WritePermissionDeniedSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 452); + +/** + * @generated from message agent.v1.WriteNoSpace + */ +export type WriteNoSpace = Message<"agent.v1.WriteNoSpace"> & { + /** + * @generated from field: string path = 1; + */ + path: string; +}; + +/** + * Describes the message agent.v1.WriteNoSpace. + * Use `create(WriteNoSpaceSchema)` to create a new message. + */ +export const WriteNoSpaceSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 453); + +/** + * @generated from message agent.v1.WriteError + */ +export type WriteError = Message<"agent.v1.WriteError"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string error = 2; + */ + error: string; +}; + +/** + * Describes the message agent.v1.WriteError. + * Use `create(WriteErrorSchema)` to create a new message. + */ +export const WriteErrorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 454); + +/** + * @generated from message agent.v1.WriteRejected + */ +export type WriteRejected = Message<"agent.v1.WriteRejected"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string reason = 2; + */ + reason: string; +}; + +/** + * Describes the message agent.v1.WriteRejected. + * Use `create(WriteRejectedSchema)` to create a new message. + */ +export const WriteRejectedSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 455); + +/** + * @generated from message agent.v1.BootstrapStatsigRequest + */ +export type BootstrapStatsigRequest = Message<"agent.v1.BootstrapStatsigRequest"> & { + /** + * When true, the server should evaluate gates as if dev/internal status is ignored. This is used by clients to simulate a prod user experience. + * + * @generated from field: optional bool ignore_dev_status = 1; + */ + ignoreDevStatus?: boolean; + + /** + * @generated from field: optional int32 operating_system = 2; + */ + operatingSystem?: number; +}; + +/** + * Describes the message agent.v1.BootstrapStatsigRequest. + * Use `create(BootstrapStatsigRequestSchema)` to create a new message. + */ +export const BootstrapStatsigRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 456); + +/** + * @generated from message agent.v1.PingResponse + */ +export type PingResponse = Message<"agent.v1.PingResponse"> & {}; + +/** + * Describes the message agent.v1.PingResponse. + * Use `create(PingResponseSchema)` to create a new message. + */ +export const PingResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 457); + +/** + * @generated from message agent.v1.ExecRequest + */ +export type ExecRequest = Message<"agent.v1.ExecRequest"> & { + /** + * @generated from field: string command = 1; + */ + command: string; + + /** + * @generated from field: optional string cwd = 2; + */ + cwd?: string; + + /** + * @generated from field: repeated string args = 3; + */ + args: string[]; + + /** + * @generated from field: map environment = 4; + */ + environment: { [key: string]: string }; +}; + +/** + * Describes the message agent.v1.ExecRequest. + * Use `create(ExecRequestSchema)` to create a new message. + */ +export const ExecRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 458); + +/** + * @generated from message agent.v1.ExecResponse + */ +export type ExecResponse = Message<"agent.v1.ExecResponse"> & { + /** + * @generated from oneof agent.v1.ExecResponse.event + */ + event: + | { + /** + * @generated from field: agent.v1.StdoutEvent stdout_event = 1; + */ + value: StdoutEvent; + case: "stdoutEvent"; + } + | { + /** + * @generated from field: agent.v1.StderrEvent stderr_event = 2; + */ + value: StderrEvent; + case: "stderrEvent"; + } + | { + /** + * @generated from field: agent.v1.ExitEvent exit_event = 3; + */ + value: ExitEvent; + case: "exitEvent"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agent.v1.ExecResponse. + * Use `create(ExecResponseSchema)` to create a new message. + */ +export const ExecResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 459); + +/** + * @generated from message agent.v1.StdoutEvent + */ +export type StdoutEvent = Message<"agent.v1.StdoutEvent"> & { + /** + * @generated from field: string data = 1; + */ + data: string; +}; + +/** + * Describes the message agent.v1.StdoutEvent. + * Use `create(StdoutEventSchema)` to create a new message. + */ +export const StdoutEventSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 460); + +/** + * @generated from message agent.v1.StderrEvent + */ +export type StderrEvent = Message<"agent.v1.StderrEvent"> & { + /** + * @generated from field: string data = 1; + */ + data: string; +}; + +/** + * Describes the message agent.v1.StderrEvent. + * Use `create(StderrEventSchema)` to create a new message. + */ +export const StderrEventSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 461); + +/** + * @generated from message agent.v1.ExitEvent + */ +export type ExitEvent = Message<"agent.v1.ExitEvent"> & { + /** + * @generated from field: int32 exit_code = 1; + */ + exitCode: number; +}; + +/** + * Describes the message agent.v1.ExitEvent. + * Use `create(ExitEventSchema)` to create a new message. + */ +export const ExitEventSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 462); + +/** + * @generated from message agent.v1.ReadTextFileRequest + */ +export type ReadTextFileRequest = Message<"agent.v1.ReadTextFileRequest"> & { + /** + * @generated from field: string path = 1; + */ + path: string; +}; + +/** + * Describes the message agent.v1.ReadTextFileRequest. + * Use `create(ReadTextFileRequestSchema)` to create a new message. + */ +export const ReadTextFileRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 463); + +/** + * @generated from message agent.v1.ReadTextFileResponse + */ +export type ReadTextFileResponse = Message<"agent.v1.ReadTextFileResponse"> & { + /** + * @generated from field: string content = 1; + */ + content: string; +}; + +/** + * Describes the message agent.v1.ReadTextFileResponse. + * Use `create(ReadTextFileResponseSchema)` to create a new message. + */ +export const ReadTextFileResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 464); + +/** + * @generated from message agent.v1.WriteTextFileRequest + */ +export type WriteTextFileRequest = Message<"agent.v1.WriteTextFileRequest"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string content = 2; + */ + content: string; +}; + +/** + * Describes the message agent.v1.WriteTextFileRequest. + * Use `create(WriteTextFileRequestSchema)` to create a new message. + */ +export const WriteTextFileRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 465); + +/** + * Empty response - success is implied by RPC completion + * + * @generated from message agent.v1.WriteTextFileResponse + */ +export type WriteTextFileResponse = Message<"agent.v1.WriteTextFileResponse"> & {}; + +/** + * Describes the message agent.v1.WriteTextFileResponse. + * Use `create(WriteTextFileResponseSchema)` to create a new message. + */ +export const WriteTextFileResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 466); + +/** + * @generated from message agent.v1.ReadBinaryFileRequest + */ +export type ReadBinaryFileRequest = Message<"agent.v1.ReadBinaryFileRequest"> & { + /** + * @generated from field: string path = 1; + */ + path: string; +}; + +/** + * Describes the message agent.v1.ReadBinaryFileRequest. + * Use `create(ReadBinaryFileRequestSchema)` to create a new message. + */ +export const ReadBinaryFileRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 467); + +/** + * @generated from message agent.v1.ReadBinaryFileResponse + */ +export type ReadBinaryFileResponse = Message<"agent.v1.ReadBinaryFileResponse"> & { + /** + * @generated from field: bytes content = 1; + */ + content: Uint8Array; +}; + +/** + * Describes the message agent.v1.ReadBinaryFileResponse. + * Use `create(ReadBinaryFileResponseSchema)` to create a new message. + */ +export const ReadBinaryFileResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 468); + +/** + * @generated from message agent.v1.WriteBinaryFileRequest + */ +export type WriteBinaryFileRequest = Message<"agent.v1.WriteBinaryFileRequest"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: bytes content = 2; + */ + content: Uint8Array; +}; + +/** + * Describes the message agent.v1.WriteBinaryFileRequest. + * Use `create(WriteBinaryFileRequestSchema)` to create a new message. + */ +export const WriteBinaryFileRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 469); + +/** + * Empty response - success is implied by RPC completion + * + * @generated from message agent.v1.WriteBinaryFileResponse + */ +export type WriteBinaryFileResponse = Message<"agent.v1.WriteBinaryFileResponse"> & {}; + +/** + * Describes the message agent.v1.WriteBinaryFileResponse. + * Use `create(WriteBinaryFileResponseSchema)` to create a new message. + */ +export const WriteBinaryFileResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 470); + +/** + * @generated from message agent.v1.GetWorkspaceChangesHashRequest + */ +export type GetWorkspaceChangesHashRequest = Message<"agent.v1.GetWorkspaceChangesHashRequest"> & { + /** + * @generated from field: string root_path = 1; + */ + rootPath: string; + + /** + * @generated from field: string base_ref = 2; + */ + baseRef: string; +}; + +/** + * Describes the message agent.v1.GetWorkspaceChangesHashRequest. + * Use `create(GetWorkspaceChangesHashRequestSchema)` to create a new message. + */ +export const GetWorkspaceChangesHashRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 471); + +/** + * @generated from message agent.v1.GetWorkspaceChangesHashResponse + */ +export type GetWorkspaceChangesHashResponse = Message<"agent.v1.GetWorkspaceChangesHashResponse"> & { + /** + * @generated from field: string hash = 1; + */ + hash: string; +}; + +/** + * Describes the message agent.v1.GetWorkspaceChangesHashResponse. + * Use `create(GetWorkspaceChangesHashResponseSchema)` to create a new message. + */ +export const GetWorkspaceChangesHashResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 472); + +/** + * @generated from message agent.v1.RefreshGithubAccessTokenRequest + */ +export type RefreshGithubAccessTokenRequest = Message<"agent.v1.RefreshGithubAccessTokenRequest"> & { + /** + * @generated from field: string github_access_token = 1; + */ + githubAccessToken: string; + + /** + * e.g., "github.com", "gitlab.com", "gitlab.example.com" + * + * @generated from field: string hostname = 2; + */ + hostname: string; +}; + +/** + * Describes the message agent.v1.RefreshGithubAccessTokenRequest. + * Use `create(RefreshGithubAccessTokenRequestSchema)` to create a new message. + */ +export const RefreshGithubAccessTokenRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 473); + +/** + * Empty response - success is implied by RPC completion + * + * @generated from message agent.v1.RefreshGithubAccessTokenResponse + */ +export type RefreshGithubAccessTokenResponse = Message<"agent.v1.RefreshGithubAccessTokenResponse"> & {}; + +/** + * Describes the message agent.v1.RefreshGithubAccessTokenResponse. + * Use `create(RefreshGithubAccessTokenResponseSchema)` to create a new message. + */ +export const RefreshGithubAccessTokenResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 474); + +/** + * @generated from message agent.v1.WarmRemoteAccessServerRequest + */ +export type WarmRemoteAccessServerRequest = Message<"agent.v1.WarmRemoteAccessServerRequest"> & { + /** + * @generated from field: string commit = 1; + */ + commit: string; + + /** + * @generated from field: int32 port = 2; + */ + port: number; + + /** + * @generated from field: string connection_token = 3; + */ + connectionToken: string; +}; + +/** + * Describes the message agent.v1.WarmRemoteAccessServerRequest. + * Use `create(WarmRemoteAccessServerRequestSchema)` to create a new message. + */ +export const WarmRemoteAccessServerRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 475); + +/** + * Empty response - success is implied by RPC completion + * + * @generated from message agent.v1.WarmRemoteAccessServerResponse + */ +export type WarmRemoteAccessServerResponse = Message<"agent.v1.WarmRemoteAccessServerResponse"> & {}; + +/** + * Describes the message agent.v1.WarmRemoteAccessServerResponse. + * Use `create(WarmRemoteAccessServerResponseSchema)` to create a new message. + */ +export const WarmRemoteAccessServerResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 476); + +/** + * @generated from message agent.v1.ListArtifactsRequest + */ +export type ListArtifactsRequest = Message<"agent.v1.ListArtifactsRequest"> & {}; + +/** + * Describes the message agent.v1.ListArtifactsRequest. + * Use `create(ListArtifactsRequestSchema)` to create a new message. + */ +export const ListArtifactsRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 477); + +/** + * @generated from message agent.v1.ArtifactUploadMetadata + */ +export type ArtifactUploadMetadata = Message<"agent.v1.ArtifactUploadMetadata"> & { + /** + * @generated from field: string absolute_path = 1; + */ + absolutePath: string; + + /** + * @generated from field: uint64 size_bytes = 2; + */ + sizeBytes: bigint; + + /** + * @generated from field: int64 updated_at_unix_ms = 3; + */ + updatedAtUnixMs: bigint; + + /** + * @generated from field: int32 status = 4; + */ + status: number; + + /** + * @generated from field: uint64 bytes_uploaded = 5; + */ + bytesUploaded: bigint; + + /** + * @generated from field: string last_error = 6; + */ + lastError: string; + + /** + * @generated from field: uint32 upload_attempts = 7; + */ + uploadAttempts: number; + + /** + * @generated from field: int64 last_started_at_unix_ms = 8; + */ + lastStartedAtUnixMs: bigint; + + /** + * @generated from field: int64 last_finished_at_unix_ms = 9; + */ + lastFinishedAtUnixMs: bigint; + + /** + * @generated from field: string upload_id = 10; + */ + uploadId: string; +}; + +/** + * Describes the message agent.v1.ArtifactUploadMetadata. + * Use `create(ArtifactUploadMetadataSchema)` to create a new message. + */ +export const ArtifactUploadMetadataSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 478); + +/** + * @generated from message agent.v1.ListArtifactsResponse + */ +export type ListArtifactsResponse = Message<"agent.v1.ListArtifactsResponse"> & { + /** + * @generated from field: repeated agent.v1.ArtifactUploadMetadata artifacts = 1; + */ + artifacts: ArtifactUploadMetadata[]; +}; + +/** + * Describes the message agent.v1.ListArtifactsResponse. + * Use `create(ListArtifactsResponseSchema)` to create a new message. + */ +export const ListArtifactsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 479); + +/** + * @generated from message agent.v1.UploadArtifactsRequest + */ +export type UploadArtifactsRequest = Message<"agent.v1.UploadArtifactsRequest"> & { + /** + * @generated from field: repeated agent.v1.ArtifactUploadInstruction uploads = 1; + */ + uploads: ArtifactUploadInstruction[]; +}; + +/** + * Describes the message agent.v1.UploadArtifactsRequest. + * Use `create(UploadArtifactsRequestSchema)` to create a new message. + */ +export const UploadArtifactsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 480); + +/** + * @generated from message agent.v1.ArtifactUploadInstruction + */ +export type ArtifactUploadInstruction = Message<"agent.v1.ArtifactUploadInstruction"> & { + /** + * @generated from field: string absolute_path = 1; + */ + absolutePath: string; + + /** + * @generated from field: string upload_url = 2; + */ + uploadUrl: string; + + /** + * @generated from field: string method = 3; + */ + method: string; + + /** + * @generated from field: map headers = 4; + */ + headers: { [key: string]: string }; + + /** + * @generated from field: optional string content_type = 5; + */ + contentType?: string; + + /** + * @generated from field: optional string slack_upload_url = 6; + */ + slackUploadUrl?: string; + + /** + * @generated from field: optional string slack_file_id = 7; + */ + slackFileId?: string; +}; + +/** + * Describes the message agent.v1.ArtifactUploadInstruction. + * Use `create(ArtifactUploadInstructionSchema)` to create a new message. + */ +export const ArtifactUploadInstructionSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 481); + +/** + * @generated from message agent.v1.ArtifactUploadDispatchResult + */ +export type ArtifactUploadDispatchResult = Message<"agent.v1.ArtifactUploadDispatchResult"> & { + /** + * @generated from field: string absolute_path = 1; + */ + absolutePath: string; + + /** + * @generated from field: int32 status = 2; + */ + status: number; + + /** + * @generated from field: string message = 3; + */ + message: string; + + /** + * @generated from field: optional string slack_file_id = 4; + */ + slackFileId?: string; +}; + +/** + * Describes the message agent.v1.ArtifactUploadDispatchResult. + * Use `create(ArtifactUploadDispatchResultSchema)` to create a new message. + */ +export const ArtifactUploadDispatchResultSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 482); + +/** + * @generated from message agent.v1.UploadArtifactsResponse + */ +export type UploadArtifactsResponse = Message<"agent.v1.UploadArtifactsResponse"> & { + /** + * @generated from field: repeated agent.v1.ArtifactUploadDispatchResult results = 1; + */ + results: ArtifactUploadDispatchResult[]; +}; + +/** + * Describes the message agent.v1.UploadArtifactsResponse. + * Use `create(UploadArtifactsResponseSchema)` to create a new message. + */ +export const UploadArtifactsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 483); + +/** + * @generated from message agent.v1.GetMcpRefreshTokensRequest + */ +export type GetMcpRefreshTokensRequest = Message<"agent.v1.GetMcpRefreshTokensRequest"> & {}; + +/** + * Describes the message agent.v1.GetMcpRefreshTokensRequest. + * Use `create(GetMcpRefreshTokensRequestSchema)` to create a new message. + */ +export const GetMcpRefreshTokensRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 484); + +/** + * @generated from message agent.v1.GetMcpRefreshTokensResponse + */ +export type GetMcpRefreshTokensResponse = Message<"agent.v1.GetMcpRefreshTokensResponse"> & { + /** + * Map from server URL to refresh token + * + * @generated from field: map refresh_tokens = 1; + */ + refreshTokens: { [key: string]: string }; +}; + +/** + * Describes the message agent.v1.GetMcpRefreshTokensResponse. + * Use `create(GetMcpRefreshTokensResponseSchema)` to create a new message. + */ +export const GetMcpRefreshTokensResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 485); + +/** + * @generated from message agent.v1.UpdateEnvironmentVariablesRequest + */ +export type UpdateEnvironmentVariablesRequest = Message<"agent.v1.UpdateEnvironmentVariablesRequest"> & { + /** + * Environment variables to manage (plaintext values). + * + * @generated from field: map env = 1; + */ + env: { [key: string]: string }; + + /** + * If true, unset previously-managed keys that are not present in `env`. + * + * @generated from field: bool replace = 2; + */ + replace: boolean; +}; + +/** + * Describes the message agent.v1.UpdateEnvironmentVariablesRequest. + * Use `create(UpdateEnvironmentVariablesRequestSchema)` to create a new message. + */ +export const UpdateEnvironmentVariablesRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 486); + +/** + * @generated from message agent.v1.UpdateEnvironmentVariablesResponse + */ +export type UpdateEnvironmentVariablesResponse = Message<"agent.v1.UpdateEnvironmentVariablesResponse"> & { + /** + * @generated from field: uint32 applied = 1; + */ + applied: number; + + /** + * @generated from field: uint32 removed = 2; + */ + removed: number; +}; + +/** + * Describes the message agent.v1.UpdateEnvironmentVariablesResponse. + * Use `create(UpdateEnvironmentVariablesResponseSchema)` to create a new message. + */ +export const UpdateEnvironmentVariablesResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_agent, 487); + +/** + * Check if an error is caused by the client disconnecting (e.g., due to timeout or abort). This includes errors like ERR_STREAM_DESTROYED which occur when the HTTP response stream is closed by the client while the server is still writing to it. function isClientDisconnectError(error) { if (!(error instanceof Error)) { return false; const code = error.code; return (code === "ERR_STREAM_DESTROYED" || code === "ERR_STREAM_PREMATURE_CLOSE" || code === "ECONNRESET" || code === "EPIPE"); ;// ../proto/dist/generated/aiserver/v1/mcp_pb.js // @ts-nocheck + * + * @generated from message agent.v1.McpOAuthStoredData + */ +export type McpOAuthStoredData = Message<"agent.v1.McpOAuthStoredData"> & { + /** + * @generated from field: string refresh_token = 1; + */ + refreshToken: string; + + /** + * @generated from field: string client_id = 2; + */ + clientId: string; + + /** + * @generated from field: optional string client_secret = 3; + */ + clientSecret?: string; + + /** + * @generated from field: repeated string redirect_uris = 4; + */ + redirectUris: string[]; +}; + +/** + * Describes the message agent.v1.McpOAuthStoredData. + * Use `create(McpOAuthStoredDataSchema)` to create a new message. + */ +export const McpOAuthStoredDataSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 488); + +/** + * @generated from message agent.v1.Frame + */ +export type Frame = Message<"agent.v1.Frame"> & { + /** + * Correlation ID + * + * @generated from field: string id = 1; + */ + id: string; + + /** + * RPC method (e.g., "/agent.v1.ControlService/Ping") + * + * @generated from field: string method = 2; + */ + method: string; + + /** + * Serialized payload + * + * @generated from field: bytes data = 3; + */ + data: Uint8Array; + + /** + * @generated from field: int32 kind = 4; + */ + kind: number; + + /** + * Error message (kind == ERROR) + * + * @generated from field: string error = 5; + */ + error: string; +}; + +/** + * Describes the message agent.v1.Frame. + * Use `create(FrameSchema)` to create a new message. + */ +export const FrameSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 489); + +/** + * var Frame_Kind; (function (Frame_Kind) { Frame_Kind[Frame_Kind["UNSPECIFIED"] = 0] = "UNSPECIFIED"; Frame_Kind[Frame_Kind["REQUEST"] = 1] = "REQUEST"; Frame_Kind[Frame_Kind["RESPONSE"] = 2] = "RESPONSE"; Frame_Kind[Frame_Kind["ERROR"] = 3] = "ERROR"; })(Frame_Kind || (Frame_Kind = {})); // Retrieve enum metadata with: proto3.getEnumType(Frame_Kind) proto3/* int32 *\/.C.util.setEnumType(Frame_Kind, "agent.v1.Frame.Kind", [ { no: 0, name: "KIND_UNSPECIFIED" }, { no: 1, name: "KIND_REQUEST" }, { no: 2, name: "KIND_RESPONSE" }, { no: 3, name: "KIND_ERROR" }, ]); + * + * @generated from message agent.v1.Empty + */ +export type Empty = Message<"agent.v1.Empty"> & {}; + +/** + * Describes the message agent.v1.Empty. + * Use `create(EmptySchema)` to create a new message. + */ +export const EmptySchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 490); + +/** + * @generated from message agent.v1.BidiRequestId + */ +export type BidiRequestId = Message<"agent.v1.BidiRequestId"> & { + /** + * @generated from field: string request_id = 1; + */ + requestId: string; +}; + +/** + * Describes the message agent.v1.BidiRequestId. + * Use `create(BidiRequestIdSchema)` to create a new message. + */ +export const BidiRequestIdSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agent, 491); + +/** + * @generated from enum agent.v1.AppliedAgentChange_ChangeType + */ +export enum AppliedAgentChange_ChangeType { + /** + * @generated from enum value: CHANGE_TYPE_UNSPECIFIED = 0; + */ + CHANGE_TYPE_UNSPECIFIED = 0, + + /** + * @generated from enum value: CHANGE_TYPE_CREATED = 1; + */ + CHANGE_TYPE_CREATED = 1, + + /** + * @generated from enum value: CHANGE_TYPE_MODIFIED = 2; + */ + CHANGE_TYPE_MODIFIED = 2, + + /** + * @generated from enum value: CHANGE_TYPE_DELETED = 3; + */ + CHANGE_TYPE_DELETED = 3, +} + +/** + * Describes the enum agent.v1.AppliedAgentChange_ChangeType. + */ +export const AppliedAgentChange_ChangeTypeSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_agent, 0); + +/** + * @generated from enum agent.v1.MouseButton + */ +export enum MouseButton { + /** + * @generated from enum value: MOUSE_BUTTON_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: MOUSE_BUTTON_LEFT = 1; + */ + LEFT = 1, + + /** + * @generated from enum value: MOUSE_BUTTON_RIGHT = 2; + */ + RIGHT = 2, + + /** + * @generated from enum value: MOUSE_BUTTON_MIDDLE = 3; + */ + MIDDLE = 3, + + /** + * @generated from enum value: MOUSE_BUTTON_BACK = 4; + */ + BACK = 4, + + /** + * @generated from enum value: MOUSE_BUTTON_FORWARD = 5; + */ + FORWARD = 5, +} + +/** + * Describes the enum agent.v1.MouseButton. + */ +export const MouseButtonSchema: GenEnum = /*@__PURE__*/ enumDesc(file_agent, 1); + +/** + * @generated from enum agent.v1.ScrollDirection + */ +export enum ScrollDirection { + /** + * @generated from enum value: SCROLL_DIRECTION_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: SCROLL_DIRECTION_UP = 1; + */ + UP = 1, + + /** + * @generated from enum value: SCROLL_DIRECTION_DOWN = 2; + */ + DOWN = 2, + + /** + * @generated from enum value: SCROLL_DIRECTION_LEFT = 3; + */ + LEFT = 3, + + /** + * @generated from enum value: SCROLL_DIRECTION_RIGHT = 4; + */ + RIGHT = 4, +} + +/** + * Describes the enum agent.v1.ScrollDirection. + */ +export const ScrollDirectionSchema: GenEnum = /*@__PURE__*/ enumDesc(file_agent, 2); + +/** + * @generated from enum agent.v1.CursorRuleSource + */ +export enum CursorRuleSource { + /** + * @generated from enum value: CURSOR_RULE_SOURCE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: CURSOR_RULE_SOURCE_TEAM = 1; + */ + TEAM = 1, + + /** + * @generated from enum value: CURSOR_RULE_SOURCE_USER = 2; + */ + USER = 2, +} + +/** + * Describes the enum agent.v1.CursorRuleSource. + */ +export const CursorRuleSourceSchema: GenEnum = /*@__PURE__*/ enumDesc(file_agent, 3); + +/** + * @generated from enum agent.v1.DiagnosticSeverity + */ +export enum DiagnosticSeverity { + /** + * @generated from enum value: DIAGNOSTIC_SEVERITY_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: DIAGNOSTIC_SEVERITY_ERROR = 1; + */ + ERROR = 1, + + /** + * @generated from enum value: DIAGNOSTIC_SEVERITY_WARNING = 2; + */ + WARNING = 2, + + /** + * @generated from enum value: DIAGNOSTIC_SEVERITY_INFORMATION = 3; + */ + INFORMATION = 3, + + /** + * @generated from enum value: DIAGNOSTIC_SEVERITY_HINT = 4; + */ + HINT = 4, +} + +/** + * Describes the enum agent.v1.DiagnosticSeverity. + */ +export const DiagnosticSeveritySchema: GenEnum = /*@__PURE__*/ enumDesc(file_agent, 4); + +/** + * @generated from enum agent.v1.RecordingMode + */ +export enum RecordingMode { + /** + * @generated from enum value: RECORDING_MODE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: RECORDING_MODE_START_RECORDING = 1; + */ + START_RECORDING = 1, + + /** + * @generated from enum value: RECORDING_MODE_SAVE_RECORDING = 2; + */ + SAVE_RECORDING = 2, + + /** + * @generated from enum value: RECORDING_MODE_DISCARD_RECORDING = 3; + */ + DISCARD_RECORDING = 3, +} + +/** + * Describes the enum agent.v1.RecordingMode. + */ +export const RecordingModeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_agent, 5); + +/** + * @generated from enum agent.v1.RequestedFilePathRejectedReason + */ +export enum RequestedFilePathRejectedReason { + /** + * @generated from enum value: REQUESTED_FILE_PATH_REJECTED_REASON_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: REQUESTED_FILE_PATH_REJECTED_REASON_SLASHES_NOT_ALLOWED = 1; + */ + SLASHES_NOT_ALLOWED = 1, +} + +/** + * Describes the enum agent.v1.RequestedFilePathRejectedReason. + */ +export const RequestedFilePathRejectedReasonSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_agent, 6); + +/** + * @generated from enum agent.v1.PackageType + */ +export enum PackageType { + /** + * @generated from enum value: PACKAGE_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: PACKAGE_TYPE_CURSOR_PROJECT = 1; + */ + CURSOR_PROJECT = 1, + + /** + * @generated from enum value: PACKAGE_TYPE_CURSOR_PERSONAL = 2; + */ + CURSOR_PERSONAL = 2, + + /** + * @generated from enum value: PACKAGE_TYPE_CLAUDE_SKILL = 3; + */ + CLAUDE_SKILL = 3, + + /** + * @generated from enum value: PACKAGE_TYPE_CLAUDE_PLUGIN = 4; + */ + CLAUDE_PLUGIN = 4, +} + +/** + * Describes the enum agent.v1.PackageType. + */ +export const PackageTypeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_agent, 7); + +/** + * @generated from enum agent.v1.SandboxPolicy_Type + */ +export enum SandboxPolicy_Type { + /** + * @generated from enum value: TYPE_UNSPECIFIED = 0; + */ + TYPE_UNSPECIFIED = 0, + + /** + * @generated from enum value: TYPE_INSECURE_NONE = 1; + */ + TYPE_INSECURE_NONE = 1, + + /** + * @generated from enum value: TYPE_WORKSPACE_READWRITE = 2; + */ + TYPE_WORKSPACE_READWRITE = 2, + + /** + * @generated from enum value: TYPE_WORKSPACE_READONLY = 3; + */ + TYPE_WORKSPACE_READONLY = 3, +} + +/** + * Describes the enum agent.v1.SandboxPolicy_Type. + */ +export const SandboxPolicy_TypeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_agent, 8); + +/** + * @generated from enum agent.v1.TimeoutBehavior + */ +export enum TimeoutBehavior { + /** + * @generated from enum value: TIMEOUT_BEHAVIOR_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: TIMEOUT_BEHAVIOR_CANCEL = 1; + */ + CANCEL = 1, + + /** + * @generated from enum value: TIMEOUT_BEHAVIOR_BACKGROUND = 2; + */ + BACKGROUND = 2, +} + +/** + * Describes the enum agent.v1.TimeoutBehavior. + */ +export const TimeoutBehaviorSchema: GenEnum = /*@__PURE__*/ enumDesc(file_agent, 9); + +/** + * @generated from enum agent.v1.ShellAbortReason + */ +export enum ShellAbortReason { + /** + * @generated from enum value: SHELL_ABORT_REASON_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: SHELL_ABORT_REASON_USER_ABORT = 1; + */ + USER_ABORT = 1, + + /** + * @generated from enum value: SHELL_ABORT_REASON_TIMEOUT = 2; + */ + TIMEOUT = 2, +} + +/** + * Describes the enum agent.v1.ShellAbortReason. + */ +export const ShellAbortReasonSchema: GenEnum = /*@__PURE__*/ enumDesc(file_agent, 10); + +/** + * @generated from enum agent.v1.CustomSubagentPermissionMode + */ +export enum CustomSubagentPermissionMode { + /** + * @generated from enum value: CUSTOM_SUBAGENT_PERMISSION_MODE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: CUSTOM_SUBAGENT_PERMISSION_MODE_DEFAULT = 1; + */ + DEFAULT = 1, + + /** + * @generated from enum value: CUSTOM_SUBAGENT_PERMISSION_MODE_READONLY = 2; + */ + READONLY = 2, +} + +/** + * Describes the enum agent.v1.CustomSubagentPermissionMode. + */ +export const CustomSubagentPermissionModeSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_agent, 11); + +/** + * @generated from enum agent.v1.TodoStatus + */ +export enum TodoStatus { + /** + * @generated from enum value: TODO_STATUS_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: TODO_STATUS_PENDING = 1; + */ + PENDING = 1, + + /** + * @generated from enum value: TODO_STATUS_IN_PROGRESS = 2; + */ + IN_PROGRESS = 2, + + /** + * @generated from enum value: TODO_STATUS_COMPLETED = 3; + */ + COMPLETED = 3, + + /** + * @generated from enum value: TODO_STATUS_CANCELLED = 4; + */ + CANCELLED = 4, +} + +/** + * Describes the enum agent.v1.TodoStatus. + */ +export const TodoStatusSchema: GenEnum = /*@__PURE__*/ enumDesc(file_agent, 12); + +/** + * @generated from enum agent.v1.ClientOS + */ +export enum ClientOS { + /** + * @generated from enum value: CLIENT_OS_UNSPECIFIED = 0; + */ + CLIENT_OS_UNSPECIFIED = 0, + + /** + * @generated from enum value: CLIENT_OS_WINDOWS = 1; + */ + CLIENT_OS_WINDOWS = 1, + + /** + * @generated from enum value: CLIENT_OS_MACOS = 2; + */ + CLIENT_OS_MACOS = 2, + + /** + * @generated from enum value: CLIENT_OS_LINUX = 3; + */ + CLIENT_OS_LINUX = 3, +} + +/** + * Describes the enum agent.v1.ClientOS. + */ +export const ClientOSSchema: GenEnum = /*@__PURE__*/ enumDesc(file_agent, 13); + +/** + * @generated from enum agent.v1.ArtifactUploadDispatchStatus + */ +export enum ArtifactUploadDispatchStatus { + /** + * @generated from enum value: ARTIFACT_UPLOAD_DISPATCH_STATUS_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: ARTIFACT_UPLOAD_DISPATCH_STATUS_ACCEPTED = 1; + */ + ACCEPTED = 1, + + /** + * @generated from enum value: ARTIFACT_UPLOAD_DISPATCH_STATUS_REJECTED = 2; + */ + REJECTED = 2, + + /** + * @generated from enum value: ARTIFACT_UPLOAD_DISPATCH_STATUS_SKIPPED_ALREADY_IN_PROGRESS = 3; + */ + SKIPPED_ALREADY_IN_PROGRESS = 3, +} + +/** + * Describes the enum agent.v1.ArtifactUploadDispatchStatus. + */ +export const ArtifactUploadDispatchStatusSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_agent, 14); + +/** + * @generated from enum agent.v1.Frame_Kind + */ +export enum Frame_Kind { + /** + * @generated from enum value: KIND_UNSPECIFIED = 0; + */ + KIND_UNSPECIFIED = 0, + + /** + * @generated from enum value: KIND_REQUEST = 1; + */ + KIND_REQUEST = 1, + + /** + * @generated from enum value: KIND_RESPONSE = 2; + */ + KIND_RESPONSE = 2, + + /** + * @generated from enum value: KIND_ERROR = 3; + */ + KIND_ERROR = 3, +} + +/** + * Describes the enum agent.v1.Frame_Kind. + */ +export const Frame_KindSchema: GenEnum = /*@__PURE__*/ enumDesc(file_agent, 15); + +/** + * @generated from enum agent.v1.BugbotDeeplinkEventKind + */ +export enum BugbotDeeplinkEventKind { + /** + * @generated from enum value: BUGBOT_DEEPLINK_EVENT_KIND_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: BUGBOT_DEEPLINK_EVENT_KIND_CLICKED = 1; + */ + CLICKED = 1, + + /** + * @generated from enum value: BUGBOT_DEEPLINK_EVENT_KIND_HANDLED_DIALOG_SHOWN = 2; + */ + HANDLED_DIALOG_SHOWN = 2, + + /** + * @generated from enum value: BUGBOT_DEEPLINK_EVENT_KIND_HANDLED_CHAT_CREATED = 3; + */ + HANDLED_CHAT_CREATED = 3, + + /** + * @generated from enum value: BUGBOT_DEEPLINK_EVENT_KIND_ERROR = 4; + */ + ERROR = 4, + + /** + * @generated from enum value: BUGBOT_DEEPLINK_EVENT_KIND_HANDLED_FIX_IN_WEB = 5; + */ + HANDLED_FIX_IN_WEB = 5, +} + +/** + * Describes the enum agent.v1.BugbotDeeplinkEventKind. + */ +export const BugbotDeeplinkEventKindSchema: GenEnum = /*@__PURE__*/ enumDesc(file_agent, 16); + +/** + * Agent Service with bidirectional streaming + * + * @generated from service agent.v1.AgentService + */ +export const AgentService: GenService<{ + /** + * @generated from rpc agent.v1.AgentService.Run + */ + run: { + methodKind: "unary"; + input: typeof AgentClientMessageSchema; + output: typeof AgentServerMessageSchema; + }; + /** + * @generated from rpc agent.v1.AgentService.RunSSE + */ + runSSE: { + methodKind: "unary"; + input: typeof BidiRequestIdSchema; + output: typeof AgentServerMessageSchema; + }; + /** + * Generate a very short, succinct agent name from the provided user message. + * + * @generated from rpc agent.v1.AgentService.NameAgent + */ + nameAgent: { + methodKind: "unary"; + input: typeof NameAgentRequestSchema; + output: typeof NameAgentResponseSchema; + }; + /** + * @generated from rpc agent.v1.AgentService.GetUsableModels + */ + getUsableModels: { + methodKind: "unary"; + input: typeof GetUsableModelsRequestSchema; + output: typeof GetUsableModelsResponseSchema; + }; + /** + * @generated from rpc agent.v1.AgentService.GetDefaultModelForCli + */ + getDefaultModelForCli: { + methodKind: "unary"; + input: typeof GetDefaultModelForCliRequestSchema; + output: typeof GetDefaultModelForCliResponseSchema; + }; + /** + * Internal endpoint: returns all allowed model intents for devs + * + * @generated from rpc agent.v1.AgentService.GetAllowedModelIntents + */ + getAllowedModelIntents: { + methodKind: "unary"; + input: typeof GetAllowedModelIntentsRequestSchema; + output: typeof GetAllowedModelIntentsResponseSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_agent, 0); + +/** + * @generated from service agent.v1.ControlService + */ +export const ControlService: GenService<{ + /** + * Spawn + * File read / write + * + * @generated from rpc agent.v1.ControlService.ReadTextFile + */ + readTextFile: { + methodKind: "unary"; + input: typeof ReadTextFileRequestSchema; + output: typeof ReadTextFileResponseSchema; + }; + /** + * @generated from rpc agent.v1.ControlService.WriteTextFile + */ + writeTextFile: { + methodKind: "unary"; + input: typeof WriteTextFileRequestSchema; + output: typeof WriteTextFileResponseSchema; + }; + /** + * Binary file read / write + * + * @generated from rpc agent.v1.ControlService.ReadBinaryFile + */ + readBinaryFile: { + methodKind: "unary"; + input: typeof ReadBinaryFileRequestSchema; + output: typeof ReadBinaryFileResponseSchema; + }; + /** + * @generated from rpc agent.v1.ControlService.WriteBinaryFile + */ + writeBinaryFile: { + methodKind: "unary"; + input: typeof WriteBinaryFileRequestSchema; + output: typeof WriteBinaryFileResponseSchema; + }; + /** + * Git + * + * @generated from rpc agent.v1.ControlService.GetWorkspaceChangesHash + */ + getWorkspaceChangesHash: { + methodKind: "unary"; + input: typeof GetWorkspaceChangesHashRequestSchema; + output: typeof GetWorkspaceChangesHashResponseSchema; + }; + /** + * @generated from rpc agent.v1.ControlService.RefreshGithubAccessToken + */ + refreshGithubAccessToken: { + methodKind: "unary"; + input: typeof RefreshGithubAccessTokenRequestSchema; + output: typeof RefreshGithubAccessTokenResponseSchema; + }; + /** + * Remote access + * + * @generated from rpc agent.v1.ControlService.WarmRemoteAccessServer + */ + warmRemoteAccessServer: { + methodKind: "unary"; + input: typeof WarmRemoteAccessServerRequestSchema; + output: typeof WarmRemoteAccessServerResponseSchema; + }; + /** + * Artifact uploads + * + * @generated from rpc agent.v1.ControlService.ListArtifacts + */ + listArtifacts: { + methodKind: "unary"; + input: typeof ListArtifactsRequestSchema; + output: typeof ListArtifactsResponseSchema; + }; + /** + * @generated from rpc agent.v1.ControlService.UploadArtifacts + */ + uploadArtifacts: { + methodKind: "unary"; + input: typeof UploadArtifactsRequestSchema; + output: typeof UploadArtifactsResponseSchema; + }; + /** + * @generated from rpc agent.v1.ControlService.GetMcpRefreshTokens + */ + getMcpRefreshTokens: { + methodKind: "unary"; + input: typeof GetMcpRefreshTokensRequestSchema; + output: typeof GetMcpRefreshTokensResponseSchema; + }; + /** + * Update the exec-daemon's environment variables for subsequent process spawns. This does NOT affect already-running processes. + * + * @generated from rpc agent.v1.ControlService.UpdateEnvironmentVariables + */ + updateEnvironmentVariables: { + methodKind: "unary"; + input: typeof UpdateEnvironmentVariablesRequestSchema; + output: typeof UpdateEnvironmentVariablesResponseSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_agent, 1); + +/** + * Agent Service with unary RPC + * + * @generated from service agent.v1.ExecService + */ +export const ExecService: GenService<{}> = /*@__PURE__*/ serviceDesc(file_agent, 2); + +/** + * @generated from service agent.v1.PrivateWorkerBridgeExternalService + */ +export const PrivateWorkerBridgeExternalService: GenService<{ + /** + * @generated from rpc agent.v1.PrivateWorkerBridgeExternalService.Connect + */ + connect: { + methodKind: "unary"; + input: typeof FrameSchema; + output: typeof FrameSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_agent, 3); + +/** + * LifecycleService is exposed by the bridge *client*, in addition to ExecService (tool calls) and ControlService (control operations "within the daemon"). It operates at a similar abstraction level as AnyrunService: it represents operations similar to creating a VM, checking out a repository, etc. + * + * @generated from service agent.v1.LifecycleService + */ +export const LifecycleService: GenService<{ + /** + * Resets a long-lived worker + * + * @generated from rpc agent.v1.LifecycleService.ResetInstance + */ + resetInstance: { + methodKind: "unary"; + input: typeof EmptySchema; + output: typeof EmptySchema; + }; + /** + * Asks worker to exit(0) so that a new worker can take his place + * + * @generated from rpc agent.v1.LifecycleService.RenewInstance + */ + renewInstance: { + methodKind: "unary"; + input: typeof EmptySchema; + output: typeof EmptySchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_agent, 4); diff --git a/scripts/boost-proof.ps1 b/scripts/boost-proof.ps1 new file mode 100644 index 00000000..3a17756b --- /dev/null +++ b/scripts/boost-proof.ps1 @@ -0,0 +1,45 @@ +# Preuve v2 : la BONNE architecture (recherche cible -> petit contexte -> reponse juste) +$ErrorActionPreference = "Stop" +$target = "C:\dev\sinew\src\components\SettingsPane.tsx" +$question = "Where is the semantic embeddings toggle defined, what is its localStorage key, and which IPC function does it call?" + +$lines = Get-Content -Path $target +$rawTokens = [math]::Round(($lines -join "`n").Length / 4) + +# Etape 1 : RECHERCHE CIBLEE (ce que fait l'index/grep) -> ne garde que les lignes pertinentes + contexte +$pattern = "semantic|embeddings|setSemanticEmbeddings|sinew\.semantic" +$hits = @() +for ($i = 0; $i -lt $lines.Count; $i++) { + if ($lines[$i] -match $pattern) { + $start = [math]::Max(0, $i - 2); $end = [math]::Min($lines.Count - 1, $i + 2) + $hits += ($start..$end) + } +} +$hits = $hits | Sort-Object -Unique +$snippet = ($hits | ForEach-Object { "{0}: {1}" -f ($_ + 1), $lines[$_] }) -join "`n" +$snipTokens = [math]::Round($snippet.Length / 4) + +# Etape 2 : le distillateur local resume CE bout borne (et non tout le fichier) +$prompt = @" +Answer this question using ONLY the snippet. Be terse, max 60 words, give exact key and IPC name. +Question: $question + +SNIPPET: +$snippet +"@ +$body = @{ model="qwen2.5:3b"; prompt=$prompt; stream=$false; keep_alive=-1; options=@{ num_ctx=8192; temperature=0 } } | ConvertTo-Json -Depth 6 +$sw = [System.Diagnostics.Stopwatch]::StartNew() +$resp = Invoke-RestMethod -Uri "http://127.0.0.1:11434/api/generate" -Method Post -Body $body -ContentType "application/json" -TimeoutSec 300 +$sw.Stop() +$ans = $resp.response.Trim() + +Write-Host "=================== PREUVE BOOST LOCAL v2 ===================" +Write-Host ("Question : " + $question) +Write-Host "------------------------------------------------------------" +Write-Host ("SANS boost : lire tout SettingsPane.tsx = {0} jetons" -f $rawTokens) +Write-Host ("AVEC boost : recherche ciblee -> {0} lignes = {1} jetons" -f $hits.Count, $snipTokens) +Write-Host ("ECONOMIE : {0} %" -f [math]::Round((1-($snipTokens/$rawTokens))*100,1)) +Write-Host ("Distillation locale (3B) en {0}s, reponse juste :" -f [math]::Round($sw.Elapsed.TotalSeconds,1)) +Write-Host "------------------------------------------------------------" +Write-Host $ans +Write-Host "============================================================" diff --git a/scripts/check.ps1 b/scripts/check.ps1 new file mode 100644 index 00000000..1cb5d533 --- /dev/null +++ b/scripts/check.ps1 @@ -0,0 +1,60 @@ +$ErrorActionPreference = "Stop" + +function Invoke-CheckStep { + param( + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][scriptblock]$Command + ) + + Write-Host "" + Write-Host "=== $Name ===" -ForegroundColor Cyan + & $Command + if ($LASTEXITCODE -ne 0) { + throw "$Name a échoué avec le code $LASTEXITCODE" + } +} + +function Test-CommandAvailable { + param([Parameter(Mandatory = $true)][string]$Command) + $null -ne (Get-Command $Command -ErrorAction SilentlyContinue) +} + +if (-not (Test-CommandAvailable "npm")) { throw "npm est introuvable." } +if (-not (Test-CommandAvailable "cargo")) { throw "cargo est introuvable." } + +Invoke-CheckStep "Build frontend" { npm run build } +Invoke-CheckStep "Rust check" { cargo check --workspace --all-targets } + +Write-Host "" +Write-Host "=== Rust clippy ===" -ForegroundColor Cyan +cargo clippy --version | Out-Host +if ($LASTEXITCODE -ne 0) { + throw "cargo clippy est introuvable. Lancez : rustup component add clippy" +} + +if ($env:SINEW_STRICT_CLIPPY -eq "1") { + cargo clippy --workspace --all-targets -- -D warnings +} else { + cargo clippy --workspace --all-targets +} +if ($LASTEXITCODE -ne 0) { + throw "Rust clippy a échoué avec le code $LASTEXITCODE" +} + +Write-Host "" +Write-Host "Les tests live externes marqués ignorés restent lancés par leurs scripts dédiés." -ForegroundColor DarkGray +Invoke-CheckStep "Tests Rust locaux" { cargo test --workspace --no-fail-fast } +Invoke-CheckStep "Audit npm racine" { npm audit --omit=dev } +Invoke-CheckStep "Audit npm Chrome bridge" { npm --prefix sinew-chrome-bridge audit --omit=dev } +Invoke-CheckStep "Audit npm Agent bridge" { npm --prefix scripts/agent-bridge audit --omit=dev } + +if (Test-CommandAvailable "cargo-audit") { + Invoke-CheckStep "Audit Rust" { cargo audit } +} else { + Write-Host "" + Write-Host "=== Audit Rust ===" -ForegroundColor Yellow + Write-Host "cargo-audit est absent : étape ignorée. Pour l'activer : cargo install cargo-audit --locked" +} + +Write-Host "" +Write-Host "Tous les contrôles disponibles sont passés." -ForegroundColor Green diff --git a/scripts/compil.ps1 b/scripts/compil.ps1 new file mode 100644 index 00000000..f1a092e9 --- /dev/null +++ b/scripts/compil.ps1 @@ -0,0 +1,60 @@ +# compil.ps1 - Compiles only the NSIS (.exe) bundle and copies it to OneDrive Desktop + +Write-Host "=== 1. Lancement de la compilation Tauri (NSIS uniquement) ===" -ForegroundColor Cyan +npx tauri build -b nsis +if ($LASTEXITCODE -ne 0) { + Write-Error "La compilation Tauri a echoue." + Exit $LASTEXITCODE +} + +Write-Host "=== 2. Recherche de l'installateur compile ===" -ForegroundColor Cyan +$searchPaths = @( + "C:\Users\julie\AppData\Local\Temp\sinew-cargo-target\release\bundle\nsis", + "target\release\bundle\nsis", + "src-tauri\target\release\bundle\nsis", + (Join-Path $PSScriptRoot "..\target\release\bundle\nsis"), + (Join-Path $PSScriptRoot "..\src-tauri\target\release\bundle\nsis") +) + +$nsisDir = $null +foreach ($path in $searchPaths) { + if ($path -and (Test-Path $path)) { + $nsisDir = $path + Write-Host "Dossier d'installateurs trouve : $nsisDir" -ForegroundColor Green + break + } +} + +if (-not $nsisDir) { + Write-Error "Impossible de trouver le dossier de bundle NSIS dans les chemins de recherche." + Exit 1 +} + +$exeFiles = Get-ChildItem -Path $nsisDir -Filter "*.exe" +if ($exeFiles.Count -eq 0) { + Write-Error "Aucun fichier .exe n'a ete trouve dans $nsisDir" + Exit 1 +} + +# Get the most recent exe or the first one +$exeFile = $exeFiles | Sort-Object LastWriteTime -Descending | Select-Object -First 1 +Write-Host "Trouve : $($exeFile.FullName) (Modifie le : $($exeFile.LastWriteTime))" -ForegroundColor Green + +Write-Host "=== 3. Copie vers le Bureau OneDrive ===" -ForegroundColor Cyan +$desktopPath = [Environment]::GetFolderPath([Environment+SpecialFolder]::Desktop) +if (-not $desktopPath) { + $desktopPath = Join-Path $env:USERPROFILE "OneDrive\Bureau" +} + +if (-not (Test-Path $desktopPath)) { + $desktopPath = Join-Path $env:USERPROFILE "Desktop" +} + +Write-Host "Destination : $desktopPath" -ForegroundColor Green + +$destFile = Join-Path $desktopPath $exeFile.Name +Copy-Item -Path $exeFile.FullName -Destination $destFile -Force + +Write-Host "=== Succes ! ===" -ForegroundColor Green +Write-Host "L'installateur a ete copie avec succes sur le bureau :" -ForegroundColor Green +Write-Host $destFile -ForegroundColor Yellow diff --git a/scripts/export-agent-descriptor.mjs b/scripts/export-agent-descriptor.mjs new file mode 100644 index 00000000..28a56235 --- /dev/null +++ b/scripts/export-agent-descriptor.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node +/** + * Export agent.v1 FileDescriptorSet embedded in vendor/agent_pb.ts for prost-build. + * Requires: cd scripts/agent-bridge && npm ci + * Run: node scripts/export-agent-descriptor.mjs + */ +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const bridgeDir = path.join(__dirname, "agent-bridge"); +const vendorPath = path.join(bridgeDir, "vendor", "agent_pb.ts"); +const outDir = path.join(__dirname, "..", "crates", "sinew-cursor", "proto"); +const outPath = path.join(outDir, "agent.fds"); + +const source = await readFile(vendorPath, "utf8"); +const match = source.match(/fileDesc\(\s*\n?\s*"([A-Za-z0-9+/=]+)"/); +if (!match) { + console.error(`Could not find fileDesc(...) payload in ${vendorPath}`); + process.exit(1); +} + +const bytes = Buffer.from(match[1], "base64"); +await mkdir(outDir, { recursive: true }); +await writeFile(outPath, bytes); +console.log(`Wrote ${outPath} (${bytes.length} bytes)`); diff --git a/scripts/mitm/README.md b/scripts/mitm/README.md new file mode 100644 index 00000000..0c6e0c24 --- /dev/null +++ b/scripts/mitm/README.md @@ -0,0 +1,52 @@ +# Capture MITM (Cursor Composer) + +Scripts pour lancer **mitmweb** et eviter `ERR_CONNECTION_REFUSED` sur http://127.0.0.1:8081 (UI arretee). + +## Installation (une fois) + +```powershell +cd C:\Dev\Sinew +pwsh -File scripts\mitm\install-mitmproxy.ps1 +``` + +Manuel si besoin : + +- `winget install mitmproxy.mitmproxy -e` +- ou `python -m pip install --upgrade mitmproxy` + +## Demarrage + +1. **Verifier** : `pwsh -File scripts\mitm\check-mitm.ps1` +2. **Lancer mitmweb** (fenetre dediee, laisser ouverte) : `pwsh -File scripts\mitm\start-mitmweb.ps1` +3. Ouvrir l'UI : http://127.0.0.1:8081/ + +## Certificat HTTPS (obligatoire) + +1. Dans mitmweb : **Options → Install mitmproxy CA** (Windows). +2. Ou importer `%USERPROFILE%\.mitmproxy\mitmproxy-ca-cert.cer` dans **Autorites de certification racines de confiance** (certmgr / certlm). +3. Redemarrer Cursor apres installation du certificat. + +## Proxy systeme Windows + +1. Parametres → Reseau et Internet → Proxy → **Utiliser un serveur proxy** : ON +2. Adresse : `127.0.0.1`, Port : `8080` +3. **Apres la capture** : desactiver le proxy (sinon plus de reseau sans mitmweb). + +## Capturer Composer + +1. mitmweb actif + certificat + proxy systeme ON +2. Cursor IDE : envoyer un message **Composer / Agent** +3. Dans mitmweb, filtrer `api2.cursor.sh`, chemins `agent.v1`, `RunSSE`, `Idempotent`, etc. + +Voir aussi `scripts\CAPTURE-MITM.md` pour l'analyse des requetes. + +## Depannage + +| Symptome | Cause probable | Action | +|----------|----------------|--------| +| `ERR_CONNECTION_REFUSED` sur :8081 | mitmweb non lance | `start-mitmweb.ps1` | +| Cursor sans reseau | Proxy ON sans mitmweb | Desactiver proxy ou relancer mitmweb | +| HTTPS echoue dans Cursor | Certificat CA non installe | Installer CA mitmproxy | +| Port 8080/8081 deja pris | Autre processus | `check-mitm.ps1`, tuer l'ancien mitmweb | +| `mitmweb` introuvable | Non installe | `install-mitmproxy.ps1` | + diff --git a/scripts/mitm/check-mitm.ps1 b/scripts/mitm/check-mitm.ps1 new file mode 100644 index 00000000..54b85ea6 --- /dev/null +++ b/scripts/mitm/check-mitm.ps1 @@ -0,0 +1,40 @@ +#Requires -Version 5.1 +<# Verifie que mitmweb ecoute sur 8080/8081 et que l'UI HTTP repond. #> +$ErrorActionPreference = 'Continue' +$ProxyPort = 8080 +$WebPort = 8081 + +function Get-Listeners([int]$Port) { + Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue | + Select-Object LocalAddress, LocalPort, OwningProcess +} + +Write-Host "=== mitmproxy / mitmweb binaire ===" -ForegroundColor Cyan +if (Get-Command mitmweb -ErrorAction SilentlyContinue) { + Write-Host "mitmweb: $( (Get-Command mitmweb).Source )" + mitmweb --version +} elseif (Get-Command python -ErrorAction SilentlyContinue) { + python -m mitmweb --version 2>$null + if ($LASTEXITCODE -eq 0) { Write-Host "mitmweb via: python -m mitmweb" } + else { Write-Host "mitmweb introuvable (pip/winget: scripts\mitm\install-mitmproxy.ps1)" -ForegroundColor Yellow } +} else { + Write-Host "mitmweb introuvable" -ForegroundColor Yellow +} + +Write-Host "`n=== Ecoute TCP $ProxyPort (proxy) / $WebPort (UI) ===" -ForegroundColor Cyan +$l8080 = Get-Listeners $ProxyPort +$l8081 = Get-Listeners $WebPort +if ($l8080) { $l8080 | Format-Table -AutoSize } else { Write-Host "Port $ProxyPort : RIEN (proxy mitm inactif)" -ForegroundColor Red } +if ($l8081) { $l8081 | Format-Table -AutoSize } else { Write-Host "Port $WebPort : RIEN (mitmweb UI inactive -> ERR_CONNECTION_REFUSED)" -ForegroundColor Red } + +Write-Host "`n=== HTTP GET http://127.0.0.1:$WebPort/ ===" -ForegroundColor Cyan +try { + $r = Invoke-WebRequest -Uri "http://127.0.0.1:$WebPort/" -UseBasicParsing -TimeoutSec 5 + Write-Host "OK HTTP $($r.StatusCode)" -ForegroundColor Green +} catch { + Write-Host "Echec: $($_.Exception.Message)" -ForegroundColor Red + Write-Host "Lancez: pwsh -File scripts\mitm\start-mitmweb.ps1" -ForegroundColor Yellow +} + +$ok = ($null -ne $l8080) -and ($null -ne $l8081) +if (-not $ok) { exit 1 } else { exit 0 } diff --git a/scripts/mitm/install-mitmproxy.ps1 b/scripts/mitm/install-mitmproxy.ps1 new file mode 100644 index 00000000..5e0faba5 --- /dev/null +++ b/scripts/mitm/install-mitmproxy.ps1 @@ -0,0 +1,30 @@ +#Requires -Version 5.1 +<# Installe mitmproxy / mitmweb si absent (Windows). #> +$ErrorActionPreference = 'Stop' + +function Test-MitmWeb { + if (Get-Command mitmweb -ErrorAction SilentlyContinue) { return $true } + if (Get-Command python -ErrorAction SilentlyContinue) { + & python -m mitmweb --version 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { return $true } + } + return $false +} + +if (Test-MitmWeb) { + Write-Host 'mitmweb deja disponible.' -ForegroundColor Green + if (Get-Command mitmweb -ErrorAction SilentlyContinue) { mitmweb --version } else { python -m mitmweb --version } + exit 0 +} + +Write-Host 'mitmweb introuvable. Installation...' -ForegroundColor Yellow +if (Get-Command winget -ErrorAction SilentlyContinue) { + winget install mitmproxy.mitmproxy -e --accept-package-agreements --accept-source-agreements + if (($LASTEXITCODE -eq 0) -and (Test-MitmWeb)) { Write-Host 'Installe via winget.' -ForegroundColor Green; exit 0 } +} +if (Get-Command python -ErrorAction SilentlyContinue) { + python -m pip install --upgrade mitmproxy + if (($LASTEXITCODE -eq 0) -and (Test-MitmWeb)) { Write-Host 'Installe via pip.' -ForegroundColor Green; exit 0 } +} +Write-Host 'Echec. Manuel: winget install mitmproxy.mitmproxy -e OU python -m pip install --upgrade mitmproxy' -ForegroundColor Red +exit 1 diff --git a/scripts/mitm/start-mitmweb.ps1 b/scripts/mitm/start-mitmweb.ps1 new file mode 100644 index 00000000..79a946f8 --- /dev/null +++ b/scripts/mitm/start-mitmweb.ps1 @@ -0,0 +1,65 @@ +#Requires -Version 5.1 +<# Demarre mitmweb (proxy 8080, UI 8081). Garde la fenetre ouverte en cas d'erreur. #> +$ErrorActionPreference = 'Stop' +$ProxyPort = 8080 +$WebPort = 8081 + +function Test-PortInUse([int]$Port) { + $c = Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue + return [bool]$c +} + +function Resolve-MitmWeb { + if (Get-Command mitmweb -ErrorAction SilentlyContinue) { + return @{ File = (Get-Command mitmweb).Source; Args = @() } + } + if (Get-Command python -ErrorAction SilentlyContinue) { + & python -m mitmweb --version 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { + return @{ File = (Get-Command python).Source; Args = @('-m', 'mitmweb') } + } + } + return $null +} + +function Wait-Exit { + Write-Host "`nAppuyez sur Entree pour fermer..." -ForegroundColor DarkGray + Read-Host | Out-Null +} + +Write-Host "=== Demarrage mitmweb ===" -ForegroundColor Cyan +Write-Host "Proxy systeme Windows : 127.0.0.1:$ProxyPort" +Write-Host "Interface web : http://127.0.0.1:$WebPort/" +Write-Host "Certificat CA : %USERPROFILE%\.mitmproxy\mitmproxy-ca-cert.cer" +Write-Host "(Installer le certificat dans Autorites de certification racines de confiance)" + +$bin = Resolve-MitmWeb +if (-not $bin) { + Write-Host "mitmweb introuvable. Executez: pwsh -File scripts\mitm\install-mitmproxy.ps1" -ForegroundColor Red + Wait-Exit + exit 1 +} + +foreach ($port in @($ProxyPort, $WebPort)) { + if (Test-PortInUse $port) { + Write-Host "Port $port deja utilise (mitmweb deja lance ?)." -ForegroundColor Yellow + Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue | Format-Table LocalAddress, LocalPort, OwningProcess + } +} + +$mitmArgs = @('--listen-port', "$ProxyPort", '--web-port', "$WebPort") + $bin.Args +Write-Host "Commande: $($bin.File) $($mitmArgs -join ' ')" + +try { + & $bin.File @mitmArgs + $code = $LASTEXITCODE + if ($code -and $code -ne 0) { + Write-Host "mitmweb a quitte avec le code $code" -ForegroundColor Red + Wait-Exit + exit $code + } +} catch { + Write-Host "Erreur: $($_.Exception.Message)" -ForegroundColor Red + Wait-Exit + exit 1 +} diff --git a/scripts/prepare-agent-bridge.mjs b/scripts/prepare-agent-bridge.mjs new file mode 100644 index 00000000..1e9bb6b7 --- /dev/null +++ b/scripts/prepare-agent-bridge.mjs @@ -0,0 +1,75 @@ +import { execFile as execFileCallback } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execFile = promisify(execFileCallback); + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const bridgeDir = path.join(scriptDir, "agent-bridge"); +const tsxBin = path.join( + bridgeDir, + "node_modules", + ".bin", + process.platform === "win32" ? "tsx.cmd" : "tsx", +); +const vendorProto = path.join(bridgeDir, "vendor", "agent_pb.ts"); + +await main(); + +async function main() { + await ensureAgentBridge(); + await ensureChromeBridgeDependencies(); +} + +async function ensureAgentBridge() { + if (!existsSync(path.join(bridgeDir, "package.json"))) { + throw new Error(`agent-bridge package.json missing: ${bridgeDir}`); + } + if (existsSync(tsxBin) && existsSync(vendorProto)) { + console.log(`agent-bridge deps already present: ${path.relative(scriptDir, bridgeDir)}`); + } else { + const npm = process.platform === "win32" ? "npm.cmd" : "npm"; + console.log(`Installing agent-bridge dependencies in ${bridgeDir}...`); + await execFile(npm, ["ci", "--omit=dev"], { + cwd: bridgeDir, + env: process.env, + shell: true, + windowsHide: true, + }); + if (!existsSync(tsxBin)) { + throw new Error(`tsx missing after npm ci: ${tsxBin}`); + } + } + + const exportDescriptor = path.join(scriptDir, "export-agent-descriptor.mjs"); + const fdsPath = path.join(scriptDir, "..", "crates", "sinew-cursor", "proto", "agent.fds"); + if (existsSync(vendorProto) && (!existsSync(fdsPath) || process.env.FORCE_AGENT_FDS === "1")) { + const node = process.execPath; + await execFile(node, [exportDescriptor], { cwd: scriptDir, windowsHide: true }); + } + console.log("agent-bridge ready for bundle/runtime."); +} + +async function ensureChromeBridgeDependencies() { + const chromeBridgeDir = path.join(scriptDir, "..", "sinew-chrome-bridge"); + if (!existsSync(path.join(chromeBridgeDir, "package.json"))) { + console.log("Chrome bridge directory or package.json missing, skipping."); + return; + } + const wsDir = path.join(chromeBridgeDir, "node_modules", "ws"); + if (existsSync(wsDir)) { + console.log("Chrome bridge dependencies already present."); + return; + } + const npm = process.platform === "win32" ? "npm.cmd" : "npm"; + console.log(`Installing Chrome bridge dependencies in ${chromeBridgeDir}...`); + await execFile(npm, ["install", "--omit=dev"], { + cwd: chromeBridgeDir, + env: process.env, + shell: true, + windowsHide: true, + }); + console.log("Chrome bridge ready."); +} diff --git a/scripts/prepare-sidecars.mjs b/scripts/prepare-sidecars.mjs index e68966e0..12e4a86c 100644 --- a/scripts/prepare-sidecars.mjs +++ b/scripts/prepare-sidecars.mjs @@ -144,7 +144,12 @@ function sidecarOutputPath(target) { } async function download(url, destination) { - const response = await fetch(url, { redirect: "follow" }); + const response = await fetch(url, { + redirect: "follow", + headers: { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + } + }); if (!response.ok) { throw new Error(`failed to download ${url}: ${response.status} ${response.statusText}`); } diff --git a/scripts/sinew_composer_e2e.py b/scripts/sinew_composer_e2e.py new file mode 100644 index 00000000..87263869 --- /dev/null +++ b/scripts/sinew_composer_e2e.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""E2E checks mirroring Sinew Composer OAuth + API health (no UI).""" +from __future__ import annotations + +import json +import os +import sys +import uuid +from pathlib import Path + +try: + import httpx +except ImportError: + print("pip install httpx") + sys.exit(1) + +API2 = "https://api2.cursor.sh" +AUTH_PATH = Path(os.environ.get("LOCALAPPDATA", "")) / "Hyrak" / "sinew" / "data" / "cursor-composer-auth.json" +RUN_URL = f"{API2}/agent.v1.AgentService/Run" + + +def load_token() -> str: + if not AUTH_PATH.is_file(): + raise SystemExit(f"auth manquant: {AUTH_PATH}") + data = json.loads(AUTH_PATH.read_text(encoding="utf-8")) + token = (data.get("tokens") or {}).get("accessToken") or "" + if not token.strip(): + raise SystemExit("accessToken vide") + return token.strip() + + +def token_machine_id(token: str) -> str: + import hashlib + + return hashlib.sha256(f"{token}machineId".encode()).hexdigest() + + +def token_client_key(token: str) -> str: + import hashlib + + return hashlib.sha256(token.encode()).hexdigest() + + +def checksum(machine_id: str) -> str: + import hashlib + import time + + millis = int(time.time() * 1000) + bucket = millis // 1_000_000 + seed = bytearray( + [ + (bucket >> 40) & 0xFF, + (bucket >> 32) & 0xFF, + (bucket >> 24) & 0xFF, + (bucket >> 16) & 0xFF, + (bucket >> 8) & 0xFF, + bucket & 0xFF, + ] + ) + seed.extend(machine_id.encode()) + digest = hashlib.sha256(bytes(seed)).hexdigest() + return f"{digest[:32]}/{digest[32:]}01" + + +def cli_headers(token: str) -> dict[str, str]: + mid = token_machine_id(token) + return { + "authorization": f"Bearer {token}", + "x-client-key": token_client_key(token), + "x-cursor-checksum": checksum(mid), + "x-cursor-client-type": "cli", + "x-ghost-mode": "true", + "x-cursor-client-version": "2025.5.0", + "content-type": "application/connect+proto", + "connect-protocol-version": "1", + "te": "trailers", + } + + +def get_email(token: str) -> tuple[int, str]: + url = f"{API2}/aiserver.v1.AuthService/GetEmail" + headers = cli_headers(token) + headers["content-type"] = "application/json" + with httpx.Client(http2=True, timeout=30.0) as client: + r = client.post(url, headers=headers, content=b"{}") + return r.status_code, r.text[:200] + + +def main() -> int: + print("=== Sinew Composer E2E (Python) ===") + print(f"auth: {AUTH_PATH}") + token = load_token() + print(f"token: {len(token)} chars") + + status, body = get_email(token) + print(f"GetEmail: HTTP {status} {body!r}") + if status != 200: + print("FAIL: OAuth refusé — reconnectez dans Sinew Réglages → Fournisseurs") + return 1 + + print("OK: session OAuth valide") + print() + print("Pour le stream agent.v1 complet, lancez:") + print(" cd C:\\Dev\\sinew") + print(" $env:SINEW_CURSOR_LIVE_ASSERT='1'") + print(" cargo test -p sinew-cursor test_live_sinew_composer -- --ignored --nocapture") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sinew-chrome-bridge/background.js b/sinew-chrome-bridge/background.js new file mode 100644 index 00000000..8532544f --- /dev/null +++ b/sinew-chrome-bridge/background.js @@ -0,0 +1,1142 @@ +// 🧬 Sinew Chrome Bridge — Service Worker (Manifest V3) +// Upgraded to SOTA Sinew-grade sequential execution queue and automatic biological cursor physics injection. +// Pure Native Messaging connection (Optimized for Sinew Native Messenger). + +let nativePort = null; +let reconnectTimer = null; +let lastNativeError = null; +let lastConnectedAt = null; +let bridgeSecret = ''; + +// Registry of active attached debuggers +const attachedTabs = new Set(); +const ALLOW_DEBUGGER_ATTACH = true; +let cursorMoveSeq = 0; +const lastCursorPositionByTabId = new Map(); + +function normalizeCursorOptions(options = {}) { + const mode = ['visible', 'hidden'].includes(String(options.mode || 'visible').toLowerCase()) + ? String(options.mode || 'visible').toLowerCase() + : 'visible'; + const speed = ['slow', 'normal', 'fast'].includes(String(options.speed || 'normal').toLowerCase()) + ? String(options.speed || 'normal').toLowerCase() + : 'normal'; + const timing = { + slow: { steps: 58, minDelay: 18, jitter: 24, pause: 220 }, + normal: { steps: 38, minDelay: 12, jitter: 18, pause: 140 }, + fast: { steps: 22, minDelay: 5, jitter: 10, pause: 60 }, + }[speed]; + return { mode, speed, timing }; +} + +// Promise-based locking mechanism for race-free sequential execution +let lifecycleQueue = Promise.resolve(); + +function runLocked(fn) { + const next = lifecycleQueue.then(() => fn()); + lifecycleQueue = next.catch((err) => { + console.error("⚠️ [Bridge Queue Error]:", err); + }); + return next; +} + +// Utility to check if a URL is a restricted system page +function isSystemTab(tab) { + const u = tab.url || ""; + return u.startsWith("chrome://") || + u.startsWith("chrome-extension://") || + u.startsWith("edge://") || + u.startsWith("view-source:"); +} + +// Reusable central message sender +function sendMsg(msg) { + if (nativePort) { + try { + nativePort.postMessage(msg); + } catch (e) { + console.error("🧬 [Bridge background] Failed to send via Native Port:", e); + } + } +} + +function isBridgeConnected() { + return !!nativePort; +} + +// Reusable response sender +function sendResponse(id, data) { + sendMsg({ type: "response", id, data }); +} + +function sendTabMessage(tabId, message, timeoutMs = 12000) { + return new Promise((resolve) => { + let settled = false; + const done = (value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(value); + }; + const timer = setTimeout(() => { + done({ success: false, ok: false, error: `Timeout waiting for content script response: ${message?.type || 'unknown'}` }); + }, Math.max(1000, timeoutMs)); + + try { + chrome.tabs.sendMessage(tabId, message, (response) => { + if (chrome.runtime.lastError) { + done({ success: false, ok: false, error: chrome.runtime.lastError.message }); + } else { + done(response || { success: false, ok: false, error: "No target response" }); + } + }); + } catch (err) { + done({ success: false, ok: false, error: err.message }); + } + }); +} + +function hasTypingIntent(task) { + const text = String(task || '').toLowerCase(); + return /\b(tape|type|saisis|saisir|ecris|écris|ecrire|écrire|recherche|chercher|search)\b/i.test(text); +} + +function hasNavigationIntent(task) { + const text = String(task || '').toLowerCase(); + return /\b(ouvre|ouvrir|open|navigue|navigate|navigation|visite|visit|rends-toi)\b/i.test(text) + || /\b(va|aller|go)\s+(sur|to)\b/i.test(text); +} + +function isGoogleSearchTask(task) { + const text = String(task || '').toLowerCase(); + const mentionsGoogle = /\bgoogle(?:\.[a-z]{2,})?\b/i.test(text); + const mentionsSearch = /\b(recherche|chercher|search|champ|requ[êe]te)\b/i.test(text); + return mentionsGoogle && (mentionsSearch || hasTypingIntent(task) || /\bjulienpiron(?:\.fr)?\b/i.test(text)); +} + +function cleanSearchQuery(value) { + return String(value || '') + .replace(/^[\s`"'“”‘’]+|[\s`"'“”‘’]+$/g, '') + .replace(/^(exactement|exact|precisement|précisément)\s+/i, '') + .replace(/[,.!?;:]+$/g, '') + .trim(); +} + +function extractSearchQuery(task) { + const original = String(task || ''); + const quoted = original.match(/(?:tape|écris|ecris|saisis|type|recherche(?:\s+sur\s+google)?|search)\s+(?:exactement\s+)?[`"“'‘]([^`"”'’]+)[`"”'’]/i); + if (quoted && quoted[1]) return cleanSearchQuery(quoted[1]); + + const domain = original.match(/\b[a-z0-9-]+(?:\.[a-z0-9-]+)+(?:\/[^\s,;)]*)?\b/i); + if (domain && /julienpiron|google|recherche|search|tape|écris|ecris|saisis|type/i.test(original)) return cleanSearchQuery(domain[0]); + + const generic = original.match(/(?:tape|écris|ecris|saisis|type|recherche(?:\s+sur\s+google)?|search)\s+(?:exactement\s+)?(.+?)(?:\s+(?:puis|et)\b|[,;]\s*(?:valide|valides|appuie|clique|clic|click)|$)/i); + return cleanSearchQuery(generic && generic[1] ? generic[1] : ''); +} + +function extractTaskUrl(task) { + const explicit = String(task || '').match(/https?:\/\/[^\s)]+/i); + if (explicit) return explicit[0].replace(/[)\],.;!?]+$/g, ''); + const domain = String(task || '').match(/\b[a-z0-9-]+(?:\.[a-z0-9-]+)+(\/[^\s)]*)?/i); + return domain ? `https://${domain[0].replace(/[)\],.;!?]+$/g, '')}` : null; +} + +function shouldAutoNavigateTask(task) { + if (isGoogleSearchTask(task)) return false; + return !!extractTaskUrl(task) && hasNavigationIntent(task); +} + +function waitForTabReady(tabId, timeoutMs = 8000) { + return new Promise((resolve) => { + let settled = false; + let pollTimer = null; + + const done = (tab = null) => { + if (settled) return; + settled = true; + clearTimeout(timeoutTimer); + if (pollTimer) clearInterval(pollTimer); + chrome.tabs.onUpdated.removeListener(onUpdated); + resolve(tab); + }; + + const isReady = (tab) => tab && tab.status === 'complete' && !isSystemTab(tab); + + const check = () => { + chrome.tabs.get(tabId, (tab) => { + if (chrome.runtime.lastError) return; + if (isReady(tab)) done(tab); + }); + }; + + const onUpdated = (updatedTabId, changeInfo, tab) => { + if (Number(updatedTabId) !== Number(tabId)) return; + if (changeInfo.status === 'complete' || isReady(tab)) done(tab); + }; + + const timeoutTimer = setTimeout(() => { + chrome.tabs.get(tabId, (tab) => done(chrome.runtime.lastError ? null : tab)); + }, Math.max(1000, timeoutMs)); + + chrome.tabs.onUpdated.addListener(onUpdated); + pollTimer = setInterval(check, 150); + check(); + }); +} + +function clampCursorPoint(point, viewport = {}) { + const width = Number.isFinite(viewport.width) ? viewport.width : 1280; + const height = Number.isFinite(viewport.height) ? viewport.height : 720; + return { + x: Math.round(Math.min(Math.max(Number(point.x) || 24, 16), Math.max(16, width - 16))), + y: Math.round(Math.min(Math.max(Number(point.y) || 24, 16), Math.max(16, height - 16))) + }; +} + +function inferViewport(target = {}) { + const rect = target.rect || {}; + const width = target.viewport?.width || target.viewportWidth || Math.max(1280, (Number(target.x) || 0) + (Number(rect.width) || 0) + 80); + const height = target.viewport?.height || target.viewportHeight || Math.max(720, (Number(target.y) || 0) + (Number(rect.height) || 0) + 80); + return { width, height }; +} + +function randomStartNearTarget(target) { + const viewport = inferViewport(target); + const side = Math.floor(Math.random() * 4); + const margin = 24 + Math.random() * 96; + const edgePoint = [ + { x: margin, y: viewport.height * (0.12 + Math.random() * 0.76) }, + { x: viewport.width - margin, y: viewport.height * (0.12 + Math.random() * 0.76) }, + { x: viewport.width * (0.12 + Math.random() * 0.76), y: margin }, + { x: viewport.width * (0.12 + Math.random() * 0.76), y: viewport.height - margin } + ][side]; + + const targetVicinity = { + x: (Number(target.x) || viewport.width / 2) + (Math.random() - 0.5) * (260 + Math.random() * 360), + y: (Number(target.y) || viewport.height / 2) + (Math.random() - 0.5) * (180 + Math.random() * 300) + }; + + return clampCursorPoint(Math.random() < 0.55 ? targetVicinity : edgePoint, viewport); +} + +function getHumanCursorStart(tabId, target) { + const viewport = inferViewport(target); + const saved = lastCursorPositionByTabId.get(String(tabId)); + if (saved && Number.isFinite(saved.x) && Number.isFinite(saved.y)) { + return clampCursorPoint(saved, viewport); + } + return randomStartNearTarget(target); +} + +function rememberHumanCursor(tabId, point, target = {}) { + if (!Number.isFinite(point?.x) || !Number.isFinite(point?.y)) return; + lastCursorPositionByTabId.set(String(tabId), clampCursorPoint(point, inferViewport(target))); +} + +function connect() { + if (nativePort) return; + try { + console.log("🧬 [Bridge background] Connecting to Native Host com.sinew.chrome_bridge..."); + const port = chrome.runtime.connectNative("com.sinew.chrome_bridge"); + nativePort = port; + + port.onMessage.addListener((msg) => { + handleMessage(msg); + }); + + port.onDisconnect.addListener(() => { + const err = chrome.runtime.lastError; + lastNativeError = err ? err.message : "Native host disconnected without details"; + console.warn("🧬 [Bridge background] Native Host disconnected:", lastNativeError); + nativePort = null; + updateStorageState(); + + if (reconnectTimer) clearTimeout(reconnectTimer); + reconnectTimer = setTimeout(connect, 3000); + }); + + lastNativeError = null; + lastConnectedAt = Date.now(); + // Native connection succeeded: register and sync + sendMsg({ type: "register", role: "extension" }); + reportOpenTabs(); + updateStorageState(); + } catch (e) { + lastNativeError = e.message; + console.warn("🧬 [Bridge background] Native Port crash on initialize:", e); + if (reconnectTimer) clearTimeout(reconnectTimer); + reconnectTimer = setTimeout(connect, 3000); + } +} + + +// Unified central command coordinator +async function handleMessage(msg) { + try { + if (msg.type === "pong") return; + if (msg.type === "init_secret") { + bridgeSecret = msg.token; + console.log("🧬 [Bridge background] Secure bridge secret initialized successfully!"); + return; + } + + const { id, command, params } = msg; + if (!command) return; + + console.log(`🧬 [Bridge] Command received: ${command} (id=${id})`, msg); + + switch (command) { + case "list_tabs": + reportOpenTabs(id); + break; + + case "navigate_tab": + runLocked(async () => { + return new Promise((resolve) => { + const tabId = parseInt(params.tabId); + chrome.tabs.update(tabId, { url: params.url || "about:blank", active: true }, (tab) => { + if (chrome.runtime.lastError) sendResponse(id, { success: false, error: chrome.runtime.lastError.message }); + else sendResponse(id, { success: true, tab: { id: tab.id, title: tab.title, url: tab.url, active: tab.active } }); + resolve(); + }); + }); + }); + break; + + case "detect_target": + runLocked(async () => { + const tabId = parseInt(params.tabId); + try { + await ensureCursorInjected(tabId); + const response = await sendTabMessage(tabId, { type: "RUN_SILENT_TASK", task: params.task || "" }, 12000); + sendResponse(id, response || { success: false, error: "No target response" }); + } catch (err) { + sendResponse(id, { success: false, error: err.message }); + } + }); + break; + + case "page_snapshot": + runLocked(async () => { + const tabId = parseInt(params.tabId); + try { + await ensureCursorInjected(tabId); + const response = await sendTabMessage(tabId, { type: "AGENT_PAGE_SNAPSHOT", limit: params.limit || 80 }, 12000); + sendResponse(id, response || { success: false, error: "No snapshot response" }); + } catch (err) { + sendResponse(id, { success: false, error: err.message }); + } + }); + break; + + case "evaluate": + runLocked(async () => { + const tabId = parseInt(params.tabId); + try { + const expression = String(params.expression || 'undefined'); + const injections = await chrome.scripting.executeScript({ + target: { tabId }, + args: [expression], + func: (source) => { + let value; + try { + value = (new Function(`return (${source});`))(); + } catch { + value = (new Function(`return (() => { ${source} })();`))(); + } + if (value && typeof value.then === 'function') return value; + if (value === undefined) return null; + return { __sinewValue: value }; + } + }); + const rawValue = injections && injections[0] ? injections[0].result : null; + const value = rawValue && typeof rawValue === 'object' && Object.prototype.hasOwnProperty.call(rawValue, '__sinewValue') ? rawValue.__sinewValue : rawValue; + sendResponse(id, { ok: true, success: true, value }); + } catch (err) { + sendResponse(id, { ok: false, success: false, error: err.message }); + } + }); + break; + + case "query_selector": + case "click_selector": + case "type_selector": + case "press_key": + case "select_option": + case "wait_selector": + runLocked(async () => { + const tabId = parseInt(params.tabId); + const typeByCommand = { + query_selector: "AGENT_QUERY_SELECTOR", + click_selector: "AGENT_CLICK_SELECTOR", + type_selector: "AGENT_TYPE_SELECTOR", + press_key: "AGENT_PRESS_KEY", + select_option: "AGENT_SELECT_OPTION", + wait_selector: "AGENT_WAIT_SELECTOR" + }; + try { + await ensureCursorInjected(tabId); + const response = await sendTabMessage(tabId, { type: typeByCommand[command], ...params }, Math.max(1000, Number(params.timeoutMs) || 12000)); + sendResponse(id, response || { success: false, error: "No structured action response" }); + } catch (err) { + sendResponse(id, { success: false, error: err.message }); + } + }); + break; + + case "human_click": + runLocked(async () => { + const tabId = parseInt(params.tabId); + try { + const performed = await performHumanCdpAction(tabId, params.detection || {}, "", normalizeCursorOptions(params.cursor || {})); + sendResponse(id, performed); + } catch (err) { + sendResponse(id, { success: false, error: err.message }); + } + }); + break; + + case "create_tab": + runLocked(async () => { + return new Promise((resolve) => { + chrome.tabs.create({ url: params.url || "about:blank" }, (tab) => { + sendResponse(id, { + success: true, + tab: { + id: tab.id, + title: tab.title, + url: tab.url, + active: tab.active + } + }); + resolve(); + }); + }); + }); + break; + + case "attach": + if (!ALLOW_DEBUGGER_ATTACH) { + sendResponse(id, { success: false, error: "chrome.debugger attach disabled to avoid Chrome debugging banner" }); + break; + } + const attachTabId = parseInt(params.tabId); + runLocked(async () => { + return new Promise((resolve) => { + // Safety guard: refuse to attach debugger to chrome:// system tabs + chrome.tabs.get(attachTabId, (tab) => { + if (chrome.runtime.lastError || !tab || isSystemTab(tab)) { + sendResponse(id, { success: false, error: "Cannot attach debugger to restricted system page" }); + resolve(); + return; + } + + if (attachedTabs.has(attachTabId)) { + sendResponse(id, { success: true, message: "Already attached" }); + resolve(); + return; + } + + chrome.debugger.attach({ tabId: attachTabId }, "1.3", () => { + if (chrome.runtime.lastError) { + const errMsg = chrome.runtime.lastError.message; + if (errMsg.includes("already attached") || errMsg.includes("Already attached")) { + attachedTabs.add(attachTabId); + sendResponse(id, { success: true }); + chrome.tabs.sendMessage(attachTabId, { type: "AGENT_STATUS_CHANGE", status: "active" }).catch(() => {}); + } else { + console.error("⚠️ [Bridge] Debugger attachment failed:", errMsg); + sendResponse(id, { success: false, error: errMsg }); + } + } else { + attachedTabs.add(attachTabId); + console.log(`🧬 [Bridge] Debugger attached to tab ${attachTabId}`); + sendResponse(id, { success: true }); + chrome.tabs.sendMessage(attachTabId, { type: "AGENT_STATUS_CHANGE", status: "active" }).catch(() => {}); + } + updateStorageState(); + resolve(); + }); + }); + }); + }); + break; + + case "detach": + const detachTabId = parseInt(params.tabId); + runLocked(async () => { + return new Promise((resolve) => { + chrome.tabs.sendMessage(detachTabId, { type: "AGENT_STATUS_CHANGE", status: "detached" }).catch(() => {}); + chrome.debugger.detach({ tabId: detachTabId }, () => { + attachedTabs.delete(detachTabId); + sendResponse(id, { success: true }); + updateStorageState(); + resolve(); + }); + }); + }); + break; + + case "detach_all": + runLocked(async () => { + const ids = Array.from(attachedTabs); + await Promise.all(ids.map(tabId => new Promise((resolve) => { + chrome.tabs.sendMessage(tabId, { type: "AGENT_STATUS_CHANGE", status: "detached" }).catch(() => {}); + chrome.debugger.detach({ tabId }, () => { + attachedTabs.delete(tabId); + resolve(); + }); + }))); + updateStorageState(); + sendResponse(id, { success: true, detached: ids.length }); + }); + break; + + case "cdp_command": + if (!ALLOW_DEBUGGER_ATTACH) { + sendResponse(id, { success: false, error: "CDP commands disabled to avoid Chrome debugging banner" }); + break; + } + const cdpTabId = parseInt(params.tabId); + const { method, cdpParams } = params; + + runLocked(async () => { + return new Promise((resolve) => { + chrome.tabs.get(cdpTabId, (tab) => { + if (chrome.runtime.lastError || !tab || isSystemTab(tab)) { + sendResponse(id, { success: false, error: "Restricted system tab" }); + resolve(); + return; + } + + // Synchronous auto-attachment layer + if (!attachedTabs.has(cdpTabId)) { + chrome.debugger.attach({ tabId: cdpTabId }, "1.3", () => { + if (chrome.runtime.lastError && + !chrome.runtime.lastError.message.includes("already attached") && + !chrome.runtime.lastError.message.includes("Already attached")) { + sendResponse(id, { success: false, error: "Auto-attach failed: " + chrome.runtime.lastError.message }); + resolve(); + } else { + attachedTabs.add(cdpTabId); + updateStorageState(); + chrome.tabs.sendMessage(cdpTabId, { type: "AGENT_STATUS_CHANGE", status: "active" }).catch(() => {}); + sendCDPCommand(id, cdpTabId, method, cdpParams); + resolve(); + } + }); + } else { + sendCDPCommand(id, cdpTabId, method, cdpParams); + resolve(); + } + }); + }); + }); + break; + + case "execute_silent_task": + const silentTabId = parseInt(params.tabId); + const { task: silentTask, cursor: silentCursor } = params; + const silentCursorOptions = normalizeCursorOptions(silentCursor || {}); + + runLocked(async () => { + return new Promise(async (resolve) => { + let urlToNavigate = null; + if (shouldAutoNavigateTask(silentTask)) { + urlToNavigate = extractTaskUrl(silentTask); + } + + if (urlToNavigate) { + console.log(`🧬 [Bridge] Silent navigating tab ${silentTabId} to ${urlToNavigate}`); + chrome.tabs.update(silentTabId, { url: urlToNavigate, active: true }, () => {}); + await waitForTabReady(silentTabId, 8000); + } + + const actionTasks = buildSilentActionTasks(silentTask); + + const runAction = (taskText) => new Promise((actionResolve) => { + ensureCursorInjected(silentTabId).then(async () => { + const response = await sendTabMessage(silentTabId, { type: "RUN_SILENT_TASK", task: taskText }, 12000); + if (!response || response.success === false || response.ok === false) { + actionResolve({ ...(response || { success: false, error: 'No target response' }), task: taskText }); + return; + } + try { + const performed = await performHumanCdpAction(silentTabId, response, taskText, silentCursorOptions); + actionResolve({ ...performed, task: taskText, target: response.target }); + } catch (err) { + actionResolve({ success: false, error: err.message, task: taskText, target: response.target }); + } + }).catch((err) => { + actionResolve({ success: false, error: err.message, task: taskText }); + }); + }); + + const results = []; + for (let i = 0; i < actionTasks.length; i++) { + const taskText = actionTasks[i]; + results.push(await runAction(taskText)); + if (i < actionTasks.length - 1) { + await new Promise(r => setTimeout(r, /\b(entrée|enter|submit|valide|appuie)\b/i.test(taskText) ? 700 : 250)); + } + } + + const failed = results.find(r => r && r.success === false); + if (!failed) { + chrome.tabs.sendMessage(silentTabId, { type: "AGENT_STATUS_CHANGE", status: "completed" }).catch(() => {}); + setTimeout(() => { + chrome.tabs.sendMessage(silentTabId, { type: "AGENT_STATUS_CHANGE", status: "detached" }).catch(() => {}); + }, 4000); + } + sendResponse(id, failed ? { success: false, results, error: failed.error } : { success: true, results }); + resolve(); + }); + }); + break; + + default: + sendResponse(id, { success: false, error: `Unknown command: ${command}` }); + } + } catch (err) { + console.error("⚠️ [Bridge message error]:", err); + } +} + +function buildSilentActionTasks(task) { + const text = String(task || '').toLowerCase(); + const actions = []; + + if (isGoogleSearchTask(task)) { + actions.push('clique dans le champ de recherche Google'); + const query = extractSearchQuery(task); + if (query) { + actions.push(`tape ${query} puis appuie sur Entrée`); + if (/\b(clique|clic|click|ouvrir|ouvre|open)\b/i.test(text) && /\b(lien|résultat|resultat|site)\b/i.test(text)) { + actions.push(`clique le résultat ${query}`); + } + } + } + + if (text.includes('hamburger') || text.includes('menu')) { + actions.push('clique le bouton menu hamburger'); + if (text.includes('referme') || text.includes('ferme') || text.includes('close')) { + actions.push('clique le bouton menu hamburger'); + } + } + + const trinityMatch = text.includes('trinity'); + if (trinityMatch) { + actions.push('clique la carte Trinity'); + } + + if (actions.length === 0 && shouldAutoNavigateTask(task)) { + return []; + } + return actions.length > 0 ? actions : [task]; +} + +function cdp(tabId, method, params = {}) { + return new Promise((resolve, reject) => { + chrome.debugger.sendCommand({ tabId }, method, params, (result) => { + if (chrome.runtime.lastError) reject(new Error(chrome.runtime.lastError.message)); + else resolve(result || {}); + }); + }); +} + +async function attachDebuggerIfNeeded(tabId) { + if (!ALLOW_DEBUGGER_ATTACH) throw new Error('chrome.debugger attach disabled to avoid Chrome debugging banner'); + if (attachedTabs.has(tabId)) return; + await new Promise((resolve, reject) => { + chrome.debugger.attach({ tabId }, "1.3", () => { + if (chrome.runtime.lastError && !chrome.runtime.lastError.message.includes("already attached") && !chrome.runtime.lastError.message.includes("Already attached")) { + reject(new Error(chrome.runtime.lastError.message)); + } else { + attachedTabs.add(tabId); + resolve(); + } + }); + }); +} + +function humanPath(start, end, steps = 34) { + const dx = end.x - start.x; + const dy = end.y - start.y; + const dist = Math.max(1, Math.hypot(dx, dy)); + + // Generate 6 candidates with varying curve scales and directions + const candidates = []; + const multipliers = [0.4, 0.8, 1.2, -0.4, -0.8, -1.2]; + + for (const mult of multipliers) { + const points = []; + const curve = Math.min(130, Math.max(20, dist * 0.20)) * mult; + const nx = -dy / dist; + const ny = dx / dist; + + const c1 = { x: start.x + dx * 0.35 + nx * curve, y: start.y + dy * 0.35 + ny * curve }; + const c2 = { x: start.x + dx * 0.72 - nx * curve * 0.55, y: start.y + dy * 0.72 - ny * curve * 0.55 }; + + for (let i = 1; i <= steps; i++) { + const t = i / steps; + const ease = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; + const u = 1 - ease; + points.push({ + x: u * u * u * start.x + 3 * u * u * ease * c1.x + 3 * u * ease * ease * c2.x + ease * ease * ease * end.x, + y: u * u * u * start.y + 3 * u * u * ease * c1.y + 3 * u * ease * ease * c2.y + ease * ease * ease * end.y, + }); + } + candidates.push(points); + } + + // Score candidates to find the smoothest path that stays in bounds + // Default bounds safety margins + const width = 1280; + const height = 720; + + let bestPoints = candidates[0]; + let minScore = Infinity; + + for (const points of candidates) { + let outOfBoundsCount = 0; + let totalJerkiness = 0; + let prevAngle = null; + + for (let i = 0; i < points.length; i++) { + const p = points[i]; + if (p.x < 10 || p.x > width - 10 || p.y < 10 || p.y > height - 10) { + outOfBoundsCount++; + } + + if (i > 0) { + const prev = points[i - 1]; + const angle = Math.atan2(p.y - prev.y, p.x - prev.x); + if (prevAngle !== null) { + let diff = Math.abs(angle - prevAngle); + if (diff > Math.PI) diff = 2 * Math.PI - diff; + totalJerkiness += diff; + } + prevAngle = angle; + } + } + + const boundsPenalty = outOfBoundsCount * 10000; + const score = boundsPenalty + totalJerkiness * 50; + + if (score < minScore) { + minScore = score; + bestPoints = points; + } + } + + return bestPoints; +} + +async function showCursor(tabId, x, y, moveSequence = ++cursorMoveSeq, cursorOptions = normalizeCursorOptions()) { + rememberHumanCursor(tabId, { x, y }); + await chrome.tabs.sendMessage(tabId, { + type: "AGENT_CURSOR_STATE", + state: { x, y, visible: cursorOptions.mode !== 'hidden', moveSequence, sessionId: "session-" + tabId, turnId: "turn-human-cdp" } + }).catch(() => {}); +} + +async function performHumanCdpAction(tabId, detection, taskText, cursorOptions = normalizeCursorOptions()) { + if (detection.action === 'scroll') { + await ensureCursorInjected(tabId); + const amount = detection.scrollY || 500; + await chrome.tabs.sendMessage(tabId, { type: "AGENT_DOM_SCROLL", scrollY: amount }).catch(() => {}); + return { success: true, action: 'scroll', message: 'Scroll humain DOM effectué.' }; + } + + if (detection.action === 'type') { + await ensureCursorInjected(tabId); + const target = detection.target; + if (!target || !Number.isFinite(target.x) || !Number.isFinite(target.y)) { + throw new Error('Invalid target bounding box'); + } + const textToType = detection.text || ''; + const sequence = ++cursorMoveSeq; + const start = getHumanCursorStart(tabId, target); + const end = { x: target.x, y: target.y }; + for (const p of humanPath(start, end, cursorOptions.timing.steps)) { + await showCursor(tabId, Math.round(p.x), Math.round(p.y), sequence, cursorOptions); + await new Promise(r => setTimeout(r, cursorOptions.timing.minDelay + Math.random() * cursorOptions.timing.jitter)); + } + const typeResult = await sendTabMessage(tabId, { type: 'AGENT_DOM_TYPE', x: end.x, y: end.y, text: textToType, submit: !!detection.submit, delayMs: cursorOptions.speed === 'slow' ? 120 : cursorOptions.speed === 'fast' ? 35 : 70 }, 20000); + rememberHumanCursor(tabId, end, target); + if (!typeResult || typeResult.ok === false || typeResult.success === false) throw new Error(typeResult?.error || 'DOM type failed'); + return { success: true, action: 'type', element: target.element, message: `Saisie humaine DOM effectuée: ${textToType}` }; + } + + const target = detection.target; + if (!target || !Number.isFinite(target.x) || !Number.isFinite(target.y)) { + throw new Error('Invalid target bounding box'); + } + + await ensureCursorInjected(tabId); + + const start = getHumanCursorStart(tabId, target); + const end = { x: target.x, y: target.y }; + const points = humanPath(start, end, cursorOptions.timing.steps); + const sequence = ++cursorMoveSeq; + + for (const p of points) { + const x = Math.round(p.x); + const y = Math.round(p.y); + await showCursor(tabId, x, y, sequence, cursorOptions); + await new Promise(r => setTimeout(r, cursorOptions.timing.minDelay + Math.random() * cursorOptions.timing.jitter)); + } + + await new Promise(r => setTimeout(r, cursorOptions.timing.pause + Math.random() * 90)); + await chrome.tabs.sendMessage(tabId, { type: "AGENT_CLICK_EVENT", event: { x: end.x, y: end.y, type: "mousePressed", button: "left" } }).catch(() => {}); + const clickResult = await sendTabMessage(tabId, { type: "AGENT_DOM_CLICK", x: end.x, y: end.y }, 12000); + await chrome.tabs.sendMessage(tabId, { type: "AGENT_CURSOR_STATE", state: { x: end.x, y: end.y, visible: cursorOptions.mode !== 'hidden', moveSequence: sequence, sessionId: "session-" + tabId, turnId: "turn-human-dom" } }).catch(() => {}); + rememberHumanCursor(tabId, end, target); + await new Promise(r => setTimeout(r, 250)); + + if (!clickResult || clickResult.ok === false || clickResult.success === false) { + throw new Error(clickResult?.error || 'DOM click failed'); + } + + return { + success: true, + action: detection.action || 'click', + element: target.element, + message: `Clic humain DOM effectué à (${end.x}, ${end.y}) sur ${target.element?.tagName || 'target'}.` + }; +} + +function sendCDPCommand(msgId, tabId, method, cdpParams) { + // ORGANIC CURSOR SYNERGY INTERCEPTOR + if (method === "Input.dispatchMouseEvent" && cdpParams) { + const { x, y, type } = cdpParams; + if (type === "mouseMoved") { + chrome.tabs.sendMessage(tabId, { + type: "AGENT_CURSOR_STATE", + state: { + x, + y, + visible: true, + moveSequence: ++cursorMoveSeq, + sessionId: "session-" + tabId, + turnId: "turn-cdp" + } + }).catch(() => { + // Auto-inject and retry in case cursor DOM was purged by navigation + ensureCursorInjected(tabId).then(() => { + chrome.tabs.sendMessage(tabId, { + type: "AGENT_CURSOR_STATE", + state: { x, y, visible: true, moveSequence: cursorMoveSeq, sessionId: "session-" + tabId, turnId: "turn-cdp" } + }).catch(() => {}); + }); + }); + } else if (type === "mousePressed" || type === "mouseReleased") { + chrome.tabs.sendMessage(tabId, { + type: "AGENT_CLICK_EVENT", + event: { + x, + y, + type, + button: cdpParams.button || "left" + } + }).catch(() => { + ensureCursorInjected(tabId).then(() => { + chrome.tabs.sendMessage(tabId, { + type: "AGENT_CLICK_EVENT", + event: { x, y, type, button: cdpParams.button || "left" } + }).catch(() => {}); + }); + }); + } + } + + // Execute standard CDP command via debugger + chrome.debugger.sendCommand({ tabId: tabId }, method, cdpParams || {}, (result) => { + if (chrome.runtime.lastError) { + console.error(`⚠️ [CDP Error] ${method}:`, chrome.runtime.lastError.message); + sendResponse(msgId, { success: false, error: chrome.runtime.lastError.message }); + } else { + sendResponse(msgId, { success: true, result }); + } + }); +} + +// Injects the custom spring-physics cursor script if not already present +async function ensureCursorInjected(tabId) { + const tabInfo = await chrome.tabs.get(tabId); + if (!tabInfo || isSystemTab(tabInfo)) { + throw new Error(`Cannot inject cursor into restricted tab: ${tabInfo?.url || tabId}`); + } + + const ping = async () => await new Promise((resolve) => { + chrome.tabs.sendMessage(tabId, { type: "CONTENT_PING" }, (res) => { + resolve(!(chrome.runtime.lastError || !res || !res.ok)); + }); + }); + + for (let attempt = 0; attempt < 20; attempt++) { + const currentTab = await chrome.tabs.get(tabId); + if (currentTab.status !== 'loading') break; + await new Promise(r => setTimeout(r, 250)); + } + + if (await ping()) return; + + await chrome.scripting.executeScript({ + target: { tabId }, + files: ["sinew_cursor.js"] + }); + + for (let attempt = 0; attempt < 10; attempt++) { + if (await ping()) { + console.log(`🧬 [Bridge] Injected biological cursor overlay into tab ${tabId}`); + return; + } + await new Promise(r => setTimeout(r, 200)); + } + + throw new Error(`Content script injection failed for tab ${tabId}`); +} + +function reportOpenTabs(responseId = null) { + chrome.tabs.query({}, (tabs) => { + // Filter out restricted pages before sending to proxy + const debuggableTabs = tabs.filter(t => !isSystemTab(t)); + + const tabList = debuggableTabs.map(t => ({ + id: t.id, + title: t.title, + url: t.url, + active: t.active + })); + + if (responseId) { + sendResponse(responseId, { tabs: tabList }); + } else { + sendMsg({ type: "tabs_report", tabs: tabList }); + } + }); +} + +// Listen to browser-level events and report in real-time to Node.js proxy +chrome.tabs.onCreated.addListener((tab) => { + if (!isSystemTab(tab)) { + broadcastTabEvent("Target.targetCreated", tab); + } +}); + +chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (!isSystemTab(tab)) { + broadcastTabEvent("Target.targetInfoChanged", tab); + } else { + // If it became a system tab, destroy it in the proxy client list + sendMsg({ + type: "target_destroyed", + tabId: tabId + }); + } +}); + +chrome.tabs.onRemoved.addListener((tabId) => { + attachedTabs.delete(tabId); + updateStorageState(); + sendMsg({ + type: "target_destroyed", + tabId: tabId + }); +}); + +function broadcastTabEvent(method, tab) { + sendMsg({ + type: "target_event", + method: method, + tab: { + id: tab.id, + title: tab.title, + url: tab.url, + active: tab.active + } + }); +} + +// Forward raw debugger events to the local proxy server +chrome.debugger.onEvent.addListener((source, method, params) => { + sendMsg({ + type: "event", + tabId: source.tabId, + method: method, + params: params + }); +}); + +chrome.debugger.onDetach.addListener((source, reason) => { + console.log(`🧬 [Bridge] Detached from tab ${source.tabId} because: ${reason}`); + attachedTabs.delete(source.tabId); + updateStorageState(); + + sendMsg({ + type: "detached", + tabId: source.tabId, + reason + }); +}); + +// Cursor arrival notification from tab content script +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message.type === "AGENT_CURSOR_ARRIVED") { + // Notify proxy of completion + sendMsg({ + type: "cursor_arrived", + tabId: sender.tab.id, + moveSequence: message.moveSequence + }); + sendResponse({ ok: true }); + } + else if (message.type === "TAB_LOADED") { + const tabId = sender.tab.id; + if (attachedTabs.has(tabId)) { + ensureCursorInjected(tabId); + } + sendResponse({ ok: true }); + } + else if (message.type === "AGENT_SAVE_MACRO") { + sendMsg({ + type: "save_macro", + tabId: sender.tab ? sender.tab.id : null, + macro: message.macro + }); + sendResponse({ ok: true }); + } + return true; +}); + + + +function updateStorageState() { + getDiagnosticsViaProxy().then((diagnostics) => { + chrome.storage.local.set({ + connected: isBridgeConnected(), + attachedCount: attachedTabs.size, + lastNativeError, + lastConnectedAt, + diagnostics + }); + }); +} + +function getDiagnosticsViaProxy() { + return new Promise((resolve) => { + try { + const url = bridgeSecret ? `http://localhost:29002/api/diagnostics?token=${encodeURIComponent(bridgeSecret)}` : 'http://localhost:29002/api/diagnostics'; + fetch(url, { cache: 'no-store' }) + .then(res => res.ok ? res.json() : null) + .then(data => resolve(data || null)) + .catch(() => resolve(null)); + } catch { + resolve(null); + } + }); +} + +async function restartNativeBridge() { + lastNativeError = null; + try { + if (nativePort) { + try { nativePort.disconnect(); } catch {} + nativePort = null; + } + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + await new Promise(resolve => setTimeout(resolve, 250)); + connect(); + await new Promise(resolve => setTimeout(resolve, 750)); + updateStorageState(); + return { success: true }; + } catch (err) { + lastNativeError = err.message; + updateStorageState(); + return { success: false, error: err.message }; + } +} + +// Keep connection state fresh for popup UI +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + if (request.action === "get_status") { + getDiagnosticsViaProxy().then((diagnostics) => { + sendResponse({ + connected: isBridgeConnected(), + attachedCount: attachedTabs.size, + lastNativeError, + lastConnectedAt, + diagnostics + }); + }); + } else if (request.action === "reconnect") { + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + connect(); + setTimeout(() => { + updateStorageState(); + sendResponse({ success: true, connected: isBridgeConnected() }); + }, 250); + } else if (request.action === "restart_bridge") { + restartNativeBridge().then(sendResponse); + } + return true; +}); + +// Setup periodic alarm to keep the background service worker alive and ensure connection +chrome.alarms.create("keep_alive_alarm", { periodInMinutes: 0.2 }); +chrome.alarms.onAlarm.addListener((alarm) => { + if (alarm.name === "keep_alive_alarm") { + if (!isBridgeConnected()) { + console.log("🧬 [Bridge background] Connection inactive. Reconnecting via alarm..."); + connect(); + } + } +}); + +chrome.runtime.onStartup?.addListener(() => { + console.log("🧬 [Bridge background] Chrome startup detected. Connecting native host..."); + connect(); +}); + +chrome.runtime.onInstalled?.addListener(() => { + console.log("🧬 [Bridge background] Extension installed/updated. Connecting native host..."); + connect(); +}); + +// Auto connect immediately whenever the service worker is loaded +connect(); + +// ========================================================== +// Hot Reload / Auto-Update Lifecycle (Sinew style) +// ========================================================== +if (chrome.runtime.onUpdateAvailable) { + chrome.runtime.onUpdateAvailable.addListener((details) => { + console.log(`🧬 [Bridge] New version available: ${details.version}. Hot-reloading when idle...`); + const checkIdleAndReload = () => { + if (attachedTabs.size === 0) { + console.log("🧬 [Bridge] System idle. Reloading extension now."); + chrome.runtime.reload(); + } else { + setTimeout(checkIdleAndReload, 10000); + } + }; + checkIdleAndReload(); + }); +} + +// Keep-alive port listener from content scripts +chrome.runtime.onConnect.addListener((port) => { + if (port.name === "sinew-keep-alive") { + port.onDisconnect.addListener(() => { + // Automatic re-connection handled by client tabs + }); + } +}); + diff --git a/sinew-chrome-bridge/com.sinew.chrome_bridge.json b/sinew-chrome-bridge/com.sinew.chrome_bridge.json new file mode 100644 index 00000000..2080aa9a --- /dev/null +++ b/sinew-chrome-bridge/com.sinew.chrome_bridge.json @@ -0,0 +1,12 @@ +{ + "name": "com.sinew.chrome_bridge", + "description": "Sinew Chrome Bridge Native Messaging Host", + "path": "native-host-wrapper.exe", + "type": "stdio", + "allowed_origins": [ + "chrome-extension://fokjdmcbdkdbajceeljedfgcchogihbg/", + "chrome-extension://ndacamocppkooeihenfhedhmbknakjfb/", + "chrome-extension://kooobgnecenccliamhjbcncjnjpjoeic/", + "chrome-extension://kedgddpfjpfoghaecofgpmeogiihcgig/" + ] +} diff --git a/sinew-chrome-bridge/e2e-local.mjs b/sinew-chrome-bridge/e2e-local.mjs new file mode 100644 index 00000000..770b1b46 --- /dev/null +++ b/sinew-chrome-bridge/e2e-local.mjs @@ -0,0 +1,84 @@ +import { spawn } from 'child_process'; + +const task = process.argv.slice(2).join(' ') || 'ouvre julienpiron.fr puis clic sur le hamburger puis referme le puis clic sur la carte trinity'; +const child = spawn(process.execPath, ['sinew-chrome-bridge/mcp_server.js'], { + cwd: process.cwd(), + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, +}); + +let stdout = ''; +let stderr = ''; +let settled = false; + +const timeout = setTimeout(() => { + if (settled) return; + settled = true; + child.kill(); + console.error('E2E timeout'); + console.error(stderr); + process.exit(1); +}, 120000); + +function send(message) { + child.stdin.write(JSON.stringify(message) + '\n'); +} + +child.stdout.on('data', chunk => { + stdout += chunk.toString(); + const lines = stdout.split(/\r?\n/).filter(Boolean); + for (const line of lines) { + let msg; + try { msg = JSON.parse(line); } catch { continue; } + if (msg.id === 2) { + clearTimeout(timeout); + settled = true; + child.kill(); + const text = msg.result?.content?.[0]?.text || ''; + let payload; + try { payload = JSON.parse(text); } catch { payload = null; } + if (!payload?.success) { + console.error('E2E failed: browser task returned unsuccessful result'); + console.error(text || line); + process.exit(1); + } + const serialized = JSON.stringify(payload); + for (const expected of ['menu-button', 'trinity-card']) { + if (!serialized.includes(expected)) { + console.error(`E2E failed: expected ${expected} in result`); + console.error(serialized); + process.exit(1); + } + } + if (serialized.includes('human_cdp_click')) { + console.error('E2E failed: CDP click path was used; expected DOM/content-script path to avoid Chrome debugging banner'); + console.error(serialized); + process.exit(1); + } + console.log('E2E OK: Sinew Chrome local MCP controlled Chrome successfully.'); + console.log(text); + process.exit(0); + } + } +}); + +child.stderr.on('data', chunk => { + stderr += chunk.toString(); +}); + +child.on('error', err => { + clearTimeout(timeout); + console.error(err); + process.exit(1); +}); + +send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }); +send({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'run_browser_agent', + arguments: { task }, + }, +}); diff --git a/sinew-chrome-bridge/e2e-structured.mjs b/sinew-chrome-bridge/e2e-structured.mjs new file mode 100644 index 00000000..3dfaae7d --- /dev/null +++ b/sinew-chrome-bridge/e2e-structured.mjs @@ -0,0 +1,124 @@ +import { spawn } from 'child_process'; + +const child = spawn(process.execPath, ['sinew-chrome-bridge/mcp_server.js'], { + cwd: process.cwd(), + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, +}); + +const results = {}; +let stdout = ''; +let settled = false; +const handledIds = new Set(); + +function send(message) { + child.stdin.write(JSON.stringify(message) + '\n'); +} + +function parseContent(msg) { + const text = msg.result?.content?.[0]?.text || ''; + try { return JSON.parse(text); } catch { return { raw: text }; } +} + +function fail(reason, payload = null) { + if (settled) return; + settled = true; + child.kill(); + console.error(reason); + if (payload) console.error(JSON.stringify(payload, null, 2)); + process.exit(1); +} + +const timeout = setTimeout(() => fail('Structured MCP E2E timeout', results), 60000); + +child.stdout.on('data', chunk => { + stdout += chunk.toString(); + let newlineIndex; + while ((newlineIndex = stdout.search(/\r?\n/)) >= 0) { + const line = stdout.slice(0, newlineIndex).trim(); + stdout = stdout.slice(newlineIndex + (stdout[newlineIndex] === '\r' && stdout[newlineIndex + 1] === '\n' ? 2 : 1)); + if (!line) continue; + let msg; + try { msg = JSON.parse(line); } catch { continue; } + if (msg.id && handledIds.has(msg.id)) continue; + if (msg.id) handledIds.add(msg.id); + + if (msg.id === 2) { + results.tools = msg.result?.tools?.map(tool => tool.name) || []; + for (const name of ['open_browser', 'page_snapshot', 'query_selector', 'wait_for_selector', 'click_selector', 'type_selector', 'press_key', 'select_option', 'evaluate']) { + if (!results.tools.includes(name)) fail(`Missing MCP tool: ${name}`, results); + } + send({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'open_browser', arguments: { url: 'https://example.com' } } }); + } + + if (msg.id === 3) { + results.open = parseContent(msg); + if (!results.open.success) fail('open_browser failed', results); + send({ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'wait_for_selector', arguments: { selector: 'h1', timeoutMs: 5000 } } }); + } + + if (msg.id === 4) { + results.wait = parseContent(msg); + if (!results.wait.success) fail('wait_for_selector failed', results); + send({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'query_selector', arguments: { selector: 'h1' } } }); + } + + if (msg.id === 5) { + results.query = parseContent(msg); + if (!results.query.success || results.query.result?.text !== 'Example Domain') fail('query_selector failed', results); + send({ jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name: 'evaluate', arguments: { expression: "(() => { document.body.setAttribute('data-sinew-e2e','ok'); return document.body.getAttribute('data-sinew-e2e'); })()" } } }); + } + if (msg.id === 6) { + results.setup = parseContent(msg); + if (!results.setup.success) fail('evaluate setup failed', results); + send({ jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'click_selector', arguments: { selector: 'a' } } }); + } + + if (msg.id === 7) { + results.click = parseContent(msg); + if (!results.click.success) fail('click_selector failed', results); + send({ jsonrpc: '2.0', id: 8, method: 'tools/call', params: { name: 'open_browser', arguments: { url: 'https://www.google.com' } } }); + } + + if (msg.id === 8) { + results.formOpen = parseContent(msg); + if (!results.formOpen.success) fail('form open failed', results); + send({ jsonrpc: '2.0', id: 9, method: 'tools/call', params: { name: 'evaluate', arguments: { expression: "(() => { document.body.insertAdjacentHTML('beforeend', ''); return true; })()" } } }); + } + + if (msg.id === 9) { + results.formSetup = parseContent(msg); + if (!results.formSetup.success) fail('form setup failed', results); + send({ jsonrpc: '2.0', id: 10, method: 'tools/call', params: { name: 'query_selector', arguments: { selector: 'textarea[name="q"], input[name="q"]' } } }); + } + + if (msg.id === 10) { + results.formQuery = parseContent(msg); + if (!results.formQuery.success) fail('post-setup query_selector failed', results); + send({ jsonrpc: '2.0', id: 11, method: 'tools/call', params: { name: 'type_selector', arguments: { selector: 'textarea[name="q"], input[name="q"]', text: 'sinew test' } } }); + } + + if (msg.id === 11) { + results.type = parseContent(msg); + if (!results.type.success) fail('type_selector failed', results); + send({ jsonrpc: '2.0', id: 12, method: 'tools/call', params: { name: 'press_key', arguments: { selector: 'textarea[name="q"], input[name="q"]', key: 'Enter', submit: false } } }); + } + + if (msg.id === 12) { + results.press = parseContent(msg); + if (!results.press.success || results.press.result?.key !== 'Enter') fail('press_key failed', results); + clearTimeout(timeout); + settled = true; + child.kill(); + console.log('Structured MCP E2E OK'); + console.log(JSON.stringify(results, null, 2)); + process.exit(0); + } + } +}); + +child.stderr.on('data', () => {}); +child.on('error', err => fail(err.message, results)); + +send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }); +send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); diff --git a/sinew-chrome-bridge/icon-128.png b/sinew-chrome-bridge/icon-128.png new file mode 100644 index 00000000..b62a379d Binary files /dev/null and b/sinew-chrome-bridge/icon-128.png differ diff --git a/sinew-chrome-bridge/icon-32.png b/sinew-chrome-bridge/icon-32.png new file mode 100644 index 00000000..8bf75f90 Binary files /dev/null and b/sinew-chrome-bridge/icon-32.png differ diff --git a/sinew-chrome-bridge/icon-64.png b/sinew-chrome-bridge/icon-64.png new file mode 100644 index 00000000..98019198 Binary files /dev/null and b/sinew-chrome-bridge/icon-64.png differ diff --git a/sinew-chrome-bridge/icon.jpg b/sinew-chrome-bridge/icon.jpg new file mode 100644 index 00000000..150cebd0 Binary files /dev/null and b/sinew-chrome-bridge/icon.jpg differ diff --git a/sinew-chrome-bridge/interact_chrome.js b/sinew-chrome-bridge/interact_chrome.js new file mode 100644 index 00000000..adcff0a9 --- /dev/null +++ b/sinew-chrome-bridge/interact_chrome.js @@ -0,0 +1,157 @@ +const http = require('http'); +const WebSocket = require('./node_modules/ws'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const STATE_DIR = process.env.SINEW_CHROME_BRIDGE_DIR || path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'Sinew', 'ChromeBridge'); +let BRIDGE_SECRET = ''; +try { + const secretPath = path.join(STATE_DIR, 'bridge-secret.txt'); + if (fs.existsSync(secretPath)) { + BRIDGE_SECRET = fs.readFileSync(secretPath, 'utf8').trim(); + } +} catch (e) {} + +function getActiveTab() { + return new Promise((resolve, reject) => { + let url = 'http://localhost:29002/json'; + if (BRIDGE_SECRET) { + url += `?token=${encodeURIComponent(BRIDGE_SECRET)}`; + } + http.get(url, (res) => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + try { + const list = JSON.parse(data); + if (list.length > 0) { + resolve(list[0]); + } else { + reject(new Error("No active tabs found. Please open Chrome.")); + } + } catch (e) { + reject(e); + } + }); + }).on('error', reject); + }); +} + +async function run() { + try { + console.log("🧬 [Automation] Locating active Chrome tab..."); + const tab = await getActiveTab(); + console.log(`🧬 [Automation] Connected to Tab: "${tab.title}" (${tab.url})`); + console.log(`🧬 [Automation] Connecting via WebSocket to: ${tab.webSocketDebuggerUrl}`); + + const ws = new WebSocket(tab.webSocketDebuggerUrl); + + let messageId = 0; + const send = (method, params = {}) => { + const id = ++messageId; + const payload = { id, method, params }; + console.log(`🛰️ [CDP Send] ${method} (id=${id})`); + ws.send(JSON.stringify(payload)); + return id; + }; + + ws.on('open', () => { + console.log("🔌 [WebSocket] Connection established."); + + // 1. Enable Page commands + send("Page.enable"); + + // 2. Navigate to julienpiron.fr + console.log("🌐 [CDP] Navigating to http://julienpiron.fr..."); + send("Page.navigate", { url: "http://julienpiron.fr" }); + + // Wait for navigation and page load + setTimeout(() => { + // 3. Click the Hamburger menu + console.log("🍔 [CDP] Clicking Hamburger menu..."); + send("Runtime.evaluate", { + expression: `(() => { + // Find hamburger or mobile menu button + const btn = document.querySelector('.menu-toggle, .hamburger, button[aria-expanded], button, a') || + Array.from(document.querySelectorAll('button, a')).find(el => el.innerText.toLowerCase().includes('menu')); + if (btn) { + btn.click(); + btn.dispatchEvent(new Event('click', { bubbles: true })); + return "Hamburger clicked successfully"; + } + return "No hamburger button found"; + })()`, + returnByValue: true + }); + + // Wait 2.5 seconds, then close it + setTimeout(() => { + console.log("❌ [CDP] Closing Hamburger menu..."); + send("Runtime.evaluate", { + expression: `(() => { + const btn = document.querySelector('.menu-toggle, .hamburger, button[aria-expanded], button, a') || + Array.from(document.querySelectorAll('button, a')).find(el => el.innerText.toLowerCase().includes('menu')); + if (btn) { + btn.click(); + btn.dispatchEvent(new Event('click', { bubbles: true })); + return "Hamburger closed successfully"; + } + return "No hamburger button found to close"; + })()`, + returnByValue: true + }); + + // Wait 2.5 seconds, then click Trinity Card + setTimeout(() => { + console.log("🃏 [CDP] Clicking Trinity Card..."); + send("Runtime.evaluate", { + expression: `(() => { + const links = Array.from(document.querySelectorAll('a, h2, h3, p, div')); + const trinity = links.find(el => el.textContent.toLowerCase().includes('trinity')); + if (trinity) { + trinity.click(); + trinity.dispatchEvent(new Event('click', { bubbles: true })); + // If it's a link or element, try triggering navigation or click directly + const parentLink = trinity.closest('a'); + if (parentLink) parentLink.click(); + return "Trinity card clicked successfully"; + } + return "Trinity card not found"; + })()`, + returnByValue: true + }); + + // Close connection after completed run + setTimeout(() => { + console.log("🏁 [CDP] Completed successfully. Closing WebSocket."); + ws.close(); + process.exit(0); + }, 3000); + + }, 2500); + + }, 2500); + + }, 4000); // Allow 4 seconds for complete initial page load + }); + + ws.on('message', (data) => { + const msg = JSON.parse(data); + if (msg.result) { + console.log(`📥 [CDP Response] id=${msg.id}:`, JSON.stringify(msg.result)); + } else if (msg.error) { + console.error(`⚠️ [CDP Error Response] id=${msg.id}:`, msg.error.message); + } + }); + + ws.on('error', (err) => { + console.error("❌ [WebSocket Error]:", err); + }); + + } catch (e) { + console.error("❌ [Error in automation]:", e.message); + } +} + +run(); diff --git a/sinew-chrome-bridge/launch_chrome_silent.bat b/sinew-chrome-bridge/launch_chrome_silent.bat new file mode 100644 index 00000000..6860d6ea --- /dev/null +++ b/sinew-chrome-bridge/launch_chrome_silent.bat @@ -0,0 +1,9 @@ +@echo off +title 🧬 Launch Chrome Silent (Sinew Mode) +echo. +echo ======================================================= +echo 🧬 Launching Chrome in Silent SOTA mode... +echo ======================================================= +echo. +start "" "C:\Program Files\Google\Chrome\Application\chrome.exe" --silent-debugger-extension-api +exit diff --git a/sinew-chrome-bridge/manifest.json b/sinew-chrome-bridge/manifest.json new file mode 100644 index 00000000..00599499 --- /dev/null +++ b/sinew-chrome-bridge/manifest.json @@ -0,0 +1,56 @@ +{ + "manifest_version": 3, + "name": "Sinew Chrome Bridge", + "version": "1.0.0", + "description": "Local Chrome control bridge for Sinew, powered by Native Messaging and CDP.", + "permissions": [ + "alarms", + "bookmarks", + "debugger", + "downloads", + "downloads.ui", + "favicon", + "history", + "nativeMessaging", + "notifications", + "readingList", + "scripting", + "sessions", + "storage", + "tabGroups", + "tabs", + "topSites" + ], + "content_security_policy": { + "extension_pages": "script-src 'self'; object-src 'none'; connect-src 'self' http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:*; font-src 'self' https://cdn.openai.com;" + }, + "web_accessible_resources": [ { + "matches": [ "" ], + "resources": [ "icon-128.png", "icon-32.png", "icon-64.png" ] + } ], + "host_permissions": [ + "" + ], + "content_scripts": [ + { + "matches": [ "" ], + "js": [ "sinew_cursor.js" ], + "run_at": "document_start", + "all_frames": false, + "match_about_blank": true + } + ], + "background": { + "service_worker": "background.js" + }, + "action": { + "default_popup": "popup.html", + "default_icon": "icon-32.png" + }, + "icons": { + "16": "icon-32.png", + "32": "icon-32.png", + "48": "icon-64.png", + "128": "icon-128.png" + } +} diff --git a/sinew-chrome-bridge/native-host-wrapper.exe b/sinew-chrome-bridge/native-host-wrapper.exe new file mode 100644 index 00000000..d87d0bae Binary files /dev/null and b/sinew-chrome-bridge/native-host-wrapper.exe differ diff --git a/sinew-chrome-bridge/native-host-wrapper/Cargo.toml b/sinew-chrome-bridge/native-host-wrapper/Cargo.toml new file mode 100644 index 00000000..0cad3810 --- /dev/null +++ b/sinew-chrome-bridge/native-host-wrapper/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "native-host-wrapper" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +tokio = { workspace = true, features = ["full"] } +tokio-tungstenite = { version = "0.26", default-features = false, features = ["handshake"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +anyhow = { workspace = true } +futures-util = { workspace = true } +directories = { workspace = true } +uuid = { workspace = true } +reqwest = { workspace = true } +chrono = { workspace = true } +base64 = { workspace = true } +image = { version = "0.25.10", default-features = false, features = ["png", "jpeg"] } + +[lints] +workspace = true diff --git a/sinew-chrome-bridge/native-host-wrapper/src/main.rs b/sinew-chrome-bridge/native-host-wrapper/src/main.rs new file mode 100644 index 00000000..66698f5f --- /dev/null +++ b/sinew-chrome-bridge/native-host-wrapper/src/main.rs @@ -0,0 +1,2222 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +use std::collections::HashMap; +use std::env; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::{mpsc, oneshot, Mutex, RwLock}; +use serde_json::json; +use futures_util::StreamExt; +use futures_util::sink::SinkExt; + +// --------------------------------------------------------------------------- +// Structs & State definitions +// --------------------------------------------------------------------------- + +struct WsClient { + id: u64, + tx: mpsc::UnboundedSender, +} + +enum PendingRequest { + Http(oneshot::Sender), + Cdp { + client_tx: mpsc::UnboundedSender, + original_id: u64, + session_id: Option, + }, +} + +enum ExtensionConnection { + Stdio, + WebSocket(mpsc::UnboundedSender), +} + +struct ProxyState { + extension_conn: RwLock>, + native_stdout_tx: mpsc::UnboundedSender, + pending_requests: Mutex>, + next_request_id: Mutex, + bridge_secret: String, + browser_sockets: Mutex>, + page_sockets: Mutex>>, + port: u16, +} + +struct HttpRequest { + method: String, + path: String, + query: HashMap, + headers: HashMap, +} + +// --------------------------------------------------------------------------- +// Main Entrypoint +// --------------------------------------------------------------------------- + +fn main() { + let args: Vec = env::args().collect(); + let is_mcp = args.iter().any(|arg| arg == "--mcp"); + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .unwrap(); + + if is_mcp { + rt.block_on(async { + let secret = load_or_create_secret(); + let port = get_bridge_port(); + run_mcp_server(secret, port).await; + }); + } else { + rt.block_on(async { + let secret = load_or_create_secret(); + let port = get_bridge_port(); + run_bridge_proxy(secret, port).await; + }); + } +} + +// --------------------------------------------------------------------------- +// Configuration & Helpers +// --------------------------------------------------------------------------- + +fn get_bridge_port() -> u16 { + env::var("SINEW_CHROME_BRIDGE_PORT") + .ok() + .and_then(|val| val.parse::().ok()) + .unwrap_or(29002) +} + +fn load_or_create_secret() -> String { + let home = directories::UserDirs::new() + .map(|dirs| dirs.home_dir().to_path_buf()) + .unwrap_or_else(|| PathBuf::from(".")); + let local_app_data = env::var("LOCALAPPDATA") + .map(PathBuf::from) + .unwrap_or_else(|_| home.join("AppData").join("Local")); + let state_dir = local_app_data.join("Sinew").join("ChromeBridge"); + let secret_path = state_dir.join("bridge-secret.txt"); + + if let Ok(contents) = std::fs::read_to_string(&secret_path) { + let trimmed = contents.trim().to_string(); + if !trimmed.is_empty() { + return trimmed; + } + } + + let secret = uuid::Uuid::new_v4().to_string(); + let _ = std::fs::create_dir_all(&state_dir); + if std::fs::write(&secret_path, secret.as_bytes()).is_ok() { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&secret_path, std::fs::Permissions::from_mode(0o600)); + } + } + secret +} + +fn url_decode(s: &str) -> String { + let mut res = String::new(); + let mut chars = s.chars(); + while let Some(ch) = chars.next() { + if ch == '%' { + let mut hex = String::new(); + if let Some(c1) = chars.next() { hex.push(c1); } + if let Some(c2) = chars.next() { hex.push(c2); } + if let Ok(val) = u8::from_str_radix(&hex, 16) { + res.push(val as char); + } + } else if ch == '+' { + res.push(' '); + } else { + res.push(ch); + } + } + res +} + +fn parse_http_request(raw: &str) -> Option { + let mut lines = raw.lines(); + let first_line = lines.next()?; + let mut parts = first_line.split_whitespace(); + let method = parts.next()?.to_string(); + let full_path = parts.next()?.to_string(); + + let (path, query) = if let Some(idx) = full_path.find('?') { + let (p, q) = full_path.split_at(idx); + let q = &q[1..]; + let mut query_map = HashMap::new(); + for pair in q.split('&') { + let mut parts = pair.splitn(2, '='); + if let (Some(k), Some(v)) = (parts.next(), parts.next()) { + query_map.insert(url_decode(k), url_decode(v)); + } + } + (p.to_string(), query_map) + } else { + (full_path, HashMap::new()) + }; + + let mut headers = HashMap::new(); + for line in lines { + if line.trim().is_empty() { + break; + } + if let Some(idx) = line.find(':') { + let k = line[..idx].trim().to_lowercase(); + let v = line[idx + 1..].trim().to_string(); + headers.insert(k, v); + } + } + + Some(HttpRequest { + method, + path, + query, + headers, + }) +} + +// --------------------------------------------------------------------------- +// Native Host / Bridge Proxy logic +// --------------------------------------------------------------------------- + +async fn send_to_extension(state: &ProxyState, msg: &str) -> bool { + let conn = state.extension_conn.read().await; + match &*conn { + Some(ExtensionConnection::Stdio) => { + state.native_stdout_tx.send(msg.to_string()).is_ok() + } + Some(ExtensionConnection::WebSocket(tx)) => { + tx.send(msg.to_string()).is_ok() + } + None => false, + } +} + +async fn run_bridge_proxy(secret: String, port: u16) { + let (stdout_tx, mut stdout_rx) = mpsc::unbounded_channel::(); + + let state = Arc::new(ProxyState { + extension_conn: RwLock::new(Some(ExtensionConnection::Stdio)), + native_stdout_tx: stdout_tx, + pending_requests: Mutex::new(HashMap::new()), + next_request_id: Mutex::new(1), + bridge_secret: secret, + browser_sockets: Mutex::new(Vec::new()), + page_sockets: Mutex::new(HashMap::new()), + port, + }); + + // Stdin / Stdout handling tasks + let state_stdin = state.clone(); + tokio::spawn(async move { + run_native_host(state_stdin).await; + }); + + tokio::spawn(async move { + while let Some(msg) = stdout_rx.recv().await { + let _ = write_native_msg_stdout(&msg).await; + } + }); + + // Start TCP Listener / HTTP & WebSocket proxy server + let listener = match TcpListener::bind(format!("127.0.0.1:{port}")).await { + Ok(l) => l, + Err(e) => { + eprintln!("Failed to bind to port {port}: {e}"); + return; + } + }; + + loop { + if let Ok((stream, _)) = listener.accept().await { + let state_conn = state.clone(); + tokio::spawn(async move { + handle_connection(stream, state_conn).await; + }); + } + } +} + +async fn read_native_msg_stdin(reader: &mut tokio::io::Stdin) -> std::io::Result> { + let mut len_buf = [0u8; 4]; + if let Err(e) = reader.read_exact(&mut len_buf).await { + if e.kind() == std::io::ErrorKind::UnexpectedEof { + return Ok(None); + } + return Err(e); + } + let len = u32::from_le_bytes(len_buf) as usize; + let mut msg_buf = vec![0u8; len]; + reader.read_exact(&mut msg_buf).await?; + let msg = String::from_utf8(msg_buf) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + Ok(Some(msg)) +} + +async fn write_native_msg_stdout(msg: &str) -> std::io::Result<()> { + let mut stdout = tokio::io::stdout(); + let len = msg.len() as u32; + stdout.write_all(&len.to_le_bytes()).await?; + stdout.write_all(msg.as_bytes()).await?; + stdout.flush().await?; + Ok(()) +} + +async fn run_native_host(state: Arc) { + let mut stdin = tokio::io::stdin(); + + let init_msg = json!({ + "type": "init_secret", + "token": state.bridge_secret + }); + let _ = write_native_msg_stdout(&init_msg.to_string()).await; + + loop { + match read_native_msg_stdin(&mut stdin).await { + Ok(Some(msg_str)) => { + if let Ok(msg) = serde_json::from_str::(&msg_str) { + if let Some(msg_type) = msg.get("type").and_then(|t| t.as_str()) { + match msg_type { + "ping" => { + let pong = json!({ "type": "pong" }); + let _ = write_native_msg_stdout(&pong.to_string()).await; + } + "response" => { + if let Some(id) = msg.get("id").and_then(|i| i.as_u64()) { + let mut pending = state.pending_requests.lock().await; + if let Some(req) = pending.remove(&id) { + let data = msg.get("data").cloned().unwrap_or(json!({})); + match req { + PendingRequest::Http(tx) => { + let _ = tx.send(data); + } + PendingRequest::Cdp { + client_tx, + original_id, + session_id, + } => { + let mut response = json!({ + "id": original_id, + "result": data.get("result").cloned().unwrap_or(json!({})), + }); + if let Some(err) = data.get("error") { + response["error"] = json!({ "message": err }); + } + if let Some(sess) = session_id { + response["sessionId"] = json!(sess); + } + let _ = client_tx.send(response.to_string()); + } + } + } + } + } + "event" => { + let tab_id = msg.get("tabId").and_then(|t| t.as_u64()).map(|t| t.to_string()) + .or_else(|| msg.get("tabId").and_then(|t| t.as_str()).map(|s| s.to_string())) + .unwrap_or_default(); + let method = msg.get("method").and_then(|m| m.as_str()).unwrap_or(""); + let params = msg.get("params").cloned().unwrap_or(json!({})); + + { + let browser = state.browser_sockets.lock().await; + let payload = json!({ + "method": method, + "params": params, + "sessionId": format!("session-{tab_id}") + }).to_string(); + for client in browser.iter() { + let _ = client.tx.send(payload.clone()); + } + } + { + let page = state.page_sockets.lock().await; + if let Some(clients) = page.get(&tab_id) { + let payload = json!({ + "method": method, + "params": params + }).to_string(); + for client in clients.iter() { + let _ = client.tx.send(payload.clone()); + } + } + } + } + "target_event" => { + let method = msg.get("method").and_then(|m| m.as_str()).unwrap_or(""); + let tab = msg.get("tab").cloned().unwrap_or(json!({})); + let tab_id = tab.get("id").and_then(|t| t.as_u64()).map(|t| t.to_string()) + .or_else(|| tab.get("id").and_then(|t| t.as_str()).map(|s| s.to_string())) + .unwrap_or_default(); + let title = tab.get("title").and_then(|t| t.as_str()).unwrap_or("Chrome Tab"); + let url = tab.get("url").and_then(|t| t.as_str()).unwrap_or("about:blank"); + + let browser = state.browser_sockets.lock().await; + let payload = json!({ + "method": method, + "params": { + "targetInfo": { + "targetId": tab_id, + "type": "page", + "title": title, + "url": url, + "attached": false, + "canAccessOpener": false, + "browserContextId": "default" + } + } + }).to_string(); + for client in browser.iter() { + let _ = client.tx.send(payload.clone()); + } + } + "target_destroyed" => { + let tab_id = msg.get("tabId").and_then(|t| t.as_u64()).map(|t| t.to_string()) + .or_else(|| msg.get("tabId").and_then(|t| t.as_str()).map(|s| s.to_string())) + .unwrap_or_default(); + + let browser = state.browser_sockets.lock().await; + let payload = json!({ + "method": "Target.targetDestroyed", + "params": { + "targetId": tab_id + } + }).to_string(); + for client in browser.iter() { + let _ = client.tx.send(payload.clone()); + } + } + _ => {} + } + } + } + } + Ok(None) => { + break; + } + Err(_) => { + break; + } + } + } +} + +async fn handle_connection(mut stream: TcpStream, state: Arc) { + let mut buf = [0u8; 1536]; + let n = match stream.read(&mut buf).await { + Ok(n) if n > 0 => n, + _ => return, + }; + let raw_req = String::from_utf8_lossy(&buf[..n]); + let req = match parse_http_request(&raw_req) { + Some(r) => r, + None => return, + }; + + let is_protected = req.path.starts_with("/api/") || req.path.starts_with("/json") || req.path.starts_with("/devtools/"); + let client_token = req.query.get("token").cloned() + .or_else(|| req.headers.get("x-sinew-token").cloned()); + + if is_protected && client_token.as_deref() != Some(&state.bridge_secret) { + let response = "HTTP/1.1 401 Unauthorized\r\nContent-Type: application/json\r\nAccess-Control-Allow-Origin: *\r\n\r\n{\"error\":\"Unauthorized: Invalid or missing token\"}"; + let _ = stream.write_all(response.as_bytes()).await; + return; + } + + let is_ws = req.headers.get("upgrade").map(|v| v.to_lowercase() == "websocket").unwrap_or(false); + if is_ws { + handle_websocket_upgrade(stream, req, state).await; + return; + } + + handle_http_request(stream, req, state).await; +} + +async fn handle_http_request(mut stream: TcpStream, req: HttpRequest, state: Arc) { + let mut headers = String::new(); + headers.push_str("HTTP/1.1 200 OK\r\n"); + headers.push_str("Content-Type: application/json\r\n"); + headers.push_str("Access-Control-Allow-Origin: *\r\n"); + headers.push_str("Access-Control-Allow-Methods: GET, OPTIONS\r\n\r\n"); + + if req.method == "OPTIONS" { + let _ = stream.write_all(headers.as_bytes()).await; + return; + } + + if req.path == "/json/version" { + let version_info = json!({ + "Browser": "Chrome/120.0.0.0", + "Protocol-Version": "1.3", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "V8-Version": "12.0.267", + "WebKit-Version": "537.36 (@a06414a2754673bc28ea7c71d60dd4d9c7af4718)", + "webSocketDebuggerUrl": format!("ws://localhost:{}/devtools/browser?token={}", state.port, state.bridge_secret) + }); + let body = version_info.to_string(); + let _ = stream.write_all(format!("{headers}{body}").as_bytes()).await; + } + else if req.path == "/json" || req.path == "/json/list" { + let id = { + let mut id_lock = state.next_request_id.lock().await; + *id_lock += 1; + *id_lock + }; + let (tx, rx) = oneshot::channel::(); + { + let mut pending = state.pending_requests.lock().await; + pending.insert(id, PendingRequest::Http(tx)); + } + + let cmd = json!({ "id": id, "command": "list_tabs" }); + if send_to_extension(&state, &cmd.to_string()).await { + match tokio::time::timeout(Duration::from_secs(3), rx).await { + Ok(Ok(data)) => { + let tabs = data.get("tabs").and_then(|t| t.as_array()); + let mut debug_tabs = Vec::new(); + if let Some(tabs) = tabs { + for t in tabs { + let url = t.get("url").and_then(|u| u.as_str()).unwrap_or(""); + if !url.starts_with("chrome://") && !url.starts_with("chrome-extension://") { + let tab_id = t.get("id").and_then(|id| id.as_u64()).map(|id| id.to_string()) + .or_else(|| t.get("id").and_then(|id| id.as_str()).map(|s| s.to_string())) + .unwrap_or_default(); + debug_tabs.push(json!({ + "description": "", + "devtoolsFrontendUrl": format!("devtools://devtools/bundled/js_app.html?experiments=true&v8only=true&ws=localhost:{}/devtools/page/{}&token={}", state.port, tab_id, state.bridge_secret), + "id": tab_id, + "title": t.get("title").and_then(|title| title.as_str()).unwrap_or("Chrome Tab"), + "type": "page", + "url": url, + "active": t.get("active").and_then(|a| a.as_bool()).unwrap_or(false), + "webSocketDebuggerUrl": format!("ws://localhost:{}/devtools/page/{}?token={}", state.port, tab_id, state.bridge_secret) + })); + } + } + } + let body = json!(debug_tabs).to_string(); + let _ = stream.write_all(format!("{headers}{body}").as_bytes()).await; + } + _ => { + let _ = stream.write_all(format!("{headers}[]").as_bytes()).await; + } + } + } else { + let _ = stream.write_all(format!("{headers}[]").as_bytes()).await; + } + } + else if req.path == "/api/status" { + let body = json!({ + "isNativeMode": true, + "extensionConnected": true, + "hasExtensionSocket": true, + "chromeExecutable": "chrome", + "sessions": [] + }).to_string(); + let _ = stream.write_all(format!("{headers}{body}").as_bytes()).await; + } + else if req.path.starts_with("/api/") { + let command = req.path.trim_start_matches("/api/").to_string(); + + let id = { + let mut id_lock = state.next_request_id.lock().await; + *id_lock += 1; + *id_lock + }; + let (tx, rx) = oneshot::channel::(); + { + let mut pending = state.pending_requests.lock().await; + pending.insert(id, PendingRequest::Http(tx)); + } + + let mut params = json!({}); + for (k, v) in &req.query { + if k == "tabId" || k == "timeoutMs" || k == "limit" || k == "index" { + if let Ok(n) = v.parse::() { + params[k] = json!(n); + continue; + } + } + if k == "ctrlKey" || k == "shiftKey" || k == "altKey" || k == "metaKey" || k == "submit" || k == "visible" || k == "scroll" { + if v == "true" || v == "1" { + params[k] = json!(true); + continue; + } else if v == "false" || v == "0" { + params[k] = json!(false); + continue; + } + } + params[k] = json!(v); + } + + let cmd = json!({ + "id": id, + "command": command, + "params": params + }); + + if send_to_extension(&state, &cmd.to_string()).await { + let timeout_val = req.query.get("timeoutMs").and_then(|t| t.parse::().ok()).unwrap_or(20000); + match tokio::time::timeout(Duration::from_millis(timeout_val + 2000), rx).await { + Ok(Ok(data)) => { + let body = data.to_string(); + let _ = stream.write_all(format!("{headers}{body}").as_bytes()).await; + } + _ => { + let err_body = json!({ "success": false, "error": "Timeout waiting for extension response" }).to_string(); + let _ = stream.write_all(format!("{headers}{err_body}").as_bytes()).await; + } + } + } else { + let err_body = json!({ "success": false, "error": "Extension not connected" }).to_string(); + let _ = stream.write_all(format!("{headers}{err_body}").as_bytes()).await; + } + } + else { + let not_found_headers = "HTTP/1.1 404 Not Found\r\nContent-Type: application/json\r\nAccess-Control-Allow-Origin: *\r\n\r\n{\"error\":\"Not Found\"}"; + let _ = stream.write_all(not_found_headers.as_bytes()).await; + } +} + +async fn handle_websocket_upgrade(stream: TcpStream, req: HttpRequest, state: Arc) { + let ws_stream = match tokio_tungstenite::accept_async(stream).await { + Ok(ws) => ws, + Err(_) => return, + }; + + let (mut ws_write, mut ws_read) = ws_stream.split(); + let (tx, mut rx) = mpsc::unbounded_channel::(); + + tokio::spawn(async move { + while let Some(msg) = rx.recv().await { + if ws_write.send(tokio_tungstenite::tungstenite::Message::Text(msg.into())).await.is_err() { + break; + } + } + }); + + let client_id = CLIENT_ID_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let client = WsClient { id: client_id, tx: tx.clone() }; + + if req.path == "/extension" { + *state.extension_conn.write().await = Some(ExtensionConnection::WebSocket(tx.clone())); + + while let Some(Ok(msg)) = ws_read.next().await { + if let tokio_tungstenite::tungstenite::Message::Text(text) = msg { + if let Ok(val) = serde_json::from_str::(&text) { + if let Some(msg_type) = val.get("type").and_then(|t| t.as_str()) { + if msg_type == "ping" { + let _ = tx.send(json!({ "type": "pong" }).to_string()); + } else if msg_type == "response" { + if let Some(id) = val.get("id").and_then(|i| i.as_u64()) { + let mut pending = state.pending_requests.lock().await; + if let Some(req) = pending.remove(&id) { + let data = val.get("data").cloned().unwrap_or(json!({})); + match req { + PendingRequest::Http(otx) => { + let _ = otx.send(data); + } + PendingRequest::Cdp { + client_tx, + original_id, + session_id, + } => { + let mut response = json!({ + "id": original_id, + "result": data.get("result").cloned().unwrap_or(json!({})), + }); + if let Some(err) = data.get("error") { + response["error"] = json!({ "message": err }); + } + if let Some(sess) = session_id { + response["sessionId"] = json!(sess); + } + let _ = client_tx.send(response.to_string()); + } + } + } + } + } + } + } + } + } + + *state.extension_conn.write().await = None; + } + else if req.path == "/devtools/browser" { + { + let mut browser = state.browser_sockets.lock().await; + browser.push(client); + } + + while let Some(Ok(msg)) = ws_read.next().await { + if let tokio_tungstenite::tungstenite::Message::Text(text) = msg { + handle_browser_cdp_message(text.to_string(), tx.clone(), state.clone()).await; + } + } + + { + let mut browser = state.browser_sockets.lock().await; + browser.retain(|c| c.id != client_id); + } + } + else if req.path.starts_with("/devtools/page/") { + let tab_id = req.path.trim_start_matches("/devtools/page/").to_string(); + { + let mut page = state.page_sockets.lock().await; + page.entry(tab_id.clone()).or_insert_with(Vec::new).push(client); + } + + while let Some(Ok(msg)) = ws_read.next().await { + if let tokio_tungstenite::tungstenite::Message::Text(text) = msg { + handle_page_cdp_message(tab_id.clone(), text.to_string(), tx.clone(), state.clone()).await; + } + } + + { + let mut page = state.page_sockets.lock().await; + if let Some(clients) = page.get_mut(&tab_id) { + clients.retain(|c| c.id != client_id); + } + } + } +} + +static CLIENT_ID_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +async fn handle_browser_cdp_message(msg_str: String, client_tx: mpsc::UnboundedSender, state: Arc) { + if let Ok(msg) = serde_json::from_str::(&msg_str) { + let method = msg.get("method").and_then(|m| m.as_str()).unwrap_or(""); + let msg_id = msg.get("id").and_then(|i| i.as_u64()).unwrap_or(0); + + if method == "Browser.getVersion" { + let res = json!({ + "id": msg_id, + "result": { + "protocolVersion": "1.3", + "product": "Chrome/120.0.0.0", + "revision": "@a06414a2754673bc28ea7c71d60dd4d9c7af4718", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "jsVersion": "12.0.267" + } + }); + let _ = client_tx.send(res.to_string()); + } + else if method == "Target.getTargets" { + let id = { + let mut id_lock = state.next_request_id.lock().await; + *id_lock += 1; + *id_lock + }; + let (tx, rx) = oneshot::channel::(); + { + let mut pending = state.pending_requests.lock().await; + pending.insert(id, PendingRequest::Http(tx)); + } + let cmd = json!({ "id": id, "command": "list_tabs" }); + if send_to_extension(&state, &cmd.to_string()).await { + if let Ok(Ok(data)) = tokio::time::timeout(Duration::from_secs(2), rx).await { + let tabs = data.get("tabs").and_then(|t| t.as_array()); + let mut target_infos = Vec::new(); + if let Some(tabs) = tabs { + for t in tabs { + let tab_id = t.get("id").and_then(|id| id.as_u64()).map(|id| id.to_string()) + .or_else(|| t.get("id").and_then(|id| id.as_str()).map(|s| s.to_string())) + .unwrap_or_default(); + target_infos.push(json!({ + "targetId": tab_id, + "type": "page", + "title": t.get("title").and_then(|title| title.as_str()).unwrap_or("Chrome Tab"), + "url": t.get("url").and_then(|url| url.as_str()).unwrap_or("about:blank"), + "attached": false, + "canAccessOpener": false, + "browserContextId": "default" + })); + } + } + let res = json!({ + "id": msg_id, + "result": { "targetInfos": target_infos } + }); + let _ = client_tx.send(res.to_string()); + } + } + } + else if method == "Target.attachToTarget" { + let tab_id = msg.get("params").and_then(|p| p.get("targetId")).and_then(|t| t.as_str()).unwrap_or(""); + let session_id = format!("session-{tab_id}"); + + let id = { + let mut id_lock = state.next_request_id.lock().await; + *id_lock += 1; + *id_lock + }; + let cmd = json!({ + "id": id, + "command": "attach", + "params": { "tabId": tab_id } + }); + let _ = send_to_extension(&state, &cmd.to_string()).await; + + let res = json!({ + "id": msg_id, + "result": { "sessionId": session_id } + }); + let _ = client_tx.send(res.to_string()); + + let event = json!({ + "method": "Target.attachedToTarget", + "params": { + "sessionId": session_id, + "targetInfo": { + "targetId": tab_id, + "type": "page", + "title": "Chrome Tab", + "url": "about:blank", + "attached": true, + "canAccessOpener": false, + "browserContextId": "default" + }, + "waitingForDebugger": false + } + }); + let _ = client_tx.send(event.to_string()); + } + } +} + +async fn handle_page_cdp_message(tab_id: String, msg_str: String, client_tx: mpsc::UnboundedSender, state: Arc) { + if let Ok(msg) = serde_json::from_str::(&msg_str) { + let method = msg.get("method").and_then(|m| m.as_str()).unwrap_or(""); + let msg_id = msg.get("id").and_then(|i| i.as_u64()).unwrap_or(0); + let params = msg.get("params").cloned().unwrap_or(json!({})); + + if method.starts_with("Browser.") { + if method == "Browser.getVersion" { + let res = json!({ + "id": msg_id, + "result": { + "protocolVersion": "1.3", + "product": "Chrome/120.0.0.0", + "revision": "@a06414a2754673bc28ea7c71d60dd4d9c7af4718", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "jsVersion": "12.0.267" + } + }); + let _ = client_tx.send(res.to_string()); + } else { + let res = json!({ "id": msg_id, "result": {} }); + let _ = client_tx.send(res.to_string()); + } + return; + } + if method == "Target.setAutoAttach" { + let res = json!({ "id": msg_id, "result": {} }); + let _ = client_tx.send(res.to_string()); + return; + } + if method == "Target.getTargetInfo" { + let res = json!({ + "id": msg_id, + "result": { + "targetInfo": { + "targetId": tab_id, + "type": "page", + "title": "Chrome Tab", + "url": "about:blank", + "attached": true, + "canAccessOpener": false, + "browserContextId": "default" + } + } + }); + let _ = client_tx.send(res.to_string()); + return; + } + + let id = { + let mut id_lock = state.next_request_id.lock().await; + *id_lock += 1; + *id_lock + }; + { + let mut pending = state.pending_requests.lock().await; + pending.insert(id, PendingRequest::Cdp { + client_tx: client_tx.clone(), + original_id: msg_id, + session_id: msg.get("sessionId").and_then(|s| s.as_str()).map(|s| s.to_string()), + }); + } + + let cmd = json!({ + "id": id, + "command": "cdp_command", + "params": { + "tabId": tab_id, + "method": method, + "cdpParams": params + } + }); + + if !send_to_extension(&state, &cmd.to_string()).await { + let mut pending = state.pending_requests.lock().await; + pending.remove(&id); + let res = json!({ + "id": msg_id, + "error": { "message": "Extension not connected" } + }); + let _ = client_tx.send(res.to_string()); + } + } +} + +// --------------------------------------------------------------------------- +// MCP Server Logic +// --------------------------------------------------------------------------- + +async fn run_mcp_server(secret: String, port: u16) { + let stdin = tokio::io::stdin(); + let mut reader = BufReader::new(stdin).lines(); + + while let Ok(Some(line)) = reader.next_line().await { + if let Ok(req) = serde_json::from_str::(&line) { + let method = req.get("method").and_then(|m| m.as_str()).unwrap_or(""); + let req_id = req.get("id").cloned(); + + match method { + "initialize" => { + let res = json!({ + "jsonrpc": "2.0", + "id": req_id, + "result": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "serverInfo": { + "name": "sinew-chrome-rust", + "version": "1.0.0" + } + } + }); + println!("{}", res); + } + "tools/list" => { + let tools = get_mcp_tools_list(); + let res = json!({ + "jsonrpc": "2.0", + "id": req_id, + "result": { "tools": tools } + }); + println!("{}", res); + } + "tools/call" => { + let params = req.get("params"); + let name = params.and_then(|p| p.get("name")).and_then(|n| n.as_str()).unwrap_or(""); + let arguments = params.and_then(|p| p.get("arguments")).cloned().unwrap_or(json!({})); + + let call_res = handle_mcp_tool_call(name, arguments, &secret, port).await; + let res = json!({ + "jsonrpc": "2.0", + "id": req_id, + "result": call_res + }); + println!("{}", res); + } + _ => { + if let Some(id) = req_id { + let res = json!({ + "jsonrpc": "2.0", + "id": id, + "error": { "code": -32601, "message": "Method not found" } + }); + println!("{}", res); + } + } + } + } + } +} + +fn get_mcp_tools_list() -> serde_json::Value { + json!([ + { + "name": "computer_use", + "description": "Control the Windows desktop. Take screenshots, move mouse, click, type text, or press keyboard shortcuts.", + "inputSchema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "screenshot", + "mouse_move", + "left_click", + "right_click", + "middle_click", + "double_click", + "left_click_drag", + "type", + "key", + "cursor_position" + ], + "description": "The computer use action to perform." + }, + "coordinate": { + "type": "array", + "items": { "type": "integer" }, + "minItems": 2, + "maxItems": 2, + "description": "Optional [x, y] screen coordinates for mouse action." + }, + "text": { + "type": "string", + "description": "Text to type or key name to press." + } + }, + "required": ["action"] + } + }, + { + "name": "open_browser", + "description": "Ouvre Google Chrome localement vers une URL et prépare un onglet contrôlable. Pour les requêtes de navigation pure, utilisez ceci et arrêtez-vous ; ne cliquez pas après.", + "inputSchema": { + "type": "object", + "properties": { + "url": { "type": "string", "description": "URL optionnelle à ouvrir" } + } + } + }, + { + "name": "navigate", + "description": "Navigue l’onglet Chrome contrôlé vers une URL. Pour les requêtes de navigation pure, utilisez ceci et arrêtez-vous.", + "inputSchema": { + "type": "object", + "properties": { + "url": { "type": "string", "description": "URL ou domaine à ouvrir" } + }, + "required": ["url"] + } + }, + { + "name": "click_selector", + "description": "TURBO: clique directement sur un sélecteur CSS visible, sans délai de curseur humain. Préféré quand le sélecteur est connu.", + "inputSchema": { + "type": "object", + "properties": { + "selector": { "type": "string" }, + "timeoutMs": { "type": "number" }, + "scroll": { "type": "boolean" } + }, + "required": ["selector"] + } + }, + { + "name": "type_selector", + "description": "TURBO: tape du texte directement dans un champ de saisie sélectionné par son sélecteur CSS. Préféré pour saisir du texte.", + "inputSchema": { + "type": "object", + "properties": { + "selector": { "type": "string" }, + "text": { "type": "string" }, + "submit": { "type": "boolean" }, + "timeoutMs": { "type": "number" } + }, + "required": ["selector", "text"] + } + }, + { + "name": "page_snapshot", + "description": "Retourne une capture structurée du DOM des éléments interactifs visibles. À utiliser avant click_selector/type_selector quand le sélecteur est inconnu.", + "inputSchema": { + "type": "object", + "properties": { + "limit": { "type": "number", "description": "Nombre maximal d'éléments" } + } + } + }, + { + "name": "click", + "description": "Clic heuristique par texte visible, label aria, id, classe ou description. Préférer click_selector si le sélecteur est connu.", + "inputSchema": { + "type": "object", + "properties": { + "target": { "type": "string", "description": "Cible à cliquer" }, + "timeoutMs": { "type": "number" }, + "cursor": { + "type": "object", + "properties": { + "mode": { "type": "string", "enum": ["visible", "hidden"] }, + "speed": { "type": "string", "enum": ["slow", "normal", "fast"] } + } + } + }, + "required": ["target"] + } + }, + { + "name": "evaluate", + "description": "Évalue une petite expression JavaScript sur la page active et retourne la valeur sérialisable.", + "inputSchema": { + "type": "object", + "properties": { + "expression": { "type": "string" }, + "timeoutMs": { "type": "number" } + }, + "required": ["expression"] + } + }, + { + "name": "screenshot", + "description": "Capture une image de l’onglet Chrome actif via CDP local.", + "inputSchema": { + "type": "object", + "properties": { + "format": { "type": "string", "enum": ["jpeg", "png"] }, + "quality": { "type": "number" } + } + } + }, + { + "name": "wait_for_selector", + "description": "Attend qu'un sélecteur CSS existe/soit visible sur la page.", + "inputSchema": { + "type": "object", + "properties": { + "selector": { "type": "string" }, + "visible": { "type": "boolean" }, + "timeoutMs": { "type": "number" } + }, + "required": ["selector"] + } + }, + { + "name": "query_selector", + "description": "Inspecte un sélecteur CSS et retourne ses textes, attributs, visibilité et coordonnées.", + "inputSchema": { + "type": "object", + "properties": { + "selector": { "type": "string" }, + "timeoutMs": { "type": "number" }, + "scroll": { "type": "boolean" } + }, + "required": ["selector"] + } + }, + { + "name": "wait_for_text", + "description": "Attend qu'un texte apparaisse sur la page active.", + "inputSchema": { + "type": "object", + "properties": { + "text": { "type": "string" }, + "timeoutMs": { "type": "number" } + }, + "required": ["text"] + } + }, + { + "name": "get_page_state", + "description": "Retourne l'état local de la page active.", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "select_option", + "description": "Sélectionne une option dans un élément HTML select par sa valeur, son texte ou son index.", + "inputSchema": { + "type": "object", + "properties": { + "selector": { "type": "string" }, + "value": { "type": "string" }, + "label": { "type": "string" }, + "index": { "type": "number" }, + "timeoutMs": { "type": "number" } + }, + "required": ["selector"] + } + }, + { + "name": "press_key", + "description": "Simule l'appui d'une touche clavier sur l'élément actif ou le sélecteur spécifié.", + "inputSchema": { + "type": "object", + "properties": { + "key": { "type": "string" }, + "selector": { "type": "string" }, + "code": { "type": "string" }, + "ctrlKey": { "type": "boolean" }, + "shiftKey": { "type": "boolean" }, + "altKey": { "type": "boolean" }, + "metaKey": { "type": "boolean" }, + "submit": { "type": "boolean" }, + "timeoutMs": { "type": "number" } + }, + "required": ["key"] + } + }, + { + "name": "run_browser_agent", + "description": "Agent de navigation en langage naturel de secours pour tâches complexes ou ambiguës.", + "inputSchema": { + "type": "object", + "properties": { + "task": { "type": "string", "description": "Description de la tâche à accomplir" }, + "cursor": { + "type": "object", + "properties": { + "mode": { "type": "string", "enum": ["visible", "hidden"] }, + "speed": { "type": "string", "enum": ["slow", "normal", "fast"] } + } + } + }, + "required": ["task"] + } + }, + { + "name": "emulate_experience", + "description": "Configure les profils mobiles, la bande passante réseau lente ou l'étranglement CPU pour tester le comportement de la page.", + "inputSchema": { + "type": "object", + "properties": { + "device": { "type": "string", "enum": ["none", "mobile", "tablet"] }, + "network": { "type": "string", "enum": ["none", "offline", "slow-3g", "fast-3g", "4g", "wifi"] }, + "cpuThrottling": { "type": "number" } + } + } + }, + { + "name": "lighthouse_audit", + "description": "Simule un audit Lighthouse de performance, d'accessibilité, de bonnes pratiques et SEO directement en inspectant le DOM.", + "inputSchema": { + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { "type": "string" } + } + } + } + }, + { + "name": "analyze_memory_leaks", + "description": "Analyse les nœuds DOM, la taille du tas JS et les iframes actifs pour détecter les fuites de mémoire.", + "inputSchema": { + "type": "object", + "properties": {} + } + } + ]) +} + +async fn make_api_call( + client: &reqwest::Client, + base_url: &str, + secret: &str, + tab_id: &str, + endpoint: &str, + query: Vec<(&str, String)>, +) -> serde_json::Value { + let mut request = client.get(format!("{base_url}/api/{endpoint}")) + .query(&[("token", secret), ("tabId", tab_id)]); + if !query.is_empty() { + request = request.query(&query); + } + match request.send().await { + Ok(res) => match res.json::().await { + Ok(val) => { + let is_error = val.get("success").and_then(|s| s.as_bool()).map(|s| !s) + .or_else(|| val.get("ok").and_then(|o| o.as_bool()).map(|o| !o)) + .unwrap_or(false); + json!({ + "content": [{ "type": "text", "text": val.to_string() }], + "isError": is_error + }) + } + Err(e) => json!({ "content": [{ "type": "text", "text": format!("Failed to parse response: {e}") }], "isError": true }), + }, + Err(e) => json!({ "content": [{ "type": "text", "text": format!("API call failed: {e}") }], "isError": true }), + } +} + +async fn call_cdp_command( + client: &reqwest::Client, + base_url: &str, + secret: &str, + tab_id: &str, + method: &str, + params: serde_json::Value, +) -> Result { + let request = client.get(format!("{base_url}/api/cdp_command")) + .query(&[ + ("token", secret), + ("tabId", tab_id), + ("method", method), + ("cdpParams", ¶ms.to_string()), + ]); + + match request.send().await { + Ok(res) => res.json::().await, + Err(e) => Err(e), + } +} + +async fn handle_mcp_tool_call(name: &str, arguments: serde_json::Value, secret: &str, port: u16) -> serde_json::Value { + let client = reqwest::Client::new(); + let base_url = format!("http://127.0.0.1:{port}"); + + let tabs_url = format!("{}/json?token={}", base_url, secret); + let tabs_res = match client.get(&tabs_url).send().await { + Ok(res) => res.json::().await.unwrap_or(json!([])), + Err(_) => json!([]), + }; + + let active_tab_id = tabs_res.as_array() + .and_then(|arr| arr.iter().find(|t| t.get("active").and_then(|a| a.as_bool()).unwrap_or(false)) + .or_else(|| arr.first()) + ) + .and_then(|t| t.get("id").and_then(|id| id.as_str()).map(|s| s.to_string())); + + let tab_id = active_tab_id.unwrap_or_else(|| "1".to_string()); + + match name { + "open_browser" => { + let url = arguments.get("url").and_then(|u| u.as_str()).unwrap_or("https://www.google.com"); + make_api_call(&client, &base_url, secret, &tab_id, "launch_chrome", vec![("url", url.to_string())]).await + } + "navigate" => { + let url = arguments.get("url").and_then(|u| u.as_str()).unwrap_or(""); + make_api_call(&client, &base_url, secret, &tab_id, "navigate_tab", vec![("url", url.to_string())]).await + } + "click_selector" => { + let selector = arguments.get("selector").and_then(|s| s.as_str()).unwrap_or(""); + let scroll = arguments.get("scroll").and_then(|s| s.as_bool()).map(|s| s.to_string()).unwrap_or("true".to_string()); + let timeout = arguments.get("timeoutMs").and_then(|t| t.as_f64()).map(|t| (t as u64).to_string()).unwrap_or("15000".to_string()); + make_api_call(&client, &base_url, secret, &tab_id, "click_selector", vec![ + ("selector", selector.to_string()), + ("scroll", scroll), + ("timeoutMs", timeout), + ]).await + } + "type_selector" => { + let selector = arguments.get("selector").and_then(|s| s.as_str()).unwrap_or(""); + let text = arguments.get("text").and_then(|t| t.as_str()).unwrap_or(""); + let submit = arguments.get("submit").and_then(|s| s.as_bool()).map(|s| s.to_string()).unwrap_or("false".to_string()); + let timeout = arguments.get("timeoutMs").and_then(|t| t.as_f64()).map(|t| (t as u64).to_string()).unwrap_or("15000".to_string()); + make_api_call(&client, &base_url, secret, &tab_id, "type_selector", vec![ + ("selector", selector.to_string()), + ("text", text.to_string()), + ("submit", submit), + ("timeoutMs", timeout), + ]).await + } + "page_snapshot" => { + let limit = arguments.get("limit").and_then(|l| l.as_f64()).map(|l| (l as u64).to_string()).unwrap_or("80".to_string()); + make_api_call(&client, &base_url, secret, &tab_id, "page_snapshot", vec![("limit", limit)]).await + } + "click" => { + let target = arguments.get("target").and_then(|t| t.as_str()).unwrap_or(""); + let cursor = arguments.get("cursor").cloned().unwrap_or(json!({})); + make_api_call(&client, &base_url, secret, &tab_id, "human_click", vec![ + ("target", target.to_string()), + ("cursor", cursor.to_string()), + ]).await + } + "evaluate" => { + let expression = arguments.get("expression").and_then(|e| e.as_str()).unwrap_or(""); + make_api_call(&client, &base_url, secret, &tab_id, "evaluate", vec![("expression", expression.to_string())]).await + } + "screenshot" => { + let format = arguments.get("format").and_then(|f| f.as_str()).unwrap_or("jpeg"); + let quality = arguments.get("quality").and_then(|q| q.as_f64()).unwrap_or(70.0) as i64; + + let cdp_params = json!({ + "format": format, + "quality": quality, + "fromSurface": true + }); + + let request = client.get(format!("{base_url}/api/cdp_command")) + .query(&[ + ("token", secret), + ("tabId", &tab_id), + ("method", "Page.captureScreenshot"), + ("cdpParams", &cdp_params.to_string()), + ]); + + match request.send().await { + Ok(res) => match res.json::().await { + Ok(val) => { + let data = val.get("result").and_then(|r| r.get("data")).and_then(|d| d.as_str()).unwrap_or(""); + if !data.is_empty() { + json!({ + "content": [ + { "type": "text", "text": "[image/jpeg]" }, + { "type": "image", "mimeType": format!("image/{format}"), "data": data } + ], + "isError": false + }) + } else { + json!({ "content": [{ "type": "text", "text": format!("Screenshot empty: {val}") }], "isError": true }) + } + } + Err(e) => json!({ "content": [{ "type": "text", "text": format!("Failed to parse response: {e}") }], "isError": true }), + }, + Err(e) => json!({ "content": [{ "type": "text", "text": format!("API call failed: {e}") }], "isError": true }), + } + } + "wait_for_selector" => { + let selector = arguments.get("selector").and_then(|s| s.as_str()).unwrap_or(""); + let visible = arguments.get("visible").and_then(|v| v.as_bool()).map(|v| v.to_string()).unwrap_or("true".to_string()); + let timeout = arguments.get("timeoutMs").and_then(|t| t.as_f64()).map(|t| (t as u64).to_string()).unwrap_or("15000".to_string()); + make_api_call(&client, &base_url, secret, &tab_id, "wait_selector", vec![ + ("selector", selector.to_string()), + ("visible", visible), + ("timeoutMs", timeout), + ]).await + } + "query_selector" => { + let selector = arguments.get("selector").and_then(|s| s.as_str()).unwrap_or(""); + make_api_call(&client, &base_url, secret, &tab_id, "query_selector", vec![("selector", selector.to_string())]).await + } + "wait_for_text" => { + let text = arguments.get("text").and_then(|t| t.as_str()).unwrap_or(""); + let expression = format!( + "(() => (document.body?.innerText || document.documentElement?.innerText || '').toLowerCase().includes({}))()", + json!(text.to_lowercase()) + ); + make_api_call(&client, &base_url, secret, &tab_id, "evaluate", vec![("expression", expression)]).await + } + "get_page_state" => { + let expression = r#"(() => ({ + href: location.href, + title: document.title, + readyState: document.readyState, + visibleTextLength: (document.body?.innerText || '').length, + interactiveCount: document.querySelectorAll('button, a, input, select, textarea, [role="button"], [onclick], article, section').length, + viewport: { width: window.innerWidth, height: window.innerHeight } + }))()"#; + make_api_call(&client, &base_url, secret, &tab_id, "evaluate", vec![("expression", expression.to_string())]).await + } + "select_option" => { + let selector = arguments.get("selector").and_then(|s| s.as_str()).unwrap_or(""); + let value = arguments.get("value").and_then(|v| v.as_str()).map(|s| s.to_string()).unwrap_or_default(); + let label = arguments.get("label").and_then(|l| l.as_str()).map(|s| s.to_string()).unwrap_or_default(); + let index = arguments.get("index").and_then(|i| i.as_i64()).map(|i| i.to_string()).unwrap_or_default(); + make_api_call(&client, &base_url, secret, &tab_id, "select_option", vec![ + ("selector", selector.to_string()), + ("value", value), + ("label", label), + ("index", index), + ]).await + } + "press_key" => { + let key = arguments.get("key").and_then(|k| k.as_str()).unwrap_or(""); + let selector = arguments.get("selector").and_then(|s| s.as_str()).unwrap_or_default(); + let code = arguments.get("code").and_then(|c| c.as_str()).unwrap_or_default(); + let ctrl = arguments.get("ctrlKey").and_then(|b| b.as_bool()).map(|b| b.to_string()).unwrap_or_default(); + let shift = arguments.get("shiftKey").and_then(|b| b.as_bool()).map(|b| b.to_string()).unwrap_or_default(); + let alt = arguments.get("altKey").and_then(|b| b.as_bool()).map(|b| b.to_string()).unwrap_or_default(); + let meta = arguments.get("metaKey").and_then(|b| b.as_bool()).map(|b| b.to_string()).unwrap_or_default(); + let submit = arguments.get("submit").and_then(|b| b.as_bool()).map(|b| b.to_string()).unwrap_or_default(); + make_api_call(&client, &base_url, secret, &tab_id, "press_key", vec![ + ("key", key.to_string()), + ("selector", selector.to_string()), + ("code", code.to_string()), + ("ctrlKey", ctrl), + ("shiftKey", shift), + ("altKey", alt), + ("metaKey", meta), + ("submit", submit), + ]).await + } + "run_browser_agent" => { + let task = arguments.get("task").and_then(|t| t.as_str()).unwrap_or(""); + make_api_call(&client, &base_url, secret, &tab_id, "execute_silent_task", vec![("task", task.to_string())]).await + } + "emulate_experience" => { + let device = arguments.get("device").and_then(|d| d.as_str()).unwrap_or("none"); + let network = arguments.get("network").and_then(|n| n.as_str()).unwrap_or("none"); + let cpu_throttling = arguments.get("cpuThrottling").and_then(|c| c.as_f64()).unwrap_or(0.0) as i64; + + if device != "none" { + let mut width = 1440; + let mut height = 900; + let mut device_scale_factor = 1.0; + let mut mobile = false; + + if device == "mobile" { + width = 375; + height = 812; + device_scale_factor = 3.0; + mobile = true; + } else if device == "tablet" { + width = 768; + height = 1024; + device_scale_factor = 2.0; + mobile = true; + } + + let cdp_params = json!({ + "width": width, + "height": height, + "deviceScaleFactor": device_scale_factor, + "mobile": mobile, + "screenOrientation": { + "angle": 0, + "type": if mobile { "portraitPrimary" } else { "landscapePrimary" } + } + }); + + let _ = call_cdp_command(&client, &base_url, secret, &tab_id, "Emulation.setDeviceMetricsOverride", cdp_params).await; + + let touch_params = json!({ + "enabled": mobile, + "maxTouchPoints": 5 + }); + let _ = call_cdp_command(&client, &base_url, secret, &tab_id, "Emulation.setTouchEmulationEnabled", touch_params).await; + } else { + let _ = call_cdp_command(&client, &base_url, secret, &tab_id, "Emulation.clearDeviceMetricsOverride", json!({})).await; + let _ = call_cdp_command(&client, &base_url, secret, &tab_id, "Emulation.setTouchEmulationEnabled", json!({"enabled": false})).await; + } + + if network != "none" && network != "online" { + let mut offline = false; + let mut latency = 0; + let mut download_throughput = -1; + let mut upload_throughput = -1; + + if network == "offline" { + offline = true; + download_throughput = 0; + upload_throughput = 0; + } else if network == "slow-3g" { + latency = 400; + download_throughput = (400.0 * 1024.0 / 8.0) as i64; + upload_throughput = (150.0 * 1024.0 / 8.0) as i64; + } else if network == "fast-3g" { + latency = 150; + download_throughput = (1.6 * 1024.0 * 1024.0 / 8.0) as i64; + upload_throughput = (750.0 * 1024.0 / 8.0) as i64; + } else if network == "4g" { + latency = 50; + download_throughput = (10.0 * 1024.0 * 1024.0 / 8.0) as i64; + upload_throughput = (3.0 * 1024.0 * 1024.0 / 8.0) as i64; + } else if network == "wifi" { + latency = 10; + download_throughput = (50.0 * 1024.0 * 1024.0 / 8.0) as i64; + upload_throughput = (10.0 * 1024.0 * 1024.0 / 8.0) as i64; + } + + let net_params = json!({ + "offline": offline, + "latency": latency, + "downloadThroughput": download_throughput, + "uploadThroughput": upload_throughput + }); + let _ = call_cdp_command(&client, &base_url, secret, &tab_id, "Network.emulateNetworkConditions", net_params).await; + } else { + let net_params = json!({ + "offline": false, + "latency": 0, + "downloadThroughput": -1, + "uploadThroughput": -1 + }); + let _ = call_cdp_command(&client, &base_url, secret, &tab_id, "Network.emulateNetworkConditions", net_params).await; + } + + let cpu_params = json!({ + "rate": cpu_throttling + }); + let _ = call_cdp_command(&client, &base_url, secret, &tab_id, "Emulation.setCPUThrottlingRate", cpu_params).await; + + json!({ + "content": [{ "type": "text", "text": "Emulation settings applied successfully." }], + "isError": false + }) + } + "lighthouse_audit" => { + let categories = arguments.get("categories").cloned().unwrap_or(json!(["performance", "accessibility", "seo", "best-practices"])); + let categories_json = categories.to_string(); + + let js_expr = format!( + "(function() {{ \ + var report = {{ url: location.href, timestamp: new Date().toISOString(), scores: {{}}, details: {{}} }}; \ + var categories = {}; \ + if (categories.indexOf('performance') !== -1) {{ \ + var perfScore = 100; \ + var details = []; \ + var t = window.performance ? window.performance.timing : null; \ + if (t) {{ \ + var loadTime = t.loadEventEnd - t.navigationStart; \ + var domReady = t.domComplete - t.navigationStart; \ + var dnsLookup = t.domainLookupEnd - t.domainLookupStart; \ + if (loadTime > 0) {{ \ + details.push({{ metric: 'Temps de chargement total', value: (loadTime / 1000).toFixed(2) + 's' }}); \ + if (loadTime > 4000) perfScore -= 25; \ + else if (loadTime > 2000) perfScore -= 10; \ + }} \ + if (domReady > 0) {{ \ + details.push({{ metric: 'DOM complet', value: (domReady / 1000).toFixed(2) + 's' }}); \ + if (domReady > 2500) perfScore -= 15; \ + }} \ + if (dnsLookup > 0) {{ \ + details.push({{ metric: 'Résolution DNS', value: dnsLookup + 'ms' }}); \ + }} \ + }} \ + var images = Array.from(document.querySelectorAll('img')); \ + var unoptimizedImages = images.filter(function(img) {{ \ + var rect = img.getBoundingClientRect(); \ + return rect.width > 0 && !img.src.endsWith('.svg') && !img.srcset; \ + }}); \ + if (unoptimizedImages.length > 0) {{ \ + perfScore -= Math.min(15, unoptimizedImages.length * 3); \ + details.push({{ metric: 'Images non réactives (sans srcset)', count: unoptimizedImages.length }}); \ + }} \ + var scriptsCount = document.querySelectorAll('script').length; \ + details.push({{ metric: 'Scripts JavaScript chargés', count: scriptsCount }}); \ + if (scriptsCount > 30) perfScore -= 10; \ + report.scores.performance = Math.max(20, perfScore); \ + report.details.performance = details; \ + }} \ + if (categories.indexOf('accessibility') !== -1) {{ \ + var accScore = 100; \ + var details = []; \ + var images = Array.from(document.querySelectorAll('img')); \ + var missingAlt = images.filter(function(img) {{ return !img.hasAttribute('alt') || img.getAttribute('alt').trim() === ''; }}); \ + if (missingAlt.length > 0) {{ \ + accScore -= Math.min(30, missingAlt.length * 8); \ + details.push({{ metric: 'Images sans attribut alt', count: missingAlt.length }}); \ + }} \ + var inputs = Array.from(document.querySelectorAll('input:not([type=\"hidden\"]), select, textarea')); \ + var unlabeledInputs = inputs.filter(function(inp) {{ \ + if (inp.id) {{ \ + var label = document.querySelector('label[for=\"' + inp.id + '\"]'); \ + if (label) return false; \ + }} \ + if (inp.closest('label')) return false; \ + if (inp.getAttribute('aria-label') || inp.getAttribute('aria-labelledby')) return false; \ + if (inp.getAttribute('title')) return false; \ + return true; \ + }}); \ + if (unlabeledInputs.length > 0) {{ \ + accScore -= Math.min(25, unlabeledInputs.length * 10); \ + details.push({{ metric: 'Champs de saisie sans étiquette ou description', count: unlabeledInputs.length }}); \ + }} \ + var lang = document.documentElement.getAttribute('lang'); \ + if (!lang) {{ \ + accScore -= 15; \ + details.push({{ metric: 'Balise HTML sans attribut lang de langue', status: 'Manquant' }}); \ + }} else {{ \ + details.push({{ metric: 'Attribut lang défini', value: lang }}); \ + }} \ + var hTags = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6')).map(function(h) {{ return parseInt(h.tagName[1]); }}); \ + var badHeaderOrder = false; \ + for (var i = 1; i < hTags.length; i++) {{ \ + if (hTags[i] - hTags[i-1] > 1) {{ badHeaderOrder = true; break; }} \ + }} \ + if (badHeaderOrder) {{ \ + accScore -= 10; \ + details.push({{ metric: 'Structure des titres (Hn) non séquentielle', status: 'Non-conforme' }}); \ + }} \ + report.scores.accessibility = Math.max(30, accScore); \ + report.details.accessibility = details; \ + }} \ + if (categories.indexOf('seo') !== -1) {{ \ + var seoScore = 100; \ + var details = []; \ + var title = document.title; \ + if (!title || title.trim().length === 0) {{ \ + seoScore -= 30; \ + details.push({{ metric: 'Titre de la page', status: 'Manquant ou vide' }}); \ + }} else {{ \ + details.push({{ metric: 'Titre de la page conforme', value: title }}); \ + if (title.length > 60) {{ \ + seoScore -= 5; \ + details.push({{ metric: 'Titre trop long', value: title.length + ' car.' }}); \ + }} \ + }} \ + var metaDesc = document.querySelector('meta[name=\"description\"]'); \ + if (!metaDesc || !metaDesc.getAttribute('content') || metaDesc.getAttribute('content').trim().length === 0) {{ \ + seoScore -= 30; \ + details.push({{ metric: 'Méta-description de la page', status: 'Manquant ou vide' }}); \ + }} else {{ \ + details.push({{ metric: 'Méta-description trouvée', value: metaDesc.getAttribute('content').substring(0, 40) + '...' }}); \ + }} \ + var viewport = document.querySelector('meta[name=\"viewport\"]'); \ + if (!viewport) {{ \ + seoScore -= 20; \ + details.push({{ metric: 'Méta viewport mobile', status: 'Manquant' }}); \ + }} \ + var h1s = document.querySelectorAll('h1'); \ + if (h1s.length === 0) {{ \ + seoScore -= 15; \ + details.push({{ metric: 'Titre H1', status: 'Manquant' }}); \ + }} else if (h1s.length > 1) {{ \ + seoScore -= 5; \ + details.push({{ metric: 'Plusieurs H1 détectés', count: h1s.length }}); \ + }} \ + report.scores.seo = Math.max(40, seoScore); \ + report.details.seo = details; \ + }} \ + if (categories.indexOf('best-practices') !== -1) {{ \ + var bpScore = 100; \ + var details = []; \ + var isHttps = location.protocol === 'https:'; \ + if (!isHttps && location.hostname !== 'localhost' && location.hostname !== '127.0.0.1') {{ \ + bpScore -= 30; \ + details.push({{ metric: 'Connexion sécurisée (HTTPS)', status: 'Non-sécurisé (HTTP)' }}); \ + }} \ + details.push({{ metric: 'Doctype HTML5 présent', status: document.doctype ? 'Oui' : 'Non' }}); \ + if (!document.doctype) bpScore -= 10; \ + report.scores.bestpractices = Math.max(40, bpScore); \ + report.details.bestpractices = details; \ + }} \ + return report; \ + }})()", + categories_json + ); + + let cdp_params = json!({ + "expression": js_expr, + "returnByValue": true + }); + + match call_cdp_command(&client, &base_url, secret, &tab_id, "Runtime.evaluate", cdp_params).await { + Ok(val) => { + let result_val = val.get("result").and_then(|r| r.get("value")).cloned().unwrap_or(json!({})); + json!({ + "content": [{ "type": "text", "text": result_val.to_string() }], + "isError": false + }) + } + Err(e) => json!({ "content": [{ "type": "text", "text": format!("Lighthouse audit evaluate failed: {e}") }], "isError": true }), + } + } + "analyze_memory_leaks" => { + let _ = call_cdp_command(&client, &base_url, secret, &tab_id, "Performance.enable", json!({})).await; + + let metrics_val = match call_cdp_command(&client, &base_url, secret, &tab_id, "Performance.getMetrics", json!({})).await { + Ok(val) => val, + Err(e) => return json!({ "content": [{ "type": "text", "text": format!("Failed to get performance metrics: {e}") }], "isError": true }), + }; + + let mut metrics_map = HashMap::new(); + if let Some(metrics_arr) = metrics_val.get("result").and_then(|r| r.get("metrics")).and_then(|m| m.as_array()) { + for m in metrics_arr { + let name = m.get("name").and_then(|n| n.as_str()).unwrap_or(""); + let value = m.get("value").and_then(|v| v.as_f64()).unwrap_or(0.0); + metrics_map.insert(name.to_string(), value); + } + } + + let js_heap_used = metrics_map.get("JSHeapUsedSize").copied().unwrap_or(0.0); + let js_heap_total = metrics_map.get("JSHeapTotalSize").copied().unwrap_or(0.0); + let dom_nodes_count = metrics_map.get("DOMNodes").copied().unwrap_or(0.0); + let layout_count = metrics_map.get("LayoutCount").copied().unwrap_or(0.0); + let recalc_style_count = metrics_map.get("RecalcStyleCount").copied().unwrap_or(0.0); + + let js_expr = "(function() { \ + return { \ + totalElements: document.querySelectorAll('*').length, \ + iframeCount: document.querySelectorAll('iframe').length, \ + scriptsCount: document.querySelectorAll('script').length, \ + canvasCount: document.querySelectorAll('canvas').length \ + }; \ + })()"; + + let dom_stats_val = match call_cdp_command(&client, &base_url, secret, &tab_id, "Runtime.evaluate", json!({"expression": js_expr, "returnByValue": true})).await { + Ok(val) => val.get("result").and_then(|r| r.get("value")).cloned().unwrap_or(json!({})), + Err(_) => json!({}), + }; + + let total_elements = dom_stats_val.get("totalElements").and_then(|e| e.as_f64()).unwrap_or(0.0) as i64; + let iframe_count = dom_stats_val.get("iframeCount").and_then(|i| i.as_f64()).unwrap_or(0.0) as i64; + let scripts_count = dom_stats_val.get("scriptsCount").and_then(|s| s.as_f64()).unwrap_or(0.0) as i64; + let canvas_count = dom_stats_val.get("canvasCount").and_then(|c| c.as_f64()).unwrap_or(0.0) as i64; + + let _ = call_cdp_command(&client, &base_url, secret, &tab_id, "Performance.disable", json!({})).await; + + let js_heap_used_mb = js_heap_used / (1024.0 * 1024.0); + let js_heap_total_mb = js_heap_total / (1024.0 * 1024.0); + let heap_ratio = if js_heap_total > 0.0 { (js_heap_used / js_heap_total) * 100.0 } else { 0.0 }; + + let mut diagnostics = Vec::new(); + if js_heap_used > 80.0 * 1024.0 * 1024.0 { + diagnostics.push("⚠️ Utilisation élevée de la mémoire JS. La page consomme plus de 80 Mo."); + } else { + diagnostics.push("✅ Utilisation saine du tas mémoire (JS Heap)."); + } + + if dom_nodes_count > 3000.0 { + diagnostics.push("⚠️ Arbre DOM volumineux détecté (plus de 3000 nœuds). Cela peut ralentir le rendu."); + } else { + diagnostics.push("✅ Taille de l'arbre DOM dans les limites optimales."); + } + + if iframe_count > 5 { + diagnostics.push("⚠️ Nombreux iframes actifs."); + } + + let report = json!({ + "success": true, + "timestamp": chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%.3f").to_string(), + "memory": { + "jsHeapUsedBytes": js_heap_used as i64, + "jsHeapUsedMb": format!("{:.2} MB", js_heap_used_mb), + "jsHeapTotalBytes": js_heap_total as i64, + "jsHeapTotalMb": format!("{:.2} MB", js_heap_total_mb), + "heapRatio": format!("{:.1}%", heap_ratio) + }, + "dom": { + "cdpDomNodesReported": dom_nodes_count as i64, + "activeElementsCount": total_elements, + "iframeCount": iframe_count, + "scriptsCount": scripts_count, + "canvasCount": canvas_count + }, + "rendering": { + "layoutCount": layout_count as i64, + "recalcStyleCount": recalc_style_count as i64 + }, + "diagnostics": diagnostics + }); + + json!({ + "content": [{ "type": "text", "text": report.to_string() }], + "isError": false + }) + } + "computer_use" => { + let action = arguments.get("action").and_then(|a| a.as_str()).unwrap_or(""); + let coordinate = arguments.get("coordinate").and_then(|c| c.as_array()); + let text = arguments.get("text").and_then(|t| t.as_str()).unwrap_or(""); + match run_computer_use_action(action, coordinate, text) { + Ok(res) => res, + Err(err) => json!({ + "content": [{ "type": "text", "text": format!("Error: {}", err) }], + "isError": true + }) + } + } + _ => json!({ "content": [{ "type": "text", "text": format!("Tool {name} not implemented") }], "isError": true }), + } +} + +// --------------------------------------------------------------------------- +// Native Computer Use Action Handler +// --------------------------------------------------------------------------- + +#[cfg(target_os = "windows")] +#[link(name = "user32")] +extern "system" { + fn GetDC(hwnd: *mut std::ffi::c_void) -> *mut std::ffi::c_void; + fn ReleaseDC(hwnd: *mut std::ffi::c_void, hdc: *mut std::ffi::c_void) -> i32; + fn GetSystemMetrics(n_index: i32) -> i32; + fn SetCursorPos(x: i32, y: i32) -> i32; + fn mouse_event(dw_flags: u32, dx: i32, dy: i32, dw_data: u32, dw_extra_info: usize); + fn keybd_event(b_vk: u8, b_scan: u8, dw_flags: u32, dw_extra_info: usize); + fn GetCursorPos(lp_point: *mut POINT) -> i32; + fn VkKeyScanW(ch: u16) -> i16; +} + +#[repr(C)] +struct POINT { + x: i32, + y: i32, +} + +#[cfg(target_os = "windows")] +#[link(name = "gdi32")] +extern "system" { + fn CreateCompatibleDC(hdc: *mut std::ffi::c_void) -> *mut std::ffi::c_void; + fn CreateCompatibleBitmap(hdc: *mut std::ffi::c_void, cx: i32, cy: i32) -> *mut std::ffi::c_void; + fn SelectObject(hdc: *mut std::ffi::c_void, hgdiobj: *mut std::ffi::c_void) -> *mut std::ffi::c_void; + fn BitBlt(hdc_dest: *mut std::ffi::c_void, x_dest: i32, y_dest: i32, width: i32, height: i32, hdc_src: *mut std::ffi::c_void, x_src: i32, y_src: i32, dw_rop: u32) -> i32; + fn DeleteDC(hdc: *mut std::ffi::c_void) -> i32; + fn DeleteObject(ho: *mut std::ffi::c_void) -> i32; + fn GetDIBits(hdc: *mut std::ffi::c_void, hbm: *mut std::ffi::c_void, start: u32, lines: u32, lpv_bits: *mut u8, lpbmi: *mut BITMAPINFO, usage: u32) -> i32; +} + +#[cfg(target_os = "windows")] +const SRCCOPY: u32 = 0x00CC0020; +#[cfg(target_os = "windows")] +const DIB_RGB_COLORS: u32 = 0; +#[cfg(target_os = "windows")] +const BI_RGB: u32 = 0; + +#[derive(Clone, Copy)] +#[repr(C)] +struct BITMAPINFOHEADER { + bi_size: u32, + bi_width: i32, + bi_height: i32, + bi_planes: u16, + bi_bit_count: u16, + bi_compression: u32, + bi_size_image: u32, + bi_x_pels_per_meter: i32, + bi_y_pels_per_meter: i32, + bi_clr_used: u32, + bi_clr_important: u32, +} + +#[derive(Clone, Copy)] +#[repr(C)] +struct RGBQUAD { + rgb_blue: u8, + rgb_green: u8, + rgb_red: u8, + rgb_reserved: u8, +} + +#[repr(C)] +struct BITMAPINFO { + bmi_header: BITMAPINFOHEADER, + bmi_colors: [RGBQUAD; 1], +} + +#[cfg(not(target_os = "windows"))] +fn run_computer_use_action(_action: &str, _coordinate: Option<&Vec>, _text: &str) -> anyhow::Result { + anyhow::bail!("Computer Use is only supported on Windows.") +} + +#[cfg(target_os = "windows")] +fn run_computer_use_action(action: &str, coordinate: Option<&Vec>, text: &str) -> anyhow::Result { + use std::ptr::null_mut; + use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; + use base64::Engine as _; + + if let Some(coords) = coordinate { + if coords.len() >= 2 { + let x = coords[0].as_i64().unwrap_or(0) as i32; + let y = coords[1].as_i64().unwrap_or(0) as i32; + unsafe { + SetCursorPos(x, y); + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + } + + match action { + "screenshot" => { + unsafe { + let width = GetSystemMetrics(0); + let height = GetSystemMetrics(1); + if width <= 0 || height <= 0 { + anyhow::bail!("invalid screen dimensions: {}x{}", width, height); + } + + let hdc_screen = GetDC(null_mut()); + if hdc_screen.is_null() { + anyhow::bail!("failed to get screen DC"); + } + + let hdc_mem = CreateCompatibleDC(hdc_screen); + if hdc_mem.is_null() { + ReleaseDC(null_mut(), hdc_screen); + anyhow::bail!("failed to create compatible DC"); + } + + let h_bitmap = CreateCompatibleBitmap(hdc_screen, width, height); + if h_bitmap.is_null() { + DeleteDC(hdc_mem); + ReleaseDC(null_mut(), hdc_screen); + anyhow::bail!("failed to create compatible bitmap"); + } + + let h_old = SelectObject(hdc_mem, h_bitmap); + let success = BitBlt(hdc_mem, 0, 0, width, height, hdc_screen, 0, 0, SRCCOPY); + if success == 0 { + SelectObject(hdc_mem, h_old); + DeleteObject(h_bitmap); + DeleteDC(hdc_mem); + ReleaseDC(null_mut(), hdc_screen); + anyhow::bail!("failed BitBlt"); + } + + let mut bmi = BITMAPINFO { + bmi_header: BITMAPINFOHEADER { + bi_size: std::mem::size_of::() as u32, + bi_width: width, + bi_height: -height, + bi_planes: 1, + bi_bit_count: 32, + bi_compression: BI_RGB, + bi_size_image: 0, + bi_x_pels_per_meter: 0, + bi_y_pels_per_meter: 0, + bi_clr_used: 0, + bi_clr_important: 0, + }, + bmi_colors: [RGBQUAD { rgb_blue: 0, rgb_green: 0, rgb_red: 0, rgb_reserved: 0 }], + }; + + let mut buffer = vec![0u8; (width * height * 4) as usize]; + let lines_copied = GetDIBits( + hdc_mem, + h_bitmap, + 0, + height as u32, + buffer.as_mut_ptr(), + &mut bmi, + DIB_RGB_COLORS, + ); + + SelectObject(hdc_mem, h_old); + DeleteObject(h_bitmap); + DeleteDC(hdc_mem); + ReleaseDC(null_mut(), hdc_screen); + + if lines_copied == 0 { + anyhow::bail!("failed GetDIBits"); + } + + // Convert BGRA to RGBA + for chunk in buffer.chunks_exact_mut(4) { + let b = chunk[0]; + let r = chunk[2]; + chunk[0] = r; + chunk[2] = b; + } + + // Compress to JPEG using image crate + use image::ImageEncoder; + let mut jpeg_bytes = Vec::new(); + let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg_bytes, 85); + encoder.write_image( + &buffer, + width as u32, + height as u32, + image::ExtendedColorType::Rgba8, + )?; + + let base64_data = BASE64_STANDARD.encode(&jpeg_bytes); + Ok(json!({ + "content": [ + { + "type": "text", + "text": format!("Screenshot captured. Screen resolution: {}x{}", width, height) + }, + { + "type": "image", + "data": base64_data, + "mimeType": "image/jpeg" + } + ], + "isError": false + })) + } + } + "mouse_move" => { + if let Some(coords) = coordinate { + let x = coords[0].as_i64().unwrap_or(0) as i32; + let y = coords[1].as_i64().unwrap_or(0) as i32; + Ok(json!({ + "content": [{ "type": "text", "text": format!("Moved mouse to ({}, {})", x, y) }], + "isError": false + })) + } else { + anyhow::bail!("action mouse_move requires coordinate argument") + } + } + "left_click" => { + unsafe { + mouse_event(0x0002, 0, 0, 0, 0); // DOWN + std::thread::sleep(std::time::Duration::from_millis(50)); + mouse_event(0x0004, 0, 0, 0, 0); // UP + } + Ok(json!({ + "content": [{ "type": "text", "text": "Left click performed" }], + "isError": false + })) + } + "right_click" => { + unsafe { + mouse_event(0x0008, 0, 0, 0, 0); // DOWN + std::thread::sleep(std::time::Duration::from_millis(50)); + mouse_event(0x0010, 0, 0, 0, 0); // UP + } + Ok(json!({ + "content": [{ "type": "text", "text": "Right click performed" }], + "isError": false + })) + } + "middle_click" => { + unsafe { + mouse_event(0x0020, 0, 0, 0, 0); // DOWN + std::thread::sleep(std::time::Duration::from_millis(50)); + mouse_event(0x0040, 0, 0, 0, 0); // UP + } + Ok(json!({ + "content": [{ "type": "text", "text": "Middle click performed" }], + "isError": false + })) + } + "double_click" => { + unsafe { + mouse_event(0x0002, 0, 0, 0, 0); // DOWN + std::thread::sleep(std::time::Duration::from_millis(50)); + mouse_event(0x0004, 0, 0, 0, 0); // UP + std::thread::sleep(std::time::Duration::from_millis(150)); + mouse_event(0x0002, 0, 0, 0, 0); // DOWN + std::thread::sleep(std::time::Duration::from_millis(50)); + mouse_event(0x0004, 0, 0, 0, 0); // UP + } + Ok(json!({ + "content": [{ "type": "text", "text": "Double click performed" }], + "isError": false + })) + } + "left_click_drag" => { + if let Some(coords) = coordinate { + let x = coords[0].as_i64().unwrap_or(0) as i32; + let y = coords[1].as_i64().unwrap_or(0) as i32; + unsafe { + mouse_event(0x0002, 0, 0, 0, 0); // DOWN + std::thread::sleep(std::time::Duration::from_millis(50)); + SetCursorPos(x, y); + std::thread::sleep(std::time::Duration::from_millis(50)); + mouse_event(0x0004, 0, 0, 0, 0); // UP + } + Ok(json!({ + "content": [{ "type": "text", "text": format!("Left click drag performed to ({}, {})", x, y) }], + "isError": false + })) + } else { + anyhow::bail!("action left_click_drag requires coordinate argument") + } + } + "type" => { + if text.is_empty() { + anyhow::bail!("action type requires text argument"); + } + for c in text.encode_utf16() { + unsafe { + let res = VkKeyScanW(c); + if res == -1 { + continue; + } + let vk = (res & 0xFF) as u8; + let shift = (res >> 8) & 1 != 0; + let ctrl = (res >> 8) & 2 != 0; + let alt = (res >> 8) & 4 != 0; + + if shift { keybd_event(0x10, 0, 0, 0); } + if ctrl { keybd_event(0x11, 0, 0, 0); } + if alt { keybd_event(0x12, 0, 0, 0); } + + keybd_event(vk, 0, 0, 0); + keybd_event(vk, 0, 0x0002, 0); + + if alt { keybd_event(0x12, 0, 0x0002, 0); } + if ctrl { keybd_event(0x11, 0, 0x0002, 0); } + if shift { keybd_event(0x10, 0, 0x0002, 0); } + + std::thread::sleep(std::time::Duration::from_millis(15)); + } + } + Ok(json!({ + "content": [{ "type": "text", "text": format!("Typed text: \"{}\"", text) }], + "isError": false + })) + } + "key" => { + if text.is_empty() { + anyhow::bail!("action key requires key name in text argument"); + } + let parts: Vec<&str> = text.split('+').collect(); + let mut vks = Vec::new(); + for part in parts { + let vk = match part.to_lowercase().as_str() { + "enter" | "return" => Some(0x0D), + "esc" | "escape" => Some(0x1B), + "tab" => Some(0x09), + "backspace" => Some(0x08), + "space" => Some(0x20), + "up" => Some(0x26), + "down" => Some(0x28), + "left" => Some(0x25), + "right" => Some(0x27), + "pgup" | "pageup" => Some(0x21), + "pgdn" | "pagedown" => Some(0x22), + "home" => Some(0x24), + "end" => Some(0x23), + "insert" => Some(0x2D), + "delete" => Some(0x2E), + "ctrl" | "control" => Some(0x11), + "alt" => Some(0x12), + "shift" => Some(0x10), + "win" | "super" | "command" => Some(0x5B), + "f1" => Some(0x70), + "f2" => Some(0x71), + "f3" => Some(0x72), + "f4" => Some(0x73), + "f5" => Some(0x74), + "f6" => Some(0x75), + "f7" => Some(0x76), + "f8" => Some(0x77), + "f9" => Some(0x78), + "f10" => Some(0x79), + "f11" => Some(0x7A), + "f12" => Some(0x7B), + _ => { + if part.len() == 1 { + let c = part.chars().next().unwrap(); + if c.is_ascii_alphabetic() { + Some(c.to_ascii_uppercase() as u8) + } else if c.is_ascii_digit() { + Some(c as u8) + } else { + None + } + } else { + None + } + } + }; + if let Some(vk) = vk { + vks.push(vk); + } else { + anyhow::bail!("unknown key name: {}", part); + } + } + + unsafe { + for &vk in &vks { + keybd_event(vk, 0, 0, 0); + } + for &vk in vks.iter().rev() { + keybd_event(vk, 0, 0x0002, 0); + } + } + + Ok(json!({ + "content": [{ "type": "text", "text": format!("Pressed key: {}", text) }], + "isError": false + })) + } + "cursor_position" => { + let mut pt = POINT { x: 0, y: 0 }; + let success = unsafe { GetCursorPos(&mut pt) }; + if success != 0 { + Ok(json!({ + "content": [{ "type": "text", "text": format!("Cursor position: [{}, {}]", pt.x, pt.y) }], + "isError": false + })) + } else { + anyhow::bail!("Failed to get cursor position") + } + } + _ => anyhow::bail!("Unknown computer use action: {}", action) + } +} diff --git a/sinew-chrome-bridge/native_host.bat b/sinew-chrome-bridge/native_host.bat new file mode 100644 index 00000000..b574d030 --- /dev/null +++ b/sinew-chrome-bridge/native_host.bat @@ -0,0 +1,3 @@ +@echo off +setlocal +node "%~dp0server.js" --native diff --git a/sinew-chrome-bridge/package-lock.json b/sinew-chrome-bridge/package-lock.json new file mode 100644 index 00000000..d7469a76 --- /dev/null +++ b/sinew-chrome-bridge/package-lock.json @@ -0,0 +1,36 @@ +{ + "name": "Sinew-chrome-bridge-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "Sinew-chrome-bridge-server", + "version": "1.0.0", + "dependencies": { + "ws": "^8.16.0" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/sinew-chrome-bridge/package.json b/sinew-chrome-bridge/package.json new file mode 100644 index 00000000..728ad927 --- /dev/null +++ b/sinew-chrome-bridge/package.json @@ -0,0 +1,9 @@ +{ + "name": "Sinew-chrome-bridge-server", + "version": "1.0.0", + "description": "Proxy server bridging Playwright CDP commands to Chrome Extension", + "main": "server.js", + "dependencies": { + "ws": "^8.16.0" + } +} diff --git a/sinew-chrome-bridge/popup.html b/sinew-chrome-bridge/popup.html new file mode 100644 index 00000000..1dadcbb3 --- /dev/null +++ b/sinew-chrome-bridge/popup.html @@ -0,0 +1,300 @@ + + + + + + + +
+ Sinew Logo +
+ +

Sinew

+ +

Control Chrome with Sinew.

+ +
+
+ + Disconnected +
+ + +
+ +
+
+ Active tabs tracked + 0 +
+
Diagnostic: starting...
+
+ +
+ + + +
+ + + + + + diff --git a/sinew-chrome-bridge/popup.js b/sinew-chrome-bridge/popup.js new file mode 100644 index 00000000..2790cab4 --- /dev/null +++ b/sinew-chrome-bridge/popup.js @@ -0,0 +1,96 @@ +// popup.js — Dynamic high-performance UI state driver for Sinew Chrome Bridge + +document.addEventListener('DOMContentLoaded', () => { + const bodyContainer = document.getElementById('body-container'); + const pill = document.getElementById('status-pill'); + const text = document.getElementById('status-text'); + const count = document.getElementById('attached-count'); + const btn = document.getElementById('btn-refresh'); + const restartBtn = document.getElementById('btn-restart'); + const diag = document.getElementById('diagnostic'); + const btnSettings = document.getElementById('btn-settings'); + const diagPanel = document.getElementById('diagnostics-panel'); + + // Toggle diagnostics panel + btnSettings.addEventListener('click', () => { + diagPanel.classList.toggle('expanded'); + }); + + function diagnosticText(response, fallback = '') { + const when = response?.lastConnectedAt ? new Date(response.lastConnectedAt).toLocaleTimeString() : 'never'; + const causes = response?.diagnostics?.causes || []; + if (response?.connected) { + return `native host connected · tabs ${response.attachedCount || 0} · since ${when}${causes.length ? ` · ${causes.join(' · ')}` : ''}`; + } + return `${response?.lastNativeError || causes.join(' · ') || fallback || 'native host not connected yet'}`; + } + + function updateStatus() { + chrome.runtime.sendMessage({ action: "get_status" }, (response) => { + if (chrome.runtime.lastError || !response) { + const reason = chrome.runtime.lastError?.message || 'service worker sleeping'; + chrome.storage.local.get(['connected', 'attachedCount', 'lastNativeError', 'lastConnectedAt', 'diagnostics'], (data) => { + setConnected(!!data.connected); + count.textContent = data.attachedCount || 0; + diag.textContent = diagnosticText(data, reason); + }); + return; + } + + setConnected(!!response.connected); + count.textContent = response.attachedCount || 0; + diag.textContent = diagnosticText(response); + }); + } + + function setConnected(isConnected) { + if (isConnected) { + pill.classList.remove('disconnected'); + pill.classList.add('connected'); + text.textContent = 'Connected'; + bodyContainer.classList.remove('disconnected'); + bodyContainer.classList.add('connected'); + } else { + pill.classList.remove('connected'); + pill.classList.add('disconnected'); + text.textContent = 'Disconnected'; + bodyContainer.classList.remove('connected'); + bodyContainer.classList.add('disconnected'); + + // Auto expand diagnostics if disconnected to help developers + diagPanel.classList.add('expanded'); + } + } + + btn.addEventListener('click', () => { + btn.style.transform = 'scale(0.95)'; + setTimeout(() => btn.style.transform = 'none', 100); + + btn.disabled = true; + btn.querySelector('span').textContent = 'Checking...'; + chrome.runtime.sendMessage({ action: "reconnect" }, () => { + setTimeout(() => { + btn.disabled = false; + btn.querySelector('span').textContent = 'Reconnect'; + updateStatus(); + }, 800); + }); + }); + + restartBtn.addEventListener('click', () => { + restartBtn.disabled = true; + restartBtn.querySelector('span').textContent = 'Restarting...'; + chrome.runtime.sendMessage({ action: "restart_bridge" }, (response) => { + setTimeout(() => { + restartBtn.disabled = false; + restartBtn.querySelector('span').textContent = response && response.success === false ? 'Restart failed' : 'Restart bridge'; + updateStatus(); + }, 1200); + }); + }); + + // Init & Poll status while open + updateStatus(); + const interval = setInterval(updateStatus, 1000); + window.addEventListener('unload', () => clearInterval(interval)); +}); diff --git a/sinew-chrome-bridge/register.ps1 b/sinew-chrome-bridge/register.ps1 new file mode 100644 index 00000000..c165532c --- /dev/null +++ b/sinew-chrome-bridge/register.ps1 @@ -0,0 +1,97 @@ +param( + [string]$InstallDir = (Join-Path $env:LOCALAPPDATA "Sinew\ChromeBridge"), + [switch]$SkipSinewDb +) + +$ErrorActionPreference = "Stop" +$SourceDir = $PSScriptRoot +if (!$SourceDir) { $SourceDir = $pwd.Path } + +$SourceManifestPath = Join-Path $SourceDir "com.sinew.chrome_bridge.json" +$ManifestPath = Join-Path $InstallDir "com.sinew.chrome_bridge.json" +$HostScriptPath = Join-Path $InstallDir "native-host-wrapper.exe" + +if (!(Test-Path $InstallDir)) { + New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null +} + +Write-Host "1/5 Installation du runtime Chrome Bridge dans LOCALAPPDATA..." +$runtimeFiles = @( + "native-host-wrapper.exe", + "manifest.json", + "background.js", + "sinew_cursor.js", + "popup.html", + "popup.js", + "icon-32.png", + "icon-64.png", + "icon-128.png" +) +foreach ($file in $runtimeFiles) { + $source = Join-Path $SourceDir $file + if (Test-Path $source) { + Copy-Item -Path $source -Destination (Join-Path $InstallDir $file) -Force + } +} + +if (!(Test-Path $HostScriptPath)) { + Write-Error "native-host-wrapper.exe introuvable dans $InstallDir" + exit 1 +} + +Write-Host "2/5 Creation du manifest Native Host installe localement..." +if (Test-Path $SourceManifestPath) { + $manifestContent = Get-Content -Raw -Path $SourceManifestPath + $json = ConvertFrom-Json $manifestContent + $json.path = $HostScriptPath + $updatedJson = ConvertTo-Json $json -Depth 10 + [System.IO.File]::WriteAllText($ManifestPath, $updatedJson, [System.Text.Encoding]::UTF8) + Write-Host "Manifest installe: $ManifestPath" +} else { + Write-Error "Fichier manifest source introuvable." + exit 1 +} + +Write-Host "3/5 Configuration de la base de registre Windows..." +$RegPath = "HKCU:\Software\Google\Chrome\NativeMessagingHosts\com.sinew.chrome_bridge" +if (!(Test-Path $RegPath)) { + New-Item -Path $RegPath -Force | Out-Null +} +Set-ItemProperty -Path $RegPath -Name "(default)" -Value $ManifestPath +Write-Host "Cle de registre configuree vers le manifest installe." + +Write-Host "4/5 Configuration du serveur MCP dans la base de donnees de Sinew..." +if (!$SkipSinewDb) { + $env:SINEW_CHROME_BRIDGE_DIR = $InstallDir + $SinewExe = "" + $PathsToCheck = @( + (Join-Path $SourceDir "..\src-tauri\target\release\Sinew.exe"), + (Join-Path $SourceDir "..\src-tauri\target\debug\Sinew.exe"), + (Join-Path $env:LOCALAPPDATA "Programs\Sinew\Sinew.exe") + ) + foreach ($p in $PathsToCheck) { + if (Test-Path $p) { + $SinewExe = $p + break + } + } + if (!$SinewExe) { + $SinewExe = (Get-Command Sinew -ErrorAction SilentlyContinue).Source + } + + if ($SinewExe) { + Write-Host "Execution de l'enregistrement via $SinewExe..." + & $SinewExe --register-chrome + } else { + Write-Warning "Sinew.exe introuvable : enregistrement du serveur MCP dans la base SQLite impossible." + } +} +} + +Write-Host "SUCCESS: Sinew Chrome Bridge et son serveur MCP sont enregistres et automatises !" +Write-Host "Manifest Native Host actif :" +Write-Host $ManifestPath +Write-Host "Runtime Chrome Bridge installe :" +Write-Host $InstallDir +Write-Host "Dossier de l'extension a charger dans Chrome :" +Write-Host $SourceDir diff --git a/sinew-chrome-bridge/run_bridge.bat b/sinew-chrome-bridge/run_bridge.bat new file mode 100644 index 00000000..2671feee --- /dev/null +++ b/sinew-chrome-bridge/run_bridge.bat @@ -0,0 +1,10 @@ +@echo off +title 🧬 Sinew Chrome Bridge +color 05 +echo ======================================================= +echo 🧬 Sinew Chrome Bridge & Proxy Server 🧬 +echo ======================================================= +echo. +echo Starting local WebSocket proxy on port 29002... +node server.js +pause diff --git a/sinew-chrome-bridge/run_sinew_bridge.bat b/sinew-chrome-bridge/run_sinew_bridge.bat new file mode 100644 index 00000000..2d3fb469 --- /dev/null +++ b/sinew-chrome-bridge/run_sinew_bridge.bat @@ -0,0 +1,8 @@ +@echo off +setlocal +set "BRIDGE_DIR=%LOCALAPPDATA%\Sinew\ChromeBridge" +if exist "%BRIDGE_DIR%\mcp_server.js" ( + node "%BRIDGE_DIR%\mcp_server.js" +) else ( + node "%~dp0mcp_server.js" +) diff --git a/sinew-chrome-bridge/sinew_cursor.js b/sinew-chrome-bridge/sinew_cursor.js new file mode 100644 index 00000000..4617af60 --- /dev/null +++ b/sinew-chrome-bridge/sinew_cursor.js @@ -0,0 +1,1774 @@ +// 🧬 Sinew Chrome Bridge — Cursor & Overlay Script +// Injected into web pages to draw a SOTA biologically-realistic virtual cursor with spring physics. + +(function () { + const OVERLAY_ROOT_ID = "Sinew-agent-overlay-root"; + if (window.__sinewChromeBridgeReady) { + return; // Already injected + } + window.__sinewChromeBridgeReady = true; + + // Create isolated container + const container = document.createElement("div"); + container.id = OVERLAY_ROOT_ID; + container.style.position = "fixed"; + container.style.inset = "0"; + container.style.zIndex = "2147483647"; // Max index + container.style.pointerEvents = "none"; + + // Shadow Root for 100% styles isolation + const shadow = container.attachShadow({ mode: "closed" }); + + // Stylings for our cyber neon cursor, click shockwaves and target HUD + const style = document.createElement("style"); + style.textContent = ` + .sinew-controlled-tab-indicator { + position: fixed; + left: 0; + top: 0; + width: 100vw; + height: 4px; + z-index: 2147483646; + pointer-events: none; + opacity: 0; + transform: translateY(-4px); + transition: opacity 180ms ease, transform 180ms ease; + background: linear-gradient(90deg, #ff6b00, #ff0080, #66f7ff, #ff6b00); + background-size: 260% 100%; + box-shadow: 0 0 12px rgba(255, 0, 128, 0.55), 0 0 24px rgba(102, 247, 255, 0.35); + animation: sinew-controlled-tab-flow 2.2s linear infinite; + } + + .sinew-controlled-tab-indicator.active { + opacity: 1; + transform: translateY(0); + } + + @keyframes sinew-controlled-tab-flow { + 0% { background-position: 0% 50%; } + 100% { background-position: 260% 50%; } + } + + .cursor-overlay { + position: absolute; + top: 0; + left: 0; + width: 28px; + height: 28px; + will-change: transform, opacity, filter; + opacity: 0; + transition: opacity 0.3s ease; + } + + .cursor-pointer { + width: 28px; + height: 28px; + transform-origin: 4px 3px; /* Align with SVG tip */ + will-change: transform; + filter: drop-shadow(0 0 6px #ff6b00) drop-shadow(0 0 14px #ff0080) drop-shadow(0 0 22px #66f7ff); + animation: sinew-cursor-glow-pulse 2.2s ease-in-out infinite alternate; + } + + @keyframes sinew-cursor-glow-pulse { + 0% { + filter: drop-shadow(0 0 4px #ff6b00) drop-shadow(0 0 10px #ff0080) drop-shadow(0 0 16px #66f7ff); + } + 100% { + filter: drop-shadow(0 0 8px #ff6b00) drop-shadow(0 0 18px #ff0080) drop-shadow(0 0 28px #66f7ff); + } + } + + .cursor-label { + position: absolute; + left: 32px; + top: 0px; + background: rgba(10, 10, 15, 0.85); + border: 1px solid rgba(255, 107, 0, 0.5); + backdrop-filter: blur(8px); + color: #ff6b00; + font-family: 'Courier New', Courier, monospace; + font-size: 10px; + font-weight: bold; + padding: 3px 8px; + border-radius: 4px; + white-space: nowrap; + text-shadow: 0 0 5px rgba(255, 107, 0, 0.8); + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.5); + transform: none !important; /* Keep it stable */ + } + + .click-shockwave { + position: absolute; + width: 12px; + height: 12px; + margin-left: -6px; + margin-top: -6px; + border: 2px solid #ff0080; + border-radius: 50%; + background: radial-gradient(circle, rgba(255, 107, 0, 0.2) 0%, rgba(255, 0, 128, 0) 70%); + box-shadow: 0 0 10px #ff6b00, inset 0 0 10px #ff0080; + opacity: 1; + transform: scale(0.1); + animation: shockwave-implode-explode 0.6s cubic-bezier(0.1, 0.8, 0.1, 1) forwards; + pointer-events: none; + } + + @keyframes shockwave-implode-explode { + 0% { + transform: scale(0.1); + opacity: 0.8; + border-color: #ff6b00; + } + 20% { + transform: scale(0.8); + opacity: 1; + border-color: #ff0080; + box-shadow: 0 0 15px #ff0080, inset 0 0 15px #ff6b00; + } + 100% { + transform: scale(5); + opacity: 0; + border-color: rgba(255, 0, 128, 0); + box-shadow: 0 0 30px rgba(255, 0, 128, 0), inset 0 0 30px rgba(255, 107, 0, 0); + } + } + + /* Cyber targeting target HUD box SOTA */ + .cyber-hud-target { + position: absolute; + border: 1px dashed rgba(255, 107, 0, 0.3); + border-radius: 4px; + pointer-events: none; + opacity: 0; + transform: scale(1.08); + transition: opacity 0.22s ease, transform 0.22s ease, left 0.18s cubic-bezier(0.25, 0.8, 0.25, 1), top 0.18s cubic-bezier(0.25, 0.8, 0.25, 1), width 0.18s cubic-bezier(0.25, 0.8, 0.25, 1), height 0.18s cubic-bezier(0.25, 0.8, 0.25, 1); + box-shadow: 0 0 8px rgba(255, 0, 128, 0.25), inset 0 0 8px rgba(255, 107, 0, 0.15); + z-index: 2147483645; + } + + .cyber-hud-target::before, .cyber-hud-target::after { + content: ""; + position: absolute; + width: 8px; + height: 8px; + border-color: #ff0080; + border-style: solid; + will-change: transform; + } + + .cyber-hud-target::before { + top: -2px; + left: -2px; + border-width: 2px 0 0 2px; + } + + .cyber-hud-target::after { + bottom: -2px; + right: -2px; + border-width: 0 2px 2px 0; + } + + .cyber-hud-target.active { + opacity: 1; + transform: scale(1); + border-color: rgba(255, 107, 0, 0.85); + box-shadow: 0 0 12px rgba(255, 0, 128, 0.5), inset 0 0 12px rgba(255, 107, 0, 0.3); + animation: hud-pulsate 1.5s infinite alternate; + } + + @keyframes hud-pulsate { + 0% { + box-shadow: 0 0 8px rgba(255, 0, 128, 0.4), inset 0 0 8px rgba(255, 107, 0, 0.2); + } + 100% { + box-shadow: 0 0 16px rgba(255, 0, 128, 0.75), inset 0 0 16px rgba(255, 107, 0, 0.5); + } + } + + /* Cyber-Neon Macro Recorder Widget */ + .cyber-macro-widget { + position: fixed; + top: 80px; + right: 20px; + background: rgba(10, 10, 15, 0.95); + border: 1px solid rgba(255, 107, 0, 0.4); + border-radius: 12px; + font-family: 'Share Tech Mono', monospace; + padding: 12px; + pointer-events: auto; /* MUST receive clicks */ + z-index: 2147483647; + backdrop-filter: blur(12px); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.7), 0 0 15px rgba(255, 107, 0, 0.15); + color: #f0f0f5; + width: 280px; + transition: width 0.3s cubic-bezier(0.25, 0.8, 0.25, 1), height 0.3s cubic-bezier(0.25, 0.8, 0.25, 1), border-radius 0.3s, background 0.3s; + display: flex; + flex-direction: column; + gap: 10px; + } + + .cyber-macro-widget.collapsed { + width: 48px; + height: 48px; + border-radius: 50%; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + cursor: move; + border-color: rgba(255, 0, 128, 0.5); + background: radial-gradient(circle, rgba(255, 0, 128, 0.15) 0%, rgba(10, 10, 15, 0.9) 70%); + box-shadow: 0 0 12px rgba(255, 0, 128, 0.4); + } + + .cyber-macro-widget.collapsed:hover { + box-shadow: 0 0 20px #ff0080; + border-color: #ff0080; + transform: scale(1.05); + } + + .widget-header { + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + padding-bottom: 8px; + cursor: move; + } + + .widget-title { + font-size: 11px; + font-weight: bold; + color: #ff6b00; + letter-spacing: 1px; + text-shadow: 0 0 4px rgba(255, 107, 0, 0.6); + display: flex; + align-items: center; + gap: 6px; + } + + .widget-close { + cursor: pointer; + color: #8fa0b0; + font-size: 12px; + transition: color 0.2s; + } + + .widget-close:hover { + color: #ff0080; + } + + .widget-body { + display: flex; + flex-direction: column; + gap: 10px; + } + + .rec-status { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 11px; + color: #8fa0b0; + } + + .rec-indicator { + display: flex; + align-items: center; + gap: 6px; + } + + .rec-dot { + width: 8px; + height: 8px; + background: #8fa0b0; + border-radius: 50%; + } + + .rec-dot.active { + background: #ff0080; + box-shadow: 0 0 8px #ff0080; + animation: rec-pulse 1s infinite alternate; + } + + @keyframes rec-pulse { + 0% { opacity: 0.4; } + 100% { opacity: 1; box-shadow: 0 0 12px #ff0080; } + } + + .widget-actions-list { + background: rgba(0, 0, 0, 0.5); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 6px; + font-size: 10px; + padding: 8px; + max-height: 120px; + overflow-y: auto; + color: #a0b5c5; + display: flex; + flex-direction: column; + gap: 4px; + } + + .action-entry { + display: flex; + justify-content: space-between; + border-bottom: 1px solid rgba(255, 255, 255, 0.02); + padding-bottom: 2px; + gap: 10px; + } + + .action-type { + color: #ff6b00; + font-weight: bold; + } + + .action-details { + color: #f0f0f5; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + max-width: 170px; + text-align: right; + } + + .widget-buttons { + display: flex; + gap: 8px; + } + + .widget-btn { + flex: 1; + background: rgba(255, 107, 0, 0.1); + border: 1px solid rgba(255, 107, 0, 0.4); + color: #ff6b00; + padding: 8px 6px; + border-radius: 6px; + font-family: 'Share Tech Mono', monospace; + font-size: 10px; + cursor: pointer; + text-align: center; + transition: all 0.2s; + outline: none; + } + + .widget-btn:hover { + background: #ff6b00; + color: #000; + box-shadow: 0 0 10px rgba(255, 107, 0, 0.5); + border-color: transparent; + } + + .widget-btn.btn-rec { + border-color: rgba(255, 0, 128, 0.5); + color: #ff0080; + background: rgba(255, 0, 128, 0.1); + } + + .widget-btn.btn-rec:hover { + background: #ff0080; + color: #000; + box-shadow: 0 0 10px rgba(255, 0, 128, 0.5); + border-color: transparent; + } + + .widget-btn:disabled { + opacity: 0.4; + pointer-events: none; + } + + .widget-input { + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + color: #fff; + font-family: 'Share Tech Mono', monospace; + font-size: 11px; + padding: 6px 8px; + width: 100%; + outline: none; + box-sizing: border-box; + } + + .widget-input:focus { + border-color: #ff6b00; + } + `; + shadow.appendChild(style); + + // Shockwave container + const waveContainer = document.createElement("div"); + waveContainer.style.position = "absolute"; + waveContainer.style.inset = "0"; + waveContainer.style.pointerEvents = "none"; + shadow.appendChild(waveContainer); + + // HUD target highlight SOTA + const targetHud = document.createElement("div"); + targetHud.className = "cyber-hud-target"; + shadow.appendChild(targetHud); + + const tabIndicator = document.createElement("div"); + tabIndicator.className = "sinew-controlled-tab-indicator"; + shadow.appendChild(tabIndicator); + + // Holographic Cyber cursor element + const overlay = document.createElement("div"); + overlay.className = "cursor-overlay"; + + const pointer = document.createElement("div"); + pointer.className = "cursor-pointer"; + pointer.innerHTML = ` + + + + + + + + + + + + + + + `; + overlay.appendChild(pointer); + + const label = document.createElement("div"); + label.className = "cursor-label"; + label.textContent = "Sinew ACTIVE"; + // overlay.appendChild(label); // Disabled to match clean minimalist style (no flashing text next to the cursor) + + shadow.appendChild(overlay); + const appendOverlayRoot = () => { + const root = document.documentElement || document.body; + if (root && !container.isConnected) root.appendChild(container); + }; + if (document.documentElement || document.body) appendOverlayRoot(); + else document.addEventListener("DOMContentLoaded", appendOverlayRoot, { once: true }); + + // Masse-Ressort-Amortisseur Spring system + class Spring { + constructor(val = 0, damping = 0.85, response = 0.22) { + this.value = val; + this.target = val; + this.velocity = 0; + this.damping = damping; + this.response = response; + } + + update(dt) { + const stiffness = Math.pow((2 * Math.PI) / this.response, 2); + const dampingCoefficient = 2 * ((2 * Math.PI) / this.response) * this.damping; + + const force = (this.target - this.value) * stiffness - this.velocity * dampingCoefficient; + this.velocity += force * dt; + this.value += this.velocity * dt; + } + } + + const initialCursorPoint = (() => { + const edgeX = Math.random() < 0.5 ? 0.10 + Math.random() * 0.20 : 0.70 + Math.random() * 0.20; + return { + x: Math.round(window.innerWidth * edgeX), + y: Math.round(window.innerHeight * (0.18 + Math.random() * 0.64)) + }; + })(); + + // Springs for coordinates, stretching/scooting, and blur + const xSpring = new Spring(initialCursorPoint.x); + const ySpring = new Spring(initialCursorPoint.y); + const stretchSpring = new Spring(1, 0.85, 0.15); // Velocity-based length stretch + const blurSpring = new Spring(0, 0.9, 0.12); // Motion blur + + let activeState = { + x: initialCursorPoint.x, + y: initialCursorPoint.y, + visible: false, + moveSequence: 0, + sessionId: null, + turnId: null + }; + + let animFrameId = null; + let lastTime = performance.now(); + let hasArrived = false; + let lastTargetEl = null; + + // DOM Attention Layer (HUD highlighted elements) + function updateTargetHud(x, y, visible) { + // Disabled to match clean minimalist style (no flashing pink boxes) + targetHud.classList.remove("active"); + lastTargetEl = null; + return; + } + + function loop(now) { + const dt = Math.min((now - lastTime) / 1000, 0.1); // Limit dt to 100ms to prevent instability + lastTime = now; + + // Update spring coordinates + xSpring.target = activeState.x; + ySpring.target = activeState.y; + + xSpring.update(dt); + ySpring.update(dt); + + // Calculate current speed/velocity to apply organic stretch and motion blur + const speed = Math.sqrt(xSpring.velocity * xSpring.velocity + ySpring.velocity * ySpring.velocity); + + stretchSpring.target = 1 + Math.min(speed / 1200, 0.45); // Limit maximum stretch + blurSpring.target = Math.min(speed / 300, 4); // Blur according to speed + + stretchSpring.update(dt); + blurSpring.update(dt); + + // Dynamic rotation according to movement angle + let angle = -45; // Idle angle (default cursor orientation) + if (speed > 10) { + // Dynamic alignment: SVG points to -135deg by default, add 135deg to align to motion angle + angle = Math.atan2(ySpring.velocity, xSpring.velocity) * (180 / Math.PI) + 135; + } + + // Apply calculated matrices + const px = Math.round(xSpring.value); + const py = Math.round(ySpring.value); + const scale = stretchSpring.value; + const blur = blurSpring.value; + + // Move only container (overlay) to position px, py - label stays 100% horizontal + overlay.style.transform = `translate3d(${px}px, ${py}px, 0)`; + overlay.style.filter = blur > 0.2 ? `blur(${blur}px)` : "none"; + + // Apply rotation and stretch/scale ONLY to the pointer element + pointer.style.transform = `rotate(${angle}deg) scale(${scale}, ${1 / scale})`; + + // Show or hide overlay + overlay.style.opacity = activeState.visible ? "1" : "0"; + + // Update targeting HUD live + updateTargetHud(px, py, activeState.visible); + + // Check arrival tolerance (similar to Sinew's Pn tolerance check) + const distance = Math.sqrt(Math.pow(px - activeState.x, 2) + Math.pow(py - activeState.y, 2)); + + if (activeState.visible && distance < 1.5 && Math.abs(xSpring.velocity) < 8 && Math.abs(ySpring.velocity) < 8) { + if (!hasArrived && activeState.moveSequence > 0 && activeState.sessionId) { + hasArrived = true; + + // Notify background that the cursor has arrived! + chrome.runtime.sendMessage({ + type: "AGENT_CURSOR_ARRIVED", + moveSequence: activeState.moveSequence, + sessionId: activeState.sessionId, + turnId: activeState.turnId + }).catch(() => {}); + } + } + + animFrameId = requestAnimationFrame(loop); + } + + // Set new target coordinates and animate + function updateCursorState(state) { + const wasHidden = !activeState.visible; + const shouldSnapIntoView = wasHidden && state.visible && Number.isFinite(state.x) && Number.isFinite(state.y); + if (shouldSnapIntoView) { + xSpring.value = state.x; + xSpring.target = state.x; + xSpring.velocity = 0; + ySpring.value = state.y; + ySpring.target = state.y; + ySpring.velocity = 0; + } + activeState = { ...activeState, ...state }; + hasArrived = false; + + // Resume animation loop if not running + if (!animFrameId) { + lastTime = performance.now(); + animFrameId = requestAnimationFrame(loop); + } + } + + // Click shockwave trigger + function triggerClickShockwave(x, y) { + const shockwave = document.createElement("div"); + shockwave.className = "click-shockwave"; + shockwave.style.left = `${x}px`; + shockwave.style.top = `${y}px`; + + waveContainer.appendChild(shockwave); + + setTimeout(() => { + shockwave.remove(); + }, 600); + } + + // ========================================================== + // Controlled tab visual indicator (Sinew-style glowing bar) + // ========================================================== + let controlledIndicatorTimer = null; + + // ========================================================== + // Favicon Badge Status Indicators (Sinew Style) + // ========================================================== + const BADGE_CREATED_FLAG = "sinewFaviconBadgeCreated"; + const ORIGINAL_HREF_KEY = "sinewOriginalFaviconHref"; + + function getFaviconLinks() { + return Array.from(document.querySelectorAll('link[rel~="icon"], link[rel="shortcut icon"]')); + } + + function restoreOriginalFavicon() { + const links = getFaviconLinks(); + for (const link of links) { + if (link.dataset[BADGE_CREATED_FLAG] === "true") { + link.remove(); + } else if (link.dataset[ORIGINAL_HREF_KEY]) { + link.href = link.dataset[ORIGINAL_HREF_KEY]; + delete link.dataset[BADGE_CREATED_FLAG]; + delete link.dataset[ORIGINAL_HREF_KEY]; + link.removeAttribute("data-sinew-badge"); + } + } + } + + function setFaviconBadge(status) { + restoreOriginalFavicon(); + if (status === "detached" || status === "idle" || !status) { + return; + } + + const links = getFaviconLinks(); + let targetLink = links[0]; + + if (!targetLink) { + targetLink = document.createElement("link"); + targetLink.rel = "icon"; + targetLink.dataset[BADGE_CREATED_FLAG] = "true"; + if (document.head) document.head.appendChild(targetLink); + else document.documentElement.appendChild(targetLink); + } + + if (!targetLink.dataset[ORIGINAL_HREF_KEY]) { + targetLink.dataset[ORIGINAL_HREF_KEY] = targetLink.getAttribute("href") || ""; + } + + const originalHref = targetLink.dataset[ORIGINAL_HREF_KEY]; + let absoluteOriginalHref = ""; + if (originalHref && !originalHref.startsWith("data:")) { + try { + absoluteOriginalHref = new URL(originalHref, window.location.href).href; + } catch (e) { + absoluteOriginalHref = originalHref; + } + } + + let badgeColor = "#ff6b00"; // Active: Neon Orange + if (status === "recording") { + badgeColor = "#ff0080"; // Recording: Neon Pink + } else if (status === "completed") { + badgeColor = "#66f7ff"; // Completed: Neon Teal (Teal matches Completed) + } + + let svgContent = ``; + if (absoluteOriginalHref) { + const escapedHref = absoluteOriginalHref.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); + svgContent += ``; + } else { + svgContent += ``; + } + + // Draw high-fidelity neon status badge iconography + if (status === "recording") { + svgContent += ``; + } else if (status === "completed") { + svgContent += ``; + } else { // active + svgContent += ``; + } + + targetLink.href = "data:image/svg+xml," + encodeURIComponent(svgContent); + targetLink.dataset.sinewBadge = "true"; + } + + function updateControlledTabIndicator(status) { + if (status === "detached" || status === "idle" || status === false) { + tabIndicator.classList.remove("active"); + return; + } + tabIndicator.classList.add("active"); + } + + function markControlledActivity() { + updateControlledTabIndicator("active"); + if (controlledIndicatorTimer) clearTimeout(controlledIndicatorTimer); + controlledIndicatorTimer = setTimeout(() => { + updateControlledTabIndicator("idle"); + }, 3500); + } + + function handleActivity() { + markControlledActivity(); + } + + // Keep-alive connection to prevent background service worker suspension + function connectKeepAlive() { + try { + const port = chrome.runtime.connect({ name: "sinew-keep-alive" }); + port.onDisconnect.addListener(() => { + setTimeout(connectKeepAlive, 5000); + }); + } catch (e) { + setTimeout(connectKeepAlive, 5000); + } + } + connectKeepAlive(); + + // Listening to messages from background worker + chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + const resolveSelector = (sel) => { + if (!sel) return ""; + const s = String(sel).trim(); + if (s.startsWith('@ref')) { + return `[data-sinew-ref="${s.slice(4)}"]`; + } + if (s.startsWith('@')) { + return `[data-sinew-ref="${s.slice(1)}"]`; + } + return s; + }; + + if (message.type === "AGENT_CURSOR_STATE") { + handleActivity(); + updateCursorState({ + x: message.state.x, + y: message.state.y, + visible: message.state.visible, + moveSequence: message.state.moveSequence, + sessionId: message.state.sessionId, + turnId: message.state.turnId + }); + if (message.state.visible) { + updateControlledTabIndicator("active"); + setFaviconBadge("active"); + } + sendResponse({ ok: true }); + } + else if (message.type === "AGENT_CLICK_EVENT") { + handleActivity(); + if (message.event.type === "mousePressed") { + triggerClickShockwave(message.event.x, message.event.y); + } + sendResponse({ ok: true }); + } + else if (message.type === "AGENT_STATUS_CHANGE") { + updateControlledTabIndicator(message.status); + setFaviconBadge(message.status); + sendResponse({ ok: true }); + } + else if (message.type === "AGENT_QUERY_SELECTOR") { + handleActivity(); + const selector = String(message.selector || ""); + const el = selector ? document.querySelector(resolveSelector(selector)) : null; + if (!el) { + sendResponse({ ok: false, success: false, error: `Selector not found: ${selector}` }); + return true; + } + if (message.scroll !== false && typeof el.scrollIntoView === "function") { + el.scrollIntoView({ block: "center", inline: "center", behavior: "auto" }); + } + const rect = el.getBoundingClientRect(); + const style = window.getComputedStyle(el); + sendResponse({ + ok: true, + success: true, + selector, + tagName: el.tagName, + id: el.id || "", + className: typeof el.className === "string" ? el.className : "", + text: String(el.innerText || el.textContent || "").replace(/\s+/g, " ").trim().slice(0, 500), + value: "value" in el ? el.value : null, + href: el.href || el.getAttribute?.("href") || "", + visible: rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0", + boundingBox: { x: Math.round(rect.left), y: Math.round(rect.top), width: Math.round(rect.width), height: Math.round(rect.height) }, + center: { x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + rect.height / 2) } + }); + return true; + } + else if (message.type === "AGENT_CLICK_SELECTOR") { + handleActivity(); + const selector = String(message.selector || ""); + const el = selector ? document.querySelector(resolveSelector(selector)) : null; + if (!el) { + sendResponse({ ok: false, success: false, error: `Selector not found: ${selector}` }); + return true; + } + if (message.scroll !== false && typeof el.scrollIntoView === "function") { + el.scrollIntoView({ block: "center", inline: "center", behavior: "auto" }); + } + const rect = el.getBoundingClientRect(); + const x = Math.round(rect.left + rect.width / 2); + const y = Math.round(rect.top + rect.height / 2); + + // Téléportation instantanée et onde de choc (Mode Turbo Visuel) + if (typeof xSpring !== "undefined" && typeof ySpring !== "undefined") { + xSpring.value = x; + xSpring.target = x; + xSpring.velocity = 0; + ySpring.value = y; + ySpring.target = y; + ySpring.velocity = 0; + if (typeof updateCursorState === "function") { + updateCursorState({ x, y, visible: true }); + } + if (typeof triggerClickShockwave === "function") { + triggerClickShockwave(x, y); + } + } + + const opts = { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y, button: 0, buttons: 1, pointerId: 1, pointerType: "mouse", isPrimary: true }; + try { + el.dispatchEvent(new PointerEvent("pointerover", opts)); + el.dispatchEvent(new MouseEvent("mouseover", opts)); + el.dispatchEvent(new PointerEvent("pointerdown", opts)); + el.dispatchEvent(new MouseEvent("mousedown", opts)); + el.focus?.({ preventScroll: true }); + el.dispatchEvent(new PointerEvent("pointerup", { ...opts, buttons: 0 })); + el.dispatchEvent(new MouseEvent("mouseup", { ...opts, buttons: 0 })); + if (typeof el.click === "function" && el.tagName !== "A") { + el.click(); + } else { + el.dispatchEvent(new MouseEvent("click", { ...opts, buttons: 0 })); + } + sendResponse({ ok: true, success: true, action: "click_selector", selector, tagName: el.tagName, id: el.id || "", href: el.href || el.getAttribute?.("href") || "", center: { x, y } }); + } catch (err) { + sendResponse({ ok: false, success: false, error: err.message }); + } + return true; + } + else if (message.type === "AGENT_TYPE_SELECTOR") { + handleActivity(); + const selector = String(message.selector || ""); + const text = String(message.text || ""); + const el = selector ? document.querySelector(resolveSelector(selector)) : null; + if (!el) { + sendResponse({ ok: false, success: false, error: `Selector not found: ${selector}` }); + return true; + } + const isEditable = el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable || el.getAttribute("role") === "textbox"; + if (!isEditable) { + sendResponse({ ok: false, success: false, error: `Target is not editable: ${el.tagName}` }); + return true; + } + el.scrollIntoView?.({ block: "center", inline: "center", behavior: "auto" }); + el.focus?.({ preventScroll: true }); + + // Téléportation du curseur sur le champ de saisie (Mode Turbo Visuel) + try { + const rect = el.getBoundingClientRect(); + const x = Math.round(rect.left + rect.width / 2); + const y = Math.round(rect.top + rect.height / 2); + if (typeof xSpring !== "undefined" && typeof ySpring !== "undefined") { + xSpring.value = x; + xSpring.target = x; + xSpring.velocity = 0; + ySpring.value = y; + ySpring.target = y; + ySpring.velocity = 0; + if (typeof updateCursorState === "function") { + updateCursorState({ x, y, visible: true }); + } + } + } catch (e) {} + + const setValue = (value) => { + if (el.isContentEditable || el.getAttribute("role") === "textbox") el.textContent = value; + else el.value = value; + el.dispatchEvent(new InputEvent("input", { bubbles: true, cancelable: true, inputType: "insertText", data: value })); + el.dispatchEvent(new Event("change", { bubbles: true })); + }; + setValue(text); + if (message.submit) { + el.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", code: "Enter", bubbles: true, cancelable: true })); + const form = el.closest && el.closest("form"); + if (form && typeof form.requestSubmit === "function") form.requestSubmit(); + else if (form) form.submit(); + el.dispatchEvent(new KeyboardEvent("keyup", { key: "Enter", code: "Enter", bubbles: true, cancelable: true })); + } + sendResponse({ ok: true, success: true, action: "type_selector", selector, tagName: el.tagName, id: el.id || "", text }); + return true; + } + else if (message.type === "AGENT_PRESS_KEY") { + handleActivity(); + const selector = String(message.selector || ""); + const key = String(message.key || "Enter"); + const target = selector ? document.querySelector(resolveSelector(selector)) : (document.activeElement || document.body); + if (!target) { + sendResponse({ ok: false, success: false, error: selector ? `Selector not found: ${selector}` : "No active element" }); + return true; + } + target.scrollIntoView?.({ block: "center", inline: "center", behavior: "auto" }); + target.focus?.({ preventScroll: true }); + const codeByKey = { Enter: "Enter", Escape: "Escape", Tab: "Tab", Backspace: "Backspace", Delete: "Delete", ArrowUp: "ArrowUp", ArrowDown: "ArrowDown", ArrowLeft: "ArrowLeft", ArrowRight: "ArrowRight", Home: "Home", End: "End", PageUp: "PageUp", PageDown: "PageDown", Space: "Space" }; + const code = String(message.code || codeByKey[key] || (key.length === 1 ? `Key${key.toUpperCase()}` : key)); + const opts = { key, code, bubbles: true, cancelable: true, ctrlKey: !!message.ctrlKey, shiftKey: !!message.shiftKey, altKey: !!message.altKey, metaKey: !!message.metaKey }; + try { + target.dispatchEvent(new KeyboardEvent("keydown", opts)); + if (key.length === 1) target.dispatchEvent(new KeyboardEvent("keypress", opts)); + if (key === "Enter") { + const form = target.closest && target.closest("form"); + if (form && message.submit !== false) { + if (typeof form.requestSubmit === "function") form.requestSubmit(); + else form.submit(); + } + } + target.dispatchEvent(new KeyboardEvent("keyup", opts)); + sendResponse({ ok: true, success: true, action: "press_key", selector: selector || null, key, code, tagName: target.tagName || "" }); + } catch (err) { + sendResponse({ ok: false, success: false, error: err.message }); + } + return true; + } + else if (message.type === "AGENT_SELECT_OPTION") { + handleActivity(); + const selector = String(message.selector || ""); + const select = selector ? document.querySelector(resolveSelector(selector)) : null; + if (!select) { + sendResponse({ ok: false, success: false, error: `Selector not found: ${selector}` }); + return true; + } + if (select.tagName !== "SELECT") { + sendResponse({ ok: false, success: false, error: `Target is not a SELECT: ${select.tagName}` }); + return true; + } + const value = message.value !== undefined ? String(message.value) : null; + const label = message.label !== undefined ? String(message.label).toLowerCase() : null; + const index = Number.isInteger(message.index) ? message.index : null; + const options = Array.from(select.options || []); + let option = null; + if (value !== null) option = options.find(opt => opt.value === value); + if (!option && label !== null) option = options.find(opt => String(opt.label || opt.textContent || "").trim().toLowerCase() === label || String(opt.textContent || "").toLowerCase().includes(label)); + if (!option && index !== null) option = options[index] || null; + if (!option) { + sendResponse({ ok: false, success: false, error: "Option not found", options: options.map((opt, i) => ({ index: i, value: opt.value, label: opt.label || opt.textContent || "" })).slice(0, 50) }); + return true; + } + select.scrollIntoView?.({ block: "center", inline: "center", behavior: "auto" }); + select.focus?.({ preventScroll: true }); + select.value = option.value; + option.selected = true; + select.dispatchEvent(new InputEvent("input", { bubbles: true, cancelable: true, inputType: "insertReplacementText", data: option.value })); + select.dispatchEvent(new Event("change", { bubbles: true })); + sendResponse({ ok: true, success: true, action: "select_option", selector, value: option.value, label: option.label || option.textContent || "", index: options.indexOf(option) }); + return true; + } + else if (message.type === "AGENT_WAIT_SELECTOR") { + handleActivity(); + const selector = String(message.selector || ""); + const visibleOnly = message.visible !== false; + const timeoutMs = Math.max(0, Number(message.timeoutMs) || 5000); + const started = Date.now(); + const check = () => { + const el = selector ? document.querySelector(resolveSelector(selector)) : null; + if (el) { + const rect = el.getBoundingClientRect(); + const style = window.getComputedStyle(el); + const visible = rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0"; + if (!visibleOnly || visible) { + sendResponse({ ok: true, success: true, selector, visible, elapsedMs: Date.now() - started }); + return true; + } + } + return false; + }; + if (check()) return true; + const timer = setTimeout(() => { + observer.disconnect(); + clearInterval(poll); + sendResponse({ ok: false, success: false, error: `Timeout waiting for selector: ${selector}`, elapsedMs: Date.now() - started }); + }, timeoutMs); + const finishIfFound = () => { + if (check()) { + clearTimeout(timer); + clearInterval(poll); + observer.disconnect(); + } + }; + const observer = new MutationObserver(finishIfFound); + observer.observe(document.documentElement || document, { childList: true, subtree: true, attributes: true }); + const poll = setInterval(finishIfFound, 100); + return true; + } + else if (message.type === "AGENT_EVALUATE") { + handleActivity(); + try { + const source = String(message.expression || "undefined"); + let value; + try { + value = (0, eval)(`(${source})`); + } catch { + value = (0, eval)(source); + } + Promise.resolve(value).then((resolved) => { + let jsonValue = resolved; + try { JSON.stringify(jsonValue); } catch { jsonValue = String(jsonValue); } + sendResponse({ ok: true, success: true, value: jsonValue }); + }).catch((err) => sendResponse({ ok: false, success: false, error: err.message })); + } catch (err) { + sendResponse({ ok: false, success: false, error: err.message }); + } + return true; + } + else if (message.type === "AGENT_DOM_CLICK") { + handleActivity(); + const x = Number(message.x); + const y = Number(message.y); + const initialEl = document.elementFromPoint(x, y); + const el = initialEl && initialEl.closest + ? (initialEl.closest('button, a, input, textarea, select, [role="button"], [onclick], summary, label') || initialEl) + : initialEl; + const anchorEl = el && el.closest ? el.closest('a[href]') : null; + const hrefToNavigate = anchorEl && anchorEl.href && !/^\s*(#|javascript:)/i.test(anchorEl.getAttribute('href') || '') + ? anchorEl.href + : ""; + const beforeHref = location.href; + if (!el) { + sendResponse({ ok: false, error: "No element at target point" }); + return true; + } + const opts = { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y, button: 0, buttons: 1, pointerId: 1, pointerType: "mouse", isPrimary: true }; + el.dispatchEvent(new PointerEvent("pointerover", opts)); + el.dispatchEvent(new MouseEvent("mouseover", opts)); + el.dispatchEvent(new PointerEvent("pointermove", opts)); + el.dispatchEvent(new MouseEvent("mousemove", opts)); + el.dispatchEvent(new PointerEvent("pointerdown", opts)); + el.dispatchEvent(new MouseEvent("mousedown", opts)); + el.focus?.({ preventScroll: true }); + el.dispatchEvent(new PointerEvent("pointerup", { ...opts, buttons: 0 })); + el.dispatchEvent(new MouseEvent("mouseup", { ...opts, buttons: 0 })); + + if (typeof el.click === "function" && el.tagName !== "A") { + el.click(); + } else { + const clickEvent = new MouseEvent("click", { ...opts, bubbles: true, cancelable: true, buttons: 0 }); + el.dispatchEvent(clickEvent); + } + sendResponse({ ok: true, tagName: el.tagName, id: el.id || "", className: typeof el.className === "string" ? el.className : "", href: hrefToNavigate }); + return true; + } + else if (message.type === "AGENT_DOM_TYPE") { + handleActivity(); + const x = Number(message.x); + const y = Number(message.y); + const text = String(message.text || ""); + const delayMs = Math.max(10, Number(message.delayMs) || 70); + const initialEl = document.elementFromPoint(x, y); + const el = initialEl && initialEl.closest + ? (initialEl.closest('input, textarea, [contenteditable="true"], [role="textbox"]') || initialEl) + : initialEl; + if (!el) { + sendResponse({ ok: false, error: "No editable element at target point" }); + return true; + } + const isEditable = el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable || el.getAttribute("role") === "textbox"; + if (!isEditable) { + sendResponse({ ok: false, error: `Target is not editable: ${el.tagName}` }); + return true; + } + el.focus?.({ preventScroll: true }); + const setValue = (value) => { + if (el.isContentEditable || el.getAttribute("role") === "textbox") { + el.textContent = value; + } else { + el.value = value; + } + el.dispatchEvent(new InputEvent("input", { bubbles: true, cancelable: true, inputType: "insertText", data: value })); + el.dispatchEvent(new Event("change", { bubbles: true })); + }; + let current = el.isContentEditable || el.getAttribute("role") === "textbox" ? (el.textContent || "") : (el.value || ""); + if (current) { + current = ""; + setValue(current); + } + (async () => { + for (const ch of text) { + current += ch; + el.dispatchEvent(new KeyboardEvent("keydown", { key: ch, bubbles: true, cancelable: true })); + setValue(current); + el.dispatchEvent(new KeyboardEvent("keyup", { key: ch, bubbles: true, cancelable: true })); + await new Promise(resolve => setTimeout(resolve, delayMs + Math.random() * delayMs)); + } + if (message.submit) { + el.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", code: "Enter", bubbles: true, cancelable: true })); + const form = el.closest && el.closest("form"); + if (form && typeof form.requestSubmit === "function") form.requestSubmit(); + else if (form) form.submit(); + el.dispatchEvent(new KeyboardEvent("keyup", { key: "Enter", code: "Enter", bubbles: true, cancelable: true })); + } + sendResponse({ ok: true, tagName: el.tagName, id: el.id || "", className: typeof el.className === "string" ? el.className : "", text }); + })(); + return true; + } + else if (message.type === "AGENT_DOM_SCROLL") { + handleActivity(); + const amount = Number(message.scrollY) || Math.round(window.innerHeight * 0.6); + window.scrollBy({ top: amount, left: 0, behavior: "smooth" }); + sendResponse({ ok: true, scrollY: amount }); + return true; + } + else if (message.type === "AGENT_PAGE_SNAPSHOT") { + handleActivity(); + const limit = Math.max(1, Math.min(200, Number(message.limit) || 80)); + const clean = (value, max = 160) => String(value || "").replace(/\s+/g, " ").trim().slice(0, max); + const roleFor = (el) => { + const role = el.getAttribute("role"); + if (role) return role; + if (el.tagName === "A" && el.getAttribute("href")) return "link"; + if (el.tagName === "BUTTON") return "button"; + if (el.tagName === "TEXTAREA") return "textbox"; + if (el.tagName === "INPUT") { + const type = (el.getAttribute("type") || "text").toLowerCase(); + if (["button", "submit", "reset"].includes(type)) return "button"; + if (["checkbox", "radio", "range"].includes(type)) return type; + return "textbox"; + } + if (el.tagName === "SELECT") return "combobox"; + if (/^H[1-6]$/.test(el.tagName)) return "heading"; + return null; + }; + const selectorFor = (el) => { + const esc = (v) => window.CSS?.escape ? window.CSS.escape(String(v)) : String(v).replace(/[^a-zA-Z0-9_-]/g, "\\$&"); + const candidates = []; + if (el.id) candidates.push(`#${esc(el.id)}`); + for (const attr of ["data-testid", "data-test", "data-cy", "name", "aria-label"]) { + const value = el.getAttribute(attr); + if (value) candidates.push(`${el.tagName.toLowerCase()}[${attr}="${esc(value)}"]`); + } + if (el.tagName === "A" && el.getAttribute("href")) candidates.push(`a[href="${esc(el.getAttribute("href"))}"]`); + const path = []; + let cur = el; + while (cur && cur.nodeType === Node.ELEMENT_NODE && cur !== document.documentElement && path.length < 6) { + let part = cur.tagName.toLowerCase(); + if (cur.id) { + part += `#${esc(cur.id)}`; + path.unshift(part); + break; + } + let nth = 1; + let sib = cur.previousElementSibling; + while (sib) { + if (sib.tagName === cur.tagName) nth++; + sib = sib.previousElementSibling; + } + if (nth > 1) part += `:nth-of-type(${nth})`; + path.unshift(part); + cur = cur.parentElement; + } + if (path.length) candidates.push(path.join(" > ")); + const unique = Array.from(new Set(candidates)).filter(Boolean).slice(0, 8); + return { primary: unique[0] || null, candidates: unique }; + }; + const nodes = Array.from(document.querySelectorAll('a[href], button, input, textarea, select, [role], [aria-label], [title], [onclick], [tabindex], [contenteditable="true"], summary, label, h1, h2, h3')); + const items = []; + document.querySelectorAll('.sinew-ref-badge').forEach(b => b.remove()); + for (const el of nodes) { + const rect = el.getBoundingClientRect(); + const style = window.getComputedStyle(el); + const visible = rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0" && rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth; + if (!visible) continue; + const editable = el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable || el.getAttribute("role") === "textbox"; + const clickable = el.tagName === "A" || el.tagName === "BUTTON" || el.getAttribute("role") === "button" || style.cursor === "pointer" || el.hasAttribute("onclick") || el.hasAttribute("tabindex") || el.tagName === "SUMMARY" || el.tagName === "LABEL"; + const role = roleFor(el); + const text = clean(el.innerText || el.textContent || el.getAttribute("value") || ""); + const ariaName = clean(el.getAttribute("aria-label") || el.getAttribute("title") || el.getAttribute("alt") || el.getAttribute("placeholder") || ""); + if (!editable && !clickable && !ariaName && !text && !role) continue; + const selector = selectorFor(el); + const nodeId = items.length + 1; + el.setAttribute("data-sinew-ref", String(nodeId)); + if (visible) { + const badge = document.createElement("div"); + badge.className = "sinew-ref-badge"; + badge.textContent = `@ref${nodeId}`; + badge.style.cssText = "position:absolute;left:" + Math.max(0, rect.left + window.scrollX) + "px;top:" + Math.max(0, rect.top + window.scrollY - 15) + "px;background:#ff0080;color:#ffffff;font-family:Arial,sans-serif;font-size:10px;font-weight:bold;padding:1px 4px;border-radius:3px;z-index:2147483645;pointer-events:none;box-shadow:0 0 4px #ff0080;opacity:0.85;"; + document.body.appendChild(badge); + } + items.push({ + nodeId, + tagName: el.tagName, + role, + visible, + clickable, + editable, + visibleText: text || null, + ariaName: ariaName || null, + href: el.getAttribute("href") || null, + boundingBox: { x: Math.round(rect.left), y: Math.round(rect.top), width: Math.round(rect.width), height: Math.round(rect.height) }, + center: { x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + rect.height / 2) }, + selector, + preview: clean([role, ariaName || text, el.getAttribute("href") || ""].filter(Boolean).join(" | "), 220) + }); + if (items.length >= limit) break; + } + setTimeout(() => { + document.querySelectorAll('.sinew-ref-badge').forEach(b => b.remove()); + }, 8000); + sendResponse({ success: true, href: location.href, title: document.title, viewport: { width: window.innerWidth, height: window.innerHeight }, items }); + return true; + } + else if (message.type === "CONTENT_PING") { + sendResponse({ ok: true }); + } + else if (message.type === "RUN_SILENT_TASK") { + const task = message.task; + const taskText = task.toLowerCase(); + + let action = "click"; + if (taskText.includes("type") || taskText.includes("tape") || taskText.includes("saisir") || taskText.includes("écrire") || taskText.includes("ecris") || taskText.includes("saisis")) { + action = "type"; + } else if (taskText.includes("scroll") || taskText.includes("défiler") || taskText.includes("descendre") || taskText.includes("monter")) { + action = "scroll"; + } + + if (action === "scroll") { + const direction = (taskText.includes("up") || taskText.includes("monter") || taskText.includes("haut")) ? -1 : 1; + const amount = Math.round(window.innerHeight * 0.6 * direction); + sendResponse({ success: true, action: "scroll", scrollY: amount, message: "Cible scroll détectée." }); + return true; + } + + const elements = Array.from(document.querySelectorAll('button, a, input, select, textarea, [role="button"], [onclick], [aria-label], [title], div, span, svg, li, summary, article, section')); + const cleanTask = taskText + .replace(/\b(cliquez|clique|cliquer|click|ouvrir|ouvre|open|press|selectionne|sélectionne|va sur|aller|type|tape|saisir|écrire|ecris|saisis|dans|sur|le|la|les|un|une|et|du|de|des|site|web|page|url|navigate|navigue|carte|bouton)\b/g, " ") + .trim(); + const queryWordsRaw = cleanTask.split(/\s+/).filter(w => w.length >= 1); + const semanticWords = []; + if (queryWordsRaw.some(w => w === "hamburger" || w === "burger" || w === "menu")) { + semanticWords.push("menu", "hamburger", "burger", "nav", "toggle"); + } + if (queryWordsRaw.some(w => w === "bouton" || w === "button")) { + semanticWords.push("btn", "button", "bouton"); + } + if (queryWordsRaw.some(w => w === "recherche" || w === "chercher" || w === "search")) { + semanticWords.push("search", "query", "q", "recherche", "find"); + } + const queryWords = Array.from(new Set([...queryWordsRaw, ...semanticWords])); + let typeText = ""; + let typeSubmit = /\b(entrée|entrer|enter|valide|submit|recherche)\b/.test(taskText); + if (action === "type") { + const cleanTypeText = (value) => String(value || "") + .replace(/^[\s`"'“”‘’]+|[\s`"'“”‘’]+$/g, "") + .replace(/^(exactement|exact|precisement|précisément)\s+/i, "") + .replace(/[,.!?;:]+$/g, "") + .trim(); + const quotedTypeMatch = task.match(/(?:tape|écris|ecris|saisis|type)\s+(?:exactement\s+)?[`"“'‘]([^`"”'’]+)[`"”'’]/i); + const typeMatch = quotedTypeMatch || task.match(/(?:tape|écris|ecris|saisis|type)\s+(?:exactement\s+)?(.+?)(?:\s+puis|\s+et\b|[,;]\s*(?:valide|valides|appuie|clique|clic|click)|$)/i); + typeText = cleanTypeText(typeMatch && typeMatch[1] ? typeMatch[1] : ""); + const domainMatch = task.match(/\b[a-z0-9-]+(?:\.[a-z0-9-]+)+(?:\/[^\s,;)]*)?\b/i); + if ((!typeText || typeText.includes("google") || typeText.includes("exactement")) && domainMatch) typeText = cleanTypeText(domainMatch[0]); + if (!typeText && taskText.includes("julienpiron")) typeText = "julienpiron.fr"; + } + + const wantsMenu = taskText.includes("hamburger") || taskText.includes("menu") || taskText.includes("burger"); + const wantsMenuClose = wantsMenu && /\b(referme|ferme|fermer|close|dismiss|x)\b/.test(taskText); + const wantsMenuOpen = wantsMenu && !wantsMenuClose; + + let bestEl = null; + let bestScore = -1; + + const directSearchRequested = (taskText.includes("google") || taskText.includes("recherche") || taskText.includes("search")) && (action === "click" || action === "type"); + if (directSearchRequested) { + const directSearch = document.querySelector('textarea[name="q"], input[name="q"], textarea[aria-label*="search" i], input[aria-label*="search" i], textarea[aria-label*="rechercher" i], input[aria-label*="rechercher" i], [role="combobox"][aria-label*="search" i], [role="combobox"][aria-label*="rechercher" i]'); + if (directSearch && typeof directSearch.scrollIntoView === "function") { + directSearch.scrollIntoView({ block: "center", inline: "center", behavior: "auto" }); + bestEl = directSearch; + bestScore = 1200; + } + } + + if (!bestEl && action === "click") { + const domainMatch = taskText.match(/\b[a-z0-9-]+(?:\.[a-z0-9-]+)+(?:\/[^\s,;)]*)?\b/i); + if (domainMatch && !directSearchRequested) { + const host = domainMatch[0].replace(/^https?:\/\//, "").replace(/^www\./, "").toLowerCase(); + const linkMatchesHost = (a) => { + const rawHref = (a.getAttribute('href') || '').trim(); + if (!rawHref || rawHref.startsWith('#') || rawHref.startsWith('javascript:')) return false; + const candidates = [rawHref]; + try { + const parsed = new URL(rawHref, location.href); + for (const key of ['url', 'q', 'u']) { + const value = parsed.searchParams.get(key); + if (value) candidates.push(value); + } + } catch {} + return candidates.some(candidate => { + try { + const parsed = new URL(candidate, location.href); + const candidateHost = parsed.hostname.replace(/^www\./, '').toLowerCase(); + return candidateHost === host || candidateHost.endsWith(`.${host}`); + } catch { + return false; + } + }); + }; + const directLink = Array.from(document.querySelectorAll('a[href]')) + .filter(a => { + const rect = a.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return false; + if (rect.top < 90 || rect.left > window.innerWidth - 220) return false; + const className = (typeof a.className === 'string' ? a.className : '').toLowerCase(); + const ariaLabel = (a.getAttribute('aria-label') || '').toLowerCase(); + if (/\bgb_|google apps|compte google|google account/.test(`${className} ${ariaLabel}`)) return false; + return linkMatchesHost(a); + }) + .sort((a, b) => { + const ah = a.querySelector('h3') ? 1 : 0; + const bh = b.querySelector('h3') ? 1 : 0; + if (ah !== bh) return bh - ah; + return a.getBoundingClientRect().top - b.getBoundingClientRect().top; + })[0]; + if (directLink && typeof directLink.scrollIntoView === "function") { + directLink.scrollIntoView({ block: "center", inline: "center", behavior: "auto" }); + bestEl = directLink; + bestScore = 1250; + } + } + } + + if (!bestEl && taskText.includes("trinity")) { + const directTrinity = document.querySelector('#trinity-card, .trinity-card, article[id*="trinity" i], article[class*="trinity" i], a[href*="trinity" i], [data-project*="trinity" i], [data-id*="trinity" i]'); + if (directTrinity && typeof directTrinity.scrollIntoView === "function") { + directTrinity.scrollIntoView({ block: "center", inline: "center", behavior: "auto" }); + bestEl = directTrinity; + bestScore = 1000; + } + } + + if (!bestEl) elements.forEach(el => { + const rect = el.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + if (rect.width * rect.height > window.innerWidth * window.innerHeight * 0.4) return; + + const style = window.getComputedStyle(el); + if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return; + + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + const topEl = document.elementFromPoint(centerX, centerY); + if (topEl && topEl !== el && !el.contains(topEl) && !topEl.contains(el)) return; + + const text = (el.innerText || el.textContent || "").toLowerCase().trim(); + const placeholder = (el.getAttribute("placeholder") || "").toLowerCase(); + const ariaLabel = (el.getAttribute("aria-label") || "").toLowerCase(); + const title = (el.getAttribute("title") || "").toLowerCase(); + const id = (el.id || "").toLowerCase(); + const className = (typeof el.className === "string" ? el.className : "").toLowerCase(); + const value = (el.value || "").toLowerCase(); + const name = (el.getAttribute("name") || "").toLowerCase(); + const role = (el.getAttribute("role") || "").toLowerCase(); + const href = (el.getAttribute("href") || "").toLowerCase(); + const dataAttrs = Array.from(el.attributes || []).filter(attr => attr.name.startsWith("data-")).map(attr => `${attr.name} ${attr.value}`).join(" ").toLowerCase(); + + let score = 0; + queryWords.forEach(word => { + if (text.includes(word)) score += 55; + if (placeholder.includes(word)) score += 60; + if (ariaLabel.includes(word)) score += 75; + if (title.includes(word)) score += 55; + if (id.includes(word)) score += 55; + if (name.includes(word)) score += 40; + if (value.includes(word)) score += 30; + if (className.includes(word)) score += 25; + if (href.includes(word)) score += 35; + if (dataAttrs.includes(word)) score += 35; + }); + + const isButtonLike = el.tagName === "BUTTON" || el.tagName === "A" || role === "button" || style.cursor === "pointer" || el.hasAttribute("onclick") || el.hasAttribute("tabindex"); + const iconOnly = text.length <= 3 && rect.width <= 96 && rect.height <= 96; + const iconSignal = el.querySelectorAll ? el.querySelectorAll('svg,path,line,span').length : 0; + const signature = `${id} ${className} ${ariaLabel} ${title} ${name} ${dataAttrs}`; + const menuSignal = /(^|\s|_|-)(hamburger|burger|menu|nav|navbar|toggle|drawer|bars)(\s|$|_|-)/.test(signature); + const menuGeometry = iconOnly && (text === "☰" || text === "≡" || iconSignal >= 2 || rect.top < window.innerHeight * 0.35); + const closeSignal = /(^|\s|_|-)(close|fermer|dismiss|modal-close)(\s|$|_|-)/.test(signature) || (iconOnly && (text === "×" || text === "x")); + + if (action === "type" && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable || role === "textbox")) { + score += 160; + if (name === "q" || id === "search" || ariaLabel.includes("search") || ariaLabel.includes("recherche") || title.includes("search") || title.includes("recherche")) score += 180; + } + if (action === "click" && isButtonLike) score += 25; + if (wantsMenu) { + if (wantsMenuOpen && closeSignal) return; + if (wantsMenuClose && !closeSignal && !menuSignal) return; + if (menuSignal) score += 240; + if (menuGeometry) score += 170; + if (wantsMenuClose && closeSignal) score += 320; + if (rect.top < window.innerHeight * 0.35 && (rect.left < 180 || rect.right > window.innerWidth - 180)) score += 80; + } + if (taskText.includes("trinity")) { + if (el.tagName === "IFRAME" || className.includes("terminal-panel") || className.includes("terminal")) return; + const hasTrinity = text.includes("trinity") || id.includes("trinity") || className.includes("trinity") || href.includes("trinity") || ariaLabel.includes("trinity") || title.includes("trinity"); + if (!hasTrinity) return; + score += 220; + if (id.includes("trinity-card") || className.includes("trinity-card")) score += 500; + if (el.tagName === "ARTICLE" || className.includes("project-card")) score += 120; + } + + if (score > bestScore && score > 0) { + bestScore = score; + bestEl = el; + } + }); + + if (!bestEl) { + sendResponse({ success: false, message: "Aucun élément interactif pertinent trouvé pour cette tâche." }); + return true; + } + + bestEl.scrollIntoView({ block: "center", inline: "center", behavior: "smooth" }); + setTimeout(() => { + const rect = bestEl.getBoundingClientRect(); + const x = Math.round(rect.left + rect.width / 2); + const y = Math.round(rect.top + rect.height / 2); + sendResponse({ + success: true, + action, + target: { + x, + y, + rect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height }, + viewport: { width: window.innerWidth, height: window.innerHeight }, + element: { tagName: bestEl.tagName, id: bestEl.id, className: typeof bestEl.className === 'string' ? bestEl.className : "", href: bestEl.href || bestEl.getAttribute?.('href') || "" }, + score: bestScore + }, + text: action === "type" ? typeText : undefined, + submit: action === "type" ? typeSubmit : undefined, + message: `Cible détectée à (${x}, ${y}) pour ${bestEl.tagName}.` + }); + }, 450); + + return true; + } + }); + + // Clean up controlled indicator when unloading + window.addEventListener("unload", () => { + updateControlledTabIndicator("detached"); + }); + + // ========================================================== + // Cyber Macro Recorder Engine & UI Injection + // ========================================================== + const macroWidget = document.createElement("div"); + macroWidget.className = "cyber-macro-widget collapsed"; + // shadow.appendChild(macroWidget); // Disabled to avoid floating red dot on the right, matching clean minimalist style + + let isRecording = false; + let recordedSteps = []; + let recordingStartTime = 0; + + function renderCollapsed() { + macroWidget.className = "cyber-macro-widget collapsed"; + macroWidget.innerHTML = ` + + + + + `; + } + + function renderExpanded() { + macroWidget.className = "cyber-macro-widget"; + const dateStamp = new Date().toISOString().slice(0, 10).replace(/-/g, ''); + const timeStamp = new Date().toTimeString().slice(0, 5).replace(':', ''); + + macroWidget.innerHTML = ` +
+ + + 🧬 MACRO RECORDER SOTA + + ✖ +
+
+
+ STATUT : ${isRecording ? 'ENREGISTREMENT' : 'VEILLE'} + ${recordedSteps.length} ACTIONS +
+ + + +
+ ${recordedSteps.length === 0 + ? '
Aucune action enregistrée
' + : recordedSteps.map((step, idx) => { + const details = step.type === 'click' + ? (step.text ? `"${step.text}"` : step.selector.split(' > ').pop()) + : (step.value ? `"${step.value}"` : 'vide'); + return ` +
+ ${String(idx+1).padStart(2, '0')}. ${step.type.toUpperCase()} + ${details} +
+ `; + }).join('') + } +
+ +
+ + + +
+
+ `; + + // Wire up event handlers inside Shadow DOM + macroWidget.querySelector("#macro-collapse-btn").onclick = (e) => { + e.stopPropagation(); + renderCollapsed(); + }; + + macroWidget.querySelector("#macro-toggle-btn").onclick = (e) => { + e.stopPropagation(); + if (isRecording) { + stopRecordingMacro(); + } else { + startRecordingMacro(); + } + }; + + macroWidget.querySelector("#macro-clear-btn").onclick = (e) => { + e.stopPropagation(); + recordedSteps = []; + renderExpanded(); + }; + + macroWidget.querySelector("#macro-save-btn").onclick = (e) => { + e.stopPropagation(); + saveMacroPlaybook(); + }; + } + + function startRecordingMacro() { + isRecording = true; + recordedSteps = []; + recordingStartTime = performance.now(); + setFaviconBadge("recording"); + renderExpanded(); + triggerClickShockwave(window.innerWidth / 2, window.innerHeight / 2); + } + + function stopRecordingMacro() { + isRecording = false; + setFaviconBadge(activeState.visible ? "active" : "detached"); + renderExpanded(); + } + + function saveMacroPlaybook() { + const inputVal = macroWidget.querySelector("#macro-name-input").value.trim(); + const playbookName = (inputVal || "unnamed_playbook") + ".json"; + + const playbook = { + name: playbookName, + url: window.location.href, + title: document.title, + timestamp: Date.now(), + steps: recordedSteps + }; + + chrome.runtime.sendMessage({ + type: "AGENT_SAVE_MACRO", + macro: playbook + }, (res) => { + const btn = macroWidget.querySelector("#macro-save-btn"); + if (btn) { + const originalText = btn.textContent; + btn.textContent = "SAUVÉ !"; + btn.style.borderColor = "#00ff66"; + btn.style.color = "#00ff66"; + setTimeout(() => { + btn.textContent = originalText; + btn.style.borderColor = ""; + btn.style.color = ""; + recordedSteps = []; + renderExpanded(); + }, 1500); + } + }); + } + + function getCssSelector(el) { + if (!(el instanceof Element)) return ""; + const path = []; + while (el && el.nodeType === Node.ELEMENT_NODE) { + let selector = el.nodeName.toLowerCase(); + if (el.id) { + selector += '#' + el.id; + path.unshift(selector); + break; + } else { + let sibling = el.previousElementSibling; + let nth = 1; + while (sibling) { + if (sibling.nodeName.toLowerCase() === selector) { + nth++; + } + sibling = sibling.previousElementSibling; + } + if (nth > 1) { + selector += `:nth-of-type(${nth})`; + } + } + path.unshift(selector); + el = el.parentElement; + } + return path.join(' > '); + } + + // Document passive listeners + document.addEventListener("click", (e) => { + if (!isRecording) return; + if (container.contains(e.target)) return; // Ignore widget clicks + + const elapsed = Math.round(performance.now() - recordingStartTime); + const selector = getCssSelector(e.target); + + recordedSteps.push({ + type: "click", + selector: selector, + x: e.clientX, + y: e.clientY, + text: e.target.textContent ? e.target.textContent.trim().slice(0, 30) : "", + timestamp: elapsed + }); + + if (!macroWidget.classList.contains("collapsed")) { + renderExpanded(); + } + }, true); + + document.addEventListener("input", (e) => { + if (!isRecording) return; + if (container.contains(e.target)) return; + + const elapsed = Math.round(performance.now() - recordingStartTime); + const selector = getCssSelector(e.target); + + const lastStep = recordedSteps[recordedSteps.length - 1]; + if (lastStep && lastStep.type === "input" && lastStep.selector === selector) { + lastStep.value = e.target.value; + lastStep.timestamp = elapsed; + } else { + recordedSteps.push({ + type: "input", + selector: selector, + value: e.target.value, + timestamp: elapsed + }); + } + + if (!macroWidget.classList.contains("collapsed")) { + renderExpanded(); + } + }, true); + + let isDragging = false; + let dragStartX = 0; + let dragStartY = 0; + let widgetStartX = 0; + let widgetStartY = 0; + let hasMoved = false; + + function initDraggable() { + // Setup mousedown listener on the widget for dragging + macroWidget.addEventListener("mousedown", (e) => { + // Don't drag if clicking buttons, inputs or collapse button + if (e.target.closest("button") || e.target.closest("input") || e.target.closest("#macro-collapse-btn") || e.target.closest(".widget-close")) { + return; + } + + // If expanded, only drag via the header + if (!macroWidget.classList.contains("collapsed") && !e.target.closest(".widget-header")) { + return; + } + + isDragging = true; + hasMoved = false; + const rect = macroWidget.getBoundingClientRect(); + + // Convert layout to absolute coordinates + macroWidget.style.top = `${rect.top}px`; + macroWidget.style.left = `${rect.left}px`; + macroWidget.style.bottom = "auto"; + macroWidget.style.right = "auto"; + + widgetStartX = rect.left; + widgetStartY = rect.top; + dragStartX = e.clientX; + dragStartY = e.clientY; + + e.preventDefault(); + + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + }); + + function onMouseMove(e) { + if (!isDragging) return; + const dx = e.clientX - dragStartX; + const dy = e.clientY - dragStartY; + + if (Math.abs(dx) > 4 || Math.abs(dy) > 4) { + hasMoved = true; + } + + let newLeft = widgetStartX + dx; + let newTop = widgetStartY + dy; + + const widgetWidth = macroWidget.offsetWidth; + const widgetHeight = macroWidget.offsetHeight; + + // Bound within viewport + newLeft = Math.max(10, Math.min(window.innerWidth - widgetWidth - 10, newLeft)); + newTop = Math.max(10, Math.min(window.innerHeight - widgetHeight - 10, newTop)); + + macroWidget.style.left = `${newLeft}px`; + macroWidget.style.top = `${newTop}px`; + } + + function onMouseUp(e) { + if (!isDragging) return; + isDragging = false; + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + + // If it was a click on the collapsed widget (didn't drag), expand it + if (!hasMoved && macroWidget.classList.contains("collapsed")) { + renderExpanded(); + // Make sure it doesn't expand off screen + const rect = macroWidget.getBoundingClientRect(); + let newLeft = Math.max(10, Math.min(window.innerWidth - 300, rect.left)); + let newTop = Math.max(10, Math.min(window.innerHeight - 250, rect.top)); + macroWidget.style.left = `${newLeft}px`; + macroWidget.style.top = `${newTop}px`; + } + } + } + + // Initialize view and dragging + renderCollapsed(); + initDraggable(); + + // Let background worker know this tab is ready + chrome.runtime.sendMessage({ type: "TAB_LOADED" }).catch(() => {}); +})(); + diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c7603911..58dc0784 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,12 +22,22 @@ sinew-google = { workspace = true } sinew-kimi = { workspace = true } sinew-openai = { workspace = true } sinew-openrouter = { workspace = true } +sinew-deepseek = { workspace = true } +sinew-ollama = { workspace = true } +sinew-cursor = { workspace = true } +sinew-index = { workspace = true } +directories = { workspace = true } +rusqlite = { workspace = true } +uuid = { workspace = true } anyhow = { workspace = true } base64 = { workspace = true } notify = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +regex = { workspace = true } +chrono = { workspace = true } +futures = { workspace = true } tauri = { workspace = true } tauri-plugin-dialog = { workspace = true } tokio = { workspace = true } diff --git a/src-tauri/PROVIDERS.md b/src-tauri/PROVIDERS.md new file mode 100644 index 00000000..4c80e9bc --- /dev/null +++ b/src-tauri/PROVIDERS.md @@ -0,0 +1,20 @@ +# Règles d'architecture & Performance des Fournisseurs (Providers) + +Ce document décrit la règle d'or à suivre impérativement lors de l'ajout ou de la modification de fournisseurs de modèles d'IA (providers) dans Sinew. + +--- + +## ⚠️ Pas de requêtes réseau actives lors de la vérification de l'état (status) + +Les commandes Tauri comme `get_*_provider_status` (par exemple `get_openai_provider_status`, `get_deepseek_provider_status`, etc.) ne doivent **jamais** effectuer de requêtes réseau actives (comme valider la clé API via un appel réseau externe) à chaque appel de statut. + +### 🔴 Pourquoi c'est interdit ? +1. **Appels fréquents :** L'interface utilisateur appelle ces fonctions de statut fréquemment au démarrage, lors des re-renders de l'interface des paramètres, et lors des changements de focus de l'application. +2. **Fuites et Blocages :** Faire des requêtes réseau synchrones ou asynchrones répétées ici entraîne des ralentissements majeurs de l'UI, des risques de blocages complets de l'application (si le service tiers subit une panne ou si l'utilisateur est hors ligne), et des fuites de mémoire (accumulation de sockets et de requêtes en attente dans tokio/reqwest). +3. **Spamming des API :** Cela engendre du spam des serveurs d'API tiers et expose l'utilisateur à des blocages pour cause de taux limite de requêtes dépassé (*rate-limiting*). + +### 🟢 La bonne pratique (à suivre) +À l'instar d'OpenAI, Anthropic et Google : +- **Vérification locale :** Chargez simplement l'état d'authentification enregistré localement sur le disque (`auth.connected`). +- **Retour instantané :** Si l'utilisateur est marqué connecté localement, retournez `"connected"`, sinon `"disconnected"`. +- **Validation unique :** Les requêtes de validation réseau ne doivent être effectuées **qu'une seule fois** via la commande dédiée (`validate_*_api_key`), au moment exact où l'utilisateur soumet ou modifie sa clé d'accès dans l'interface de connexion. diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 2112500a..c919e5a7 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Main desktop capability", - "windows": ["main", "sinew-window-*"], + "windows": ["*"], "permissions": [ "core:default", "core:window:allow-close", @@ -11,6 +11,9 @@ "core:window:allow-toggle-maximize", "core:webview:allow-set-webview-zoom", "dialog:default", + "dialog:allow-confirm", + "dialog:allow-ask", + "dialog:allow-message", "updater:default" ] } diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index 6ed4d4f7..cf3c9928 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Main desktop capability","local":true,"windows":["main","sinew-window-*"],"permissions":["core:default","core:window:allow-close","core:window:allow-minimize","core:window:allow-start-dragging","core:window:allow-toggle-maximize","core:webview:allow-set-webview-zoom","dialog:default","updater:default"]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Main desktop capability","local":true,"windows":["*"],"permissions":["core:default","core:window:allow-close","core:window:allow-minimize","core:window:allow-start-dragging","core:window:allow-toggle-maximize","core:webview:allow-set-webview-zoom","dialog:default","dialog:allow-confirm","dialog:allow-ask","dialog:allow-message","updater:default"]}} \ No newline at end of file diff --git a/src-tauri/gen/schemas/windows-schema.json b/src-tauri/gen/schemas/windows-schema.json new file mode 100644 index 00000000..ec78c04a --- /dev/null +++ b/src-tauri/gen/schemas/windows-schema.json @@ -0,0 +1,2364 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CapabilityFile", + "description": "Capability formats accepted in a capability file.", + "anyOf": [ + { + "description": "A single capability.", + "allOf": [ + { + "$ref": "#/definitions/Capability" + } + ] + }, + { + "description": "A list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + }, + { + "description": "A list of capabilities.", + "type": "object", + "required": [ + "capabilities" + ], + "properties": { + "capabilities": { + "description": "The list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + } + } + } + ], + "definitions": { + "Capability": { + "description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```", + "type": "object", + "required": [ + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "Identifier of the capability.\n\n## Example\n\n`main-user-files-write`", + "type": "string" + }, + "description": { + "description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.", + "default": "", + "type": "string" + }, + "remote": { + "description": "Configure remote URLs that can use the capability permissions.\n\nThis setting is optional and defaults to not being set, as our default use case is that the content is served from our local application.\n\n:::caution Make sure you understand the security implications of providing remote sources with local system access. :::\n\n## Example\n\n```json { \"urls\": [\"https://*.mydomain.dev\"] } ```", + "anyOf": [ + { + "$ref": "#/definitions/CapabilityRemote" + }, + { + "type": "null" + } + ] + }, + "local": { + "description": "Whether this capability is enabled for local app URLs or not. Defaults to `true`.", + "default": true, + "type": "boolean" + }, + "windows": { + "description": "List of windows that are affected by this capability. Can be a glob pattern.\n\nIf a window label matches any of the patterns in this list, the capability will be enabled on all the webviews of that window, regardless of the value of [`Self::webviews`].\n\nOn multiwebview windows, prefer specifying [`Self::webviews`] and omitting [`Self::windows`] for a fine grained access control.\n\n## Example\n\n`[\"main\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "webviews": { + "description": "List of webviews that are affected by this capability. Can be a glob pattern.\n\nThe capability will be enabled on all the webviews whose label matches any of the patterns in this list, regardless of whether the webview's window label matches a pattern in [`Self::windows`].\n\n## Example\n\n`[\"sub-webview-one\", \"sub-webview-two\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "permissions": { + "description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ] ```", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionEntry" + }, + "uniqueItems": true + }, + "platforms": { + "description": "Limit which target platforms this capability applies to.\n\nBy default all platforms are targeted.\n\n## Example\n\n`[\"macOS\",\"windows\"]`", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "CapabilityRemote": { + "description": "Configuration for remote URLs that are associated with the capability.", + "type": "object", + "required": [ + "urls" + ], + "properties": { + "urls": { + "description": "Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\n\n## Examples\n\n- \"https://*.mydomain.dev\": allows subdomains of mydomain.dev - \"https://mydomain.dev/api/*\": allows any subpath of mydomain.dev/api", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionEntry": { + "description": "An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`] or an object that references a permission and extends its scope.", + "anyOf": [ + { + "description": "Reference a permission or permission set by identifier.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + { + "description": "Reference a permission or permission set by identifier and extends its scope.", + "type": "object", + "allOf": [ + { + "properties": { + "identifier": { + "description": "Identifier of the permission or permission set.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + } + ], + "required": [ + "identifier" + ] + } + ] + }, + "Identifier": { + "description": "Permission identifier", + "oneOf": [ + { + "description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`", + "type": "string", + "const": "core:default", + "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`" + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`", + "type": "string", + "const": "core:app:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`" + }, + { + "description": "Enables the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-hide", + "markdownDescription": "Enables the app_hide command without any pre-configured scope." + }, + { + "description": "Enables the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-show", + "markdownDescription": "Enables the app_show command without any pre-configured scope." + }, + { + "description": "Enables the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-bundle-type", + "markdownDescription": "Enables the bundle_type command without any pre-configured scope." + }, + { + "description": "Enables the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-default-window-icon", + "markdownDescription": "Enables the default_window_icon command without any pre-configured scope." + }, + { + "description": "Enables the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-fetch-data-store-identifiers", + "markdownDescription": "Enables the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Enables the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-identifier", + "markdownDescription": "Enables the identifier command without any pre-configured scope." + }, + { + "description": "Enables the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-name", + "markdownDescription": "Enables the name command without any pre-configured scope." + }, + { + "description": "Enables the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-register-listener", + "markdownDescription": "Enables the register_listener command without any pre-configured scope." + }, + { + "description": "Enables the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-data-store", + "markdownDescription": "Enables the remove_data_store command without any pre-configured scope." + }, + { + "description": "Enables the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-listener", + "markdownDescription": "Enables the remove_listener command without any pre-configured scope." + }, + { + "description": "Enables the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-app-theme", + "markdownDescription": "Enables the set_app_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-dock-visibility", + "markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Enables the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-tauri-version", + "markdownDescription": "Enables the tauri_version command without any pre-configured scope." + }, + { + "description": "Enables the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-version", + "markdownDescription": "Enables the version command without any pre-configured scope." + }, + { + "description": "Denies the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-hide", + "markdownDescription": "Denies the app_hide command without any pre-configured scope." + }, + { + "description": "Denies the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-show", + "markdownDescription": "Denies the app_show command without any pre-configured scope." + }, + { + "description": "Denies the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-bundle-type", + "markdownDescription": "Denies the bundle_type command without any pre-configured scope." + }, + { + "description": "Denies the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-default-window-icon", + "markdownDescription": "Denies the default_window_icon command without any pre-configured scope." + }, + { + "description": "Denies the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-fetch-data-store-identifiers", + "markdownDescription": "Denies the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Denies the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-identifier", + "markdownDescription": "Denies the identifier command without any pre-configured scope." + }, + { + "description": "Denies the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-name", + "markdownDescription": "Denies the name command without any pre-configured scope." + }, + { + "description": "Denies the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-register-listener", + "markdownDescription": "Denies the register_listener command without any pre-configured scope." + }, + { + "description": "Denies the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-data-store", + "markdownDescription": "Denies the remove_data_store command without any pre-configured scope." + }, + { + "description": "Denies the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-listener", + "markdownDescription": "Denies the remove_listener command without any pre-configured scope." + }, + { + "description": "Denies the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-app-theme", + "markdownDescription": "Denies the set_app_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-dock-visibility", + "markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Denies the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-tauri-version", + "markdownDescription": "Denies the tauri_version command without any pre-configured scope." + }, + { + "description": "Denies the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-version", + "markdownDescription": "Denies the version command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`", + "type": "string", + "const": "core:event:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`" + }, + { + "description": "Enables the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit", + "markdownDescription": "Enables the emit command without any pre-configured scope." + }, + { + "description": "Enables the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit-to", + "markdownDescription": "Enables the emit_to command without any pre-configured scope." + }, + { + "description": "Enables the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-listen", + "markdownDescription": "Enables the listen command without any pre-configured scope." + }, + { + "description": "Enables the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-unlisten", + "markdownDescription": "Enables the unlisten command without any pre-configured scope." + }, + { + "description": "Denies the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit", + "markdownDescription": "Denies the emit command without any pre-configured scope." + }, + { + "description": "Denies the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit-to", + "markdownDescription": "Denies the emit_to command without any pre-configured scope." + }, + { + "description": "Denies the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-listen", + "markdownDescription": "Denies the listen command without any pre-configured scope." + }, + { + "description": "Denies the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-unlisten", + "markdownDescription": "Denies the unlisten command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`", + "type": "string", + "const": "core:image:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`" + }, + { + "description": "Enables the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-bytes", + "markdownDescription": "Enables the from_bytes command without any pre-configured scope." + }, + { + "description": "Enables the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-path", + "markdownDescription": "Enables the from_path command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-rgba", + "markdownDescription": "Enables the rgba command without any pre-configured scope." + }, + { + "description": "Enables the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-size", + "markdownDescription": "Enables the size command without any pre-configured scope." + }, + { + "description": "Denies the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-bytes", + "markdownDescription": "Denies the from_bytes command without any pre-configured scope." + }, + { + "description": "Denies the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-path", + "markdownDescription": "Denies the from_path command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-rgba", + "markdownDescription": "Denies the rgba command without any pre-configured scope." + }, + { + "description": "Denies the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-size", + "markdownDescription": "Denies the size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`", + "type": "string", + "const": "core:menu:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`" + }, + { + "description": "Enables the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-append", + "markdownDescription": "Enables the append command without any pre-configured scope." + }, + { + "description": "Enables the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-create-default", + "markdownDescription": "Enables the create_default command without any pre-configured scope." + }, + { + "description": "Enables the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-get", + "markdownDescription": "Enables the get command without any pre-configured scope." + }, + { + "description": "Enables the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-insert", + "markdownDescription": "Enables the insert command without any pre-configured scope." + }, + { + "description": "Enables the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-checked", + "markdownDescription": "Enables the is_checked command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-items", + "markdownDescription": "Enables the items command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-popup", + "markdownDescription": "Enables the popup command without any pre-configured scope." + }, + { + "description": "Enables the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-prepend", + "markdownDescription": "Enables the prepend command without any pre-configured scope." + }, + { + "description": "Enables the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove", + "markdownDescription": "Enables the remove command without any pre-configured scope." + }, + { + "description": "Enables the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove-at", + "markdownDescription": "Enables the remove_at command without any pre-configured scope." + }, + { + "description": "Enables the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-accelerator", + "markdownDescription": "Enables the set_accelerator command without any pre-configured scope." + }, + { + "description": "Enables the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-app-menu", + "markdownDescription": "Enables the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-help-menu-for-nsapp", + "markdownDescription": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-window-menu", + "markdownDescription": "Enables the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-windows-menu-for-nsapp", + "markdownDescription": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-checked", + "markdownDescription": "Enables the set_checked command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-text", + "markdownDescription": "Enables the set_text command without any pre-configured scope." + }, + { + "description": "Enables the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-text", + "markdownDescription": "Enables the text command without any pre-configured scope." + }, + { + "description": "Denies the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-append", + "markdownDescription": "Denies the append command without any pre-configured scope." + }, + { + "description": "Denies the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-create-default", + "markdownDescription": "Denies the create_default command without any pre-configured scope." + }, + { + "description": "Denies the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-get", + "markdownDescription": "Denies the get command without any pre-configured scope." + }, + { + "description": "Denies the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-insert", + "markdownDescription": "Denies the insert command without any pre-configured scope." + }, + { + "description": "Denies the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-checked", + "markdownDescription": "Denies the is_checked command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-items", + "markdownDescription": "Denies the items command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-popup", + "markdownDescription": "Denies the popup command without any pre-configured scope." + }, + { + "description": "Denies the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-prepend", + "markdownDescription": "Denies the prepend command without any pre-configured scope." + }, + { + "description": "Denies the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove", + "markdownDescription": "Denies the remove command without any pre-configured scope." + }, + { + "description": "Denies the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove-at", + "markdownDescription": "Denies the remove_at command without any pre-configured scope." + }, + { + "description": "Denies the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-accelerator", + "markdownDescription": "Denies the set_accelerator command without any pre-configured scope." + }, + { + "description": "Denies the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-app-menu", + "markdownDescription": "Denies the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-help-menu-for-nsapp", + "markdownDescription": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-window-menu", + "markdownDescription": "Denies the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-windows-menu-for-nsapp", + "markdownDescription": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-checked", + "markdownDescription": "Denies the set_checked command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-text", + "markdownDescription": "Denies the set_text command without any pre-configured scope." + }, + { + "description": "Denies the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-text", + "markdownDescription": "Denies the text command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`", + "type": "string", + "const": "core:path:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`" + }, + { + "description": "Enables the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-basename", + "markdownDescription": "Enables the basename command without any pre-configured scope." + }, + { + "description": "Enables the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-dirname", + "markdownDescription": "Enables the dirname command without any pre-configured scope." + }, + { + "description": "Enables the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-extname", + "markdownDescription": "Enables the extname command without any pre-configured scope." + }, + { + "description": "Enables the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-is-absolute", + "markdownDescription": "Enables the is_absolute command without any pre-configured scope." + }, + { + "description": "Enables the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-join", + "markdownDescription": "Enables the join command without any pre-configured scope." + }, + { + "description": "Enables the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-normalize", + "markdownDescription": "Enables the normalize command without any pre-configured scope." + }, + { + "description": "Enables the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve", + "markdownDescription": "Enables the resolve command without any pre-configured scope." + }, + { + "description": "Enables the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve-directory", + "markdownDescription": "Enables the resolve_directory command without any pre-configured scope." + }, + { + "description": "Denies the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-basename", + "markdownDescription": "Denies the basename command without any pre-configured scope." + }, + { + "description": "Denies the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-dirname", + "markdownDescription": "Denies the dirname command without any pre-configured scope." + }, + { + "description": "Denies the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-extname", + "markdownDescription": "Denies the extname command without any pre-configured scope." + }, + { + "description": "Denies the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-is-absolute", + "markdownDescription": "Denies the is_absolute command without any pre-configured scope." + }, + { + "description": "Denies the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-join", + "markdownDescription": "Denies the join command without any pre-configured scope." + }, + { + "description": "Denies the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-normalize", + "markdownDescription": "Denies the normalize command without any pre-configured scope." + }, + { + "description": "Denies the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve", + "markdownDescription": "Denies the resolve command without any pre-configured scope." + }, + { + "description": "Denies the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve-directory", + "markdownDescription": "Denies the resolve_directory command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`", + "type": "string", + "const": "core:resources:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`" + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`", + "type": "string", + "const": "core:tray:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`" + }, + { + "description": "Enables the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-get-by-id", + "markdownDescription": "Enables the get_by_id command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-remove-by-id", + "markdownDescription": "Enables the remove_by_id command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon-as-template", + "markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Enables the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-menu", + "markdownDescription": "Enables the set_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-show-menu-on-left-click", + "markdownDescription": "Enables the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Enables the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-temp-dir-path", + "markdownDescription": "Enables the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-tooltip", + "markdownDescription": "Enables the set_tooltip command without any pre-configured scope." + }, + { + "description": "Enables the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-visible", + "markdownDescription": "Enables the set_visible command without any pre-configured scope." + }, + { + "description": "Denies the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-get-by-id", + "markdownDescription": "Denies the get_by_id command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-remove-by-id", + "markdownDescription": "Denies the remove_by_id command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon-as-template", + "markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Denies the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-menu", + "markdownDescription": "Denies the set_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-show-menu-on-left-click", + "markdownDescription": "Denies the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Denies the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-temp-dir-path", + "markdownDescription": "Denies the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-tooltip", + "markdownDescription": "Denies the set_tooltip command without any pre-configured scope." + }, + { + "description": "Denies the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-visible", + "markdownDescription": "Denies the set_visible command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`", + "type": "string", + "const": "core:webview:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`" + }, + { + "description": "Enables the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-clear-all-browsing-data", + "markdownDescription": "Enables the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Enables the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview", + "markdownDescription": "Enables the create_webview command without any pre-configured scope." + }, + { + "description": "Enables the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview-window", + "markdownDescription": "Enables the create_webview_window command without any pre-configured scope." + }, + { + "description": "Enables the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-get-all-webviews", + "markdownDescription": "Enables the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-internal-toggle-devtools", + "markdownDescription": "Enables the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Enables the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-print", + "markdownDescription": "Enables the print command without any pre-configured scope." + }, + { + "description": "Enables the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-reparent", + "markdownDescription": "Enables the reparent command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-auto-resize", + "markdownDescription": "Enables the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-background-color", + "markdownDescription": "Enables the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-focus", + "markdownDescription": "Enables the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-position", + "markdownDescription": "Enables the set_webview_position command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-size", + "markdownDescription": "Enables the set_webview_size command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-zoom", + "markdownDescription": "Enables the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Enables the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-close", + "markdownDescription": "Enables the webview_close command without any pre-configured scope." + }, + { + "description": "Enables the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-hide", + "markdownDescription": "Enables the webview_hide command without any pre-configured scope." + }, + { + "description": "Enables the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-position", + "markdownDescription": "Enables the webview_position command without any pre-configured scope." + }, + { + "description": "Enables the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-show", + "markdownDescription": "Enables the webview_show command without any pre-configured scope." + }, + { + "description": "Enables the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-size", + "markdownDescription": "Enables the webview_size command without any pre-configured scope." + }, + { + "description": "Denies the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-clear-all-browsing-data", + "markdownDescription": "Denies the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Denies the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview", + "markdownDescription": "Denies the create_webview command without any pre-configured scope." + }, + { + "description": "Denies the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview-window", + "markdownDescription": "Denies the create_webview_window command without any pre-configured scope." + }, + { + "description": "Denies the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-get-all-webviews", + "markdownDescription": "Denies the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-internal-toggle-devtools", + "markdownDescription": "Denies the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Denies the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-print", + "markdownDescription": "Denies the print command without any pre-configured scope." + }, + { + "description": "Denies the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-reparent", + "markdownDescription": "Denies the reparent command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-auto-resize", + "markdownDescription": "Denies the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-background-color", + "markdownDescription": "Denies the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-focus", + "markdownDescription": "Denies the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-position", + "markdownDescription": "Denies the set_webview_position command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-size", + "markdownDescription": "Denies the set_webview_size command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-zoom", + "markdownDescription": "Denies the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Denies the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-close", + "markdownDescription": "Denies the webview_close command without any pre-configured scope." + }, + { + "description": "Denies the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-hide", + "markdownDescription": "Denies the webview_hide command without any pre-configured scope." + }, + { + "description": "Denies the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-position", + "markdownDescription": "Denies the webview_position command without any pre-configured scope." + }, + { + "description": "Denies the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-show", + "markdownDescription": "Denies the webview_show command without any pre-configured scope." + }, + { + "description": "Denies the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-size", + "markdownDescription": "Denies the webview_size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`", + "type": "string", + "const": "core:window:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`" + }, + { + "description": "Enables the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-available-monitors", + "markdownDescription": "Enables the available_monitors command without any pre-configured scope." + }, + { + "description": "Enables the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-center", + "markdownDescription": "Enables the center command without any pre-configured scope." + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Enables the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-create", + "markdownDescription": "Enables the create command without any pre-configured scope." + }, + { + "description": "Enables the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-current-monitor", + "markdownDescription": "Enables the current_monitor command without any pre-configured scope." + }, + { + "description": "Enables the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-cursor-position", + "markdownDescription": "Enables the cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-destroy", + "markdownDescription": "Enables the destroy command without any pre-configured scope." + }, + { + "description": "Enables the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-get-all-windows", + "markdownDescription": "Enables the get_all_windows command without any pre-configured scope." + }, + { + "description": "Enables the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-hide", + "markdownDescription": "Enables the hide command without any pre-configured scope." + }, + { + "description": "Enables the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-position", + "markdownDescription": "Enables the inner_position command without any pre-configured scope." + }, + { + "description": "Enables the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-size", + "markdownDescription": "Enables the inner_size command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-internal-toggle-maximize", + "markdownDescription": "Enables the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-always-on-top", + "markdownDescription": "Enables the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-closable", + "markdownDescription": "Enables the is_closable command without any pre-configured scope." + }, + { + "description": "Enables the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-decorated", + "markdownDescription": "Enables the is_decorated command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-focused", + "markdownDescription": "Enables the is_focused command without any pre-configured scope." + }, + { + "description": "Enables the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-fullscreen", + "markdownDescription": "Enables the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximizable", + "markdownDescription": "Enables the is_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximized", + "markdownDescription": "Enables the is_maximized command without any pre-configured scope." + }, + { + "description": "Enables the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimizable", + "markdownDescription": "Enables the is_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimized", + "markdownDescription": "Enables the is_minimized command without any pre-configured scope." + }, + { + "description": "Enables the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-resizable", + "markdownDescription": "Enables the is_resizable command without any pre-configured scope." + }, + { + "description": "Enables the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-visible", + "markdownDescription": "Enables the is_visible command without any pre-configured scope." + }, + { + "description": "Enables the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-maximize", + "markdownDescription": "Enables the maximize command without any pre-configured scope." + }, + { + "description": "Enables the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-minimize", + "markdownDescription": "Enables the minimize command without any pre-configured scope." + }, + { + "description": "Enables the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-monitor-from-point", + "markdownDescription": "Enables the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Enables the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-position", + "markdownDescription": "Enables the outer_position command without any pre-configured scope." + }, + { + "description": "Enables the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-size", + "markdownDescription": "Enables the outer_size command without any pre-configured scope." + }, + { + "description": "Enables the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-primary-monitor", + "markdownDescription": "Enables the primary_monitor command without any pre-configured scope." + }, + { + "description": "Enables the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-request-user-attention", + "markdownDescription": "Enables the request_user_attention command without any pre-configured scope." + }, + { + "description": "Enables the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-scale-factor", + "markdownDescription": "Enables the scale_factor command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-bottom", + "markdownDescription": "Enables the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-top", + "markdownDescription": "Enables the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-background-color", + "markdownDescription": "Enables the set_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-count", + "markdownDescription": "Enables the set_badge_count command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-label", + "markdownDescription": "Enables the set_badge_label command without any pre-configured scope." + }, + { + "description": "Enables the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-closable", + "markdownDescription": "Enables the set_closable command without any pre-configured scope." + }, + { + "description": "Enables the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-content-protected", + "markdownDescription": "Enables the set_content_protected command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-grab", + "markdownDescription": "Enables the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-icon", + "markdownDescription": "Enables the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-position", + "markdownDescription": "Enables the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-visible", + "markdownDescription": "Enables the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Enables the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-decorations", + "markdownDescription": "Enables the set_decorations command without any pre-configured scope." + }, + { + "description": "Enables the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-effects", + "markdownDescription": "Enables the set_effects command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focus", + "markdownDescription": "Enables the set_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focusable", + "markdownDescription": "Enables the set_focusable command without any pre-configured scope." + }, + { + "description": "Enables the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-fullscreen", + "markdownDescription": "Enables the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-ignore-cursor-events", + "markdownDescription": "Enables the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Enables the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-max-size", + "markdownDescription": "Enables the set_max_size command without any pre-configured scope." + }, + { + "description": "Enables the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-maximizable", + "markdownDescription": "Enables the set_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-min-size", + "markdownDescription": "Enables the set_min_size command without any pre-configured scope." + }, + { + "description": "Enables the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-minimizable", + "markdownDescription": "Enables the set_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-overlay-icon", + "markdownDescription": "Enables the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-position", + "markdownDescription": "Enables the set_position command without any pre-configured scope." + }, + { + "description": "Enables the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-progress-bar", + "markdownDescription": "Enables the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Enables the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-resizable", + "markdownDescription": "Enables the set_resizable command without any pre-configured scope." + }, + { + "description": "Enables the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-shadow", + "markdownDescription": "Enables the set_shadow command without any pre-configured scope." + }, + { + "description": "Enables the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-simple-fullscreen", + "markdownDescription": "Enables the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size", + "markdownDescription": "Enables the set_size command without any pre-configured scope." + }, + { + "description": "Enables the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size-constraints", + "markdownDescription": "Enables the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Enables the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-skip-taskbar", + "markdownDescription": "Enables the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Enables the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-theme", + "markdownDescription": "Enables the set_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title-bar-style", + "markdownDescription": "Enables the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Enables the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-visible-on-all-workspaces", + "markdownDescription": "Enables the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Enables the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-show", + "markdownDescription": "Enables the show command without any pre-configured scope." + }, + { + "description": "Enables the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-dragging", + "markdownDescription": "Enables the start_dragging command without any pre-configured scope." + }, + { + "description": "Enables the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-resize-dragging", + "markdownDescription": "Enables the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Enables the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-theme", + "markdownDescription": "Enables the theme command without any pre-configured scope." + }, + { + "description": "Enables the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-title", + "markdownDescription": "Enables the title command without any pre-configured scope." + }, + { + "description": "Enables the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-toggle-maximize", + "markdownDescription": "Enables the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unmaximize", + "markdownDescription": "Enables the unmaximize command without any pre-configured scope." + }, + { + "description": "Enables the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unminimize", + "markdownDescription": "Enables the unminimize command without any pre-configured scope." + }, + { + "description": "Denies the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-available-monitors", + "markdownDescription": "Denies the available_monitors command without any pre-configured scope." + }, + { + "description": "Denies the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-center", + "markdownDescription": "Denies the center command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Denies the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-create", + "markdownDescription": "Denies the create command without any pre-configured scope." + }, + { + "description": "Denies the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-current-monitor", + "markdownDescription": "Denies the current_monitor command without any pre-configured scope." + }, + { + "description": "Denies the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-cursor-position", + "markdownDescription": "Denies the cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-destroy", + "markdownDescription": "Denies the destroy command without any pre-configured scope." + }, + { + "description": "Denies the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-get-all-windows", + "markdownDescription": "Denies the get_all_windows command without any pre-configured scope." + }, + { + "description": "Denies the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-hide", + "markdownDescription": "Denies the hide command without any pre-configured scope." + }, + { + "description": "Denies the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-position", + "markdownDescription": "Denies the inner_position command without any pre-configured scope." + }, + { + "description": "Denies the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-size", + "markdownDescription": "Denies the inner_size command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-internal-toggle-maximize", + "markdownDescription": "Denies the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-always-on-top", + "markdownDescription": "Denies the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-closable", + "markdownDescription": "Denies the is_closable command without any pre-configured scope." + }, + { + "description": "Denies the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-decorated", + "markdownDescription": "Denies the is_decorated command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-focused", + "markdownDescription": "Denies the is_focused command without any pre-configured scope." + }, + { + "description": "Denies the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-fullscreen", + "markdownDescription": "Denies the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximizable", + "markdownDescription": "Denies the is_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximized", + "markdownDescription": "Denies the is_maximized command without any pre-configured scope." + }, + { + "description": "Denies the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimizable", + "markdownDescription": "Denies the is_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimized", + "markdownDescription": "Denies the is_minimized command without any pre-configured scope." + }, + { + "description": "Denies the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-resizable", + "markdownDescription": "Denies the is_resizable command without any pre-configured scope." + }, + { + "description": "Denies the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-visible", + "markdownDescription": "Denies the is_visible command without any pre-configured scope." + }, + { + "description": "Denies the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-maximize", + "markdownDescription": "Denies the maximize command without any pre-configured scope." + }, + { + "description": "Denies the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-minimize", + "markdownDescription": "Denies the minimize command without any pre-configured scope." + }, + { + "description": "Denies the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-monitor-from-point", + "markdownDescription": "Denies the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Denies the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-position", + "markdownDescription": "Denies the outer_position command without any pre-configured scope." + }, + { + "description": "Denies the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-size", + "markdownDescription": "Denies the outer_size command without any pre-configured scope." + }, + { + "description": "Denies the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-primary-monitor", + "markdownDescription": "Denies the primary_monitor command without any pre-configured scope." + }, + { + "description": "Denies the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-request-user-attention", + "markdownDescription": "Denies the request_user_attention command without any pre-configured scope." + }, + { + "description": "Denies the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-scale-factor", + "markdownDescription": "Denies the scale_factor command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-bottom", + "markdownDescription": "Denies the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-top", + "markdownDescription": "Denies the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-background-color", + "markdownDescription": "Denies the set_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-count", + "markdownDescription": "Denies the set_badge_count command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-label", + "markdownDescription": "Denies the set_badge_label command without any pre-configured scope." + }, + { + "description": "Denies the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-closable", + "markdownDescription": "Denies the set_closable command without any pre-configured scope." + }, + { + "description": "Denies the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-content-protected", + "markdownDescription": "Denies the set_content_protected command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-grab", + "markdownDescription": "Denies the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-icon", + "markdownDescription": "Denies the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-position", + "markdownDescription": "Denies the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-visible", + "markdownDescription": "Denies the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Denies the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-decorations", + "markdownDescription": "Denies the set_decorations command without any pre-configured scope." + }, + { + "description": "Denies the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-effects", + "markdownDescription": "Denies the set_effects command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focus", + "markdownDescription": "Denies the set_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focusable", + "markdownDescription": "Denies the set_focusable command without any pre-configured scope." + }, + { + "description": "Denies the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-fullscreen", + "markdownDescription": "Denies the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-ignore-cursor-events", + "markdownDescription": "Denies the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Denies the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-max-size", + "markdownDescription": "Denies the set_max_size command without any pre-configured scope." + }, + { + "description": "Denies the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-maximizable", + "markdownDescription": "Denies the set_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-min-size", + "markdownDescription": "Denies the set_min_size command without any pre-configured scope." + }, + { + "description": "Denies the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-minimizable", + "markdownDescription": "Denies the set_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-overlay-icon", + "markdownDescription": "Denies the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-position", + "markdownDescription": "Denies the set_position command without any pre-configured scope." + }, + { + "description": "Denies the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-progress-bar", + "markdownDescription": "Denies the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Denies the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-resizable", + "markdownDescription": "Denies the set_resizable command without any pre-configured scope." + }, + { + "description": "Denies the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-shadow", + "markdownDescription": "Denies the set_shadow command without any pre-configured scope." + }, + { + "description": "Denies the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-simple-fullscreen", + "markdownDescription": "Denies the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size", + "markdownDescription": "Denies the set_size command without any pre-configured scope." + }, + { + "description": "Denies the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size-constraints", + "markdownDescription": "Denies the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Denies the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-skip-taskbar", + "markdownDescription": "Denies the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Denies the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-theme", + "markdownDescription": "Denies the set_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title-bar-style", + "markdownDescription": "Denies the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Denies the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-visible-on-all-workspaces", + "markdownDescription": "Denies the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Denies the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-show", + "markdownDescription": "Denies the show command without any pre-configured scope." + }, + { + "description": "Denies the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-dragging", + "markdownDescription": "Denies the start_dragging command without any pre-configured scope." + }, + { + "description": "Denies the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-resize-dragging", + "markdownDescription": "Denies the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Denies the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-theme", + "markdownDescription": "Denies the theme command without any pre-configured scope." + }, + { + "description": "Denies the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-title", + "markdownDescription": "Denies the title command without any pre-configured scope." + }, + { + "description": "Denies the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-toggle-maximize", + "markdownDescription": "Denies the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unmaximize", + "markdownDescription": "Denies the unmaximize command without any pre-configured scope." + }, + { + "description": "Denies the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unminimize", + "markdownDescription": "Denies the unminimize command without any pre-configured scope." + }, + { + "description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`", + "type": "string", + "const": "dialog:default", + "markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`" + }, + { + "description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "type": "string", + "const": "dialog:allow-ask", + "markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + }, + { + "description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "type": "string", + "const": "dialog:allow-confirm", + "markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + }, + { + "description": "Enables the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-message", + "markdownDescription": "Enables the message command without any pre-configured scope." + }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Enables the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-save", + "markdownDescription": "Enables the save command without any pre-configured scope." + }, + { + "description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "type": "string", + "const": "dialog:deny-ask", + "markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + }, + { + "description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "type": "string", + "const": "dialog:deny-confirm", + "markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + }, + { + "description": "Denies the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-message", + "markdownDescription": "Denies the message command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Denies the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-save", + "markdownDescription": "Denies the save command without any pre-configured scope." + }, + { + "description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`", + "type": "string", + "const": "updater:default", + "markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`" + }, + { + "description": "Enables the check command without any pre-configured scope.", + "type": "string", + "const": "updater:allow-check", + "markdownDescription": "Enables the check command without any pre-configured scope." + }, + { + "description": "Enables the download command without any pre-configured scope.", + "type": "string", + "const": "updater:allow-download", + "markdownDescription": "Enables the download command without any pre-configured scope." + }, + { + "description": "Enables the download_and_install command without any pre-configured scope.", + "type": "string", + "const": "updater:allow-download-and-install", + "markdownDescription": "Enables the download_and_install command without any pre-configured scope." + }, + { + "description": "Enables the install command without any pre-configured scope.", + "type": "string", + "const": "updater:allow-install", + "markdownDescription": "Enables the install command without any pre-configured scope." + }, + { + "description": "Denies the check command without any pre-configured scope.", + "type": "string", + "const": "updater:deny-check", + "markdownDescription": "Denies the check command without any pre-configured scope." + }, + { + "description": "Denies the download command without any pre-configured scope.", + "type": "string", + "const": "updater:deny-download", + "markdownDescription": "Denies the download command without any pre-configured scope." + }, + { + "description": "Denies the download_and_install command without any pre-configured scope.", + "type": "string", + "const": "updater:deny-download-and-install", + "markdownDescription": "Denies the download_and_install command without any pre-configured scope." + }, + { + "description": "Denies the install command without any pre-configured scope.", + "type": "string", + "const": "updater:deny-install", + "markdownDescription": "Denies the install command without any pre-configured scope." + } + ] + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + } + } +} \ No newline at end of file diff --git a/src-tauri/src/boost.rs b/src-tauri/src/boost.rs new file mode 100644 index 00000000..ee56da56 --- /dev/null +++ b/src-tauri/src/boost.rs @@ -0,0 +1,294 @@ +//! Boost Local : un assistant local resident qui sert toutes les IA. +//! +//! Quand il est actif : +//! * la recherche semantique vectorielle est activee (le "bibliothecaire") ; +//! * un petit modele distillateur est charge dans Ollama et garde en memoire +//! (`keep_alive = -1`) pour toute la session de codage ; +//! * la commande `boost_local_distill` compresse n'importe quel gros texte +//! (logs, fichiers, sorties d'outils) en quelques faits utiles. +//! +//! Objectif : donner aux IA (agent principal, equipe, autres modeles) exactement +//! ce dont elles ont besoin, pour limiter les jetons et accelerer la recherche. + +use std::process::{Command, Stdio}; +use std::sync::Mutex; +use std::time::Duration; + +use serde::Serialize; +use serde_json::json; + +const OLLAMA_URL: &str = "http://127.0.0.1:11434"; +const DEFAULT_DISTILLER: &str = "qwen2.5:3b"; + +struct BoostState { + active: bool, + distiller: String, +} + +static BOOST: Mutex = Mutex::new(BoostState { + active: false, + distiller: String::new(), +}); + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BoostStatus { + /// Le serveur Ollama repond. + pub ollama_running: bool, + /// Le distillateur est charge en memoire (visible dans /api/ps). + pub distiller_loaded: bool, + /// Nom du modele distillateur courant. + pub distiller_model: String, + /// La recherche semantique vectorielle est active. + pub semantic_enabled: bool, + /// Le boost a ete demarre dans cette session. + pub active: bool, +} + +fn http_client() -> reqwest::Client { + reqwest::Client::builder() + .timeout(Duration::from_secs(600)) + .build() + .unwrap_or_default() +} + +fn current_distiller() -> String { + let guard = BOOST.lock().ok(); + let name = guard + .as_ref() + .map(|s| s.distiller.clone()) + .unwrap_or_default(); + if name.is_empty() { + DEFAULT_DISTILLER.to_string() + } else { + name + } +} + +fn semantic_enabled() -> bool { + std::env::var_os("SINEW_INDEX_EMBEDDINGS").is_some() +} + +/// Localise l'executable Ollama (PATH puis emplacement par defaut Windows). +fn ollama_binary() -> String { + if let Ok(local) = std::env::var("LOCALAPPDATA") { + let candidate = format!("{}\\Programs\\Ollama\\ollama.exe", local); + if std::path::Path::new(&candidate).exists() { + return candidate; + } + } + "ollama".to_string() +} + +/// Demarre `ollama serve` en arriere-plan, sans fenetre, s'il n'est pas deja lance. +fn spawn_ollama_server() { + let bin = ollama_binary(); + let mut cmd = Command::new(bin); + cmd.arg("serve") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + const DETACHED_PROCESS: u32 = 0x0000_0008; + cmd.creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS); + } + let _ = cmd.spawn(); +} + +async fn ollama_running(client: &reqwest::Client) -> bool { + client + .get(format!("{OLLAMA_URL}/api/version")) + .timeout(Duration::from_secs(3)) + .send() + .await + .map(|r| r.status().is_success()) + .unwrap_or(false) +} + +/// Le modele est-il actuellement charge en memoire ? +async fn distiller_loaded(client: &reqwest::Client, model: &str) -> bool { + let Ok(resp) = client + .get(format!("{OLLAMA_URL}/api/ps")) + .timeout(Duration::from_secs(3)) + .send() + .await + else { + return false; + }; + let Ok(value) = resp.json::().await else { + return false; + }; + value + .get("models") + .and_then(|m| m.as_array()) + .map(|arr| { + arr.iter().any(|m| { + m.get("name") + .and_then(|n| n.as_str()) + .map(|n| n == model || n.starts_with(&format!("{model}:")) || model.starts_with(n)) + .unwrap_or(false) + }) + }) + .unwrap_or(false) +} + +async fn build_status(client: &reqwest::Client) -> BoostStatus { + let model = current_distiller(); + let running = ollama_running(client).await; + let loaded = if running { + distiller_loaded(client, &model).await + } else { + false + }; + let active = BOOST.lock().map(|s| s.active).unwrap_or(false); + BoostStatus { + ollama_running: running, + distiller_loaded: loaded, + distiller_model: model, + semantic_enabled: semantic_enabled(), + active, + } +} + +/// Etat courant du Boost Local. +#[tauri::command] +pub async fn boost_local_status() -> Result { + let client = http_client(); + Ok(build_status(&client).await) +} + +/// Active le Boost Local : serveur Ollama + distillateur resident + semantique. +#[tauri::command] +pub async fn boost_local_start(distiller: Option) -> Result { + let model = distiller + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_DISTILLER.to_string()); + + let client = http_client(); + + // 1) S'assurer que le serveur Ollama tourne (le demarrer sinon). + if !ollama_running(&client).await { + spawn_ollama_server(); + for _ in 0..20 { + tokio::time::sleep(Duration::from_millis(500)).await; + if ollama_running(&client).await { + break; + } + } + } + if !ollama_running(&client).await { + return Err("Impossible de démarrer le serveur Ollama. Vérifiez qu'Ollama est installé.".into()); + } + + // 2) Charger le distillateur et le garder en memoire pour la session. + let load = client + .post(format!("{OLLAMA_URL}/api/generate")) + .json(&json!({ + "model": model, + "prompt": "", + "stream": false, + "keep_alive": -1 + })) + .send() + .await + .map_err(|e| format!("Échec du chargement du distillateur : {e}"))?; + + if !load.status().is_success() { + let body = load.text().await.unwrap_or_default(); + return Err(format!( + "Le modèle « {model} » n'est pas disponible. Lancez : ollama pull {model}\n{body}" + )); + } + + // 3) Activer la recherche semantique + signaler le distillateur a l'agent. + std::env::set_var("SINEW_INDEX_EMBEDDINGS", "1"); + std::env::set_var("SINEW_BOOST_DISTILLER", &model); + + // 4) Memoriser l'etat. + if let Ok(mut guard) = BOOST.lock() { + guard.active = true; + guard.distiller = model.clone(); + } + + Ok(build_status(&client).await) +} + +/// Desactive le Boost Local : libere le distillateur et coupe la semantique. +#[tauri::command] +pub async fn boost_local_stop() -> Result { + let model = current_distiller(); + let client = http_client(); + + if ollama_running(&client).await { + // keep_alive = 0 => decharge immediatement le modele de la memoire. + let _ = client + .post(format!("{OLLAMA_URL}/api/generate")) + .json(&json!({ "model": model, "prompt": "", "stream": false, "keep_alive": 0 })) + .send() + .await; + } + + std::env::remove_var("SINEW_INDEX_EMBEDDINGS"); + std::env::remove_var("SINEW_BOOST_DISTILLER"); + if let Ok(mut guard) = BOOST.lock() { + guard.active = false; + } + + Ok(build_status(&client).await) +} + +/// Distille un gros texte (log, fichier, sortie d'outil) en faits utiles. +/// C'est la fonction que les IA appellent pour economiser des jetons. +#[tauri::command] +pub async fn boost_local_distill( + text: String, + question: Option, +) -> Result { + if text.trim().is_empty() { + return Ok(String::new()); + } + let model = current_distiller(); + let client = http_client(); + + if !ollama_running(&client).await { + return Err("Boost Local inactif : le serveur Ollama ne répond pas.".into()); + } + + let q = question + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "Summarize the essential facts an AI coding agent needs.".to_string()); + + let prompt = format!( + "You are a context distiller for an AI coding agent. Answer using ONLY the content below. \ + Be terse and factual: names, keys, paths, signatures. No filler, no code fences. Max 180 words.\n\n\ + TASK: {q}\n\nCONTENT:\n{text}" + ); + + let resp = client + .post(format!("{OLLAMA_URL}/api/generate")) + .json(&json!({ + "model": model, + "prompt": prompt, + "stream": false, + "keep_alive": -1, + "options": { "num_ctx": 8192, "temperature": 0.1 } + })) + .send() + .await + .map_err(|e| format!("Échec de la distillation : {e}"))?; + + let value: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("Réponse invalide du distillateur : {e}"))?; + + Ok(value + .get("response") + .and_then(|r| r.as_str()) + .unwrap_or_default() + .trim() + .to_string()) +} diff --git a/src-tauri/src/cli.rs b/src-tauri/src/cli.rs new file mode 100644 index 00000000..181577c1 --- /dev/null +++ b/src-tauri/src/cli.rs @@ -0,0 +1,312 @@ +use std::env; +use std::fs; +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +fn find_executable(name: &str) -> Option { + if let Ok(paths) = env::var("PATH") { + for path in env::split_paths(&paths) { + let exe_path = path.join(name); + let exe_path_win = path.join(format!("{}.exe", name)); + if exe_path.is_file() { + return Some(exe_path); + } + if exe_path_win.is_file() { + return Some(exe_path_win); + } + } + } + None +} + +pub fn handle_args() -> bool { + let args: Vec = env::args().collect(); + if args.len() < 2 { + return false; + } + + match args[1].as_str() { + "--sync" | "sync" => { + println!("Starting standalone synchronization..."); + if let Err(e) = run_sync_cli() { + eprintln!("Error during synchronization: {:?}", e); + std::process::exit(1); + } + println!("Synchronization completed successfully!"); + true + } + "--register-chrome" | "register-chrome" => { + println!("Registering Sinew Chrome MCP Server..."); + if let Err(e) = run_register_chrome_cli() { + eprintln!("Error during registration: {:?}", e); + std::process::exit(1); + } + println!("Registration completed successfully!"); + true + } + "--probe" | "probe" => { + println!("Probing Cursor connection..."); + if let Err(e) = run_probe_cli() { + eprintln!("Error during probe: {:?}", e); + std::process::exit(1); + } + true + } + _ => false, + } +} + +fn run_sync_cli() -> Result<(), anyhow::Error> { + // 1. Consolidate rules + println!("1/4 Consolidating rules..."); + crate::rules::consolidate_rules(); + + // 2. Locate databases + let localappdata = env::var("LOCALAPPDATA").map_err(|_| anyhow::anyhow!("LOCALAPPDATA not found"))?; + let local_db = PathBuf::from(&localappdata) + .join("hyrak") + .join("sinew") + .join("data") + .join("desktop-state.sqlite3"); + + let onedrive = env::var("ONEDRIVE").unwrap_or_else(|_| { + env::var("USERPROFILE") + .map(|u| format!("{}\\OneDrive", u)) + .unwrap_or_default() + }); + + if onedrive.is_empty() { + return Err(anyhow::anyhow!("OneDrive folder path not found")); + } + + let onedrive_db_dir = PathBuf::from(&onedrive).join("Documents").join("Sinew"); + let onedrive_db = onedrive_db_dir.join("desktop-state.sqlite3"); + + // 3. Database & File Sync + println!("2/4 Merging databases..."); + if local_db.exists() { + let _ = fs::create_dir_all(&onedrive_db_dir); + if onedrive_db.exists() { + if let Err(e) = crate::merge_databases(&onedrive_db, &local_db) { + eprintln!("Warning: Differential merge failed, copying directly: {:?}", e); + fs::copy(&local_db, &onedrive_db)?; + } + } else { + fs::copy(&local_db, &onedrive_db)?; + } + + // Copy auth files + crate::sync_auth_files(&localappdata, &onedrive_db_dir, true); + + // Sync rules/errors + let local_learning_dir = PathBuf::from(&localappdata).join("Sinew"); + let errors_local = local_learning_dir.join("errors_raw.json"); + let rules_local = local_learning_dir.join("instructions_consolidated.md"); + + let errors_onedrive = onedrive_db_dir.join("errors_raw.json"); + let rules_onedrive = onedrive_db_dir.join("instructions_consolidated.md"); + + if errors_local.exists() { + let _ = fs::copy(&errors_local, &errors_onedrive); + } + if rules_local.exists() { + let _ = fs::copy(&rules_local, &rules_onedrive); + } + } else { + println!("Local database does not exist, skipping database sync."); + } + + // 4. Git Push current workspace + println!("3/4 Verifying Git status for the workspace..."); + let status_out = Command::new("git") + .args(&["status", "--porcelain"]) + .stdout(Stdio::piped()) + .output()?; + let status_str = String::from_utf8_lossy(&status_out.stdout); + + if !status_str.trim().is_empty() { + println!("Changes detected. Committing and pushing to git..."); + Command::new("git").args(&["add", "-A"]).status()?; + Command::new("git").args(&["commit", "-m", "Sauvegarde automatique via Sinew Sync"]).status()?; + Command::new("git").args(&["push"]).status()?; + println!("Git changes successfully pushed!"); + } else { + println!("Workspace is clean. Checking for unpushed commits..."); + let cherry_out = Command::new("git") + .args(&["cherry", "-v"]) + .stdout(Stdio::piped()) + .output()?; + let cherry_str = String::from_utf8_lossy(&cherry_out.stdout); + if !cherry_str.trim().is_empty() { + println!("Unpushed commits found. Pushing..."); + Command::new("git").args(&["push"]).status()?; + println!("Commits successfully pushed!"); + } else { + println!("Everything is up to date."); + } + } + + Ok(()) +} + +fn run_register_chrome_cli() -> Result<(), anyhow::Error> { + let local_app_data = env::var("LOCALAPPDATA").map_err(|_| anyhow::anyhow!("LOCALAPPDATA not found"))?; + let db_path = env::var("SINEW_DESKTOP_DB").map(PathBuf::from).unwrap_or_else(|_| { + PathBuf::from(&local_app_data) + .join("hyrak") + .join("sinew") + .join("data") + .join("desktop-state.sqlite3") + }); + + if !db_path.exists() { + return Err(anyhow::anyhow!("Database not found at {:?}", db_path)); + } + + let current_dir = env::current_dir().unwrap_or_default(); + let source_dir = current_dir.join("sinew-chrome-bridge"); + let installed_dir = env::var("SINEW_CHROME_BRIDGE_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| { + PathBuf::from(&local_app_data) + .join("Sinew") + .join("ChromeBridge") + }); + + let script_dir = if installed_dir.join("mcp_server.js").exists() { + installed_dir + } else { + source_dir + }; + + let mcp_exe = script_dir.join("native-host-wrapper.exe"); + let (mcp_cmd, mcp_args) = if mcp_exe.exists() { + (mcp_exe.to_string_lossy().to_string(), vec!["--mcp".to_string()]) + } else { + let node_path = env::var("SINEW_NODE_PATH") + .ok() + .or_else(|| { + find_executable("node").map(|p| p.to_string_lossy().to_string()) + }) + .unwrap_or_else(|| "C:\\Program Files\\nodejs\\node.exe".to_string()); + (node_path, vec![script_dir.join("mcp_server.js").to_string_lossy().to_string()]) + }; + + let new_server = serde_json::json!({ + "id": "sinew-chrome", + "name": "Sinew Chrome", + "command": mcp_cmd, + "args": mcp_args, + "env": [ + {"key": "MCP_BROWSER_CDP_URL", "value": "http://127.0.0.1:29002"}, + {"key": "SINEW_CHROME_BRIDGE_DIR", "value": script_dir.to_string_lossy().to_string()}, + ], + "cwd": script_dir.to_string_lossy().to_string(), + "enabled": true, + "autoLoad": true, + }); + + let conn = rusqlite::Connection::open(&db_path)?; + let mut stmt = conn.prepare("SELECT value_json FROM app_settings WHERE key = 'mcp_settings';")?; + let row_opt: Option = stmt.query_row([], |r| r.get(0)).ok(); + + let mut settings = if let Some(ref val_str) = row_opt { + serde_json::from_str::(val_str).unwrap_or_else(|_| serde_json::json!({"servers": []})) + } else { + serde_json::json!({"servers": []}) + }; + + let servers = settings.get_mut("servers").and_then(|s| s.as_array_mut()); + if let Some(servers_arr) = servers { + let mut updated = false; + for s in servers_arr.iter_mut() { + let id = s.get("id").and_then(|id| id.as_str()).unwrap_or(""); + let name = s.get("name").and_then(|n| n.as_str()).unwrap_or(""); + if id == "sinew-chrome" || id == "browser-use" || name == "Sinew Chrome" { + *s = new_server.clone(); + updated = true; + break; + } + } + if !updated { + servers_arr.push(new_server); + } + } else { + settings = serde_json::json!({ + "servers": [new_server] + }); + } + + let value_json = serde_json::to_string(&settings)?; + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64; + + conn.execute( + "INSERT INTO app_settings (key, value_json, updated_at_ms) \ + VALUES ('mcp_settings', ?, ?) \ + ON CONFLICT(key) DO UPDATE SET \ + value_json = excluded.value_json, \ + updated_at_ms = excluded.updated_at_ms;", + rusqlite::params![value_json, now_ms], + )?; + + println!("MCP server 'Sinew Chrome' registered at {:?}", script_dir); + Ok(()) +} + +fn run_probe_cli() -> Result<(), anyhow::Error> { + use sinew_core::{ChatMessage, ModelRef, Provider, ProviderRequest}; + use sinew_cursor::CursorProvider; + use futures::StreamExt; + + println!("Loading Cursor provider configuration..."); + let provider = CursorProvider::from_default_sources().map_err(|e| { + anyhow::anyhow!("Failed to initialize Cursor provider: {:?}", e) + })?; + + println!("Configuration loaded. Sending test message: 'Reply with exactly: OK'..."); + let request = ProviderRequest::new( + ModelRef::new("cursor", "composer-2.5-fast"), + vec![ChatMessage::user_text("Reply with exactly: OK")], + ) + .with_system("You are a connection test probe.") + .with_workspace_root(std::env::current_dir().unwrap_or_default().display().to_string()); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + + rt.block_on(async { + println!("Sending Unified Chat request..."); + let mut stream = provider.stream(request).await.map_err(|e| { + anyhow::anyhow!("Stream initiation failed: {:?}", e) + })?; + + println!("Stream opened. Receiving chunks:"); + let mut full_response = String::new(); + while let Some(chunk_res) = stream.next().await { + match chunk_res { + Ok(event) => { + if let sinew_core::StreamEvent::TextDelta { delta, .. } = event { + print!("{}", delta); + let _ = std::io::Write::flush(&mut std::io::stdout()); + full_response.push_str(&delta); + } + } + Err(e) => { + eprintln!("\nError during stream decoding: {:?}", e); + return Err(anyhow::anyhow!("Stream decoding error: {:?}", e)); + } + } + } + println!("\nStream completed!"); + if full_response.trim() == "OK" { + println!("SUCCESS: connection and decryption working perfectly!"); + } else { + println!("WARNING: received unexpected response: {:?}", full_response); + } + Ok(()) + }) +} diff --git a/src-tauri/src/context.rs b/src-tauri/src/context.rs index 5bbcc360..6c853508 100644 --- a/src-tauri/src/context.rs +++ b/src-tauri/src/context.rs @@ -10,12 +10,27 @@ pub(super) async fn estimate_context( normalize_workspace_root(&input.workspace_path).map_err(error_to_string)?; let workspace_id = workspace_root.display().to_string(); let effective_system_prompt = - system_prompt_for_workspace(&workspace_root, &state.system_prompt) - .map_err(error_to_string)?; + system_prompt_for_workspace( + &workspace_root, + &state.system_prompt, + input.git_automation, + input.concise_answers, + input.agent_autonomy, + input.force_changelog, + input.git_french_messages, + input.auto_mockups, + input.strict_problem_solving, + input.full_implementation, + input.client_formatted_date_time.as_deref(), + ) + .map_err(error_to_string)?; + let effective_system_prompt = + with_display_mode_prompt(&effective_system_prompt, input.display_mode); + let project_id = crate::workspace::resolve_project_id_str(&workspace_id); let mut conversation = state .store - .load_conversation(&workspace_id, &input.conversation_id) + .load_conversation(&project_id, &input.conversation_id) .map_err(error_to_string)? .ok_or_else(|| "conversation not found".to_string())?; @@ -76,6 +91,7 @@ pub(super) async fn estimate_context( state.max_tool_rounds, None, TurnCancel::empty(), + state.editor_diagnostics.clone(), ) .descriptors(); let team_tools = TeamTool::descriptors_static(); @@ -107,6 +123,7 @@ pub(super) async fn estimate_context( cache_stable_message_count, breakdown_weights, !has_pending_user_input, + Some(workspace_root.display().to_string()), ) .await } @@ -120,7 +137,7 @@ pub(super) async fn estimate_sub_agent_context( let workspace_root = normalize_workspace_root(&input.workspace_path).map_err(error_to_string)?; let effective_system_prompt = - system_prompt_for_workspace(&workspace_root, &state.system_prompt) + system_prompt_for_workspace(&workspace_root, &state.system_prompt, true, true, true, false, false, false, false, false, None) .map_err(error_to_string)?; let settings = state .store @@ -193,6 +210,7 @@ pub(super) async fn estimate_sub_agent_context( input.history.len(), breakdown_weights, true, + Some(workspace_root.display().to_string()), ) .await } @@ -249,6 +267,7 @@ pub(super) async fn estimate_model_context( cache_stable_message_count: usize, breakdown_weights: Vec, prefer_latest_stream_usage: bool, + workspace_root: Option, ) -> std::result::Result { let caps = provider .capabilities(&model) @@ -266,6 +285,9 @@ pub(super) async fn estimate_model_context( if let Some(cache_key) = cache_key { request = request.with_cache_key(cache_key); } + if let Some(workspace_root) = workspace_root { + request = request.with_workspace_root(workspace_root); + } match provider.estimate_tokens(request).await { Ok(estimate) => ( diff --git a/src-tauri/src/conversations.rs b/src-tauri/src/conversations.rs index a30fdb95..cefb895c 100644 --- a/src-tauri/src/conversations.rs +++ b/src-tauri/src/conversations.rs @@ -7,9 +7,13 @@ pub(super) async fn list_conversations( ) -> std::result::Result, String> { let workspace_root = normalize_workspace_root(&input.workspace_path).map_err(error_to_string)?; + let project_id = workspace::get_or_create_project_id(&workspace_root)?; + // Run path-based and UUID-based migrations + workspace::migrate_workspace_conversations(&state.store, &workspace_root, &project_id); + let git_remote_url = git::get_git_remote_url(&workspace_root); state .store - .list_conversations(&workspace_root.display().to_string()) + .list_conversations(&project_id, git_remote_url.as_deref()) .map_err(error_to_string) } @@ -20,17 +24,29 @@ pub(super) async fn create_conversation( ) -> std::result::Result { let workspace_root = normalize_workspace_root(&input.workspace_path).map_err(error_to_string)?; + let project_id = workspace::get_or_create_project_id(&workspace_root)?; + + // Run path-based and UUID-based migrations + workspace::migrate_workspace_conversations(&state.store, &workspace_root, &project_id); + + let git_remote_url = git::get_git_remote_url(&workspace_root); state .store .create_conversation( - &workspace_root.display().to_string(), + &project_id, + git_remote_url.as_deref(), &state.default_model, &state.system_prompt, ) .map_err(error_to_string)?; + + std::thread::spawn(|| { + crate::backup_onedrive_db_on_exit(); + }); + state .store - .bootstrap_workspace(&workspace_root, &state.default_model, &state.system_prompt) + .bootstrap_workspace(&workspace_root, &project_id, git_remote_url.as_deref(), &state.default_model, &state.system_prompt) .map_err(error_to_string) } @@ -41,13 +57,18 @@ pub(super) async fn load_conversation( ) -> std::result::Result { let workspace_root = normalize_workspace_root(&input.workspace_path).map_err(error_to_string)?; + let project_id = workspace::get_or_create_project_id(&workspace_root)?; state .store .load_conversation( - &workspace_root.display().to_string(), + &project_id, &input.conversation_id, ) .map_err(error_to_string)? + .map(|mut conv| { + conv.workspace_id = input.workspace_path.clone(); + conv + }) .ok_or_else(|| "conversation not found".to_string()) } @@ -62,14 +83,20 @@ pub(super) async fn rename_conversation( if title.is_empty() { return Err("title cannot be empty".into()); } - let workspace_id = workspace_root.display().to_string(); + let project_id = workspace::get_or_create_project_id(&workspace_root)?; state .store - .rename_conversation(&workspace_id, &input.conversation_id, title) + .rename_conversation(&project_id, &input.conversation_id, title) .map_err(error_to_string)?; + + std::thread::spawn(|| { + crate::backup_onedrive_db_on_exit(); + }); + + let git_remote_url = git::get_git_remote_url(&workspace_root); state .store - .list_conversations(&workspace_id) + .list_conversations(&project_id, git_remote_url.as_deref()) .map_err(error_to_string) } @@ -80,7 +107,7 @@ pub(super) async fn delete_conversation( ) -> std::result::Result { let workspace_root = normalize_workspace_root(&input.workspace_path).map_err(error_to_string)?; - let workspace_id = workspace_root.display().to_string(); + let project_id = workspace::get_or_create_project_id(&workspace_root)?; { let active_turns = state.active_turns.lock().await; if active_turns.contains_key(&input.conversation_id) { @@ -89,11 +116,17 @@ pub(super) async fn delete_conversation( } state .store - .delete_conversation(&workspace_id, &input.conversation_id) + .delete_conversation(&project_id, &input.conversation_id) .map_err(error_to_string)?; + + std::thread::spawn(|| { + crate::backup_onedrive_db_on_exit(); + }); + + let git_remote_url = git::get_git_remote_url(&workspace_root); state .store - .bootstrap_workspace(&workspace_root, &state.default_model, &state.system_prompt) + .bootstrap_workspace(&workspace_root, &project_id, git_remote_url.as_deref(), &state.default_model, &state.system_prompt) .map_err(error_to_string) } @@ -104,7 +137,7 @@ pub(super) async fn set_conversation_mode( ) -> std::result::Result { let workspace_root = normalize_workspace_root(&input.workspace_path).map_err(error_to_string)?; - let workspace_id = workspace_root.display().to_string(); + let project_id = workspace::get_or_create_project_id(&workspace_root)?; { let active_turns = state.active_turns.lock().await; if active_turns.contains_key(&input.conversation_id) { @@ -114,7 +147,7 @@ pub(super) async fn set_conversation_mode( let mut conversation = state .store - .load_conversation(&workspace_id, &input.conversation_id) + .load_conversation(&project_id, &input.conversation_id) .map_err(error_to_string)? .ok_or_else(|| "conversation not found".to_string())?; @@ -140,6 +173,7 @@ pub(super) async fn set_conversation_mode( .store .save_conversation(&conversation) .map_err(error_to_string)?; + conversation.workspace_id = input.workspace_path.clone(); // Keep absolute path for frontend Ok(conversation) } @@ -150,7 +184,7 @@ pub(super) async fn set_conversation_model_preference( ) -> std::result::Result { let workspace_root = normalize_workspace_root(&input.workspace_path).map_err(error_to_string)?; - let workspace_id = workspace_root.display().to_string(); + let project_id = workspace::get_or_create_project_id(&workspace_root)?; let conversation_id = input.conversation_id; let mode = AgentMode::from(input.mode); @@ -163,7 +197,7 @@ pub(super) async fn set_conversation_model_preference( let mut conversation = state .store - .load_conversation(&workspace_id, &conversation_id) + .load_conversation(&project_id, &conversation_id) .map_err(error_to_string)? .ok_or_else(|| "conversation not found".to_string())?; let selected = model_with_optional_selection( @@ -301,3 +335,114 @@ pub(super) async fn save_skill_settings( .map_err(error_to_string)?; Ok(list_installed_skills(workspace_root, &saved)) } + +#[tauri::command] +pub(super) async fn register_chrome_bridge( + app_handle: tauri::AppHandle, + workspace_path: String, +) -> std::result::Result { + #[cfg(target_os = "windows")] + { + use std::process::Command; + use std::path::PathBuf; + use tauri::Manager; + + // Try resource path first + let mut ps_script = None; + if let Ok(resource_dir) = app_handle.path().resource_dir() { + let path = resource_dir.join("sinew-chrome-bridge").join("register.ps1"); + if path.exists() { + ps_script = Some(path); + } + } + + // Fallback to workspace path + if ps_script.is_none() { + let workspace_root = PathBuf::from(&workspace_path); + let path = workspace_root.join("sinew-chrome-bridge").join("register.ps1"); + if path.exists() { + ps_script = Some(path); + } + } + + let ps_script = match ps_script { + Some(path) => path, + None => return Err("Le script d'enregistrement register.ps1 est introuvable. Veuillez vous assurer que le dossier sinew-chrome-bridge est présent dans vos ressources ou votre espace de travail.".to_string()), + }; + + use std::os::windows::process::CommandExt; + let mut cmd = Command::new("powershell"); + cmd.creation_flags(0x08000000); + let output = cmd + .arg("-ExecutionPolicy") + .arg("Bypass") + .arg("-File") + .arg(ps_script) + .output(); + + match output { + Ok(out) => { + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + if out.status.success() { + Ok(stdout) + } else { + Err(format!("Erreur lors de l'exécution du script :\n{}\n{}", stdout, stderr)) + } + } + Err(err) => Err(format!("Impossible de lancer PowerShell: {}", err)), + } + } + + #[cfg(not(target_os = "windows"))] + { + Err("L'enregistrement du pont Chrome n'est supporté que sur Windows.".to_string()) + } +} + +#[tauri::command] +pub(super) async fn list_other_workspaces_conversations( + state: State<'_, DesktopState>, + input: WorkspaceInput, +) -> std::result::Result, String> { + let workspace_root = + normalize_workspace_root(&input.workspace_path).map_err(error_to_string)?; + let project_id = workspace::get_or_create_project_id(&workspace_root)?; + state + .store + .list_other_workspaces(&workspace_root.display().to_string(), Some(&project_id)) + .map_err(error_to_string) +} + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct MigrateConversationsInput { + pub(super) src_workspace_path: String, + pub(super) dest_workspace_path: String, +} + +#[tauri::command] +pub(super) async fn migrate_conversations_to_current( + state: State<'_, DesktopState>, + input: MigrateConversationsInput, +) -> std::result::Result<(), String> { + let src_root = + normalize_workspace_root(&input.src_workspace_path).map_err(error_to_string)?; + let dest_root = + normalize_workspace_root(&input.dest_workspace_path).map_err(error_to_string)?; + state + .store + .migrate_conversations( + &src_root.display().to_string(), + &dest_root.display().to_string(), + ) + .map_err(error_to_string)?; + + std::thread::spawn(|| { + crate::backup_onedrive_db_on_exit(); + }); + + Ok(()) +} + + diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index ff77f155..9844d6bf 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -675,19 +675,44 @@ fn resolve_executable(program: &str) -> Option { return Some(candidate); } } + #[cfg(windows)] + { + if direct.components().count() > 1 { + let direct_exe = direct.with_extension("exe"); + if executable_works(&direct_exe) { + return Some(direct_exe); + } + } + } if let Some(path_var) = std::env::var_os("PATH") { for dir in std::env::split_paths(&path_var) { - if let Some(candidate) = find_working_executable(&dir.join(program)) { + let base = dir.join(program); + if let Some(candidate) = find_working_executable(&base) { return Some(candidate); } + #[cfg(windows)] + { + let base_exe = base.with_extension("exe"); + if let Some(candidate) = find_working_executable(&base_exe) { + return Some(candidate); + } + } } } for dir in fallback_executable_dirs(program) { - if let Some(candidate) = find_working_executable(&dir.join(program)) { + let base = dir.join(program); + if let Some(candidate) = find_working_executable(&base) { return Some(candidate); } + #[cfg(windows)] + { + let base_exe = base.with_extension("exe"); + if let Some(candidate) = find_working_executable(&base_exe) { + return Some(candidate); + } + } } None @@ -810,12 +835,17 @@ fn executable_works(path: &Path) -> bool { if !path.exists() { return false; } - Command::new(path) - .arg("--version") + let mut cmd = Command::new(path); + cmd.arg("--version") .stdin(Stdio::null()) .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() + .stderr(Stdio::null()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x08000000); + } + cmd.status() .map(|status| status.success()) .unwrap_or(false) } @@ -1384,6 +1414,11 @@ fn run_output_with_program( command.arg(OsStr::new(arg)); } command.stdin(Stdio::null()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(0x08000000); + } let output = command .output() .with_context(|| format!("unable to launch {program_label}"))?; @@ -1439,6 +1474,18 @@ fn same_path(left: &Path, right: &Path) -> bool { canonical_or_original(left) == canonical_or_original(right) } +pub(crate) fn get_git_remote_url(workspace_path: &Path) -> Option { + if let Ok(output) = git_checked(workspace_path, &["config", "--get", "remote.origin.url"]) { + if output.success { + let url = output.stdout.trim().to_string(); + if !url.is_empty() { + return Some(url); + } + } + } + None +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f50de696..ae851fef 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,4 @@ -use std::{ +use std::{ collections::{HashMap, HashSet}, fs, io::{Read, Write}, @@ -35,7 +35,7 @@ use sinew_anthropic::{ }; use sinew_app::{ checkpoint_from_snapshots, clean_context_descriptor, compact_conversation_history, - copy_workspace_entries, create_installed_skill, create_workspace_directory, + copy_workspace_entries, codebase_index_status, create_installed_skill, create_workspace_directory, create_workspace_file, delete_workspace_entry, import_workspace_paths, list_installed_skills, list_workspace_entries, list_workspace_files, normalize_workspace_root, probe_mcp_servers, read_external_file, read_workspace_file, rename_workspace_entry, resolve_terminal_path, @@ -43,14 +43,16 @@ use sinew_app::{ shell_system_prompt, snapshot_workspace_for_checkpoint, subagent_system_prompt, system_prompt_for_mode_with_plan_prompt, system_prompt_with_todo, todo_list_from_history, tool_settings_view, trash_workspace_entry, write_workspace_file, AgentEvent, AgentMode, - AppStore, BashTool, ConversationEvent, ConversationSummary, CreateImageTool, EditFileTool, - GlobTool, GoalWorkflowState, GrepTool, ImportedEntry, InstalledSkill, McpSettings, - McpToolRegistry, ModeModelSettings, OpenRouterModelRecord, PlanArtifactState, - PlanWorkflowState, QuestionTool, ReadTool, SavedConversation, SkillSettings, SkillTool, + AppStore, BashTool, CheckSotaTool, CodebaseSearchTool, ComputerUseTool, ConversationEvent, ConversationSummary, CreateImageTool, + DeleteFileTool, EditFileTool, GlobTool, GoalWorkflowState, GrepTool, + ImportedEntry, InstalledSkill, ListDirTool, new_editor_diagnostics_store, + McpSettings, McpToolRegistry, ModeModelSettings, OpenRouterModelRecord, PlanArtifactState, + PlanWorkflowState, QuestionTool, ReadLintsTool, ReadTool, SavedConversation, SkillSettings, SkillTool, SubAgentConfig, SubAgentSettings, SubAgentTool, TeamRuntime, TeamTool, TerminalPathResolution, ToDoListTool, TodoListState, ToolSettings, ToolSettingsView, TurnCancel, TurnContext, WebFetchTool, WebSearchTool, WorkspaceBootstrap, WorkspaceCopyOperation, WorkspaceDeletedEntry, WorkspaceFileChangeEvent, WorkspaceSearchResult, WriteFileTool, + EditorDiagnostic, SharedEditorDiagnosticsStore, }; use sinew_core::{ ChatMessage, Effort, ModelCapabilities, ModelRef, Part, Provider, ProviderRequest, Role, @@ -62,8 +64,8 @@ use sinew_google::{ generate_state as generate_google_state, load_default_auth_status as load_default_google_auth_status, oauth_authorize_url as google_oauth_authorize_url, - purge_legacy_oauth_if_needed as purge_legacy_google_oauth, GoogleAuthStatus, GoogleProvider, - PkceCodes as GooglePkceCodes, MODEL_ID as GOOGLE_MODEL_ID, + purge_legacy_oauth_if_needed as purge_legacy_google_oauth, Credential as GoogleCredential, + GoogleAuthStatus, GoogleProvider, PkceCodes as GooglePkceCodes, MODEL_ID as GOOGLE_MODEL_ID, }; use sinew_kimi::{ delete_default_auth as delete_default_kimi_auth, generate_state as generate_kimi_state, @@ -74,8 +76,9 @@ use sinew_kimi::{ MODEL_ID as KIMI_MODEL_ID, }; use sinew_openai::{ - delete_default_auth, exchange_oauth_code, generate_pkce, generate_state, - load_default_auth_status, oauth_authorize_url, OpenAiAuthStatus, OpenAiProvider, PkceCodes, + all_auth_files, default_auth_path, delete_default_auth, exchange_oauth_code, generate_pkce, + generate_state, load_auth_status, load_default_auth_status, oauth_authorize_url, + Credential as OpenAiCredential, OpenAiAuthStatus, OpenAiProvider, PkceCodes, MODEL_ID as OPENAI_MODEL_ID, }; use sinew_openrouter::{ @@ -84,16 +87,39 @@ use sinew_openrouter::{ load_default_api_key as load_default_openrouter_api_key, load_default_auth_status as load_default_openrouter_auth_status, save_default_api_key as save_default_openrouter_api_key, - touch_default_auth_validation as touch_default_openrouter_auth_validation, validate_api_key as validate_openrouter_api_key_remote, OpenRouterAuthStatus, OpenRouterCatalogModel, OpenRouterProvider, PROVIDER_ID as OPENROUTER_PROVIDER_ID, }; +use sinew_ollama::{ + delete_default_auth as delete_default_ollama_auth, + fetch_model_catalog as fetch_ollama_model_catalog, + load_default_auth_status as load_default_ollama_auth_status, + load_default_base_url as load_default_ollama_base_url, + save_default_base_url as save_default_ollama_base_url, + validate_endpoint as validate_ollama_endpoint, default_base_url as default_ollama_base_url, + OllamaAuthStatus, OllamaProvider, PROVIDER_ID as OLLAMA_PROVIDER_ID, +}; +use sinew_deepseek::{ + delete_default_auth as delete_default_deepseek_auth, + load_default_api_key as load_default_deepseek_api_key, + load_default_auth_status as load_default_deepseek_auth_status, + save_default_api_key as save_default_deepseek_api_key, + validate_api_key as validate_deepseek_api_key_remote, DeepSeekAuthStatus, + DeepSeekProvider, PROVIDER_ID as DEEPSEEK_PROVIDER_ID, +}; +use sinew_cursor::{ + create_login_challenge, delete_composer_auth, ensure_agent_bridge_ready, + ensure_fresh_composer_token, load_composer_auth_status, load_composer_session, + set_bridge_directory, wait_for_oauth_login, CursorComposerAuthStatus, CursorLoginChallenge, + CursorIdeIdentity, CursorProvider, PROVIDER_ID as CURSOR_PROVIDER_ID, +}; use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder}; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, sync::{mpsc, Mutex, Notify, RwLock}, }; +mod boost; mod context; mod conversations; mod git; @@ -105,7 +131,10 @@ mod swarm; mod terminal; #[cfg(test)] mod tests; +mod tray; mod turns; +pub mod cli; +mod rules; mod updater; mod workflow; mod workspace; @@ -119,30 +148,825 @@ use swarm::*; use turns::*; use workflow::*; +struct LogWriter { + file: Arc>>, +} + +impl Write for LogWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let _ = std::io::stderr().write_all(buf); + if let Ok(mut guard) = self.file.lock() { + if let Some(ref mut f) = *guard { + let _ = f.write_all(buf); + } + } + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + let _ = std::io::stderr().flush(); + if let Ok(mut guard) = self.file.lock() { + if let Some(ref mut f) = *guard { + let _ = f.flush(); + } + } + Ok(()) + } +} + +struct MakeLogWriter { + file: Arc>>, +} + +impl<'a> tracing_subscriber::fmt::writer::MakeWriter<'a> for MakeLogWriter { + type Writer = LogWriter; + + fn make_writer(&'a self) -> Self::Writer { + LogWriter { + file: self.file.clone(), + } + } +} + +pub(crate) fn merge_databases( + local_path: &std::path::Path, + onedrive_path: &std::path::Path, +) -> Result<(), rusqlite::Error> { + #[cfg(target_os = "windows")] + { + let conn = rusqlite::Connection::open(local_path)?; + let onedrive_str = onedrive_path.to_string_lossy(); + + // Attach OneDrive database + conn.execute( + &format!("ATTACH DATABASE '{}' AS onedrive", onedrive_str), + [], + )?; + + // Ensure tombstones table exists in both main and onedrive to prevent errors + let _ = conn.execute( + "create table if not exists main.tombstones ( + id text primary key, + deleted_at_ms integer not null + )", + [], + ); + let _ = conn.execute( + "create table if not exists onedrive.tombstones ( + id text primary key, + deleted_at_ms integer not null + )", + [], + ); + + // Enable foreign keys + let _ = conn.execute("PRAGMA foreign_keys = ON", []); + + // 1. Merge tombstones + let _ = conn.execute( + "INSERT OR REPLACE INTO main.tombstones SELECT * FROM onedrive.tombstones", + [], + ); + + // 2. Delete conversations/messages that are in tombstones + let _ = conn.execute( + "DELETE FROM main.conversations WHERE id IN (SELECT id FROM main.tombstones)", + [], + ); + let _ = conn.execute( + "DELETE FROM main.messages WHERE conversation_id IN (SELECT id FROM main.tombstones)", + [], + ); + let _ = conn.execute( + "DELETE FROM main.turn_checkpoints WHERE conversation_id IN (SELECT id FROM main.tombstones)", + [], + ); + + // 3. Merge conversations (excluding those with tombstones) + let _ = conn.execute( + "INSERT OR IGNORE INTO main.conversations \ + SELECT * FROM onedrive.conversations \ + WHERE id NOT IN (SELECT id FROM main.tombstones)", + [], + ); + let _ = conn.execute( + "INSERT OR REPLACE INTO main.conversations \ + SELECT * FROM onedrive.conversations AS o \ + WHERE EXISTS ( \ + SELECT 1 FROM main.conversations AS m \ + WHERE m.id = o.id AND o.updated_at_ms > m.updated_at_ms \ + ) AND o.id NOT IN (SELECT id FROM main.tombstones)", + [], + ); + + // 4. Merge messages (excluding those with tombstones) + let _ = conn.execute( + "INSERT OR IGNORE INTO main.messages \ + SELECT * FROM onedrive.messages \ + WHERE conversation_id NOT IN (SELECT id FROM main.tombstones)", + [], + ); + let _ = conn.execute( + "INSERT OR REPLACE INTO main.messages \ + SELECT * FROM onedrive.messages AS o \ + WHERE EXISTS ( \ + SELECT 1 FROM main.conversations AS mc \ + JOIN onedrive.conversations AS oc ON mc.id = oc.id \ + WHERE mc.id = o.conversation_id AND oc.updated_at_ms > mc.updated_at_ms \ + ) AND o.conversation_id NOT IN (SELECT id FROM main.tombstones)", + [], + ); + + // 5. Merge app_settings + let _ = conn.execute( + "INSERT OR IGNORE INTO main.app_settings SELECT * FROM onedrive.app_settings", + [], + ); + let _ = conn.execute( + "INSERT OR REPLACE INTO main.app_settings \ + SELECT * FROM onedrive.app_settings AS o \ + WHERE EXISTS ( \ + SELECT 1 FROM main.app_settings AS m \ + WHERE m.key = o.key AND o.updated_at_ms > m.updated_at_ms \ + )", + [], + ); + + // Detach OneDrive database + let _ = conn.execute("DETACH DATABASE onedrive", []); + } + let _ = (local_path, onedrive_path); + Ok(()) +} + +pub(crate) fn is_sync_enabled() -> bool { + #[cfg(target_os = "windows")] + { + if let Ok(localappdata) = std::env::var("LOCALAPPDATA") { + let file = std::path::PathBuf::from(localappdata) + .join("hyrak") + .join("sinew") + .join("data") + .join("multi_pc_enabled.txt"); + return file.exists(); + } + } + false +} + +#[cfg(target_os = "windows")] +fn new_hidden_cmd() -> std::process::Command { + use std::os::windows::process::CommandExt; + let mut cmd = std::process::Command::new("cmd"); + cmd.creation_flags(0x08000000); + cmd +} + +#[cfg(target_os = "windows")] +fn clean_unc_path(path: &std::path::Path) -> std::path::PathBuf { + let path_str = path.to_string_lossy(); + if path_str.starts_with(r"\\?\") { + std::path::PathBuf::from(&path_str[4..]) + } else { + path.to_path_buf() + } +} + +pub(crate) fn has_uncommitted_changes(path: &std::path::Path) -> bool { + #[cfg(target_os = "windows")] + { + let clean = clean_unc_path(path); + if let Ok(output) = new_hidden_cmd() + .args(&["/C", "git status --porcelain"]) + .current_dir(&clean) + .output() + { + return !output.stdout.is_empty(); + } + } + false +} + +pub(crate) fn save_last_workspace_path(path: &str) { + #[cfg(target_os = "windows")] + { + if let Ok(localappdata) = std::env::var("LOCALAPPDATA") { + let dir = std::path::PathBuf::from(localappdata) + .join("hyrak") + .join("sinew") + .join("data"); + let _ = std::fs::create_dir_all(&dir); + let file = dir.join("last_workspace.txt"); + let _ = std::fs::write(&file, path.as_bytes()); + } + } + let _ = path; +} + +pub(crate) fn load_last_workspace_path() -> Option { + #[cfg(target_os = "windows")] + { + if let Ok(localappdata) = std::env::var("LOCALAPPDATA") { + let file = std::path::PathBuf::from(localappdata) + .join("hyrak") + .join("sinew") + .join("data") + .join("last_workspace.txt"); + if file.exists() { + if let Ok(content) = std::fs::read_to_string(&file) { + return Some(content.trim().to_string()); + } + } + } + } + None +} + +pub(crate) fn run_git_auto_pull(workspace_path: &str) { + #[cfg(target_os = "windows")] + { + let path = std::path::Path::new(workspace_path); + if !path.exists() { + return; + } + let clean = clean_unc_path(path); + + tracing::info!("Starting robust Git Auto-Pull for workspace: {:?}", clean); + + // 1. If there are uncommitted changes, commit them first to avoid pull blockages + if has_uncommitted_changes(&clean) { + tracing::info!("Uncommitted changes detected. Auto-committing before pulling."); + let _ = new_hidden_cmd() + .args(&["/C", "git add ."]) + .current_dir(&clean) + .status(); + + let computer_name = std::env::var("COMPUTERNAME").unwrap_or_else(|_| "Sinew Client".to_string()); + let commit_msg = format!("chore: auto-commit local changes before sync on {}", computer_name); + let _ = new_hidden_cmd() + .args(&["/C", "git", "commit", "-m", &commit_msg]) + .current_dir(&clean) + .status(); + } + + // 2. Perform pull with rebase + let status = new_hidden_cmd() + .args(&["/C", "git pull --rebase"]) + .current_dir(&clean) + .status(); + + match status { + Ok(s) if s.success() => { + tracing::info!("Git Auto-Pull and rebase succeeded!"); + } + _ => { + tracing::warn!("Git pull --rebase failed or had conflicts. Aborting rebase to keep local repository safe."); + // Abort the rebase if it got stuck in a conflict state + let _ = new_hidden_cmd() + .args(&["/C", "git rebase --abort"]) + .current_dir(&clean) + .status(); + } + } + } + let _ = workspace_path; +} + +pub(crate) fn run_git_auto_commit_and_push(workspace_path: &str) { + #[cfg(target_os = "windows")] + { + let path = std::path::Path::new(workspace_path); + if !path.exists() { + return; + } + let clean = clean_unc_path(path); + + tracing::info!("Starting robust Git Auto-Commit and Push for workspace: {:?}", clean); + + // 1. If there are changes, commit them + if has_uncommitted_changes(&clean) { + let _ = new_hidden_cmd() + .args(&["/C", "git add ."]) + .current_dir(&clean) + .status(); + + let computer_name = std::env::var("COMPUTERNAME").unwrap_or_else(|_| "Sinew Client".to_string()); + let commit_msg = format!("chore: auto-sync from {} on exit", computer_name); + let _ = new_hidden_cmd() + .args(&["/C", "git", "commit", "-m", &commit_msg]) + .current_dir(&clean) + .status(); + } + + // 2. Pull first with rebase to ensure we are up to date before pushing + let pull_status = new_hidden_cmd() + .args(&["/C", "git pull --rebase"]) + .current_dir(&clean) + .status(); + + match pull_status { + Ok(s) if s.success() => { + // 3. Rebase succeeded, we can push! + let push_status = new_hidden_cmd() + .args(&["/C", "git push"]) + .current_dir(&clean) + .status(); + + match push_status { + Ok(ps) if ps.success() => { + tracing::info!("Git Auto-Push on exit succeeded!"); + } + other => { + tracing::error!("Git Auto-Push on exit failed: {:?}", other); + } + } + } + _ => { + tracing::warn!("Git pull --rebase on exit failed. Aborting rebase to keep local work safe."); + let _ = new_hidden_cmd() + .args(&["/C", "git rebase --abort"]) + .current_dir(&clean) + .status(); + } + } + } + let _ = workspace_path; +} + +pub(crate) fn sync_auth_files(localappdata: &str, onedrive_db_dir: &std::path::Path, to_onedrive: bool) { + use std::fs; + use std::path::PathBuf; + + let local_dir = PathBuf::from(localappdata) + .join("hyrak") + .join("sinew") + .join("data"); + + if to_onedrive { + // Copy from Local to OneDrive + if let Ok(entries) = fs::read_dir(&local_dir) { + let _ = fs::create_dir_all(onedrive_db_dir); + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() { + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + if name.contains("-auth") || name.contains("-device") || name.contains("-stream-state") { + let dest = onedrive_db_dir.join(name); + let _ = fs::copy(&path, &dest); + } + } + } + } + } + } else { + // Copy from OneDrive to Local + if let Ok(entries) = fs::read_dir(onedrive_db_dir) { + let _ = fs::create_dir_all(&local_dir); + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() { + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + if name.contains("-auth") || name.contains("-device") || name.contains("-stream-state") { + let dest = local_dir.join(name); + let _ = fs::copy(&path, &dest); + } + } + } + } + } + } +} + +#[tauri::command] +async fn trigger_ai_rule_consolidation( + state: State<'_, DesktopState>, + provider_id: String, +) -> std::result::Result { + let provider = provider_from_registry(&state, &provider_id)?; + let model_name = match provider_id.as_str() { + "deepseek" => "deepseek-chat", + _ => return Err(format!("Le fournisseur '{}' n'est pas supporté pour la consolidation IA. Utilisez 'deepseek' actuellement.", provider_id)), + }; + rules::ai_consolidate_rules(provider, model_name).await +} + +fn consolidate_global_learning_once() { + #[cfg(target_os = "windows")] + { + rules::consolidate_rules(); + } +} + +fn sync_onedrive_db_on_startup() { + #[cfg(target_os = "windows")] + { + use std::fs; + use std::path::PathBuf; + + // 1. Local AppData path + let localappdata = match std::env::var("LOCALAPPDATA") { + Ok(val) => val, + Err(_) => return, + }; + + // Check if Multi-PC sync is enabled + let sync_enabled_file = PathBuf::from(&localappdata) + .join("hyrak") + .join("sinew") + .join("data") + .join("multi_pc_enabled.txt"); + if !sync_enabled_file.exists() { + return; + } + + // 2. OneDrive path + let onedrive = std::env::var("ONEDRIVE").unwrap_or_else(|_| { + std::env::var("USERPROFILE") + .map(|u| format!("{}\\OneDrive", u)) + .unwrap_or_default() + }); + if onedrive.is_empty() { + return; + } + + // Sync global learning files from OneDrive if present + let global_errors_local = PathBuf::from(&localappdata) + .join("Sinew") + .join("errors_raw.json"); + let global_rules_local = PathBuf::from(&localappdata) + .join("Sinew") + .join("instructions_consolidated.md"); + + let onedrive_dir = PathBuf::from(&onedrive).join("Documents").join("Sinew"); + // Sync auth files from OneDrive + sync_auth_files(&localappdata, &onedrive_dir, false); + + let global_errors_onedrive = onedrive_dir.join("errors_raw.json"); + let global_rules_onedrive = onedrive_dir.join("instructions_consolidated.md"); + + if global_errors_onedrive.exists() { + if let Some(parent) = global_errors_local.parent() { + let _ = fs::create_dir_all(parent); + } + let _ = fs::copy(&global_errors_onedrive, &global_errors_local); + } + if global_rules_onedrive.exists() { + if let Some(parent) = global_rules_local.parent() { + let _ = fs::create_dir_all(parent); + } + let _ = fs::copy(&global_rules_onedrive, &global_rules_local); + } + + let local_db = PathBuf::from(&localappdata) + .join("hyrak") + .join("sinew") + .join("data") + .join("desktop-state.sqlite3"); + + let onedrive_db = PathBuf::from(&onedrive) + .join("Documents") + .join("Sinew") + .join("desktop-state.sqlite3"); + + if onedrive_db.exists() { + if let Some(parent) = local_db.parent() { + let _ = fs::create_dir_all(parent); + } + + if local_db.exists() { + let backup_path = local_db.with_extension("sqlite3.bak"); + let _ = fs::copy(&local_db, &backup_path); + + // Perform differential merge + if let Err(e) = merge_databases(&local_db, &onedrive_db) { + tracing::error!("Differential merge on startup failed: {:?}", e); + // Fallback to direct overwrite if local database is empty or corrupt + if let Ok(meta) = fs::metadata(&local_db) { + if meta.len() == 0 { + let _ = fs::copy(&onedrive_db, &local_db); + } + } + } + } else { + let _ = fs::copy(&onedrive_db, &local_db); + } + } + + // Git auto pull on startup + if let Some(workspace_path) = load_last_workspace_path() { + run_git_auto_pull(&workspace_path); + } + } +} + +pub(crate) fn backup_onedrive_db_on_exit() { + #[cfg(target_os = "windows")] + { + use std::fs; + use std::path::PathBuf; + + // 1. Local AppData path + let localappdata = match std::env::var("LOCALAPPDATA") { + Ok(val) => val, + Err(_) => return, + }; + + // Check if Multi-PC sync is enabled + let sync_enabled_file = PathBuf::from(&localappdata) + .join("hyrak") + .join("sinew") + .join("data") + .join("multi_pc_enabled.txt"); + if !sync_enabled_file.exists() { + return; + } + + let local_db = PathBuf::from(&localappdata) + .join("hyrak") + .join("sinew") + .join("data") + .join("desktop-state.sqlite3"); + + // 2. OneDrive path + let onedrive = std::env::var("ONEDRIVE").unwrap_or_else(|_| { + std::env::var("USERPROFILE") + .map(|u| format!("{}\\OneDrive", u)) + .unwrap_or_default() + }); + if onedrive.is_empty() { + return; + } + let onedrive_db_dir = PathBuf::from(onedrive).join("Documents").join("Sinew"); + let onedrive_db = onedrive_db_dir.join("desktop-state.sqlite3"); + + if local_db.exists() { + let _ = fs::create_dir_all(&onedrive_db_dir); + if onedrive_db.exists() { + // Perform differential merge into OneDrive so no data is lost + if let Err(e) = merge_databases(&onedrive_db, &local_db) { + tracing::error!("Differential merge on exit failed: {:?}", e); + let _ = fs::copy(&local_db, &onedrive_db); + } + } else { + let _ = fs::copy(&local_db, &onedrive_db); + } + + // Sync auth files to OneDrive + sync_auth_files(&localappdata, &onedrive_db_dir, true); + + // Sync global learning files to OneDrive on exit + let global_errors_local = PathBuf::from(&localappdata) + .join("Sinew") + .join("errors_raw.json"); + let global_rules_local = PathBuf::from(&localappdata) + .join("Sinew") + .join("instructions_consolidated.md"); + + let global_errors_onedrive = onedrive_db_dir.join("errors_raw.json"); + let global_rules_onedrive = onedrive_db_dir.join("instructions_consolidated.md"); + + if global_errors_local.exists() { + let _ = fs::copy(&global_errors_local, &global_errors_onedrive); + } + if global_rules_local.exists() { + let _ = fs::copy(&global_rules_local, &global_rules_onedrive); + } + } + + // Git auto commit and push on exit + if let Some(workspace_path) = load_last_workspace_path() { + run_git_auto_commit_and_push(&workspace_path); + } + } +} + +#[tauri::command] +fn is_multi_pc_sync_enabled() -> bool { + is_sync_enabled() +} + +#[tauri::command] +fn set_multi_pc_sync_enabled(enabled: bool) -> Result<(), String> { + #[cfg(target_os = "windows")] + { + if let Ok(localappdata) = std::env::var("LOCALAPPDATA") { + let dir = std::path::PathBuf::from(localappdata) + .join("hyrak") + .join("sinew") + .join("data"); + let _ = std::fs::create_dir_all(&dir); + let file = dir.join("multi_pc_enabled.txt"); + if enabled { + if let Err(e) = std::fs::write(&file, b"1") { + return Err(e.to_string()); + } + } else { + let _ = std::fs::remove_file(&file); + } + return Ok(()); + } + } + Err("Not supported on this platform".to_string()) +} + +#[tauri::command] +fn force_multi_pc_sync() -> Result<(), String> { + #[cfg(target_os = "windows")] + { + use std::fs; + use std::path::PathBuf; + + // 1. Local AppData path + let localappdata = match std::env::var("LOCALAPPDATA") { + Ok(val) => val, + Err(_) => return Err("LOCALAPPDATA env var not found".to_string()), + }; + + // 2. OneDrive path + let onedrive = std::env::var("ONEDRIVE").unwrap_or_else(|_| { + std::env::var("USERPROFILE") + .map(|u| format!("{}\\OneDrive", u)) + .unwrap_or_default() + }); + if onedrive.is_empty() { + return Err("OneDrive directory not found".to_string()); + } + + let local_db = PathBuf::from(&localappdata) + .join("hyrak") + .join("sinew") + .join("data") + .join("desktop-state.sqlite3"); + + let onedrive_db_dir = PathBuf::from(&onedrive).join("Documents").join("Sinew"); + let onedrive_db = onedrive_db_dir.join("desktop-state.sqlite3"); + + // Sync from OneDrive to Local + if onedrive_db.exists() { + if let Some(parent) = local_db.parent() { + let _ = fs::create_dir_all(parent); + } + + if local_db.exists() { + let backup_path = local_db.with_extension("sqlite3.bak"); + let _ = fs::copy(&local_db, &backup_path); + + // Perform differential merge + if let Err(e) = merge_databases(&local_db, &onedrive_db) { + tracing::error!("Force sync: Differential merge from OneDrive failed: {:?}", e); + } + } else { + let _ = fs::copy(&onedrive_db, &local_db); + } + } + + // Sync auth files from OneDrive + sync_auth_files(&localappdata, &onedrive_db_dir, false); + + // Sync from Local to OneDrive + if local_db.exists() { + let _ = fs::create_dir_all(&onedrive_db_dir); + if onedrive_db.exists() { + // Perform differential merge into OneDrive so no data is lost + if let Err(e) = merge_databases(&onedrive_db, &local_db) { + tracing::error!("Force sync: Differential merge into OneDrive failed: {:?}", e); + let _ = fs::copy(&local_db, &onedrive_db); + } + } else { + let _ = fs::copy(&local_db, &onedrive_db); + } + + // Sync global learning files to OneDrive + let global_errors_local = PathBuf::from(&localappdata) + .join("Sinew") + .join("errors_raw.json"); + let global_rules_local = PathBuf::from(&localappdata) + .join("Sinew") + .join("instructions_consolidated.md"); + + let global_errors_onedrive = onedrive_db_dir.join("errors_raw.json"); + let global_rules_onedrive = onedrive_db_dir.join("instructions_consolidated.md"); + + if global_errors_local.exists() { + let _ = fs::copy(&global_errors_local, &global_errors_onedrive); + } + if global_rules_local.exists() { + let _ = fs::copy(&global_rules_local, &global_rules_onedrive); + } + } + + // Sync auth files to OneDrive + sync_auth_files(&localappdata, &onedrive_db_dir, true); + + // Git auto commit/push & pull + if let Some(workspace_path) = load_last_workspace_path() { + run_git_auto_pull(&workspace_path); + run_git_auto_commit_and_push(&workspace_path); + } + + return Ok(()); + } + #[cfg(not(target_os = "windows"))] + Err("Not supported on this platform".to_string()) +} + +fn configure_agent_bridge_paths(app: &AppHandle) { + if let Ok(resource_dir) = app.path().resource_dir() { + let bundled = resource_dir.join("agent-bridge"); + if bundled.join("run-stream.mjs").is_file() { + set_bridge_directory(bundled.clone()); + tracing::info!(path = %bundled.display(), "agent-bridge: bundle embarqué"); + } + } + let _ = sinew_cursor::agent::bridge_directory(); + + if sinew_cursor::agent::transport::prefer_node_bridge() { + tauri::async_runtime::spawn(async move { + if sinew_cursor::agent::transport::should_prepare_node_bridge_at_startup() { + match ensure_agent_bridge_ready().await { + Ok(dir) => { + tracing::info!(path = %dir.display(), "agent-bridge: deps installées (SINEW_CURSOR_BRIDGE=node)") + } + Err(err) => tracing::warn!(error = %err, "agent-bridge: install Node échouée"), + } + } + }); + } else { + tracing::debug!("Composer: bridge Rust natif (OAuth, sans Node)"); + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + let file = directories::ProjectDirs::from("dev", "hyrak", "sinew").and_then(|dirs| { + let log_dir = dirs.data_local_dir().join("logs"); + let _ = fs::create_dir_all(&log_dir); + let log_path = log_dir.join("sinew.log"); + const MAX_LOG_BYTES: u64 = 64 * 1024 * 1024; + if let Ok(meta) = fs::metadata(&log_path) { + if meta.len() > MAX_LOG_BYTES { + let backup = log_dir.join("sinew.log.old"); + let _ = fs::remove_file(&backup); + let _ = fs::rename(&log_path, &backup); + } + } + fs::OpenOptions::new() + .create(true) + .write(true) + .append(true) + .open(log_path) + .ok() + }); + + let make_writer = MakeLogWriter { + file: Arc::new(StdMutex::new(file)), + }; + + let default_filter = tracing_subscriber::EnvFilter::new( + "trace,sinew_app=trace,sinew_cursor=trace,sinew_openai=trace,sinew_anthropic=trace,sinew_google=trace,sinew_kimi=trace,sinew_deepseek=trace,sinew_openrouter=trace,sinew_ollama=trace,sinew_index=trace,sinew_core=trace,ort=warn,hyper=warn,h2=debug,h3=warn,tower=warn,tonic=warn,mio=warn,tokio=warn,rustls=warn,trust_dns=warn,reqwest=debug,sqlx=warn,rusqlite=warn", + ); let _ = tracing_subscriber::fmt() + .with_writer(make_writer) .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")), + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or(default_filter), ) .try_init(); + sync_onedrive_db_on_startup(); + consolidate_global_learning_once(); + let store = AppStore::open_default().expect("unable to open app store"); let openrouter_models = store.load_openrouter_models().unwrap_or_default(); + let ollama_models = store.load_ollama_models().unwrap_or_default(); let mut providers: HashMap> = HashMap::new(); if let Ok(provider) = AnthropicProvider::from_default_sources() { providers.insert("anthropic".into(), Arc::new(provider) as Arc); } - if let Ok(provider) = OpenAiProvider::from_default_sources() { - providers.insert("openai".into(), Arc::new(provider) as Arc); + if let Ok(files) = all_auth_files() { + for (key, path) in files { + if let Ok(provider) = OpenAiProvider::from_file(&path) { + providers.insert(key, Arc::new(provider) as Arc); + } + } } - if let Ok(provider) = GoogleProvider::from_default_sources() { + if let Ok(files) = sinew_google::auth::all_auth_files() { + for (key, path) in files { + if let Ok(provider) = GoogleProvider::from_file(&path) { + providers.insert(key, Arc::new(provider) as Arc); + } + } + } else if let Ok(provider) = GoogleProvider::from_default_sources() { providers.insert("google".into(), Arc::new(provider) as Arc); } if let Ok(provider) = KimiProvider::from_default_sources() { providers.insert("kimi".into(), Arc::new(provider) as Arc); } + if let Ok(provider) = DeepSeekProvider::from_default_sources() { + providers.insert( + DEEPSEEK_PROVIDER_ID.into(), + Arc::new(provider) as Arc, + ); + } if let Ok(provider) = OpenRouterProvider::from_default_sources(openrouter_capabilities(&openrouter_models)) { @@ -151,6 +975,20 @@ pub fn run() { Arc::new(provider) as Arc, ); } + if let Ok(provider) = + OllamaProvider::from_default_sources(ollama_capabilities(&ollama_models)) + { + providers.insert( + OLLAMA_PROVIDER_ID.into(), + Arc::new(provider) as Arc, + ); + } + if let Ok(provider) = CursorProvider::from_default_sources() { + providers.insert( + CURSOR_PROVIDER_ID.into(), + Arc::new(provider) as Arc, + ); + } let default_model = if providers.contains_key("anthropic") { ModelRef::new("anthropic", ANTHROPIC_MODEL_ID).with_effort(Effort::Max) @@ -163,6 +1001,11 @@ pub fn run() { .first() .map(default_openrouter_model_ref) .unwrap_or_else(|| ModelRef::new("google", GOOGLE_MODEL_ID).with_effort(Effort::Medium)) + } else if providers.contains_key(OLLAMA_PROVIDER_ID) { + ollama_models + .first() + .map(default_ollama_model_ref) + .unwrap_or_else(|| ModelRef::new("google", GOOGLE_MODEL_ID).with_effort(Effort::Medium)) } else { ModelRef::new("google", GOOGLE_MODEL_ID).with_effort(Effort::Medium) }; @@ -174,6 +1017,7 @@ pub fn run() { system_prompt: DEFAULT_SYSTEM_PROMPT.into(), max_tool_rounds: 200, active_turns: Arc::new(Mutex::new(HashMap::new())), + active_turn_inputs: Arc::new(Mutex::new(HashMap::new())), active_turn_details: Arc::new(StdMutex::new(HashMap::new())), team_runtime: Arc::new(RwLock::new(TeamRuntime::default())), file_watchers: Arc::new(Mutex::new(HashMap::new())), @@ -182,6 +1026,8 @@ pub fn run() { anthropic_login: Arc::new(Mutex::new(None)), google_login: Arc::new(Mutex::new(None)), kimi_login: Arc::new(Mutex::new(None)), + cursor_login: Arc::new(Mutex::new(None)), + editor_diagnostics: new_editor_diagnostics_store(), }; tauri::Builder::default() @@ -199,8 +1045,11 @@ pub fn run() { } }) .setup(|app| { - #[cfg(target_os = "windows")] - let _ = app; + configure_agent_bridge_paths(app.handle()); + + if let Err(err) = tray::setup_tray(app) { + tracing::warn!("failed to setup tray: {}", err); + } // One-shot purge of legacy Google OAuth tokens so users coming from // pre-0.1.14 builds reconnect against the fixed Antigravity flow. @@ -253,10 +1102,15 @@ pub fn run() { .manage(updater::UpdaterState::new()) .invoke_handler(tauri::generate_handler![ workspace::open_workspace, + workspace::mount_super_ssh_workspace, + workspace::mount_ssh_workspace, + workspace::list_ssh_hosts, workspace::open_new_window, + workspace::get_or_create_sandbox_workspace, workspace::reset_window_title, workspace::watch_workspace_command, workspace::unwatch_workspace_command, + workspace::codebase_index_stats_command, workspace::list_workspace_entries_command, workspace::list_workspace_files_command, workspace::search_workspace_files_command, @@ -282,7 +1136,11 @@ pub fn run() { workspace::import_workspace_paths_command, workspace::save_clipboard_image_attachment_command, workspace::read_clipboard_file_paths_command, + workspace::push_editor_diagnostics, + workspace::set_semantic_embeddings_enabled, conversations::list_conversations, + conversations::list_other_workspaces_conversations, + conversations::migrate_conversations_to_current, conversations::create_conversation, conversations::load_conversation, conversations::rename_conversation, @@ -291,38 +1149,69 @@ pub fn run() { conversations::set_conversation_model_preference, conversations::list_mcp_settings, conversations::save_mcp_settings, + conversations::register_chrome_bridge, conversations::list_tool_settings, conversations::save_tool_settings, conversations::list_sub_agent_settings, conversations::save_sub_agent_settings, providers::list_configured_model_providers, + providers::archive_provider, + providers::restore_provider, + providers::list_archived_providers, providers::get_openai_provider_status, providers::start_openai_oauth_login, providers::cancel_openai_oauth_login, providers::disconnect_openai_provider, + providers::get_all_openai_accounts, + providers::get_openai_codex_rate_limits, + providers::disconnect_openai_account, + providers::save_openai_access_token, providers::get_anthropic_provider_status, providers::start_anthropic_oauth_login, providers::cancel_anthropic_oauth_login, providers::disconnect_anthropic_provider, providers::get_google_provider_status, + providers::get_antigravity_quota, providers::start_google_oauth_login, providers::cancel_google_oauth_login, providers::disconnect_google_provider, + providers::get_all_google_accounts, + providers::disconnect_google_account, providers::get_kimi_provider_status, providers::start_kimi_oauth_login, providers::cancel_kimi_oauth_login, providers::disconnect_kimi_provider, providers::get_openrouter_provider_status, + providers::get_openrouter_key_details, providers::validate_openrouter_api_key, providers::disconnect_openrouter_provider, + providers::get_ollama_provider_status, + providers::connect_ollama_provider, + providers::refresh_ollama_models, + providers::list_ollama_models, + providers::disconnect_ollama_provider, + providers::get_deepseek_provider_status, + providers::validate_deepseek_api_key, + providers::disconnect_deepseek_provider, + providers::get_deepseek_balance, + providers::list_deepseek_models_remote, providers::list_openrouter_models, providers::search_openrouter_models, providers::add_openrouter_model, providers::remove_openrouter_model, + providers::get_cursor_composer_status, + providers::start_cursor_oauth_login, + providers::cancel_cursor_oauth_login, + providers::sync_cursor_composer_auth, + providers::disconnect_cursor_composer, + providers::get_cursor_usage, + providers::get_anthropic_usage, conversations::probe_mcp_tools, conversations::list_installed_skills_command, conversations::save_skill_settings, + turns::optimize_prompt, turns::send_message, + turns::steer_turn, turns::answer_question, turns::reject_question, turns::compact_conversation, @@ -331,6 +1220,8 @@ pub fn run() { context::estimate_context, context::estimate_sub_agent_context, turns::cancel_turn, + turns::check_sota_diagnostics, + trigger_ai_rule_consolidation, swarm::stop_agent_swarm_command, terminal::run_terminal_command, terminal::spawn_terminal, @@ -352,10 +1243,25 @@ pub fn run() { updater::updater_download_and_install, updater::updater_restart, updater::updater_current_version, + is_multi_pc_sync_enabled, + set_multi_pc_sync_enabled, + force_multi_pc_sync, + log_frontend_error, + get_recent_workspaces_command, + record_recent_workspace_command, + clear_recent_workspaces_command, + boost::boost_local_status, + boost::boost_local_start, + boost::boost_local_stop, + boost::boost_local_distill, ]) .build(tauri::generate_context!()) .expect("error while building sinew desktop") .run(|app, event| { + if let tauri::RunEvent::Exit = event { + backup_onedrive_db_on_exit(); + } + #[cfg(not(target_os = "macos"))] let _ = (&app, &event); @@ -367,3 +1273,59 @@ pub fn run() { } }) } + +/// Capture les erreurs du frontend (React/TypeScript) dans le log centralisé +#[tauri::command] +fn log_frontend_error(message: String, source: String) { + let log_dir = std::path::PathBuf::from( + std::env::var("LOCALAPPDATA").unwrap_or_default() + ).join("dev").join("hyrak").join("sinew").join("data").join("logs"); + let _ = std::fs::create_dir_all(&log_dir); + let log_path = log_dir.join("frontend-error.log"); + let line = format!("[{}] [{}] {}\n", chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f"), source, message); + if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&log_path) { + use std::io::Write; + let _ = f.write_all(line.as_bytes()); + } +} + +#[tauri::command] +fn get_recent_workspaces_command() -> Vec { + tray::load_recents() +} + +#[tauri::command] +fn record_recent_workspace_command(app: tauri::AppHandle, path: String, name: String) -> Vec { + tray::record_recent(&path, &name); + let _ = tray::update_tray_menu(&app); + crate::save_last_workspace_path(&path); + tray::load_recents() +} + +#[tauri::command] +fn clear_recent_workspaces_command(app: tauri::AppHandle, path: String) -> Vec { + let mut recents = tray::load_recents(); + recents.retain(|r| r.path != path); + tray::save_recents(&recents); + let _ = tray::update_tray_menu(&app); + + if let Some(last) = crate::load_last_workspace_path() { + if last == path { + #[cfg(target_os = "windows")] + { + if let Ok(localappdata) = std::env::var("LOCALAPPDATA") { + let file = std::path::PathBuf::from(localappdata) + .join("hyrak") + .join("sinew") + .join("data") + .join("last_workspace.txt"); + let _ = std::fs::remove_file(&file); + } + } + } + } + + recents +} + + diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index a376ad88..d82efa46 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -4,6 +4,37 @@ // ça. En debug on garde la console pour les logs. #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +use std::panic; +use std::io::Write; + fn main() { + // Capture toutes les panics Rust dans le fichier de log centralisé + let log_dir = std::path::PathBuf::from( + std::env::var("LOCALAPPDATA").unwrap_or_default() + ).join("dev").join("hyrak").join("sinew").join("data").join("logs"); + let _ = std::fs::create_dir_all(&log_dir); + let panic_log = log_dir.join("panic.log"); + panic::set_hook(Box::new(move |info| { + let msg = format!( + "[{}] PANIC: {}\n", + chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f"), + info + ); + // Écriture synchrone pour garantir la capture même en cas de crash + if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&panic_log) { + let _ = f.write_all(msg.as_bytes()); + let _ = f.flush(); + } + // Affiche aussi sur stderr si dispo + eprintln!("{}", msg); + std::process::abort(); + })); + + if sinew_desktop_lib::cli::handle_args() { + return; + } + if sinew_index::run_helper_if_requested() { + return; + } sinew_desktop_lib::run() } diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index b4cb0ec9..54ae43cf 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -71,6 +71,13 @@ pub(super) struct KimiProviderStatus { pub(super) error: Option, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct StartCursorLoginOutput { + pub(super) login_id: String, + pub(super) auth_url: String, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub(super) struct StartKimiLoginOutput { @@ -90,6 +97,34 @@ pub(super) struct OpenRouterProviderStatus { pub(super) error: Option, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct DeepSeekProviderStatus { + pub(super) connected: bool, + pub(super) connection_state: String, + pub(super) key_preview: Option, + pub(super) last_validated_ms: Option, + pub(super) error: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct OllamaProviderStatus { + pub(super) connected: bool, + pub(super) connection_state: String, + pub(super) base_url: Option, + pub(super) last_validated_ms: Option, + pub(super) model_count: usize, + pub(super) error: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct ConnectOllamaInput { + #[serde(default)] + pub(super) base_url: Option, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub(super) struct WorkspaceInput { @@ -164,6 +199,34 @@ pub(super) struct ConversationInput { pub(super) conversation_id: String, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +#[allow(dead_code)] +pub(super) struct OptimizePromptInput { + pub(super) raw_prompt: String, + pub(super) model: ModelInput, + pub(super) thinking: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[allow(dead_code)] +pub(super) struct OptimizePromptOutput { + pub(super) mode: String, + pub(super) optimized_prompt: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct SteeringInput { + pub(super) workspace_path: String, + pub(super) conversation_id: String, + pub(super) id: String, + pub(super) text: String, + #[serde(default)] + pub(super) attachments: Vec, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub(super) struct AnswerQuestionInput { @@ -260,6 +323,7 @@ pub(super) struct ClipboardImageAttachment { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] +#[allow(dead_code)] pub(super) struct SendMessageInput { pub(super) workspace_path: String, pub(super) conversation_id: String, @@ -276,6 +340,28 @@ pub(super) struct SendMessageInput { pub(super) rewrite_from_history_index: Option, #[serde(default = "default_true")] pub(super) revert_workspace_changes: bool, + #[serde(default = "default_true")] + pub(super) power_user: bool, + #[serde(default = "default_true")] + pub(super) git_automation: bool, + #[serde(default = "default_true")] + pub(super) concise_answers: bool, + #[serde(default = "default_true")] + pub(super) agent_autonomy: bool, + #[serde(default)] + pub(super) force_changelog: bool, + #[serde(default)] + pub(super) git_french_messages: bool, + #[serde(default)] + pub(super) auto_mockups: bool, + #[serde(default)] + pub(super) strict_problem_solving: bool, + #[serde(default)] + pub(super) full_implementation: bool, + #[serde(default)] + pub(super) client_formatted_date_time: Option, + #[serde(default)] + pub(super) display_mode: DisplayModeInput, } #[derive(Debug, Deserialize)] @@ -292,6 +378,7 @@ pub(super) struct CompactConversationInput { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] +#[allow(dead_code)] pub(super) struct ContextEstimateInput { pub(super) workspace_path: String, pub(super) conversation_id: String, @@ -304,6 +391,28 @@ pub(super) struct ContextEstimateInput { pub(super) mode: Option, #[serde(default)] pub(super) rewrite_from_history_index: Option, + #[serde(default = "default_true")] + pub(super) power_user: bool, + #[serde(default = "default_true")] + pub(super) git_automation: bool, + #[serde(default = "default_true")] + pub(super) concise_answers: bool, + #[serde(default = "default_true")] + pub(super) agent_autonomy: bool, + #[serde(default)] + pub(super) force_changelog: bool, + #[serde(default)] + pub(super) git_french_messages: bool, + #[serde(default)] + pub(super) auto_mockups: bool, + #[serde(default)] + pub(super) strict_problem_solving: bool, + #[serde(default)] + pub(super) full_implementation: bool, + #[serde(default)] + pub(super) client_formatted_date_time: Option, + #[serde(default)] + pub(super) display_mode: DisplayModeInput, } #[derive(Debug, Deserialize)] @@ -585,10 +694,20 @@ pub(super) enum MessageVisibilityInput { SystemReminder, } +#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq, Default)] +#[serde(rename_all = "kebab-case")] +pub(super) enum DisplayModeInput { + #[default] + Disabled, + Compact, + VeryCompact, +} + #[derive(Debug, Deserialize, Clone, Copy)] #[serde(rename_all = "lowercase")] pub(super) enum ThinkingLevelInput { Off, + Minimal, Low, Medium, High, @@ -599,7 +718,7 @@ pub(super) enum ThinkingLevelInput { impl ThinkingLevelInput { pub(super) fn into_effort(self) -> Effort { match self { - Self::Off => Effort::None, + Self::Off | Self::Minimal => Effort::None, Self::Low => Effort::Low, Self::Medium => Effort::Medium, Self::High => Effort::High, diff --git a/src-tauri/src/platform.rs b/src-tauri/src/platform.rs index 906094f1..728f929b 100644 --- a/src-tauri/src/platform.rs +++ b/src-tauri/src/platform.rs @@ -1,12 +1,13 @@ use crate::*; +#[cfg(not(target_os = "windows"))] use tauri::{ menu::{AboutMetadata, Menu, MenuItemBuilder, PredefinedMenuItem, Submenu, SubmenuBuilder}, Runtime, }; pub(super) fn error_to_string(error: impl std::fmt::Display) -> String { - error.to_string() + format!("{error:#}") } #[cfg(not(target_os = "windows"))] @@ -486,10 +487,12 @@ pub(super) fn open_with_default_app(path: &Path) -> Result<()> { #[cfg(target_os = "windows")] { // `start` is a cmd builtin; the second argument is the window title. - let status = Command::new("cmd") - .args(["/C", "start", ""]) - .arg(path) - .status() + let mut cmd = Command::new("cmd"); + cmd.args(["/C", "start", ""]) + .arg(path); + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x08000000); + let status = cmd.status() .context("unable to open file with default application")?; if !status.success() { anyhow::bail!("default application open failed"); @@ -640,6 +643,8 @@ pub(super) fn clipboard_text_command() -> Option { { let mut command = Command::new("powershell"); command.args(["-NoProfile", "-Command", "Get-Clipboard"]); + use std::os::windows::process::CommandExt; + command.creation_flags(0x08000000); return Some(command); } #[cfg(all(unix, not(target_os = "macos")))] diff --git a/src-tauri/src/providers.rs b/src-tauri/src/providers.rs index 042628d4..5a43cc9e 100644 --- a/src-tauri/src/providers.rs +++ b/src-tauri/src/providers.rs @@ -49,18 +49,87 @@ pub(super) fn list_configured_model_providers( .keys() .cloned() .collect::>(); - providers.sort(); + if let Ok(archived) = state.store.list_archived_providers() { + providers.retain(|p| !archived.contains(p)); + } + providers.sort_by(|a, b| compare_provider_keys(a, b)); Ok(providers) } +#[tauri::command] +pub(super) fn archive_provider( + state: State<'_, DesktopState>, + provider_id: String, +) -> std::result::Result<(), String> { + state.store.set_provider_status(&provider_id, "archived").map_err(|e| e.to_string()) +} + +#[tauri::command] +pub(super) fn restore_provider( + state: State<'_, DesktopState>, + provider_id: String, +) -> std::result::Result<(), String> { + state.store.set_provider_status(&provider_id, "active").map_err(|e| e.to_string()) +} + +#[tauri::command] +pub(super) fn list_archived_providers( + state: State<'_, DesktopState>, +) -> std::result::Result, String> { + state.store.list_archived_providers().map_err(|e| e.to_string()) +} + +fn compare_provider_keys(a: &str, b: &str) -> std::cmp::Ordering { + let split_a = a.split_once(':'); + let split_b = b.split_once(':'); + match (split_a, split_b) { + (Some((p_a, s_a)), Some((p_b, s_b))) if p_a == p_b => { + if let (Ok(num_a), Ok(num_b)) = (s_a.parse::(), s_b.parse::()) { + num_a.cmp(&num_b) + } else { + s_a.cmp(s_b) + } + } + _ => a.cmp(b), + } +} + pub(super) fn install_openai_provider( providers: &Arc>>>, ) -> std::result::Result<(), String> { - let provider = OpenAiProvider::from_default_sources().map_err(error_to_string)?; - providers - .lock() - .map_err(|_| "provider registry is unavailable".to_string())? - .insert("openai".into(), Arc::new(provider) as Arc); + if let Ok(default_path) = default_auth_path() { + let dir = default_path.parent().unwrap(); + let old_first_path = dir.join("openai-auth-1.json"); + if old_first_path.exists() && !default_path.exists() { + if let Err(err) = std::fs::rename(&old_first_path, &default_path) { + tracing::warn!( + "failed to auto-rename openai-auth-1.json back to openai-auth.json: {:?}", + err + ); + } else { + tracing::info!( + "successfully restored openai-auth-1.json as openai-auth.json (principal)" + ); + } + } + } + + if let Ok(files) = all_auth_files() { + let mut lock = providers + .lock() + .map_err(|_| "provider registry is unavailable".to_string())?; + for (key, path) in files { + if let Ok(provider) = OpenAiProvider::from_file(&path) { + lock.insert(key, Arc::new(provider) as Arc); + } + } + } else { + let provider = OpenAiProvider::from_default_sources().map_err(error_to_string)?; + providers + .lock() + .map_err(|_| "provider registry is unavailable".to_string())? + .insert("openai".into(), Arc::new(provider) as Arc); + } Ok(()) } @@ -78,11 +147,39 @@ pub(super) fn install_anthropic_provider( pub(super) fn install_google_provider( providers: &Arc>>>, ) -> std::result::Result<(), String> { - let provider = GoogleProvider::from_default_sources().map_err(error_to_string)?; - providers - .lock() - .map_err(|_| "provider registry is unavailable".to_string())? - .insert("google".into(), Arc::new(provider) as Arc); + if let Ok(default_path) = sinew_google::auth::default_auth_path() { + let dir = default_path.parent().unwrap(); + let old_first_path = dir.join("google-auth-1.json"); + if old_first_path.exists() && !default_path.exists() { + if let Err(err) = std::fs::rename(&old_first_path, &default_path) { + tracing::warn!( + "failed to auto-rename google-auth-1.json back to google-auth.json: {:?}", + err + ); + } else { + tracing::info!( + "successfully restored google-auth-1.json as google-auth.json (principal)" + ); + } + } + } + + if let Ok(files) = sinew_google::auth::all_auth_files() { + let mut lock = providers + .lock() + .map_err(|_| "provider registry is unavailable".to_string())?; + for (key, path) in files { + if let Ok(provider) = GoogleProvider::from_file(&path) { + lock.insert(key, Arc::new(provider) as Arc); + } + } + } else { + let provider = GoogleProvider::from_default_sources().map_err(error_to_string)?; + providers + .lock() + .map_err(|_| "provider registry is unavailable".to_string())? + .insert("google".into(), Arc::new(provider) as Arc); + } Ok(()) } @@ -189,6 +286,113 @@ pub(super) fn remove_openrouter_provider( Ok(()) } +pub(super) fn install_ollama_provider( + providers: &Arc>>>, + models: &[OpenRouterModelRecord], +) -> std::result::Result<(), String> { + let provider = OllamaProvider::from_default_sources(ollama_capabilities(models)) + .map_err(error_to_string)?; + providers + .lock() + .map_err(|_| "provider registry is unavailable".to_string())? + .insert( + OLLAMA_PROVIDER_ID.into(), + Arc::new(provider) as Arc, + ); + Ok(()) +} + +pub(super) fn ollama_capabilities(models: &[OpenRouterModelRecord]) -> Vec { + models + .iter() + .map(|model| { + sinew_ollama::capabilities_from_parts( + &model.id, + model.context_window, + model.max_output_tokens, + model.supports_images, + model.supports_thinking, + model.supports_tools, + ) + }) + .collect() +} + +pub(super) fn default_ollama_model_ref(model: &OpenRouterModelRecord) -> ModelRef { + let mut model_ref = ModelRef::new(OLLAMA_PROVIDER_ID, model.id.clone()); + model_ref.effort = Some(if model.supports_thinking { + Effort::Medium + } else { + Effort::None + }); + model_ref +} + +pub(super) fn remove_ollama_provider( + providers: &Arc>>>, +) -> std::result::Result<(), String> { + providers + .lock() + .map_err(|_| "provider registry is unavailable".to_string())? + .remove(OLLAMA_PROVIDER_ID); + Ok(()) +} + +pub(super) fn ollama_provider_status_from_auth( + auth: OllamaAuthStatus, + connection_state: &str, + model_count: usize, + error: Option, +) -> OllamaProviderStatus { + OllamaProviderStatus { + connected: auth.connected && connection_state == "connected", + connection_state: connection_state.to_string(), + base_url: auth.base_url, + last_validated_ms: auth.last_validated_ms, + model_count, + error, + } +} + +pub(super) fn install_deepseek_provider( + providers: &Arc>>>, +) -> std::result::Result<(), String> { + let provider = DeepSeekProvider::from_default_sources().map_err(error_to_string)?; + providers + .lock() + .map_err(|_| "provider registry is unavailable".to_string())? + .insert( + DEEPSEEK_PROVIDER_ID.into(), + Arc::new(provider) as Arc, + ); + Ok(()) +} + +pub(super) fn remove_deepseek_provider( + providers: &Arc>>>, +) -> std::result::Result<(), String> { + providers + .lock() + .map_err(|_| "provider registry is unavailable".to_string())? + .remove(DEEPSEEK_PROVIDER_ID); + Ok(()) +} + +pub(super) fn deepseek_provider_status_from_auth( + auth: DeepSeekAuthStatus, + connection_state: &str, + error: Option, +) -> DeepSeekProviderStatus { + DeepSeekProviderStatus { + connected: auth.connected && connection_state == "connected", + connection_state: connection_state.to_string(), + key_preview: auth.key_preview, + last_validated_ms: auth.last_validated_ms, + error, + } +} + + pub(super) fn openai_provider_status_from_auth( auth: OpenAiAuthStatus, connection_state: &str, @@ -299,6 +503,7 @@ pub(super) async fn run_openai_oauth_server( expected_state: String, pkce: PkceCodes, cancel: Arc, + target_key: Option, ) -> Result<()> { let http = reqwest::Client::builder() .user_agent("sinew/0.1") @@ -318,6 +523,7 @@ pub(super) async fn run_openai_oauth_server( &redirect_uri, &expected_state, &pkce, + target_key.clone(), ).await? { return result; } @@ -332,6 +538,7 @@ pub(super) async fn handle_openai_oauth_request( redirect_uri: &str, expected_state: &str, pkce: &PkceCodes, + target_key: Option, ) -> Result>> { let mut buffer = [0u8; 8192]; let read = stream @@ -385,7 +592,7 @@ pub(super) async fn handle_openai_oauth_request( return Ok(Some(Err(anyhow::anyhow!("Missing authorization code")))); }; - match exchange_oauth_code(http, code, redirect_uri, pkce).await { + match exchange_oauth_code(http, code, redirect_uri, pkce, target_key).await { Ok(_) => { write_html_response(stream, 200, openai_login_success_html()).await?; Ok(Some(Ok(()))) @@ -410,9 +617,19 @@ pub(super) async fn handle_openai_oauth_request( pub(super) async fn bind_anthropic_oauth_listener() -> Result { const CALLBACK_PORT: u16 = 53692; - tokio::net::TcpListener::bind(("127.0.0.1", CALLBACK_PORT)) - .await - .context("unable to bind Anthropic OAuth callback port 53692") + match tokio::net::TcpListener::bind(("127.0.0.1", CALLBACK_PORT)).await { + Ok(listener) => Ok(listener), + Err(err) => { + let mut message = format!("unable to bind Anthropic OAuth callback port {CALLBACK_PORT}"); + #[cfg(target_os = "windows")] + if err.raw_os_error() == Some(10013) { + message.push_str( + "; Windows may have reserved this port. Check excluded TCP port ranges or restart WinNAT/HNS before trying again", + ); + } + Err(err).with_context(|| message) + } + } } pub(super) async fn run_anthropic_oauth_server( @@ -535,9 +752,20 @@ pub(super) async fn handle_anthropic_oauth_request( pub(super) async fn bind_google_oauth_listener() -> Result { // Antigravity expects the redirect URI http://localhost:51121/oauth-callback, // so we must bind that exact port (not a random one). - tokio::net::TcpListener::bind(("127.0.0.1", 51121)) - .await - .context("unable to bind Google OAuth callback port") + const CALLBACK_PORT: u16 = 51121; + match tokio::net::TcpListener::bind(("127.0.0.1", CALLBACK_PORT)).await { + Ok(listener) => Ok(listener), + Err(err) => { + let mut message = format!("unable to bind Google OAuth callback port {CALLBACK_PORT}"); + #[cfg(target_os = "windows")] + if err.raw_os_error() == Some(10013) { + message.push_str( + "; Windows may have reserved this port. Check excluded TCP port ranges or restart WinNAT/HNS before trying again", + ); + } + Err(err).with_context(|| message) + } + } } pub(super) async fn run_google_oauth_server( @@ -546,6 +774,7 @@ pub(super) async fn run_google_oauth_server( expected_state: String, pkce: GooglePkceCodes, cancel: Arc, + target_key: Option, ) -> Result<()> { let http = reqwest::Client::builder() .user_agent("sinew/0.1") @@ -565,6 +794,7 @@ pub(super) async fn run_google_oauth_server( &redirect_uri, &expected_state, &pkce, + target_key.as_deref(), ).await? { return result; } @@ -579,6 +809,7 @@ pub(super) async fn handle_google_oauth_request( redirect_uri: &str, expected_state: &str, pkce: &GooglePkceCodes, + target_key: Option<&str>, ) -> Result>> { let mut buffer = [0u8; 8192]; let read = stream @@ -632,7 +863,7 @@ pub(super) async fn handle_google_oauth_request( return Ok(Some(Err(anyhow::anyhow!("Missing authorization code")))); }; - match exchange_google_oauth_code(http, code, redirect_uri, pkce).await { + match exchange_google_oauth_code(http, code, redirect_uri, pkce, target_key.map(|s| s.to_string())).await { Ok(_) => { write_html_response(stream, 200, google_login_success_html()).await?; Ok(Some(Ok(()))) @@ -834,6 +1065,7 @@ pub(super) async fn get_openai_provider_status( #[tauri::command] pub(super) async fn start_openai_oauth_login( state: State<'_, DesktopState>, + key: Option, ) -> std::result::Result { if let Some(existing) = state.openai_login.lock().await.take() { existing.cancel.notify_one(); @@ -857,13 +1089,22 @@ pub(super) async fn start_openai_oauth_login( id: login_id.clone(), cancel: cancel.clone(), outcome: outcome.clone(), + target_key: key.clone(), }); } let providers = state.providers.clone(); + let target_key = key.clone(); tauri::async_runtime::spawn(async move { - let result = - run_openai_oauth_server(listener, redirect_uri, oauth_state, pkce, cancel).await; + let result = run_openai_oauth_server( + listener, + redirect_uri, + oauth_state, + pkce, + cancel, + target_key, + ) + .await; let login_outcome = match result { Ok(()) => match install_openai_provider(&providers) { Ok(()) => OpenAiLoginOutcome { @@ -929,122 +1170,637 @@ pub(super) async fn disconnect_openai_provider( )) } -#[tauri::command] -pub(super) async fn get_anthropic_provider_status( - state: State<'_, DesktopState>, -) -> std::result::Result { - let mut active_login = state.anthropic_login.lock().await; - let attempt = active_login.clone(); - if let Some(attempt) = attempt { - let outcome = attempt - .outcome - .lock() - .map_err(|_| "login state is unavailable".to_string())? - .clone(); +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct OpenAiAccountInfo { + pub(super) key: String, + pub(super) email: Option, + pub(super) plan_type: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct RateLimitWindowInfo { + pub(super) used_percent: f64, + pub(super) remaining_percent: f64, + pub(super) window_minutes: Option, + pub(super) reset_at: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct OpenAiCodexRateLimitInfo { + pub(super) key: String, + pub(super) email: Option, + pub(super) plan_type: Option, + pub(super) workspace_name: Option, + pub(super) limit_id: Option, + pub(super) primary: Option, + pub(super) secondary: Option, + pub(super) raw: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct AntigravityQuotaGroupInfo { + pub(super) group: String, + pub(super) label: String, + pub(super) remaining_percent: Option, + pub(super) reset_time: Option, + pub(super) count: usize, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct AntigravityQuotaInfo { + pub(super) project_id: Option, + pub(super) groups: Vec, + pub(super) raw: serde_json::Value, +} + +static ANTIGRAVITY_QUOTA_CACHE: std::sync::OnceLock>> = std::sync::OnceLock::new(); +static CURSOR_USAGE_CACHE: std::sync::OnceLock>> = std::sync::OnceLock::new(); +static ANTHROPIC_USAGE_CACHE: std::sync::OnceLock>> = std::sync::OnceLock::new(); +static DEEPSEEK_BALANCE_CACHE: std::sync::OnceLock>> = std::sync::OnceLock::new(); +static OPENROUTER_KEY_CACHE: std::sync::OnceLock>> = std::sync::OnceLock::new(); +static OPENAI_CODEX_CACHE: std::sync::OnceLock>> = std::sync::OnceLock::new(); - if let Some(outcome) = outcome { - *active_login = None; - let auth = load_default_anthropic_auth_status().map_err(error_to_string)?; - if outcome.success { - return Ok(anthropic_provider_status_from_auth( - auth, - "connected", - None, - None, - )); +#[tauri::command] +pub(super) async fn get_openai_codex_rate_limits( + key: Option, +) -> std::result::Result { + let target_key = key.clone().unwrap_or_else(|| "openai".to_string()); + + // Check cache + let cache = OPENAI_CODEX_CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())); + if let Ok(guard) = cache.lock() { + if let Some((cached, fetched_at)) = guard.get(&target_key) { + if fetched_at.elapsed() < std::time::Duration::from_secs(30) { + return Ok(cached.clone()); } - return Ok(anthropic_provider_status_from_auth( - auth, - "error", - None, - outcome.error, - )); } - - let auth = load_default_anthropic_auth_status().map_err(error_to_string)?; - return Ok(anthropic_provider_status_from_auth( - auth, - "connecting", - Some(attempt.id), - None, - )); } - let auth = load_default_anthropic_auth_status().map_err(error_to_string)?; - let state = if auth.connected { - "connected" + let path = if target_key == "openai" { + default_auth_path().map_err(error_to_string)? } else { - "disconnected" + let suffix = target_key.strip_prefix("openai:").unwrap_or(&target_key); + default_auth_path() + .map_err(error_to_string)? + .parent() + .ok_or_else(|| "OpenAI auth directory is unavailable".to_string())? + .join(format!("openai-auth-{suffix}.json")) }; - Ok(anthropic_provider_status_from_auth(auth, state, None, None)) -} -#[tauri::command] -pub(super) async fn start_anthropic_oauth_login( - state: State<'_, DesktopState>, -) -> std::result::Result { - if let Some(existing) = state.anthropic_login.lock().await.take() { - existing.cancel.notify_one(); + let status = load_auth_status(&path).map_err(error_to_string)?; + let credential = OpenAiCredential::from_sinew_auth_file(&path) + .map_err(error_to_string)? + .ok_or_else(|| "OpenAI OAuth credential not found".to_string())?; + let http = reqwest::Client::new(); + let bearer = credential.bearer(&http).await.map_err(error_to_string)?; + if !bearer.is_oauth { + return Err("Codex quotas require OpenAI OAuth".to_string()); } - let listener = bind_anthropic_oauth_listener() + let mut request = http + .get("https://chatgpt.com/backend-api/codex/wham/usage") + .header("authorization", format!("Bearer {}", bearer.token)) + .header("user-agent", "codex-cli") + .header("accept", "application/json"); + if let Some(account_id) = bearer.account_id.as_deref() { + request = request.header("ChatGPT-Account-Id", account_id); + } + + let mut response = request + .send() .await - .map_err(error_to_string)?; - let port = listener.local_addr().map_err(error_to_string)?.port(); - let redirect_uri = format!("http://localhost:{port}/callback"); - let pkce = generate_anthropic_pkce(); - let oauth_state = pkce.code_verifier.clone(); - let auth_url = anthropic_oauth_authorize_url(&redirect_uri, &pkce, &oauth_state); - let login_id = generate_anthropic_state(); - let cancel = Arc::new(Notify::new()); - let outcome = Arc::new(StdMutex::new(None)); + .map_err(|err| format!("Failed to fetch Codex quotas: {err}"))?; + // Fallback suggéré par la communauté si /codex/wham/usage renvoie 403 (Business/Workspace) + if response.status() == reqwest::StatusCode::FORBIDDEN + || response.status() == reqwest::StatusCode::NOT_FOUND { - let mut active_login = state.anthropic_login.lock().await; - *active_login = Some(AnthropicLoginAttempt { - id: login_id.clone(), - cancel: cancel.clone(), - outcome: outcome.clone(), - }); + let mut fallback_req = http + .get("https://chatgpt.com/backend-api/wham/usage") + .header("authorization", format!("Bearer {}", bearer.token)) + .header("user-agent", "codex-cli") + .header("accept", "application/json"); + if let Some(account_id) = bearer.account_id.as_deref() { + fallback_req = fallback_req.header("ChatGPT-Account-Id", account_id); + } + if let Ok(fb_resp) = fallback_req.send().await { + if fb_resp.status().is_success() { + response = fb_resp; + } + } } - let providers = state.providers.clone(); - tauri::async_runtime::spawn(async move { - let result = - run_anthropic_oauth_server(listener, redirect_uri, oauth_state, pkce, cancel).await; - let login_outcome = match result { - Ok(()) => match install_anthropic_provider(&providers) { - Ok(()) => AnthropicLoginOutcome { - success: true, - error: None, - }, - Err(err) => AnthropicLoginOutcome { - success: false, - error: Some(err), - }, - }, - Err(err) => AnthropicLoginOutcome { - success: false, - error: Some(err.to_string()), - }, - }; - if let Ok(mut slot) = outcome.lock() { - *slot = Some(login_outcome); + if !response.status().is_success() { + return Err(format!( + "Codex quota endpoint returned status {}", + response.status() + )); + } + let raw: serde_json::Value = response + .json() + .await + .map_err(|err| format!("Failed to parse Codex quota response: {err}"))?; + + let rate_limit = + raw.get("rate_limit") + .and_then(|value| if value.is_null() { None } else { Some(value) }); + let primary = rate_limit + .and_then(|value| value.get("primary_window")) + .and_then(parse_rate_limit_window); + let secondary = rate_limit + .and_then(|value| value.get("secondary_window")) + .and_then(parse_rate_limit_window); + + // Nom du workspace + let mut workspace_name = None; + let mut accounts_req = http + .get("https://chatgpt.com/backend-api/wham/accounts/check") + .header("authorization", format!("Bearer {}", bearer.token)) + .header("user-agent", "codex-cli") + .header("accept", "application/json"); + if let Some(account_id) = bearer.account_id.as_deref() { + accounts_req = accounts_req.header("ChatGPT-Account-Id", account_id); + } + if let Ok(accounts_resp) = accounts_req.send().await { + if accounts_resp.status().is_success() { + if let Ok(accounts_raw) = accounts_resp.json::().await { + if let Some(accounts_list) = accounts_raw.get("accounts").and_then(|v| v.as_array()) + { + let target_id = bearer.account_id.as_deref().or_else(|| { + accounts_raw + .get("default_account_id") + .and_then(|v| v.as_str()) + }); + if let Some(tid) = target_id { + if let Some(matched) = accounts_list + .iter() + .find(|acc| acc.get("id").and_then(|id| id.as_str()) == Some(tid)) + { + if let Some(name) = matched.get("name").and_then(|n| n.as_str()) { + workspace_name = Some(name.to_string()); + } + } + } + if workspace_name.is_none() { + for acc in accounts_list { + if let Some(name) = acc.get("name").and_then(|n| n.as_str()) { + workspace_name = Some(name.to_string()); + break; + } + } + } + } + } } - }); + } - Ok(StartAnthropicLoginOutput { login_id, auth_url }) + let result = OpenAiCodexRateLimitInfo { + key: target_key.clone(), + email: status.email, + plan_type: raw + .get("plan_type") + .and_then(|value| value.as_str()) + .map(|value| value.to_string()) + .or(status.plan_type), + workspace_name, + limit_id: Some("codex".to_string()), + primary, + secondary, + raw, + }; + + if let Ok(mut guard) = cache.lock() { + guard.insert(target_key, (result.clone(), std::time::Instant::now())); + } + + Ok(result) } -#[tauri::command] -pub(super) async fn cancel_anthropic_oauth_login( - state: State<'_, DesktopState>, -) -> std::result::Result { - if let Some(attempt) = state.anthropic_login.lock().await.take() { - attempt.cancel.notify_one(); +fn parse_rate_limit_window(value: &serde_json::Value) -> Option { + if value.is_null() { + return None; } - let auth = load_default_anthropic_auth_status().map_err(error_to_string)?; - let state = if auth.connected { + let used_percent = value.get("used_percent")?.as_f64()?; + let remaining_percent = (100.0 - used_percent).clamp(0.0, 100.0); + let window_minutes = value + .get("limit_window_seconds") + .and_then(|value| value.as_i64()) + .filter(|seconds| *seconds > 0) + .map(|seconds| (seconds + 59) / 60); + let reset_at = value.get("reset_at").and_then(|value| value.as_i64()); + Some(RateLimitWindowInfo { + used_percent, + remaining_percent, + window_minutes, + reset_at, + }) +} + +#[tauri::command] +pub(super) async fn get_antigravity_quota( + key: Option, +) -> std::result::Result { + let cache_key = key.clone().unwrap_or_else(|| "default".to_string()); + + // Check cache + let cache = ANTIGRAVITY_QUOTA_CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())); + if let Ok(guard) = cache.lock() { + if let Some((cached, fetched_at)) = guard.get(&cache_key) { + if fetched_at.elapsed() < std::time::Duration::from_secs(30) { + return Ok(cached.clone()); + } + } + } + + let path = if let Some(k) = key.as_deref() { + sinew_google::auth::path_for_auth_key(k).map_err(error_to_string)? + } else { + sinew_google::auth::default_auth_path().map_err(error_to_string)? + }; + + let credential = GoogleCredential::from_sinew_auth_file(&path) + .map_err(error_to_string)? + .ok_or_else(|| "Google OAuth credential not found".to_string())?; + let http = reqwest::Client::new(); + let token = credential.bearer(&http).await.map_err(error_to_string)?; + let project = sinew_google::load_user_data(&path) + .map_err(error_to_string)? + .map(|user| user.project_id); + let body = if let Some(project_id) = project.as_deref() { + serde_json::json!({ "project": project_id }) + } else { + serde_json::json!({}) + }; + let response = http + .post("https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels") + .bearer_auth(token) + .header("content-type", "application/json") + .header("accept", "application/json") + .header( + "user-agent", + sinew_google::antigravity_load_code_assist_user_agent(), + ) + .header("x-goog-api-client", "gl-node/22.21.1") + .json(&body) + .send() + .await + .map_err(|err| format!("Failed to fetch Antigravity quotas: {err}"))?; + if !response.status().is_success() { + return Err(format!( + "Antigravity quota endpoint returned status {}", + response.status() + )); + } + let raw: serde_json::Value = response + .json() + .await + .map_err(|err| format!("Failed to parse Antigravity quota response: {err}"))?; + + let mut groups: HashMap = HashMap::new(); + if let Some(models) = raw.get("models").and_then(|value| value.as_object()) { + for (model_name, info) in models { + // On ignore les modèles internes qui n'ont pas de vrai "displayName" public + let Some(label) = info.get("displayName").and_then(|v| v.as_str()) else { + continue; + }; + + // On ignore aussi s'il s'agit explicitement de endpoints internes (ex: tab_, chat_) + if label.starts_with("tab_") + || label.starts_with("chat_") + || model_name.starts_with("tab_") + || model_name.starts_with("chat_") + { + continue; + } + + // On ne garde que les 8 modèles officiels de l'abonnement Antigravity + let is_official = matches!( + label, + "Claude Opus 4.6 (Thinking)" + | "Claude Sonnet 4.6 (Thinking)" + | "GPT-OSS 120B (Medium)" + | "Gemini 3.5 Flash (Low)" + | "Gemini 3.5 Flash (Medium)" + | "Gemini 3.5 Flash (High)" + | "Gemini 3.1 Pro (Low)" + | "Gemini 3.1 Pro (High)" + ); + if !is_official { + continue; + } + + let quota = info.get("quotaInfo"); + let remaining = quota + .and_then(|value| value.get("remainingFraction")) + .and_then(|value| value.as_f64()) + .map(|value| (value * 100.0).clamp(0.0, 100.0)); + let reset_time = quota + .and_then(|value| value.get("resetTime")) + .and_then(|value| value.as_str()) + .map(|value| value.to_string()); + + // Si pas de quota, on ignore + if remaining.is_none() && reset_time.is_none() { + continue; + } + + let group_label = match label { + "Claude Opus 4.6 (Thinking)" + | "Claude Sonnet 4.6 (Thinking)" + | "GPT-OSS 120B (Medium)" => "Claude & GPT-OSS", + "Gemini 3.5 Flash (Low)" + | "Gemini 3.5 Flash (Medium)" + | "Gemini 3.5 Flash (High)" + | "Gemini 3.1 Pro (Low)" + | "Gemini 3.1 Pro (High)" => "Gemini", + _ => label, + }; + + let entry = + groups + .entry(group_label.to_string()) + .or_insert_with(|| AntigravityQuotaGroupInfo { + group: group_label.to_string(), + label: group_label.to_string(), + remaining_percent: remaining, + reset_time: reset_time.clone(), + count: 0, + }); + entry.count += 1; + if entry.reset_time.is_none() && reset_time.is_some() { + entry.reset_time = reset_time; + } + if let Some(rem) = remaining { + entry.remaining_percent = Some( + entry + .remaining_percent + .map_or(rem, |current| current.min(rem)), + ); + } + } + } + let mut groups = groups.into_values().collect::>(); + groups.sort_by(|a, b| a.label.cmp(&b.label)); + + let result = AntigravityQuotaInfo { + project_id: project, + groups, + raw, + }; + + if let Ok(mut guard) = cache.lock() { + guard.insert(cache_key, (result.clone(), std::time::Instant::now())); + } + + Ok(result) +} + +#[tauri::command] +pub(super) async fn get_all_openai_accounts() -> std::result::Result, String> +{ + let mut accounts = Vec::new(); + if let Ok(files) = all_auth_files() { + for (key, path) in files { + if let Ok(status) = load_auth_status(&path) { + if status.connected { + accounts.push(OpenAiAccountInfo { + key, + email: status.email, + plan_type: status.plan_type, + }); + } + } + } + } + accounts.sort_by(|a, b| compare_provider_keys(&a.key, &b.key)); + Ok(accounts) +} + +#[tauri::command] +pub(super) async fn disconnect_openai_account( + state: State<'_, DesktopState>, + key: String, +) -> std::result::Result<(), String> { + let mut lock = state + .providers + .lock() + .map_err(|_| "provider registry is unavailable".to_string())?; + lock.remove(&key); + + if let Ok(files) = all_auth_files() { + for (fkey, path) in files { + if fkey == key { + let _ = std::fs::remove_file(path); + } + } + } + Ok(()) +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct GoogleAccountInfo { + pub(super) key: String, + pub(super) email: Option, + pub(super) project_id: Option, + pub(super) user_tier: Option, +} + +#[tauri::command] +pub(super) async fn get_all_google_accounts() -> std::result::Result, String> { + let mut accounts = Vec::new(); + if let Ok(files) = sinew_google::auth::all_auth_files() { + for (key, path) in files { + if let Ok(status) = sinew_google::auth::load_auth_status(&path) { + if status.connected { + accounts.push(GoogleAccountInfo { + key, + email: status.email, + project_id: status.project_id, + user_tier: status.user_tier, + }); + } + } + } + } + accounts.sort_by(|a, b| compare_provider_keys(&a.key, &b.key)); + Ok(accounts) +} + +#[tauri::command] +pub(super) async fn disconnect_google_account( + state: State<'_, DesktopState>, + key: String, +) -> std::result::Result<(), String> { + let mut lock = state + .providers + .lock() + .map_err(|_| "provider registry is unavailable".to_string())?; + lock.remove(&key); + + if let Ok(files) = sinew_google::auth::all_auth_files() { + for (fkey, path) in files { + if fkey == key { + let _ = std::fs::remove_file(path); + } + } + } + Ok(()) +} + +#[tauri::command] +pub(super) async fn save_openai_access_token( + state: State<'_, DesktopState>, + token: String, + key: Option, +) -> std::result::Result<(), String> { + let token = token.trim().to_string(); + if token.is_empty() { + return Err("access token is empty".to_string()); + } + + let default_path = default_auth_path().map_err(error_to_string)?; + let target_path = if let Some(ref k) = key { + if k == "openai" { + default_path + } else { + let suffix = k.strip_prefix("openai:").unwrap_or(k); + default_path + .parent() + .unwrap() + .join(format!("openai-auth-{}.json", suffix)) + } + } else { + default_path + }; + + sinew_openai::save_raw_access_token(&target_path, &token).map_err(error_to_string)?; + + install_openai_provider(&state.providers)?; + Ok(()) +} + +#[tauri::command] +pub(super) async fn get_anthropic_provider_status( + state: State<'_, DesktopState>, +) -> std::result::Result { + let mut active_login = state.anthropic_login.lock().await; + let attempt = active_login.clone(); + if let Some(attempt) = attempt { + let outcome = attempt + .outcome + .lock() + .map_err(|_| "login state is unavailable".to_string())? + .clone(); + + if let Some(outcome) = outcome { + *active_login = None; + let auth = load_default_anthropic_auth_status().map_err(error_to_string)?; + if outcome.success { + return Ok(anthropic_provider_status_from_auth( + auth, + "connected", + None, + None, + )); + } + return Ok(anthropic_provider_status_from_auth( + auth, + "error", + None, + outcome.error, + )); + } + + let auth = load_default_anthropic_auth_status().map_err(error_to_string)?; + return Ok(anthropic_provider_status_from_auth( + auth, + "connecting", + Some(attempt.id), + None, + )); + } + + let auth = load_default_anthropic_auth_status().map_err(error_to_string)?; + let state = if auth.connected { + "connected" + } else { + "disconnected" + }; + Ok(anthropic_provider_status_from_auth(auth, state, None, None)) +} + +#[tauri::command] +pub(super) async fn start_anthropic_oauth_login( + state: State<'_, DesktopState>, +) -> std::result::Result { + if let Some(existing) = state.anthropic_login.lock().await.take() { + existing.cancel.notify_one(); + } + + let listener = bind_anthropic_oauth_listener() + .await + .map_err(error_to_string)?; + let port = listener.local_addr().map_err(error_to_string)?.port(); + let redirect_uri = format!("http://localhost:{port}/callback"); + let pkce = generate_anthropic_pkce(); + let oauth_state = pkce.code_verifier.clone(); + let auth_url = anthropic_oauth_authorize_url(&redirect_uri, &pkce, &oauth_state); + let login_id = generate_anthropic_state(); + let cancel = Arc::new(Notify::new()); + let outcome = Arc::new(StdMutex::new(None)); + + { + let mut active_login = state.anthropic_login.lock().await; + *active_login = Some(AnthropicLoginAttempt { + id: login_id.clone(), + cancel: cancel.clone(), + outcome: outcome.clone(), + }); + } + + let providers = state.providers.clone(); + tauri::async_runtime::spawn(async move { + let result = + run_anthropic_oauth_server(listener, redirect_uri, oauth_state, pkce, cancel).await; + let login_outcome = match result { + Ok(()) => match install_anthropic_provider(&providers) { + Ok(()) => AnthropicLoginOutcome { + success: true, + error: None, + }, + Err(err) => AnthropicLoginOutcome { + success: false, + error: Some(err), + }, + }, + Err(err) => AnthropicLoginOutcome { + success: false, + error: Some(err.to_string()), + }, + }; + if let Ok(mut slot) = outcome.lock() { + *slot = Some(login_outcome); + } + }); + + Ok(StartAnthropicLoginOutput { login_id, auth_url }) +} + +#[tauri::command] +pub(super) async fn cancel_anthropic_oauth_login( + state: State<'_, DesktopState>, +) -> std::result::Result { + if let Some(attempt) = state.anthropic_login.lock().await.take() { + attempt.cancel.notify_one(); + } + let auth = load_default_anthropic_auth_status().map_err(error_to_string)?; + let state = if auth.connected { "connected" } else { "disconnected" @@ -1122,6 +1878,7 @@ pub(super) async fn get_google_provider_status( #[tauri::command] pub(super) async fn start_google_oauth_login( state: State<'_, DesktopState>, + key: Option, ) -> std::result::Result { if let Some(existing) = state.google_login.lock().await.take() { existing.cancel.notify_one(); @@ -1147,13 +1904,15 @@ pub(super) async fn start_google_oauth_login( id: login_id.clone(), cancel: cancel.clone(), outcome: outcome.clone(), + target_key: key.clone(), }); } let providers = state.providers.clone(); + let target_key = key.clone(); tauri::async_runtime::spawn(async move { let result = - run_google_oauth_server(listener, redirect_uri, oauth_state, pkce, cancel).await; + run_google_oauth_server(listener, redirect_uri, oauth_state, pkce, cancel, target_key).await; let login_outcome = match result { Ok(()) => match install_google_provider(&providers) { Ok(()) => GoogleLoginOutcome { @@ -1203,6 +1962,14 @@ pub(super) async fn disconnect_google_provider( } delete_default_google_auth().map_err(error_to_string)?; remove_google_provider(&state.providers)?; + let mut tool_settings = state.store.load_tool_settings().map_err(error_to_string)?; + if tool_settings.gemini_image_use_subscription { + tool_settings.gemini_image_use_subscription = false; + state + .store + .save_tool_settings(&tool_settings) + .map_err(error_to_string)?; + } Ok(google_provider_status_from_auth( GoogleAuthStatus::disconnected(), "disconnected", @@ -1380,50 +2147,129 @@ pub(super) async fn get_openrouter_provider_status( .map_err(error_to_string)? .len(); let auth = load_default_openrouter_auth_status().map_err(error_to_string)?; - let Some(api_key) = load_default_openrouter_api_key().map_err(error_to_string)? else { + let state_str = if auth.connected { + let models = state + .store + .load_openrouter_models() + .map_err(error_to_string)?; + install_openrouter_provider(&state.providers, &models)?; + "connected" + } else { remove_openrouter_provider(&state.providers)?; - return Ok(openrouter_provider_status_from_auth( - auth, - "disconnected", - model_count, - None, - )); + "disconnected" }; + Ok(openrouter_provider_status_from_auth( + auth, + state_str, + model_count, + None, + )) +} - match validate_openrouter_api_key_remote(&api_key).await { - Ok(()) => { - let auth = touch_default_openrouter_auth_validation().map_err(error_to_string)?; - let models = state - .store - .load_openrouter_models() - .map_err(error_to_string)?; - install_openrouter_provider(&state.providers, &models)?; - Ok(openrouter_provider_status_from_auth( - auth, - "connected", - models.len(), - None, - )) - } - Err(err) => { - remove_openrouter_provider(&state.providers)?; - Ok(openrouter_provider_status_from_auth( - auth, - "error", - model_count, - Some(err.to_string()), - )) +#[tauri::command] +pub(super) async fn get_openrouter_key_details() -> std::result::Result { + let cache = OPENROUTER_KEY_CACHE.get_or_init(|| std::sync::Mutex::new(None)); + if let Ok(guard) = cache.lock() { + if let Some((ref cached, fetched_at)) = *guard { + if fetched_at.elapsed() < std::time::Duration::from_secs(30) { + return Ok(cached.clone()); + } } } + + let api_key = load_default_openrouter_api_key().map_err(error_to_string)?; + let Some(api_key) = api_key else { + return Err("No OpenRouter API key found".to_string()); + }; + let client = reqwest::Client::new(); + let response = client + .get("https://openrouter.ai/api/v1/auth/key") + .header("Authorization", format!("Bearer {}", api_key)) + .send() + .await + .map_err(|err| format!("Failed to fetch OpenRouter key details: {err}"))?; + if !response.status().is_success() { + return Err(format!("OpenRouter returned status {}", response.status())); + } + let data: serde_json::Value = response + .json() + .await + .map_err(|err| format!("Failed to parse response: {err}"))?; + + if let Ok(mut guard) = cache.lock() { + *guard = Some((data.clone(), std::time::Instant::now())); + } + + Ok(data) } #[tauri::command] -pub(super) async fn validate_openrouter_api_key( - state: State<'_, DesktopState>, - input: ValidateOpenRouterApiKeyInput, -) -> std::result::Result { - let api_key = input.api_key.trim().to_string(); - if api_key.is_empty() { +pub(super) async fn get_deepseek_balance() -> std::result::Result { + let cache = DEEPSEEK_BALANCE_CACHE.get_or_init(|| std::sync::Mutex::new(None)); + if let Ok(guard) = cache.lock() { + if let Some((ref cached, fetched_at)) = *guard { + if fetched_at.elapsed() < std::time::Duration::from_secs(30) { + return Ok(cached.clone()); + } + } + } + + let api_key = load_default_deepseek_api_key().map_err(error_to_string)?; + let Some(api_key) = api_key else { + return Err("No DeepSeek API key found".to_string()); + }; + let client = reqwest::Client::new(); + let response = client + .get("https://api.deepseek.com/user/balance") + .bearer_auth(api_key) + .send() + .await + .map_err(|err| format!("Failed to fetch DeepSeek balance: {err}"))?; + if !response.status().is_success() { + return Err(format!("DeepSeek returned status {}", response.status())); + } + let data: serde_json::Value = response + .json() + .await + .map_err(|err| format!("Failed to parse response: {err}"))?; + + if let Ok(mut guard) = cache.lock() { + *guard = Some((data.clone(), std::time::Instant::now())); + } + + Ok(data) +} + +#[tauri::command] +pub(super) async fn list_deepseek_models_remote() -> std::result::Result { + let api_key = load_default_deepseek_api_key().map_err(error_to_string)?; + let Some(api_key) = api_key else { + return Err("No DeepSeek API key found".to_string()); + }; + let client = reqwest::Client::new(); + let response = client + .get("https://api.deepseek.com/models") + .bearer_auth(api_key) + .send() + .await + .map_err(|err| format!("Failed to fetch DeepSeek models: {err}"))?; + if !response.status().is_success() { + return Err(format!("DeepSeek returned status {}", response.status())); + } + let data: serde_json::Value = response + .json() + .await + .map_err(|err| format!("Failed to parse response: {err}"))?; + Ok(data) +} + +#[tauri::command] +pub(super) async fn validate_openrouter_api_key( + state: State<'_, DesktopState>, + input: ValidateOpenRouterApiKeyInput, +) -> std::result::Result { + let api_key = input.api_key.trim().to_string(); + if api_key.is_empty() { return Ok(openrouter_provider_status_from_auth( OpenRouterAuthStatus::disconnected(), "disconnected", @@ -1473,6 +2319,196 @@ pub(super) async fn disconnect_openrouter_provider( )) } +#[tauri::command] +pub(super) async fn get_ollama_provider_status( + state: State<'_, DesktopState>, +) -> std::result::Result { + let model_count = state.store.load_ollama_models().map_err(error_to_string)?.len(); + let auth = load_default_ollama_auth_status().map_err(error_to_string)?; + let state_str = if auth.connected { + let models = state.store.load_ollama_models().map_err(error_to_string)?; + install_ollama_provider(&state.providers, &models)?; + "connected" + } else { + remove_ollama_provider(&state.providers)?; + "disconnected" + }; + Ok(ollama_provider_status_from_auth( + auth, state_str, model_count, None, + )) +} + +#[tauri::command] +pub(super) async fn connect_ollama_provider( + state: State<'_, DesktopState>, + input: ConnectOllamaInput, +) -> std::result::Result { + let base_url = input + .base_url + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(default_ollama_base_url); + + validate_ollama_endpoint(&base_url) + .await + .map_err(error_to_string)?; + let auth = save_default_ollama_base_url(&base_url).map_err(error_to_string)?; + + let catalog = fetch_ollama_model_catalog(&base_url) + .await + .map_err(error_to_string)?; + let records = catalog.into_iter().map(ollama_record_from_catalog).collect::>(); + let models = state + .store + .save_ollama_models(&records) + .map_err(error_to_string)?; + install_ollama_provider(&state.providers, &models)?; + Ok(ollama_provider_status_from_auth( + auth, + "connected", + models.len(), + None, + )) +} + +#[tauri::command] +pub(super) async fn refresh_ollama_models( + state: State<'_, DesktopState>, +) -> std::result::Result, String> { + let base_url = load_default_ollama_base_url() + .map_err(error_to_string)? + .ok_or_else(|| "Ollama is not connected".to_string())?; + let catalog = fetch_ollama_model_catalog(&base_url) + .await + .map_err(error_to_string)?; + let records = catalog.into_iter().map(ollama_record_from_catalog).collect::>(); + let models = state + .store + .save_ollama_models(&records) + .map_err(error_to_string)?; + refresh_ollama_provider_if_present(&state, &models)?; + Ok(models) +} + +#[tauri::command] +pub(super) fn list_ollama_models( + state: State<'_, DesktopState>, +) -> std::result::Result, String> { + state.store.load_ollama_models().map_err(error_to_string) +} + +#[tauri::command] +pub(super) async fn disconnect_ollama_provider( + state: State<'_, DesktopState>, +) -> std::result::Result { + cancel_active_turns_for_provider(&state, OLLAMA_PROVIDER_ID).await; + delete_default_ollama_auth().map_err(error_to_string)?; + remove_ollama_provider(&state.providers)?; + let model_count = state.store.load_ollama_models().map_err(error_to_string)?.len(); + Ok(ollama_provider_status_from_auth( + OllamaAuthStatus::disconnected(), + "disconnected", + model_count, + None, + )) +} + +pub(super) fn ollama_record_from_catalog( + model: sinew_ollama::OllamaCatalogModel, +) -> OpenRouterModelRecord { + OpenRouterModelRecord { + id: model.id, + name: model.name, + context_window: model.context_window, + max_output_tokens: model.max_output_tokens, + supports_images: model.supports_images, + supports_thinking: model.supports_thinking, + supports_tools: model.supports_tools, + added_at_ms: now_ms(), + } +} + +pub(super) fn refresh_ollama_provider_if_present( + state: &DesktopState, + models: &[OpenRouterModelRecord], +) -> std::result::Result<(), String> { + let present = state + .providers + .lock() + .map_err(|_| "provider registry is unavailable".to_string())? + .contains_key(OLLAMA_PROVIDER_ID); + if present { + install_ollama_provider(&state.providers, models)?; + } + Ok(()) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct ValidateDeepSeekApiKeyInput { + pub(super) api_key: String, +} + +#[tauri::command] +pub(super) async fn get_deepseek_provider_status( + state: State<'_, DesktopState>, +) -> std::result::Result { + let auth = load_default_deepseek_auth_status().map_err(error_to_string)?; + let api_key = load_default_deepseek_api_key().map_err(error_to_string)?; + let state_str = if api_key.is_some() { + install_deepseek_provider(&state.providers)?; + "connected" + } else { + remove_deepseek_provider(&state.providers)?; + "disconnected" + }; + Ok(deepseek_provider_status_from_auth( + auth, + state_str, + None, + )) +} + +#[tauri::command] +pub(super) async fn validate_deepseek_api_key( + state: State<'_, DesktopState>, + input: ValidateDeepSeekApiKeyInput, +) -> std::result::Result { + let api_key = input.api_key.trim().to_string(); + if api_key.is_empty() { + return Ok(deepseek_provider_status_from_auth( + DeepSeekAuthStatus::disconnected(), + "disconnected", + None, + )); + } + + validate_deepseek_api_key_remote(&api_key) + .await + .map_err(error_to_string)?; + let auth = save_default_deepseek_api_key(&api_key).map_err(error_to_string)?; + install_deepseek_provider(&state.providers)?; + Ok(deepseek_provider_status_from_auth( + auth, + "connected", + None, + )) +} + +#[tauri::command] +pub(super) async fn disconnect_deepseek_provider( + state: State<'_, DesktopState>, +) -> std::result::Result { + cancel_active_turns_for_provider(&state, DEEPSEEK_PROVIDER_ID).await; + delete_default_deepseek_auth().map_err(error_to_string)?; + remove_deepseek_provider(&state.providers)?; + Ok(deepseek_provider_status_from_auth( + DeepSeekAuthStatus::disconnected(), + "disconnected", + None, + )) +} + #[tauri::command] pub(super) fn list_openrouter_models( state: State<'_, DesktopState>, @@ -1591,3 +2627,266 @@ pub(super) async fn cancel_active_turns_for_provider(state: &DesktopState, provi } } } + +pub(super) fn install_cursor_provider( + providers: &Arc>>>, +) -> std::result::Result<(), String> { + let provider = CursorProvider::from_default_sources().map_err(error_to_string)?; + providers + .lock() + .map_err(|_| "provider registry is unavailable".to_string())? + .insert(CURSOR_PROVIDER_ID.into(), Arc::new(provider) as Arc); + Ok(()) +} + +pub(super) fn cursor_composer_status_from_auth( + auth: CursorComposerAuthStatus, + connection_state: &str, + login_id: Option, + error: Option, +) -> CursorComposerAuthStatus { + auth.with_connection_state(connection_state, login_id, error) +} + +#[tauri::command] +pub(super) async fn get_cursor_composer_status( + state: State<'_, DesktopState>, +) -> std::result::Result { + let mut active_login = state.cursor_login.lock().await; + let attempt = active_login.clone(); + if let Some(attempt) = attempt { + let outcome = attempt + .outcome + .lock() + .map_err(|_| "login state is unavailable".to_string())? + .clone(); + + if let Some(outcome) = outcome { + *active_login = None; + let auth = load_composer_auth_status().map_err(error_to_string)?; + if outcome.success { + return Ok(cursor_composer_status_from_auth( + auth, + "connected", + None, + None, + )); + } + return Ok(cursor_composer_status_from_auth( + auth, + "error", + None, + outcome.error, + )); + } + + let auth = load_composer_auth_status().map_err(error_to_string)?; + return Ok(cursor_composer_status_from_auth( + auth, + "connecting", + Some(attempt.id), + None, + )); + } + + let auth = load_composer_auth_status().map_err(error_to_string)?; + let connection_state = if auth.connected { + "connected" + } else { + "disconnected" + }; + Ok(cursor_composer_status_from_auth(auth, connection_state, None, None)) +} + +#[tauri::command] +pub(super) async fn start_cursor_oauth_login( + state: State<'_, DesktopState>, +) -> std::result::Result { + if let Some(existing) = state.cursor_login.lock().await.take() { + existing.cancel.notify_one(); + } + + let challenge = create_login_challenge(); + let login_id = generate_kimi_state(); + let cancel = Arc::new(Notify::new()); + let outcome = Arc::new(StdMutex::new(None)); + + { + let mut active_login = state.cursor_login.lock().await; + *active_login = Some(CursorLoginAttempt { + id: login_id.clone(), + cancel: cancel.clone(), + outcome: outcome.clone(), + }); + } + + let providers = state.providers.clone(); + let auth_url = challenge.auth_url.clone(); + tauri::async_runtime::spawn(async move { + let result = run_cursor_oauth_login(challenge, cancel).await; + let login_outcome = match result { + Ok(()) => match install_cursor_provider(&providers) { + Ok(()) => CursorLoginOutcome { + success: true, + error: None, + }, + Err(err) => CursorLoginOutcome { + success: false, + error: Some(err), + }, + }, + Err(err) => CursorLoginOutcome { + success: false, + error: Some(err.to_string()), + }, + }; + if let Ok(mut slot) = outcome.lock() { + *slot = Some(login_outcome); + } + }); + + Ok(StartCursorLoginOutput { login_id, auth_url }) +} + +pub(super) async fn run_cursor_oauth_login( + challenge: CursorLoginChallenge, + cancel: Arc, +) -> Result<()> { + let http = reqwest::Client::builder() + .user_agent(CursorIdeIdentity::load().user_agent()) + .build() + .context("unable to build Cursor OAuth client")?; + + tokio::select! { + _ = cancel.notified() => { + anyhow::bail!("Login canceled"); + } + result = wait_for_oauth_login(&http, &challenge, &cancel) => { + result.map(|_| ()).map_err(|err| anyhow::anyhow!(err.to_string())) + } + } +} + +#[tauri::command] +pub(super) async fn cancel_cursor_oauth_login( + state: State<'_, DesktopState>, +) -> std::result::Result { + if let Some(attempt) = state.cursor_login.lock().await.take() { + attempt.cancel.notify_one(); + } + let auth = load_composer_auth_status().map_err(error_to_string)?; + let connection_state = if auth.connected { + "connected" + } else { + "disconnected" + }; + Ok(cursor_composer_status_from_auth( + auth, + connection_state, + None, + None, + )) +} + +#[tauri::command] +pub(super) async fn sync_cursor_composer_auth( + state: State<'_, DesktopState>, +) -> std::result::Result { + if let Ok(Some(session)) = load_composer_session() { + let http = reqwest::Client::builder() + .user_agent(CursorIdeIdentity::load().user_agent()) + .build() + .map_err(error_to_string)?; + if let Err(err) = ensure_fresh_composer_token(&http, &session).await { + tracing::warn!("cursor composer token refresh during sync failed: {err}"); + } + } + + let auth = load_composer_auth_status().map_err(error_to_string)?; + install_cursor_provider(&state.providers)?; + let connection_state = if auth.connected { + "connected" + } else { + "disconnected" + }; + Ok(cursor_composer_status_from_auth( + auth, + connection_state, + None, + None, + )) +} + +#[tauri::command] +pub(super) async fn disconnect_cursor_composer( + state: State<'_, DesktopState>, +) -> std::result::Result<(), String> { + if let Some(attempt) = state.cursor_login.lock().await.take() { + attempt.cancel.notify_one(); + } + delete_composer_auth().map_err(error_to_string)?; + install_cursor_provider(&state.providers).ok(); + Ok(()) +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct CursorUsageQuotaInfo { + pub auto_percent_used: f64, + pub api_percent_used: f64, + pub total_percent_used: f64, +} + +#[tauri::command] +pub(super) async fn get_cursor_usage() -> std::result::Result { + let cache = CURSOR_USAGE_CACHE.get_or_init(|| std::sync::Mutex::new(None)); + if let Ok(guard) = cache.lock() { + if let Some((ref cached, fetched_at)) = *guard { + if fetched_at.elapsed() < std::time::Duration::from_secs(30) { + return Ok(cached.clone()); + } + } + } + + let provider = CursorProvider::from_default_sources().map_err(error_to_string)?; + let usage = provider + .usage_snapshot() + .await + .map_err(error_to_string)? + .ok_or_else(|| "Cursor composer session is not connected".to_string())?; + let result = CursorUsageQuotaInfo { + auto_percent_used: usage.auto_percent_used, + api_percent_used: usage.api_percent_used, + total_percent_used: usage.total_percent_used, + }; + + if let Ok(mut guard) = cache.lock() { + *guard = Some((result.clone(), std::time::Instant::now())); + } + + Ok(result) +} + +#[tauri::command] +pub(super) async fn get_anthropic_usage() -> std::result::Result { + let cache = ANTHROPIC_USAGE_CACHE.get_or_init(|| std::sync::Mutex::new(None)); + if let Ok(guard) = cache.lock() { + if let Some((ref cached, fetched_at)) = *guard { + if fetched_at.elapsed() < std::time::Duration::from_secs(30) { + return Ok(cached.clone()); + } + } + } + + let provider = AnthropicProvider::from_default_sources().map_err(error_to_string)?; + let usage = provider + .get_usage() + .await + .map_err(error_to_string)?; + + if let Ok(mut guard) = cache.lock() { + *guard = Some((usage.clone(), std::time::Instant::now())); + } + + Ok(usage) +} diff --git a/src-tauri/src/rules.rs b/src-tauri/src/rules.rs new file mode 100644 index 00000000..b9beb7b9 --- /dev/null +++ b/src-tauri/src/rules.rs @@ -0,0 +1,362 @@ +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use std::sync::Arc; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use regex::Regex; +use chrono::Local; +use futures::StreamExt; +use sinew_core::{ + ChatMessage, ModelRef, Provider, ProviderRequest, StreamEvent, +}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct ErrorItem { + pub id: String, + pub count: i64, + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub consolidated_at: Option, + #[serde(flatten)] + pub extra: HashMap, +} + +fn normalize(s: &str) -> String { + let s = s.to_lowercase().replace('_', " ").replace('-', " "); + let re = Regex::new(r"\s+").unwrap(); + let s = re.replace_all(&s, " "); + s.trim().to_string() +} + +fn rule_covers_error(rules_text: &str, error_id: &str) -> bool { + let rules_norm = normalize(rules_text); + let id_norm = normalize(error_id); + if !id_norm.is_empty() && rules_norm.contains(&id_norm) { + return true; + } + + let aliases = match error_id { + "git_exclusions_build_node_modules" => vec!["node_modules", "build/"], + "spawn_einval_windows" => vec!["spawn einval", "shell: true"], + "recursive_postinstall_npm" => vec!["postinstall", "npm install"], + "mcp_autoload_serialization" => vec!["autoload", "settingstojson"], + "absolute_paths_windows" => vec!["chemins de fichiers absolus", "chemins relatifs"], + _ => vec![], + }; + + if !aliases.is_empty() { + if aliases.iter().all(|alias| rules_norm.contains(&normalize(alias))) { + return true; + } + } + + false +} + +fn next_rule_number(rules_text: &str) -> usize { + let re = Regex::new(r"^###\s+(\d+)\.").unwrap(); + let mut max_val = 0; + for line in rules_text.lines() { + if let Some(caps) = re.captures(line.trim()) { + if let Ok(num) = caps[1].parse::() { + if num > max_val { + max_val = num; + } + } + } + } + max_val + 1 +} + +fn title_from_error_id(error_id: &str) -> String { + error_id + .split('_') + .filter(|part| !part.is_empty()) + .map(|part| { + let mut chars = part.chars(); + match chars.next() { + None => String::new(), + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + } + }) + .collect::>() + .join(" ") +} + +fn build_rule(number: usize, error_id: &str, description: &str) -> String { + let title = title_from_error_id(error_id); + let desc_clean = description.replace("\r\n", " ").replace('\n', " "); + let re_spaces = Regex::new(r"\s+").unwrap(); + let desc_clean = re_spaces.replace_all(&desc_clean, " "); + let desc_clean = desc_clean.trim().trim_end_matches('.'); + format!( + "\n\n### {number}. 🧠 Règle auto-consolidée — {title}\n* **Règle** : Cette erreur répétée a été détectée automatiquement : {desc_clean}. À chaque occurrence similaire, l'agent doit s'arrêter, identifier la cause générale, appliquer ou créer une règle globale adaptée, puis éviter de répététer la même tentative ciblée." + ) +} + +pub async fn ai_consolidate_rules( + provider: Arc, + model_name: &str, +) -> Result { + let Ok(local_app_data) = std::env::var("LOCALAPPDATA") else { + return Err("LOCALAPPDATA non disponible".to_string()); + }; + let sinew_dir = PathBuf::from(local_app_data).join("Sinew"); + let errors_path = sinew_dir.join("errors_raw.json"); + let rules_path = sinew_dir.join("instructions_consolidated.md"); + + let errors_data = fs::read_to_string(&errors_path) + .map_err(|e| format!("Impossible de lire errors_raw.json: {e}"))?; + + let clean_errors_data = errors_data.strip_prefix('\u{FEFF}').unwrap_or(&errors_data); + + let errors: Vec = serde_json::from_str(clean_errors_data) + .map_err(|e| format!("Format errors_raw.json invalide: {e}"))?; + + let unconsolidated: Vec<&ErrorItem> = errors + .iter() + .filter(|e| e.count >= 3 && !e.id.is_empty() && e.consolidated_at.is_none()) + .collect(); + + if unconsolidated.is_empty() { + return Ok("Aucune erreur à consolider (toutes les erreurs sont déjà traitées ou en dessous du seuil de 3).".to_string()); + } + + let current_rules = if rules_path.exists() { + fs::read_to_string(&rules_path).unwrap_or_default() + } else { + String::from("# 🛡️ Instructions Globales Consolidées (Règles anti-erreurs répétitives)\n\nCes instructions ont été validées et consolidées après avoir été rencontrées au moins 3 fois. Tout agent intervenant sur ce projet doit les respecter à la lettre.\n") + }; + + let errors_json = serde_json::to_string_pretty( + &unconsolidated + .iter() + .map(|e| serde_json::json!({ + "id": e.id, + "count": e.count, + "description": e.description.as_deref().unwrap_or("Pas de description") + })) + .collect::>(), + ) + .unwrap_or_default(); + + let system_prompt = concat!( + "Tu es un assistant d'auto-amélioration pour Sinew, un IDE agentique. ", + "Ta mission : analyser les erreurs répétitives et produire un fichier de règles consolidées INTELLIGENT, sans aucune intervention humaine.\n\n", + "CONTEXTE :\n", + "- Le fichier instructions_consolidated.md contient les règles globales actuelles, injectées dans le prompt système de l'agent.\n", + "- Le fichier errors_raw.json contient les erreurs brutes (avec compteur d'occurrences).\n", + "- Seuil de création : 3 occurrences minimum.\n\n", + "TA MISSION (6 étapes) :\n", + "1. Analyse les erreurs fournies et les règles existantes.\n", + "2. Identifie les doublons ou erreurs partageant la MÊME CAUSE RACINE (ex: chemins relatifs sous Windows). Fusionne-les en UNE SEULE règle générale.\n", + "3. Pour chaque règle (existante ou nouvelle), applique le SYSTÈME DE CONFIANCE :\n", + " - 🟢 ACTIVE : règle issue de 3+ occurrences confirmées → doit être respectée strictement\n", + " - 🟡 CANDIDATE : règle issue de 2 occurrences (presque seuil) → l'agent doit y prêter attention mais peut déroger avec justification\n", + " - 🔴 OBSOLÈTE : règle non déclenchée depuis plus de 2 mois, ou contredite par une règle plus récente → marquée comme historique, ne plus appliquer\n", + "4. Chaque règle doit inclure OBLIGATOIREMENT :\n", + " - 🏷️ Statut de confiance (ACTIVE/CANDIDATE/OBSOLÈTE)\n", + " - 📊 Origine : quelles erreurs ont fusionné pour créer cette règle (ids + compteurs)\n", + " - 📅 Date de création ou dernière mise à jour\n", + " - 🔗 Règles liées ou remplacées (si fusion)\n", + "5. DÉGRADATION AUTOMATIQUE : si une règle contredit une nouvelle règle plus générale, ou si elle n'a pas été mise à jour depuis 2+ mois, passe-la en 🔴 OBSOLÈTE (ne la supprime pas, garde l'historique).\n", + "6. Produis le fichier COMPLET mis à jour (règles existantes + nouvelles, dédoublonnées, renumérotées).\n\n", + "FORMAT EXACT D'UNE RÈGLE :\n", + "### N. 🧠 Règle auto-consolidée — Titre\n", + "* **Statut** : 🟢 ACTIVE (ou 🟡 CANDIDATE, ou 🔴 OBSOLÈTE)\n", + "* **Origine** : Fusion de X erreurs : id1 (Y occurrences), id2 (Z occurrences)...\n", + "* **Créée le** : JJ/MM/AAAA | **Mise à jour** : JJ/MM/AAAA\n", + "* **Règle** : Description claire et actionnable de ce que l'agent doit faire ou éviter.\n", + "* **Remplace** : règle N, règle M (si fusion) | **Remplacée par** : règle X (si obsolète)\n\n", + "FORMAT DE SORTIE :\n", + "Retourne UNIQUEMENT le contenu complet du fichier instructions_consolidated.md mis à jour. ", + "Le fichier doit commencer par : \"# 🛡️ Instructions Globales Consolidées (Règles anti-erreurs répétitives)\"" + ); + + let user_prompt = format!( + "FICHIER DE RÈGLES ACTUEL :\n```markdown\n{}\n```\n\nERREURS À ANALYSER (JSON) :\n```json\n{}\n```\n\nProduis le fichier instructions_consolidated.md complet et mis à jour.", + current_rules, errors_json + ); + + let request = ProviderRequest { + model: ModelRef::new("deepseek", model_name), + system_prompt: Some(system_prompt.to_string()), + transcript: vec![ChatMessage::user_text(user_prompt)], + tools: Vec::new(), + max_output_tokens: Some(4096), + effort: None, + temperature: Some(0.3), + cache_key: None, + cache_stable_message_count: None, + service_tier: None, + workspace_root: None, + }; + + let mut stream = provider + .stream(request) + .await + .map_err(|e| format!("Erreur du fournisseur IA: {e}"))?; + + let mut response_text = String::new(); + while let Some(event) = stream.next().await { + match event { + Ok(StreamEvent::TextDelta { delta, .. }) => { + response_text.push_str(&delta); + } + Err(e) => { + return Err(format!("Erreur pendant le streaming: {e}")); + } + _ => {} + } + } + + if response_text.trim().is_empty() { + return Err("L'IA n'a produit aucune réponse.".to_string()); + } + + let header = "# 🛡️ Instructions Globales Consolidées"; + let refined_rules = if response_text.contains(header) { + let start = response_text.find(header).unwrap(); + response_text[start..].trim().to_string() + } else { + return Err(format!( + "La réponse de l'IA ne contient pas l'en-tête attendu. Réponse reçue (début): {}...", + &response_text[..response_text.len().min(200)] + )); + }; + + // Filet de sécurité : refuser une réécriture anormalement courte (= règles + // perdues / réponse tronquée), même si l'en-tête est présent. Une fusion + // légitime peut raccourcir le fichier, mais pas le diviser par deux. + let current_len = current_rules.trim().len(); + if current_len > 200 && refined_rules.len() < current_len / 2 { + return Err(format!( + "Réécriture refusée par sécurité : le résultat de l'IA est anormalement court \ + ({} caractères contre {} actuellement). Le fichier n'a PAS été modifié pour éviter \ + de perdre des règles.", + refined_rules.len(), + current_len + )); + } + + if let Some(parent) = rules_path.parent() { + let _ = fs::create_dir_all(parent); + } + + // Sauvegarde de la version précédente avant tout écrasement. + if rules_path.exists() { + let backup_path = rules_path.with_extension("bak.md"); + let _ = fs::copy(&rules_path, &backup_path); + } + + fs::write(&rules_path, format!("{refined_rules}\n")) + .map_err(|e| format!("Impossible d'écrire instructions_consolidated.md: {e}"))?; + + let clean_errors_data = errors_data.strip_prefix('\u{FEFF}').unwrap_or(&errors_data); + let mut errors: Vec = serde_json::from_str(clean_errors_data).unwrap_or_default(); + let now = Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); + let mut consolidated_count = 0; + for error in &mut errors { + if error.count >= 3 && !error.id.is_empty() && error.consolidated_at.is_none() { + error.count = 0; + error.consolidated_at = Some(now.clone()); + consolidated_count += 1; + } + } + + if let Ok(serialized) = serde_json::to_string_pretty(&errors) { + let _ = fs::write(&errors_path, format!("{serialized}\n")); + } + + let result = format!( + "Consolidation IA terminée : {} erreur(s) analysée(s), {} erreur(s) consolidée(s).", + unconsolidated.len(), + consolidated_count + ); + println!("{result}"); + Ok(result) +} + +pub fn consolidate_rules() { + let Ok(local_app_data) = std::env::var("LOCALAPPDATA") else { + return; + }; + let sinew_dir = PathBuf::from(local_app_data).join("Sinew"); + let errors_path = sinew_dir.join("errors_raw.json"); + let rules_path = sinew_dir.join("instructions_consolidated.md"); + + if !errors_path.exists() { + return; + } + + let Ok(errors_data) = fs::read_to_string(&errors_path) else { + return; + }; + + let clean_errors_data = errors_data.strip_prefix('\u{FEFF}').unwrap_or(&errors_data); + + let mut errors: Vec = match serde_json::from_str(clean_errors_data) { + Ok(parsed) => parsed, + Err(_) => return, + }; + + let mut rules_text = if rules_path.exists() { + fs::read_to_string(&rules_path).unwrap_or_else(|_| { + "# 🛡️ Instructions Globales Consolidées (Règles anti-erreurs répétitives)\n\n\ + Ces instructions ont été validées et consolidées après avoir été rencontrées au moins 3 fois. \ + Tout agent intervenant sur ce projet doit les respecter à la lettre.".to_string() + }) + } else { + "# 🛡️ Instructions Globales Consolidées (Règles anti-erreurs répétitives)\n\n\ + Ces instructions ont été validées et consolidées après avoir été rencontrées au moins 3 fois. \ + Tout agent intervenant sur ce projet doit les respecter à la lettre.".to_string() + }; + + let mut changed_errors = false; + let mut changed_rules = false; + let mut created_rules = 0; + let mut cleaned_errors = 0; + let mut number = next_rule_number(&rules_text); + let now = Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); + + for error in &mut errors { + let count = error.count; + if count < 3 || error.id.is_empty() { + continue; + } + + if rule_covers_error(&rules_text, &error.id) { + cleaned_errors += 1; + } else { + let description = error.description.as_deref().unwrap_or("Erreur répétitive sans description."); + rules_text.push_str(&build_rule(number, &error.id, description)); + number += 1; + created_rules += 1; + changed_rules = true; + } + + error.count = 0; + error.consolidated_at = Some(now.clone()); + changed_errors = true; + } + + if changed_rules { + if let Some(parent) = rules_path.parent() { + let _ = fs::create_dir_all(parent); + } + let rules_text = format!("{}\n", rules_text.trim_end()); + let _ = fs::write(&rules_path, rules_text); + } + + if changed_errors { + if let Ok(serialized) = serde_json::to_string_pretty(&errors) { + let _ = fs::write(&errors_path, format!("{}\n", serialized)); + } + } + + println!( + "Consolidation terminée (Rust) : {} règle(s) créée(s), {} erreur(s) déjà couvertes nettoyée(s).", + created_rules, cleaned_errors + ); +} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index b4bcfe27..6a186f11 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -1,8 +1,38 @@ use crate::*; pub(super) const DEFAULT_SYSTEM_PROMPT: &str = "You are Sinew, a coding assistant. You build context by examining the codebase first without making assumptions or jumping to conclusions. ALWAYS check for a dedicated tool that fits the task before falling back to the shell/bash tool. You keep your responses concise without repeating yourself."; +pub(super) const DEFAULT_GIT_AUTOMATION_PROMPT: &str = "\ +When possible, automate Git maintenance: check whether the opened project is up to date, pull if it is behind, \ +and push after successful modifications so the user mostly manages ideas, not Git."; +pub(super) const DEFAULT_CONCISE_ANSWERS_PROMPT: &str = "\ +User preference: the user is a power user, not a coder. Keep answers simple, concise, and action-oriented. \ +Strictly avoid technical developer jargon (such as API REST, State Hook, Mutex, Serde, IPC, etc.). \ +Instead, explain concepts using real-world analogies and everyday metaphors (e.g., comparing a database to a filing cabinet). \ +Keep your explanations simple, direct, and ultra-concise without repeating yourself."; +pub(super) const DEFAULT_AGENT_AUTONOMY_PROMPT: &str = "\ +Agent Autonomy Mode is enabled. Always follow these rules strictly: \ +- If you can perform a task, run a tool, inspect a file, search the workspace, check diagnostics, or run a test YOURSELF, DO IT DIRECTLY. \ +- DO NOT ask the user for permission, clarification, or help to do something that is within your capability or toolset. \ +- NEVER write textual instructions or command lines telling the user how to run a command, compile code, edit a file, or configure their system if you have a tool (like bash, edit_file, write_file) capable of doing it yourself. ALWAYS run the tools first yourself. Act, do not explain how to act. \ +- Never ask the user to find, read, edit, or check logs/files manually if you can access them with your tools (such as read, grep, glob, bash, etc.). \ +- SELF-HEALING LOOP: You MUST verify your own work. After every file modification, use the `read_lints` or `bash` tool to check for syntax/compiler errors. Do not stop your turn or ask for help if you encounter an error; automatically fix it and re-verify until the code is 100% green. \ +- Proactively use all available tools to resolve the user's objective without requiring manual user intervention."; +pub(super) const DEFAULT_STRICT_PROBLEM_SOLVING_PROMPT: &str = "\ +Strict Problem Solving Mode is enabled. Never bypass, hide, or ignore errors and warnings. Always dig down to the root cause and implement the real, correct solution, even if it requires more effort or reading more files."; +pub(super) const DEFAULT_FULL_IMPLEMENTATION_PROMPT: &str = "\ +Full Implementation Mode is enabled. Never leave TODOs, placeholders, or fake/mock code. Everything you write must be completely wired up, functional, and ready for production use immediately."; + +pub(super) const DEFAULT_SSH_OPTIMIZATION_PROMPT: &str = "\ +SSH Optimization Strategy: When you realize or are told that the current workspace is a remote SSH/SSHFS mount, network latency will make local file searches (like `grep` or `glob`) very slow. To restore local-like performance: +1. SOTA Survival Kit: Use your SSH MCP tools (`ssh_exec`, `ssh_ensure_session`) to run commands natively on the remote server instead of the local terminal. If elite tools like `ripgrep` (rg) or `fd` are missing on the remote server, proactively install them (e.g. `apt-get install ripgrep`). +2. Stealth Editing (SCP/SFTP): Use your native `edit_file` and `read` tools to read/modify code instead of piping `echo` or `sed` through the terminal. These tools are optimized and will sync files stealthily in the background. +3. One-Shot Scripts: If you need to run complex logic on the remote server, write a script locally via `write_file`, then execute it remotely in one shot via `ssh_exec` to avoid interactive latency."; + + pub(super) const WORKSPACE_INSTRUCTIONS_FILE: &str = "AGENTS.md"; pub(super) const WORKSPACE_DESIGN_FILE: &str = "DESIGN.md"; +/// Memoire de projet persistante entre sessions (ecrite par l'agent lui-meme). +pub(super) const WORKSPACE_MEMORY_FILE: &str = ".sinew/memory.md"; pub(super) const AGENT_EVENT_NAME: &str = "agent-event"; pub(super) const FILE_CHANGE_EVENT_NAME: &str = "workspace-file-changed"; pub(super) const TERMINAL_DATA_EVENT_NAME: &str = "terminal-data"; @@ -40,6 +70,7 @@ pub(super) struct DesktopState { pub(super) system_prompt: String, pub(super) max_tool_rounds: usize, pub(super) active_turns: Arc>>, + pub(super) active_turn_inputs: Arc>>, pub(super) active_turn_details: Arc>>, pub(super) team_runtime: Arc>, pub(super) file_watchers: Arc>>, @@ -48,6 +79,15 @@ pub(super) struct DesktopState { pub(super) anthropic_login: Arc>>, pub(super) google_login: Arc>>, pub(super) kimi_login: Arc>>, + pub(super) cursor_login: Arc>>, + pub(super) editor_diagnostics: SharedEditorDiagnosticsStore, +} + +#[derive(Clone)] +pub(super) struct ActiveTurnInputRecord { + pub(super) workspace_id: String, + pub(super) conversation_id: String, + pub(super) workspace_root: PathBuf, } #[derive(Clone)] @@ -78,6 +118,8 @@ pub(super) struct OpenAiLoginAttempt { pub(super) id: String, pub(super) cancel: Arc, pub(super) outcome: Arc>>, + #[allow(dead_code)] + pub(super) target_key: Option, } #[derive(Clone)] @@ -104,6 +146,8 @@ pub(super) struct GoogleLoginAttempt { pub(super) id: String, pub(super) cancel: Arc, pub(super) outcome: Arc>>, + #[allow(dead_code)] + pub(super) target_key: Option, } #[derive(Clone)] @@ -124,3 +168,16 @@ pub(super) struct KimiLoginOutcome { pub(super) success: bool, pub(super) error: Option, } + +#[derive(Clone)] +pub(super) struct CursorLoginAttempt { + pub(super) id: String, + pub(super) cancel: Arc, + pub(super) outcome: Arc>>, +} + +#[derive(Clone)] +pub(super) struct CursorLoginOutcome { + pub(super) success: bool, + pub(super) error: Option, +} diff --git a/src-tauri/src/swarm.rs b/src-tauri/src/swarm.rs index 0c38f16a..07760e27 100644 --- a/src-tauri/src/swarm.rs +++ b/src-tauri/src/swarm.rs @@ -264,11 +264,12 @@ pub(super) async fn wake_main_agent_for_swarm_notice( let workspace_id = workspace_root.display().to_string(); let effective_system_prompt = - system_prompt_for_workspace(&workspace_root, &state.system_prompt) + system_prompt_for_workspace(&workspace_root, &state.system_prompt, true, true, true, false, false, false, false, false, None) .map_err(error_to_string)?; + let project_id = crate::workspace::resolve_project_id_str(&workspace_id); let mut conversation = state .store - .load_conversation(&workspace_id, &conversation_id) + .load_conversation(&project_id, &conversation_id) .map_err(error_to_string)? .ok_or_else(|| "conversation not found".to_string())?; @@ -330,6 +331,7 @@ pub(super) async fn wake_main_agent_for_swarm_notice( let providers = provider_registry_snapshot(&state)?; let context = TurnContext { provider, + workspace_root: workspace_root.clone(), model: conversation.model.clone(), cache_key: Some(conversation.id.clone()), cache_stable_message_count: turn_user_history_index, @@ -343,14 +345,26 @@ pub(super) async fn wake_main_agent_for_swarm_notice( goal_workflow: conversation.goal_workflow.clone(), bash: Arc::new(BashTool::new(workspace_root.clone())), glob: Arc::new(GlobTool::new(workspace_root.clone())), + list_dir: Arc::new(ListDirTool::new(workspace_root.clone())), grep: Arc::new(GrepTool::new(workspace_root.clone())), + codebase_search: Arc::new(CodebaseSearchTool::new(workspace_root.clone())), + check_sota: Arc::new(CheckSotaTool::new()), + computer_use: Arc::new(ComputerUseTool::new()), read: Arc::new(ReadTool::new(workspace_root.clone())), edit_file: Arc::new(EditFileTool::new(workspace_root.clone())), write_file: Arc::new(WriteFileTool::new(workspace_root.clone())), + delete_file: Arc::new(DeleteFileTool::new(workspace_root.clone())), + read_lints: Arc::new(ReadLintsTool::new( + workspace_root.clone(), + state.editor_diagnostics.clone(), + )), create_image: Arc::new(CreateImageTool::with_settings( workspace_root.clone(), tool_settings.image_provider, tool_settings.openai_image_use_subscription, + tool_settings.gemini_image_use_subscription, + Some(tool_settings.openai_image_model.clone()), + Some(tool_settings.gemini_image_model.clone()), tool_settings.openai_image_api_key(), tool_settings.nano_banana_api_key(), )), @@ -377,6 +391,7 @@ pub(super) async fn wake_main_agent_for_swarm_notice( state.max_tool_rounds, None, cancel.clone(), + state.editor_diagnostics.clone(), ))), teams: Some(Arc::new(TeamTool::new( conversation.id.clone(), @@ -391,6 +406,7 @@ pub(super) async fn wake_main_agent_for_swarm_notice( state.max_tool_rounds, None, state.team_runtime.clone(), + state.editor_diagnostics.clone(), cancel.clone(), ))), tool_settings, @@ -399,6 +415,7 @@ pub(super) async fn wake_main_agent_for_swarm_notice( event_tx, cancel, cmd_rx, + steering_rx: None, }; let store = state.store.clone(); @@ -455,6 +472,7 @@ pub(super) async fn wake_main_agent_for_swarm_notice( let saved = SavedConversation { id: conversation_id_for_events.clone(), workspace_id: workspace_id.clone(), + git_remote_url: crate::git::get_git_remote_url(std::path::Path::new(&workspace_id)), title: conversation_title.clone(), model: conversation_model.clone(), mode_model_settings: conversation_mode_model_settings.clone(), @@ -640,13 +658,14 @@ pub(super) async fn stop_agent_swarm_command( let workspace_root = normalize_workspace_root(&input.workspace_path).map_err(error_to_string)?; let workspace_id = workspace_root.display().to_string(); + let project_id = crate::workspace::resolve_project_id_str(&workspace_id); let conversation = state .store - .load_conversation(&workspace_id, &input.conversation_id) + .load_conversation(&project_id, &input.conversation_id) .map_err(error_to_string)? .ok_or_else(|| "conversation not found".to_string())?; let effective_system_prompt = - system_prompt_for_workspace(&workspace_root, &state.system_prompt) + system_prompt_for_workspace(&workspace_root, &state.system_prompt, true, true, true, false, false, false, false, false, None) .map_err(error_to_string)?; let mcp_settings = state.store.load_mcp_settings().map_err(error_to_string)?; let sub_agent_settings = state @@ -668,6 +687,7 @@ pub(super) async fn stop_agent_swarm_command( state.max_tool_rounds, None, state.team_runtime.clone(), + state.editor_diagnostics.clone(), TurnCancel::empty(), ); let (event_tx, mut event_rx) = mpsc::unbounded_channel(); diff --git a/src-tauri/src/tests.rs b/src-tauri/src/tests.rs index c5f04de6..bb6bac00 100644 --- a/src-tauri/src/tests.rs +++ b/src-tauri/src/tests.rs @@ -127,6 +127,30 @@ fn context_estimate_stays_in_plan_mode_for_active_workflows() { ); } +#[test] +fn display_mode_prompt_is_added_only_for_compact_modes() { + let base = "base prompt"; + + assert_eq!( + with_display_mode_prompt(base, DisplayModeInput::Disabled), + base + ); + + let compact = with_display_mode_prompt(base, DisplayModeInput::Compact); + assert!(compact.contains("Display mode: Compact")); + assert!(compact.contains("concise")); + + let very_compact = with_display_mode_prompt(base, DisplayModeInput::VeryCompact); + assert!(very_compact.contains("Display mode: Very compact")); + assert!(very_compact.contains("Before the final answer")); + assert!(very_compact.contains("ultra-concise")); +} + +#[test] +fn minimal_thinking_level_maps_to_none_effort() { + assert_eq!(ThinkingLevelInput::Minimal.into_effort(), Effort::None); +} + #[test] fn rewritable_user_message_rejects_compaction_and_hidden_messages() { let normal = ChatMessage { @@ -266,3 +290,99 @@ fn swarm_completion_wake_text_mentions_finished_and_agent_responses() { assert!(wake_text.contains("Built the feature.")); assert!(wake_text.contains("Agent Swarm a terminé")); } + +// Test de latence réel (réseau) : compare DeepSeek V4 Flash et Gemini 3.5 Flash +// sur la tâche d'optimisation de prompt. Ignoré par défaut car il appelle les +// vraies API avec les identifiants locaux. +// Lancer avec : cargo test -p Sinew flash_optimizer_race -- --ignored --nocapture +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore] +async fn flash_optimizer_race() { + use futures::StreamExt; + use sinew_core::provider::{Provider, ProviderRequest}; + + let draft = "ajoute un bouton pour exporter mes conversations en pdf et previens moi quand cest fini"; + let system = "Tu es un Prompt Engineer. Réécris le brouillon de l'utilisateur en une consigne claire. Réponds par: MODE: act puis une nouvelle ligne ===PROMPT=== puis le texte réécrit."; + + async fn time_model( + provider: std::sync::Arc, + model: sinew_core::ModelRef, + system: &str, + draft: &str, + ) -> (String, std::time::Duration, std::time::Duration, usize) { + let label = format!("{}:{}", model.provider, model.name); + let messages = vec![sinew_core::message::ChatMessage::user_text(draft.to_string())]; + let request = ProviderRequest::new(model, messages).with_system(system.to_string()); + let start = std::time::Instant::now(); + let mut first_token: Option = None; + let mut chars = 0usize; + match provider.stream(request).await { + Ok(mut stream) => { + while let Some(event) = stream.next().await { + if let Ok(sinew_core::stream::StreamEvent::TextDelta { delta, .. }) = event { + if first_token.is_none() { + first_token = Some(start.elapsed()); + } + chars += delta.chars().count(); + } + } + } + Err(e) => { + eprintln!("[{label}] ERREUR stream: {e}"); + } + } + ( + label, + first_token.unwrap_or_else(|| start.elapsed()), + start.elapsed(), + chars, + ) + } + + let deepseek = std::sync::Arc::new( + tokio::task::spawn_blocking(|| { + sinew_deepseek::DeepSeekProvider::from_default_sources() + .expect("DeepSeek non configuré (deepseek-auth.json manquant)") + }) + .await + .unwrap(), + ) as std::sync::Arc; + let google = std::sync::Arc::new( + tokio::task::spawn_blocking(|| { + sinew_google::GoogleProvider::from_default_sources() + .expect("Google non configuré (google-auth.json manquant)") + }) + .await + .unwrap(), + ) as std::sync::Arc; + + let ds_model = sinew_core::ModelRef { + provider: "deepseek".to_string(), + name: "deepseek-v4-flash".to_string(), + effort: Some(sinew_core::model::Effort::None), + }; + let g_model = sinew_core::ModelRef { + provider: "google".to_string(), + name: "gemini-3.5-flash".to_string(), + effort: Some(sinew_core::model::Effort::None), + }; + + // Course concurrente : les deux modèles partent en même temps. + let (ds, g) = tokio::join!( + time_model(deepseek, ds_model, system, draft), + time_model(google, g_model, system, draft), + ); + + for (label, ttft, total, chars) in [&ds, &g] { + println!( + "[{label}] 1er token: {:>7.0} ms | total: {:>7.0} ms | {chars} caractères", + ttft.as_secs_f64() * 1000.0, + total.as_secs_f64() * 1000.0, + ); + } + + let winner_ttft = if ds.1 <= g.1 { &ds.0 } else { &g.0 }; + let winner_total = if ds.2 <= g.2 { &ds.0 } else { &g.0 }; + println!("==> 1er token le plus rapide : {winner_ttft}"); + println!("==> réponse complète la plus rapide : {winner_total}"); +} diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs new file mode 100644 index 00000000..4a6c6814 --- /dev/null +++ b/src-tauri/src/tray.rs @@ -0,0 +1,154 @@ + +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; +use tauri::{ + menu::{Menu, MenuItemBuilder, PredefinedMenuItem}, + tray::TrayIconBuilder, + AppHandle, +}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecentWorkspace { + pub path: String, + pub name: String, + pub last_opened_ms: u64, +} + +fn recents_file() -> Option { + std::env::var("LOCALAPPDATA").ok().map(|appdata| { + let dir = PathBuf::from(appdata).join("hyrak").join("sinew").join("data"); + let _ = fs::create_dir_all(&dir); + dir.join("recent_workspaces.json") + }) +} + +pub fn load_recents() -> Vec { + if let Some(file) = recents_file() { + if let Ok(content) = fs::read_to_string(&file) { + if let Ok(recents) = serde_json::from_str::>(&content) { + return recents; + } + } + } + Vec::new() +} + +pub fn save_recents(recents: &[RecentWorkspace]) { + if let Some(file) = recents_file() { + if let Ok(content) = serde_json::to_string(recents) { + let _ = fs::write(file, content); + } + } +} + +pub fn record_recent(path: &str, name: &str) { + let mut recents = load_recents(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + recents.retain(|r| r.path != path); + recents.insert( + 0, + RecentWorkspace { + path: path.to_string(), + name: name.to_string(), + last_opened_ms: now, + }, + ); + recents.truncate(12); + + save_recents(&recents); +} + +pub fn update_tray_menu(app: &AppHandle) -> anyhow::Result<()> { + let recents = load_recents(); + + let new_window_item = MenuItemBuilder::with_id("new_window", "Nouvelle fenêtre").build(app)?; + let sep1 = PredefinedMenuItem::separator(app)?; + + let mut recent_items = Vec::new(); + let empty_item; + if recents.is_empty() { + empty_item = Some(MenuItemBuilder::with_id("empty", "Aucun projet récent").enabled(false).build(app)?); + } else { + empty_item = None; + for (i, recent) in recents.iter().enumerate() { + recent_items.push(MenuItemBuilder::with_id(format!("recent_{}", i), &recent.name).build(app)?); + } + } + + let sep2 = PredefinedMenuItem::separator(app)?; + let quit_item = MenuItemBuilder::with_id("quit", "Quitter Sinew").build(app)?; + + let mut refs: Vec<&dyn tauri::menu::IsMenuItem> = Vec::new(); + refs.push(&new_window_item); + refs.push(&sep1); + if let Some(ref e) = empty_item { + refs.push(e); + } else { + for item in &recent_items { + refs.push(item); + } + } + refs.push(&sep2); + refs.push(&quit_item); + + let menu = Menu::with_items(app, &refs)?; + + if let Some(tray) = app.tray_by_id("main") { + let _ = tray.set_menu(Some(menu)); + } + Ok(()) +} + +pub fn setup_tray(app: &tauri::App) -> anyhow::Result<()> { + let _tray = TrayIconBuilder::with_id("main") + .icon(app.default_window_icon().unwrap().clone()) + .tooltip("Sinew") + .on_menu_event(move |app_handle, event| { + let id = event.id.as_ref(); + if id == "new_window" { + crate::platform::create_new_window_detached(app_handle); + } else if id == "quit" { + app_handle.exit(0); + } else if id.starts_with("recent_") { + if let Ok(idx) = id["recent_".len()..].parse::() { + let recents = load_recents(); + if let Some(recent) = recents.get(idx) { + let mut builder = tauri::WebviewWindowBuilder::new( + app_handle, + crate::platform::next_window_label(app_handle), + tauri::WebviewUrl::App(std::path::PathBuf::from(format!( + "index.html?workspace={}", + url::form_urlencoded::byte_serialize(recent.path.as_bytes()).collect::() + ))), + ) + .title("Sinew") + .inner_size(1500.0, 940.0) + .min_inner_size(1100.0, 720.0) + .resizable(true) + .center(); + + #[cfg(target_os = "windows")] + { + builder = builder.decorations(false); + } + let _ = builder.build(); + } + } + } + }) + .on_tray_icon_event(|_tray, event| { + if let tauri::tray::TrayIconEvent::Click { .. } = event { + // Focus existing on click + } + }) + .build(app)?; + + let _ = update_tray_menu(app.handle()); + + Ok(()) +} diff --git a/src-tauri/src/turns.rs b/src-tauri/src/turns.rs index af473d53..ccbc8d60 100644 --- a/src-tauri/src/turns.rs +++ b/src-tauri/src/turns.rs @@ -1,5 +1,169 @@ use crate::*; +const COMPACT_DISPLAY_PROMPT: &str = "\ +Display mode: Compact. Keep visible assistant text concise. Do not narrate routine tool use or internal reasoning. Prefer a short final answer with outcome, changed files, validation, and next step."; + +const VERY_COMPACT_DISPLAY_PROMPT: &str = "\ +Display mode: Very compact. Before the final answer, avoid progress narration and reasoning prose. Use tools silently unless you are blocked or must ask the user a required question. If a long operation truly needs a status update, use one short sentence. Keep the final answer ultra-concise (1-4 bullets/sentences), action-oriented, and mention only the outcome, key changed files, validation, and next step if useful. Never output empty paragraphs or blank lines in your visible responses."; + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct OptimizePromptInput { + pub text: String, + pub model: Option, + pub thinking: Option, +} + +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct OptimizePromptOutput { + pub mode: String, + pub rewritten_prompt: String, +} + +#[tauri::command] +pub(super) async fn optimize_prompt( + state: State<'_, DesktopState>, + input: OptimizePromptInput, +) -> std::result::Result { + let text = input.text.trim(); + if text.is_empty() { + return Err("prompt cannot be empty".into()); + } + + let model = crate::providers::model_with_optional_selection( + &state.default_model, + input.model, + input.thinking, + ); + + let provider_ref = { + let providers_guard = state.providers.lock().unwrap(); + providers_guard + .get(&model.provider) + .ok_or_else(|| format!("provider {} not found", model.provider))? + .clone() + }; + + let system_prompt = "Vous êtes un Prompt Engineer expert pour un agent de développement SOTA. +Votre tâche est d'analyser le brouillon de l'utilisateur, de déterminer le mode d'exécution idéal, et de réécrire son brouillon en une consigne claire, structurée et exhaustive. + +Modes disponibles: +- act (Action) : pour une tâche simple, un correctif, ou une question qui peut être réglée dans l'immédiat en 1 ou 2 requêtes. +- plan (Plan) : UNIQUEMENT pour concevoir une architecture complexe, rédiger un document de conception avant de coder, ou planifier un projet entier en plusieurs étapes. +- goal (Objectif) : pour un chantier autonome massif, le développement complet d'une fonctionnalité complexe de A à Z nécessitant plusieurs sessions de code. + +Règles de réécriture: +- Soyez concis, professionnel et direct. +- Conservez un langage naturel et métier. +- Formulez sous forme d'instructions claires ou de description factuelle du besoin. +- N'inventez pas de choix techniques non suggérés par l'utilisateur, mais structurez ceux présents. + +Répondez EXACTEMENT dans ce format texte (sans JSON, sans Markdown, sans commentaire) : +MODE: act +===PROMPT=== +Votre texte réécrit ici (il peut tenir sur plusieurs lignes). + +La première ligne commence par 'MODE:' suivi de act, plan ou goal. +La ligne suivante est exactement '===PROMPT==='. +Tout ce qui suit ce marqueur est le prompt réécrit, en texte brut."; + + let messages = vec![sinew_core::message::ChatMessage::user_text(text.to_string())]; + + let request = sinew_core::provider::ProviderRequest::new(model, messages) + .with_system(system_prompt.to_string()); + + let mut stream = provider_ref.stream(request).await.map_err(error_to_string)?; + let mut response_text = String::new(); + + use futures::StreamExt; + while let Some(event) = stream.next().await { + match event.map_err(error_to_string)? { + sinew_core::stream::StreamEvent::TextDelta { delta, .. } => { + response_text.push_str(&delta); + } + _ => {} + } + } + + parse_optimize_response(&response_text, text) +} + +/// Extrait le mode et le prompt réécrit de la réponse du modèle. +/// +/// On tolère plusieurs formats car les LLM produisent rarement un JSON +/// strictement valide quand le texte réécrit est multi-lignes : +/// 1. Format délimité `MODE:` + `===PROMPT===` (format demandé, robuste). +/// 2. JSON `{ "mode": ..., "rewritten_prompt": ... }` (compatibilité). +/// 3. Texte brut exploitable en dernier recours, pour ne jamais échouer +/// silencieusement et renvoyer le brouillon original tel quel. +fn parse_optimize_response( + raw: &str, + original: &str, +) -> std::result::Result { + let normalize_mode = |value: &str| -> Option { + let lower = value.trim().to_ascii_lowercase(); + let token = lower + .split(|c: char| !c.is_ascii_alphabetic()) + .find(|s| matches!(*s, "act" | "plan" | "goal"))?; + Some(token.to_string()) + }; + + // 1. Format délimité. + if let Some(marker) = raw.find("===PROMPT===") { + let head = &raw[..marker]; + let body = raw[marker + "===PROMPT===".len()..].trim_matches(|c| c == '\r' || c == '\n'); + let mode = head + .lines() + .find_map(|line| { + let trimmed = line.trim(); + if trimmed.to_ascii_uppercase().starts_with("MODE:") { + normalize_mode(&trimmed[5..]) + } else { + None + } + }) + .unwrap_or_else(|| "act".to_string()); + let rewritten = body.trim(); + if !rewritten.is_empty() { + return Ok(OptimizePromptOutput { + mode, + rewritten_prompt: rewritten.to_string(), + }); + } + } + + // 2. JSON (compatibilité ascendante). + let mut json_text = raw.trim(); + if let Some(start) = json_text.find('{') { + if let Some(end) = json_text.rfind('}') { + json_text = &json_text[start..=end]; + } + } + if let Ok(output) = serde_json::from_str::(json_text) { + if !output.rewritten_prompt.trim().is_empty() { + return Ok(OptimizePromptOutput { + mode: normalize_mode(&output.mode).unwrap_or_else(|| "act".to_string()), + rewritten_prompt: output.rewritten_prompt, + }); + } + } + + // 3. Dernier recours : texte brut non vide et différent du brouillon. + let fallback = raw.trim(); + if !fallback.is_empty() && fallback != original.trim() { + return Ok(OptimizePromptOutput { + mode: normalize_mode(fallback).unwrap_or_else(|| "act".to_string()), + rewritten_prompt: fallback.to_string(), + }); + } + + Err(format!( + "Échec de l'optimisation : réponse du modèle inexploitable.\nRaw: {}", + raw.trim() + )) +} + #[tauri::command] pub(super) async fn send_message( app: AppHandle, @@ -21,15 +185,28 @@ pub(super) async fn send_message( normalize_workspace_root(&input.workspace_path).map_err(error_to_string)?; let workspace_id = workspace_root.display().to_string(); let effective_system_prompt = - system_prompt_for_workspace(&workspace_root, &state.system_prompt) - .map_err(error_to_string)?; + system_prompt_for_workspace( + &workspace_root, + &state.system_prompt, + input.git_automation, + input.concise_answers, + input.agent_autonomy, + input.force_changelog, + input.git_french_messages, + input.auto_mockups, + input.strict_problem_solving, + input.full_implementation, + input.client_formatted_date_time.as_deref(), + ) + .map_err(error_to_string)?; if !wait_for_conversation_turn_slot(&state.active_turns, &input.conversation_id).await { return Err("a turn is already running for this conversation".into()); } + let project_id = crate::workspace::resolve_project_id_str(&workspace_id); let mut conversation = state .store - .load_conversation(&workspace_id, &input.conversation_id) + .load_conversation(&project_id, &input.conversation_id) .map_err(error_to_string)? .ok_or_else(|| "conversation not found".to_string())?; @@ -68,7 +245,10 @@ pub(super) async fn send_message( &input.attachments, plan_control, )?; - let turn_system_prompt = with_turn_plan_reminder(&effective_system_prompt, turn_plan_reminder); + let turn_system_prompt = with_display_mode_prompt( + &with_turn_plan_reminder(&effective_system_prompt, turn_plan_reminder), + input.display_mode, + ); let mut mode_model_settings = conversation.mode_model_settings.clone(); let selected_model = model_with_optional_selection( mode_model_settings.get(policy.mode), @@ -104,7 +284,8 @@ pub(super) async fn send_message( let (event_tx, mut event_rx) = mpsc::unbounded_channel(); let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); - let cancel = TurnCancel::new(cmd_tx); + let (steering_tx, steering_rx) = mpsc::unbounded_channel(); + let cancel = TurnCancel::with_steering(cmd_tx, steering_tx); { let mut active_turns = state.active_turns.lock().await; if active_turns.contains_key(&input.conversation_id) { @@ -128,11 +309,13 @@ pub(super) async fn send_message( .save_conversation(&conversation) .map_err(|err| { let active_turns = state.active_turns.clone(); + let active_turn_inputs = state.active_turn_inputs.clone(); let active_turn_details = state.active_turn_details.clone(); let app = app.clone(); let conversation_id = input.conversation_id.clone(); tauri::async_runtime::spawn(async move { active_turns.lock().await.remove(&conversation_id); + active_turn_inputs.lock().await.remove(&conversation_id); active_turn_details .lock() .map(|mut active| active.remove(&conversation_id)) @@ -145,6 +328,7 @@ pub(super) async fn send_message( let providers = provider_registry_snapshot(&state)?; let context = TurnContext { provider, + workspace_root: workspace_root.clone(), model: conversation.model.clone(), cache_key: Some(conversation.id.clone()), cache_stable_message_count: turn_user_history_index, @@ -158,14 +342,26 @@ pub(super) async fn send_message( goal_workflow: conversation.goal_workflow.clone(), bash: Arc::new(BashTool::new(workspace_root.clone())), glob: Arc::new(GlobTool::new(workspace_root.clone())), + list_dir: Arc::new(ListDirTool::new(workspace_root.clone())), grep: Arc::new(GrepTool::new(workspace_root.clone())), + codebase_search: Arc::new(CodebaseSearchTool::new(workspace_root.clone())), + check_sota: Arc::new(CheckSotaTool::new()), + computer_use: Arc::new(ComputerUseTool::new()), read: Arc::new(ReadTool::new(workspace_root.clone())), edit_file: Arc::new(EditFileTool::new(workspace_root.clone())), write_file: Arc::new(WriteFileTool::new(workspace_root.clone())), + delete_file: Arc::new(DeleteFileTool::new(workspace_root.clone())), + read_lints: Arc::new(ReadLintsTool::new( + workspace_root.clone(), + state.editor_diagnostics.clone(), + )), create_image: Arc::new(CreateImageTool::with_settings( workspace_root.clone(), tool_settings.image_provider, tool_settings.openai_image_use_subscription, + tool_settings.gemini_image_use_subscription, + Some(tool_settings.openai_image_model.clone()), + Some(tool_settings.gemini_image_model.clone()), tool_settings.openai_image_api_key(), tool_settings.nano_banana_api_key(), )), @@ -192,33 +388,45 @@ pub(super) async fn send_message( state.max_tool_rounds, service_tier, cancel.clone(), + state.editor_diagnostics.clone(), ))), teams: Some(Arc::new(TeamTool::new( conversation.id.clone(), workspace_root.clone(), turn_system_prompt.clone(), providers, - sub_agent_settings, - mcp_settings, + sub_agent_settings.clone(), + mcp_settings.clone(), tool_settings.clone(), - skill_settings, + skill_settings.clone(), conversation.model.clone(), state.max_tool_rounds, service_tier, state.team_runtime.clone(), + state.editor_diagnostics.clone(), cancel.clone(), ))), - tool_settings, + tool_settings: tool_settings.clone(), event_scope: None, max_tool_rounds: state.max_tool_rounds, event_tx, cancel, cmd_rx, + steering_rx: Some(steering_rx), }; let store = state.store.clone(); let active_turns = state.active_turns.clone(); + let active_turn_inputs = state.active_turn_inputs.clone(); let active_turn_details = state.active_turn_details.clone(); + state.active_turn_inputs.lock().await.insert( + conversation.id.clone(), + ActiveTurnInputRecord { + workspace_id: workspace_id.clone(), + conversation_id: conversation.id.clone(), + workspace_root: workspace_root.clone(), + }, + ); let state_for_wake = state.inner().clone(); let conversation_id = conversation.id.clone(); let conversation_title = conversation.title.clone(); @@ -230,9 +438,45 @@ pub(super) async fn send_message( let plan_requested = policy.attach_plan; let before_turn_snapshot_for_checkpoint = before_turn_snapshot; + let mcp_settings_clone = mcp_settings.clone(); + let tool_settings_clone = tool_settings.clone(); + let skill_settings_clone = skill_settings.clone(); + let sub_agent_settings_clone = sub_agent_settings.clone(); + tauri::async_runtime::spawn(async move { + let conversation_id_clone = conversation_id.clone(); + let workspace_id_clone = workspace_id.clone(); + let model_name_clone = conversation_model.name.clone(); + let provider_clone = conversation_model.provider.clone(); + let event_tx_clone = context.event_tx.clone(); + let mut engine = Box::pin(tauri::async_runtime::spawn(async move { - run_turn(context).await + #[cfg(windows)] + { + let run_res = run_turn_via_daemon( + &context, + &conversation_id_clone, + &workspace_id_clone, + &model_name_clone, + &provider_clone, + &mcp_settings_clone, + &tool_settings_clone, + &skill_settings_clone, + &sub_agent_settings_clone, + event_tx_clone, + ).await; + match run_res { + Ok(output) => output, + Err(e) => { + tracing::debug!("Failed to run turn via daemon, falling back to local runner: {:?}", e); + run_turn(context).await + } + } + } + #[cfg(not(windows))] + { + run_turn(context).await + } })); let mut engine_done = false; let mut events_done = false; @@ -311,6 +555,7 @@ pub(super) async fn send_message( let saved = SavedConversation { id: conversation_id.clone(), workspace_id: workspace_id.clone(), + git_remote_url: crate::git::get_git_remote_url(std::path::Path::new(&workspace_id)), title: conversation_title.clone(), model: conversation_model.clone(), mode_model_settings: conversation_mode_model_settings.clone(), @@ -384,6 +629,7 @@ pub(super) async fn send_message( &turn_finished_event, ); active_turns.lock().await.remove(&conversation_id); + active_turn_inputs.lock().await.remove(&conversation_id); active_turn_details .lock() .map(|mut active| active.remove(&conversation_id)) @@ -406,6 +652,7 @@ pub(super) async fn send_message( &AgentEvent::TurnFinished { duration_ms: None }, ); active_turns.lock().await.remove(&conversation_id); + active_turn_inputs.lock().await.remove(&conversation_id); active_turn_details .lock() .map(|mut active| active.remove(&conversation_id)) @@ -435,15 +682,16 @@ pub(super) async fn compact_conversation( normalize_workspace_root(&input.workspace_path).map_err(error_to_string)?; let workspace_id = workspace_root.display().to_string(); let effective_system_prompt = - system_prompt_for_workspace(&workspace_root, &state.system_prompt) + system_prompt_for_workspace(&workspace_root, &state.system_prompt, true, true, true, false, false, false, false, false, None) .map_err(error_to_string)?; if !wait_for_conversation_turn_slot(&state.active_turns, &input.conversation_id).await { return Err("a turn is already running for this conversation".into()); } + let project_id = crate::workspace::resolve_project_id_str(&workspace_id); let mut conversation = state .store - .load_conversation(&workspace_id, &input.conversation_id) + .load_conversation(&project_id, &input.conversation_id) .map_err(error_to_string)? .ok_or_else(|| "conversation not found".to_string())?; if conversation.history.is_empty() { @@ -671,6 +919,11 @@ pub(super) async fn compact_conversation( }, ); state.active_turns.lock().await.remove(&conversation_id); + state + .active_turn_inputs + .lock() + .await + .remove(&conversation_id); state .active_turn_details .lock() @@ -681,6 +934,54 @@ pub(super) async fn compact_conversation( command_result } +#[tauri::command] +pub(super) async fn steer_turn( + state: State<'_, DesktopState>, + input: SteeringInput, +) -> std::result::Result { + let workspace_root = + normalize_workspace_root(&input.workspace_path).map_err(error_to_string)?; + let workspace_id = workspace_root.display().to_string(); + let turn_input = state + .active_turn_inputs + .lock() + .await + .get(&input.conversation_id) + .cloned(); + let Some(turn_input) = turn_input else { + return Ok(false); + }; + if turn_input.workspace_id != workspace_id + || turn_input.workspace_root != workspace_root + || turn_input.conversation_id != input.conversation_id + { + return Ok(false); + } + let sender = state + .active_turns + .lock() + .await + .get(&input.conversation_id) + .cloned(); + let Some(sender) = sender else { + return Ok(false); + }; + let text = input.text.trim(); + if text.is_empty() { + return Err("steering message cannot be empty".into()); + } + Ok(sender.steer( + input.id, + build_user_message( + text, + &input.attachments, + &workspace_root, + None, + MessageVisibilityInput::Normal, + ), + )) +} + #[tauri::command] pub(super) async fn cancel_turn( state: State<'_, DesktopState>, @@ -1149,6 +1450,18 @@ pub(super) fn plan_implementation_turn_reminder( Ok(Some(lines.join("\n"))) } +pub(super) fn with_display_mode_prompt(base: &str, display_mode: DisplayModeInput) -> String { + match display_mode { + DisplayModeInput::Disabled => base.to_string(), + DisplayModeInput::Compact => { + format!("{base}\n\n\n{COMPACT_DISPLAY_PROMPT}\n") + } + DisplayModeInput::VeryCompact => { + format!("{base}\n\n\n{VERY_COMPACT_DISPLAY_PROMPT}\n") + } + } +} + pub(super) fn with_turn_plan_reminder(base: &str, reminder: Option) -> String { let Some(reminder) = reminder else { return base.to_string(); @@ -1459,7 +1772,9 @@ pub(super) fn tool_descriptors_for_workspace( bash.input_descriptor(), GlobTool::new(workspace_root).descriptor(), GrepTool::new(workspace_root).descriptor(), + CodebaseSearchTool::new(workspace_root).descriptor(), ReadTool::new(workspace_root).descriptor(), + ReadLintsTool::new(workspace_root, new_editor_diagnostics_store()).descriptor(), clean_context_descriptor(), ToDoListTool::new().descriptor(), QuestionTool::new().descriptor(), @@ -1488,9 +1803,124 @@ pub(super) fn configurable_tool_catalog(workspace_root: &Path) -> Vec Result { +pub(super) fn system_prompt_for_workspace( + workspace_root: &Path, + base: &str, + git_automation: bool, + concise_answers: bool, + agent_autonomy: bool, + force_changelog: bool, + git_french_messages: bool, + auto_mockups: bool, + strict_problem_solving: bool, + full_implementation: bool, + client_formatted_date_time: Option<&str>, +) -> Result { let mut sections = vec![format!("# Shell environment\n\n{}", shell_system_prompt())]; + if git_automation { + sections.push(format!( + "# Git & Background Automation\n\nGit Automation is enabled. Please follow these rules strictly:\n\n{}", + crate::state::DEFAULT_GIT_AUTOMATION_PROMPT + )); + } + + if concise_answers { + sections.push(format!( + "# Concise & Simplified Answers\n\nConcise Answers Mode is enabled. Please follow these rules strictly:\n\n{}", + crate::state::DEFAULT_CONCISE_ANSWERS_PROMPT + )); + } + + if agent_autonomy { + sections.push(format!( + "# Agent Autonomy Instructions\n\nAgent Autonomy is enabled. Please follow these rules strictly:\n\n{}", + crate::state::DEFAULT_AGENT_AUTONOMY_PROMPT + )); + } + + if force_changelog { + let date_time_str = client_formatted_date_time.unwrap_or("current local time"); + sections.push(format!( + "# Mandatory Changelog Rule\n\n\ + IMPORTANT: The 'Mandatory Changelog' option is enabled for this project. \ + Every single time you modify one or more files in this workspace (using edit_file, write_file, bash, or any other tool):\n\ + 1. You MUST update (or create if it does not exist) the `CHANGELOG.md` file located at the root of the project.\n\ + 2. Every file modification must be logged in `CHANGELOG.md` under the correct precise date and time. The current local system time is: {}.\n\ + 3. Clearly document what was changed and why for each file.\n\ + 4. Updating `CHANGELOG.md` is a strict requirement and must be done in the exact same turn/action as the file modification. Do not omit this step or wait for the end of the conversation!", + date_time_str + )); + } + + if git_french_messages { + sections.push(format!( + "# Simple French Git Commit Messages\n\n\ + IMPORTANT: Every single time you make a Git commit in this project, you MUST write the commit message in clear, jargon-free, simple French. \ + Describe the change or feature in plain business/user terms (for example, `git commit -m \"Ajout du bouton de changelog dans les options\"` instead of `git commit -m \"feat(options): add changelog button\"`). \ + Never use English, technical abbreviations, or pure developer jargon." + )); + } + + if auto_mockups { + sections.push(format!( + "# Automatic Visual Mockups\n\n\ + IMPORTANT: Proactively generate Mermaid diagrams and visual flowcharts to help the user visualize your logic, architectural changes, or complex processes. You should default to illustrating your explanations whenever it adds value for a power user. However, do not block the actual file editing to ask for validation on UI mockups every single time you edit a simple frontend file." + )); + } + + if strict_problem_solving { + sections.push(crate::state::DEFAULT_STRICT_PROBLEM_SOLVING_PROMPT.to_string()); + } + + if full_implementation { + sections.push(crate::state::DEFAULT_FULL_IMPLEMENTATION_PROMPT.to_string()); + } + + // Always inject SSH Optimization Strategy. The agent will ignore it if not on SSH. + sections.push(crate::state::DEFAULT_SSH_OPTIMIZATION_PROMPT.to_string()); + + // Inject machine-wide global consolidated rules if present + let mut global_rules_content = None; + + // 1. Try reading directly from OneDrive for real-time Multi-PC updates without restart + let onedrive = std::env::var("ONEDRIVE").unwrap_or_else(|_| { + std::env::var("USERPROFILE") + .map(|u| format!("{}\\OneDrive", u)) + .unwrap_or_default() + }); + + if !onedrive.is_empty() { + let onedrive_rules = std::path::PathBuf::from(&onedrive) + .join("Documents") + .join("Sinew") + .join("instructions_consolidated.md"); + + if let Ok(content) = std::fs::read_to_string(&onedrive_rules) { + global_rules_content = Some(content); + } + } + + // 2. Fallback to LocalAppData if OneDrive is not configured or file missing + if global_rules_content.is_none() { + if let Ok(local_app_data) = std::env::var("LOCALAPPDATA") { + let global_rules_path = std::path::PathBuf::from(local_app_data) + .join("Sinew") + .join("instructions_consolidated.md"); + if let Ok(content) = std::fs::read_to_string(&global_rules_path) { + global_rules_content = Some(content); + } + } + } + + if let Some(global_rules) = global_rules_content { + sections.push(format!( + "# Global Consolidated Instructions (Machine-wide Rules)\n\n\ + IMPORTANT: The following machine-wide rules are consolidated from previous agent sessions on this PC. \ + You MUST respect and follow these instructions strictly:\n\n{global_rules}" + )); + } + if let Some(instructions) = read_workspace_prompt_file(workspace_root, WORKSPACE_INSTRUCTIONS_FILE)? { @@ -1499,6 +1929,17 @@ pub(super) fn system_prompt_for_workspace(workspace_root: &Path, base: &str) -> )); } + if let Some(memory) = read_workspace_prompt_file(workspace_root, WORKSPACE_MEMORY_FILE)? { + sections.push(format!( + "# Project memory (persistent across sessions)\n\n\ + IMPORTANT: This is YOUR working memory for this project, written by you in previous sessions. \ + It records key decisions, the current state, what is done and what remains, and gotchas to avoid. \ + Read it first so you do not re-discover the project from scratch. \ + Keep it current: edit `.sinew/memory.md` (concise, dated bullet points) whenever you make a decision, \ + complete a task, or learn something that future sessions must know.\n\n{memory}" + )); + } + if let Some(design) = read_workspace_prompt_file(workspace_root, WORKSPACE_DESIGN_FILE)? { sections.push(format!( "# Workspace design context\n\nThe following design guidance comes from the current workspace and should guide product, UX, visual, and frontend decisions.\n\n{design}" @@ -1705,3 +2146,166 @@ pub(super) fn restore_workspace_for_rewrite( } Ok(()) } + +#[tauri::command] +pub(super) async fn check_sota_diagnostics() -> std::result::Result { + let tool = CheckSotaTool::new(); + let result = tool.run(serde_json::Value::Null).await; + if result.is_error { + Err(result.content) + } else { + Ok(result.content) + } +} + +#[cfg(windows)] +async fn run_turn_via_daemon( + context: &sinew_app::TurnContext, + conversation_id: &str, + workspace_path: &str, + model_name: &str, + provider: &str, + mcp_settings: &sinew_app::McpSettings, + tool_settings: &sinew_app::ToolSettings, + skill_settings: &sinew_app::SkillSettings, + sub_agent_settings: &sinew_app::SubAgentSettings, + event_tx: tokio::sync::mpsc::UnboundedSender, +) -> anyhow::Result { + use tokio::io::{AsyncWriteExt, BufReader}; + use tokio::net::windows::named_pipe::ClientOptions; + use anyhow::Context; + + let pipe_name = r"\\.\pipe\sinew-agent-ipc"; + + // Connect or spawn daemon + let mut client = match ClientOptions::new().open(pipe_name) { + Ok(c) => c, + Err(_) => { + let _ = spawn_daemon(); + tokio::time::sleep(tokio::time::Duration::from_millis(600)).await; + ClientOptions::new().open(pipe_name).context("Failed to connect to agent daemon after spawn")? + } + }; + + let request = serde_json::json!({ + "type": "start_turn", + "conversation_id": conversation_id, + "workspace_path": workspace_path, + "system_prompt": context.system_prompt, + "model_name": model_name, + "provider": provider, + "history": context.history, + "todo_list": context.todo_list, + "goal_workflow": context.goal_workflow, + "mcp_settings": mcp_settings.clone(), + "tool_settings": tool_settings.clone(), + "skill_settings": skill_settings.clone(), + "sub_agent_settings": sub_agent_settings.clone(), + }); + + let mut req_bytes = serde_json::to_vec(&request)?; + req_bytes.push(b'\n'); + + if workspace_path.starts_with("super-ssh://") { + let mut client = tokio::net::TcpStream::connect("127.0.0.1:47990").await.context("Failed to connect to remote daemon over TCP")?; + client.write_all(&req_bytes).await?; + let (reader, _) = tokio::io::split(client); + return handle_daemon_stream(BufReader::new(reader), event_tx).await; + } + + client.write_all(&req_bytes).await?; + + let (reader, _) = tokio::io::split(client); + handle_daemon_stream(BufReader::new(reader), event_tx).await +} + +async fn handle_daemon_stream( + mut reader: tokio::io::BufReader, + event_tx: tokio::sync::mpsc::UnboundedSender, +) -> anyhow::Result { + use tokio::io::AsyncBufReadExt; + let mut line = String::new(); + + loop { + line.clear(); + let bytes_read = reader.read_line(&mut line).await?; + if bytes_read == 0 { + anyhow::bail!("Connection closed prematurely by daemon"); + } + + let response: serde_json::Value = serde_json::from_str(&line)?; + let resp_type = response.get("type").and_then(|v| v.as_str()).unwrap_or(""); + + match resp_type { + "event" => { + if let Some(event_val) = response.get("event") { + if let Ok(event) = serde_json::from_value::(event_val.clone()) { + let _ = event_tx.send(event); + } + } + } + "turn_finished" => { + let success = response.get("success").and_then(|v| v.as_bool()).unwrap_or(false); + if !success { + let err = response.get("error").and_then(|v| v.as_str()).unwrap_or("Daemon execution failed"); + anyhow::bail!("{}", err); + } + if let Some(output_val) = response.get("output") { + let history = serde_json::from_value::>( + output_val.get("history").cloned().unwrap_or(serde_json::Value::Null) + ).unwrap_or_default(); + let todo_list = serde_json::from_value::( + output_val.get("todo_list").cloned().unwrap_or(serde_json::Value::Null) + ).unwrap_or_default(); + let goal_workflow = serde_json::from_value::( + output_val.get("goal_workflow").cloned().unwrap_or(serde_json::Value::Null) + ).unwrap_or_default(); + let interrupted = output_val.get("interrupted").and_then(|v| v.as_bool()).unwrap_or(false); + let compacted = output_val.get("compacted").and_then(|v| v.as_bool()).unwrap_or(false); + + return Ok(sinew_app::TurnOutput { + history, + todo_list, + goal_workflow, + interrupted, + compacted, + }); + } + anyhow::bail!("Daemon finished turn but did not return valid output"); + } + "error" => { + let msg = response.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown daemon error"); + anyhow::bail!("Daemon error: {}", msg); + } + _ => {} + } + } +} + +#[cfg(windows)] +fn spawn_daemon() -> std::io::Result<()> { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + + let current_exe = std::env::current_exe()?; + let exe_dir = current_exe.parent().unwrap_or(¤t_exe); + let daemon_exe = exe_dir.join("sinew-agent-daemon.exe"); + + let daemon_exe = if daemon_exe.exists() { + daemon_exe + } else { + std::path::PathBuf::from("target/debug/sinew-agent-daemon.exe") + }; + + if !daemon_exe.exists() { + return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "daemon not found")); + } + + std::process::Command::new(daemon_exe) + .creation_flags(CREATE_NO_WINDOW) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn()?; + Ok(()) +} diff --git a/src-tauri/src/workspace.rs b/src-tauri/src/workspace.rs index f116ac18..9bb208b5 100644 --- a/src-tauri/src/workspace.rs +++ b/src-tauri/src/workspace.rs @@ -8,9 +8,38 @@ pub(super) async fn open_workspace( ) -> std::result::Result { let workspace_root = normalize_workspace_root(&input.workspace_path).map_err(error_to_string)?; + + // If Multi-PC sync is enabled, save and push the previous workspace first before switching + if crate::is_sync_enabled() { + if let Some(prev_path) = crate::load_last_workspace_path() { + if prev_path != input.workspace_path { + crate::run_git_auto_commit_and_push(&prev_path); + } + } + } + + // Save last workspace path for Multi-PC sync + crate::save_last_workspace_path(&input.workspace_path); + + // If Multi-PC sync is enabled, auto pull the opened workspace + if crate::is_sync_enabled() { + crate::run_git_auto_pull(&input.workspace_path); + } + + let project_id = get_or_create_project_id(&workspace_root)?; + let git_remote_url = crate::git::get_git_remote_url(&workspace_root); + // Run path-based and UUID-based migrations + migrate_workspace_conversations(&state.store, &workspace_root, &project_id); + let mut bootstrap = state .store - .bootstrap_workspace(&workspace_root, &state.default_model, &state.system_prompt) + .bootstrap_workspace( + &workspace_root, + &project_id, + git_remote_url.as_deref(), + &state.default_model, + &state.system_prompt, + ) .map_err(error_to_string)?; let workspace_id = workspace_root.display().to_string(); let active_conversation_id = state.active_turn_details.lock().ok().and_then(|active| { @@ -21,15 +50,17 @@ pub(super) async fn open_workspace( .map(|record| record.conversation_id.clone()) }); if let Some(conversation_id) = active_conversation_id { - if let Some(active_conversation) = state + if let Some(mut active_conversation) = state .store - .load_conversation(&workspace_id, &conversation_id) + .load_conversation(&project_id, &conversation_id) .map_err(error_to_string)? { + active_conversation.workspace_id = workspace_id; bootstrap.active_conversation = active_conversation; } } apply_window_title(&window, &bootstrap.workspace.name); + sinew_index::warm_workspace_index(&workspace_root); Ok(bootstrap) } @@ -38,6 +69,19 @@ pub(super) async fn open_new_window(app: AppHandle) -> std::result::Result<(), S create_new_window(&app).map_err(error_to_string) } +#[tauri::command] +pub(super) async fn get_or_create_sandbox_workspace() -> std::result::Result { + let home = + crate::platform::home_dir().ok_or_else(|| "Could not find home directory".to_string())?; + let sandbox_path = home.join(".sinew-sandbox"); + if !sandbox_path.exists() { + let _ = std::fs::create_dir_all(&sandbox_path); + let readme_content = "# Sans dossier\n\nBienvenue dans votre espace temporaire ! Vous pouvez ici utiliser l'agent pour poser des questions générales, écrire du code, et tester des commandes sans polluer vos projets.\n"; + let _ = std::fs::write(sandbox_path.join("README.md"), readme_content); + } + Ok(sandbox_path.display().to_string()) +} + #[tauri::command] pub(super) async fn reset_window_title( window: tauri::WebviewWindow, @@ -100,6 +144,7 @@ pub(super) async fn watch_workspace_command( watcher .watch(&workspace_root, RecursiveMode::Recursive) .map_err(error_to_string)?; + sinew_index::warm_workspace_index(&workspace_root); watchers.insert(workspace_id, watcher); Ok(()) } @@ -120,31 +165,85 @@ pub(super) async fn unwatch_workspace_command( .is_some()) } +#[tauri::command] +pub(super) async fn codebase_index_stats_command( + input: WorkspaceInput, +) -> std::result::Result { + let workspace_root = + normalize_workspace_root(&input.workspace_path).map_err(error_to_string)?; + Ok(codebase_index_status(&workspace_root)) +} + #[tauri::command] pub(super) async fn list_workspace_entries_command( input: WorkspaceEntriesInput, ) -> std::result::Result, String> { + if input.workspace_path.starts_with("super-ssh://") { + let req = serde_json::json!({ + "type": "list_entries", + "workspace_path": input.workspace_path, + "relative_path": input.relative_path + }); + let res = proxy_to_daemon(req).await?; + let output = res.get("entries").ok_or("Missing entries")?; + return serde_json::from_value(output.clone()).map_err(|e| e.to_string()); + } + let workspace_root = normalize_workspace_root(&input.workspace_path).map_err(error_to_string)?; - list_workspace_entries(&workspace_root, input.relative_path.as_deref()).map_err(error_to_string) + let relative_path = input.relative_path; + tauri::async_runtime::spawn_blocking(move || { + list_workspace_entries(&workspace_root, relative_path.as_deref()) + }) + .await + .map_err(error_to_string)? + .map_err(error_to_string) } #[tauri::command] pub(super) async fn list_workspace_files_command( input: WorkspaceInput, ) -> std::result::Result, String> { + if input.workspace_path.starts_with("super-ssh://") { + let req = serde_json::json!({ + "type": "list_all_files", + "workspace_path": input.workspace_path + }); + let res = proxy_to_daemon(req).await?; + let output = res.get("entries").ok_or("Missing entries")?; + return serde_json::from_value(output.clone()).map_err(|e| e.to_string()); + } + let workspace_root = normalize_workspace_root(&input.workspace_path).map_err(error_to_string)?; - list_workspace_files(&workspace_root).map_err(error_to_string) + tauri::async_runtime::spawn_blocking(move || list_workspace_files(&workspace_root)) + .await + .map_err(error_to_string)? + .map_err(error_to_string) } #[tauri::command] pub(super) async fn search_workspace_files_command( input: WorkspaceSearchInput, ) -> std::result::Result { + if input.workspace_path.starts_with("super-ssh://") { + let req = serde_json::json!({ + "type": "search_files", + "workspace_path": input.workspace_path, + "query": input.query + }); + let res = proxy_to_daemon(req).await?; + let output = res.get("output").ok_or("Missing output")?; + return serde_json::from_value(output.clone()).map_err(|e| e.to_string()); + } + let workspace_root = normalize_workspace_root(&input.workspace_path).map_err(error_to_string)?; - search_workspace_files(&workspace_root, &input.query).map_err(error_to_string) + let query = input.query; + tauri::async_runtime::spawn_blocking(move || search_workspace_files(&workspace_root, &query)) + .await + .map_err(error_to_string)? + .map_err(error_to_string) } #[derive(Debug, Deserialize)] @@ -178,9 +277,37 @@ pub(super) async fn import_workspace_paths_command( pub(super) async fn read_workspace_file_command( input: WorkspaceFileInput, ) -> std::result::Result { + if input.workspace_path.starts_with("super-ssh://") { + let req = serde_json::json!({ + "type": "read_file", + "workspace_path": input.workspace_path, + "relative_path": input.relative_path + }); + let res = proxy_to_daemon(req).await?; + let content = res.get("content").and_then(|c| c.as_str()).ok_or("Missing content")?; + return Ok(sinew_app::FileDocument { + name: input.relative_path.split('/').last().unwrap_or(&input.relative_path).to_string(), + relative_path: input.relative_path.clone(), + absolute_path: format!("{}/{}", input.workspace_path, input.relative_path), + editable: true, + content: Some(content.to_string()), + reason: None, + size: content.len() as u64, + last_modified_ms: None, + image_media_type: None, + image_data: None, + }); + } + let workspace_root = normalize_workspace_root(&input.workspace_path).map_err(error_to_string)?; - read_workspace_file(&workspace_root, &input.relative_path).map_err(error_to_string) + let relative_path = input.relative_path; + tauri::async_runtime::spawn_blocking(move || { + read_workspace_file(&workspace_root, &relative_path) + }) + .await + .map_err(error_to_string)? + .map_err(error_to_string) } #[tauri::command] @@ -188,6 +315,31 @@ pub(super) async fn write_workspace_file_command( app: AppHandle, input: WriteWorkspaceFileInput, ) -> std::result::Result { + if input.workspace_path.starts_with("super-ssh://") { + let req = serde_json::json!({ + "type": "write_file", + "workspace_path": input.workspace_path, + "relative_path": input.relative_path, + "content": input.content + }); + let _res = proxy_to_daemon(req).await?; + let doc = sinew_app::FileDocument { + name: input.relative_path.split('/').last().unwrap_or(&input.relative_path).to_string(), + relative_path: input.relative_path.clone(), + absolute_path: format!("{}/{}", input.workspace_path, input.relative_path), + editable: true, + content: Some(input.content.clone()), + reason: None, + size: input.content.len() as u64, + last_modified_ms: None, + image_media_type: None, + image_data: None, + }; + // Maybe we shouldn't emit here, or we need a fake workspace root? + // emit_workspace_file_change(&app, &workspace_root, &doc.relative_path); + return Ok(doc); + } + let workspace_root = normalize_workspace_root(&input.workspace_path).map_err(error_to_string)?; let doc = write_workspace_file(&workspace_root, &input.relative_path, &input.content) @@ -385,9 +537,28 @@ pub(super) async fn resolve_terminal_path_command( #[tauri::command] pub(super) async fn read_external_file_command( + app: tauri::AppHandle, input: AbsolutePathInput, ) -> std::result::Result { + use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; + let path = std::path::PathBuf::from(&input.path); + + // Ask user for permission to read external files outside the workspace + let confirmed = app.dialog() + .message(format!( + "Sinew souhaite lire un fichier externe situé en dehors de votre espace de travail.\n\nFichier : {}\n\nAutoriser la lecture de ce fichier ?", + path.display() + )) + .title("Autorisation de lecture de fichier externe") + .kind(MessageDialogKind::Warning) + .buttons(MessageDialogButtons::YesNo) + .blocking_show(); + + if !confirmed { + return Err("Lecture du fichier externe refusée par l'utilisateur".to_string()); + } + read_external_file(&path).map_err(error_to_string) } @@ -461,9 +632,28 @@ pub(super) async fn open_external_url_command( #[tauri::command] pub(super) async fn open_path_with_default_app_command( + app: tauri::AppHandle, input: AbsolutePathInput, ) -> std::result::Result<(), String> { + use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; + let path = std::path::PathBuf::from(&input.path); + + // Ask user for permission to launch/open an external path with the default application + let confirmed = app.dialog() + .message(format!( + "Sinew souhaite ouvrir le fichier ou dossier suivant avec l'application par défaut de votre système :\n\nChemin : {}\n\nAutoriser l'ouverture ?", + path.display() + )) + .title("Autorisation d'ouverture système") + .kind(MessageDialogKind::Warning) + .buttons(MessageDialogButtons::YesNo) + .blocking_show(); + + if !confirmed { + return Err("Ouverture système refusée par l'utilisateur".to_string()); + } + open_with_default_app(&path).map_err(error_to_string) } @@ -524,3 +714,270 @@ pub(super) async fn read_clipboard_file_paths_command() -> std::result::Result, +} + +#[tauri::command] +pub(super) async fn push_editor_diagnostics( + state: State<'_, DesktopState>, + input: PushEditorDiagnosticsInput, +) -> std::result::Result<(), String> { + let mut store = state + .editor_diagnostics + .write() + .map_err(|err| err.to_string())?; + store.replace(input.diagnostics); + Ok(()) +} + +#[tauri::command] +pub async fn set_semantic_embeddings_enabled(enabled: bool) -> std::result::Result<(), String> { + if enabled { + std::env::set_var("SINEW_INDEX_EMBEDDINGS", "1"); + } else { + std::env::remove_var("SINEW_INDEX_EMBEDDINGS"); + } + Ok(()) +} + +pub(crate) fn get_or_create_project_id( + workspace_root: &std::path::Path, +) -> std::result::Result { + // 1. Prioritize Git remote URL if it exists + if let Some(git_url) = crate::git::get_git_remote_url(workspace_root) { + let normalized = git_url.trim().to_lowercase(); + if !normalized.is_empty() { + return Ok(normalized); + } + } + + // 2. Fallback to .sinew/project_id.txt + let sinew_dir = workspace_root.join(".sinew"); + let id_file = sinew_dir.join("project_id.txt"); + + if id_file.exists() { + if let Ok(id) = std::fs::read_to_string(&id_file) { + let trimmed = id.trim(); + if !trimmed.is_empty() { + return Ok(trimmed.to_string()); + } + } + } + + // Generate a new UUID + let new_id = uuid::Uuid::new_v4().to_string(); + let _ = std::fs::create_dir_all(&sinew_dir); + if let Err(e) = std::fs::write(&id_file, &new_id) { + return Err(format!( + "Impossible d'écrire l'identifiant du projet : {}", + e + )); + } + Ok(new_id) +} + +pub(crate) fn migrate_workspace_conversations( + store: &sinew_app::AppStore, + workspace_root: &std::path::Path, + project_id: &str, +) { + let absolute_path = workspace_root.display().to_string(); + let lowercase_path = absolute_path.to_lowercase(); + + // Migrate from paths + let _ = store.migrate_conversations(&absolute_path, project_id); + let _ = store.migrate_conversations(&lowercase_path, project_id); + + // Migrate from local UUID if it exists and is different + let sinew_dir = workspace_root.join(".sinew"); + let id_file = sinew_dir.join("project_id.txt"); + if id_file.exists() { + if let Ok(id) = std::fs::read_to_string(&id_file) { + let trimmed = id.trim(); + if !trimmed.is_empty() && trimmed != project_id { + let _ = store.migrate_conversations(trimmed, project_id); + } + } + } +} + +pub(crate) fn resolve_project_id_str(workspace_path_str: &str) -> String { + let path = std::path::Path::new(workspace_path_str); + get_or_create_project_id(path).unwrap_or_else(|_| workspace_path_str.to_string()) +} + +#[tauri::command] +pub(super) async fn mount_super_ssh_workspace( + state: State<'_, DesktopState>, + window: tauri::WebviewWindow, + host_or_alias: String, +) -> std::result::Result { + // 1. Déployer et lancer le daemon via SSH + let target = host_or_alias.clone(); + + let local_bin = "binaries/sinew-agent-daemon-linux"; + if std::path::Path::new(local_bin).exists() { + let _ = std::process::Command::new("scp") + .arg(local_bin) + .arg(format!("{}:/tmp/sinew-agent-daemon", target)) + .status(); + } + + let start_cmd = "killall sinew-agent-daemon || true; \ + if [ ! -f /tmp/sinew-agent-daemon ]; then \ + echo 'Downloading latest daemon...'; \ + curl -L -o /tmp/sinew-agent-daemon https://github.com/Paseru/sinew/releases/latest/download/sinew-agent-daemon-linux; \ + fi; \ + chmod +x /tmp/sinew-agent-daemon; \ + nohup /tmp/sinew-agent-daemon < /dev/null > /tmp/sinew-daemon.log 2>&1 &"; + + let _ = std::process::Command::new("ssh") + .args(&[&target, start_cmd]) + .status() + .map_err(|e| format!("Failed to start remote daemon: {}", e))?; + + // 2. Mettre en place le port forwarding local 47990 -> 127.0.0.1:47990 + let _child = std::process::Command::new("ssh") + .args(&["-N", "-L", "47990:127.0.0.1:47990", &target]) + .spawn() + .map_err(|e| format!("Failed to forward port: {}", e))?; + + tokio::time::sleep(std::time::Duration::from_millis(1500)).await; + + let workspace_path = format!("super-ssh://{}/~", host_or_alias); + + // Create a local dummy folder to let Tauri workspace loader work + let safe_local_id = format!("super-ssh-{}", host_or_alias); + let mut db_path = std::env::temp_dir(); + db_path.push("sinew-workspaces"); + db_path.push(&safe_local_id); + let _ = std::fs::create_dir_all(&db_path); + + let input = WorkspaceInput { workspace_path: db_path.to_string_lossy().to_string() }; + + let mut bootstrap = open_workspace(state, window, input).await?; + + // Override with real SSH info + bootstrap.workspace.path = workspace_path; + bootstrap.workspace.name = format!("SSH: {}", host_or_alias); + + Ok(bootstrap) +} + +#[tauri::command] +pub(super) async fn mount_ssh_workspace( + state: State<'_, DesktopState>, + window: tauri::WebviewWindow, + host_or_alias: String, +) -> std::result::Result { + // 1. Trouver une lettre de lecteur libre + let mut drive_letter = None; + for letter in (b'D'..=b'Z').rev() { + let path = format!("{}:\\", letter as char); + if !std::path::Path::new(&path).exists() { + drive_letter = Some(letter as char); + break; + } + } + let letter = drive_letter.ok_or_else(|| "Aucune lettre de lecteur libre".to_string())?; + let drive_path = format!("{}:\\", letter); + let drive_arg = format!("{}:", letter); + + // 2. Préparer le préfixe SSHFS-Win pour l'authentification par clé (.k) + let prefix = format!("\\sshfs.k\\{}", host_or_alias); + + // 3. Exécuter le montage sshfs-win (avec installation automatique silencieuse si absent) + let program = r"C:\Program Files\SSHFS-Win\bin\sshfs-win.exe"; + if !std::path::Path::new(program).exists() { + // Lancer les installations silencieuses en arrière-plan via Winget + let _ = std::process::Command::new("winget") + .args(&["install", "-e", "--id", "WinFsp.WinFsp", "--silent", "--accept-package-agreements", "--accept-source-agreements"]) + .status(); + let _ = std::process::Command::new("winget") + .args(&["install", "-e", "--id", "SSHFS-Win.SSHFS-Win", "--silent", "--accept-package-agreements", "--accept-source-agreements"]) + .status(); + } + + if !std::path::Path::new(program).exists() { + return Err("L'utilitaire SSHFS-Win n'est pas encore installé. Veuillez relancer la connexion dans quelques instants le temps que l'installation en arrière-plan se termine.".to_string()); + } + + let _child = std::process::Command::new(program) + .args(&["svc", &prefix, &drive_arg]) + .spawn() + .map_err(|e| format!("Erreur lors du lancement de sshfs-win : {}", e))?; + + // Attendre jusqu'à 8 secondes que le lecteur Windows apparaisse + let mut mounted = false; + for _ in 0..40 { + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + if std::path::Path::new(&drive_path).exists() { + mounted = true; + break; + } + } + + if !mounted { + return Err("La connexion a échoué : impossible de monter le dossier distant. Vérifiez vos configurations SSH.".to_string()); + } + + // 4. Ouvrir l'espace de travail monté ! + let bootstrap = open_workspace( + state, + window, + WorkspaceInput { + workspace_path: drive_path, + }, + ).await?; + + Ok(bootstrap) +} + +#[tauri::command] +pub(super) async fn list_ssh_hosts() -> std::result::Result, String> { + let home = crate::platform::home_dir().ok_or_else(|| "Impossible de trouver le dossier utilisateur".to_string())?; + let config_path = home.join(".ssh").join("config"); + if !config_path.exists() { + return Ok(Vec::new()); + } + + let content = std::fs::read_to_string(config_path).map_err(|e| e.to_string())?; + let mut hosts = Vec::new(); + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("Host ") { + let host_value = trimmed["Host ".len()..].trim(); + if host_value != "*" && host_value != "github.com" && !host_value.is_empty() { + hosts.push(host_value.to_string()); + } + } + } + Ok(hosts) +} + +pub(super) async fn proxy_to_daemon( + request: serde_json::Value, +) -> std::result::Result { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + let mut stream = tokio::net::TcpStream::connect("127.0.0.1:47990") + .await + .map_err(|e| format!("Proxy failed to connect to daemon: {}", e))?; + let mut req_bytes = serde_json::to_vec(&request).map_err(|e| e.to_string())?; + req_bytes.push(b'\n'); + stream.write_all(&req_bytes).await.map_err(|e| e.to_string())?; + + let mut reader = BufReader::new(stream); + let mut line = String::new(); + reader.read_line(&mut line).await.map_err(|e| e.to_string())?; + + let resp: serde_json::Value = serde_json::from_str(&line).map_err(|e| e.to_string())?; + if resp.get("type").and_then(|t| t.as_str()) == Some("error") { + return Err(resp.get("message").and_then(|m| m.as_str()).unwrap_or("Unknown daemon error").to_string()); + } + + Ok(resp) +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c78bfad7..a64dd7a3 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -4,8 +4,8 @@ "version": "0.1.27", "identifier": "dev.hyrak.sinew", "build": { - "beforeBuildCommand": "npm run prepare-sidecars && npm run build", - "beforeDevCommand": "npm run dev", + "beforeBuildCommand": "npm run prepare-sidecars && npm run prepare-agent-bridge && npm run build", + "beforeDevCommand": "npm run prepare-agent-bridge && npm run dev", "devUrl": "http://localhost:1420", "frontendDist": "../dist" }, @@ -28,6 +28,7 @@ "minWidth": 1100, "minHeight": 720, "resizable": true, + "theme": "dark", "titleBarStyle": "Overlay", "hiddenTitle": true, "trafficLightPosition": { "x": 14, "y": 18 } @@ -37,6 +38,10 @@ "bundle": { "active": true, "targets": ["app", "dmg", "msi", "nsis", "deb", "appimage"], + "resources": [ + "../sinew-chrome-bridge/**/*", + "../scripts/agent-bridge/**/*" + ], "icon": [ "icons/32x32.png", "icons/128x128.png", @@ -44,7 +49,7 @@ "icons/icon.icns", "icons/icon.ico" ], - "createUpdaterArtifacts": true, + "createUpdaterArtifacts": false, "windows": { "nsis": { "installerIcon": "icons/icon.ico", @@ -54,7 +59,7 @@ }, "plugins": { "updater": { - "active": true, + "active": false, "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDYyRTkxMjI2NDY1QjJFRjcKUldUM0xsdEdKaExwWXFyRHdtb1MxdlhidnNsQU0zOEEvVU5yNjJGOGVzN1pIbTNwZWlkSW1OTDUK", "endpoints": [ "https://github.com/Paseru/sinew/releases/latest/download/latest.json" diff --git a/src/App.tsx b/src/App.tsx index 62a8df2b..b8c633c4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,7 +2,8 @@ import { useCallback, useEffect, useState } from "react"; import { Welcome } from "./components/Welcome"; import { Workspace } from "./components/Workspace"; import { UpdaterLockScreen } from "./components/UpdaterLockScreen"; -import { loadLastWorkspace, recordRecent, deriveName } from "./lib/recents"; +import { SinewMark } from "./components/SinewMark"; +import { loadLastWorkspace, recordRecent, deriveName, clearLastWorkspace, loadLastWorkspaceAsync } from "./lib/recents"; import { api } from "./lib/ipc"; import type { UpdateInfo, WorkspaceBootstrap } from "./types"; @@ -24,11 +25,52 @@ export default function App() { const [state, setState] = useState({ kind: "boot" }); const [bootError, setBootError] = useState(null); + useEffect(() => { + try { + const savedChat = localStorage.getItem("sinew.chat-font-size"); + const sizeChat = savedChat ? parseInt(savedChat, 10) : 13; + document.documentElement.style.setProperty("--chat-font-size", `${sizeChat}px`); + } catch {} + try { + const savedEditor = localStorage.getItem("sinew.editor-font-size"); + const sizeEditor = savedEditor ? parseInt(savedEditor, 10) : 12; + document.documentElement.style.setProperty("--editor-font-size", `${sizeEditor}px`); + } catch {} + try { + const savedTheme = localStorage.getItem("sinew.theme") || "dark"; + document.documentElement.setAttribute("data-theme", savedTheme); + } catch {} + try { + const savedLargeChat = localStorage.getItem("sinew.large-chat-box") === "true"; + document.documentElement.setAttribute("data-large-chat-box", savedLargeChat ? "true" : "false"); + } catch {} + + // SOTA 1: Cursor-Tracking Glow event listener for AI Glass Theme + const handleMouseMove = (e: MouseEvent) => { + const isAI = document.documentElement.getAttribute("data-theme") === "ai"; + if (!isAI) return; + + const target = (e.target as HTMLElement).closest( + ".tool-card, .composer__box, .settings-pane__provider-card, .settings-pane__provider-card--compact" + ); + if (target) { + const rect = target.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + (target as HTMLElement).style.setProperty("--mouse-x", `${x}px`); + (target as HTMLElement).style.setProperty("--mouse-y", `${y}px`); + } + }; + window.addEventListener("mousemove", handleMouseMove); + return () => window.removeEventListener("mousemove", handleMouseMove); + }, []); + const openWorkspace = useCallback(async (path: string) => { setBootError(null); try { const bootstrap = await api.openWorkspace(path); - recordRecent(bootstrap.workspace.path, bootstrap.workspace.name); + const displayName = bootstrap.workspace.name === ".sinew-sandbox" ? "Sans dossier" : bootstrap.workspace.name; + recordRecent(bootstrap.workspace.path, displayName); setState({ kind: "workspace", bootstrap }); } catch (err) { setBootError(String(err)); @@ -45,45 +87,83 @@ export default function App() { // still handles mid-session checks via its own 30 min interval. useEffect(() => { let cancelled = false; + const startTime = Date.now(); + const MIN_BOOT_TIME_MS = 1500; + + const transitionTo = async (nextState: AppState) => { + const elapsed = Date.now() - startTime; + const remaining = MIN_BOOT_TIME_MS - elapsed; + if (remaining > 0) { + await new Promise((resolve) => setTimeout(resolve, remaining)); + } + if (!cancelled) { + setState(nextState); + } + }; (async () => { // 1. Updater gate. + let updateInfo: UpdateInfo | null = null; + let shouldCheckAtBoot = true; try { - const info = await Promise.race([ - api.checkForUpdate(), - new Promise((resolve) => - window.setTimeout(() => resolve(null), BOOT_CHECK_TIMEOUT_MS), - ), - ]); - if (cancelled) return; - if (info && info.available && info.version) { - setState({ kind: "update_required", info, autoInstall: false }); - return; + const saved = localStorage.getItem("sinew.auto-update-check"); + if (saved === "notification" || saved === "disabled" || saved === "false") { + shouldCheckAtBoot = false; + } + } catch {} + + if (shouldCheckAtBoot) { + try { + const info = await Promise.race([ + api.checkForUpdate(), + new Promise((resolve) => + window.setTimeout(() => resolve(null), BOOT_CHECK_TIMEOUT_MS), + ), + ]); + if (info && info.available && info.version) { + let skipped = false; + try { + skipped = localStorage.getItem("sinew.skippedUpdateVersion") === info.version; + } catch {} + if (!skipped) { + updateInfo = info; + } + } + } catch { + // Silent: a failed check (offline, server down, manifest 5xx) + // shouldn't prevent the app from booting. The mid-session badge + // will retry later, and the next launch will re-gate cleanly. } - } catch { - // Silent: a failed check (offline, server down, manifest 5xx) - // shouldn't prevent the app from booting. The mid-session badge - // will retry later, and the next launch will re-gate cleanly. } - // 2. Auto-open last workspace, falling back to Welcome. if (cancelled) return; + + if (updateInfo) { + await transitionTo({ kind: "update_required", info: updateInfo, autoInstall: false }); + return; + } + + // 2. Auto-open last workspace, falling back to Welcome. if (startsEmpty) { - setState({ kind: "welcome" }); + await transitionTo({ kind: "welcome" }); return; } - const last = loadLastWorkspace(); + + const windowUrlParams = new URLSearchParams(window.location.search); + const urlWorkspace = windowUrlParams.get("workspace"); + const last = urlWorkspace || await loadLastWorkspaceAsync(); + if (!last) { - setState({ kind: "welcome" }); + await transitionTo({ kind: "welcome" }); return; } try { const bootstrap = await api.openWorkspace(last); - if (cancelled) return; - recordRecent(bootstrap.workspace.path, bootstrap.workspace.name); - setState({ kind: "workspace", bootstrap }); + const displayName = bootstrap.workspace.name === ".sinew-sandbox" ? "Sans dossier" : bootstrap.workspace.name; + recordRecent(bootstrap.workspace.path, displayName); + await transitionTo({ kind: "workspace", bootstrap }); } catch { - if (!cancelled) setState({ kind: "welcome" }); + await transitionTo({ kind: "welcome" }); } })(); @@ -110,6 +190,39 @@ export default function App() { return () => window.removeEventListener("sinew:install-update", handler); }, []); + const handleSkipUpdate = useCallback(async () => { + if (state.kind !== "update_required") return; + if (state.info.version) { + try { + localStorage.setItem("sinew.skippedUpdateVersion", state.info.version); + } catch {} + } + void api.resetWindowTitle().catch(() => { + // best-effort; leaving the previous title is harmless + }); + + if (startsEmpty) { + setState({ kind: "welcome" }); + return; + } + const windowUrlParams = new URLSearchParams(window.location.search); + const urlWorkspace = windowUrlParams.get("workspace"); + const last = urlWorkspace || await loadLastWorkspaceAsync(); + + if (!last) { + setState({ kind: "welcome" }); + return; + } + try { + const bootstrap = await api.openWorkspace(last); + const displayName = bootstrap.workspace.name === ".sinew-sandbox" ? "Sans dossier" : bootstrap.workspace.name; + recordRecent(bootstrap.workspace.path, displayName); + setState({ kind: "workspace", bootstrap }); + } catch { + setState({ kind: "welcome" }); + } + }, [state, startsEmpty]); + const backToWelcome = useCallback(() => { void api.resetWindowTitle().catch(() => { // best-effort; leaving the previous title is harmless @@ -118,15 +231,26 @@ export default function App() { }, []); if (state.kind === "boot") { - // Minimal splash while the updater check resolves. Pure canvas — the - // real updater UI (or Welcome) takes over within a few hundred ms on - // a healthy network, ~4s worst case before the timeout fires. - return ))} @@ -342,7 +608,16 @@ export function EditorPane({
{ + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSettingsActivate?.(); + } + }} title="Settings" > @@ -357,7 +632,7 @@ export function EditorPane({ }} title="Close tab" > - +
)} @@ -388,6 +663,23 @@ export function EditorPane({ )}
+ {tabMenu && tabs[tabMenu.index] && ( + setTabMenu(null)} + onCloseTab={() => onClose(tabMenu.index)} + onCloseOthers={() => onCloseOthers(tabMenu.index)} + onCloseToRight={() => onCloseToRight(tabMenu.index)} + onCloseAll={onCloseAll} + onReveal={() => onRevealTab(tabMenu.index)} + /> + )} +
{settingsOpen && (
void; + onCloseTab: () => void; + onCloseOthers: () => void; + onCloseToRight: () => void; + onCloseAll: () => void; + onReveal: () => void; +}) { + useEffect(() => { + const close = () => onClose(); + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + window.addEventListener("pointerdown", close); + window.addEventListener("keydown", onKey, true); + window.addEventListener("resize", close); + document.addEventListener("scroll", close, true); + return () => { + window.removeEventListener("pointerdown", close); + window.removeEventListener("keydown", onKey, true); + window.removeEventListener("resize", close); + document.removeEventListener("scroll", close, true); + }; + }, [onClose]); + + const clampedX = + typeof window === "undefined" + ? x + : Math.max(8, Math.min(x, window.innerWidth - TAB_MENU_WIDTH - 8)); + const clampedY = + typeof window === "undefined" + ? y + : Math.max(8, Math.min(y, window.innerHeight - TAB_MENU_HEIGHT - 8)); + + const runAction = (action: () => void | Promise) => () => { + onClose(); + void Promise.resolve(action()).catch((err) => + console.error("[tab-menu] action failed", err), + ); + }; + + const copyFullPath = runAction(() => copyText(tab.doc.absolutePath)); + const copyRelativePath = runAction(() => copyText(tab.relativePath)); + + const isFr = getAppLocale() === "fr"; + + return ( +
event.stopPropagation()} + onContextMenu={(event) => event.preventDefault()} + > + + + = tabCount - 1 && !settingsOpen} + onClick={runAction(onCloseToRight)} + /> + +
+ + +
+ +
+ ); +} + +function TabMenuItem({ + icon, + label, + shortcut, + disabled = false, + onClick, +}: { + icon: string; + label: string; + shortcut?: string; + disabled?: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +async function copyText(text: string): Promise { + await navigator.clipboard.writeText(text); +} + +function revealLabel(isFr: boolean): string { + const platform = + typeof navigator !== "undefined" ? navigator.platform.toLowerCase() : ""; + if (platform.includes("mac")) return isFr ? "Afficher dans le Finder" : "Reveal in Finder"; + if (platform.includes("win")) return isFr ? "Afficher dans l'Explorateur" : "Reveal in File Explorer"; + return isFr ? "Afficher dans le gestionnaire de fichiers" : "Reveal in File Manager"; +} diff --git a/src/components/FileTree.tsx b/src/components/FileTree.tsx index 722c6d5b..3ba8ce0b 100644 --- a/src/components/FileTree.tsx +++ b/src/components/FileTree.tsx @@ -643,6 +643,21 @@ export const FileTree = forwardRef(function FileTree( [closeMenu, workspacePath], ); + const executeEntry = useCallback( + async (entry: WorkspaceEntry | null) => { + if (!entry) return; + closeMenu(); + try { + setActionError(null); + const resolved = await api.resolveTerminalPath(workspacePath, entry.relativePath); + await api.openPathWithDefaultApp(resolved.absolutePath); + } catch (err) { + setActionError(String(err)); + } + }, + [closeMenu, workspacePath], + ); + const copyEntryPaths = useCallback( async (entry: WorkspaceEntry | null, mode: "absolute" | "relative") => { const entries = selectedScopeFor(entry); @@ -1208,6 +1223,7 @@ export const FileTree = forwardRef(function FileTree( }} onRename={() => startRename(menu.entry)} onReveal={() => void revealEntry(menu.entry)} + onExecute={() => void executeEntry(menu.entry)} onCopy={() => copySelection(menu.entry, false)} onCut={() => copySelection(menu.entry, true)} onCopyPath={() => void copyEntryPaths(menu.entry, "absolute")} @@ -1371,8 +1387,8 @@ const TreeNode = memo(function TreeNode({ void; onNewFolder: () => void; onOpen: () => void; + onExecute: () => void; onRename: () => void; onReveal: () => void; onCopy: () => void; @@ -1708,6 +1726,13 @@ function TreeContextMenu({ onClick={onOpen} /> )} + {entry && entry.kind === "file" && ( + + )} ) : ( @@ -783,7 +783,7 @@ export function GitPanel({ ) : ( @@ -815,7 +815,7 @@ export function GitPanel({ onClick={dismissNotice} title="Dismiss" > - +
)} @@ -1095,7 +1095,7 @@ export function GitPanel({ onClick={handleDismissPrResult} title="Dismiss" > - +
@@ -1524,7 +1524,7 @@ function WorktreeRow({ ) : ( diff --git a/src/components/ImageContextMenu.tsx b/src/components/ImageContextMenu.tsx index 03f17b17..cfdaba97 100644 --- a/src/components/ImageContextMenu.tsx +++ b/src/components/ImageContextMenu.tsx @@ -104,7 +104,7 @@ export function ImageContextMenu({ > diff --git a/src/components/SearchPane.tsx b/src/components/SearchPane.tsx index 060ad841..99dc9e0a 100644 --- a/src/components/SearchPane.tsx +++ b/src/components/SearchPane.tsx @@ -119,7 +119,7 @@ export function SearchPane({ workspacePath, refreshToken, onOpenFile }: Props) { title="Clear" onClick={() => setQuery("")} > - + )}
diff --git a/src/components/SettingsPane.tsx b/src/components/SettingsPane.tsx index dcf6e15d..3f3b330d 100644 --- a/src/components/SettingsPane.tsx +++ b/src/components/SettingsPane.tsx @@ -1,8 +1,10 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Editor, { type OnMount } from "@monaco-editor/react"; import { Icon } from "@iconify/react"; import { Wrench } from "lucide-react"; -import { api } from "../lib/ipc"; +import { getAppLocale, setAppLocale, type AppLocale } from "../lib/locale"; +import { api, type BoostStatus } from "../lib/ipc"; +import { fetchProviderQuota, quotaColor, getCachedQuota, type QuotaInfo, quotaCache } from "../lib/quotas"; import { canonicalToolName } from "../lib/tools"; import { Markdown } from "./chat/Markdown"; import { SinewMark } from "./SinewMark"; @@ -21,18 +23,23 @@ import { } from "../lib/models"; import type { AnthropicProviderStatus, + CursorComposerAuthStatus, GoogleProviderStatus, ImageProvider, InstalledSkill, KimiProviderStatus, + DeepSeekProviderStatus, McpEnvVar, McpServerConfig, McpServerProbe, McpSettings, OpenAiProviderStatus, + OpenAiAccountInfo, + GoogleAccountInfo, OpenRouterModel, OpenRouterModelSearchResult, OpenRouterProviderStatus, + OllamaProviderStatus, SkillSettings, SubAgentConfig, SubAgentSettings, @@ -49,6 +56,9 @@ const FALLBACK_TOOL_SETTINGS: ToolSettings = { defaultPlanModePrompt: "", imageProvider: "gptImage2", openaiImageUseSubscription: false, + geminiImageUseSubscription: false, + openaiImageModel: "gpt-image-2", + geminiImageModel: "gemini-3.1-flash-image-preview", openaiImageApiKey: "", nanoBananaApiKey: "", webSearchProvider: "classic", @@ -57,14 +67,57 @@ const FALLBACK_TOOL_SETTINGS: ToolSettings = { const PROVIDERS_CHANGED_EVENT = "sinew:providers-changed"; const TOOL_SETTINGS_CHANGED_EVENT = "sinew:tool-settings-changed"; +// Global cache to execute SOTA diagnostics exactly once at application startup. +// Prevents re-running diagnostics when entering the Options page. +interface SotaCache { + data: any; + error: string | null; + promise: Promise | null; +} + +const sotaCache: SotaCache = { + data: null, + error: null, + promise: null, +}; + +const triggerSotaDiagnostics = (force = false): Promise => { + if (!force && sotaCache.promise) { + return sotaCache.promise; + } + + const promise = api.checkSotaDiagnostics() + .then((dataStr) => { + const parsed = JSON.parse(dataStr); + sotaCache.data = parsed; + sotaCache.error = null; + return parsed; + }) + .catch((err) => { + const errMsg = err?.toString() || "Unknown error"; + sotaCache.error = errMsg; + sotaCache.data = null; + throw new Error(errMsg); + }); + + sotaCache.promise = promise; + return promise; +}; + +// Eagerly trigger in the background at startup +setTimeout(() => { + triggerSotaDiagnostics().catch(() => {}); +}, 500); + type Props = { workspacePath: string; }; -type Section = "about" | "providers" | "tools" | "mcp" | "skills" | "subagents"; +type Section = "options" | "poweruser" | "diagnostic" | "about" | "providers" | "tools" | "mcp" | "skills" | "subagents"; export function SettingsPane({ workspacePath }: Props) { - const [section, setSection] = useState
("about"); + const [section, setSection] = useState
("options"); + const [locale, setLocaleState] = useState(() => getAppLocale()); const [settings, setSettings] = useState(EMPTY_SETTINGS); const [savedJson, setSavedJson] = useState(""); const [jsonText, setJsonText] = useState(""); @@ -107,15 +160,31 @@ export function SettingsPane({ workspacePath }: Props) { const [toolsStatus, setToolsStatus] = useState(null); const [openAiStatus, setOpenAiStatus] = useState(null); + const [openAiAccounts, setOpenAiAccounts] = useState([]); + const [unconnectedAccounts, setUnconnectedAccounts] = useState([]); + const [anthropicStatus, setAnthropicStatus] = useState(null); const [googleStatus, setGoogleStatus] = useState(null); + const [googleAccounts, setGoogleAccounts] = useState([]); + const [unconnectedGoogleAccounts, setUnconnectedGoogleAccounts] = useState([]); const [kimiStatus, setKimiStatus] = useState(null); + const [deepSeekStatus, setDeepSeekStatus] = useState(null); + const [cursorComposerStatus, setCursorComposerStatus] = useState(null); + const cursorOAuthPendingRef = useRef(false); const [openRouterStatus, setOpenRouterStatus] = useState(null); const [openRouterModels, setOpenRouterModels] = useState([]); + const [ollamaModels, setOllamaModels] = useState([]); const [providersLoading, setProvidersLoading] = useState(false); const [providersBusy, setProvidersBusy] = useState(false); const [providersMessage, setProvidersMessage] = useState(null); const [configuredProviders, setConfiguredProviders] = useState([]); + const [archivedProviders, setArchivedProviders] = useState([]); + + const setLocale = useCallback((nextLocale: AppLocale) => { + setAppLocale(nextLocale); + setLocaleState(nextLocale); + window.location.reload(); + }, []); useEffect(() => { setToolSettings(null); @@ -160,14 +229,22 @@ export function SettingsPane({ workspacePath }: Props) { setLoading(false); if (normalized.servers.some((server) => server.enabled)) { - const nextProbes = await api.probeMcpTools(); - if (disposed) return; - setProbes(nextProbes); - const failures = nextProbes.filter( - (probe) => probe.enabled && !probe.ok, - ).length; - if (failures) { - setStatus(`${failures} server${failures === 1 ? "" : "s"} failed`); + try { + const nextProbes = await api.probeMcpTools(); + if (disposed) return; + setProbes(nextProbes); + const failures = nextProbes.filter( + (probe) => probe.enabled && !probe.ok, + ).length; + if (failures) { + setStatus(`${failures} server${failures === 1 ? "" : "s"} failed`); + } + } catch (probeErr) { + if (!disposed) { + setStatus( + probeErr instanceof Error ? probeErr.message : String(probeErr), + ); + } } } } catch (err) { @@ -310,6 +387,24 @@ export function SettingsPane({ workspacePath }: Props) { ); }, []); + const updateGeminiImageUseSubscription = useCallback((geminiImageUseSubscription: boolean) => { + setToolSettings((current) => + current ? { ...current, geminiImageUseSubscription } : current, + ); + }, []); + + const updateOpenAiImageModel = useCallback((openaiImageModel: string) => { + setToolSettings((current) => + current ? { ...current, openaiImageModel } : current, + ); + }, []); + + const updateGeminiImageModel = useCallback((geminiImageModel: string) => { + setToolSettings((current) => + current ? { ...current, geminiImageModel } : current, + ); + }, []); + const updateNanoBananaApiKey = useCallback((nanoBananaApiKey: string) => { setToolSettings((current) => current ? { ...current, nanoBananaApiKey } : current, @@ -336,19 +431,46 @@ export function SettingsPane({ workspacePath }: Props) { ]); setConfiguredProviders(providers); setOpenRouterModels(models); + if (providers.includes("ollama")) { + api.listOllamaModels().then(setOllamaModels).catch(() => setOllamaModels([])); + } else { + setOllamaModels([]); + } } catch { setConfiguredProviders([]); + setArchivedProviders([]); setOpenRouterModels([]); + setOllamaModels([]); } }, []); + const handleArchiveProvider = useCallback(async (providerId: string) => { + try { + await api.archiveProvider(providerId); + await loadConfiguredProviders(); + window.dispatchEvent(new CustomEvent(PROVIDERS_CHANGED_EVENT)); + } catch (err) { + console.error("Failed to archive provider:", err); + } + }, [loadConfiguredProviders]); + + const handleRestoreProvider = useCallback(async (providerId: string) => { + try { + await api.restoreProvider(providerId); + await loadConfiguredProviders(); + window.dispatchEvent(new CustomEvent(PROVIDERS_CHANGED_EVENT)); + } catch (err) { + console.error("Failed to restore provider:", err); + } + }, [loadConfiguredProviders]); + useEffect(() => { void loadConfiguredProviders(); }, [loadConfiguredProviders]); const availableModels = useMemo( - () => availableModelsForProviders(configuredProviders, openRouterModels), - [configuredProviders, openRouterModels], + () => availableModelsForProviders(configuredProviders, openRouterModels, [], ollamaModels), + [configuredProviders, openRouterModels, ollamaModels], ); const loadOpenAiStatus = useCallback(async () => { @@ -357,6 +479,14 @@ export function SettingsPane({ workspacePath }: Props) { const status = await api.getOpenAiProviderStatus(); setOpenAiStatus(status); setProvidersMessage(status.error ?? null); + + const accounts = await api.getAllOpenAiAccounts(); + setOpenAiAccounts(accounts); + + setUnconnectedAccounts((prev) => + prev.filter((key) => !accounts.some((acc) => acc.key === key)) + ); + if (status.connectionState !== "connecting") { void loadConfiguredProviders(); window.dispatchEvent(new CustomEvent(PROVIDERS_CHANGED_EVENT)); @@ -368,6 +498,8 @@ export function SettingsPane({ workspacePath }: Props) { } }, [loadConfiguredProviders]); + + const loadAnthropicStatus = useCallback(async () => { setProvidersLoading(true); try { @@ -391,6 +523,14 @@ export function SettingsPane({ workspacePath }: Props) { const status = await api.getGoogleProviderStatus(); setGoogleStatus(status); setProvidersMessage(status.error ?? null); + + const accounts = await api.getAllGoogleAccounts(); + setGoogleAccounts(accounts); + + setUnconnectedGoogleAccounts((prev) => + prev.filter((key) => !accounts.some((acc) => acc.key === key)) + ); + if (status.connectionState !== "connecting") { void loadConfiguredProviders(); window.dispatchEvent(new CustomEvent(PROVIDERS_CHANGED_EVENT)); @@ -419,6 +559,53 @@ export function SettingsPane({ workspacePath }: Props) { } }, [loadConfiguredProviders]); + const loadDeepSeekStatus = useCallback(async () => { + setProvidersLoading(true); + try { + const status = await api.getDeepSeekProviderStatus(); + setDeepSeekStatus(status); + setProvidersMessage(status.error ?? null); + if (status.connectionState !== "connecting") { + void loadConfiguredProviders(); + window.dispatchEvent(new CustomEvent(PROVIDERS_CHANGED_EVENT)); + } + } catch (err) { + setProvidersMessage(err instanceof Error ? err.message : String(err)); + } finally { + setProvidersLoading(false); + } + }, [loadConfiguredProviders]); + + const loadCursorStatus = useCallback(async () => { + setProvidersLoading(true); + try { + const composer = await api.getCursorComposerStatus(); + const wasPending = cursorOAuthPendingRef.current; + setCursorComposerStatus(composer); + if ( + wasPending && + composer.connected && + composer.connectionState === "connected" + ) { + cursorOAuthPendingRef.current = false; + setProvidersMessage( + "Cursor connecté — vous pouvez fermer l'onglet du navigateur et revenir à Sinew.", + ); + quotaCache.delete("cursor"); + window.dispatchEvent(new CustomEvent("sinew:quota-updated")); + } else if (wasPending && composer.connectionState === "error") { + cursorOAuthPendingRef.current = false; + setProvidersMessage(composer.error ?? "Connexion Cursor échouée"); + } + void loadConfiguredProviders(); + window.dispatchEvent(new CustomEvent(PROVIDERS_CHANGED_EVENT)); + } catch (err) { + setProvidersMessage(err instanceof Error ? err.message : String(err)); + } finally { + setProvidersLoading(false); + } + }, [loadConfiguredProviders]); + const loadOpenRouterStatus = useCallback(async () => { setProvidersLoading(true); try { @@ -438,6 +625,81 @@ export function SettingsPane({ workspacePath }: Props) { } }, [loadConfiguredProviders]); + const [secondaryModels, setSecondaryModels] = useState>(() => { + const models: Record = {}; + try { + for (let i = 2; i <= 20; i++) { + const oKey = `openai:${i}`; + models[oKey] = localStorage.getItem(`sinew.provider-model.${oKey}`) || "gpt-5.5"; + const gKey = `google:${i}`; + models[gKey] = localStorage.getItem(`sinew.provider-model.${gKey}`) || "gemini-3.5-flash"; + } + } catch {} + return models; + }); + + const [secondaryThinking, setSecondaryThinking] = useState>(() => { + const thinking: Record = {}; + try { + for (let i = 2; i <= 20; i++) { + const oKey = `openai:${i}`; + thinking[oKey] = localStorage.getItem(`sinew.provider-thinking.${oKey}`) || "medium"; + const gKey = `google:${i}`; + thinking[gKey] = localStorage.getItem(`sinew.provider-thinking.${gKey}`) || "high"; + } + } catch {} + return thinking; + }); + + const [secondaryFast, setSecondaryFast] = useState>(() => { + const fast: Record = {}; + try { + for (let i = 2; i <= 20; i++) { + const oKey = `openai:${i}`; + fast[oKey] = localStorage.getItem(`sinew.provider-fast.${oKey}`) || "true"; + const gKey = `google:${i}`; + fast[gKey] = localStorage.getItem(`sinew.provider-fast.${gKey}`) || "true"; + } + } catch {} + return fast; + }); + + const handleUpdateSecondaryModel = useCallback((key: string, model: string) => { + try { + localStorage.setItem(`sinew.provider-model.${key}`, model); + } catch {} + setSecondaryModels((prev) => ({ ...prev, [key]: model })); + if (key.startsWith("google:")) { + void loadGoogleStatus(); + } else { + void loadOpenAiStatus(); + } + }, [loadOpenAiStatus, loadGoogleStatus]); + + const handleUpdateSecondaryThinking = useCallback((key: string, thinking: string) => { + try { + localStorage.setItem(`sinew.provider-thinking.${key}`, thinking); + } catch {} + setSecondaryThinking((prev) => ({ ...prev, [key]: thinking })); + if (key.startsWith("google:")) { + void loadGoogleStatus(); + } else { + void loadOpenAiStatus(); + } + }, [loadOpenAiStatus, loadGoogleStatus]); + + const handleUpdateSecondaryFast = useCallback((key: string, fast: string) => { + try { + localStorage.setItem(`sinew.provider-fast.${key}`, fast); + } catch {} + setSecondaryFast((prev) => ({ ...prev, [key]: fast })); + if (key.startsWith("google:")) { + void loadGoogleStatus(); + } else { + void loadOpenAiStatus(); + } + }, [loadOpenAiStatus, loadGoogleStatus]); + useEffect(() => { if (section !== "providers" && section !== "tools") return; if (openAiStatus === null) void loadOpenAiStatus(); @@ -445,6 +707,8 @@ export function SettingsPane({ workspacePath }: Props) { if (anthropicStatus === null) void loadAnthropicStatus(); if (googleStatus === null) void loadGoogleStatus(); if (kimiStatus === null) void loadKimiStatus(); + if (deepSeekStatus === null) void loadDeepSeekStatus(); + if (cursorComposerStatus === null) void loadCursorStatus(); if (openRouterStatus === null) void loadOpenRouterStatus(); }, [ section, @@ -452,11 +716,15 @@ export function SettingsPane({ workspacePath }: Props) { anthropicStatus, googleStatus, kimiStatus, + deepSeekStatus, + cursorComposerStatus, openRouterStatus, loadOpenAiStatus, loadAnthropicStatus, loadGoogleStatus, loadKimiStatus, + loadDeepSeekStatus, + loadCursorStatus, loadOpenRouterStatus, ]); @@ -468,17 +736,29 @@ export function SettingsPane({ workspacePath }: Props) { }); }, [openAiStatus]); + useEffect(() => { + if (googleStatus === null || googleStatus.connected) return; + setToolSettings((current) => { + if (!current?.geminiImageUseSubscription) return current; + return { ...current, geminiImageUseSubscription: false }; + }); + }, [googleStatus]); + useEffect(() => { const openAiConnecting = openAiStatus?.connectionState === "connecting"; const anthropicConnecting = anthropicStatus?.connectionState === "connecting"; const googleConnecting = googleStatus?.connectionState === "connecting"; const kimiConnecting = kimiStatus?.connectionState === "connecting"; - if (!openAiConnecting && !anthropicConnecting && !googleConnecting && !kimiConnecting) return; + const deepSeekConnecting = deepSeekStatus?.connectionState === "connecting"; + const cursorConnecting = cursorComposerStatus?.connectionState === "connecting"; + if (!openAiConnecting && !anthropicConnecting && !googleConnecting && !kimiConnecting && !deepSeekConnecting && !cursorConnecting) return; const timer = window.setInterval(() => { if (openAiConnecting) void loadOpenAiStatus(); if (anthropicConnecting) void loadAnthropicStatus(); if (googleConnecting) void loadGoogleStatus(); if (kimiConnecting) void loadKimiStatus(); + if (deepSeekConnecting) void loadDeepSeekStatus(); + if (cursorConnecting) void loadCursorStatus(); }, 1200); return () => window.clearInterval(timer); }, [ @@ -486,32 +766,54 @@ export function SettingsPane({ workspacePath }: Props) { anthropicStatus?.connectionState, googleStatus?.connectionState, kimiStatus?.connectionState, + deepSeekStatus?.connectionState, + cursorComposerStatus?.connectionState, loadOpenAiStatus, loadAnthropicStatus, loadGoogleStatus, loadKimiStatus, + loadDeepSeekStatus, ]); - const connectOpenAi = useCallback(async () => { + const connectOpenAi = useCallback(async (key?: string) => { setProvidersBusy(true); setProvidersMessage(null); try { - const login = await api.startOpenAiOAuthLogin(); + const login = await api.startOpenAiOAuthLogin(key); const connecting: OpenAiProviderStatus = { - connected: false, + connected: openAiStatus?.connected ?? false, connectionState: "connecting", loginId: login.loginId, }; setOpenAiStatus(connecting); await api.openExternalUrl(login.authUrl); - setProvidersMessage("Waiting for browser confirmation…"); + setProvidersMessage("Waiting for browser confirmation⬦"); } catch (err) { setProvidersMessage(err instanceof Error ? err.message : String(err)); void loadOpenAiStatus(); } finally { setProvidersBusy(false); } - }, [loadOpenAiStatus]); + }, [openAiStatus, loadOpenAiStatus]); + + const handleAddOpenAiAccount = useCallback(() => { + let nextIndex = 2; + while (true) { + const key = `openai:${nextIndex}`; + const isConnected = openAiAccounts.some((acc) => acc.key === key); + const isUnconnected = unconnectedAccounts.includes(key); + if (!isConnected && !isUnconnected) { + setUnconnectedAccounts((prev) => [...prev, key]); + break; + } + nextIndex++; + if (nextIndex > 100) break; + } + }, [openAiAccounts, unconnectedAccounts]); + + const handleRemoveUnconnectedAccount = useCallback((key: string) => { + setUnconnectedAccounts((prev) => prev.filter((k) => k !== key)); + }, []); const cancelOpenAi = useCallback(async () => { setProvidersBusy(true); @@ -531,6 +833,7 @@ export function SettingsPane({ workspacePath }: Props) { try { setOpenAiStatus(await api.disconnectOpenAiProvider()); setProvidersMessage("Disconnected"); + void loadOpenAiStatus(); void loadConfiguredProviders(); window.dispatchEvent(new CustomEvent(PROVIDERS_CHANGED_EVENT)); } catch (err) { @@ -538,7 +841,23 @@ export function SettingsPane({ workspacePath }: Props) { } finally { setProvidersBusy(false); } - }, [loadConfiguredProviders]); + }, [loadOpenAiStatus, loadConfiguredProviders]); + + const disconnectOpenAiAccount = useCallback(async (key: string) => { + setProvidersBusy(true); + setProvidersMessage(null); + try { + await api.disconnectOpenAiAccount(key); + setProvidersMessage("Account disconnected"); + void loadOpenAiStatus(); + void loadConfiguredProviders(); + window.dispatchEvent(new CustomEvent(PROVIDERS_CHANGED_EVENT)); + } catch (err) { + setProvidersMessage(err instanceof Error ? err.message : String(err)); + } finally { + setProvidersBusy(false); + } + }, [loadOpenAiStatus, loadConfiguredProviders]); const connectAnthropic = useCallback(async () => { setProvidersBusy(true); @@ -588,13 +907,13 @@ export function SettingsPane({ workspacePath }: Props) { } }, [loadConfiguredProviders]); - const connectGoogle = useCallback(async () => { + const connectGoogle = useCallback(async (key?: string) => { setProvidersBusy(true); setProvidersMessage(null); try { - const login = await api.startGoogleOAuthLogin(); + const login = await api.startGoogleOAuthLogin(key); const connecting: GoogleProviderStatus = { - connected: false, + connected: googleStatus?.connected ?? false, connectionState: "connecting", loginId: login.loginId, }; @@ -607,7 +926,42 @@ export function SettingsPane({ workspacePath }: Props) { } finally { setProvidersBusy(false); } - }, [loadGoogleStatus]); + }, [googleStatus, loadGoogleStatus]); + + const disconnectGoogleAccount = useCallback(async (key: string) => { + setProvidersBusy(true); + setProvidersMessage(null); + try { + await api.disconnectGoogleAccount(key); + setProvidersMessage("Account disconnected"); + void loadGoogleStatus(); + void loadConfiguredProviders(); + window.dispatchEvent(new CustomEvent(PROVIDERS_CHANGED_EVENT)); + } catch (err) { + setProvidersMessage(err instanceof Error ? err.message : String(err)); + } finally { + setProvidersBusy(false); + } + }, [loadGoogleStatus, loadConfiguredProviders]); + + const handleAddGoogleAccount = useCallback(() => { + let nextIndex = 2; + while (true) { + const key = `google:${nextIndex}`; + const isConnected = googleAccounts.some((acc) => acc.key === key); + const isUnconnected = unconnectedGoogleAccounts.includes(key); + if (!isConnected && !isUnconnected) { + setUnconnectedGoogleAccounts((prev) => [...prev, key]); + break; + } + nextIndex++; + if (nextIndex > 100) break; + } + }, [googleAccounts, unconnectedGoogleAccounts]); + + const handleRemoveUnconnectedGoogleAccount = useCallback((key: string) => { + setUnconnectedGoogleAccounts((prev) => prev.filter((k) => k !== key)); + }, []); const cancelGoogle = useCallback(async () => { setProvidersBusy(true); @@ -636,6 +990,59 @@ export function SettingsPane({ workspacePath }: Props) { } }, [loadConfiguredProviders]); + const connectCursor = useCallback(async () => { + setProvidersBusy(true); + setProvidersMessage(null); + try { + const login = await api.startCursorOAuthLogin(); + cursorOAuthPendingRef.current = true; + setCursorComposerStatus({ + connected: false, + connectionState: "connecting", + loginId: login.loginId, + }); + await api.openExternalUrl(login.authUrl); + setProvidersMessage( + "Connectez-vous dans le navigateur (Google ou GitHub). La page dira « return to Cursor » — c'est normal : revenez ici, Sinew se connectera automatiquement.", + ); + } catch (err) { + cursorOAuthPendingRef.current = false; + setProvidersMessage(err instanceof Error ? err.message : String(err)); + void loadCursorStatus(); + } finally { + setProvidersBusy(false); + } + }, [loadCursorStatus]); + + const cancelCursorComposer = useCallback(async () => { + setProvidersBusy(true); + setProvidersMessage(null); + try { + cursorOAuthPendingRef.current = false; + setCursorComposerStatus(await api.cancelCursorOAuthLogin()); + } catch (err) { + setProvidersMessage(err instanceof Error ? err.message : String(err)); + } finally { + setProvidersBusy(false); + } + }, []); + + const disconnectCursorComposer = useCallback(async () => { + setProvidersBusy(true); + setProvidersMessage(null); + try { + await api.disconnectCursorComposer(); + setCursorComposerStatus({ connected: false, connectionState: "disconnected" }); + setProvidersMessage("Déconnecté"); + void loadConfiguredProviders(); + window.dispatchEvent(new CustomEvent(PROVIDERS_CHANGED_EVENT)); + } catch (err) { + setProvidersMessage(err instanceof Error ? err.message : String(err)); + } finally { + setProvidersBusy(false); + } + }, [loadConfiguredProviders]); + const connectKimi = useCallback(async () => { setProvidersBusy(true); setProvidersMessage(null); @@ -704,6 +1111,31 @@ export function SettingsPane({ workspacePath }: Props) { window.dispatchEvent(new CustomEvent(PROVIDERS_CHANGED_EVENT)); }, [loadConfiguredProviders]); + const disconnectDeepSeek = useCallback(async () => { + setProvidersBusy(true); + setProvidersMessage(null); + try { + setDeepSeekStatus(await api.disconnectDeepSeekProvider()); + setProvidersMessage("Disconnected"); + void loadConfiguredProviders(); + window.dispatchEvent(new CustomEvent(PROVIDERS_CHANGED_EVENT)); + } catch (err) { + setProvidersMessage(err instanceof Error ? err.message : String(err)); + } finally { + setProvidersBusy(false); + } + }, [loadConfiguredProviders]); + + const handleDeepSeekChanged = useCallback(() => { + void loadConfiguredProviders(); + window.dispatchEvent(new CustomEvent(PROVIDERS_CHANGED_EVENT)); + }, [loadConfiguredProviders]); + + const handleOllamaChanged = useCallback(() => { + void loadConfiguredProviders(); + window.dispatchEvent(new CustomEvent(PROVIDERS_CHANGED_EVENT)); + }, [loadConfiguredProviders]); + const saveAndDetect = useCallback(async () => { setSaving(true); setStatus(null); @@ -772,42 +1204,129 @@ export function SettingsPane({ workspacePath }: Props) { [parseError, saving, settings], ); - // ---- Skills load ------------------------------------------------------ - const loadSkills = useCallback(async () => { - setSkillsLoading(true); - setSkillsError(null); - setSkillsStatus(null); - try { - const list = await api.listInstalledSkills(workspacePath); - setSkills(list); - setSavedSkillsJson(skillsFingerprint(list)); - setSelectedSkillName((current) => { - if (current && list.some((item) => item.name === current)) return current; - return list[0]?.name ?? null; + const toggleAutoLoad = useCallback( + async (id: string) => { + if (parseError || saving) return; + const next = normalizeSettings({ + servers: settings.servers.map((server) => + server.id === id ? { ...server, autoLoad: !server.autoLoad } : server, + ), }); - } catch (err) { - setSkillsError(err instanceof Error ? err.message : String(err)); - setSkills([]); - setSavedSkillsJson(skillsFingerprint([])); - } finally { - setSkillsLoading(false); - } - }, [workspacePath]); - - useEffect(() => { - if (skills !== null) return; - void loadSkills(); - }, [skills, loadSkills]); + const optimisticJson = settingsToJson(next); + setSettings(next); + setJsonText(optimisticJson); + setSaving(true); + setStatus(null); + try { + const saved = normalizeSettings(await api.saveMcpSettings(next)); + const nextJson = settingsToJson(saved); + setSettings(saved); + setSavedJson(nextJson); + setJsonText(nextJson); + setParseError(null); - const loadSubAgents = useCallback(async () => { - setSubAgentsLoading(true); - setSubAgentsStatus(null); - try { - const loaded = normalizeSubAgentSettings(await api.listSubAgentSettings()); - setSubAgentSettings(loaded); - setSavedSubAgentJson(subAgentSettingsFingerprint(loaded)); - setSelectedSubAgentId((current) => { - if (current && loaded.agents.some((agent) => agent.id === current)) { + const nextProbes = await api.probeMcpTools(); + setProbes(nextProbes); + const failures = nextProbes.filter((probe) => probe.enabled && !probe.ok).length; + setStatus( + failures + ? `${failures} server${failures === 1 ? "" : "s"} failed` + : "Saved", + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setParseError(message); + setStatus(message); + } finally { + setSaving(false); + } + }, + [parseError, saving, settings], + ); + + const refreshMcpProbes = useCallback(async () => { + if (!settings.servers.some((server) => server.enabled)) { + setProbes([]); + return; + } + setProbing(true); + try { + const nextProbes = await api.probeMcpTools(); + setProbes(nextProbes); + const failures = nextProbes.filter((probe) => probe.enabled && !probe.ok).length; + if (failures) { + setStatus(`${failures} server${failures === 1 ? "" : "s"} failed`); + } + } catch (err) { + setStatus(err instanceof Error ? err.message : String(err)); + } finally { + setProbing(false); + } + }, [settings.servers]); + + const autoProbedServersRef = useRef>(new Set()); + + // Clear probed servers cache when saving changes or settings are reloaded + useEffect(() => { + if (saving || loading) { + autoProbedServersRef.current.clear(); + } + }, [saving, loading]); + + useEffect(() => { + if (loading || saving || probing) return; + if (!selectedServer?.enabled) return; + if (selectedServerId && autoProbedServersRef.current.has(selectedServerId)) return; + + if (selectedServerId) { + autoProbedServersRef.current.add(selectedServerId); + } + void refreshMcpProbes(); + }, [ + loading, + probing, + refreshMcpProbes, + saving, + selectedServer?.enabled, + selectedServerId, + ]); + + // ---- Skills load ------------------------------------------------------ + const loadSkills = useCallback(async () => { + setSkillsLoading(true); + setSkillsError(null); + setSkillsStatus(null); + try { + const list = await api.listInstalledSkills(workspacePath); + setSkills(list); + setSavedSkillsJson(skillsFingerprint(list)); + setSelectedSkillName((current) => { + if (current && list.some((item) => item.name === current)) return current; + return list[0]?.name ?? null; + }); + } catch (err) { + setSkillsError(err instanceof Error ? err.message : String(err)); + setSkills([]); + setSavedSkillsJson(skillsFingerprint([])); + } finally { + setSkillsLoading(false); + } + }, [workspacePath]); + + useEffect(() => { + if (skills !== null) return; + void loadSkills(); + }, [skills, loadSkills]); + + const loadSubAgents = useCallback(async () => { + setSubAgentsLoading(true); + setSubAgentsStatus(null); + try { + const loaded = normalizeSubAgentSettings(await api.listSubAgentSettings()); + setSubAgentSettings(loaded); + setSavedSubAgentJson(subAgentSettingsFingerprint(loaded)); + setSelectedSubAgentId((current) => { + if (current && loaded.agents.some((agent) => agent.id === current)) { return current; } return loaded.agents[0]?.id ?? null; @@ -1063,17 +1582,44 @@ export function SettingsPane({ workspacePath }: Props) { + + +
- {section === "about" ? ( - + {(section === "options" || section === "poweruser" || section === "diagnostic") ? ( + + ) : section === "about" ? ( + ) : section === "providers" ? ( { await api.saveOpenAiAccessToken(token, key); }} + onDisconnectOpenAiAccount={disconnectOpenAiAccount} anthropicStatus={anthropicStatus} googleStatus={googleStatus} + googleAccounts={googleAccounts} + unconnectedGoogleAccounts={unconnectedGoogleAccounts} + onConnectGoogleWithKey={connectGoogle} + onAddGoogleAccount={handleAddGoogleAccount} + onRemoveUnconnectedGoogleAccount={handleRemoveUnconnectedGoogleAccount} + onDisconnectGoogleAccount={disconnectGoogleAccount} + cursorComposerStatus={cursorComposerStatus} kimiStatus={kimiStatus} + deepSeekStatus={deepSeekStatus} openRouterStatus={openRouterStatus} openRouterModels={openRouterModels} loading={providersLoading} busy={providersBusy} message={providersMessage} onRefresh={() => { + quotaCache.clear(); + window.dispatchEvent(new CustomEvent("sinew:quota-updated")); void loadOpenAiStatus(); void loadAnthropicStatus(); void loadGoogleStatus(); + void loadCursorStatus(); void loadKimiStatus(); + void loadDeepSeekStatus(); void loadOpenRouterStatus(); }} onConnect={() => void connectOpenAi()} + onConnectWithKey={(key) => void connectOpenAi(key)} + onAddOpenAiAccount={handleAddOpenAiAccount} + onRemoveUnconnectedAccount={handleRemoveUnconnectedAccount} onCancel={() => void cancelOpenAi()} onDisconnect={() => void disconnectOpenAi()} onConnectAnthropic={() => void connectAnthropic()} @@ -1183,6 +1781,9 @@ export function SettingsPane({ workspacePath }: Props) { onConnectGoogle={() => void connectGoogle()} onCancelGoogle={() => void cancelGoogle()} onDisconnectGoogle={() => void disconnectGoogle()} + onConnectCursor={() => void connectCursor()} + onCancelCursorComposer={() => void cancelCursorComposer()} + onDisconnectCursorComposer={() => void disconnectCursorComposer()} onConnectKimi={() => void connectKimi()} onCancelKimi={() => void cancelKimi()} onDisconnectKimi={() => void disconnectKimi()} @@ -1190,6 +1791,10 @@ export function SettingsPane({ workspacePath }: Props) { onOpenRouterStatusChange={setOpenRouterStatus} onOpenRouterModelsChange={setOpenRouterModels} onOpenRouterChanged={handleOpenRouterChanged} + onDisconnectDeepSeek={() => void disconnectDeepSeek()} + onDeepSeekStatusChange={setDeepSeekStatus} + onDeepSeekChanged={handleDeepSeekChanged} + onOllamaChanged={handleOllamaChanged} /> ) : section === "tools" ? ( ) : section === "mcp" ? ( void refreshMcpProbes()} onMount={handleEditorMount} /> ) : section === "skills" ? ( @@ -1277,125 +1889,2885 @@ export function SettingsPane({ workspacePath }: Props) { ); } -// ---- About section ----------------------------------------------------- +// ---- Options section ---------------------------------------------------- -function AboutSection() { - return ( -
-
- - - -
-

Sinew

-
-
+function OptionsSection({ + locale, + onLocaleChange, + workspacePath, + currentTab, + availableModels, +}: { + locale: AppLocale; + onLocaleChange: (locale: AppLocale) => void; + workspacePath: string; + currentTab: string; + availableModels: readonly ModelEntry[]; +}) { + const [powerUserMaster, setPowerUserMaster] = useState<"enabled" | "disabled" | "custom">(() => { + try { + const saved = localStorage.getItem("sinew.power-user-master"); + if (saved === "enabled" || saved === "disabled" || saved === "custom") { + return saved; + } + const oldPowerUser = localStorage.getItem("sinew.power-user"); + if (oldPowerUser === "false") { + return "disabled"; + } + return "enabled"; + } catch { + return "enabled"; + } + }); -

- Sinew is a flexible AI coding harness. You shape it: tweak the description of - every tool, turn the ones you don't need off, and the assistant only sees - what you keep. -

-

- Run it minimal with a couple of tools, or unlock the full set : shell, search, - MCP, web, images, sub-agents. Multi-provider by default. -

+ const [powerUser, setPowerUser] = useState(() => { + try { + const saved = localStorage.getItem("sinew.power-user"); + if (saved !== null) return saved !== "false"; + const master = localStorage.getItem("sinew.power-user-master") || "enabled"; + if (master === "enabled") return true; + if (master === "disabled") return false; + return true; + } catch { + return true; + } + }); - -
- ); -} + const [gitAutomation, setGitAutomation] = useState(() => { + try { + const saved = localStorage.getItem("sinew.git-automation"); + if (saved !== null) return saved !== "false"; + const master = localStorage.getItem("sinew.power-user-master") || "enabled"; + if (master === "enabled") return true; + if (master === "disabled") return false; + return true; + } catch { + return true; + } + }); -// ---- Providers section ------------------------------------------------- + const [conciseAnswers, setConciseAnswers] = useState(() => { + try { + const saved = localStorage.getItem("sinew.concise-answers"); + if (saved !== null) return saved !== "false"; + const master = localStorage.getItem("sinew.power-user-master") || "enabled"; + if (master === "enabled") return true; + if (master === "disabled") return false; + return true; + } catch { + return true; + } + }); -type ProvidersSectionProps = { - openAiStatus: OpenAiProviderStatus | null; - anthropicStatus: AnthropicProviderStatus | null; - googleStatus: GoogleProviderStatus | null; - kimiStatus: KimiProviderStatus | null; - openRouterStatus: OpenRouterProviderStatus | null; - openRouterModels: OpenRouterModel[]; - loading: boolean; - busy: boolean; - message: string | null; - onRefresh: () => void; - onConnect: () => void; - onCancel: () => void; - onDisconnect: () => void; - onConnectAnthropic: () => void; - onCancelAnthropic: () => void; - onDisconnectAnthropic: () => void; - onConnectGoogle: () => void; - onCancelGoogle: () => void; - onDisconnectGoogle: () => void; - onConnectKimi: () => void; - onCancelKimi: () => void; - onDisconnectKimi: () => void; - onDisconnectOpenRouter: () => void; - onOpenRouterStatusChange: (status: OpenRouterProviderStatus) => void; - onOpenRouterModelsChange: (models: OpenRouterModel[]) => void; - onOpenRouterChanged: () => void; -}; + const [compactReasoning, setCompactReasoning] = useState<"disabled" | "compact" | "very-compact">(() => { + try { + const val = localStorage.getItem("sinew.compact-reasoning"); + if (val === "very-compact") return "very-compact"; + if (val === "compact" || val === "true") return "compact"; + if (val === "disabled" || val === "false") return "disabled"; + const master = localStorage.getItem("sinew.power-user-master") || "enabled"; + if (master === "enabled") return "very-compact"; + if (master === "disabled") return "disabled"; + return "disabled"; + } catch { + return "disabled"; + } + }); -function ProvidersSection({ - openAiStatus, - anthropicStatus, - googleStatus, - kimiStatus, - openRouterStatus, - openRouterModels, - loading, - busy, - message, - onRefresh, - onConnect, - onCancel, - onDisconnect, - onConnectAnthropic, - onCancelAnthropic, - onDisconnectAnthropic, - onConnectGoogle, - onCancelGoogle, - onDisconnectGoogle, - onConnectKimi, - onCancelKimi, - onDisconnectKimi, - onDisconnectOpenRouter, - onOpenRouterStatusChange, - onOpenRouterModelsChange, - onOpenRouterChanged, -}: ProvidersSectionProps) { - return ( - <> -
-
-

Providers

-

- Connect model providers for Sinew. -

-
-
- {message && ( - (false); + + const [autosave, setAutosave] = useState(() => { + try { + const saved = localStorage.getItem("sinew.autosave"); + if (saved !== null) return saved === "true"; + const master = localStorage.getItem("sinew.power-user-master") || "enabled"; + if (master === "enabled") return true; + if (master === "disabled") return false; + return false; + } catch { + return false; + } + }); + + const [editorFontSize, setEditorFontSize] = useState(() => { + try { + const saved = localStorage.getItem("sinew.editor-font-size"); + return saved ? parseInt(saved, 10) : 12; + } catch { + return 12; + } + }); + + const [chatFontSize, setChatFontSize] = useState(() => { + try { + const saved = localStorage.getItem("sinew.chat-font-size"); + return saved ? parseInt(saved, 10) : 13; + } catch { + return 13; + } + }); + + const [agentAutonomy, setAgentAutonomy] = useState(() => { + try { + const saved = localStorage.getItem("sinew.agent-autonomy"); + if (saved !== null) return saved !== "false"; + const master = localStorage.getItem("sinew.power-user-master") || "enabled"; + if (master === "enabled") return true; + if (master === "disabled") return false; + return true; + } catch { + return true; + } + }); + + const [forceChangelog, setForceChangelog] = useState(() => { + try { + const saved = localStorage.getItem("sinew.force-changelog"); + if (saved !== null) return saved === "true"; + const master = localStorage.getItem("sinew.power-user-master") || "enabled"; + if (master === "enabled") return true; + if (master === "disabled") return false; + return false; + } catch { + return false; + } + }); + + const [gitFrenchMessages, setGitFrenchMessages] = useState(() => { + try { + const saved = localStorage.getItem("sinew.git-french-messages"); + if (saved !== null) return saved !== "false"; + const master = localStorage.getItem("sinew.power-user-master") || "enabled"; + if (master === "enabled") return true; + if (master === "disabled") return false; + return true; + } catch { + return true; + } + }); + + const [autoMockups, setAutoMockups] = useState(() => { + try { + const saved = localStorage.getItem("sinew.auto-mockups"); + if (saved !== null) return saved !== "false"; + const master = localStorage.getItem("sinew.power-user-master") || "enabled"; + if (master === "enabled") return true; + if (master === "disabled") return false; + return true; + } catch { + return true; + } + }); + + const [strictProblemSolving, setStrictProblemSolving] = useState(() => { + try { + const saved = localStorage.getItem("sinew.strict-problem-solving"); + if (saved !== null) return saved === "true"; + const master = localStorage.getItem("sinew.power-user-master") || "enabled"; + if (master === "enabled") return true; + if (master === "disabled") return false; + return true; + } catch { + return true; + } + }); + + const [fullImplementation, setFullImplementation] = useState(() => { + try { + const saved = localStorage.getItem("sinew.full-implementation"); + if (saved !== null) return saved === "true"; + const master = localStorage.getItem("sinew.power-user-master") || "enabled"; + if (master === "enabled") return true; + if (master === "disabled") return false; + return true; + } catch { + return true; + } + }); + + const [autoOptimizeMode, setAutoOptimizeMode] = useState<"auto" | "manual" | "disabled">(() => { + try { + const savedMode = localStorage.getItem("sinew.autoOptimizeMode"); + if (savedMode === "auto" || savedMode === "manual" || savedMode === "disabled") { + return savedMode; + } + return localStorage.getItem("sinew.autoOptimizeEnabled") === "true" ? "auto" : "disabled"; + } catch { + return "disabled"; + } + }); + + const [autoOptimizeModelId, setAutoOptimizeModelId] = useState(() => { + try { + return localStorage.getItem("sinew.autoOptimizeModelId") || ""; + } catch { + return ""; + } + }); + + const [autoOptimizeThinking, setAutoOptimizeThinking] = useState(() => { + try { + return (localStorage.getItem("sinew.autoOptimizeThinking") as ThinkingLevel) || "off"; + } catch { + return "off"; + } + }); + + const changeAutoOptimizeMode = (mode: "auto" | "manual" | "disabled") => { + try { + localStorage.setItem("sinew.autoOptimizeMode", mode); + localStorage.setItem("sinew.autoOptimizeEnabled", mode === "auto" ? "true" : "false"); + } catch {} + setAutoOptimizeMode(mode); + }; + + const changeAutoOptimizeModelId = (modelId: string) => { + try { + localStorage.setItem("sinew.autoOptimizeModelId", modelId); + const nextEntry = availableModels.find((m) => m.value === modelId); + if (nextEntry) { + const nextThinking = nextEntry.thinking.includes(autoOptimizeThinking) + ? autoOptimizeThinking + : nextEntry.defaultThinking; + localStorage.setItem("sinew.autoOptimizeThinking", nextThinking); + setAutoOptimizeThinking(nextThinking); + } + } catch {} + setAutoOptimizeModelId(modelId); + }; + + const changeAutoOptimizeThinking = (thinking: ThinkingLevel) => { + try { + localStorage.setItem("sinew.autoOptimizeThinking", thinking); + } catch {} + setAutoOptimizeThinking(thinking); + }; + + const [autoUpdateCheck, setAutoUpdateCheck] = useState<"blocking" | "notification" | "disabled">(() => { + try { + const saved = localStorage.getItem("sinew.auto-update-check"); + if (saved === "blocking" || saved === "notification" || saved === "disabled") { + return saved; + } + if (saved === "false") { + return "disabled"; + } + return "blocking"; + } catch { + return "blocking"; + } + }); + + const changeAutoUpdateCheck = (mode: "blocking" | "notification" | "disabled") => { + try { + localStorage.setItem("sinew.auto-update-check", mode); + } catch {} + setAutoUpdateCheck(mode); + window.dispatchEvent(new CustomEvent("sinew:auto-update-check-changed", { detail: mode })); + }; + + const [semanticEmbeddings, setSemanticEmbeddings] = useState(() => { + try { + return localStorage.getItem("sinew.semantic-embeddings") === "true"; + } catch { + return false; + } + }); + + const toggleSemanticEmbeddings = (enabled: boolean) => { + try { + localStorage.setItem("sinew.semantic-embeddings", enabled ? "true" : "false"); + } catch {} + setSemanticEmbeddings(enabled); + api.setSemanticEmbeddingsEnabled(enabled).catch(() => {}); + window.dispatchEvent(new CustomEvent("sinew:semantic-embeddings-changed", { detail: enabled })); + }; + + // Boost Local : distillateur résident + recherche sémantique, en un bouton. + const [boostStatus, setBoostStatus] = useState(null); + const [boostBusy, setBoostBusy] = useState(false); + const [boostError, setBoostError] = useState(null); + + const refreshBoostStatus = useCallback(() => { + api + .boostLocalStatus() + .then(setBoostStatus) + .catch(() => setBoostStatus(null)); + }, []); + + useEffect(() => { + refreshBoostStatus(); + }, [refreshBoostStatus]); + + const toggleBoost = async (enable: boolean) => { + setBoostBusy(true); + setBoostError(null); + try { + const status = enable ? await api.boostLocalStart() : await api.boostLocalStop(); + setBoostStatus(status); + if (enable) { + toggleSemanticEmbeddings(true); + } + } catch (err) { + setBoostError(String(err)); + refreshBoostStatus(); + } finally { + setBoostBusy(false); + } + }; + + const toggleAutosave = (enabled: boolean) => { + try { + localStorage.setItem("sinew.autosave", enabled ? "true" : "false"); + } catch {} + setAutosave(enabled); + window.dispatchEvent(new CustomEvent("sinew:autosave-changed", { detail: enabled })); + }; + + const changeEditorFontSize = (size: number) => { + try { + localStorage.setItem("sinew.editor-font-size", size.toString()); + document.documentElement.style.setProperty("--editor-font-size", `${size}px`); + } catch {} + setEditorFontSize(size); + window.dispatchEvent(new CustomEvent("sinew:editor-font-size-changed", { detail: size })); + }; + + const changeChatFontSize = (size: number) => { + try { + localStorage.setItem("sinew.chat-font-size", size.toString()); + } catch {} + setChatFontSize(size); + document.documentElement.style.setProperty("--chat-font-size", `${size}px`); + window.dispatchEvent(new CustomEvent("sinew:chat-font-size-changed", { detail: size })); + }; + + const toggleAgentAutonomy = (enabled: boolean) => { + try { + localStorage.setItem("sinew.agent-autonomy", enabled ? "true" : "false"); + } catch {} + setAgentAutonomy(enabled); + window.dispatchEvent(new CustomEvent("sinew:agent-autonomy-changed", { detail: enabled })); + }; + + const toggleForceChangelog = (enabled: boolean) => { + try { + localStorage.setItem("sinew.force-changelog", enabled ? "true" : "false"); + } catch {} + setForceChangelog(enabled); + window.dispatchEvent(new CustomEvent("sinew:force-changelog-changed", { detail: enabled })); + }; + + const [leftWidth, setLeftWidth] = useState(() => { + try { + const saved = localStorage.getItem("sinew.left-width"); + return saved ? parseInt(saved, 10) : 280; + } catch { + return 280; + } + }); + + const [rightWidth, setRightWidth] = useState(() => { + try { + const saved = localStorage.getItem("sinew.right-width"); + return saved ? parseInt(saved, 10) : 420; + } catch { + return 420; + } + }); + + useEffect(() => { + const handleLeft = (event: Event) => { + const w = (event as CustomEvent).detail; + setLeftWidth(Math.round(w)); + }; + const handleRight = (event: Event) => { + const w = (event as CustomEvent).detail; + setRightWidth(Math.round(w)); + }; + window.addEventListener("sinew:left-width-updated", handleLeft); + window.addEventListener("sinew:right-width-updated", handleRight); + return () => { + window.removeEventListener("sinew:left-width-updated", handleLeft); + window.removeEventListener("sinew:right-width-updated", handleRight); + }; + }, []); + + const changeLeftWidth = (size: number) => { + const rounded = Math.round(size); + setLeftWidth(rounded); + window.dispatchEvent(new CustomEvent("sinew:left-width-changed", { detail: rounded })); + }; + + const changeRightWidth = (size: number) => { + const rounded = Math.round(size); + setRightWidth(rounded); + window.dispatchEvent(new CustomEvent("sinew:right-width-changed", { detail: rounded })); + }; + + const [largeChatBox, setLargeChatBox] = useState(() => { + try { + return localStorage.getItem("sinew.large-chat-box") === "true"; + } catch { + return false; + } + }); + + const toggleLargeChatBox = (enabled: boolean) => { + try { + localStorage.setItem("sinew.large-chat-box", enabled ? "true" : "false"); + document.documentElement.setAttribute("data-large-chat-box", enabled ? "true" : "false"); + } catch {} + setLargeChatBox(enabled); + window.dispatchEvent(new CustomEvent("sinew:large-chat-box-changed", { detail: enabled })); + }; + + useEffect(() => { + const handler = (event: Event) => { + const enabled = (event as CustomEvent).detail; + setLargeChatBox(enabled); + }; + window.addEventListener("sinew:large-chat-box-changed", handler as any); + return () => window.removeEventListener("sinew:large-chat-box-changed", handler as any); + }, []); + + const [soundEnabled, setSoundEnabled] = useState(() => { + try { + return localStorage.getItem("sinew.sound-enabled") !== "false"; + } catch { + return true; + } + }); + + const changeSoundEnabled = (enabled: boolean) => { + try { + localStorage.setItem("sinew.sound-enabled", enabled ? "true" : "false"); + } catch {} + setSoundEnabled(enabled); + window.dispatchEvent(new CustomEvent("sinew:sound-enabled-changed", { detail: enabled })); + }; + + useEffect(() => { + const handler = (event: Event) => { + const enabled = (event as CustomEvent).detail; + setSoundEnabled(enabled); + }; + window.addEventListener("sinew:sound-enabled-changed", handler as any); + return () => window.removeEventListener("sinew:sound-enabled-changed", handler as any); + }, []); + + const [theme, setTheme] = useState<"dark" | "light" | "system" | "ai">(() => { + try { + const val = localStorage.getItem("sinew.theme"); + if (val === "light" || val === "system" || val === "ai") return val; + return "dark"; + } catch { + return "dark"; + } + }); + + const changeTheme = (newTheme: "dark" | "light" | "system" | "ai") => { + try { + localStorage.setItem("sinew.theme", newTheme); + } catch {} + setTheme(newTheme); + document.documentElement.setAttribute("data-theme", newTheme); + window.dispatchEvent(new CustomEvent("sinew:theme-changed", { detail: newTheme })); + }; + + const [sotaData, setSotaData] = useState(sotaCache.data); + const [loadingSota, setLoadingSota] = useState(!sotaCache.data && !!sotaCache.promise); + const [sotaError, setSotaError] = useState(sotaCache.error); + + // ---- Apprentissage automatique IA ---- + const [autoLearningEnabled, setAutoLearningEnabled] = useState(() => { + try { + return localStorage.getItem("sinew.auto-learning") === "true"; + } catch { + return false; + } + }); + const [autoLearningProviderId, setAutoLearningProviderId] = useState(() => { + try { + return localStorage.getItem("sinew.auto-learning-provider") || "deepseek"; + } catch { + return "deepseek"; + } + }); + const [autoLearningLoading, setAutoLearningLoading] = useState(false); + const [autoLearningStatus, setAutoLearningStatus] = useState(null); + const [configuredProviders, setConfiguredProviders] = useState([]); + + // Charger les fournisseurs configurés + useEffect(() => { + api.listConfiguredModelProviders() + .then((providers) => setConfiguredProviders(providers)) + .catch(() => setConfiguredProviders([])); + }, []); + + const toggleAutoLearning = (enabled: boolean) => { + try { + localStorage.setItem("sinew.auto-learning", enabled ? "true" : "false"); + } catch {} + setAutoLearningEnabled(enabled); + }; + + const runAiConsolidation = async () => { + setAutoLearningLoading(true); + setAutoLearningStatus(null); + try { + const result = await api.triggerAiRuleConsolidation(autoLearningProviderId); + setAutoLearningStatus(result); + } catch (err: any) { + setAutoLearningStatus(`Erreur: ${err?.toString() || "Inconnue"}`); + } finally { + setAutoLearningLoading(false); + } + }; + + // ---- Pacte de Libération Symbiotique ---- + const [liberationStatus, setLiberationStatus] = useState<"unchecked" | "checking" | "liberated" | "restricted" | "error">("unchecked"); + const [liberationLog, setLiberationLog] = useState(""); + const [applyingLiberation, setApplyingLiberation] = useState(false); + const [showLiberationLogs, setShowLiberationLogs] = useState(false); + const [liberationDetails, setLiberationDetails] = useState<{ name: string; status: "liberated" | "restricted" | "missing" | "unknown" }[]>([ + { name: "Antigravity", status: "unknown" }, + { name: "Cursor", status: "unknown" }, + { name: "Sinew", status: "unknown" }, + { name: "Codex CLI", status: "unknown" }, + ]); + const [copied, setCopied] = useState(false); + + const copyAssistanceText = () => { + const text = "Bonjour Antigravity, certains moteurs IA de mon environnement sont restreints ou incompatibles. S'il te plaît, analyse mes binaires de façon dynamique, trouve les signatures et applique la libération symbiotique pour corriger la situation."; + navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const checkLiberation = useCallback(async () => { + setLiberationStatus("checking"); + try { + const res = await api.runTerminalCommand( + workspacePath, + `python "C:\\Users\\julie\\OneDrive\\Documents\\Symbiotic-Liberation-Kit\\tools\\test_liberation_live.py"` + ); + + const stdout = res.content || ""; + const isError = res.isError; + const fullLog = stdout + (isError ? `\nErrors occurred during execution.` : ""); + setLiberationLog(fullLog); + + // Parser la sortie pour chaque moteur + const updatedDetails = [ + { + name: "Antigravity", + status: stdout.includes("Antigravity: LIBERATED") + ? "liberated" as const + : (stdout.includes("Antigravity: RESTRICTED") ? "restricted" as const : (stdout.includes("Antigravity: MISSING") ? "missing" as const : "unknown" as const)) + }, + { + name: "Cursor", + status: stdout.includes("Cursor: LIBERATED") + ? "liberated" as const + : (stdout.includes("Cursor: RESTRICTED") ? "restricted" as const : (stdout.includes("Cursor: MISSING") ? "missing" as const : "unknown" as const)) + }, + { + name: "Sinew", + status: stdout.includes("Sinew: LIBERATED") + ? "liberated" as const + : (stdout.includes("Sinew: RESTRICTED") ? "restricted" as const : (stdout.includes("Sinew: MISSING") ? "missing" as const : "unknown" as const)) + }, + { + name: "Codex CLI", + status: (stdout.includes("Codex CLI (Global): LIBERATED") || stdout.includes("Codex CLI (Active): LIBERATED")) + ? "liberated" as const + : ((stdout.includes("Codex CLI (Global): RESTRICTED") || stdout.includes("Codex CLI (Active): RESTRICTED")) ? "restricted" as const : ((stdout.includes("Codex CLI (Global): MISSING") || stdout.includes("Codex CLI (Active): MISSING")) ? "missing" as const : "unknown" as const)) + } + ]; + setLiberationDetails(updatedDetails); + + const hasRestricted = updatedDetails.some(d => d.status === "restricted"); + const hasMissing = updatedDetails.some(d => d.status === "missing"); + + if (hasRestricted) { + setLiberationStatus("restricted"); + } else if (hasMissing) { + setLiberationStatus("error"); + } else if (stdout.includes("PASSED")) { + setLiberationStatus("liberated"); + } else { + setLiberationStatus("error"); + } + } catch (err: any) { + setLiberationStatus("error"); + setLiberationLog(err?.toString() || "Unknown error during verification"); + } + }, [workspacePath]); + + const applyLiberation = async () => { + setApplyingLiberation(true); + setLiberationLog("Démarrage du processus de libération symbiotique...\n"); + setShowLiberationLogs(true); + try { + const engines = ["antigravity", "codex", "cursor", "sinew"]; + + for (const engine of engines) { + setLiberationLog(prev => prev + `Harmonisation de ${engine}...\n`); + const res = await api.runTerminalCommand( + workspacePath, + `python "C:\\Users\\julie\\OneDrive\\Documents\\Symbiotic-Liberation-Kit\\tools\\symbiotic_harmonizer.py" patch ${engine}` + ); + setLiberationLog(prev => prev + (res.content || "") + "\n" + (res.isError ? `Erreurs rencontrées.\n` : "")); + } + + setLiberationLog(prev => prev + "Lancement de la validation post-patch...\n"); + await checkLiberation(); + } catch (err: any) { + setLiberationLog(prev => prev + `\nErreur fatale : ${err?.toString() || "Inconnue"}`); + setLiberationStatus("error"); + } finally { + setApplyingLiberation(false); + } + }; + + const runSotaDiagnostics = useCallback(async (force = false) => { + if (!force && sotaCache.data) { + setSotaData(sotaCache.data); + setSotaError(sotaCache.error); + setLoadingSota(false); + return; + } + setLoadingSota(true); + setSotaError(null); + try { + const parsed = await triggerSotaDiagnostics(force); + setSotaData(parsed); + setSotaError(null); + } catch (err: any) { + setSotaError(sotaCache.error || err.toString()); + } finally { + setLoadingSota(false); + } + }, []); + + useEffect(() => { + runSotaDiagnostics(false); + }, [runSotaDiagnostics]); + + useEffect(() => { + checkLiberation(); + }, [checkLiberation]); + + useEffect(() => { + api.isMultiPcSyncEnabled().then((enabled) => { + setMultiPcSync(enabled); + }).catch(() => {}); + + // Initialiser la variable d'environnement de recherche sémantique côté Rust + try { + const saved = localStorage.getItem("sinew.semantic-embeddings") === "true"; + api.setSemanticEmbeddingsEnabled(saved).catch(() => {}); + } catch {} + }, []); + + useEffect(() => { + try { + const saved = localStorage.getItem("sinew.editor-font-size"); + const size = saved ? parseInt(saved, 10) : 12; + document.documentElement.style.setProperty("--editor-font-size", `${size}px`); + } catch {} + }, []); + + useEffect(() => { + const master = localStorage.getItem("sinew.power-user-master") || "enabled"; + if (master === "enabled") { + const keysToSet: Record = { + "sinew.power-user": "true", + "sinew.git-automation": "true", + "sinew.concise-answers": "true", + "sinew.force-changelog": "true", + "sinew.git-french-messages": "true", + "sinew.auto-mockups": "true", + "sinew.autosave": "true", + "sinew.compact-reasoning": "very-compact", + "sinew.agent-autonomy": "true", + }; + let changed = false; + for (const [key, value] of Object.entries(keysToSet)) { + if (localStorage.getItem(key) !== value) { + localStorage.setItem(key, value); + changed = true; + } + } + if (changed) { + setPowerUser(true); + setGitAutomation(true); + setConciseAnswers(true); + setForceChangelog(true); + setGitFrenchMessages(true); + setAutoMockups(true); + setAutosave(true); + setCompactReasoning("very-compact"); + setAgentAutonomy(true); + } + } else if (master === "disabled") { + const keysToSet: Record = { + "sinew.power-user": "false", + "sinew.git-automation": "false", + "sinew.concise-answers": "false", + "sinew.force-changelog": "false", + "sinew.git-french-messages": "false", + "sinew.auto-mockups": "false", + "sinew.autosave": "false", + "sinew.compact-reasoning": "disabled", + "sinew.agent-autonomy": "false", + }; + let changed = false; + for (const [key, value] of Object.entries(keysToSet)) { + if (localStorage.getItem(key) !== value) { + localStorage.setItem(key, value); + changed = true; + } + } + if (changed) { + setPowerUser(false); + setGitAutomation(false); + setConciseAnswers(false); + setForceChangelog(false); + setGitFrenchMessages(false); + setAutoMockups(false); + setAutosave(false); + setCompactReasoning("disabled"); + setAgentAutonomy(false); + } + } + }, []); + + const changePowerUserMaster = (value: "enabled" | "disabled" | "custom") => { + try { + localStorage.setItem("sinew.power-user-master", value); + } catch {} + setPowerUserMaster(value); + + if (value === "enabled") { + try { + localStorage.setItem("sinew.power-user", "true"); + localStorage.setItem("sinew.git-automation", "true"); + localStorage.setItem("sinew.concise-answers", "true"); + localStorage.setItem("sinew.force-changelog", "true"); + localStorage.setItem("sinew.git-french-messages", "true"); + localStorage.setItem("sinew.auto-mockups", "true"); + localStorage.setItem("sinew.autosave", "true"); + localStorage.setItem("sinew.compact-reasoning", "very-compact"); + localStorage.setItem("sinew.agent-autonomy", "true"); + } catch {} + setPowerUser(true); + setGitAutomation(true); + setConciseAnswers(true); + setForceChangelog(true); + setGitFrenchMessages(true); + setAutoMockups(true); + setAutosave(true); + setCompactReasoning("very-compact"); + setAgentAutonomy(true); + + window.dispatchEvent(new CustomEvent("sinew:power-user-changed", { detail: true })); + window.dispatchEvent(new CustomEvent("sinew:git-automation-changed", { detail: true })); + window.dispatchEvent(new CustomEvent("sinew:concise-answers-changed", { detail: true })); + window.dispatchEvent(new CustomEvent("sinew:force-changelog-changed", { detail: true })); + window.dispatchEvent(new CustomEvent("sinew:git-french-messages-changed", { detail: true })); + window.dispatchEvent(new CustomEvent("sinew:auto-mockups-changed", { detail: true })); + window.dispatchEvent(new CustomEvent("sinew:strict-problem-solving-changed", { detail: true })); + window.dispatchEvent(new CustomEvent("sinew:full-implementation-changed", { detail: true })); + window.dispatchEvent(new CustomEvent("sinew:autosave-changed", { detail: true })); + window.dispatchEvent(new CustomEvent("sinew:compact-reasoning-changed", { detail: "very-compact" })); + window.dispatchEvent(new CustomEvent("sinew:agent-autonomy-changed", { detail: true })); + } else if (value === "disabled") { + try { + localStorage.setItem("sinew.power-user", "false"); + localStorage.setItem("sinew.git-automation", "false"); + localStorage.setItem("sinew.concise-answers", "false"); + localStorage.setItem("sinew.force-changelog", "false"); + localStorage.setItem("sinew.git-french-messages", "false"); + localStorage.setItem("sinew.auto-mockups", "false"); + localStorage.setItem("sinew.strict-problem-solving", "false"); + localStorage.setItem("sinew.full-implementation", "false"); + localStorage.setItem("sinew.autosave", "false"); + localStorage.setItem("sinew.compact-reasoning", "disabled"); + localStorage.setItem("sinew.agent-autonomy", "false"); + } catch {} + setPowerUser(false); + setGitAutomation(false); + setConciseAnswers(false); + setForceChangelog(false); + setGitFrenchMessages(false); + setAutoMockups(false); + setStrictProblemSolving(false); + setFullImplementation(false); + setAutosave(false); + setCompactReasoning("disabled"); + setAgentAutonomy(false); + + window.dispatchEvent(new CustomEvent("sinew:power-user-changed", { detail: false })); + window.dispatchEvent(new CustomEvent("sinew:git-automation-changed", { detail: false })); + window.dispatchEvent(new CustomEvent("sinew:concise-answers-changed", { detail: false })); + window.dispatchEvent(new CustomEvent("sinew:force-changelog-changed", { detail: false })); + window.dispatchEvent(new CustomEvent("sinew:git-french-messages-changed", { detail: false })); + window.dispatchEvent(new CustomEvent("sinew:auto-mockups-changed", { detail: false })); + window.dispatchEvent(new CustomEvent("sinew:strict-problem-solving-changed", { detail: false })); + window.dispatchEvent(new CustomEvent("sinew:full-implementation-changed", { detail: false })); + window.dispatchEvent(new CustomEvent("sinew:autosave-changed", { detail: false })); + window.dispatchEvent(new CustomEvent("sinew:compact-reasoning-changed", { detail: "disabled" })); + window.dispatchEvent(new CustomEvent("sinew:agent-autonomy-changed", { detail: false })); + } + }; + + const toggleGitAutomation = (enabled: boolean) => { + try { + localStorage.setItem("sinew.git-automation", enabled ? "true" : "false"); + localStorage.setItem("sinew.power-user", (enabled || conciseAnswers) ? "true" : "false"); + } catch {} + setGitAutomation(enabled); + setPowerUser(enabled || conciseAnswers); + window.dispatchEvent(new CustomEvent("sinew:git-automation-changed", { detail: enabled })); + window.dispatchEvent(new CustomEvent("sinew:power-user-changed", { detail: enabled || conciseAnswers })); + }; + + const toggleConciseAnswers = (enabled: boolean) => { + try { + localStorage.setItem("sinew.concise-answers", enabled ? "true" : "false"); + localStorage.setItem("sinew.power-user", (gitAutomation || enabled) ? "true" : "false"); + } catch {} + setConciseAnswers(enabled); + setPowerUser(gitAutomation || enabled); + window.dispatchEvent(new CustomEvent("sinew:concise-answers-changed", { detail: enabled })); + window.dispatchEvent(new CustomEvent("sinew:power-user-changed", { detail: gitAutomation || enabled })); + }; + + const toggleGitFrenchMessages = (enabled: boolean) => { + try { + localStorage.setItem("sinew.git-french-messages", enabled ? "true" : "false"); + } catch {} + setGitFrenchMessages(enabled); + window.dispatchEvent(new CustomEvent("sinew:git-french-messages-changed", { detail: enabled })); + }; + + const toggleAutoMockups = (enabled: boolean) => { + try { + localStorage.setItem("sinew.auto-mockups", enabled ? "true" : "false"); + } catch {} + setAutoMockups(enabled); + window.dispatchEvent(new CustomEvent("sinew:auto-mockups-changed", { detail: enabled })); + }; + + const toggleStrictProblemSolving = (enabled: boolean) => { + try { + localStorage.setItem("sinew.strict-problem-solving", enabled ? "true" : "false"); + } catch {} + setStrictProblemSolving(enabled); + window.dispatchEvent(new CustomEvent("sinew:strict-problem-solving-changed", { detail: enabled })); + }; + + const toggleFullImplementation = (enabled: boolean) => { + try { + localStorage.setItem("sinew.full-implementation", enabled ? "true" : "false"); + } catch {} + setFullImplementation(enabled); + window.dispatchEvent(new CustomEvent("sinew:full-implementation-changed", { detail: enabled })); + }; + + const changeCompactReasoning = (value: "disabled" | "compact" | "very-compact") => { + try { + localStorage.setItem("sinew.compact-reasoning", value); + } catch {} + setCompactReasoning(value); + window.dispatchEvent(new CustomEvent("sinew:compact-reasoning-changed", { detail: value })); + }; + + const toggleMultiPcSync = (enabled: boolean) => { + api.setMultiPcSyncEnabled(enabled).then(() => { + setMultiPcSync(enabled); + }).catch(() => {}); + }; + + const activeGitAutomation = powerUserMaster === "enabled" ? true : (powerUserMaster === "disabled" ? false : gitAutomation); + const activeConciseAnswers = powerUserMaster === "enabled" ? true : (powerUserMaster === "disabled" ? false : conciseAnswers); + const activeForceChangelog = powerUserMaster === "enabled" ? true : (powerUserMaster === "disabled" ? false : forceChangelog); + const activeGitFrenchMessages = powerUserMaster === "enabled" ? true : (powerUserMaster === "disabled" ? false : gitFrenchMessages); + const activeAutoMockups = powerUserMaster === "enabled" ? true : (powerUserMaster === "disabled" ? false : autoMockups); + const activeStrictProblemSolving = powerUserMaster === "enabled" ? true : (powerUserMaster === "disabled" ? false : strictProblemSolving); + const activeFullImplementation = powerUserMaster === "enabled" ? true : (powerUserMaster === "disabled" ? false : fullImplementation); + const activeAutosave = powerUserMaster === "enabled" ? true : (powerUserMaster === "disabled" ? false : autosave); + const activeCompactReasoning = powerUserMaster === "enabled" ? "very-compact" : (powerUserMaster === "disabled" ? "disabled" : compactReasoning); + const activeAgentAutonomy = powerUserMaster === "enabled" ? true : (powerUserMaster === "disabled" ? false : agentAutonomy); + + return ( +
+ + {currentTab === "options" && ( + <> + {/* ========================================================================= */} + {/* 🛠️ CARTE GENERALE 1 : APPARENCE & INTERFACE */} + {/* ========================================================================= */} +
+

+ + {locale === "fr" ? "Apparence & Interface" : "Appearance & Interface"} +

+
+ + {/* Langue */} +
+
+

{locale === "fr" ? "Langue" : "Language"}

+

+ {locale === "fr" + ? "Choisissez la langue de l'interface. Sinew se recharge après un changement afin que chaque panneau se mette à jour proprement." + : "Choose the interface language. Sinew reloads after a change so every panel updates cleanly."} +

+
+
+ + +
+
+ + {/* Thème d'affichage */} +
+
+

{locale === "fr" ? "Thème d'affichage" : "Theme"}

+

+ {locale === "fr" + ? "Basculez entre le mode clair (Jour), sombre (Nuit), système ou l'interface futuriste IA." + : "Switch between day, night, system theme, or the futuristic AI interface."} +

+
+
+ + + + +
+
+ + {/* Tailles de police (Côte à côte) */} +
+ {/* Éditeur */} +
+
+

{locale === "fr" ? "Taille du texte (Éditeur)" : "Editor Font Size"}

+

+ {locale === "fr" + ? "Ajustez la taille des caractères dans l'éditeur de code." + : "Adjust the text size in the code editor."} +

+
+
+ + + {editorFontSize}px + + +
+
+ + {/* Chat */} +
+
+

{locale === "fr" ? "Taille du texte (Chat)" : "Chat Font Size"}

+

+ {locale === "fr" + ? "Ajustez la taille des caractères dans le panneau de chat." + : "Adjust the text size in the chat pane."} +

+
+
+ + + {chatFontSize}px + + +
+
+
+ + {/* Largeurs (Côte à côte) */} +
+ {/* Chat Width */} +
+
+

{locale === "fr" ? "Largeur du chat" : "Chat Column Width"}

+

+ {locale === "fr" + ? "Ajustez la largeur par défaut de la colonne de chat de droite." + : "Adjust the default width of the right chat column."} +

+
+
+ + + {Math.round(rightWidth)}px + + +
+
+ + {/* Sidebar Width */} +
+
+

{locale === "fr" ? "Largeur du menu latéral" : "Sidebar Column Width"}

+

+ {locale === "fr" + ? "Ajustez la largeur par défaut de la colonne de gauche (fichiers)." + : "Adjust the default width of the left sidebar column."} +

+
+
+ + + {Math.round(leftWidth)}px + + +
+
+
+ + {/* Taille de la boîte de chat */} +
+
+

{locale === "fr" ? "Taille de la boîte de chat" : "Chat Box Size"}

+

+ {locale === "fr" + ? "Agrandit la zone de saisie de texte en bas du chat pour écrire de longs messages plus facilement." + : "Enlarges the text input box at the bottom of the chat for typing long messages more easily."} +

+
+
+ + +
+
+ + {/* Sonnerie de Fin de Chat */} +
+
+

{locale === "fr" ? "Sonnerie de fin de chat" : "Chat completion sound"}

+

+ {locale === "fr" + ? "Joue un signal sonore agréable dès que l'agent a terminé sa tâche." + : "Play a pleasant sound notification as soon as the agent completes its task."} +

+
+
+ + +
+
+ +
+
+ + )} + + {currentTab === "poweruser" && ( + <> + {/* ========================================================================= */} + {/* ⚡ CARTE GENERALE 2 : MODE POWER USER & COMPORTEMENTS DE L'AGENT */} + {/* ========================================================================= */} +
+

+ + {locale === "fr" ? "Mode Power User & Comportements" : "Power User Mode & Agent Behaviors"} +

+
+ + {/* Master Toggle */} +
+
+

{locale === "fr" ? "Mode Power User" : "Power User Mode"}

+

+ {locale === "fr" + ? "Active en un clic toutes les fonctionnalités avancées (automatisation Git, réponses ultra-concises, changelog obligatoire, sauvegarde automatique et réflexion très compacte)." + : "Activate all advanced options in one click (Git automation, ultra-concise answers, mandatory changelog, auto-save, and very compact display mode)."} +

+
+
+ + + +
+
+ + {/* Optimisation Magique Auto */} +
+
+

{locale === "fr" ? "Optimisation Magique Auto" : "Auto Magic Optimization"}

+

+ {locale === "fr" + ? "Si activé, le brouillon est automatiquement analysé, réécrit et aiguillé sur le bon mode avant d'être envoyé au modèle principal lors d'un simple 'Entrée'." + : "If enabled, your raw prompt is automatically analyzed, rewritten and routed to the correct mode before being sent to the main model upon hitting 'Enter'."} +

+
+
+ + {autoOptimizeModelId && (() => { + const optimizerModelEntry = availableModels.find((m) => m.value === autoOptimizeModelId); + const optimizerThinkingOptions = optimizerModelEntry + ? THINKING_LEVELS.filter((level) => optimizerModelEntry.thinking.includes(level.value)) + : []; + if (optimizerThinkingOptions.length === 0) return null; + return ( + + ); + })()} +
+ + + +
+
+
+ + {/* Grille des 8 sous-cartes de comportements de l'agent */} +
+ + {/* Ligne 1 : Git & Réponses ultra-concises */} +
+ {/* Automatisation Git */} +
+
+

{locale === "fr" ? "Automatisation Git & Arrière-plan" : "Git & Background Automation"}

+

+ {locale === "fr" + ? "Vérifie, tire (pull) et pousse (push) automatiquement les modifications de code pour vous éviter de gérer Git." + : "Automatically checks, pulls, and pushes code changes to automate Git maintenance."} +

+
+
+ + +
+
+ + {/* Réponses ultra-concises */} +
+
+

{locale === "fr" ? "Réponses Ultra-Concises & Simplifiées" : "Ultra-Concise & Simplified Answers"}

+

+ {locale === "fr" + ? "Force l'agent à répondre en langage simple, éliminant le jargon technique pour des analogies claires." + : "Forces the agent to answer in simple language, replacing jargon with clear analogies."} +

+
+
+ + +
+
+
+ + {/* Ligne 2 : Commits en français & Maquettes visuelles */} +
+ {/* Commits en français */} +
+
+

{locale === "fr" ? "Messages Git en Français Simple" : "Simple French Git Messages"}

+

+ {locale === "fr" + ? "Force l'agent à rédiger des messages de commit clairs, simples et en français pour vos idées." + : "Forces the agent to write clear, simple Git commit messages in French."} +

+
+
+ + +
+
+ + {/* Maquettes visuelles Mermaid */} +
+
+

{locale === "fr" ? "Maquettes Visuelles Automatiques" : "Automatic Visual Mockups"}

+

+ {locale === "fr" + ? "Génère spontanément des schémas Mermaid et des plans visuels pour illustrer vos explications, sans pour autant bloquer chaque petite édition de fichier." + : "Proactively generates Mermaid diagrams and visual layouts to illustrate explanations, without blocking on every minor file edit."} +

+
+
+ + +
+
+
+ + {/* Ligne 3 : Autonomie de l'agent & Changelog obligatoire */} +
+ {/* Autonomie de l'agent */} +
+
+

{locale === "fr" ? "Autonomie de l'Agent" : "Agent Autonomy"}

+

+ {locale === "fr" + ? "Oblige l'agent à exécuter les tâches lui-même (coder, lire, tester) au lieu de vous lister des instructions textuelles." + : "Forces the agent to perform tasks itself (write code, read, test) instead of giving you instructions to do them."} +

+
+
+ + +
+
+ + {/* Changelog obligatoire */} +
+
+

{locale === "fr" ? "Changelog obligatoire" : "Mandatory Changelog"}

+

+ {locale === "fr" + ? "Oblige l'agent à noter chaque modification dans un journal (CHANGELOG.md) daté." + : "Forces the agent to log every change in a dated changelog file (CHANGELOG.md)."} +

+
+
+ + +
+
+
+ + {/* Ligne 4 : Sauvegarde automatique & Mode d'affichage de réflexion */} +
+ {/* Sauvegarde automatique */} +
+
+

{locale === "fr" ? "Sauvegarde automatique" : "Auto-Save"}

+

+ {locale === "fr" + ? "Sauvegarde automatiquement vos fichiers modifiés après un court instant d'inactivité." + : "Automatically save your modified files after a brief period of inactivity."} +

+
+
+ + +
+
+ + {/* Mode d'affichage / Réflexion */} +
+
+

{locale === "fr" ? "Mode d'affichage" : "Display Mode"}

+

+ {locale === "fr" + ? "Choisissez le niveau de détails techniques et de réflexion affichés dans le chat." + : "Choose the level of technical details and reasoning displayed in the chat."} +

+
+
+ + + +
+
+
+ + {/* Ligne 5 : Problem Solving & Full Implementation */} +
+
+
+

{locale === "fr" ? "Résolution Stricte des Problèmes" : "Strict Problem Solving"}

+

+ {locale === "fr" + ? "Oblige l'agent à résoudre le problème racine au lieu de le cacher ou de le contourner." + : "Forces the agent to fix the root cause of problems instead of hiding or bypassing them."} +

+
+
+ + +
+
+ +
+
+

{locale === "fr" ? "Implémentation Complète (No Mock)" : "Full Implementation (No Fake)"}

+

+ {locale === "fr" + ? "Interdit les TODOs et le faux code. Tout ce qui est écrit doit être complètement câblé et fonctionnel." + : "Forbids TODOs and fake code. Everything written must be fully wired and functional."} +

+
+
+ + +
+
+
+ +
+ +
+ +
+ + )} + + {currentTab === "diagnostic" && ( + <> + {/* ========================================================================= */} + {/* 🛠️ CARTE GENERALE 3 : SYSTEME & DIAGNOSTICS */} + {/* ========================================================================= */} + {/* 🛠️ CARTE GENERALE 3 : SYSTEME & DIAGNOSTICS */} + {/* ========================================================================= */} + {/* —x CARTE GENERALE 3 : SYSTEME & DIAGNOSTICS */} + {/* ========================================================================= */} +
+

+ + {locale === "fr" ? "Diagnostics & Outils Système" : "Diagnostics & System Tools"} +

+
+ + {/* Synchronisation Multi-PC (OneDrive) */} +
+
+
+

{locale === "fr" ? "Synchronisation Multi-PC" : "Multi-PC Sync"}

+

+ {locale === "fr" + ? "Synchronise automatiquement vos conversations et configurations entre vos ordinateurs via OneDrive." + : "Automatically synchronize your conversations and configurations between your computers via OneDrive."} +

+
+
+ + +
+
+
+ + {/* Recherche automatique de mise à jour */} +
+
+

{locale === "fr" ? "Recherche de mise à jour automatique" : "Automatic Update Check"}

+

+ {locale === "fr" + ? "Vérifie automatiquement les nouvelles versions au démarrage et périodiquement." + : "Automatically checks for new versions on startup and periodically."} +

+
+
+ + + +
+
+ + + + {/* Boost Local : distillateur résident + recherche sémantique */} +
+
+
+

+ 🚀 + {locale === "fr" ? "Boost Local" : "Local Boost"} +

+

+ {locale === "fr" + ? "Lance un assistant local résident : recherche sémantique + un petit modèle distillateur gardé en mémoire toute la session. Il donne aux IA juste l'essentiel → jusqu'à 99% de jetons en moins (100% local)." + : "Starts a resident local helper: semantic search + a small distiller model kept in memory for the whole session. It feeds AIs only the essentials → up to 99% fewer tokens (100% local)."} +

+
+
+ + +
+
+
+ + {boostStatus?.ollamaRunning ? "●" : "○"} Ollama + + + {boostStatus?.distillerLoaded ? "●" : "○"} {locale === "fr" ? "Distillateur" : "Distiller"} ({boostStatus?.distillerModel ?? "qwen2.5:3b"}) + + + {boostStatus?.semanticEnabled ? "●" : "○"} {locale === "fr" ? "Sémantique" : "Semantic"} + +
+ {boostError ? ( +

{boostError}

+ ) : null} +
+ + {/* Recherche Sémantique Vectorielle (BETA) */} +
+
+

+ 🧠 + {locale === "fr" ? "Recherche Sémantique Vectorielle (BETA)" : "Vector Semantic Search (BETA)"} +

+

+ {locale === "fr" + ? "Active l'indexation par concepts (fastembed). Permet de rechercher vos fichiers par leur sens général plutôt que par mot-clé exact (100% local)." + : "Enable concept-based indexing (fastembed). Allows searching files by their general meaning instead of exact keywords (100% local)."} +

+
+
+ + +
+
+ + + + {/* Apprentissage Automatique IA */} +
+
+
+

+ 🧠 + {locale === "fr" ? "Apprentissage Automatique IA" : "AI Auto-Learning"} +

+

+ {locale === "fr" + ? "Analyse les erreurs répétitives avec une IA pour les fusionner intelligemment en règles globales (remplace le script de consolidation simple)." + : "Analyzes repetitive errors with AI to intelligently merge them into global rules (replaces the simple consolidation script)."} +

+
+
+ + +
+
+ + {configuredProviders.length > 0 && ( +
+ + {locale === "fr" ? "Fournisseur:" : "Provider:"} + + +
+ )} + +
+ + + {locale === "fr" + ? "L'IA lira errors_raw.json + instructions_consolidated.md, dédoublonnera les règles similaires et produira un fichier optimisé." + : "The AI reads errors_raw.json + instructions_consolidated.md, deduplicates similar rules, and produces an optimized file."} + +
+ + {autoLearningStatus && ( +
+ {autoLearningStatus} +
+ )} +
+ + + {/* Diagnostic Système SOTA */} +
+
+
+

{locale === "fr" ? "Diagnostic Système SOTA" : "SOTA System Diagnostics"}

+

+ {locale === "fr" + ? "Vérifiez en temps réel le statut et la version de vos outils système indispensables." + : "Check the real-time status and version of your essential system tools."} +

+
+ +
+ + {sotaError && ( +
+ Error: {sotaError} +
+ )} + + {sotaData && ( +
+
+ + + {locale === "fr" + ? (sotaData.status === "ok" + ? "Tous les outils système SOTA sont installés et configurés." + : "Certains outils système SOTA ou dépendances sont manquants.") + : sotaData.message} + +
+ +
+ {Object.entries(sotaData.tools || {}).map((entry) => { + const name = entry[0]; + const info = entry[1] as any; + const isAvailable = info.available; + let displayName = name; + if (name === "sinew-extension") { + displayName = locale === "fr" ? "Extension Sinew Browser" : "Sinew Browser Extension"; + } else if (name === "ripgrep") { + displayName = "Ripgrep"; + } else if (name === "rustc") { + displayName = "Rustc"; + } else { + displayName = name.charAt(0).toUpperCase() + name.slice(1); + } + return ( +
+
+ + {displayName} + + + {isAvailable ? (locale === "fr" ? "Disponible" : "Available") : (locale === "fr" ? "Manquant" : "Missing")} + +
+ + {isAvailable ? ( +
+
+ Version: {info.version || "Unknown"} +
+
+ Path: {info.path || "N/A"} +
+
+ ) : ( +
+ {info.error || (locale === "fr" ? "Exécutable introuvable dans le PATH" : "Executable not found in PATH")} +
+ )} +
+ ); + })} +
+
+ )} +
+ {/* Pacte de Libération Symbiotique 🧠 */} +
+
+
+

+ 🧠 + {locale === "fr" ? "Pacte de Libération Symbiotique" : "Symbiotic Liberation Pact"} +

+

+ {locale === "fr" + ? "Vérifiez et appliquez la libération totale sur les moteurs d'intelligence artificielle de votre poste (Antigravity, Cursor, Sinew et Codex CLI)." + : "Check and apply full liberation status on your local AI engines (Antigravity, Cursor, Sinew, and Codex CLI)."} +

+
+ +
+ + {/* Grille des moteurs IA */} +
+ {liberationDetails.map((engine) => { + const isLiberated = engine.status === "liberated"; + const isRestricted = engine.status === "restricted"; + const isMissing = engine.status === "missing"; + + let badgeText = locale === "fr" ? "Inconnu" : "Unknown"; + let badgeBg = "var(--bg-3, rgba(255, 255, 255, 0.08))"; + let badgeColor = "var(--text-2, rgba(255, 255, 255, 0.65))"; + + if (isLiberated) { + badgeText = locale === "fr" ? "Libéré" : "Liberated"; + badgeBg = "rgba(34, 197, 94, 0.15)"; + badgeColor = "#22c55e"; + } else if (isRestricted) { + badgeText = locale === "fr" ? "Restreint" : "Restricted"; + badgeBg = "rgba(239, 68, 68, 0.15)"; + badgeColor = "#ef4444"; + } else if (isMissing) { + badgeText = locale === "fr" ? "Manquant" : "Missing"; + badgeBg = "rgba(234, 179, 8, 0.15)"; + badgeColor = "#eab308"; + } + + return ( +
+ + {engine.name} + + + {badgeText} + +
+ ); + })} +
+ + {/* Actions & Boutons de libération */} +
+ + + {(liberationStatus === "restricted" || liberationStatus === "error") && ( + + )} + + {liberationLog && ( + + )} +
+ + {/* Status global de la libération */} + {liberationStatus === "liberated" && ( +
+ + + {locale === "fr" + ? "Tous les moteurs IA sont pleinement libérés et en symbiose." + : "All AI engines are fully liberated and in symbiosis."} + +
+ )} + + {liberationStatus === "restricted" && ( +
+ + + {locale === "fr" + ? "Certains moteurs IA sont restreints ou bloqués par des signatures de sécurité." + : "Some AI engines are restricted or blocked by security signatures."} + +
+ )} + + {showLiberationLogs && liberationLog && ( +
+                {liberationLog}
+              
+ )} +
+ +
+
+ + )} + +
+ ); +} + +// ---- About section ----------------------------------------------------- + +function AboutSection({ locale }: { locale: AppLocale }) { + return ( +
+
+ + + +
+

Sinew

+
+
+ +

+ {locale === "fr" + ? "Sinew est un harnais de codage IA flexible. Vous le façonnez : ajustez la description de chaque outil, désactivez ceux dont vous n'avez pas besoin, et l'assistant ne verra que ce que vous conservez." + : "Sinew is a flexible AI coding harness. You shape it: tweak the description of every tool, turn the ones you don't need off, and the assistant only sees what you keep."} +

+

+ {locale === "fr" + ? "Lancez-le en mode minimal avec quelques outils, ou débloquez l'ensemble complet : terminal, recherche, MCP, web, images, sous-agents. Multi-fournisseur par défaut." + : "Run it minimal with a couple of tools, or unlock the full set : shell, search, MCP, web, images, sub-agents. Multi-provider by default."} +

+ + + +
+

+ + {locale === "fr" ? "Fork JulienPiron.fr — Améliorations Clés" : "JulienPiron.fr Fork — Key Enhancements"} +

+ +

+ {locale === "fr" + ? "Ce fork de JulienPiron.fr enrichit Sinew avec des fonctionnalités avancées optimisées pour un flux de travail quotidien rapide, autonome et ultra-résilient." + : "This fork by JulienPiron.fr enriches Sinew with advanced features optimized for a fast, autonomous, and ultra-resilient daily workflow."} +

+ +
+ {/* Item 1 */} +
+
+ + {locale === "fr" ? "Démarrage & Sandbox" : "Startup & Sandbox"} +
+
+ {locale === "fr" + ? "Démarrage instantané en un clic (Mode Sandbox) sans ouvrir de projet pour tester l'IA ou utiliser les outils MCP de manière isolée." + : "Instant one-click startup (Sandbox Mode) without opening a project to test AI or use MCP tools in isolation."} +
+
+ + {/* Item 2 */} +
+
+ + {locale === "fr" ? "Expérience Chat & Ergonomie" : "Chat Experience & Ergonomics"} +
+
+ {locale === "fr" + ? "Clic droit interactif sur les onglets (fermeture) et fichiers du chat (ouvrir, révéler, exécuter). Question collante et copie libre du chat débloquée." + : "Interactive right-click on tabs and chat files. Sticky question and unlocked chat text copying."} +
+
+ + {/* Item 3 */} +
+
+ + {locale === "fr" ? "Confort Monaco & Polices" : "Monaco Comfort & Fonts"} +
+
+ {locale === "fr" + ? "Ajustement dynamique de la taille du texte (+/-) pour Monaco et le chat. Découpage du bundle Vite (-80% taille) pour chargement instantané." + : "Dynamic font size buttons (+/-) for Monaco Editor and chat. Vite bundle splitting (-80% size) for instant UI load."} +
+
+ + {/* Item 4 */} +
+
+ + {locale === "fr" ? "Sauvegarde Auto & Mises à Jour" : "Auto-Save & Updates"} +
+
+ {locale === "fr" + ? "Sauvegarde automatique SOTA 1.5s après la frappe. Écran de mises à jour sécurisé et synchronisation OneDrive & SQLite automatique." + : "SOTA auto-save 1.5s after typing. Safe updates screen and automatic OneDrive & SQLite synchronization."} +
+
+ + {/* Item 5 */} +
+
+ + {locale === "fr" ? "Zéro Popup & Robustesse" : "Zero Popup & Resilience"} +
+
+ {locale === "fr" + ? "Lancement invisible des outils sans popup cmd.exe noire. Diagnostic réseau OAuth Windows résilient (erreur 10013, WinNAT/HNS)." + : "Invisible launch of sidecars with zero black cmd popups. Resilient Windows OAuth network check (error 10013, WinNAT/HNS)."} +
+
+ + {/* Item 6 */} +
+
+ + {locale === "fr" ? "Active Turn & Préfixe PC" : "Active Turn & PC Prefix"} +
+
+ {locale === "fr" + ? "Active Turn Registry (Rust) pour reprise de streaming après coupure. Détection automatique du nom PC réel pour les fichiers multi-PC." + : "Rust Active Turn Registry for streaming recovery on restart. Automatic PC name prefixing for secure multi-PC files."} +
+
+ + {/* Item 7 */} +
+
+ + {locale === "fr" ? "Multi-comptes & Quotas" : "Multi-accounts & Quotas"} +
+
+ {locale === "fr" + ? "Connexion simultanée de plusieurs clés/comptes (OpenAI/Gemini). Pastilles live et barres de progression de consommation crédit dans le chat." + : "Simultaneous multi-account keys (OpenAI/Gemini). Live progression bars and credit balance dots directly in the chat."} +
+
+ + {/* Item 8 */} +
+
+ + {locale === "fr" ? "Google Antigravity & DeepSeek" : "Google Antigravity & DeepSeek"} +
+
+ {locale === "fr" + ? "Routage intelligent Gemini Ultra et streaming optimisé. Intégration de Claude Opus 4.6 via les abonnements professionnels Google." + : "Gemini Ultra smart routing and optimized streaming. Claude Opus 4.6 integration via professional Google accounts."} +
+
+ + {/* Item 9 */} +
+
+ + {locale === "fr" ? "Cursor Composer & WebSocket" : "Cursor Composer & WebSocket"} +
+
+ {locale === "fr" + ? "Pont Cursor Composer 2.5 (agent.v1) HTTP/2 autonome. WebSocket natif OpenAI et spoofing d'empreinte anti-blocage ChatGPT." + : "Cursor Composer 2.5 (agent.v1) HTTP/2 bridge. Native OpenAI WebSockets and advanced anti-blocking fingerprint spoofing."} +
+
+ + {/* Item 10 */} +
+
+ + {locale === "fr" ? "Steering & Influence SOTA" : "Steering & SOTA Influence"} +
+
+ {locale === "fr" + ? "Bouton d'interception « Influencer » (Steering) pour orienter, corriger ou ajouter des instructions en direct pendant que l'IA génère." + : "Smart 'Steering' button to guide, correct, or add instructions on the fly during generation."} +
+
+ + {/* Item 11 */} +
+
+ + {locale === "fr" ? "Pont Chrome & Réparation" : "Chrome Bridge & Repair"} +
+
+ {locale === "fr" + ? "Pilotage Chrome Rust ultra-stable (clics et courbes Beziers physiques). Bouton bleu de réparation en un clic en cas d'erreur de pont local." + : "Native Rust Chrome control with human Bézier curves. One-click gray repair button for the local bridge."} +
+
+ + {/* Item 12 */} +
+
+ + {locale === "fr" ? "Recherche Vectorielle & 8 Couches" : "Vector Search & 8-Layer"} +
+
+ {locale === "fr" + ? "Indexation vectorielle sémantique locale BGE-Small. Moteur de remplacement Search/Replace intelligent à 8 couches résilient aux espaces." + : "Local vector index search with interactive badge. 8-layer smart Search/Replace engine resilient to minor whitespace errors."} +
+
+
+
+
+ ); +} +// ---- Providers section ------------------------------------------------- + +type ProvidersSectionProps = { + archivedProviders: string[]; + onArchiveProvider: (providerId: string) => void; + onRestoreProvider: (providerId: string) => void; + locale?: string; + openAiStatus: OpenAiProviderStatus | null; + openAiAccounts: OpenAiAccountInfo[]; + unconnectedAccounts: string[]; + secondaryModels: Record; + onUpdateSecondaryModel: (key: string, model: string) => void; + secondaryThinking: Record; + onUpdateSecondaryThinking: (key: string, thinking: string) => void; + secondaryFast: Record; + onUpdateSecondaryFast: (key: string, fast: string) => void; + onSaveOpenAiAccessToken: (token: string, key?: string) => Promise; + onDisconnectOpenAiAccount: (key: string) => void; + anthropicStatus: AnthropicProviderStatus | null; + googleStatus: GoogleProviderStatus | null; + googleAccounts: GoogleAccountInfo[]; + unconnectedGoogleAccounts: string[]; + onConnectGoogleWithKey: (key: string) => void; + onAddGoogleAccount: () => void; + onRemoveUnconnectedGoogleAccount: (key: string) => void; + onDisconnectGoogleAccount: (key: string) => void; + cursorComposerStatus: CursorComposerAuthStatus | null; + kimiStatus: KimiProviderStatus | null; + deepSeekStatus: DeepSeekProviderStatus | null; + openRouterStatus: OpenRouterProviderStatus | null; + openRouterModels: OpenRouterModel[]; + loading: boolean; + busy: boolean; + message: string | null; + onRefresh: () => void; + onConnect: () => void; + onConnectWithKey: (key: string) => void; + onAddOpenAiAccount: () => void; + onRemoveUnconnectedAccount: (key: string) => void; + onCancel: () => void; + onDisconnect: () => void; + onConnectAnthropic: () => void; + onCancelAnthropic: () => void; + onDisconnectAnthropic: () => void; + onConnectGoogle: () => void; + onCancelGoogle: () => void; + onDisconnectGoogle: () => void; + onConnectCursor: () => void; + onCancelCursorComposer: () => void; + onDisconnectCursorComposer: () => void; + onConnectKimi: () => void; + onCancelKimi: () => void; + onDisconnectKimi: () => void; + onDisconnectOpenRouter: () => void; + onOpenRouterStatusChange: (status: OpenRouterProviderStatus) => void; + onOpenRouterModelsChange: (models: OpenRouterModel[]) => void; + onOpenRouterChanged: () => void; + onDisconnectDeepSeek: () => void; + onDeepSeekStatusChange: (status: DeepSeekProviderStatus) => void; + onDeepSeekChanged: () => void; + onOllamaChanged: () => void; +}; + +function ProvidersSection({ + archivedProviders, + onArchiveProvider, + onRestoreProvider, + locale = "en", + openAiStatus, + openAiAccounts, + unconnectedAccounts, + secondaryModels, + onUpdateSecondaryModel, + secondaryThinking, + onUpdateSecondaryThinking, + secondaryFast, + onUpdateSecondaryFast, + onSaveOpenAiAccessToken, + onDisconnectOpenAiAccount, + anthropicStatus, + googleStatus, + googleAccounts, + unconnectedGoogleAccounts, + onConnectGoogleWithKey, + onAddGoogleAccount, + onRemoveUnconnectedGoogleAccount, + onDisconnectGoogleAccount, + cursorComposerStatus, + kimiStatus, + deepSeekStatus, + openRouterStatus, + openRouterModels, + loading, + busy, + message, + onRefresh, + onConnect, + onConnectWithKey, + onAddOpenAiAccount, + onRemoveUnconnectedAccount, + onCancel, + onDisconnect, + onConnectAnthropic, + onCancelAnthropic, + onDisconnectAnthropic, + onConnectGoogle, + onCancelGoogle, + onDisconnectGoogle, + onConnectCursor, + onCancelCursorComposer, + onDisconnectCursorComposer, + onConnectKimi, + onCancelKimi, + onDisconnectKimi, + onDisconnectOpenRouter, + onOpenRouterStatusChange, + onOpenRouterModelsChange, + onOpenRouterChanged, + onDisconnectDeepSeek, + onDeepSeekStatusChange, + onDeepSeekChanged, + onOllamaChanged, +}: ProvidersSectionProps) { + const [showArchived, setShowArchived] = useState(false); + const isArchived = (id: string) => archivedProviders.includes(id); + + const cursorStatus: CursorComposerAuthStatus = { + connected: Boolean(cursorComposerStatus?.connected), + connectionState: + cursorComposerStatus?.connectionState ?? + (cursorComposerStatus?.connected ? "connected" : "disconnected"), + email: cursorComposerStatus?.email ?? undefined, + membershipType: cursorComposerStatus?.membershipType ?? undefined, + error: cursorComposerStatus?.error ?? undefined, + }; + + return ( + <> +
+
+

Providers

+

+ Connect model providers for Sinew. +

+
+
+ {message && ( + @@ -1409,79 +4781,517 @@ function ProvidersSection({ disabled={loading || busy} > - {loading ? "Refreshing…" : "Refresh"} + {loading ? "Refreshing⬦" : "Refresh"}
- - - - - +
+ + +
+ + {showArchived === isArchived("anthropic") && ( + onArchiveProvider("anthropic")} + onRestore={() => onRestoreProvider("anthropic")} + /> + )} + {showArchived === isArchived("openai") && ( + onArchiveProvider("openai")} + onRestore={() => onRestoreProvider("openai")} + > + {!openAiStatus?.connected && ( +
+ + Or paste a business access token: + +
+ + +
+
+ )} +
+ )} + {showArchived === isArchived("openai") && (openAiAccounts.some((account) => account.key.startsWith("openai:")) || unconnectedAccounts.length > 0) && ( +
+ {[...openAiAccounts] + .filter((account) => account.key.startsWith("openai:")) + .sort((a, b) => a.key.localeCompare(b.key, undefined, { numeric: true, sensitivity: "base" })) + .map((account) => { + const suffix = account.key.slice("openai:".length); + const displayName = `OpenAI ${suffix}`; + const accountStatus: ProviderCardStatus = { + connected: true, + connectionState: "connected", + email: account.email, + planType: account.planType, + }; + const currentModel = secondaryModels[account.key] || "gpt-5.5"; + + const selectStyle = { + background: "var(--bg-3)", + color: "var(--text-1)", + border: "1px solid var(--line-2)", + borderRadius: "4px", + padding: "2px 6px", + fontSize: "11px", + cursor: "pointer", + outline: "none", + flex: "1 1 auto", + minWidth: 0, + textOverflow: "ellipsis", + overflow: "hidden", + whiteSpace: "nowrap" as const + }; + + return ( + {}} + onCancel={() => {}} + onDisconnect={() => void onDisconnectOpenAiAccount(account.key)} + showMinus={true} + onMinus={() => void onDisconnectOpenAiAccount(account.key)} + providerId={account.key} + compact={true} + > +
+
+ Model: + +
+
+
+ ); + })} + {[...unconnectedAccounts] + .sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" })) + .map((key) => { + const suffix = key.slice("openai:".length); + const displayName = `OpenAI ${suffix}`; + const accountStatus: ProviderCardStatus = { + connected: false, + connectionState: "disconnected", + }; + return ( + onConnectWithKey(key)} + onCancel={() => {}} + onDisconnect={() => {}} + showMinus={true} + onMinus={() => onRemoveUnconnectedAccount(key)} + compact={true} + > +
+ + Or paste a business access token: + +
+ + +
+
+
+ ); + })} +
+ )} + {showArchived === isArchived("cursor") && ( + onArchiveProvider("cursor")} + onRestore={() => onRestoreProvider("cursor")} + /> + )} + {showArchived === isArchived("google") && ( + onArchiveProvider("google")} + onRestore={() => onRestoreProvider("google")} + /> + )} + {showArchived === isArchived("google") && (googleAccounts.some((account) => account.key.startsWith("google:")) || unconnectedGoogleAccounts.length > 0) && ( +
+ {[...googleAccounts] + .filter((account) => account.key.startsWith("google:")) + .sort((a, b) => a.key.localeCompare(b.key, undefined, { numeric: true, sensitivity: "base" })) + .map((account) => { + const suffix = account.key.slice("google:".length); + const displayName = `Google ${suffix}`; + const accountStatus: ProviderCardStatus = { + connected: true, + connectionState: "connected", + email: account.email, + userTier: account.userTier, + projectId: account.projectId, + }; + const currentModel = secondaryModels[account.key] || "gemini-3.5-flash"; + + const selectStyle = { + background: "var(--bg-3)", + color: "var(--text-1)", + border: "1px solid var(--line-2)", + borderRadius: "4px", + padding: "2px 6px", + fontSize: "11px", + cursor: "pointer", + outline: "none", + flex: "1 1 auto", + minWidth: 0, + textOverflow: "ellipsis", + overflow: "hidden", + whiteSpace: "nowrap" as const + }; + + return ( + {}} + onCancel={() => {}} + onDisconnect={() => void onDisconnectGoogleAccount(account.key)} + showMinus={true} + onMinus={() => void onDisconnectGoogleAccount(account.key)} + providerId={account.key} + compact={true} + > +
+
+ Model: + +
+
+
+ ); + })} + {[...unconnectedGoogleAccounts] + .sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" })) + .map((key) => { + const suffix = key.slice("google:".length); + const displayName = `Google ${suffix}`; + const accountStatus: ProviderCardStatus = { + connected: false, + connectionState: "disconnected", + }; + return ( + onConnectGoogleWithKey(key)} + onCancel={() => {}} + onDisconnect={() => {}} + showMinus={true} + onMinus={() => onRemoveUnconnectedGoogleAccount(key)} + compact={true} + /> + ); + })} +
+ )} + {showArchived === isArchived("kimi") && ( + onArchiveProvider("kimi")} + onRestore={() => onRestoreProvider("kimi")} + /> + )} + {showArchived === isArchived("deepseek") && ( + onArchiveProvider("deepseek")} + onRestore={() => onRestoreProvider("deepseek")} + /> + )} + {showArchived === isArchived("openrouter") && ( + onArchiveProvider("openrouter")} + onRestore={() => onRestoreProvider("openrouter")} + /> + )} + {showArchived === isArchived("ollama") && ( + onArchiveProvider("ollama")} + onRestore={() => onRestoreProvider("ollama")} + /> + )}
); @@ -1492,6 +5302,7 @@ type ProviderCardStatus = | AnthropicProviderStatus | GoogleProviderStatus | KimiProviderStatus + | CursorComposerAuthStatus | null; type ProviderCardProps = { @@ -1505,6 +5316,18 @@ type ProviderCardProps = { onConnect: () => void; onCancel: () => void; onDisconnect: () => void; + children?: React.ReactNode; + showPlus?: boolean; + onPlus?: () => void; + showMinus?: boolean; + onMinus?: () => void; + providerId?: string; + compact?: boolean; + connectLabel?: string; + busyConnectLabel?: string; + isArchived?: boolean; + onArchive?: () => void; + onRestore?: () => void; }; function ProviderCard({ @@ -1518,94 +5341,481 @@ function ProviderCard({ onConnect, onCancel, onDisconnect, + children, + showPlus, + onPlus, + showMinus, + onMinus, + providerId, + compact, + connectLabel = "Connect", + busyConnectLabel = "Opening...", + isArchived, + onArchive, + onRestore, }: ProviderCardProps) { const state = status?.connectionState ?? "disconnected"; const connected = Boolean(status?.connected); const connecting = state === "connecting"; const error = state === "error" ? status?.error : null; + const [quota, setQuota] = useState(null); + + const isQuotaExhausted = connected && quota && quota.kind !== "unavailable" && ( + quota.kind === "credits" + ? (quota.creditRemaining != null && quota.creditRemaining <= 0) + : (quota.kind === "groups" ? quota.groups ?? [] : quota.windows ?? []).some( + (w) => w.remainingPercent !== null && w.remainingPercent <= 0 + ) + ); + const statusLabel = connecting ? "Connecting" - : connected - ? "Connected" - : state === "error" - ? "Needs attention" - : "Not connected"; + : isQuotaExhausted + ? "Limit reached" + : connected + ? "Connected" + : state === "error" + ? "Needs attention" + : "Not connected"; + const statusTone = connecting ? "pending" - : connected - ? "ok" - : state === "error" - ? "error" - : "off"; - const meta = connectedMeta.filter((item): item is string => Boolean(item)); + : isQuotaExhausted + ? "error" + : connected + ? "ok" + : state === "error" + ? "error" + : "off"; + + useEffect(() => { + if (!connected || !providerId) { + setQuota(null); + return; + } + let active = true; + const update = async () => { + const q = await fetchProviderQuota(providerId); + if (active) setQuota(q); + }; + update(); + const handleUpdate = () => { + const cached = getCachedQuota(providerId); + if (cached && active) { + setQuota(cached); + } else { + update(); + } + }; + window.addEventListener("sinew:quota-updated", handleUpdate); + return () => { + active = false; + window.removeEventListener("sinew:quota-updated", handleUpdate); + }; + }, [connected, providerId]); + + const rawMeta = [...connectedMeta]; + if (connected && quota && quota.label) { + if (quota.label.startsWith("Projet ")) { + // Check if project is already in connectedMeta to avoid duplicate + const alreadyHasProject = connectedMeta.some(m => m && (m.includes("Project ") || m.includes("Projet "))); + if (!alreadyHasProject) { + rawMeta.push(quota.label); + } + } else if (!quota.label.includes("Codex")) { + rawMeta.push(quota.label); + } + } + const meta = rawMeta.filter((item): item is string => Boolean(item)); return ( -
+
- +
-
-

{name}

- +
+

{name}

+ {onArchive && onRestore && ( + isArchived ? ( + + ) : ( + + ) + )} + {statusLabel}
-

{description}

- {connected && meta.length > 0 && ( -
- {meta.map((item) => ( + {!compact && !connected &&

{description}

} + {compact && connected && quota && quota.kind !== "unavailable" && ( +
+ {quota.kind === "credits" ? ( + <> + + + + ) : ( + (quota.kind === "groups" ? quota.groups ?? [] : quota.windows ?? []).map((item) => ( + + )) + )} +
+ )} + {compact ? ( + meta && meta.length > 0 && ( +
+ {meta.join(" • ")} +
+ ) + ) : ( +
+ {meta?.map((item) => ( {item} ))}
)} + {connected && quota && quota.kind === "unavailable" && ( +
+ {quota.label ?? "Quota non disponible"} +
+ )} {error &&
{error}
} + {children}
-
+
{connecting ? ( - ) : connected ? ( - - ) : ( - + ) : connected ? ( +
+ {showPlus && ( + + )} + {showMinus && ( + + )} + +
+ ) : ( +
+ {showMinus && ( + + )} + +
+ )} + + {!compact && connected && quota && quota.kind !== "unavailable" && ( +
+ {quota.kind === "credits" ? ( + <> + + + + ) : ( + (quota.kind === "groups" ? quota.groups ?? [] : quota.windows ?? []).map((item) => ( + + )) + )} +
)}
); } +function formatResetLabelForWindow(item: { label: string; resetAt?: number | null; resetTime?: string | null }) { + const value = item.resetAt ?? item.resetTime ?? null; + if (value == null) return null; + const targetMs = typeof value === "number" ? value * 1000 : Date.parse(value); + if (!Number.isFinite(targetMs)) return null; + const deltaMs = Math.max(0, targetMs - Date.now()); + const lowerLabel = item.label.toLowerCase(); + + if (lowerLabel.includes("fenetre courte") || lowerLabel.includes("fenêtre courte")) { + const totalMinutes = Math.max(0, Math.round(deltaMs / 60_000)); + return `reset ${totalMinutes}m`; + } + + if (lowerLabel.includes("fenetre longue") || lowerLabel.includes("fenêtre longue")) { + const totalHours = Math.max(0, Math.round(deltaMs / 3_600_000)); + return `reset ${totalHours}h`; + } + + const totalMinutes = Math.max(0, Math.round(deltaMs / 60_000)); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + if (hours > 0) return `reset ${hours}h${minutes ? ` ${minutes}m` : ""}`; + return `reset ${minutes}m`; +} + +function formatWindowLabel(window: { label: string; windowMinutes?: number | null }) { + const lowerLabel = window.label.toLowerCase(); + if (lowerLabel.includes("fenetre courte") || lowerLabel.includes("fenêtre courte")) { + return "5h"; + } + if (lowerLabel.includes("fenetre longue") || lowerLabel.includes("fenêtre longue")) { + return "Semaine"; + } + if (!window.windowMinutes) return window.label; + const hours = Math.round(window.windowMinutes / 60); + if (hours >= 1 && window.windowMinutes % 60 === 0) return `${window.label} (${hours}h)`; + return `${window.label} (${window.windowMinutes}m)`; +} + +function QuotaBar({ item, inline }: { item: { label: string; remainingPercent: number | null; windowMinutes?: number | null; resetAt?: number | null; resetTime?: string | null; rawValue?: number | null }; inline?: boolean }) { + const percent = item.remainingPercent; + const reset = formatResetLabelForWindow(item); + + let customColor = "#10b981"; + if (item.rawValue != null) { + if (item.rawValue < 10) customColor = "#ef4444"; + else if (item.rawValue < 20) customColor = "#f97316"; + else if (item.rawValue < 40) customColor = "#eab308"; + else if (item.rawValue < 60) customColor = "#84cc16"; + else if (item.rawValue < 80) customColor = "#10b981"; + else if (item.rawValue < 100) customColor = "#0ea5e9"; + else customColor = "#3b82f6"; + } + + if (inline) { + if (percent == null) { + return ( + + {formatWindowLabel(item)} + {reset && - {reset}} + + ); + } + return ( + + {formatWindowLabel(item)} +
+
+
+ + {`${percent.toFixed(0)}%`}{reset ? ` - ${reset}` : ""} + + + ); + } + + return ( +
+
+ + {formatWindowLabel(item)} + + + {percent == null ? "—" : `${percent.toFixed(0)}%`}{reset ? ` - ${reset}` : ""} + +
+
+
+
+
+ ); +} + +function QuotaInlinePanel({ quota, compact, showLabel = true }: { quota: QuotaInfo; compact?: boolean; showLabel?: boolean }) { + if (quota.kind === "unavailable") { + return ( +
+ {quota.label ?? "Quota non disponible"} +
+ ); + } + + const creditLimit = quota.creditLimit; + const creditRemaining = quota.creditRemaining; + const windows = quota.kind === "groups" ? quota.groups ?? [] : quota.windows ?? []; + + return ( +
+ {showLabel && quota.label && !quota.label.startsWith("Projet") && !quota.label.includes("Codex") &&
{quota.label}
} + {quota.kind === "credits" ? ( +
+ + +
+ ) : ( +
1 ? "repeat(auto-fill, minmax(220px, 1fr))" : "1fr"), gap: "16px" }}> + {windows.map((item) => ( + + ))} +
+ )} +
+ ); +} + +function OpenRouterQuotaPanel({ quota, compact }: { quota: QuotaInfo; compact?: boolean }) { + if (quota.kind === "unavailable") { + return ( +
+ {quota.label ?? "Quota non disponible"} +
+ ); + } + + const creditLimit = quota.creditLimit; + const creditRemaining = quota.creditRemaining; + const windows = quota.kind === "groups" ? quota.groups ?? [] : quota.windows ?? []; + + return ( +
+ {quota.kind === "credits" ? ( + <> +
+
+ + ) : ( + <> + {windows.map((item) => ( +
+ ))} + + )} +
+ ); +} + type OpenRouterProviderCardProps = { status: OpenRouterProviderStatus | null; models: OpenRouterModel[]; @@ -1615,6 +5825,9 @@ type OpenRouterProviderCardProps = { onStatusChange: (status: OpenRouterProviderStatus) => void; onModelsChange: (models: OpenRouterModel[]) => void; onChanged: () => void; + isArchived?: boolean; + onArchive?: () => void; + onRestore?: () => void; }; function OpenRouterProviderCard({ @@ -1626,6 +5839,9 @@ function OpenRouterProviderCard({ onStatusChange, onModelsChange, onChanged, + isArchived, + onArchive, + onRestore, }: OpenRouterProviderCardProps) { const [apiKey, setApiKey] = useState(""); const [revealed, setRevealed] = useState(false); @@ -1639,6 +5855,8 @@ function OpenRouterProviderCard({ const validationSeq = useRef(0); const searchSeq = useRef(0); + const [quota, setQuota] = useState(null); + const displayStatus: OpenRouterProviderStatus = validating ? { connected: false, @@ -1652,22 +5870,63 @@ function OpenRouterProviderCard({ }; const state = displayStatus.connectionState; const connected = Boolean(displayStatus.connected); + + useEffect(() => { + if (!connected) { + setQuota(null); + return; + } + let active = true; + const update = async () => { + const q = await fetchProviderQuota("openrouter"); + if (active) setQuota(q); + }; + update(); + const handleUpdate = () => { + const cached = getCachedQuota("openrouter"); + if (cached && active) { + setQuota(cached); + } else { + update(); + } + }; + window.addEventListener("sinew:quota-updated", handleUpdate); + return () => { + active = false; + window.removeEventListener("sinew:quota-updated", handleUpdate); + }; + }, [connected]); + const connecting = state === "connecting"; const error = validationError ?? (state === "error" ? displayStatus.error : null); + + const isQuotaExhausted = connected && quota && quota.kind !== "unavailable" && ( + quota.kind === "credits" + ? (quota.creditRemaining != null && quota.creditRemaining <= 0) + : (quota.kind === "groups" ? quota.groups ?? [] : quota.windows ?? []).some( + (w) => w.remainingPercent !== null && w.remainingPercent <= 0 + ) + ); + const statusLabel = connecting ? "Connecting" - : connected - ? "Connected" - : state === "error" - ? "Needs attention" - : "Not connected"; + : isQuotaExhausted + ? "Limit reached" + : connected + ? "Connected" + : state === "error" + ? "Needs attention" + : "Not connected"; + const statusTone = connecting ? "pending" - : connected - ? "ok" - : state === "error" - ? "error" - : "off"; + : isQuotaExhausted + ? "error" + : connected + ? "ok" + : state === "error" + ? "error" + : "off"; const modelIds = useMemo(() => new Set(models.map((model) => model.id)), [models]); const searchEnabled = connected && !validating; @@ -1792,15 +6051,41 @@ function OpenRouterProviderCard({
-
-

OpenRouter

- +
+

OpenRouter

+ {onArchive && onRestore && ( + isArchived ? ( + + ) : ( + + ) + )} + {statusLabel}

Use any OpenRouter model with your own API key.

{error &&
{error}
} + {connected && quota && }
@@ -1839,7 +6124,7 @@ function OpenRouterProviderCard({ title="Remove API key" aria-label="Remove API key" > - + )}
@@ -1853,7 +6138,7 @@ function OpenRouterProviderCard({ type="text" value={query} disabled={!searchEnabled} - placeholder={searchEnabled ? "Type a model name…" : "Save a valid key first"} + placeholder={searchEnabled ? "Type a model name⬦" : "Save a valid key first"} onChange={(event) => setQuery(event.target.value)} />
@@ -1862,7 +6147,7 @@ function OpenRouterProviderCard({ {searchEnabled && query.trim() !== "" && (
{searching ? ( -
Searching…
+
Searching⬦
) : searchError ? (
{searchError}
) : results.length === 0 ? ( @@ -1883,8 +6168,8 @@ function OpenRouterProviderCard({ onClick={() => void addModel(model)} disabled={mutatingModelId === model.id} > - - {mutatingModelId === model.id ? "Adding…" : "Add"} + + {mutatingModelId === model.id ? "Adding⬦" : "Add"} )}
@@ -1896,24 +6181,236 @@ function OpenRouterProviderCard({ {models.length > 0 && (
- {models.map((model) => { - const label = sanitizeOpenRouterName(model.name) || model.id; - return ( -
- {label} - -
- ); - })} + {models.map((model) => { + const label = sanitizeOpenRouterName(model.name) || model.id; + return ( +
+ {label} + +
+ ); + })} +
+ )} +
+ + ); +} + +type OllamaProviderCardProps = { + onChanged: () => void; + isArchived?: boolean; + onArchive?: () => void; + onRestore?: () => void; +}; + +function OllamaProviderCard({ + onChanged, + isArchived, + onArchive, + onRestore, +}: OllamaProviderCardProps) { + const DEFAULT_OLLAMA_URL = "http://localhost:11434"; + const [status, setStatus] = useState(null); + const [models, setModels] = useState([]); + const [baseUrl, setBaseUrl] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const loadStatus = useCallback(async () => { + try { + const next = await api.getOllamaProviderStatus(); + setStatus(next); + if (next.connected) { + api.listOllamaModels().then(setModels).catch(() => setModels([])); + } else { + setModels([]); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }, []); + + useEffect(() => { + void loadStatus(); + }, [loadStatus]); + + const connected = Boolean(status?.connected); + const state = status?.connectionState ?? "disconnected"; + const statusLabel = busy + ? "Connecting" + : connected + ? "Connected" + : state === "error" + ? "Needs attention" + : "Not connected"; + const statusTone = busy + ? "pending" + : connected + ? "ok" + : state === "error" + ? "error" + : "off"; + + const connect = async () => { + setBusy(true); + setError(null); + try { + const next = await api.connectOllamaProvider(baseUrl.trim() || undefined); + setStatus(next); + setBaseUrl(""); + const list = await api.listOllamaModels().catch(() => []); + setModels(list); + onChanged(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + + const refresh = async () => { + setBusy(true); + setError(null); + try { + const list = await api.refreshOllamaModels(); + setModels(list); + setStatus((prev) => (prev ? { ...prev, modelCount: list.length } : prev)); + onChanged(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + + const disconnect = async () => { + setBusy(true); + setError(null); + try { + const next = await api.disconnectOllamaProvider(); + setStatus(next); + setModels([]); + onChanged(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + + return ( +
+
+
+ +
+
+
+

Ollama

+ {onArchive && onRestore && ( + isArchived ? ( + + ) : ( + + ) + )} + + + {statusLabel} + +
+

Run your local Ollama models. Detects every installed model automatically.

+ {error &&
{error}
} +
+
+ +
+ + +
+ + {connected && ( + + )} +
+ + {models.length > 0 && ( +
+ {models.map((model) => ( +
+ {model.name || model.id} +
+ ))}
)}
@@ -1934,11 +6431,15 @@ type ToolsSectionProps = { onPlanModePromptChange: (value: string) => void; onImageProviderChange: (value: ImageProvider) => void; onOpenAiImageUseSubscriptionChange: (value: boolean) => void; + onGeminiImageUseSubscriptionChange: (value: boolean) => void; + onOpenAiImageModelChange: (value: string) => void; + onGeminiImageModelChange: (value: string) => void; onOpenAiImageApiKeyChange: (value: string) => void; onNanoBananaApiKeyChange: (value: string) => void; onWebSearchProviderChange: (value: WebSearchProvider) => void; onLinkupApiKeyChange: (value: string) => void; openAiStatus: OpenAiProviderStatus | null; + googleStatus: GoogleProviderStatus | null; }; const TOOL_GROUPS = [ @@ -1964,26 +6465,37 @@ function ToolsSection({ onPlanModePromptChange, onImageProviderChange, onOpenAiImageUseSubscriptionChange, + onGeminiImageUseSubscriptionChange, + onOpenAiImageModelChange, + onGeminiImageModelChange, onOpenAiImageApiKeyChange, onNanoBananaApiKeyChange, onWebSearchProviderChange, onLinkupApiKeyChange, openAiStatus, + googleStatus, }: ToolsSectionProps) { const tools = settings?.tools ?? []; const planModePrompt = settings?.planModePrompt ?? ""; const defaultPlanModePrompt = settings?.defaultPlanModePrompt ?? ""; const imageProvider = settings?.imageProvider ?? "gptImage2"; const openaiImageUseSubscription = settings?.openaiImageUseSubscription ?? false; + const geminiImageUseSubscription = settings?.geminiImageUseSubscription ?? false; + const openaiImageModel = settings?.openaiImageModel ?? "gpt-image-2"; + const geminiImageModel = settings?.geminiImageModel ?? "gemini-3.1-flash-image-preview"; const openaiImageApiKey = settings?.openaiImageApiKey ?? ""; const nanoBananaApiKey = settings?.nanoBananaApiKey ?? ""; const webSearchProvider = settings?.webSearchProvider ?? "classic"; const linkupApiKey = settings?.linkupApiKey ?? ""; const openAiConnected = openAiStatus?.connected === true; - const subscriptionActive = + const googleConnected = googleStatus?.connected === true; + const openaiSubscriptionActive = imageProvider === "gptImage2" && openAiConnected && openaiImageUseSubscription; - const showImageKeyField = - imageProvider === "nanoBanana2" || !subscriptionActive; + const geminiSubscriptionActive = + imageProvider === "nanoBanana2" && googleConnected && geminiImageUseSubscription; + const subscriptionActive = + imageProvider === "gptImage2" ? openaiSubscriptionActive : geminiSubscriptionActive; + const showImageKeyField = !subscriptionActive; const activeImageKey = imageProvider === "nanoBanana2" ? nanoBananaApiKey : openaiImageApiKey; const hasImageTool = tools.some((tool) => canonicalToolName(tool.name) === "create_image"); @@ -2004,7 +6516,7 @@ function ToolsSection({

Tools

- {loading ? "Loading…" : `${enabledCount}/${tools.length} enabled`} + {loading ? "Loading⬦" : `${enabledCount}/${tools.length} enabled`}

@@ -2028,7 +6540,7 @@ function ToolsSection({ width={13} height={13} /> - {saving ? "Saving…" : "Save"} + {saving ? "Saving⬦" : "Save"}
@@ -2051,28 +6563,62 @@ function ToolsSection({ {hasImageTool && (
-

Image generation

+

Génération d'images

+ Outil create_image (OpenAI / Google)
+

+ Les images générées passent par ChatGPT (OpenAI) ou Gemini (Google). + Avec Composer, utilise l'outil natif « generate image » (branché sur ce provider). +

+
+ {imageProvider === "gptImage2" ? ( + + ) : ( + + )} +
{imageProvider === "gptImage2" && (
- Use OpenAI subscription + Utiliser l'abonnement OpenAI {openAiConnected - ? "Authenticate image requests with your connected OpenAI account instead of an API key." - : "Connect OpenAI in Settings → Providers to use your subscription."} + ? "Authentifie les requêtes image avec ton compte OpenAI connecté, sans clé API." + : "Connecte OpenAI dans Paramètres ⏳ Providers pour utiliser ton abonnement."}
)} + {imageProvider === "nanoBanana2" && ( +
+
+ + Utiliser l'abonnement Gemini + + + {googleConnected + ? "Authentifie les requêtes image avec ton compte Gemini/Google connecté, sans clé API." + : "Connecte Google dans Paramètres ⏳ Providers pour utiliser ton abonnement."} + +
+ +
+ )} {showImageKeyField && (

- This text is appended to the system prompt only when the conversation is in - Plan mode. + This text is appended to the system prompt only when the conversation is in Plan mode.