From db884f464683fca16b5e953df102f5cf6097ba4c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 2 May 2026 03:19:39 -0500 Subject: [PATCH] chore: uptick --- .dockerignore | 6 + .github/workflows/deploy-site.yml | 10 + Dockerfile | 26 +- acp_adapter/server.py | 139 ++- acp_adapter/session.py | 53 +- agent/auxiliary_client.py | 30 +- agent/context_compressor.py | 6 +- agent/curator.py | 199 ++- agent/curator_backup.py | 440 +++++++ agent/manual_compression_feedback.py | 10 +- agent/moonshot_schema.py | 49 +- agent/tool_guardrails.py | 455 +++++++ apps/desktop/electron/main.cjs | 218 +++- apps/desktop/electron/preload.cjs | 19 +- apps/desktop/package-lock.json | 239 ++-- apps/desktop/package.json | 2 + apps/desktop/src/app/artifacts/index.tsx | 651 +++++----- .../src/app/chat/composer/attachments.tsx | 30 +- .../src/app/chat/composer/controls.tsx | 10 +- .../composer/hooks/use-slash-completions.ts | 27 +- apps/desktop/src/app/chat/composer/index.tsx | 259 +++- .../app/chat/composer/skin-slash-popover.tsx | 56 + .../src/app/chat/composer/slash-popover.tsx | 2 +- apps/desktop/src/app/chat/composer/types.ts | 4 + .../app/chat/hooks/use-composer-actions.ts | 283 ++++- apps/desktop/src/app/chat/index.tsx | 44 +- .../desktop/src/app/chat/right-rail/index.tsx | 9 + .../src/app/chat/right-rail/preview-pane.tsx | 522 ++++++++ apps/desktop/src/app/chat/sidebar/index.tsx | 16 +- .../app/chat/sidebar/session-actions-menu.tsx | 20 +- .../src/app/chat/sidebar/session-row.tsx | 2 +- apps/desktop/src/app/desktop-controller.tsx | 242 +++- .../src/app/gateway/hooks/use-gateway-boot.ts | 37 +- .../app/gateway/hooks/use-gateway-request.ts | 10 +- .../app/session/hooks/use-message-stream.ts | 46 +- .../app/session/hooks/use-prompt-actions.ts | 278 ++++- .../app/session/hooks/use-session-actions.ts | 378 ++++-- apps/desktop/src/app/shell/app-shell.tsx | 20 +- .../components/assistant-ui/clarify-tool.tsx | 284 +++++ .../assistant-ui/directive-text.tsx | 19 +- .../src/components/assistant-ui/intro.tsx | 2 +- .../assistant-ui/markdown-text.test.ts | 73 ++ .../components/assistant-ui/markdown-text.tsx | 420 ++++--- .../assistant-ui/preview-attachment.tsx | 94 ++ .../assistant-ui/shiki-highlighter.tsx | 13 +- .../src/components/assistant-ui/thread.tsx | 643 ++++++---- .../components/assistant-ui/tool-fallback.tsx | 54 +- .../assistant-ui/zoomable-image.tsx | 170 +++ .../src/components/session-inspector.tsx | 9 +- apps/desktop/src/components/ui/pagination.tsx | 105 ++ apps/desktop/src/global.d.ts | 25 + apps/desktop/src/hermes.ts | 4 +- apps/desktop/src/lib/chat-messages.test.ts | 85 +- apps/desktop/src/lib/chat-messages.ts | 125 +- .../src/lib/desktop-slash-commands.test.ts | 108 ++ .../desktop/src/lib/desktop-slash-commands.ts | 251 ++++ apps/desktop/src/lib/embedded-images.test.ts | 35 + apps/desktop/src/lib/embedded-images.ts | 59 + apps/desktop/src/lib/markdown-code.test.ts | 23 + apps/desktop/src/lib/markdown-code.ts | 132 ++ apps/desktop/src/lib/media.ts | 90 ++ apps/desktop/src/lib/preview-targets.test.ts | 53 + apps/desktop/src/lib/preview-targets.ts | 216 ++++ apps/desktop/src/store/clarify.ts | 32 + apps/desktop/src/store/composer.ts | 1 + apps/desktop/src/store/gateway.ts | 16 + apps/desktop/src/store/preview.ts | 14 + apps/desktop/src/store/tool-diffs.ts | 23 + apps/desktop/src/styles.css | 89 +- apps/desktop/src/themes/context.tsx | 24 +- apps/desktop/src/types/hermes.ts | 6 +- cli-config.yaml.example | 19 + cli.py | 243 +++- cron/jobs.py | 118 ++ docker-compose.yml | 2 +- gateway/config.py | 70 +- gateway/delivery.py | 16 +- gateway/platforms/api_server.py | 6 +- gateway/platforms/base.py | 222 +++- gateway/platforms/discord.py | 17 +- gateway/platforms/feishu.py | 258 +++- gateway/platforms/helpers.py | 5 +- gateway/platforms/signal.py | 12 + gateway/platforms/slack.py | 234 +++- gateway/platforms/telegram.py | 95 +- gateway/platforms/yuanbao.py | 10 +- gateway/run.py | 681 ++++++++-- gateway/session.py | 12 + gateway/status.py | 12 +- hermes_cli/auth.py | 10 +- hermes_cli/commands.py | 19 +- hermes_cli/config.py | 65 +- hermes_cli/curator.py | 145 ++- hermes_cli/gateway.py | 112 +- hermes_cli/goals.py | 535 ++++++++ hermes_cli/main.py | 160 ++- hermes_cli/model_switch.py | 28 +- hermes_cli/models.py | 2 +- hermes_cli/plugins.py | 52 + hermes_cli/plugins_cmd.py | 491 ++++++-- hermes_cli/runtime_provider.py | 13 +- hermes_cli/slack_cli.py | 3 +- hermes_cli/status.py | 1 + hermes_cli/web_server.py | 302 ++++- hermes_state.py | 244 +++- model_tools.py | 13 +- nix/tui.nix | 2 +- .../scripts/openclaw_to_hermes.py | 2 +- .../productivity/here-now/SKILL.md | 217 ++++ .../productivity/here-now/scripts/drive.sh | 406 ++++++ .../productivity/here-now/scripts/publish.sh | 445 +++++++ .../dashboard/plugin_api.py | 14 +- plugins/kanban/dashboard/dist/index.js | 42 +- plugins/kanban/dashboard/dist/style.css | 12 +- plugins/memory/honcho/client.py | 35 +- plugins/memory/honcho/session.py | 15 +- plugins/platforms/teams/adapter.py | 3 + run_agent.py | 454 +++++-- scripts/profile-tui.py | 12 +- scripts/release.py | 24 + tests/acp/test_server.py | 3 +- tests/acp/test_session.py | 75 ++ tests/acp_adapter/test_acp_commands.py | 150 +++ .../test_auxiliary_named_custom_providers.py | 65 + tests/agent/test_context_compressor.py | 52 + tests/agent/test_curator.py | 121 +- tests/agent/test_curator_backup.py | 316 +++++ tests/agent/test_curator_reports.py | 164 +++ tests/agent/test_moonshot_schema.py | 171 ++- tests/agent/test_skill_utils.py | 58 + tests/agent/test_tool_guardrails.py | 238 ++++ tests/cli/test_manual_compress.py | 20 +- tests/cron/test_rewrite_skill_refs.py | 289 +++++ tests/gateway/feishu_helpers.py | 65 + tests/gateway/test_api_server.py | 30 + tests/gateway/test_api_server_runs.py | 5 +- tests/gateway/test_approve_deny_commands.py | 17 + tests/gateway/test_compress_command.py | 28 +- tests/gateway/test_config.py | 96 ++ tests/gateway/test_delivery.py | 58 + tests/gateway/test_discord_free_response.py | 20 + tests/gateway/test_ephemeral_reply.py | 336 +++++ tests/gateway/test_feishu.py | 373 ++++-- tests/gateway/test_feishu_bot_admission.py | 745 +++++++++++ tests/gateway/test_feishu_bot_auth_bypass.py | 113 ++ .../test_fresh_reset_skill_injection.py | 201 +++ tests/gateway/test_home_target_env_var.py | 36 + .../test_native_image_buffer_isolation.py | 79 ++ tests/gateway/test_notice_delivery.py | 67 + tests/gateway/test_reasoning_command.py | 41 + tests/gateway/test_restart_notification.py | 30 + tests/gateway/test_restart_resume_pending.py | 62 + tests/gateway/test_session.py | 21 +- .../test_session_boundary_security_state.py | 60 + tests/gateway/test_session_race_guard.py | 33 + tests/gateway/test_signal.py | 145 +++ tests/gateway/test_slack.py | 454 +++++++ tests/gateway/test_slack_mention.py | 17 + tests/gateway/test_stale_code_self_check.py | 223 ++++ tests/gateway/test_status.py | 51 + tests/gateway/test_status_command.py | 126 ++ .../gateway/test_telegram_approval_buttons.py | 83 ++ tests/gateway/test_update_command.py | 56 +- tests/gateway/test_update_streaming.py | 52 + tests/gateway/test_verbose_command.py | 19 + tests/hermes_cli/test_auth_commands.py | 35 + tests/hermes_cli/test_auth_nous_provider.py | 14 + tests/hermes_cli/test_commands.py | 34 +- tests/hermes_cli/test_goals.py | 358 ++++++ .../test_model_provider_persistence.py | 26 + tests/hermes_cli/test_plugins.py | 40 + .../test_runtime_provider_resolution.py | 52 + tests/hermes_cli/test_tools_config.py | 11 +- .../hermes_cli/test_update_gateway_restart.py | 75 ++ tests/hermes_cli/test_update_yes_flag.py | 167 +++ .../test_user_providers_model_switch.py | 145 +++ tests/run_agent/test_860_dedup.py | 2 + .../test_deepseek_reasoning_content_echo.py | 80 +- tests/run_agent/test_run_agent.py | 75 +- .../test_tool_call_guardrail_runtime.py | 275 +++++ tests/test_hermes_state.py | 188 +++ tests/test_tui_gateway_server.py | 170 ++- .../test_browser_supervisor_healthcheck.py | 167 +++ tests/tools/test_delegate.py | 20 + tests/tools/test_discord_tool.py | 32 + tests/tools/test_registry.py | 1 + tests/tools/test_session_search.py | 15 + tests/tools/test_skill_manager_tool.py | 20 + tests/tools/test_terminal_tool.py | 51 + tests/tools/test_yolo_mode.py | 27 + tests/tui_gateway/test_protocol.py | 112 +- tools/approval.py | 17 +- tools/browser_supervisor.py | 8 +- tools/delegate_tool.py | 2 +- tools/discord_tool.py | 10 +- tools/session_search_tool.py | 6 +- tools/skill_manager_tool.py | 7 +- tools/terminal_tool.py | 35 + tui_gateway/server.py | 456 +++++-- ui-tui/package-lock.json | 25 - ui-tui/src/app/useSubmission.ts | 10 +- ui-tui/src/components/modelPicker.tsx | 255 +++- ui-tui/src/gatewayTypes.ts | 3 + uv.lock | 198 ++- web/src/App.tsx | 169 ++- web/src/i18n/en.ts | 43 + web/src/i18n/types.ts | 42 + web/src/i18n/zh.ts | 42 + web/src/lib/api.ts | 109 ++ web/src/lib/resolve-page-title.ts | 1 + web/src/pages/AnalyticsPage.tsx | 181 ++- web/src/pages/PluginsPage.tsx | 581 +++++++++ web/src/plugins/slots.ts | 4 + web/src/themes/presets.ts | 29 +- website/.gitignore | 2 + website/docs/getting-started/updating.md | 6 +- website/docs/index.md | 9 + website/docs/reference/cli-commands.md | 9 + .../docs/reference/environment-variables.md | 3 + .../docs/reference/optional-skills-catalog.md | 2 + website/docs/reference/skills-catalog.md | 4 +- website/docs/reference/slash-commands.md | 2 + website/docs/user-guide/features/curator.md | 49 + website/docs/user-guide/features/goals.md | 165 +++ website/docs/user-guide/messaging/feishu.md | 53 +- website/docs/user-guide/messaging/index.md | 3 + website/docs/user-guide/messaging/teams.md | 27 +- .../bundled/creative/creative-comfyui.md | 656 +++++----- .../devops/devops-kanban-orchestrator.md | 170 +++ .../bundled/devops/devops-kanban-worker.md | 152 +++ .../productivity/productivity-here-now.md | 231 ++++ .../productivity/productivity-shopify.md | 376 ++++++ website/docs/user-stories.mdx | 10 + website/scripts/generate-llms-txt.py | 305 +++++ website/scripts/generate-skill-docs.py | 57 +- website/scripts/prebuild.mjs | 64 +- website/sidebars.ts | 410 +------ .../components/UserStoriesCollage/index.tsx | 310 +++++ .../UserStoriesCollage/styles.module.css | 252 ++++ website/src/data/userStories.json | 1091 +++++++++++++++++ 240 files changed, 25206 insertions(+), 3155 deletions(-) create mode 100644 agent/curator_backup.py create mode 100644 agent/tool_guardrails.py create mode 100644 apps/desktop/src/app/chat/composer/skin-slash-popover.tsx create mode 100644 apps/desktop/src/app/chat/right-rail/preview-pane.tsx create mode 100644 apps/desktop/src/components/assistant-ui/clarify-tool.tsx create mode 100644 apps/desktop/src/components/assistant-ui/markdown-text.test.ts create mode 100644 apps/desktop/src/components/assistant-ui/preview-attachment.tsx create mode 100644 apps/desktop/src/components/assistant-ui/zoomable-image.tsx create mode 100644 apps/desktop/src/components/ui/pagination.tsx create mode 100644 apps/desktop/src/lib/desktop-slash-commands.test.ts create mode 100644 apps/desktop/src/lib/desktop-slash-commands.ts create mode 100644 apps/desktop/src/lib/embedded-images.test.ts create mode 100644 apps/desktop/src/lib/embedded-images.ts create mode 100644 apps/desktop/src/lib/markdown-code.test.ts create mode 100644 apps/desktop/src/lib/markdown-code.ts create mode 100644 apps/desktop/src/lib/media.ts create mode 100644 apps/desktop/src/lib/preview-targets.test.ts create mode 100644 apps/desktop/src/lib/preview-targets.ts create mode 100644 apps/desktop/src/store/clarify.ts create mode 100644 apps/desktop/src/store/gateway.ts create mode 100644 apps/desktop/src/store/preview.ts create mode 100644 apps/desktop/src/store/tool-diffs.ts create mode 100644 hermes_cli/goals.py create mode 100644 optional-skills/productivity/here-now/SKILL.md create mode 100755 optional-skills/productivity/here-now/scripts/drive.sh create mode 100755 optional-skills/productivity/here-now/scripts/publish.sh create mode 100644 tests/acp_adapter/test_acp_commands.py create mode 100644 tests/agent/test_curator_backup.py create mode 100644 tests/agent/test_skill_utils.py create mode 100644 tests/agent/test_tool_guardrails.py create mode 100644 tests/cron/test_rewrite_skill_refs.py create mode 100644 tests/gateway/feishu_helpers.py create mode 100644 tests/gateway/test_ephemeral_reply.py create mode 100644 tests/gateway/test_feishu_bot_admission.py create mode 100644 tests/gateway/test_feishu_bot_auth_bypass.py create mode 100644 tests/gateway/test_fresh_reset_skill_injection.py create mode 100644 tests/gateway/test_home_target_env_var.py create mode 100644 tests/gateway/test_native_image_buffer_isolation.py create mode 100644 tests/gateway/test_notice_delivery.py create mode 100644 tests/gateway/test_stale_code_self_check.py create mode 100644 tests/hermes_cli/test_goals.py create mode 100644 tests/hermes_cli/test_update_yes_flag.py create mode 100644 tests/run_agent/test_tool_call_guardrail_runtime.py create mode 100644 tests/tools/test_browser_supervisor_healthcheck.py create mode 100644 web/src/pages/PluginsPage.tsx create mode 100644 website/docs/user-guide/features/goals.md create mode 100644 website/docs/user-guide/skills/bundled/devops/devops-kanban-orchestrator.md create mode 100644 website/docs/user-guide/skills/bundled/devops/devops-kanban-worker.md create mode 100644 website/docs/user-guide/skills/optional/productivity/productivity-here-now.md create mode 100644 website/docs/user-guide/skills/optional/productivity/productivity-shopify.md create mode 100644 website/docs/user-stories.mdx create mode 100644 website/scripts/generate-llms-txt.py create mode 100644 website/src/components/UserStoriesCollage/index.tsx create mode 100644 website/src/components/UserStoriesCollage/styles.module.css create mode 100644 website/src/data/userStories.json diff --git a/.dockerignore b/.dockerignore index 542c96700e..41999f5ac6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,6 +9,12 @@ node_modules .venv **/.venv +# Built artifacts that are regenerated inside the image. Excluded so local +# rebuilds on the developer's machine don't invalidate the npm-install layer +# that now depends on the full ui-tui/packages/hermes-ink/ tree being present. +ui-tui/dist/ +ui-tui/packages/hermes-ink/dist/ + # CI/CD .github diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index 67f557badc..8df74c0509 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -76,6 +76,16 @@ jobs: run: | mkdir -p _site/docs cp -r website/build/* _site/docs/ + # llms.txt / llms-full.txt are also published at the site root + # (https://hermes-agent.nousresearch.com/llms.txt) because some + # agents and IDE plugins probe the classic root-level path rather + # than /docs/llms.txt. Same file, two URLs, one source of truth. + if [ -f website/build/llms.txt ]; then + cp website/build/llms.txt _site/llms.txt + fi + if [ -f website/build/llms-full.txt ]; then + cp website/build/llms-full.txt _site/llms-full.txt + fi - name: Upload artifact uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 diff --git a/Dockerfile b/Dockerfile index 18177cc1ac..08a5b6a275 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,10 +28,26 @@ WORKDIR /opt/hermes # ---------- Layer-cached dependency install ---------- # Copy only package manifests first so npm install + Playwright are cached # unless the lockfiles themselves change. +# +# ui-tui/packages/hermes-ink/ is copied IN FULL (not just its manifests) +# because it is referenced as a `file:` workspace dependency from +# ui-tui/package.json. Copying the tree up front lets npm resolve the +# workspace to real content instead of stopping at a bare package.json. COPY package.json package-lock.json ./ COPY web/package.json web/package-lock.json web/ COPY ui-tui/package.json ui-tui/package-lock.json ui-tui/ -COPY ui-tui/packages/hermes-ink/package.json ui-tui/packages/hermes-ink/package-lock.json ui-tui/packages/hermes-ink/ +COPY ui-tui/packages/hermes-ink/ ui-tui/packages/hermes-ink/ + +# `npm_config_install_links=false` forces npm to install `file:` deps as +# symlinks (the npm 10+ default) even on Debian's older bundled npm 9.x, +# which defaults to `install-links=true` and installs file deps as *copies*. +# The host-side package-lock.json is generated with a newer npm that uses +# symlinks, so an install-as-copy produces a hidden node_modules/.package-lock.json +# that permanently disagrees with the root lock on the @hermes/ink entry. +# That disagreement trips the TUI launcher's `_tui_need_npm_install()` +# check on every startup and triggers a runtime `npm install` that then +# fails with EACCES (node_modules/ is root-owned from build time). +ENV npm_config_install_links=false RUN npm install --prefer-offline --no-audit && \ npx playwright install --with-deps chromium --only-shell && \ @@ -45,13 +61,7 @@ COPY --chown=hermes:hermes . . # Build browser dashboard and terminal UI assets. RUN cd web && npm run build && \ - cd ../ui-tui && npm run build && \ - rm -rf node_modules/@hermes/ink && \ - rm -rf packages/hermes-ink/node_modules && \ - cp -R packages/hermes-ink node_modules/@hermes/ink && \ - npm install --omit=dev --prefer-offline --no-audit --prefix node_modules/@hermes/ink && \ - rm -rf node_modules/@hermes/ink/node_modules/react && \ - node --input-type=module -e "await import('@hermes/ink')" + cd ../ui-tui && npm run build # ---------- Permissions ---------- # Make install dir world-readable so any HERMES_UID can read it at runtime. diff --git a/acp_adapter/server.py b/acp_adapter/server.py index 862e9c5866..f8dade72af 100644 --- a/acp_adapter/server.py +++ b/acp_adapter/server.py @@ -164,6 +164,8 @@ class HermesACPAgent(acp.Agent): "context": "Show conversation context info", "reset": "Clear conversation history", "compact": "Compress conversation context", + "steer": "Inject guidance into the currently running agent turn", + "queue": "Queue a prompt to run after the current turn finishes", "version": "Show Hermes version", } @@ -193,6 +195,16 @@ class HermesACPAgent(acp.Agent): "name": "compact", "description": "Compress conversation context", }, + { + "name": "steer", + "description": "Inject guidance into the currently running agent turn", + "input_hint": "guidance for the active turn", + }, + { + "name": "queue", + "description": "Queue a prompt to run after the current turn finishes", + "input_hint": "prompt to run next", + }, { "name": "version", "description": "Show Hermes version", @@ -557,6 +569,9 @@ class HermesACPAgent(acp.Agent): async def cancel(self, session_id: str, **kwargs: Any) -> None: state = self.session_manager.get_session(session_id) if state and state.cancel_event: + with state.runtime_lock: + if state.is_running and state.current_prompt_text: + state.interrupted_prompt_text = state.current_prompt_text state.cancel_event.set() try: if getattr(state, "agent", None) and hasattr(state.agent, "interrupt"): @@ -654,6 +669,39 @@ class HermesACPAgent(acp.Agent): if not has_content: return PromptResponse(stop_reason="end_turn") + # /steer on an idle session has no in-flight tool call to inject into. + # Rewrite it so the payload runs as a normal user prompt, matching the + # gateway's behavior (gateway/run.py ~L4898). Two sub-cases: + # 1. Zed-interrupt salvage — a prior prompt was cancelled by the + # client right before /steer arrived; replay it with the steer + # text attached as explicit correction/guidance so the user's + # in-flight work isn't lost. + # 2. Plain idle — no prior work to salvage; just run the steer + # payload as a regular prompt. Without this, _cmd_steer would + # silently append to state.queued_prompts and respond with + # "No active turn — queued for the next turn", which looks like + # /queue even though the user never typed /queue. + if isinstance(user_content, str) and user_text.startswith("/steer"): + steer_text = user_text.split(maxsplit=1)[1].strip() if len(user_text.split(maxsplit=1)) > 1 else "" + interrupted_prompt = "" + rewrite_idle = False + with state.runtime_lock: + if not state.is_running and steer_text: + if state.interrupted_prompt_text: + interrupted_prompt = state.interrupted_prompt_text + state.interrupted_prompt_text = "" + else: + rewrite_idle = True + if interrupted_prompt: + user_text = ( + f"{interrupted_prompt}\n\n" + f"User correction/guidance after interrupt: {steer_text}" + ) + user_content = user_text + elif rewrite_idle: + user_text = steer_text + user_content = steer_text + # Intercept slash commands — handle locally without calling the LLM. # Slash commands are text-only; if the client included images/resources, # send the whole multimodal prompt to the agent instead of treating it as @@ -666,6 +714,24 @@ class HermesACPAgent(acp.Agent): await self._conn.session_update(session_id, update) return PromptResponse(stop_reason="end_turn") + # If Zed sends another regular prompt while the same ACP session is + # still running, queue it instead of racing two AIAgent loops against + # the same state.history. /steer and /queue are handled above and can + # land immediately. + with state.runtime_lock: + if state.is_running: + queued_text = user_text or "[Image attachment]" + state.queued_prompts.append(queued_text) + depth = len(state.queued_prompts) + if self._conn: + update = acp.update_agent_message_text( + f"Queued for the next turn. ({depth} queued)" + ) + await self._conn.session_update(session_id, update) + return PromptResponse(stop_reason="end_turn") + state.is_running = True + state.current_prompt_text = user_text or "[Image attachment]" + logger.info("Prompt on session %s: %s", session_id, user_text[:100]) conn = self._conn @@ -777,6 +843,9 @@ class HermesACPAgent(acp.Agent): result = await loop.run_in_executor(_executor, ctx.run, _run_agent) except Exception: logger.exception("Executor error for session %s", session_id) + with state.runtime_lock: + state.is_running = False + state.current_prompt_text = "" return PromptResponse(stop_reason="end_turn") if result.get("messages"): @@ -802,6 +871,28 @@ class HermesACPAgent(acp.Agent): update = acp.update_agent_message_text(final_response) await conn.session_update(session_id, update) + # Mark this turn idle before draining queued work so recursive prompt() + # calls can acquire the session. Queued turns are intentionally run as + # normal follow-up user prompts, preserving role alternation and history. + with state.runtime_lock: + state.is_running = False + state.current_prompt_text = "" + + while True: + with state.runtime_lock: + if not state.queued_prompts: + break + next_prompt = state.queued_prompts.pop(0) + if conn: + await conn.session_update( + session_id, + acp.update_user_message_text(next_prompt), + ) + await self.prompt( + prompt=[TextContentBlock(type="text", text=next_prompt)], + session_id=session_id, + ) + usage = None if any(result.get(key) is not None for key in ("prompt_tokens", "completion_tokens", "total_tokens")): usage = Usage( @@ -879,6 +970,8 @@ class HermesACPAgent(acp.Agent): "context": self._cmd_context, "reset": self._cmd_reset, "compact": self._cmd_compact, + "steer": self._cmd_steer, + "queue": self._cmd_queue, "version": self._cmd_version, }.get(cmd) @@ -975,10 +1068,16 @@ class HermesACPAgent(acp.Agent): if not hasattr(agent, "_compress_context"): return "Context compression not available for this agent." - from agent.model_metadata import estimate_messages_tokens_rough + from agent.model_metadata import estimate_request_tokens_rough original_count = len(state.history) - approx_tokens = estimate_messages_tokens_rough(state.history) + # Include system prompt + tool schemas so the figure reflects real + # request pressure, not a transcript-only underestimate (#6217). + _sys_prompt = getattr(agent, "_cached_system_prompt", "") or "" + _tools = getattr(agent, "tools", None) or None + approx_tokens = estimate_request_tokens_rough( + state.history, system_prompt=_sys_prompt, tools=_tools + ) original_session_db = getattr(agent, "_session_db", None) try: @@ -998,7 +1097,13 @@ class HermesACPAgent(acp.Agent): self.session_manager.save_session(state.session_id) new_count = len(state.history) - new_tokens = estimate_messages_tokens_rough(state.history) + _sys_prompt_after = getattr(agent, "_cached_system_prompt", "") or _sys_prompt + _tools_after = getattr(agent, "tools", None) or _tools + new_tokens = estimate_request_tokens_rough( + state.history, + system_prompt=_sys_prompt_after, + tools=_tools_after, + ) return ( f"Context compressed: {original_count} -> {new_count} messages\n" f"~{approx_tokens:,} -> ~{new_tokens:,} tokens" @@ -1006,6 +1111,34 @@ class HermesACPAgent(acp.Agent): except Exception as e: return f"Compression failed: {e}" + def _cmd_steer(self, args: str, state: SessionState) -> str: + steer_text = args.strip() + if not steer_text: + return "Usage: /steer " + + if state.is_running and hasattr(state.agent, "steer"): + try: + if state.agent.steer(steer_text): + preview = steer_text[:80] + ("..." if len(steer_text) > 80 else "") + return f"⏩ Steer queued for the active turn: {preview}" + except Exception as exc: + logger.warning("ACP steer failed for session %s: %s", state.session_id, exc) + return f"⚠️ Steer failed: {exc}" + + with state.runtime_lock: + state.queued_prompts.append(steer_text) + depth = len(state.queued_prompts) + return f"No active turn — queued for the next turn. ({depth} queued)" + + def _cmd_queue(self, args: str, state: SessionState) -> str: + queued_text = args.strip() + if not queued_text: + return "Usage: /queue " + with state.runtime_lock: + state.queued_prompts.append(queued_text) + depth = len(state.queued_prompts) + return f"Queued for the next turn. ({depth} queued)" + def _cmd_version(self, args: str, state: SessionState) -> str: return f"Hermes Agent v{HERMES_VERSION}" diff --git a/acp_adapter/session.py b/acp_adapter/session.py index 7245730026..d6dace66b4 100644 --- a/acp_adapter/session.py +++ b/acp_adapter/session.py @@ -26,6 +26,33 @@ from typing import Any, Dict, List, Optional logger = logging.getLogger(__name__) +def _win_path_to_wsl(path: str) -> str | None: + """Convert a Windows drive path to its WSL /mnt//... equivalent.""" + match = re.match(r"^([A-Za-z]):[\\/](.*)$", path) + if not match: + return None + drive = match.group(1).lower() + tail = match.group(2).replace("\\", "/") + return f"/mnt/{drive}/{tail}" + + +def _translate_acp_cwd(cwd: str) -> str: + """Translate Windows ACP cwd values when Hermes itself is running in WSL. + + Windows ACP clients can launch ``hermes acp`` inside WSL while still sending + editor workspaces as Windows drive paths such as ``E:\\Projects``. Store + and execute against the WSL mount path so agents, tools, and persisted ACP + sessions all agree on the usable workspace. Native Linux/macOS keeps the + original cwd unchanged. + """ + from hermes_constants import is_wsl + + if not is_wsl(): + return cwd + translated = _win_path_to_wsl(str(cwd)) + return translated if translated is not None else cwd + + def _normalize_cwd_for_compare(cwd: str | None) -> str: raw = str(cwd or ".").strip() if not raw: @@ -34,11 +61,9 @@ def _normalize_cwd_for_compare(cwd: str | None) -> str: # Normalize Windows drive paths into the equivalent WSL mount form so # ACP history filters match the same workspace across Windows and WSL. - match = re.match(r"^([A-Za-z]):[\\/](.*)$", expanded) - if match: - drive = match.group(1).lower() - tail = match.group(2).replace("\\", "/") - expanded = f"/mnt/{drive}/{tail}" + translated = _win_path_to_wsl(expanded) + if translated is not None: + expanded = translated elif re.match(r"^/mnt/[A-Za-z]/", expanded): expanded = f"/mnt/{expanded[5].lower()}/{expanded[7:]}" @@ -96,12 +121,18 @@ def _acp_stderr_print(*args, **kwargs) -> None: def _register_task_cwd(task_id: str, cwd: str) -> None: - """Bind a task/session id to the editor's working directory for tools.""" + """Bind a task/session id to the editor's working directory for tools. + + Zed can launch Hermes from a Windows workspace while the ACP process runs + inside WSL. In that case ACP sends cwd as e.g. ``E:\\Projects\\POTI``; + local tools need the WSL mount equivalent or subprocess creation fails + before the command can run. + """ if not task_id: return try: from tools.terminal_tool import register_task_env_overrides - register_task_env_overrides(task_id, {"cwd": cwd}) + register_task_env_overrides(task_id, {"cwd": _translate_acp_cwd(cwd)}) except Exception: logger.debug("Failed to register ACP task cwd override", exc_info=True) @@ -145,6 +176,11 @@ class SessionState: model: str = "" history: List[Dict[str, Any]] = field(default_factory=list) cancel_event: Any = None # threading.Event + is_running: bool = False + queued_prompts: List[str] = field(default_factory=list) + runtime_lock: Any = field(default_factory=Lock) + current_prompt_text: str = "" + interrupted_prompt_text: str = "" class SessionManager: @@ -175,6 +211,7 @@ class SessionManager: """Create a new session with a unique ID and a fresh AIAgent.""" import threading + cwd = _translate_acp_cwd(cwd) session_id = str(uuid.uuid4()) agent = self._make_agent(session_id=session_id, cwd=cwd) state = SessionState( @@ -217,6 +254,7 @@ class SessionManager: """Deep-copy a session's history into a new session.""" import threading + cwd = _translate_acp_cwd(cwd) original = self.get_session(session_id) # checks DB too if original is None: return None @@ -318,6 +356,7 @@ class SessionManager: def update_cwd(self, session_id: str, cwd: str) -> Optional[SessionState]: """Update the working directory for a session and its tool overrides.""" + cwd = _translate_acp_cwd(cwd) state = self.get_session(session_id) # checks DB too if state is None: return None diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 6826476fdc..df3fdeccc6 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1977,6 +1977,12 @@ def resolve_provider_client( (client, resolved_model) or (None, None) if auth is unavailable. """ _validate_proxy_env_urls() + # Preserve the original provider name before alias normalization so a + # user-declared ``custom_providers`` entry whose name coincidentally + # matches a built-in alias (e.g. user names their custom provider "kimi" + # which aliases to "kimi-coding") is still reachable via the named-custom + # branch below. + original_provider = (provider or "").strip().lower() # Normalise aliases provider = _normalize_aux_provider(provider) @@ -2163,7 +2169,18 @@ def resolve_provider_client( # ── Named custom providers (config.yaml providers dict / custom_providers list) ─── try: from hermes_cli.runtime_provider import _get_named_custom_provider - custom_entry = _get_named_custom_provider(provider) + # When the raw requested name is an alias (``kimi`` → ``kimi-coding``) + # and the user defined a ``custom_providers`` entry under that alias + # name, the custom entry is the intended target — the built-in alias + # rewriting would otherwise hijack the request. Only preferred when + # the raw name is an alias (not a canonical provider name) so custom + # entries that coincidentally match a canonical provider (e.g. ``nous``) + # still defer to the built-in per `_get_named_custom_provider`'s guard. + custom_entry = None + if original_provider and original_provider != provider: + custom_entry = _get_named_custom_provider(original_provider) + if custom_entry is None: + custom_entry = _get_named_custom_provider(provider) if custom_entry: custom_base = custom_entry.get("base_url", "").strip() custom_key = custom_entry.get("api_key", "").strip() @@ -2273,6 +2290,12 @@ def resolve_provider_client( creds = resolve_api_key_provider_credentials(provider) api_key = str(creds.get("api_key", "")).strip() + # Honour an explicit api_key override (e.g. from a fallback_model entry + # or a custom_providers entry) so callers that pass an explicit + # credential can authenticate against endpoints where no built-in + # credential is registered for this provider alias. + if explicit_api_key: + api_key = explicit_api_key.strip() or api_key if not api_key: tried_sources = list(pconfig.api_key_env_vars) if provider == "copilot": @@ -2284,6 +2307,11 @@ def resolve_provider_client( raw_base_url = str(creds.get("base_url", "")).strip().rstrip("/") or pconfig.inference_base_url base_url = _to_openai_base_url(raw_base_url) + # Honour an explicit base_url override from the caller — used when a + # fallback_model entry (or custom_providers lookup) routes through a + # built-in provider name but targets a user-specified endpoint. + if explicit_base_url: + base_url = _to_openai_base_url(explicit_base_url.strip().rstrip("/")) default_model = _API_KEY_PROVIDER_AUX_MODELS.get(provider, "") final_model = _normalize_resolved_model(model or default_model, provider) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index edbc89b7dd..21f07df491 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -538,7 +538,7 @@ class ContextCompressor(ContextEngine): # Token-budget approach: walk backward accumulating tokens accumulated = 0 boundary = len(result) - min_protect = min(protect_tail_count, len(result) - 1) + min_protect = min(protect_tail_count, len(result)) for i in range(len(result) - 1, -1, -1): msg = result[i] raw_content = msg.get("content") or "" @@ -992,8 +992,8 @@ The user has requested that this compaction PRIORITISE preserving all informatio def _get_tool_call_id(tc) -> str: """Extract the call ID from a tool_call entry (dict or SimpleNamespace).""" if isinstance(tc, dict): - return tc.get("id", "") - return getattr(tc, "id", "") or "" + return tc.get("call_id", "") or tc.get("id", "") or "" + return getattr(tc, "call_id", "") or getattr(tc, "id", "") or "" def _sanitize_tool_pairs(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """Fix orphaned tool_call / tool_result pairs after compression. diff --git a/agent/curator.py b/agent/curator.py index 7419f9ca0c..2eebe10ef5 100644 --- a/agent/curator.py +++ b/agent/curator.py @@ -55,6 +55,7 @@ def _default_state() -> Dict[str, Any]: "last_run_at": None, "last_run_duration_seconds": None, "last_run_summary": None, + "last_report_path": None, "paused": False, "run_count": 0, } @@ -183,7 +184,16 @@ def should_run_now(now: Optional[datetime] = None) -> bool: Gates: - curator.enabled == True - not paused - - last_run_at missing, OR older than interval_hours + - last_run_at present AND older than interval_hours + + First-run behavior: when there is no ``last_run_at`` (fresh install, or + install that predates the curator), we DO NOT run immediately. The + curator is designed to run after at least ``interval_hours`` (7 days by + default) of skill activity, not on the first background tick after + ``hermes update``. On first observation we seed ``last_run_at`` to "now" + and defer the first real pass by one full interval. Users who want to + run it sooner can always invoke ``hermes curator run`` (with or without + ``--dry-run``) explicitly — that path bypasses this gate. The idle check (min_idle_hours) is applied at the call site where we know whether an agent is actively running — here we only enforce the static @@ -197,7 +207,21 @@ def should_run_now(now: Optional[datetime] = None) -> bool: state = load_state() last = _parse_iso(state.get("last_run_at")) if last is None: - return True + # Never run before. Seed state so we wait a full interval before the + # first real pass. Report-only; do not auto-mutate the library the + # very first time a gateway ticks after an update. + if now is None: + now = datetime.now(timezone.utc) + try: + state["last_run_at"] = now.isoformat() + state["last_run_summary"] = ( + "deferred first run — curator seeded, will run after one " + "interval; use `hermes curator run --dry-run` to preview now" + ) + save_state(state) + except Exception as e: # pragma: no cover — best-effort persistence + logger.debug("Failed to seed curator last_run_at: %s", e) + return False if now is None: now = datetime.now(timezone.utc) @@ -258,6 +282,33 @@ def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int # Review prompt for the forked agent # --------------------------------------------------------------------------- +CURATOR_DRY_RUN_BANNER = ( + "═══════════════════════════════════════════════════════════════\n" + "DRY-RUN — REPORT ONLY. DO NOT MUTATE THE SKILL LIBRARY.\n" + "═══════════════════════════════════════════════════════════════\n" + "\n" + "This is a PREVIEW pass. Follow every instruction below EXCEPT:\n" + "\n" + " • DO NOT call skill_manage with action=patch, create, delete, " + "write_file, or remove_file.\n" + " • DO NOT call terminal to mv skill directories into .archive/.\n" + " • DO NOT call terminal to mv, cp, rm, or rewrite any file under " + "~/.hermes/skills/.\n" + " • skills_list and skill_view are FINE — read as much as you need.\n" + "\n" + "Your output IS the deliverable. Produce the exact same " + "human-readable summary and structured YAML block you would " + "produce on a live run — but describe the actions you WOULD take, " + "not actions you took. A downstream reviewer will read the report " + "and decide whether to approve a live run with " + "`hermes curator run` (no flag).\n" + "\n" + "If you accidentally take a mutating action, say so explicitly in " + "the summary so the reviewer can revert it.\n" + "═══════════════════════════════════════════════════════════════" +) + + CURATOR_REVIEW_PROMPT = ( "You are running as Hermes' background skill CURATOR. This is an " "UMBRELLA-BUILDING consolidation pass, not a passive audit and not a " @@ -766,6 +817,39 @@ def _write_run_report( consolidated = classification["consolidated"] pruned = classification["pruned"] + # Rewrite cron job skill references. When the curator consolidates + # skill X into umbrella Y, any cron job that lists X fails to load + # it at run time — the scheduler skips it and the job runs without + # the instructions it was scheduled to follow. Rewriting the + # references in-place keeps scheduled jobs working across + # consolidation passes. Best-effort: never let a cron-module issue + # break the curator. + cron_rewrites: Dict[str, Any] = {"rewrites": [], "jobs_updated": 0, "jobs_scanned": 0} + try: + consolidated_map = { + e["name"]: e["into"] + for e in consolidated + if isinstance(e, dict) and e.get("name") and e.get("into") + } + pruned_names = [ + e["name"] for e in pruned + if isinstance(e, dict) and e.get("name") + ] + if consolidated_map or pruned_names: + from cron.jobs import rewrite_skill_refs as _rewrite_cron_refs + cron_rewrites = _rewrite_cron_refs( + consolidated=consolidated_map, + pruned=pruned_names, + ) + except Exception as e: + logger.debug("Curator cron skill rewrite failed: %s", e, exc_info=True) + cron_rewrites = { + "rewrites": [], + "jobs_updated": 0, + "jobs_scanned": 0, + "error": str(e), + } + payload = { "started_at": started_at.isoformat(), "duration_seconds": round(elapsed_seconds, 2), @@ -781,6 +865,7 @@ def _write_run_report( "consolidated_this_run": len(consolidated), "pruned_this_run": len(pruned), "state_transitions": len(transitions), + "cron_jobs_rewritten": int(cron_rewrites.get("jobs_updated", 0)), "tool_calls_total": sum(tc_counts.values()), }, "tool_call_counts": tc_counts, @@ -790,6 +875,7 @@ def _write_run_report( "pruned_names": [p["name"] for p in pruned], "added": added, "state_transitions": transitions, + "cron_rewrites": cron_rewrites, "llm_final": llm_meta.get("final", ""), "llm_summary": llm_meta.get("summary", ""), "llm_error": llm_meta.get("error"), @@ -812,6 +898,17 @@ def _write_run_report( except Exception as e: logger.debug("Curator REPORT.md write failed: %s", e) + # cron_rewrites.json — only when at least one job was touched, to + # keep run dirs uncluttered for the common no-op case. + try: + if int(cron_rewrites.get("jobs_updated", 0)) > 0: + (run_dir / "cron_rewrites.json").write_text( + json.dumps(cron_rewrites, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + except Exception as e: + logger.debug("Curator cron_rewrites.json write failed: %s", e) + return run_dir @@ -942,6 +1039,39 @@ def _render_report_markdown(p: Dict[str, Any]) -> str: lines.append(f"- `{t.get('name')}`: {t.get('from')} → {t.get('to')}") lines.append("") + # Cron job rewrites — show which scheduled jobs had their skill + # references updated so users can audit that the auto-rewrite did + # the right thing. Only present when at least one job changed. + cron_rw = p.get("cron_rewrites") or {} + cron_rewrites_list = cron_rw.get("rewrites") or [] + if cron_rewrites_list: + lines.append(f"### Cron job skill references rewritten ({len(cron_rewrites_list)})\n") + lines.append( + "_Cron jobs that referenced a consolidated or pruned skill were " + "updated in-place so they keep loading the right instructions " + "on their next run. See `cron_rewrites.json` for the full record._\n" + ) + SHOW = 25 + for entry in cron_rewrites_list[:SHOW]: + job_name = entry.get("job_name") or entry.get("job_id") or "?" + before = entry.get("before") or [] + after = entry.get("after") or [] + mapped = entry.get("mapped") or {} + dropped = entry.get("dropped") or [] + lines.append( + f"- `{job_name}`: `{', '.join(before)}` → `{', '.join(after) or '(none)'}`" + ) + for old, new in mapped.items(): + lines.append(f" - `{old}` → `{new}` (consolidated)") + for name in dropped: + lines.append(f" - `{name}` dropped (pruned)") + if len(cron_rewrites_list) > SHOW: + lines.append( + f"- … and {len(cron_rewrites_list) - SHOW} more " + "(see `cron_rewrites.json`)" + ) + lines.append("") + # Full LLM final response final = (p.get("llm_final") or "").strip() if final: @@ -992,6 +1122,7 @@ def _render_candidate_list() -> str: def run_curator_review( on_summary: Optional[Callable[[str], None]] = None, synchronous: bool = False, + dry_run: bool = False, ) -> Dict[str, Any]: """Execute a single curator review pass. @@ -1004,9 +1135,43 @@ def run_curator_review( If *synchronous* is True, the LLM review runs in the calling thread; the default is to spawn a daemon thread so the caller returns immediately. + + If *dry_run* is True, the automatic stale/archive transitions are SKIPPED + and the LLM review pass is instructed to produce a report only — no + skill_manage mutations, no terminal archive moves. The REPORT.md still + gets written and ``state.last_report_path`` still records it so users + can read what the curator WOULD have done. """ start = datetime.now(timezone.utc) - counts = apply_automatic_transitions(now=start) + if dry_run: + # Count candidates without mutating state. + try: + report = skill_usage.agent_created_report() + counts = { + "checked": len(report), + "marked_stale": 0, + "archived": 0, + "reactivated": 0, + } + except Exception: + counts = {"checked": 0, "marked_stale": 0, "archived": 0, "reactivated": 0} + else: + # Pre-mutation snapshot — best-effort, never blocks the run. A + # failed snapshot logs at debug and continues (the alternative is + # that a transient disk issue silently disables curator forever, + # which is worse). Users who want to require snapshots can disable + # curator entirely until they can fix disk space. + try: + from agent import curator_backup + snap = curator_backup.snapshot_skills(reason="pre-curator-run") + if snap is not None and on_summary: + try: + on_summary(f"curator: snapshot created ({snap.name})") + except Exception: + pass + except Exception as e: + logger.debug("Curator pre-run snapshot failed: %s", e, exc_info=True) + counts = apply_automatic_transitions(now=start) auto_summary_parts = [] if counts["marked_stale"]: @@ -1018,11 +1183,16 @@ def run_curator_review( auto_summary = ", ".join(auto_summary_parts) if auto_summary_parts else "no changes" # Persist state before the LLM pass so a crash mid-review still records - # the run and doesn't immediately re-trigger. + # the run and doesn't immediately re-trigger. In dry-run we do NOT bump + # last_run_at or run_count — a preview shouldn't push the next scheduled + # real pass out. We still record a summary so `hermes curator status` + # shows that a preview ran. state = load_state() - state["last_run_at"] = start.isoformat() - state["run_count"] = int(state.get("run_count", 0)) + 1 - state["last_run_summary"] = f"auto: {auto_summary}" + if not dry_run: + state["last_run_at"] = start.isoformat() + state["run_count"] = int(state.get("run_count", 0)) + 1 + prefix = "dry-run auto: " if dry_run else "auto: " + state["last_run_summary"] = f"{prefix}{auto_summary}" save_state(state) def _llm_pass(): @@ -1038,7 +1208,7 @@ def run_curator_review( try: candidate_list = _render_candidate_list() if "No agent-created skills" in candidate_list: - final_summary = f"auto: {auto_summary}; llm: skipped (no candidates)" + final_summary = f"{prefix}{auto_summary}; llm: skipped (no candidates)" llm_meta = { "final": "", "summary": "skipped (no candidates)", @@ -1048,14 +1218,21 @@ def run_curator_review( "error": None, } else: - prompt = f"{CURATOR_REVIEW_PROMPT}\n\n{candidate_list}" + if dry_run: + prompt = ( + f"{CURATOR_DRY_RUN_BANNER}\n\n" + f"{CURATOR_REVIEW_PROMPT}\n\n" + f"{candidate_list}" + ) + else: + prompt = f"{CURATOR_REVIEW_PROMPT}\n\n{candidate_list}" llm_meta = _run_llm_review(prompt) final_summary = ( - f"auto: {auto_summary}; llm: {llm_meta.get('summary', 'no change')}" + f"{prefix}{auto_summary}; llm: {llm_meta.get('summary', 'no change')}" ) except Exception as e: logger.debug("Curator LLM pass failed: %s", e, exc_info=True) - final_summary = f"auto: {auto_summary}; llm: error ({e})" + final_summary = f"{prefix}{auto_summary}; llm: error ({e})" llm_meta = { "final": "", "summary": f"error ({e})", diff --git a/agent/curator_backup.py b/agent/curator_backup.py new file mode 100644 index 0000000000..268de64f41 --- /dev/null +++ b/agent/curator_backup.py @@ -0,0 +1,440 @@ +"""Curator snapshot + rollback. + +A pre-run snapshot of ``~/.hermes/skills/`` (excluding ``.curator_backups/`` +itself) is taken before any mutating curator pass. Snapshots are tar.gz +files under ``~/.hermes/skills/.curator_backups//`` with a +companion ``manifest.json`` describing the snapshot (reason, time, size, +counted skill files). Rollback picks a snapshot, moves the current +``skills/`` tree aside into another snapshot so even the rollback itself +is undoable, then extracts the chosen snapshot into place. + +The snapshot does NOT include: + - ``.curator_backups/`` (would recurse) + - ``.hub/`` (hub-installed skills — managed by the hub, not us) + +It DOES include: + - all SKILL.md files + their directories (``scripts/``, ``references/``, + ``templates/``, ``assets/``) + - ``.usage.json`` (usage telemetry — needed to rehydrate state cleanly) + - ``.archive/`` (so rollback restores previously-archived skills too) + - ``.curator_state`` (so rolling back also restores the last-run-at + pointer — otherwise the curator would immediately re-fire on the next + tick) + - ``.bundled_manifest`` (so protection markers stay consistent) +""" + +from __future__ import annotations + +import json +import logging +import os +import re +import shutil +import tarfile +import tempfile +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from hermes_constants import get_hermes_home + +logger = logging.getLogger(__name__) + + +DEFAULT_KEEP = 5 + +# Entries under skills/ that should NEVER be rolled up into a snapshot. +# .hub/ is managed by the skills hub; rolling it back would break lockfile +# invariants. .curator_backups is the backup dir itself — recursion bomb. +_EXCLUDE_TOP_LEVEL = {".curator_backups", ".hub"} + +# Snapshot id regex: UTC ISO with colons replaced by dashes so the filename +# is portable (Windows-safe). An optional ``-NN`` suffix handles two +# snapshots landing in the same wallclock second. +_ID_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z(-\d{2})?$") + + +def _backups_dir() -> Path: + return get_hermes_home() / "skills" / ".curator_backups" + + +def _skills_dir() -> Path: + return get_hermes_home() / "skills" + + +def _utc_id(now: Optional[datetime] = None) -> str: + """UTC ISO-ish filesystem-safe timestamp: ``2026-05-01T13-05-42Z``.""" + if now is None: + now = datetime.now(timezone.utc) + # isoformat → "2026-05-01T13:05:42.123456+00:00"; strip subseconds and tz. + s = now.replace(microsecond=0).isoformat() + if s.endswith("+00:00"): + s = s[:-6] + return s.replace(":", "-") + "Z" + + +def _load_config() -> Dict[str, Any]: + try: + from hermes_cli.config import load_config + cfg = load_config() + except Exception as e: + logger.debug("Failed to load config for curator backup: %s", e) + return {} + if not isinstance(cfg, dict): + return {} + cur = cfg.get("curator") or {} + if not isinstance(cur, dict): + return {} + bk = cur.get("backup") or {} + return bk if isinstance(bk, dict) else {} + + +def is_enabled() -> bool: + """Default ON — the whole point of the backup is safety by default.""" + return bool(_load_config().get("enabled", True)) + + +def get_keep() -> int: + cfg = _load_config() + try: + n = int(cfg.get("keep", DEFAULT_KEEP)) + except (TypeError, ValueError): + n = DEFAULT_KEEP + return max(1, n) + + +# --------------------------------------------------------------------------- +# Snapshot +# --------------------------------------------------------------------------- + +def _count_skill_files(base: Path) -> int: + try: + return sum(1 for _ in base.rglob("SKILL.md")) + except OSError: + return 0 + + +def _write_manifest(dest: Path, reason: str, archive_path: Path, + skills_counted: int) -> None: + manifest = { + "id": dest.name, + "reason": reason, + "created_at": datetime.now(timezone.utc).isoformat(), + "archive": archive_path.name, + "archive_bytes": archive_path.stat().st_size, + "skill_files": skills_counted, + } + (dest / "manifest.json").write_text( + json.dumps(manifest, indent=2, sort_keys=True), encoding="utf-8" + ) + + +def snapshot_skills(reason: str = "manual") -> Optional[Path]: + """Create a tar.gz snapshot of ``~/.hermes/skills/`` and prune old ones. + + Returns the snapshot directory path, or ``None`` if the snapshot was + skipped (backup disabled, skills dir missing, or an IO error occurred — + in which case we log at debug and return None so the curator never + aborts a pass because of a backup failure). + """ + if not is_enabled(): + logger.debug("Curator backup disabled by config; skipping snapshot") + return None + + skills = _skills_dir() + if not skills.exists(): + logger.debug("No ~/.hermes/skills/ directory — nothing to back up") + return None + + backups = _backups_dir() + try: + backups.mkdir(parents=True, exist_ok=True) + except OSError as e: + logger.debug("Failed to create backups dir %s: %s", backups, e) + return None + + # Uniquify: if a snapshot with the same second already exists (can + # happen if two curator runs fire in the same second), append a short + # counter. Avoids clobbering and avoids timestamp collisions. + base_id = _utc_id() + snap_id = base_id + counter = 1 + while (backups / snap_id).exists(): + snap_id = f"{base_id}-{counter:02d}" + counter += 1 + + dest = backups / snap_id + try: + dest.mkdir(parents=True, exist_ok=False) + except OSError as e: + logger.debug("Failed to create snapshot dir %s: %s", dest, e) + return None + + archive = dest / "skills.tar.gz" + try: + # Stream into the tarball — no tempdir copy needed. + with tarfile.open(archive, "w:gz", compresslevel=6) as tf: + for entry in sorted(skills.iterdir()): + if entry.name in _EXCLUDE_TOP_LEVEL: + continue + # arcname: store paths relative to skills/ so extraction + # drops cleanly back into the skills dir. + tf.add(str(entry), arcname=entry.name, recursive=True) + _write_manifest(dest, reason, archive, _count_skill_files(skills)) + except (OSError, tarfile.TarError) as e: + logger.debug("Curator snapshot failed: %s", e, exc_info=True) + # Clean up partial snapshot + try: + shutil.rmtree(dest, ignore_errors=True) + except OSError: + pass + return None + + _prune_old(keep=get_keep()) + logger.info("Curator snapshot created: %s (%s)", snap_id, reason) + return dest + + +def _prune_old(keep: int) -> List[str]: + """Delete regular snapshots beyond the newest *keep*. Returns deleted + ids. Staging dirs (``.rollback-staging-*``) are implementation detail + and pruned independently on every call.""" + backups = _backups_dir() + if not backups.exists(): + return [] + entries: List[Tuple[str, Path]] = [] + stale_staging: List[Path] = [] + for child in backups.iterdir(): + if not child.is_dir(): + continue + if child.name.startswith(".rollback-staging-"): + # Staging dirs are only supposed to exist briefly during a + # rollback. If we find one here (e.g. from a crashed rollback), + # clean it up opportunistically. + stale_staging.append(child) + continue + if _ID_RE.match(child.name): + entries.append((child.name, child)) + # Newest first (lexicographic works because the id is UTC ISO). + entries.sort(key=lambda t: t[0], reverse=True) + deleted: List[str] = [] + for _, path in entries[keep:]: + try: + shutil.rmtree(path) + deleted.append(path.name) + except OSError as e: + logger.debug("Failed to prune %s: %s", path, e) + for path in stale_staging: + try: + shutil.rmtree(path) + except OSError as e: + logger.debug("Failed to clean stale staging dir %s: %s", path, e) + return deleted + + +# --------------------------------------------------------------------------- +# List + rollback +# --------------------------------------------------------------------------- + +def _read_manifest(snap_dir: Path) -> Dict[str, Any]: + mf = snap_dir / "manifest.json" + if not mf.exists(): + return {} + try: + return json.loads(mf.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + + +def list_backups() -> List[Dict[str, Any]]: + """Return all restorable snapshots, newest first. Only entries with a + real ``skills.tar.gz`` tarball are listed — transient + ``.rollback-staging-*`` directories created mid-rollback are + implementation detail and not shown.""" + backups = _backups_dir() + if not backups.exists(): + return [] + out: List[Dict[str, Any]] = [] + for child in sorted(backups.iterdir(), reverse=True): + if not child.is_dir(): + continue + if not _ID_RE.match(child.name): + continue + if not (child / "skills.tar.gz").exists(): + continue + mf = _read_manifest(child) + mf.setdefault("id", child.name) + mf.setdefault("path", str(child)) + if "archive_bytes" not in mf: + arc = child / "skills.tar.gz" + try: + mf["archive_bytes"] = arc.stat().st_size + except OSError: + mf["archive_bytes"] = 0 + out.append(mf) + return out + + +def _resolve_backup(backup_id: Optional[str]) -> Optional[Path]: + """Return the path of the requested backup, or the newest one if + *backup_id* is None. Returns None if no match.""" + backups = _backups_dir() + if not backups.exists(): + return None + if backup_id: + target = backups / backup_id + if ( + target.is_dir() + and _ID_RE.match(backup_id) + and (target / "skills.tar.gz").exists() + ): + return target + return None + candidates = [ + c for c in sorted(backups.iterdir(), reverse=True) + if c.is_dir() and _ID_RE.match(c.name) and (c / "skills.tar.gz").exists() + ] + return candidates[0] if candidates else None + + +def rollback(backup_id: Optional[str] = None) -> Tuple[bool, str, Optional[Path]]: + """Restore ``~/.hermes/skills/`` from a snapshot. + + Strategy: + 1. Resolve the target snapshot (explicit id or newest regular). + 2. Take a safety snapshot of the CURRENT skills tree under + ``.curator_backups/pre-rollback-/`` so the rollback itself is + undoable. + 3. Move all current top-level entries (except ``.curator_backups`` + and ``.hub``) into a tempdir. + 4. Extract the chosen snapshot into ``~/.hermes/skills/``. + 5. On failure during 4, move the tempdir contents back (best-effort) + and return failure. + + Returns ``(ok, message, snapshot_path)``. + """ + target = _resolve_backup(backup_id) + if target is None: + return ( + False, + f"no matching backup found" + + (f" for id '{backup_id}'" if backup_id else "") + + " (use `hermes curator rollback --list` to see available snapshots)", + None, + ) + archive = target / "skills.tar.gz" + if not archive.exists(): + return (False, f"snapshot {target.name} has no skills.tar.gz — corrupted?", None) + + skills = _skills_dir() + skills.mkdir(parents=True, exist_ok=True) + backups = _backups_dir() + backups.mkdir(parents=True, exist_ok=True) + + # Step 2: safety snapshot of current state FIRST. If this fails we bail + # out before touching anything — otherwise a failed extract could leave + # the user with no skills. + try: + snapshot_skills(reason=f"pre-rollback to {target.name}") + except Exception as e: + return (False, f"pre-rollback safety snapshot failed: {e}", None) + + # Additionally move current entries into an internal staging dir so + # the extract happens into an empty skills tree (predictable result). + # This dir is implementation detail — not listed as a restorable + # backup. The safety snapshot above is the user-facing undo handle. + staged = backups / f".rollback-staging-{_utc_id()}" + try: + staged.mkdir(parents=True, exist_ok=False) + except OSError as e: + return (False, f"failed to create staging dir: {e}", None) + + moved: List[Tuple[Path, Path]] = [] + try: + for entry in list(skills.iterdir()): + if entry.name in _EXCLUDE_TOP_LEVEL: + continue + dest = staged / entry.name + shutil.move(str(entry), str(dest)) + moved.append((entry, dest)) + except OSError as e: + # Best-effort rollback of the move + for orig, dest in moved: + try: + shutil.move(str(dest), str(orig)) + except OSError: + pass + try: + shutil.rmtree(staged, ignore_errors=True) + except OSError: + pass + return (False, f"failed to stage current skills: {e}", None) + + # Step 4: extract the snapshot into skills/ + try: + with tarfile.open(archive, "r:gz") as tf: + # Python 3.12+ supports filter='data' for safer extraction. + # Fall back to the unfiltered call for older interpreters but + # still reject absolute paths and .. components defensively. + for member in tf.getmembers(): + name = member.name + if name.startswith("/") or ".." in Path(name).parts: + raise tarfile.TarError( + f"refusing to extract unsafe path: {name!r}" + ) + try: + tf.extractall(str(skills), filter="data") # type: ignore[call-arg] + except TypeError: + # Python < 3.12 — no filter kwarg + tf.extractall(str(skills)) + except (OSError, tarfile.TarError) as e: + # Best-effort recover: move staged contents back + for orig, dest in moved: + try: + shutil.move(str(dest), str(orig)) + except OSError: + pass + try: + shutil.rmtree(staged, ignore_errors=True) + except OSError: + pass + return (False, f"snapshot extract failed (state restored): {e}", None) + + # Extract succeeded — the staging dir has served its purpose. The + # user's undo handle is the safety snapshot tarball we took earlier. + try: + shutil.rmtree(staged, ignore_errors=True) + except OSError: + pass + + logger.info("Curator rollback: restored from %s", target.name) + return (True, f"restored from snapshot {target.name}", target) + + +# --------------------------------------------------------------------------- +# Human-readable summary for CLI +# --------------------------------------------------------------------------- + +def format_size(n: int) -> str: + for unit in ("B", "KB", "MB", "GB"): + if n < 1024 or unit == "GB": + return f"{n:.1f} {unit}" if unit != "B" else f"{n} B" + n /= 1024 + return f"{n:.1f} GB" + + +def summarize_backups() -> str: + rows = list_backups() + if not rows: + return "No curator snapshots yet." + lines = [f"{'id':<24} {'reason':<40} {'skills':>6} {'size':>8}"] + lines.append("─" * len(lines[0])) + for r in rows: + lines.append( + f"{r.get('id','?'):<24} " + f"{(r.get('reason','?') or '?')[:40]:<40} " + f"{r.get('skill_files', 0):>6} " + f"{format_size(int(r.get('archive_bytes', 0))):>8}" + ) + return "\n".join(lines) diff --git a/agent/manual_compression_feedback.py b/agent/manual_compression_feedback.py index 8f2d5e5d52..32b00f7cf4 100644 --- a/agent/manual_compression_feedback.py +++ b/agent/manual_compression_feedback.py @@ -20,25 +20,25 @@ def summarize_manual_compression( headline = f"No changes from compression: {before_count} messages" if after_tokens == before_tokens: token_line = ( - f"Rough transcript estimate: ~{before_tokens:,} tokens (unchanged)" + f"Approx request size: ~{before_tokens:,} tokens (unchanged)" ) else: token_line = ( - f"Rough transcript estimate: ~{before_tokens:,} → " + f"Approx request size: ~{before_tokens:,} → " f"~{after_tokens:,} tokens" ) else: headline = f"Compressed: {before_count} → {after_count} messages" token_line = ( - f"Rough transcript estimate: ~{before_tokens:,} → " + f"Approx request size: ~{before_tokens:,} → " f"~{after_tokens:,} tokens" ) note = None if not noop and after_count < before_count and after_tokens > before_tokens: note = ( - "Note: fewer messages can still raise this rough transcript estimate " - "when compression rewrites the transcript into denser summaries." + "Note: fewer messages can still raise this estimate when " + "compression rewrites the transcript into denser summaries." ) return { diff --git a/agent/moonshot_schema.py b/agent/moonshot_schema.py index 08585bab4c..aeefd4a0ce 100644 --- a/agent/moonshot_schema.py +++ b/agent/moonshot_schema.py @@ -81,15 +81,56 @@ def _repair_schema(node: Any, is_schema: bool = True) -> Any: return repaired # Rule 2: when anyOf is present, type belongs only on the children. + # Additionally, Moonshot rejects null-type branches inside anyOf + # (enum value () does not match any type in [string]). + # Collapse the anyOf to the first non-null branch and infer its type. if "anyOf" in repaired and isinstance(repaired["anyOf"], list): repaired.pop("type", None) - return repaired + non_null = [b for b in repaired["anyOf"] + if isinstance(b, dict) and b.get("type") != "null"] + if non_null and len(non_null) < len(repaired["anyOf"]): + # Drop the anyOf wrapper — keep only the non-null branch. + # If there's a single non-null branch, promote it and fall + # through to Rules 1/3 so nullable/enum cleanup still applies + # to the merged node. + if len(non_null) == 1: + merge = {k: v for k, v in repaired.items() if k != "anyOf"} + merge.update(non_null[0]) + repaired = merge + else: + repaired["anyOf"] = non_null + return repaired + else: + # Nothing to collapse — parent type stripped, children already + # repaired by the recursive walk above. + return repaired + + # Moonshot also rejects non-standard keywords like ``nullable`` on + # parameter schemas — strip it. + repaired.pop("nullable", None) # Rule 1: property schemas without type need one. $ref nodes are exempt # — their type comes from the referenced definition. - if "$ref" in repaired: - return repaired - return _fill_missing_type(repaired) + # Fill missing type BEFORE Rule 3 so enum cleanup can check the type. + if "$ref" not in repaired: + repaired = _fill_missing_type(repaired) + + # Rule 3: Moonshot rejects null/empty-string values inside enum arrays + # when the parent type is a scalar (string, integer, etc.). The error: + # "enum value () does not match any type in [string]" + # Strip null and empty-string from enum values, and if the enum becomes + # empty, drop it entirely. + if "enum" in repaired and isinstance(repaired["enum"], list): + node_type = repaired.get("type") + if node_type in ("string", "integer", "number", "boolean"): + cleaned = [v for v in repaired["enum"] + if v is not None and v != ""] + if cleaned: + repaired["enum"] = cleaned + else: + repaired.pop("enum") + + return repaired def _fill_missing_type(node: Dict[str, Any]) -> Dict[str, Any]: diff --git a/agent/tool_guardrails.py b/agent/tool_guardrails.py new file mode 100644 index 0000000000..3c85d78209 --- /dev/null +++ b/agent/tool_guardrails.py @@ -0,0 +1,455 @@ +"""Pure tool-call loop guardrail primitives. + +The controller in this module is intentionally side-effect free: it tracks +per-turn tool-call observations and returns decisions. Runtime code owns whether +those decisions become warning guidance, synthetic tool results, or controlled +turn halts. +""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass, field +from typing import Any, Mapping + +from utils import safe_json_loads + + +IDEMPOTENT_TOOL_NAMES = frozenset( + { + "read_file", + "search_files", + "web_search", + "web_extract", + "session_search", + "browser_snapshot", + "browser_console", + "browser_get_images", + "mcp_filesystem_read_file", + "mcp_filesystem_read_text_file", + "mcp_filesystem_read_multiple_files", + "mcp_filesystem_list_directory", + "mcp_filesystem_list_directory_with_sizes", + "mcp_filesystem_directory_tree", + "mcp_filesystem_get_file_info", + "mcp_filesystem_search_files", + } +) + +MUTATING_TOOL_NAMES = frozenset( + { + "terminal", + "execute_code", + "write_file", + "patch", + "todo", + "memory", + "skill_manage", + "browser_click", + "browser_type", + "browser_press", + "browser_scroll", + "browser_navigate", + "send_message", + "cronjob", + "delegate_task", + "process", + } +) + + +@dataclass(frozen=True) +class ToolCallGuardrailConfig: + """Thresholds for per-turn tool-call loop detection. + + Warnings are enabled by default and never prevent tool execution. Hard stops + are explicit opt-in so interactive CLI/TUI sessions get a gentle nudge unless + the user enables circuit-breaker behavior in config.yaml. + """ + + warnings_enabled: bool = True + hard_stop_enabled: bool = False + exact_failure_warn_after: int = 2 + exact_failure_block_after: int = 5 + same_tool_failure_warn_after: int = 3 + same_tool_failure_halt_after: int = 8 + no_progress_warn_after: int = 2 + no_progress_block_after: int = 5 + idempotent_tools: frozenset[str] = field(default_factory=lambda: IDEMPOTENT_TOOL_NAMES) + mutating_tools: frozenset[str] = field(default_factory=lambda: MUTATING_TOOL_NAMES) + + @classmethod + def from_mapping(cls, data: Mapping[str, Any] | None) -> "ToolCallGuardrailConfig": + """Build config from the `tool_loop_guardrails` config.yaml section.""" + if not isinstance(data, Mapping): + return cls() + + warn_after = data.get("warn_after") + if not isinstance(warn_after, Mapping): + warn_after = {} + hard_stop_after = data.get("hard_stop_after") + if not isinstance(hard_stop_after, Mapping): + hard_stop_after = {} + + defaults = cls() + return cls( + warnings_enabled=_as_bool(data.get("warnings_enabled"), defaults.warnings_enabled), + hard_stop_enabled=_as_bool(data.get("hard_stop_enabled"), defaults.hard_stop_enabled), + exact_failure_warn_after=_positive_int( + warn_after.get("exact_failure", data.get("exact_failure_warn_after")), + defaults.exact_failure_warn_after, + ), + same_tool_failure_warn_after=_positive_int( + warn_after.get("same_tool_failure", data.get("same_tool_failure_warn_after")), + defaults.same_tool_failure_warn_after, + ), + no_progress_warn_after=_positive_int( + warn_after.get("idempotent_no_progress", data.get("no_progress_warn_after")), + defaults.no_progress_warn_after, + ), + exact_failure_block_after=_positive_int( + hard_stop_after.get("exact_failure", data.get("exact_failure_block_after")), + defaults.exact_failure_block_after, + ), + same_tool_failure_halt_after=_positive_int( + hard_stop_after.get("same_tool_failure", data.get("same_tool_failure_halt_after")), + defaults.same_tool_failure_halt_after, + ), + no_progress_block_after=_positive_int( + hard_stop_after.get("idempotent_no_progress", data.get("no_progress_block_after")), + defaults.no_progress_block_after, + ), + ) + + +@dataclass(frozen=True) +class ToolCallSignature: + """Stable, non-reversible identity for a tool name plus canonical args.""" + + tool_name: str + args_hash: str + + @classmethod + def from_call(cls, tool_name: str, args: Mapping[str, Any] | None) -> "ToolCallSignature": + canonical = canonical_tool_args(args or {}) + return cls(tool_name=tool_name, args_hash=_sha256(canonical)) + + def to_metadata(self) -> dict[str, str]: + """Return public metadata without raw argument values.""" + return {"tool_name": self.tool_name, "args_hash": self.args_hash} + + +@dataclass(frozen=True) +class ToolGuardrailDecision: + """Decision returned by the tool-call guardrail controller.""" + + action: str = "allow" # allow | warn | block | halt + code: str = "allow" + message: str = "" + tool_name: str = "" + count: int = 0 + signature: ToolCallSignature | None = None + + @property + def allows_execution(self) -> bool: + return self.action in {"allow", "warn"} + + @property + def should_halt(self) -> bool: + return self.action in {"block", "halt"} + + def to_metadata(self) -> dict[str, Any]: + data: dict[str, Any] = { + "action": self.action, + "code": self.code, + "message": self.message, + "tool_name": self.tool_name, + "count": self.count, + } + if self.signature is not None: + data["signature"] = self.signature.to_metadata() + return data + + +def canonical_tool_args(args: Mapping[str, Any]) -> str: + """Return sorted compact JSON for parsed tool arguments.""" + if not isinstance(args, Mapping): + raise TypeError(f"tool args must be a mapping, got {type(args).__name__}") + return json.dumps( + args, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + default=str, + ) + + +def classify_tool_failure(tool_name: str, result: str | None) -> tuple[bool, str]: + """Safety-fallback classifier used only when callers don't pass ``failed``. + + Mirrors ``agent.display._detect_tool_failure`` exactly so the guardrail + never disagrees with the CLI's user-visible ``[error]`` tag. Production + callers in ``run_agent.py`` always pass an explicit ``failed=`` derived + from ``_detect_tool_failure``; this function exists so standalone callers + (tests, tooling) still get consistent behavior. + """ + if result is None: + return False, "" + + if tool_name == "terminal": + data = safe_json_loads(result) + if isinstance(data, dict): + exit_code = data.get("exit_code") + if exit_code is not None and exit_code != 0: + return True, f" [exit {exit_code}]" + return False, "" + + if tool_name == "memory": + data = safe_json_loads(result) + if isinstance(data, dict): + if data.get("success") is False and "exceed the limit" in data.get("error", ""): + return True, " [full]" + + lower = result[:500].lower() + if '"error"' in lower or '"failed"' in lower or result.startswith("Error"): + return True, " [error]" + + return False, "" + + +class ToolCallGuardrailController: + """Per-turn controller for repeated failed/non-progressing tool calls.""" + + def __init__(self, config: ToolCallGuardrailConfig | None = None): + self.config = config or ToolCallGuardrailConfig() + self.reset_for_turn() + + def reset_for_turn(self) -> None: + self._exact_failure_counts: dict[ToolCallSignature, int] = {} + self._same_tool_failure_counts: dict[str, int] = {} + self._no_progress: dict[ToolCallSignature, tuple[str, int]] = {} + self._halt_decision: ToolGuardrailDecision | None = None + + @property + def halt_decision(self) -> ToolGuardrailDecision | None: + return self._halt_decision + + def before_call(self, tool_name: str, args: Mapping[str, Any] | None) -> ToolGuardrailDecision: + signature = ToolCallSignature.from_call(tool_name, _coerce_args(args)) + if not self.config.hard_stop_enabled: + return ToolGuardrailDecision(tool_name=tool_name, signature=signature) + + exact_count = self._exact_failure_counts.get(signature, 0) + if exact_count >= self.config.exact_failure_block_after: + decision = ToolGuardrailDecision( + action="block", + code="repeated_exact_failure_block", + message=( + f"Blocked {tool_name}: the same tool call failed {exact_count} " + "times with identical arguments. Stop retrying it unchanged; " + "change strategy or explain the blocker." + ), + tool_name=tool_name, + count=exact_count, + signature=signature, + ) + self._halt_decision = decision + return decision + + if self._is_idempotent(tool_name): + record = self._no_progress.get(signature) + if record is not None: + _result_hash, repeat_count = record + if repeat_count >= self.config.no_progress_block_after: + decision = ToolGuardrailDecision( + action="block", + code="idempotent_no_progress_block", + message=( + f"Blocked {tool_name}: this read-only call returned the same " + f"result {repeat_count} times. Stop repeating it unchanged; " + "use the result already provided or try a different query." + ), + tool_name=tool_name, + count=repeat_count, + signature=signature, + ) + self._halt_decision = decision + return decision + + return ToolGuardrailDecision(tool_name=tool_name, signature=signature) + + def after_call( + self, + tool_name: str, + args: Mapping[str, Any] | None, + result: str | None, + *, + failed: bool | None = None, + ) -> ToolGuardrailDecision: + args = _coerce_args(args) + signature = ToolCallSignature.from_call(tool_name, args) + if failed is None: + failed, _ = classify_tool_failure(tool_name, result) + + if failed: + exact_count = self._exact_failure_counts.get(signature, 0) + 1 + self._exact_failure_counts[signature] = exact_count + self._no_progress.pop(signature, None) + + same_count = self._same_tool_failure_counts.get(tool_name, 0) + 1 + self._same_tool_failure_counts[tool_name] = same_count + + if self.config.hard_stop_enabled and same_count >= self.config.same_tool_failure_halt_after: + decision = ToolGuardrailDecision( + action="halt", + code="same_tool_failure_halt", + message=( + f"Stopped {tool_name}: it failed {same_count} times this turn. " + "Stop retrying the same failing tool path and choose a different approach." + ), + tool_name=tool_name, + count=same_count, + signature=signature, + ) + self._halt_decision = decision + return decision + + if self.config.warnings_enabled and exact_count >= self.config.exact_failure_warn_after: + return ToolGuardrailDecision( + action="warn", + code="repeated_exact_failure_warning", + message=( + f"{tool_name} has failed {exact_count} times with identical arguments. " + "This looks like a loop; inspect the error and change strategy " + "instead of retrying it unchanged." + ), + tool_name=tool_name, + count=exact_count, + signature=signature, + ) + + if self.config.warnings_enabled and same_count >= self.config.same_tool_failure_warn_after: + return ToolGuardrailDecision( + action="warn", + code="same_tool_failure_warning", + message=( + f"{tool_name} has failed {same_count} times this turn. " + "This looks like a loop; change approach before retrying." + ), + tool_name=tool_name, + count=same_count, + signature=signature, + ) + + return ToolGuardrailDecision(tool_name=tool_name, count=exact_count, signature=signature) + + self._exact_failure_counts.pop(signature, None) + self._same_tool_failure_counts.pop(tool_name, None) + + if not self._is_idempotent(tool_name): + self._no_progress.pop(signature, None) + return ToolGuardrailDecision(tool_name=tool_name, signature=signature) + + result_hash = _result_hash(result) + previous = self._no_progress.get(signature) + repeat_count = 1 + if previous is not None and previous[0] == result_hash: + repeat_count = previous[1] + 1 + self._no_progress[signature] = (result_hash, repeat_count) + + if self.config.warnings_enabled and repeat_count >= self.config.no_progress_warn_after: + return ToolGuardrailDecision( + action="warn", + code="idempotent_no_progress_warning", + message=( + f"{tool_name} returned the same result {repeat_count} times. " + "Use the result already provided or change the query instead of " + "repeating it unchanged." + ), + tool_name=tool_name, + count=repeat_count, + signature=signature, + ) + + return ToolGuardrailDecision(tool_name=tool_name, count=repeat_count, signature=signature) + + def _is_idempotent(self, tool_name: str) -> bool: + if tool_name in self.config.mutating_tools: + return False + return tool_name in self.config.idempotent_tools + + +def toolguard_synthetic_result(decision: ToolGuardrailDecision) -> str: + """Build a synthetic role=tool content string for a blocked tool call.""" + return json.dumps( + { + "error": decision.message, + "guardrail": decision.to_metadata(), + }, + ensure_ascii=False, + ) + + +def append_toolguard_guidance(result: str, decision: ToolGuardrailDecision) -> str: + """Append runtime guidance to the current tool result content.""" + if decision.action not in {"warn", "halt"} or not decision.message: + return result + label = "Tool loop hard stop" if decision.action == "halt" else "Tool loop warning" + suffix = ( + f"\n\n[{label}: " + f"{decision.code}; count={decision.count}; {decision.message}]" + ) + return (result or "") + suffix + + +def _coerce_args(args: Mapping[str, Any] | None) -> Mapping[str, Any]: + return args if isinstance(args, Mapping) else {} + + +def _result_hash(result: str | None) -> str: + parsed = safe_json_loads(result or "") + if parsed is not None: + try: + canonical = json.dumps( + parsed, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + default=str, + ) + except TypeError: + canonical = str(parsed) + else: + canonical = result or "" + return _sha256(canonical) + + +def _as_bool(value: Any, default: bool) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in {"1", "true", "yes", "on", "enabled"}: + return True + if lowered in {"0", "false", "no", "off", "disabled"}: + return False + return default + + +def _positive_int(value: Any, default: int) -> int: + if value is None: + return default + try: + parsed = int(value) + except (TypeError, ValueError): + return default + return parsed if parsed >= 1 else default + + +def _sha256(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 8a0f47f61e..e62034908e 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -70,6 +70,30 @@ const APP_ICON_PATHS = [ path.join(unpackedPathFor(APP_ROOT), 'dist', 'apple-touch-icon.png') ] +const MEDIA_MIME_TYPES = { + '.avi': 'video/x-msvideo', + '.bmp': 'image/bmp', + '.flac': 'audio/flac', + '.gif': 'image/gif', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.m4a': 'audio/mp4', + '.mkv': 'video/x-matroska', + '.mov': 'video/quicktime', + '.mp3': 'audio/mpeg', + '.mp4': 'video/mp4', + '.ogg': 'audio/ogg', + '.opus': 'audio/ogg; codecs=opus', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.wav': 'audio/wav', + '.webm': 'video/webm', + '.webp': 'image/webp' +} + +const PREVIEW_HTML_EXTENSIONS = new Set(['.html', '.htm']) +const LOCAL_PREVIEW_HOSTS = new Set(['0.0.0.0', '127.0.0.1', '::1', '[::1]', 'localhost']) + app.setName(APP_NAME) app.setAboutPanelOptions({ applicationName: APP_NAME, @@ -80,6 +104,7 @@ let mainWindow = null let hermesProcess = null let connectionPromise = null const hermesLog = [] +const previewWatchers = new Map() function rememberLog(chunk) { const text = String(chunk || '').trim() @@ -462,13 +487,8 @@ function fetchJson(url, token, options = {}) { function mimeTypeForPath(filePath) { const ext = path.extname(filePath || '').toLowerCase() - if (ext === '.png') return 'image/png' - if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg' - if (ext === '.gif') return 'image/gif' - if (ext === '.webp') return 'image/webp' - if (ext === '.svg') return 'image/svg+xml' - if (ext === '.bmp') return 'image/bmp' - return 'application/octet-stream' + + return MEDIA_MIME_TYPES[ext] || 'application/octet-stream' } function extensionForMimeType(mimeType) { @@ -552,6 +572,162 @@ async function saveImageFromUrl(rawUrl) { return true } +async function writeComposerImage(buffer, ext = '.png') { + const rawExt = String(ext || '.png') + .trim() + .toLowerCase() + const normalizedExt = rawExt.startsWith('.') ? rawExt : `.${rawExt}` + const safeExt = /^\.[a-z0-9]{1,5}$/.test(normalizedExt) ? normalizedExt : '.png' + const dir = path.join(app.getPath('userData'), 'composer-images') + await fs.promises.mkdir(dir, { recursive: true }) + const stamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').replace('Z', '') + const random = crypto.randomBytes(3).toString('hex') + const filePath = path.join(dir, `composer_${stamp}_${random}${safeExt}`) + await fs.promises.writeFile(filePath, buffer) + return filePath +} + +function previewLabelForUrl(url) { + return `${url.host}${url.pathname === '/' ? '' : url.pathname}` +} + +function expandUserPath(filePath) { + const value = String(filePath || '').trim() + + if (value === '~') { + return app.getPath('home') + } + + if (value.startsWith(`~${path.sep}`) || value.startsWith('~/')) { + return path.join(app.getPath('home'), value.slice(2)) + } + + return value +} + +function previewFileTarget(rawTarget, baseDir) { + const raw = String(rawTarget || '').trim() + const base = baseDir ? path.resolve(expandUserPath(baseDir)) : resolveHermesCwd() + const filePath = raw.startsWith('file:') ? fileURLToPath(raw) : path.resolve(base, expandUserPath(raw)) + let resolved = filePath + + if (directoryExists(resolved)) { + resolved = path.join(resolved, 'index.html') + } + + const ext = path.extname(resolved).toLowerCase() + if (!PREVIEW_HTML_EXTENSIONS.has(ext) || !fileExists(resolved)) { + return null + } + + return { + kind: 'file', + label: path.basename(resolved), + source: raw, + url: pathToFileURL(resolved).toString() + } +} + +function previewUrlTarget(rawTarget) { + const raw = String(rawTarget || '').trim() + const url = new URL(raw) + + if (!['http:', 'https:'].includes(url.protocol)) { + return null + } + + if (!LOCAL_PREVIEW_HOSTS.has(url.hostname.toLowerCase())) { + return null + } + + if (url.hostname === '0.0.0.0') { + url.hostname = '127.0.0.1' + } + + return { + kind: 'url', + label: previewLabelForUrl(url), + source: raw, + url: url.toString() + } +} + +function normalizePreviewTarget(rawTarget, baseDir) { + const raw = String(rawTarget || '').trim() + + if (!raw) { + return null + } + + try { + if (/^https?:\/\//i.test(raw)) { + return previewUrlTarget(raw) + } + + return previewFileTarget(raw, baseDir) + } catch { + return null + } +} + +function previewFilePathFromUrl(rawUrl) { + const filePath = fileURLToPath(String(rawUrl || '')) + const ext = path.extname(filePath).toLowerCase() + + if (!PREVIEW_HTML_EXTENSIONS.has(ext) || !fileExists(filePath)) { + throw new Error('Preview file is not a readable HTML file') + } + + return filePath +} + +function sendPreviewFileChanged(payload) { + if (!mainWindow || mainWindow.isDestroyed()) return + const { webContents } = mainWindow + if (!webContents || webContents.isDestroyed()) return + webContents.send('hermes:preview-file-changed', payload) +} + +function watchPreviewFile(rawUrl) { + const filePath = previewFilePathFromUrl(rawUrl) + const id = crypto.randomBytes(12).toString('base64url') + let timer = null + const watcher = fs.watch(filePath, () => { + if (timer) clearTimeout(timer) + timer = setTimeout(() => { + sendPreviewFileChanged({ id, path: filePath, url: pathToFileURL(filePath).toString() }) + }, 120) + }) + + previewWatchers.set(id, { + close: () => { + if (timer) clearTimeout(timer) + watcher.close() + } + }) + + return { id, path: filePath } +} + +function stopPreviewFileWatch(id) { + const watcher = previewWatchers.get(id) + + if (!watcher) { + return false + } + + watcher.close() + previewWatchers.delete(id) + + return true +} + +function closePreviewWatchers() { + for (const id of previewWatchers.keys()) { + stopPreviewFileWatch(id) + } +} + async function waitForHermes(baseUrl, token) { const deadline = Date.now() + 45_000 let lastError = null @@ -843,6 +1019,7 @@ function createWindow() { webPreferences: { preload: path.join(__dirname, 'preload.cjs'), contextIsolation: true, + webviewTag: true, nodeIntegration: false, devTools: Boolean(DEV_SERVER) } @@ -922,6 +1099,31 @@ ipcMain.handle('hermes:writeClipboard', (_event, text) => { ipcMain.handle('hermes:saveImageFromUrl', (_event, url) => saveImageFromUrl(String(url || ''))) +ipcMain.handle('hermes:saveImageBuffer', async (_event, payload) => { + const data = payload?.data + if (!data) throw new Error('saveImageBuffer: missing data') + + const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data) + return writeComposerImage(buffer, payload?.ext || '.png') +}) + +ipcMain.handle('hermes:saveClipboardImage', async () => { + const image = clipboard.readImage() + if (!image || image.isEmpty()) { + return '' + } + + return writeComposerImage(image.toPNG(), '.png') +}) + +ipcMain.handle('hermes:normalizePreviewTarget', (_event, target, baseDir) => + normalizePreviewTarget(String(target || ''), baseDir ? String(baseDir) : '') +) + +ipcMain.handle('hermes:watchPreviewFile', (_event, url) => watchPreviewFile(String(url || ''))) + +ipcMain.handle('hermes:stopPreviewFileWatch', (_event, id) => stopPreviewFileWatch(String(id || ''))) + ipcMain.handle('hermes:openExternal', (_event, url) => shell.openExternal(url)) app.whenReady().then(() => { @@ -936,6 +1138,8 @@ app.whenReady().then(() => { }) app.on('before-quit', () => { + closePreviewWatchers() + if (hermesProcess && !hermesProcess.killed) { hermesProcess.kill('SIGTERM') } diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index 201673a8a4..90e1e31387 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -1,4 +1,4 @@ -const { contextBridge, ipcRenderer } = require('electron') +const { contextBridge, ipcRenderer, webUtils } = require('electron') contextBridge.exposeInMainWorld('hermesDesktop', { getConnection: () => ipcRenderer.invoke('hermes:connection'), @@ -9,7 +9,24 @@ contextBridge.exposeInMainWorld('hermesDesktop', { selectPaths: options => ipcRenderer.invoke('hermes:selectPaths', options), writeClipboard: text => ipcRenderer.invoke('hermes:writeClipboard', text), saveImageFromUrl: url => ipcRenderer.invoke('hermes:saveImageFromUrl', url), + saveImageBuffer: (data, ext) => ipcRenderer.invoke('hermes:saveImageBuffer', { data, ext }), + saveClipboardImage: () => ipcRenderer.invoke('hermes:saveClipboardImage'), + getPathForFile: file => { + try { + return webUtils.getPathForFile(file) || '' + } catch { + return '' + } + }, + normalizePreviewTarget: (target, baseDir) => ipcRenderer.invoke('hermes:normalizePreviewTarget', target, baseDir), + watchPreviewFile: url => ipcRenderer.invoke('hermes:watchPreviewFile', url), + stopPreviewFileWatch: id => ipcRenderer.invoke('hermes:stopPreviewFileWatch', id), openExternal: url => ipcRenderer.invoke('hermes:openExternal', url), + onPreviewFileChanged: callback => { + const listener = (_event, payload) => callback(payload) + ipcRenderer.on('hermes:preview-file-changed', listener) + return () => ipcRenderer.removeListener('hermes:preview-file-changed', listener) + }, onBackendExit: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:backend-exit', listener) diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index a36df98746..c38a4844c7 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -32,6 +32,8 @@ "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.4", "tw-shimmer": "^0.4.11", + "unicode-animations": "^1.0.3", + "use-stick-to-bottom": "^1.1.4", "web-haptics": "^0.0.6" }, "devDependencies": { @@ -382,6 +384,16 @@ "url": "https://opencollective.com/babel" } }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/generator": { "version": "7.29.1", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", @@ -429,14 +441,14 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" + "bin": { + "semver": "bin/semver.js" } }, "node_modules/@babel/helper-create-class-features-plugin": { @@ -461,6 +473,16 @@ "@babel/core": "^7.0.0" } }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/helper-globals": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", @@ -1062,6 +1084,16 @@ "global-agent": "^3.0.0" } }, + "node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@electron/notarize": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", @@ -6939,19 +6971,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@typescript-eslint/utils": { "version": "8.59.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz", @@ -7444,19 +7463,6 @@ "node": ">=18" } }, - "node_modules/app-builder-lib/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/app-builder-lib/node_modules/which": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", @@ -9691,9 +9697,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.348", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.348.tgz", - "integrity": "sha512-QC2X59nRlycQQMc4ZXjSVBX+tSgJfgRtcrYHbIZLgOV2dCvefoQGegLR7lLXKgpPpSuVmJU19LMzGrSa2C7k3Q==", + "version": "1.5.349", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz", + "integrity": "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==", "dev": true, "license": "ISC" }, @@ -10214,6 +10220,16 @@ "node": "*" } }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/eslint-plugin-unused-imports": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.4.1.tgz", @@ -10933,20 +10949,6 @@ "node": ">=10.0" } }, - "node_modules/global-agent/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/globals": { "version": "16.5.0", "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", @@ -12184,6 +12186,16 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsdom/node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -12695,13 +12707,13 @@ } }, "node_modules/lru-cache": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", - "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" } }, "node_modules/lucide-react": { @@ -13862,19 +13874,6 @@ "node": ">=22.12.0" } }, - "node_modules/node-abi/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-addon-api": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", @@ -13893,19 +13892,6 @@ "semver": "^7.3.5" } }, - "node_modules/node-api-version/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", @@ -13925,6 +13911,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/node-gyp": { "version": "12.3.0", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.3.0.tgz", @@ -13960,19 +13956,6 @@ "node": ">=20" } }, - "node_modules/node-gyp/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-gyp/node_modules/undici": { "version": "6.25.0", "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", @@ -14540,14 +14523,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/pretty-format/node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/proc-log": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", @@ -14594,6 +14569,13 @@ "react-is": "^16.13.1" } }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/proper-lockfile": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", @@ -14815,11 +14797,12 @@ } }, "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-remove-scroll": { "version": "2.7.2", @@ -15489,13 +15472,16 @@ "license": "BSD-3-Clause" }, "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/semver-compare": { @@ -15736,19 +15722,6 @@ "node": ">=10" } }, - "node_modules/simple-update-notifier/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/slice-ansi": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", @@ -16603,6 +16576,19 @@ "devOptional": true, "license": "MIT" }, + "node_modules/unicode-animations": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/unicode-animations/-/unicode-animations-1.0.3.tgz", + "integrity": "sha512-+klB2oWwcYZjYWhwP4Pr8UZffWDFVx6jKeIahE6z0QYyM2dwDeDPyn5nevCYbyotxvtT9lh21cVURO1RX0+YMg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "unicode-animations": "^1.0.1" + }, + "bin": { + "unicode-animations": "scripts/demo.cjs" + } + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -16838,6 +16824,15 @@ } } }, + "node_modules/use-stick-to-bottom": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/use-stick-to-bottom/-/use-stick-to-bottom-1.1.4.tgz", + "integrity": "sha512-2w/lydkrwhWMv1vCaEhYbzMDhgbwIodHpAHPV0/xKJErRkbjDEUe1EWmvr6Fwb+qhiERjc1EWgAEZaSaF69CpA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 8fd63e09a1..164c998fb2 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -60,6 +60,8 @@ "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.4", "tw-shimmer": "^0.4.11", + "unicode-animations": "^1.0.3", + "use-stick-to-bottom": "^1.1.4", "web-haptics": "^0.0.6" }, "devDependencies": { diff --git a/apps/desktop/src/app/artifacts/index.tsx b/apps/desktop/src/app/artifacts/index.tsx index 303039f976..e3412993be 100644 --- a/apps/desktop/src/app/artifacts/index.tsx +++ b/apps/desktop/src/app/artifacts/index.tsx @@ -1,24 +1,21 @@ -import { - Copy, - Download, - ExternalLink, - FileImage, - FileText, - FolderOpen, - Layers3, - Link2, - RefreshCw, - Search, - X -} from 'lucide-react' +import { Copy, ExternalLink, FileImage, FileText, FolderOpen, Layers3, Link2, RefreshCw, Search, X } from 'lucide-react' import type { ReactNode } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' import { PageLoader } from '@/components/page-loader' +import { ZoomableImage } from '@/components/assistant-ui/zoomable-image' import { Button } from '@/components/ui/button' -import { Dialog, DialogContent } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' +import { + Pagination, + PaginationButton, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationNext, + PaginationPrevious +} from '@/components/ui/pagination' import { getSessionMessages, listSessions } from '@/hermes' import { sessionTitle } from '@/lib/chat-runtime' import { cn } from '@/lib/utils' @@ -49,9 +46,6 @@ const IMAGE_EXT_RE = /\.(?:png|jpe?g|gif|webp|svg|bmp)(?:\?.*)?$/i const FILE_EXT_RE = /\.(?:png|jpe?g|gif|webp|svg|bmp|pdf|txt|json|md|csv|zip|tar|gz|mp3|wav|mp4|mov)(?:\?.*)?$/i const KEY_HINT_RE = /(path|file|url|image|artifact|output|download|result|target)/i -const imageActionButtonClass = - 'absolute right-2 top-2 grid size-8 place-items-center rounded-full border border-border/70 bg-background/80 text-muted-foreground opacity-0 shadow-sm backdrop-blur transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 disabled:opacity-50' - const ARTIFACT_TIME_FMT = new Intl.DateTimeFormat(undefined, { day: 'numeric', hour: 'numeric', @@ -308,6 +302,43 @@ function formatArtifactTime(timestamp: number): string { return ARTIFACT_TIME_FMT.format(new Date(timestamp)) } +function pageRangeLabel(total: number, page: number, pageSize: number): string { + if (total === 0) { + return '0' + } + + const start = (page - 1) * pageSize + 1 + const end = Math.min(total, page * pageSize) + + return `${start}-${end} of ${total}` +} + +function paginationItems(page: number, pageCount: number): Array { + if (pageCount <= 7) { + return Array.from({ length: pageCount }, (_, index) => index + 1) + } + + const pages: Array = [1] + const start = Math.max(2, page - 1) + const end = Math.min(pageCount - 1, page + 1) + + if (start > 2) { + pages.push('ellipsis') + } + + for (let nextPage = start; nextPage <= end; nextPage += 1) { + pages.push(nextPage) + } + + if (end < pageCount - 1) { + pages.push('ellipsis') + } + + pages.push(pageCount) + + return pages +} + interface ArtifactsViewProps extends React.ComponentProps<'section'> { setTitlebarActions?: (actions: ReactNode | null) => void } @@ -318,15 +349,15 @@ export function ArtifactsView({ setTitlebarActions, ...props }: ArtifactsViewPro const [query, setQuery] = useState('') const [kindFilter, setKindFilter] = useState<'all' | ArtifactKind>('all') const [refreshing, setRefreshing] = useState(false) - const [savingArtifactId, setSavingArtifactId] = useState(null) const [failedImageIds, setFailedImageIds] = useState>(() => new Set()) - const [lightboxArtifact, setLightboxArtifact] = useState(null) + const [imagePage, setImagePage] = useState(1) + const [filePage, setFilePage] = useState(1) const refreshArtifacts = useCallback(async () => { setRefreshing(true) try { - const sessions = (await listSessions(30)).sessions + const sessions = (await listSessions(30, 1)).sessions const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id))) const nextArtifacts: ArtifactRecord[] = [] @@ -372,6 +403,11 @@ export function ArtifactsView({ setTitlebarActions, ...props }: ArtifactsViewPro return () => setTitlebarActions(null) }, [refreshArtifacts, refreshing, setTitlebarActions]) + useEffect(() => { + setImagePage(1) + setFilePage(1) + }, [artifacts, kindFilter, query]) + const visibleArtifacts = useMemo(() => { if (!artifacts) { return [] @@ -396,6 +432,31 @@ export function ArtifactsView({ setTitlebarActions, ...props }: ArtifactsViewPro }) }, [artifacts, kindFilter, query]) + const visibleImageArtifacts = useMemo( + () => visibleArtifacts.filter(artifact => artifact.kind === 'image'), + [visibleArtifacts] + ) + + const visibleFileArtifacts = useMemo( + () => visibleArtifacts.filter(artifact => artifact.kind !== 'image'), + [visibleArtifacts] + ) + + const imagePageCount = Math.max(1, Math.ceil(visibleImageArtifacts.length / 24)) + const filePageCount = Math.max(1, Math.ceil(visibleFileArtifacts.length / 100)) + const currentImagePage = Math.min(imagePage, imagePageCount) + const currentFilePage = Math.min(filePage, filePageCount) + + const pagedImageArtifacts = useMemo( + () => visibleImageArtifacts.slice((currentImagePage - 1) * 24, currentImagePage * 24), + [currentImagePage, visibleImageArtifacts] + ) + + const pagedFileArtifacts = useMemo( + () => visibleFileArtifacts.slice((currentFilePage - 1) * 100, currentFilePage * 100), + [currentFilePage, visibleFileArtifacts] + ) + const counts = useMemo(() => { const all = artifacts || [] @@ -437,34 +498,6 @@ export function ArtifactsView({ setTitlebarActions, ...props }: ArtifactsViewPro } }, []) - const saveImageArtifact = useCallback(async (artifact: ArtifactRecord) => { - if (artifact.kind !== 'image') { - return - } - - setSavingArtifactId(artifact.id) - - try { - if (!window.hermesDesktop?.saveImageFromUrl) { - throw new Error('Image saving is unavailable in this build.') - } - - const saved = await window.hermesDesktop.saveImageFromUrl(artifact.href) - - if (saved) { - notify({ - kind: 'success', - title: 'Image saved', - message: artifact.label - }) - } - } catch (err) { - notifyError(err, 'Save failed') - } finally { - setSavingArtifactId(null) - } - }, []) - const markImageFailed = useCallback((id: string) => { setFailedImageIds(current => { if (current.has(id)) { @@ -475,136 +508,208 @@ export function ArtifactsView({ setTitlebarActions, ...props }: ArtifactsViewPro }) }, []) - const imageLightbox = lightboxArtifact ? ( - !open && setLightboxArtifact(null)} open> - -
- {lightboxArtifact.label} setLightboxArtifact(null)} - src={lightboxArtifact.href} - /> - -
-
-
- ) : null - return ( - <> -
-
-

Artifacts

- {counts.all} found -
+
+
+

Artifacts

+ {counts.all} found +
-
-
-
- setKindFilter('all')} - /> - setKindFilter('image')} - /> - setKindFilter('file')} - /> - setKindFilter('link')} - /> -
-
- - setQuery(event.target.value)} - placeholder="Search artifacts..." - value={query} - /> - {query && ( - - )} -
+
+
+
+ setKindFilter('all')} + /> + setKindFilter('image')} + /> + setKindFilter('file')} + /> + setKindFilter('link')} + /> +
+
+ + setQuery(event.target.value)} + placeholder="Search artifacts..." + value={query} + /> + {query && ( + + )}
- - {!artifacts ? ( - - ) : visibleArtifacts.length === 0 ? ( -
-
-
No artifacts found
-
- Generated images and file outputs will appear here as sessions produce them. -
-
-
- ) : ( -
-
- {visibleArtifacts.map(artifact => ( - navigate(sessionRoute(sessionId))} - onSaveImage={saveImageArtifact} - onZoom={setLightboxArtifact} - saving={savingArtifactId === artifact.id} - /> - ))} -
-
- )}
-
- {imageLightbox} - + + {!artifacts ? ( + + ) : visibleArtifacts.length === 0 ? ( +
+
+
No artifacts found
+
+ Generated images and file outputs will appear here as sessions produce them. +
+
+
+ ) : ( +
+
+ {visibleImageArtifacts.length > 0 && ( +
+
+

+ Images +

+ +
+
+ {pagedImageArtifacts.map(artifact => ( + navigate(sessionRoute(sessionId))} + /> + ))} +
+
+ )} + + {visibleFileArtifacts.length > 0 && ( +
+
+

+ {kindFilter === 'link' ? 'Links' : kindFilter === 'file' ? 'Files' : 'Files and links'} +

+ +
+
+ + + + + + + + + + + {pagedFileArtifacts.map(artifact => ( + navigate(sessionRoute(sessionId))} + /> + ))} + +
NameLocationSessionActions
+
+
+ )} +
+
+ )} + +
+ ) +} + +interface ArtifactsPaginationProps { + className?: string + itemLabel: string + onPageChange: (page: number) => void + page: number + pageSize: number + total: number +} + +function ArtifactsPagination({ className, itemLabel, onPageChange, page, pageSize, total }: ArtifactsPaginationProps) { + const pageCount = Math.max(1, Math.ceil(total / pageSize)) + + return ( +
+
+ {pageRangeLabel(total, page, pageSize)} {itemLabel} +
+ {pageCount > 1 && ( + + + + onPageChange(Math.max(1, page - 1))} /> + + {paginationItems(page, pageCount).map((item, index) => ( + + {item === 'ellipsis' ? ( + + ) : ( + onPageChange(item)} + > + {item} + + )} + + ))} + + = pageCount} + onClick={() => onPageChange(Math.min(pageCount, page + 1))} + /> + + + + )} +
) } @@ -636,52 +741,106 @@ function FilterButton({ ) } -interface ArtifactCardProps { +interface ArtifactImageCardProps { artifact: ArtifactRecord failedImage: boolean - onCopy: (value: string) => void | Promise onImageError: (id: string) => void - onOpen: (href: string) => void | Promise onOpenChat: (sessionId: string) => void - onSaveImage: (artifact: ArtifactRecord) => void | Promise - onZoom: (artifact: ArtifactRecord) => void - saving: boolean } -function ArtifactCard({ - artifact, - failedImage, - onCopy, - onImageError, - onOpen, - onOpenChat, - onSaveImage, - onZoom, - saving -}: ArtifactCardProps) { - const image = artifact.kind === 'image' - - if (!image) { - const Icon = artifact.kind === 'file' ? FileText : Link2 - - return ( -
-
- -
+function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }: ArtifactImageCardProps) { + return ( +
+
+ {!failedImage && ( + onImageError(artifact.id)} + slot="artifact-media" + src={artifact.href} + /> + )} +
+
-
+
+ {artifact.kind}
-
{artifact.label}
-
{artifact.value}
-
- {artifact.sessionTitle} · {formatArtifactTime(artifact.timestamp)} -
+
{artifact.label}
+
{artifact.value}
-
+
+ {artifact.sessionTitle} · {formatArtifactTime(artifact.timestamp)} +
+ +
+ +
+
+
+ ) +} + +interface ArtifactListRowProps { + artifact: ArtifactRecord + onCopy: (value: string) => void | Promise + onOpen: (href: string) => void | Promise + onOpenChat: (sessionId: string) => void +} + +function ArtifactListRow({ artifact, onCopy, onOpen, onOpenChat }: ArtifactListRowProps) { + const Icon = artifact.kind === 'file' ? FileText : Link2 + + return ( + + +
+
+ +
+
+
+ {artifact.label} +
+
{artifact.kind}
+
+
+ + +
+ {artifact.value} +
+ + +
+
+ {artifact.sessionTitle} +
+
{formatArtifactTime(artifact.timestamp)}
+
+ + +
-
- ) - } - - return ( -
- {image && ( - - )} - -
-
-
- {image ? ( - - ) : artifact.kind === 'file' ? ( - - ) : ( - - )} - {artifact.kind} -
-
{artifact.label}
-
{artifact.value}
-
- -
- {artifact.sessionTitle} · {formatArtifactTime(artifact.timestamp)} -
- -
- -
-
-
+ + ) } diff --git a/apps/desktop/src/app/chat/composer/attachments.tsx b/apps/desktop/src/app/chat/composer/attachments.tsx index 101268a9f7..062692776f 100644 --- a/apps/desktop/src/app/chat/composer/attachments.tsx +++ b/apps/desktop/src/app/chat/composer/attachments.tsx @@ -10,7 +10,7 @@ export function AttachmentList({ onRemove?: (id: string) => void }) { return ( -
+
{attachments.map(a => ( ))} @@ -22,28 +22,30 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText }[attachment.kind] return ( -
- {attachment.previewUrl ? ( - +
+ {attachment.previewUrl && attachment.kind === 'image' ? ( + {attachment.label} ) : ( - - + + )} - - {attachment.label} - {attachment.detail && ( - {attachment.detail} - )} - {onRemove && ( )}
diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx index f6d0cbf423..4da1375c44 100644 --- a/apps/desktop/src/app/chat/composer/controls.tsx +++ b/apps/desktop/src/app/chat/composer/controls.tsx @@ -7,7 +7,7 @@ import { cn } from '@/lib/utils' import type { ConversationStatus } from './hooks/use-voice-conversation' import type { ChatBarState, VoiceStatus } from './types' -export const ICON_BTN = 'h-8 w-8 shrink-0 rounded-full' +export const ICON_BTN = 'size-(--composer-control-size) shrink-0 rounded-full' export const GHOST_ICON_BTN = cn(ICON_BTN, 'text-muted-foreground hover:bg-accent hover:text-foreground') interface ConversationProps { @@ -47,7 +47,7 @@ export function ComposerControls({ const showVoicePrimary = !busy && !hasComposerPayload return ( -
+
{showVoicePrimary ? ( + )) + )} +
+
+ ) +} diff --git a/apps/desktop/src/app/chat/composer/slash-popover.tsx b/apps/desktop/src/app/chat/composer/slash-popover.tsx index 2ae7a3a61c..0ebbc2abc4 100644 --- a/apps/desktop/src/app/chat/composer/slash-popover.tsx +++ b/apps/desktop/src/app/chat/composer/slash-popover.tsx @@ -28,7 +28,7 @@ export function SlashPopover({ adapter, loading }: { adapter: Unstable_TriggerAd
{items.length === 0 ? ( - Try /help for the full list. + Try /help for the desktop command list. ) : ( items.map((item, index) => { diff --git a/apps/desktop/src/app/chat/composer/types.ts b/apps/desktop/src/app/chat/composer/types.ts index bd0637df15..71c601e396 100644 --- a/apps/desktop/src/app/chat/composer/types.ts +++ b/apps/desktop/src/app/chat/composer/types.ts @@ -1,5 +1,7 @@ import type { HermesGateway } from '@/hermes' +import type { DroppedFile } from '../hooks/use-composer-actions' + export interface ContextSuggestion { text: string display: string @@ -36,6 +38,8 @@ export interface ChatBarProps { onCancel: () => void onAddContextRef?: (refText: string, label?: string, detail?: string) => void onAddUrl?: (url: string) => void + onAttachImageBlob?: (blob: Blob) => Promise | boolean | void + onAttachDroppedItems?: (candidates: DroppedFile[]) => Promise | boolean | void onPasteClipboardImage?: () => void onPickFiles?: () => void onPickFolders?: () => void diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts index b928a756ef..e3240e0b98 100644 --- a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts +++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts @@ -5,7 +5,88 @@ import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime' import { addComposerAttachment, type ComposerAttachment, removeComposerAttachment } from '@/store/composer' import { notify, notifyError } from '@/store/notifications' -import type { ImageAttachResponse, ImageDetachResponse } from '../../types' +import type { ImageDetachResponse } from '../../types' + +const IMAGE_EXTENSION_PATTERN = /\.(png|jpe?g|gif|webp|bmp|tiff?|svg|ico)$/i + +const BLOB_MIME_EXTENSION: Record = { + 'image/bmp': '.bmp', + 'image/gif': '.gif', + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/svg+xml': '.svg', + 'image/tiff': '.tiff', + 'image/webp': '.webp', + 'image/x-icon': '.ico' +} + +function blobExtension(blob: Blob): string { + const mime = blob.type.split(';')[0]?.trim().toLowerCase() + + return (mime && BLOB_MIME_EXTENSION[mime]) || '.png' +} + +function isImagePath(filePath: string): boolean { + return IMAGE_EXTENSION_PATTERN.test(filePath) +} + +export interface DroppedFile { + file: File + path: string +} + +/** + * Eagerly resolve files from a drop event into [File, path] pairs. + * + * Must be called synchronously from inside the drop handler — `DataTransfer` + * items are detached as soon as the handler returns, and `webUtils.getPathForFile` + * also requires the original (non-cloned) File reference. + */ +export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] { + const result: DroppedFile[] = [] + const seen = new Set() + const getPath = window.hermesDesktop?.getPathForFile + + const fileList = transfer.files + if (fileList) { + for (let i = 0; i < fileList.length; i += 1) { + const file = fileList.item(i) + if (!file || seen.has(file)) continue + seen.add(file) + let path = '' + if (getPath) { + try { + path = getPath(file) || '' + } catch { + path = '' + } + } + result.push({ file, path }) + } + } + + const items = transfer.items + if (items) { + for (let i = 0; i < items.length; i += 1) { + const item = items[i] + if (!item || item.kind !== 'file') continue + const file = item.getAsFile() + if (!file || seen.has(file)) continue + seen.add(file) + let path = '' + if (getPath) { + try { + path = getPath(file) || '' + } catch { + path = '' + } + } + result.push({ file, path }) + } + } + + return result +} interface ComposerActionsOptions { activeSessionId: string | null @@ -13,7 +94,11 @@ interface ComposerActionsOptions { requestGateway: (method: string, params?: Record) => Promise } -export function useComposerActions({ activeSessionId, currentCwd, requestGateway }: ComposerActionsOptions) { +export function useComposerActions({ + activeSessionId, + currentCwd, + requestGateway +}: ComposerActionsOptions) { const addContextRefAttachment = useCallback((refText: string, label?: string, detail?: string) => { let kind: ComposerAttachment['kind'] = 'file' @@ -62,11 +147,93 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway [currentCwd] ) - const pickImages = useCallback(async () => { - if (!activeSessionId) { - return - } + const attachContextFilePath = useCallback( + (filePath: string) => { + if (!filePath) { + return false + } + const rel = contextPath(filePath, currentCwd) + + addComposerAttachment({ + id: attachmentId('file', rel), + kind: 'file', + label: pathLabel(filePath), + detail: rel, + refText: `@file:${formatRefValue(rel)}`, + path: filePath + }) + + return true + }, + [currentCwd] + ) + + const attachImagePath = useCallback( + async (filePath: string) => { + if (!filePath) { + return false + } + + const baseAttachment: ComposerAttachment = { + id: attachmentId('image', filePath), + kind: 'image', + label: pathLabel(filePath), + detail: filePath, + path: filePath + } + + addComposerAttachment(baseAttachment) + + try { + const previewUrl = await window.hermesDesktop?.readFileDataUrl(filePath) + + if (previewUrl) { + addComposerAttachment({ ...baseAttachment, previewUrl }) + } + + return true + } catch (err) { + notifyError(err, 'Image preview failed') + + return true + } + }, + [] + ) + + const attachImageBlob = useCallback( + async (blob: Blob) => { + if (blob.size === 0) { + return false + } + + if (blob.type && !blob.type.startsWith('image/')) { + return false + } + + try { + const buffer = await blob.arrayBuffer() + const data = new Uint8Array(buffer) + const savedPath = await window.hermesDesktop?.saveImageBuffer(data, blobExtension(blob)) + + if (!savedPath) { + notify({ kind: 'error', title: 'Image attach', message: 'Failed to write image to disk.' }) + + return false + } + + return attachImagePath(savedPath) + } catch (err) { + notifyError(err, 'Image attach failed') + + return false + } + }, + [attachImagePath] + ) + + const pickImages = useCallback(async () => { const paths = await window.hermesDesktop?.selectPaths({ title: 'Attach images', defaultPath: currentCwd || undefined, @@ -83,73 +250,82 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway } for (const path of paths) { - try { - const result = await requestGateway('image.attach', { - session_id: activeSessionId, - path - }) - - const attachedPath = result.path || path - - if (result.attached) { - const previewUrl = await window.hermesDesktop?.readFileDataUrl(attachedPath) - - addComposerAttachment({ - id: attachmentId('image', attachedPath), - kind: 'image', - label: pathLabel(attachedPath), - detail: attachedPath, - previewUrl, - path: attachedPath - }) - } - } catch (err) { - notifyError(err, 'Image attach failed') - } + await attachImagePath(path) } - }, [activeSessionId, currentCwd, requestGateway]) + }, [attachImagePath, currentCwd]) const pasteClipboardImage = useCallback(async () => { - if (!activeSessionId) { - return - } - try { - const result = await requestGateway('clipboard.paste', { - session_id: activeSessionId - }) + const path = await window.hermesDesktop?.saveClipboardImage() - if (!result.attached) { + if (!path) { notify({ kind: 'warning', title: 'Clipboard', - message: result.message || 'No image found in clipboard' + message: 'No image found in clipboard' }) return } - const attachedPath = result.path || 'clipboard' - const previewUrl = result.path && (await window.hermesDesktop?.readFileDataUrl(result.path)) - - addComposerAttachment({ - id: attachmentId('image', attachedPath), - kind: 'image', - label: pathLabel(attachedPath), - detail: attachedPath, - previewUrl: previewUrl || undefined, - path: result.path - }) + await attachImagePath(path) } catch (err) { notifyError(err, 'Clipboard paste failed') } - }, [activeSessionId, requestGateway]) + }, [attachImagePath]) + + const attachDroppedItems = useCallback( + async (candidates: DroppedFile[]) => { + if (candidates.length === 0) { + return false + } + + let attached = false + let lastFailure: string | null = null + + for (const { file, path: knownPath } of candidates) { + const fallbackPath = !knownPath && window.hermesDesktop?.getPathForFile ? window.hermesDesktop.getPathForFile(file) : '' + const filePath = knownPath || fallbackPath || '' + const isImage = file.type.startsWith('image/') || isImagePath(file.name) || (filePath && isImagePath(filePath)) + + if (isImage) { + if ((filePath && (await attachImagePath(filePath))) || (await attachImageBlob(file))) { + attached = true + continue + } + + lastFailure = `Could not attach ${file.name || 'image'}` + continue + } + + if (filePath && attachContextFilePath(filePath)) { + attached = true + continue + } + + lastFailure = `Could not attach ${file.name || 'file'}` + } + + if (!attached && lastFailure) { + notify({ kind: 'warning', title: 'Drop files', message: lastFailure }) + } + + return attached + }, + [attachContextFilePath, attachImageBlob, attachImagePath] + ) const removeAttachment = useCallback( async (id: string) => { const removed = removeComposerAttachment(id) - if (removed?.kind === 'image' && removed.path && activeSessionId) { + if ( + removed?.kind === 'image' && + removed.path && + activeSessionId && + removed.attachedSessionId && + removed.attachedSessionId === activeSessionId + ) { await requestGateway('image.detach', { session_id: activeSessionId, path: removed.path @@ -161,6 +337,9 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway return { addContextRefAttachment, + attachDroppedItems, + attachImageBlob, + attachImagePath, pasteClipboardImage, pickContextPaths, pickImages, diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 4655936c9e..1604c54107 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -1,4 +1,5 @@ import { + type AppendMessage, AssistantRuntimeProvider, ExportedMessageRepository, type ThreadMessage, @@ -42,6 +43,7 @@ import { titlebarHeaderBaseClass, titlebarHeaderShadowClass } from '../shell/tit import { ChatBar, ChatBarFallback } from './composer' import type { ChatBarState } from './composer/types' +import type { DroppedFile } from './hooks/use-composer-actions' import { ChatRightRail } from './right-rail' import { SessionActionsMenu } from './sidebar/session-actions-menu' @@ -54,6 +56,8 @@ interface ChatViewProps extends Omit, 'onSubmit'> { onAddUrl: (url: string) => void onBranchInNewChat: (messageId: string) => void maxVoiceRecordingSeconds?: number + onAttachImageBlob: (blob: Blob) => Promise | boolean | void + onAttachDroppedItems: (candidates: DroppedFile[]) => Promise | boolean | void onPasteClipboardImage: () => void onPickFiles: () => void onPickFolders: () => void @@ -65,20 +69,33 @@ interface ChatViewProps extends Omit, 'onSubmit'> { onOpenModelPicker: () => void onSelectPersonality: (name: string) => void onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void + onEdit: (message: AppendMessage) => Promise onReload: (parentId: string | null) => Promise onTranscribeAudio?: (audio: Blob) => Promise } -function threadLoadingState(loadingSession: boolean, busy: boolean, awaitingResponse: boolean) { +function threadLoadingState( + loadingSession: boolean, + busy: boolean, + awaitingResponse: boolean, + lastMessageIsUser: boolean +) { if (loadingSession) { return 'session' } - if (!busy) { - return undefined + // Only show the response spinner when we're actually waiting for an + // assistant reply to a user message. Previously any `busy && awaiting` + // window showed the spinner — including the brief gateway-hydration blip + // right after a session resume, which produced a visible flicker chain: + // session spinner → response spinner → content. + // Gating on `lastMessageIsUser` means the spinner only appears when the + // user actually just sent something and there's no assistant reply yet. + if (busy && awaitingResponse && lastMessageIsUser) { + return 'response' } - return awaitingResponse ? 'response' : 'working' + return undefined } export function ChatView({ @@ -88,6 +105,8 @@ export function ChatView({ onCancel, onAddContextRef, onAddUrl, + onAttachImageBlob, + onAttachDroppedItems, onBranchInNewChat, maxVoiceRecordingSeconds, onPasteClipboardImage, @@ -101,6 +120,7 @@ export function ChatView({ onOpenModelPicker, onSelectPersonality, onThreadMessagesChange, + onEdit, onReload, onTranscribeAudio }: ChatViewProps) { @@ -129,8 +149,14 @@ export function ChatView({ const showIntro = freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messages.length === 0 - const loadingSession = isRoutedSessionView && messages.length === 0 - const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse) + // Session is still loading if the route references a session we haven't + // resumed yet. Once `activeSessionId` is set (runtime has resumed), the + // session exists — even if it has zero messages (a brand-new routed + // session). The flicker where `busy` flips true briefly during hydrate + // is handled by `threadLoadingState`'s `lastMessageIsUser` gate. + const loadingSession = isRoutedSessionView && messages.length === 0 && !activeSessionId + const lastMessageIsUser = messages.at(-1)?.role === 'user' + const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastMessageIsUser) const showChatBar = !loadingSession const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new') const title = activeStoredSession ? sessionTitle(activeStoredSession) : '' @@ -221,6 +247,7 @@ export function ChatView({ // Submission is handled explicitly by ChatBar. // Keeping this no-op avoids duplicate prompt.submit calls. }, + onEdit, onCancel: async () => onCancel(), onReload }) @@ -236,6 +263,7 @@ export function ChatView({ onDelete={selectedSessionId ? onDeleteSelectedSession : undefined} onPin={selectedSessionId ? onToggleSelectedPin : undefined} pinned={selectedIsPinned} + sessionId={selectedSessionId || activeSessionId || ''} sideOffset={8} title={title} > @@ -273,6 +301,8 @@ export function ChatView({ maxRecordingSeconds={maxVoiceRecordingSeconds} onAddContextRef={onAddContextRef} onAddUrl={onAddUrl} + onAttachDroppedItems={onAttachDroppedItems} + onAttachImageBlob={onAttachImageBlob} onCancel={onCancel} onPasteClipboardImage={onPasteClipboardImage} onPickFiles={onPickFiles} @@ -300,4 +330,4 @@ export function ChatView({ ) } -export { SESSION_INSPECTOR_WIDTH } from './right-rail' +export { PREVIEW_RAIL_WIDTH, SESSION_INSPECTOR_WIDTH } from './right-rail' diff --git a/apps/desktop/src/app/chat/right-rail/index.tsx b/apps/desktop/src/app/chat/right-rail/index.tsx index dea59799a0..af88860623 100644 --- a/apps/desktop/src/app/chat/right-rail/index.tsx +++ b/apps/desktop/src/app/chat/right-rail/index.tsx @@ -3,6 +3,7 @@ import type * as React from 'react' import { SESSION_INSPECTOR_WIDTH, SessionInspector } from '@/components/session-inspector' import { $inspectorOpen } from '@/store/layout' +import { $previewTarget } from '@/store/preview' import { $availablePersonalities, $busy, @@ -14,6 +15,8 @@ import { $gatewayState } from '@/store/session' +import { PreviewPane } from './preview-pane' + interface ChatRightRailProps extends Pick< React.ComponentProps, 'onBrowseCwd' | 'onChangeCwd' @@ -29,6 +32,7 @@ export function ChatRightRail({ onSelectPersonality }: ChatRightRailProps) { const inspectorOpen = useStore($inspectorOpen) + const previewTarget = useStore($previewTarget) const gatewayOpen = useStore($gatewayState) === 'open' const busy = useStore($busy) const cwd = useStore($currentCwd) @@ -38,6 +42,10 @@ export function ChatRightRail({ const personality = useStore($currentPersonality) const personalities = useStore($availablePersonalities) + if (previewTarget) { + return + } + return ( void + isDevToolsOpened?: () => boolean + openDevTools?: () => void + reload?: () => void + reloadIgnoringCache?: () => void +} + +interface ConsoleEntry { + id: number + level: number + line?: number + message: string + source?: string +} + +const consoleLevelLabel: Record = { + 0: 'log', + 1: 'info', + 2: 'warn', + 3: 'error' +} + +const consoleLevelClass: Record = { + 0: 'text-foreground', + 1: 'text-sky-700 dark:text-sky-300', + 2: 'text-amber-700 dark:text-amber-300', + 3: 'text-destructive' +} + +function compactUrl(value: string): string { + try { + const url = new URL(value) + + if (url.protocol === 'file:') { + return decodeURIComponent(url.pathname) + } + + return `${url.host}${url.pathname}${url.search}` + } catch { + return value + } +} + +function formatLogLine(log: ConsoleEntry): string { + const head = `[${consoleLevelLabel[log.level] || 'log'}]` + const tail = log.source ? ` (${compactUrl(log.source)}${log.line ? `:${log.line}` : ''})` : '' + + return `${head} ${log.message}${tail}`.trim() +} + +interface ConsoleRowProps { + log: ConsoleEntry + onCopy: () => void | Promise + onSend: () => void + onToggleSelect: () => void + selected: boolean +} + +function ConsoleRow({ log, onCopy, onSend, onToggleSelect, selected }: ConsoleRowProps) { + return ( +
+ +
+ + {log.message} + + {log.source && ( + + {compactUrl(log.source)} + {log.line ? `:${log.line}` : ''} + + )} +
+ + + + +
+ ) +} + +async function writeClipboardText(text: string) { + if (!text) { + return + } + + if (window.hermesDesktop?.writeClipboard) { + await window.hermesDesktop.writeClipboard(text) + + return + } + + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text) + } +} + +export function PreviewPane({ target }: { target: PreviewTarget }) { + const consoleBodyRef = useRef(null) + const hostRef = useRef(null) + const logIdRef = useRef(0) + const webviewRef = useRef(null) + const [consoleOpen, setConsoleOpen] = useState(true) + const [currentUrl, setCurrentUrl] = useState(target.url) + const [devtoolsOpen, setDevtoolsOpen] = useState(false) + const [logs, setLogs] = useState([]) + const [selectedLogIds, setSelectedLogIds] = useState>(() => new Set()) + const [copiedAll, setCopiedAll] = useState(false) + const [loading, setLoading] = useState(true) + const visibleSelection = useMemo(() => logs.filter(log => selectedLogIds.has(log.id)), [logs, selectedLogIds]) + const sendableLogs = visibleSelection.length > 0 ? visibleSelection : logs + + function toggleLogSelection(id: number) { + setSelectedLogIds(prev => { + const next = new Set(prev) + + if (!next.delete(id)) { + next.add(id) + } + + return next + }) + } + + async function copyConsoleText(entries: ConsoleEntry[], successMessage: string) { + if (!entries.length) { + return + } + + try { + await writeClipboardText(entries.map(formatLogLine).join('\n')) + notify({ kind: 'success', title: 'Console copied', message: successMessage }) + } catch (error) { + notifyError(error, 'Could not copy console output') + } + } + + function sendLogsToComposer(entries: ConsoleEntry[]) { + if (!entries.length) { + return + } + + const block = ['Preview console:', '```', ...entries.map(formatLogLine), '```'].join('\n') + const draft = $composerDraft.get() + const next = draft && !draft.endsWith('\n') ? `${draft}\n\n${block}` : `${draft}${block}` + + setComposerDraft(next) + setSelectedLogIds(new Set()) + notify({ + kind: 'success', + title: 'Sent to chat', + message: `${entries.length} log entr${entries.length === 1 ? 'y' : 'ies'} added to composer` + }) + } + + function toggleDevTools() { + const webview = webviewRef.current + + if (!webview?.openDevTools) { + return + } + + if (webview.isDevToolsOpened?.()) { + webview.closeDevTools?.() + setDevtoolsOpen(false) + + return + } + + webview.openDevTools() + setDevtoolsOpen(true) + } + + useEffect(() => { + if (consoleOpen) { + consoleBodyRef.current?.scrollTo({ top: consoleBodyRef.current.scrollHeight }) + } + }, [consoleOpen, logs]) + + useEffect(() => { + if (target.kind !== 'file' || !window.hermesDesktop?.watchPreviewFile || !window.hermesDesktop?.onPreviewFileChanged) { + return + } + + let active = true + let watchId = '' + + const unsubscribe = window.hermesDesktop.onPreviewFileChanged(payload => { + if (!active || payload.id !== watchId) { + return + } + + setLogs(prev => [ + ...prev.slice(-199), + { + id: ++logIdRef.current, + level: 1, + message: `File changed, reloading preview: ${compactUrl(payload.url)}` + } + ]) + + if (webviewRef.current?.reloadIgnoringCache) { + webviewRef.current.reloadIgnoringCache() + } else { + webviewRef.current?.reload?.() + } + }) + + void window.hermesDesktop + .watchPreviewFile(target.url) + .then(watch => { + if (!active) { + void window.hermesDesktop?.stopPreviewFileWatch?.(watch.id) + + return + } + + watchId = watch.id + }) + .catch(error => { + setLogs(prev => [ + ...prev.slice(-199), + { + id: ++logIdRef.current, + level: 2, + message: `Could not watch preview file: ${error instanceof Error ? error.message : String(error)}` + } + ]) + }) + + return () => { + active = false + unsubscribe() + + if (watchId) { + void window.hermesDesktop?.stopPreviewFileWatch?.(watchId) + } + } + }, [target.kind, target.url]) + + useEffect(() => { + const host = hostRef.current + + if (!host) { + return + } + + host.replaceChildren() + webviewRef.current = null + setCurrentUrl(target.url) + setDevtoolsOpen(false) + setLogs([]) + setLoading(true) + + const webview = document.createElement('webview') as PreviewWebview + webview.className = 'hermes-preview-webview h-full w-full flex-1 bg-background' + webview.setAttribute('partition', 'persist:hermes-preview') + webview.setAttribute('src', target.url) + webview.setAttribute('webpreferences', 'contextIsolation=yes,nodeIntegration=no,sandbox=yes') + + const appendLog = (entry: Omit) => { + setLogs(prev => [...prev.slice(-199), { ...entry, id: ++logIdRef.current }]) + } + + const onConsole = (event: Event) => { + const detail = event as Event & { + level?: number + line?: number + message?: string + sourceId?: string + } + + appendLog({ + level: detail.level ?? 0, + line: detail.line, + message: detail.message || '', + source: detail.sourceId + }) + } + + const onNavigate = (event: Event) => { + const detail = event as Event & { url?: string } + + if (detail.url) { + setCurrentUrl(detail.url) + } + } + + const onFail = (event: Event) => { + const detail = event as Event & { + errorCode?: number + errorDescription?: string + validatedURL?: string + } + + appendLog({ + level: 3, + message: `Load failed${detail.errorCode ? ` (${detail.errorCode})` : ''}: ${ + detail.errorDescription || detail.validatedURL || 'unknown error' + }` + }) + setLoading(false) + } + + const onStart = () => setLoading(true) + const onStop = () => setLoading(false) + + webview.addEventListener('console-message', onConsole) + webview.addEventListener('did-fail-load', onFail) + webview.addEventListener('did-navigate', onNavigate) + webview.addEventListener('did-navigate-in-page', onNavigate) + webview.addEventListener('did-start-loading', onStart) + webview.addEventListener('did-stop-loading', onStop) + host.appendChild(webview) + webviewRef.current = webview + + return () => { + webview.removeEventListener('console-message', onConsole) + webview.removeEventListener('did-fail-load', onFail) + webview.removeEventListener('did-navigate', onNavigate) + webview.removeEventListener('did-navigate-in-page', onNavigate) + webview.removeEventListener('did-start-loading', onStart) + webview.removeEventListener('did-stop-loading', onStop) + webview.remove() + } + }, [target.url]) + + return ( + + ) +} diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 682e906347..34fe62ae95 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -18,7 +18,6 @@ import { Skeleton } from '@/components/ui/skeleton' import type { SessionInfo } from '@/hermes' import { cn } from '@/lib/utils' import { - $isSidebarResizing, $pinnedSessionIds, $sidebarOpen, $sidebarPinsOpen, @@ -66,10 +65,10 @@ export function ChatSidebar({ }: ChatSidebarProps) { const sidebarOpen = useStore($sidebarOpen) const pinnedSessionIds = useStore($pinnedSessionIds) - const isSidebarResizing = useStore($isSidebarResizing) const pinsOpen = useStore($sidebarPinsOpen) const recentsOpen = useStore($sidebarRecentsOpen) const selectedSessionId = useStore($selectedStoredSessionId) + const activeSidebarSessionId = currentView === 'chat' ? selectedSessionId : null const sessions = useStore($sessions) const sessionsLoading = useStore($sessionsLoading) const workingSessionIds = useStore($workingSessionIds) @@ -101,13 +100,10 @@ export function ChatSidebar({ return ( @@ -159,7 +155,7 @@ export function ChatSidebar({ {pinnedSessions.map(session => ( onDeleteSession(session.id)} @@ -207,7 +203,7 @@ export function ChatSidebar({ {recentSessions.map(session => ( onDeleteSession(session.id)} diff --git a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx index 720b070ae9..560236c6c1 100644 --- a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx @@ -1,4 +1,4 @@ -import { Archive, Pencil, Pin, Trash2 } from 'lucide-react' +import { Archive, Copy, Pencil, Pin, Trash2 } from 'lucide-react' import type * as React from 'react' import type { ReactNode } from 'react' @@ -11,6 +11,7 @@ import { } from '@/components/ui/dropdown-menu' import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' +import { notify, notifyError } from '@/store/notifications' interface SessionActionsMenuProps extends Pick< React.ComponentProps, @@ -18,6 +19,7 @@ interface SessionActionsMenuProps extends Pick< > { children: ReactNode title: string + sessionId: string pinned?: boolean onPin?: () => void onDelete?: () => void @@ -26,6 +28,7 @@ interface SessionActionsMenuProps extends Pick< export function SessionActionsMenu({ children, title, + sessionId, pinned = false, onPin, onDelete, @@ -34,6 +37,17 @@ export function SessionActionsMenu({ }: SessionActionsMenuProps) { const itemClass = 'gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4' + const copyId = async () => { + triggerHaptic('selection') + + try { + await navigator.clipboard.writeText(sessionId) + notify({ kind: 'success', message: 'Session ID copied', durationMs: 2_000 }) + } catch (err) { + notifyError(err, 'Could not copy session ID') + } + } + return ( {children} @@ -49,6 +63,10 @@ export function SessionActionsMenu({ {pinned ? 'Unpin' : 'Pin'} + void copyId()}> + + Copy ID + Rename diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx index 9c7af20a05..8e7a3901bb 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx @@ -72,7 +72,7 @@ export function SidebarSessionRow({ {title}
- + + ))} + +
+ )} + + {(typing || !hasChoices) && ( +
+