Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdcc2dd7f3 | ||
|
|
4e40f7bb4d | ||
|
|
969aeb279c | ||
|
|
2ca1d7da10 | ||
|
|
eb473710e1 |
@@ -63,45 +63,3 @@ data/
|
||||
# Compose/profile runtime state (bind-mounted; avoid ownership/secret issues)
|
||||
hermes-config/
|
||||
runtime/
|
||||
|
||||
# ---------- Not needed inside the Docker image ----------
|
||||
|
||||
# Desktop app source (Tauri/Electron); never installed in the container
|
||||
apps/
|
||||
|
||||
# Test suite — not shipped in production images
|
||||
tests/
|
||||
|
||||
# Documentation site (Docusaurus) and supplementary docs
|
||||
website/
|
||||
docs/
|
||||
|
||||
# Assets only used by the GitHub README
|
||||
assets/
|
||||
infographic/
|
||||
|
||||
# Plugin-level docs (hermes-achievements ships docs/ but the runtime doesn't read them)
|
||||
plugins/hermes-achievements/docs/
|
||||
|
||||
# Nix / Homebrew / AUR packaging metadata — irrelevant to Docker
|
||||
nix/
|
||||
flake.nix
|
||||
flake.lock
|
||||
packaging/
|
||||
|
||||
# Design and planning documents
|
||||
plans/
|
||||
.plans/
|
||||
|
||||
# ACP registry manifest (icon + agent.json) — not consumed at runtime
|
||||
acp_registry/
|
||||
|
||||
# Repo-level dotfiles that are git-only or dev-tooling config
|
||||
.env.example
|
||||
.envrc
|
||||
.gitattributes
|
||||
.hadolint.yaml
|
||||
.mailmap
|
||||
|
||||
# Top-level LICENSE (not matched by *.md); not needed inside the container
|
||||
LICENSE
|
||||
|
||||
@@ -114,12 +114,6 @@ docs/superpowers/*
|
||||
# treat it as a local edit and autostash it on every run (#38529).
|
||||
.hermes-bootstrap-complete
|
||||
|
||||
# Interrupted-update breadcrumb + recovery lock written next to the shared venv
|
||||
# by `hermes update` / launch-time self-heal. Runtime state, never a code change
|
||||
# — ignore so `git status` stays clean and update's autostash skips them.
|
||||
.update-incomplete
|
||||
.update-incomplete.lock
|
||||
|
||||
# Tool Search live-test harness output — non-deterministic model transcripts,
|
||||
# regenerated by scripts/tool_search_livetest.py. Never an artifact of the repo.
|
||||
scripts/out/
|
||||
|
||||
+9
-20
@@ -25,7 +25,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
|
||||
# hermes process, the dashboard, and per-profile gateways.
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc g++ make cmake python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \
|
||||
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ---------- s6-overlay install ----------
|
||||
@@ -146,9 +146,9 @@ RUN npm install --prefer-offline --no-audit && \
|
||||
#
|
||||
# `uv sync --frozen --no-install-project --extra all --extra messaging`
|
||||
# installs the deps reachable through the composite `[all]` extra
|
||||
# (handpicked set intended for the production image — excludes `[dev]`),
|
||||
# plus gateway messaging adapters that should work in the published image
|
||||
# without a first-boot lazy install. We do NOT use `--all-extras`:
|
||||
# (handpicked set intended for the production image), plus gateway
|
||||
# messaging adapters that should work in the published image without a
|
||||
# first-boot lazy install. We do NOT use `--all-extras`:
|
||||
# that would pull in `[rl]` (atroposlib + tinker + torch + wandb from
|
||||
# git), `[yc-bench]` (another git dep), and `[termux-all]` (Android
|
||||
# redundancy), none of which belong in the published container.
|
||||
@@ -164,30 +164,19 @@ RUN npm install --prefer-offline --no-audit && \
|
||||
# image update and recall/retain then fails with
|
||||
# `ModuleNotFoundError: No module named 'hindsight_client'` (#38128).
|
||||
#
|
||||
# The Matrix gateway's deps ([matrix] extra) are baked in because
|
||||
# python-olm (transitive via mautrix[encryption]) builds from source on
|
||||
# Python/image combinations without usable wheels. The Docker image is
|
||||
# Linux-only, so keeping the native libolm/build-toolchain packages here
|
||||
# avoids the cross-platform failures that kept [matrix] out of [all]
|
||||
# while still making Matrix work in the published container. Fixes #30399.
|
||||
#
|
||||
# The editable link is created after the source copy below.
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN touch ./README.md
|
||||
RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra anthropic --extra bedrock --extra azure-identity --extra hindsight --extra matrix
|
||||
|
||||
# ---------- Frontend build (cached independently from Python source) ----------
|
||||
# Copy only the frontend source trees first so that Python-only changes don't
|
||||
# invalidate the (relatively slow) web + ui-tui build layer.
|
||||
COPY web/ web/
|
||||
COPY ui-tui/ ui-tui/
|
||||
RUN cd web && npm run build && \
|
||||
cd ../ui-tui && npm run build
|
||||
RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra anthropic --extra bedrock --extra azure-identity --extra hindsight
|
||||
|
||||
# ---------- Source code ----------
|
||||
# .dockerignore excludes node_modules, so the installs above survive.
|
||||
COPY --chown=hermes:hermes . .
|
||||
|
||||
# Build browser dashboard and terminal UI assets.
|
||||
RUN cd web && npm run build && \
|
||||
cd ../ui-tui && npm run build
|
||||
|
||||
# ---------- Permissions ----------
|
||||
# Make install dir world-readable so any HERMES_UID can read it at runtime.
|
||||
# The venv needs to be traversable too.
|
||||
|
||||
+15
-2
@@ -25,6 +25,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import threading
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
@@ -32,7 +33,6 @@ from typing import Any, Callable, Dict, List, NamedTuple, Optional, Set
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from tools import skill_usage
|
||||
from utils import atomic_json_write
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -97,7 +97,20 @@ def load_state() -> Dict[str, Any]:
|
||||
def save_state(data: Dict[str, Any]) -> None:
|
||||
path = _state_file()
|
||||
try:
|
||||
atomic_json_write(path, data, indent=2, sort_keys=True)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".curator_state_", suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, sort_keys=True, ensure_ascii=False)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.debug("Failed to save curator state: %s", e, exc_info=True)
|
||||
|
||||
|
||||
@@ -40,15 +40,6 @@ const path = require('node:path')
|
||||
const https = require('node:https')
|
||||
const { spawn } = require('node:child_process')
|
||||
|
||||
const IS_WINDOWS = process.platform === 'win32'
|
||||
|
||||
function hiddenWindowsChildOptions(options = {}) {
|
||||
if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) {
|
||||
return options
|
||||
}
|
||||
return { ...options, windowsHide: true }
|
||||
}
|
||||
|
||||
const STAMP_COMMIT_RE = /^[0-9a-f]{7,40}$/i
|
||||
|
||||
// Stages flagged needs_user_input=true in the manifest are skipped by the
|
||||
@@ -293,7 +284,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
|
||||
const ps = process.platform === 'win32' ? resolveWindowsPowerShell() : 'pwsh'
|
||||
const fullArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args]
|
||||
|
||||
const child = spawn(ps, fullArgs, hiddenWindowsChildOptions({
|
||||
const child = spawn(ps, fullArgs, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -301,7 +292,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
|
||||
// choice rather than re-computing the default.
|
||||
HERMES_HOME: hermesHome || process.env.HERMES_HOME || ''
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
|
||||
@@ -107,13 +107,6 @@ const IS_WINDOWS = process.platform === 'win32'
|
||||
const IS_WSL = isWslEnvironment()
|
||||
const APP_ROOT = app.getAppPath()
|
||||
|
||||
function hiddenWindowsChildOptions(options = {}) {
|
||||
if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) {
|
||||
return options
|
||||
}
|
||||
return { ...options, windowsHide: true }
|
||||
}
|
||||
|
||||
// Remote displays (SSH X11 forwarding, VNC, RDP) make Chromium's GPU
|
||||
// compositor flicker — accelerated layers can't be presented cleanly over the
|
||||
// wire, so the window flashes during scroll/streaming/animation. Local
|
||||
@@ -1113,7 +1106,7 @@ function findSystemPython() {
|
||||
const out = execFileSync(
|
||||
'reg',
|
||||
['query', `${hive}\\SOFTWARE\\Python\\PythonCore\\${version}\\InstallPath`, '/ve', '/reg:64'],
|
||||
hiddenWindowsChildOptions({ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
||||
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
||||
)
|
||||
// Output format: " (Default) REG_SZ C:\Path\To\Python\"
|
||||
const match = out.match(/REG_SZ\s+(.+?)\s*$/m)
|
||||
@@ -1149,10 +1142,10 @@ function findSystemPython() {
|
||||
if (pyExe) {
|
||||
for (const version of SUPPORTED_VERSIONS) {
|
||||
try {
|
||||
const out = execFileSync(pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], hiddenWindowsChildOptions({
|
||||
const out = execFileSync(pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
}))
|
||||
})
|
||||
const candidate = out.trim()
|
||||
if (candidate && fileExists(candidate)) return candidate
|
||||
} catch {
|
||||
@@ -1287,11 +1280,11 @@ function resolveUpdateRoot() {
|
||||
|
||||
function runGit(args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, hiddenWindowsChildOptions({
|
||||
const child = spawn(resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, {
|
||||
cwd: options.cwd,
|
||||
env: { ...process.env, ...(options.env || {}), GIT_TERMINAL_PROMPT: '0' },
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}))
|
||||
})
|
||||
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
@@ -1501,7 +1494,7 @@ function forceKillProcessTree(pid) {
|
||||
if (!IS_WINDOWS) return
|
||||
if (!Number.isInteger(pid) || pid <= 0) return
|
||||
try {
|
||||
execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], hiddenWindowsChildOptions({ stdio: 'ignore' }))
|
||||
execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
|
||||
} catch {
|
||||
// Already gone, or no permission — best effort; the unlock wait below is
|
||||
// the real gate.
|
||||
@@ -1687,11 +1680,11 @@ function runStreamedUpdate(command, args, { cwd, env, stage } = {}) {
|
||||
return new Promise(resolve => {
|
||||
let child
|
||||
try {
|
||||
child = spawn(command, args, hiddenWindowsChildOptions({
|
||||
child = spawn(command, args, {
|
||||
cwd,
|
||||
env: { ...process.env, ...(env || {}) },
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}))
|
||||
})
|
||||
} catch (err) {
|
||||
resolve({ code: 1, error: err.message })
|
||||
return
|
||||
@@ -2678,7 +2671,7 @@ function fetchHtmlTitleWithCurl(rawUrl) {
|
||||
'--raw',
|
||||
url
|
||||
]
|
||||
const child = spawn('curl', args, hiddenWindowsChildOptions({ stdio: ['ignore', 'pipe', 'ignore'] }))
|
||||
const child = spawn('curl', args, { stdio: ['ignore', 'pipe', 'ignore'] })
|
||||
const chunks = []
|
||||
let bytes = 0
|
||||
|
||||
@@ -4498,7 +4491,7 @@ async function spawnPoolBackend(profile, entry) {
|
||||
|
||||
rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`)
|
||||
|
||||
const child = spawn(backend.command, backend.args, hiddenWindowsChildOptions({
|
||||
const child = spawn(backend.command, backend.args, {
|
||||
cwd: hermesCwd,
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -4516,7 +4509,7 @@ async function spawnPoolBackend(profile, entry) {
|
||||
},
|
||||
shell: backend.shell,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}))
|
||||
})
|
||||
entry.process = child
|
||||
entry.port = port
|
||||
entry.token = token
|
||||
@@ -4698,7 +4691,7 @@ async function startHermes() {
|
||||
await advanceBootProgress('backend.spawn', `Starting Hermes backend via ${backend.label}`, 84)
|
||||
rememberLog(`Starting Hermes backend via ${backend.label}`)
|
||||
|
||||
hermesProcess = spawn(backend.command, backend.args, hiddenWindowsChildOptions({
|
||||
hermesProcess = spawn(backend.command, backend.args, {
|
||||
cwd: hermesCwd,
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -4721,7 +4714,7 @@ async function startHermes() {
|
||||
},
|
||||
shell: backend.shell,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}))
|
||||
})
|
||||
|
||||
hermesProcess.stdout.on('data', rememberLog)
|
||||
hermesProcess.stderr.on('data', rememberLog)
|
||||
@@ -5993,11 +5986,11 @@ async function getUninstallSummary() {
|
||||
resolve(value)
|
||||
}
|
||||
try {
|
||||
const child = spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], hiddenWindowsChildOptions({
|
||||
const child = spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], {
|
||||
cwd: agentRoot,
|
||||
env: { ...process.env, HERMES_HOME, NO_COLOR: '1' },
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
}))
|
||||
})
|
||||
child.stdout.on('data', chunk => {
|
||||
stdout += chunk.toString()
|
||||
})
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const ELECTRON_DIR = __dirname
|
||||
|
||||
function readElectronFile(name) {
|
||||
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8')
|
||||
}
|
||||
|
||||
function requireHiddenChildOptions(source, needle) {
|
||||
const index = source.indexOf(needle)
|
||||
assert.notEqual(index, -1, `missing call site: ${needle}`)
|
||||
const snippet = source.slice(index, index + 700)
|
||||
assert.match(
|
||||
snippet,
|
||||
/hiddenWindowsChildOptions\(/,
|
||||
`expected ${needle} to wrap child-process options with hiddenWindowsChildOptions`
|
||||
)
|
||||
}
|
||||
|
||||
test('desktop background child processes opt into hidden Windows consoles', () => {
|
||||
const source = readElectronFile('main.cjs')
|
||||
|
||||
assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/)
|
||||
|
||||
requireHiddenChildOptions(source, "execFileSync(\n 'reg'")
|
||||
requireHiddenChildOptions(source, 'execFileSync(pyExe')
|
||||
requireHiddenChildOptions(source, 'spawn(resolveGitBinary()')
|
||||
requireHiddenChildOptions(source, "execFileSync('taskkill'")
|
||||
requireHiddenChildOptions(source, 'spawn(command, args')
|
||||
requireHiddenChildOptions(source, "spawn('curl'")
|
||||
requireHiddenChildOptions(source, 'spawn(backend.command, backend.args')
|
||||
requireHiddenChildOptions(source, 'hermesProcess = spawn(backend.command, backend.args')
|
||||
requireHiddenChildOptions(source, "spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary']")
|
||||
})
|
||||
|
||||
test('intentional or interactive desktop child processes stay documented', () => {
|
||||
const source = readElectronFile('main.cjs')
|
||||
|
||||
assert.match(source, /windowsHide: false/)
|
||||
assert.match(source, /nodePty\.spawn\(command, args/)
|
||||
assert.match(source, /spawn\('cmd\.exe', \['\/c', 'start'/)
|
||||
})
|
||||
|
||||
test('bootstrap PowerShell runner hides Windows console children', () => {
|
||||
const source = readElectronFile('bootstrap-runner.cjs')
|
||||
|
||||
assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/)
|
||||
requireHiddenChildOptions(source, 'spawn(ps, fullArgs')
|
||||
})
|
||||
@@ -35,7 +35,7 @@
|
||||
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/windows-child-process.test.cjs",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs",
|
||||
"type-check": "tsc -b",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Streamdown } from 'streamdown'
|
||||
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { translateNow, useI18n } from '@/i18n'
|
||||
import { readDesktopFileDataUrl, readDesktopFileText } from '@/lib/desktop-fs'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { PreviewTarget } from '@/store/preview'
|
||||
|
||||
@@ -180,15 +181,13 @@ function looksBinaryBytes(bytes: Uint8Array) {
|
||||
}
|
||||
|
||||
async function readTextPreview(filePath: string) {
|
||||
if (window.hermesDesktop.readFileText) {
|
||||
try {
|
||||
return await window.hermesDesktop.readFileText(filePath)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
try {
|
||||
return await readDesktopFileText(filePath)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
if (!message.includes("No handler registered for 'hermes:readFileText'")) {
|
||||
throw error
|
||||
}
|
||||
if (!message.includes("No handler registered for 'hermes:readFileText'")) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,7 +447,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
||||
if (isImage) {
|
||||
// Prefer bytes the caller already handed us (a pasted/dropped
|
||||
// screenshot) over re-reading a path that may be transient/unreadable.
|
||||
const dataUrl = target.dataUrl || (await window.hermesDesktop.readFileDataUrl(filePath))
|
||||
const dataUrl = target.dataUrl || (await readDesktopFileDataUrl(filePath))
|
||||
|
||||
if (active) {
|
||||
setState({ dataUrl, loading: false })
|
||||
|
||||
@@ -1,11 +1,50 @@
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
import { PreviewPane } from './preview-pane'
|
||||
|
||||
describe('PreviewPane console state', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => window.setTimeout(() => callback(Date.now()), 0))
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$connection.set(null)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('does not watch backend-only remote filesystem previews locally', () => {
|
||||
const watchPreviewFile = vi.fn(async () => ({ id: 'watch-1', path: '/remote/file.txt' }))
|
||||
const onPreviewFileChanged = vi.fn(() => vi.fn())
|
||||
$connection.set({ mode: 'remote' } as never)
|
||||
vi.stubGlobal('window', {
|
||||
...window,
|
||||
hermesDesktop: {
|
||||
onPreviewFileChanged,
|
||||
watchPreviewFile
|
||||
}
|
||||
})
|
||||
|
||||
render(
|
||||
<PreviewPane
|
||||
setTitlebarToolGroup={vi.fn()}
|
||||
target={{
|
||||
kind: 'file',
|
||||
label: 'file.txt',
|
||||
path: '/remote/file.txt',
|
||||
previewKind: 'text',
|
||||
source: '/remote/file.txt',
|
||||
url: 'file:///remote/file.txt'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(watchPreviewFile).not.toHaveBeenCalled()
|
||||
expect(onPreviewFileChanged).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not rebuild the pane titlebar group for streamed console logs', () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { isDesktopFsRemoteMode } from '@/lib/desktop-fs'
|
||||
import { Bug } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
@@ -406,6 +407,7 @@ export function PreviewPane({
|
||||
useEffect(() => {
|
||||
if (
|
||||
target.kind !== 'file' ||
|
||||
isDesktopFsRemoteMode() ||
|
||||
!window.hermesDesktop?.watchPreviewFile ||
|
||||
!window.hermesDesktop?.onPreviewFileChanged
|
||||
) {
|
||||
|
||||
@@ -38,7 +38,6 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { normalizeCombo } from '@/lib/keybinds/combo'
|
||||
import { profileColor } from '@/lib/profile-color'
|
||||
import { sessionMatchesSearch } from '@/lib/session-search'
|
||||
import { normalizeSessionSource, sessionSourceLabel } from '@/lib/session-source'
|
||||
@@ -112,7 +111,8 @@ const NON_SESSION_LOAD_STEP = 10
|
||||
// Render the modifier key the user actually presses on this platform. The
|
||||
// global accelerator is bound to both Cmd+N (macOS) and Ctrl+N (everywhere
|
||||
// else) in desktop-controller.tsx, but the hint should match muscle memory.
|
||||
const NEW_SESSION_KBD: readonly string[] =normalizeCombo('mod+n')
|
||||
const NEW_SESSION_KBD: readonly string[] =
|
||||
typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('mac') ? ['⌘', 'N'] : ['Ctrl', 'N']
|
||||
|
||||
const SIDEBAR_NAV: SidebarNavItem[] = [
|
||||
{
|
||||
|
||||
@@ -10,7 +10,6 @@ import type { SessionInfo } from '@/hermes'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { modKey } from '@/lib/keybinds/combo'
|
||||
import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $attentionSessionIds } from '@/store/session'
|
||||
@@ -134,11 +133,11 @@ export function SidebarSessionRow({
|
||||
return
|
||||
}
|
||||
|
||||
// ⌘-click (mac) / Ctrl-click (win/linux) pops the chat into its own
|
||||
// ⌘-click (mac) / ⌃-click (win/linux) pops the chat into its own
|
||||
// window — the universal "open in a new window" gesture. Archive
|
||||
// lives in the row's ⋯ and right-click menus. Falls through to a
|
||||
// normal resume when standalone windows aren't available (web embed).
|
||||
if (event[modKey] && canOpenSessionWindow()) {
|
||||
if ((event.metaKey || event.ctrlKey) && canOpenSessionWindow()) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
triggerHaptic('selection')
|
||||
|
||||
@@ -91,7 +91,6 @@ import { CommandPalette } from './command-palette'
|
||||
import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
|
||||
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
|
||||
import { useKeybinds } from './hooks/use-keybinds'
|
||||
import { modKey } from '@/lib/keybinds/combo'
|
||||
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from './layout-constants'
|
||||
import { ModelPickerOverlay } from './model-picker-overlay'
|
||||
import { ModelVisibilityOverlay } from './model-visibility-overlay'
|
||||
@@ -272,7 +271,7 @@ export function DesktopController() {
|
||||
return
|
||||
}
|
||||
|
||||
if (event[modKey] && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'w') {
|
||||
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'w') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
closeActiveRightRailTab()
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useRef } from 'react'
|
||||
import type { HermesConnection } from '@/global'
|
||||
import { HermesGateway } from '@/hermes'
|
||||
import { translateNow } from '@/i18n'
|
||||
import { desktopDefaultCwd } from '@/lib/desktop-fs'
|
||||
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
|
||||
import {
|
||||
$desktopBoot,
|
||||
@@ -25,12 +26,16 @@ import {
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $activeGatewayProfile, normalizeProfileKey, touchActiveGatewayBackend } from '@/store/profile'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$attentionSessionIds,
|
||||
$connection,
|
||||
$currentCwd,
|
||||
$sessions,
|
||||
$workingSessionIds,
|
||||
ensureDefaultWorkspaceCwd,
|
||||
setConnection,
|
||||
setCurrentBranch,
|
||||
setCurrentCwd,
|
||||
setSessionsLoading
|
||||
} from '@/store/session'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
@@ -353,6 +358,11 @@ export function useGatewayBoot({
|
||||
progress: 97
|
||||
})
|
||||
await ensureDefaultWorkspaceCwd()
|
||||
const remoteDefault = await desktopDefaultCwd().catch(() => null)
|
||||
if (remoteDefault?.cwd && !$activeSessionId.get() && !$currentCwd.get()) {
|
||||
setCurrentCwd(remoteDefault.cwd)
|
||||
setCurrentBranch(remoteDefault.branch || '')
|
||||
}
|
||||
await callbacksRef.current.refreshHermesConfig()
|
||||
|
||||
if (cancelled) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import ignore from 'ignore'
|
||||
|
||||
import { desktopFsCacheKey, desktopGitRoot, readDesktopDir, readDesktopFileDataUrl } from '@/lib/desktop-fs'
|
||||
import type { HermesReadDirEntry, HermesReadDirResult } from '@/global'
|
||||
|
||||
export type ProjectTreeEntry = HermesReadDirEntry
|
||||
@@ -63,15 +64,11 @@ function ancestorDirs(root: string, dir: string) {
|
||||
}
|
||||
|
||||
async function gitRootFor(start: string) {
|
||||
if (!window.hermesDesktop?.gitRoot) {
|
||||
return null
|
||||
}
|
||||
|
||||
const key = clean(start)
|
||||
const key = `${desktopFsCacheKey()}:${clean(start)}`
|
||||
let cached = gitRootCache.get(key)
|
||||
|
||||
if (!cached) {
|
||||
cached = window.hermesDesktop.gitRoot(key)
|
||||
cached = desktopGitRoot(start)
|
||||
gitRootCache.set(key, cached)
|
||||
}
|
||||
|
||||
@@ -80,18 +77,14 @@ async function gitRootFor(start: string) {
|
||||
|
||||
/** Read .gitignore at `dir` if it actually exists — never probe missing files. */
|
||||
async function readGitignore(dir: string): Promise<GitignoreRule | null> {
|
||||
if (!window.hermesDesktop?.readDir || !window.hermesDesktop.readFileDataUrl) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const listing = await window.hermesDesktop.readDir(dir)
|
||||
const listing = await readDesktopDir(dir)
|
||||
|
||||
if (!listing.entries.some(e => e.name === '.gitignore' && !e.isDirectory)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const text = decodeDataUrl(await window.hermesDesktop.readFileDataUrl(`${dir}/.gitignore`))
|
||||
const text = decodeDataUrl(await readDesktopFileDataUrl(`${dir}/.gitignore`))
|
||||
|
||||
return { base: dir, ig: ignore().add(text) }
|
||||
} catch {
|
||||
@@ -100,11 +93,11 @@ async function readGitignore(dir: string): Promise<GitignoreRule | null> {
|
||||
}
|
||||
|
||||
async function gitignoreFor(dir: string) {
|
||||
const key = clean(dir)
|
||||
const key = `${desktopFsCacheKey()}:${clean(dir)}`
|
||||
let cached = gitignoreCache.get(key)
|
||||
|
||||
if (!cached) {
|
||||
cached = readGitignore(key)
|
||||
cached = readGitignore(clean(dir))
|
||||
gitignoreCache.set(key, cached)
|
||||
}
|
||||
|
||||
@@ -142,9 +135,10 @@ export async function readProjectDir(dirPath: string, rootPath = dirPath): Promi
|
||||
return { entries: [], error: 'no-bridge' }
|
||||
}
|
||||
|
||||
const result = await window.hermesDesktop.readDir(dirPath)
|
||||
const result = await readDesktopDir(dirPath)
|
||||
const entries = result?.entries ?? []
|
||||
|
||||
return { ...result, entries: await filterIgnored(result.entries, rootPath, dirPath) }
|
||||
return { ...result, entries: await filterIgnored(entries, rootPath, dirPath) }
|
||||
}
|
||||
|
||||
export function clearProjectDirCache(rootPath?: string) {
|
||||
@@ -155,7 +149,7 @@ export function clearProjectDirCache(rootPath?: string) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = clean(rootPath)
|
||||
const key = `${desktopFsCacheKey()}:${clean(rootPath)}`
|
||||
gitRootCache.delete(key)
|
||||
gitignoreCache.delete(key)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { readDesktopDir, setDesktopFsRemotePicker } from '@/lib/desktop-fs'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function clean(path: string) {
|
||||
return path.replace(/\/+$/, '') || '/'
|
||||
}
|
||||
|
||||
function parentDir(path: string) {
|
||||
const value = clean(path)
|
||||
if (value === '/') {
|
||||
return '/'
|
||||
}
|
||||
const parent = value.slice(0, value.lastIndexOf('/'))
|
||||
return parent || '/'
|
||||
}
|
||||
|
||||
function pathName(path: string) {
|
||||
return path.split('/').filter(Boolean).pop() || path
|
||||
}
|
||||
|
||||
interface PendingSelection {
|
||||
defaultPath: string
|
||||
resolve: (paths: string[]) => void
|
||||
title: string
|
||||
}
|
||||
|
||||
export function RemoteFolderPicker() {
|
||||
const { t } = useI18n()
|
||||
const r = t.rightSidebar
|
||||
const [pending, setPending] = useState<PendingSelection | null>(null)
|
||||
const [currentPath, setCurrentPath] = useState('/')
|
||||
const [entries, setEntries] = useState<Array<{ name: string; path: string }>>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setDesktopFsRemotePicker({
|
||||
selectPaths: options =>
|
||||
new Promise(resolve => {
|
||||
const defaultPath = clean(options?.defaultPath || '/')
|
||||
setCurrentPath(defaultPath)
|
||||
setPending({ defaultPath, resolve, title: options?.title || r.remotePickerTitle })
|
||||
})
|
||||
})
|
||||
return () => setDesktopFsRemotePicker(null)
|
||||
}, [r.remotePickerTitle])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pending) {
|
||||
return
|
||||
}
|
||||
|
||||
let active = true
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
void readDesktopDir(currentPath)
|
||||
.then(result => {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
if (result.error) {
|
||||
setError(result.error)
|
||||
setEntries([])
|
||||
return
|
||||
}
|
||||
setEntries(result.entries.filter(entry => entry.isDirectory).map(entry => ({ name: entry.name, path: entry.path })))
|
||||
})
|
||||
.catch(err => {
|
||||
if (active) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
setEntries([])
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) {
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [currentPath, pending])
|
||||
|
||||
const crumbs = useMemo(() => {
|
||||
const parts = clean(currentPath).split('/').filter(Boolean)
|
||||
const out = [{ label: '/', path: '/' }]
|
||||
let acc = ''
|
||||
for (const part of parts) {
|
||||
acc += `/${part}`
|
||||
out.push({ label: part, path: acc })
|
||||
}
|
||||
return out
|
||||
}, [currentPath])
|
||||
|
||||
const close = (paths: string[] = []) => {
|
||||
pending?.resolve(paths)
|
||||
setPending(null)
|
||||
setEntries([])
|
||||
setError(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={open => !open && close()} open={Boolean(pending)}>
|
||||
<DialogContent className="max-w-lg gap-0 overflow-hidden p-0">
|
||||
<div className="border-b border-border/70 px-4 py-3">
|
||||
<DialogTitle className="text-sm">{pending?.title || r.remotePickerTitle}</DialogTitle>
|
||||
<DialogDescription className="mt-1 text-xs">{r.remotePickerDescription}</DialogDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-[22rem] flex-col">
|
||||
<div className="flex flex-wrap items-center gap-1 border-b border-border/50 px-3 py-2 text-xs text-muted-foreground">
|
||||
{crumbs.map((crumb, index) => (
|
||||
<button
|
||||
className={cn('rounded px-1.5 py-0.5 hover:bg-muted hover:text-foreground', index === crumbs.length - 1 && 'text-foreground')}
|
||||
key={crumb.path}
|
||||
onClick={() => setCurrentPath(crumb.path)}
|
||||
type="button"
|
||||
>
|
||||
{crumb.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
<FolderRow disabled={currentPath === '/'} name=".." onClick={() => setCurrentPath(parentDir(currentPath))} />
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 px-2 py-3 text-xs text-muted-foreground">
|
||||
<Codicon name="loading" size="0.8rem" spinning />
|
||||
{r.loadingFiles}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="px-2 py-3 text-xs text-destructive">{r.unreadableBody(error)}</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="px-2 py-3 text-xs text-muted-foreground">{r.emptyBody}</div>
|
||||
) : (
|
||||
entries.map(entry => <FolderRow key={entry.path} name={pathName(entry.path)} onClick={() => setCurrentPath(entry.path)} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 border-t border-border/70 px-4 py-3">
|
||||
<div className="min-w-0 truncate text-xs text-muted-foreground">{currentPath}</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button onClick={() => close()} size="sm" variant="ghost">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button onClick={() => close([currentPath])} size="sm">
|
||||
{r.remotePickerSelect}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function FolderRow({ disabled = false, name, onClick }: { disabled?: boolean; name: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background) hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="folder" size="0.875rem" />
|
||||
<span className="min-w-0 truncate">{name}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,24 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $connection } from '@/store/session'
|
||||
import type { HermesReadDirResult } from '@/global'
|
||||
|
||||
import { clearProjectDirCache, readProjectDir } from './ipc'
|
||||
import { resetProjectTreeState, useProjectTree } from './use-project-tree'
|
||||
|
||||
const readDir = vi.fn<(path: string) => Promise<HermesReadDirResult>>()
|
||||
|
||||
beforeEach(() => {
|
||||
$connection.set(null)
|
||||
resetProjectTreeState()
|
||||
readDir.mockReset()
|
||||
;(window as unknown as { hermesDesktop: { readDir: typeof readDir } }).hermesDesktop = { readDir }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$connection.set(null)
|
||||
resetProjectTreeState()
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
@@ -106,6 +111,36 @@ describe('useProjectTree', () => {
|
||||
expect(readDir).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reads gitignore from the real path while caching per connection', async () => {
|
||||
const readFileDataUrl = vi.fn(async () => `data:text/plain;base64,${btoa('ignored.log\n')}`)
|
||||
const gitRoot = vi.fn(async () => '/repo')
|
||||
readDir.mockImplementation(async path => {
|
||||
if (path === '/repo') return ok([{ name: '.gitignore', path: '/repo/.gitignore', isDirectory: false }])
|
||||
if (path === '/repo/src') {
|
||||
return ok([
|
||||
{ name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false },
|
||||
{ name: 'ignored.log', path: '/repo/src/ignored.log', isDirectory: false }
|
||||
])
|
||||
}
|
||||
throw new Error(`unexpected path ${path}`)
|
||||
})
|
||||
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { gitRoot, readDir, readFileDataUrl }
|
||||
|
||||
$connection.set({ baseUrl: 'local-a', mode: 'local' } as never)
|
||||
await expect(readProjectDir('/repo/src', '/repo')).resolves.toMatchObject({
|
||||
entries: [{ name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false }]
|
||||
})
|
||||
expect(readDir).toHaveBeenCalledWith('/repo')
|
||||
expect(readDir).not.toHaveBeenCalledWith(expect.stringContaining('local-a'))
|
||||
|
||||
$connection.set({ baseUrl: 'local-b', mode: 'local' } as never)
|
||||
clearProjectDirCache()
|
||||
await expect(readProjectDir('/repo/src', '/repo')).resolves.toMatchObject({
|
||||
entries: [{ name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false }]
|
||||
})
|
||||
expect(readDir.mock.calls.filter(([path]) => path === '/repo')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('captures per-folder error code and leaves the folder expandable but empty', async () => {
|
||||
readDir.mockResolvedValueOnce(ok([{ name: 'priv', path: '/p/priv', isDirectory: true }]))
|
||||
readDir.mockResolvedValueOnce({ entries: [], error: 'EACCES' })
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useStore } from '@nanostores/react'
|
||||
import { atom } from 'nanostores'
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
import { clearProjectDirCache, readProjectDir } from './ipc'
|
||||
|
||||
export interface TreeNode {
|
||||
@@ -84,6 +86,7 @@ const initialState: ProjectTreeState = {
|
||||
const inflight = new Set<string>()
|
||||
const $projectTree = atom<ProjectTreeState>(initialState)
|
||||
let nextRootRequestId = 0
|
||||
let lastConnectionKey = ''
|
||||
|
||||
function setProjectTree(updater: (current: ProjectTreeState) => ProjectTreeState) {
|
||||
$projectTree.set(updater($projectTree.get()))
|
||||
@@ -145,6 +148,7 @@ async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {}
|
||||
}
|
||||
|
||||
export function resetProjectTreeState() {
|
||||
lastConnectionKey = ''
|
||||
clearProjectTree()
|
||||
clearProjectDirCache()
|
||||
}
|
||||
@@ -158,6 +162,8 @@ export function resetProjectTreeState() {
|
||||
*/
|
||||
export function useProjectTree(cwd: string): UseProjectTreeResult {
|
||||
const state = useStore($projectTree)
|
||||
const connection = useStore($connection)
|
||||
const connectionKey = `${connection?.mode || 'local'}:${connection?.profile || ''}:${connection?.baseUrl || ''}`
|
||||
|
||||
const refreshRoot = useCallback(() => loadRoot(cwd, { force: true }), [cwd])
|
||||
|
||||
@@ -236,8 +242,15 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const connectionChanged = lastConnectionKey !== '' && lastConnectionKey !== connectionKey
|
||||
lastConnectionKey = connectionKey
|
||||
if (connectionChanged) {
|
||||
clearProjectDirCache()
|
||||
void loadRoot(cwd, { force: true })
|
||||
return
|
||||
}
|
||||
void loadRoot(cwd)
|
||||
}, [cwd])
|
||||
}, [connectionKey, cwd])
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Codicon } from '@/components/ui/codicon'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { selectDesktopPaths } from '@/lib/desktop-fs'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $panesFlipped } from '@/store/layout'
|
||||
@@ -16,6 +17,7 @@ import { $currentCwd } from '@/store/session'
|
||||
|
||||
import { SidebarPanelLabel } from '../shell/sidebar-label'
|
||||
|
||||
import { RemoteFolderPicker } from './files/remote-picker'
|
||||
import { ProjectTree } from './files/tree'
|
||||
import { useProjectTree } from './files/use-project-tree'
|
||||
|
||||
@@ -54,7 +56,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
||||
const canCollapse = Object.values(openState).some(Boolean)
|
||||
|
||||
const chooseFolder = async () => {
|
||||
const selected = await window.hermesDesktop?.selectPaths({
|
||||
const selected = await selectDesktopPaths({
|
||||
defaultPath: hasCwd ? currentCwd : undefined,
|
||||
directories: true,
|
||||
multiple: false,
|
||||
@@ -90,6 +92,8 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
||||
: 'border-l shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'
|
||||
)}
|
||||
>
|
||||
<RemoteFolderPicker />
|
||||
|
||||
<FilesystemTab
|
||||
canCollapse={canCollapse}
|
||||
collapseNonce={collapseNonce}
|
||||
|
||||
@@ -69,7 +69,7 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
|
||||
variant="secondary"
|
||||
>
|
||||
{t.rightSidebar.addToChat}
|
||||
<span className="ml-1 text-[0.6rem] text-(--ui-text-tertiary)">{addSelectionShortcutLabel}</span>
|
||||
<span className="ml-1 text-[0.6rem] text-(--ui-text-tertiary)">{addSelectionShortcutLabel()}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ITheme, Terminal } from '@xterm/xterm'
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
import { formatCombo, modKey } from '@/lib/keybinds/combo'
|
||||
import type { DesktopTerminalPalette } from '@/themes/types'
|
||||
|
||||
// VS Code's default integrated-terminal palette (terminalColorRegistry.ts) — a
|
||||
@@ -98,10 +97,12 @@ export function resolveSurfaceColor(fallback: string): string {
|
||||
return resolved && resolved !== 'rgba(0, 0, 0, 0)' ? resolved : fallback
|
||||
}
|
||||
|
||||
export const addSelectionShortcutLabel = formatCombo('mod+l')
|
||||
export const isMacPlatform = () => navigator.platform.toLowerCase().includes('mac')
|
||||
|
||||
export const addSelectionShortcutLabel = () => (isMacPlatform() ? '⌘L' : 'Ctrl+L')
|
||||
|
||||
export function isAddSelectionShortcut(event: KeyboardEvent) {
|
||||
const mod = event[modKey]
|
||||
const mod = isMacPlatform() ? event.metaKey : event.ctrlKey
|
||||
|
||||
return mod && !event.shiftKey && event.key.toLowerCase() === 'l'
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type KeybindActionMeta,
|
||||
type KeybindReadonly
|
||||
} from '@/lib/keybinds/actions'
|
||||
import { formatCombo, formatFakeCombo } from '@/lib/keybinds/combo'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
import { arraysEqual } from '@/lib/storage'
|
||||
import {
|
||||
$bindings,
|
||||
@@ -210,7 +210,7 @@ function ReadonlyRow({ shortcut }: { shortcut: KeybindReadonly }) {
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{shortcut.keys.map(key => (
|
||||
<span className="kbd-cap" key={key}>
|
||||
{formatFakeCombo(key)}
|
||||
{formatCombo(key)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -722,14 +722,8 @@ function StickyHumanMessageContainer({ children }: { children: ReactNode }) {
|
||||
// edit composer render the same bubble surface (rounded glass card);
|
||||
// they only differ in border weight, cursor, and padding-right (the
|
||||
// read-only view reserves room for the restore icon).
|
||||
//
|
||||
// no-drag: sticky bubbles park at --sticky-human-top (~4px), sliding under the
|
||||
// titlebar's [-webkit-app-region:drag] strips (app-shell.tsx). Electron resolves
|
||||
// drag regions at the compositor level — z-index and pointer-events don't help —
|
||||
// so without the carve-out, clicking a stuck bubble drags the window instead of
|
||||
// opening the edit composer.
|
||||
const USER_BUBBLE_BASE_CLASS =
|
||||
'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left [-webkit-app-region:no-drag]'
|
||||
'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left'
|
||||
|
||||
const USER_ACTION_ICON_BUTTON_CLASS =
|
||||
'grid place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70'
|
||||
|
||||
@@ -16,7 +16,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { ChevronDown, Loader2 } from '@/lib/icons'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $approvalRequest, type ApprovalRequest, clearApprovalRequest } from '@/store/prompts'
|
||||
@@ -51,6 +50,8 @@ export const PendingToolApproval: FC<{ part: ToolPart }> = ({ part }) => {
|
||||
return <ApprovalBar request={request} />
|
||||
}
|
||||
|
||||
const isMac = typeof navigator !== 'undefined' && /Mac|iP(hone|ad|od)/.test(navigator.platform)
|
||||
|
||||
const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
|
||||
const { t } = useI18n()
|
||||
const copy = t.assistant.approval
|
||||
@@ -126,7 +127,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
|
||||
variant="ghost"
|
||||
>
|
||||
{submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : copy.run}
|
||||
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{formatCombo('mod+enter')}</span>}
|
||||
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}
|
||||
</Button>
|
||||
<span aria-hidden className="w-px self-stretch bg-primary/20" />
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -13,9 +13,9 @@ import { DisclosureRow } from '@/components/chat/disclosure-row'
|
||||
import { PreviewAttachment } from '@/components/chat/preview-attachment'
|
||||
import { ZoomableImage } from '@/components/chat/zoomable-image'
|
||||
import { BrailleSpinner } from '@/components/ui/braille-spinner'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { FadeText } from '@/components/ui/fade-text'
|
||||
import { ToolIcon } from '@/components/ui/tool-icon'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link'
|
||||
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
|
||||
@@ -136,7 +136,7 @@ function ToolGlyph({ copy, icon, status }: { copy: ToolStatusCopy; icon?: string
|
||||
const node = status ? (
|
||||
statusGlyph(status, copy)
|
||||
) : icon ? (
|
||||
<ToolIcon className="text-(--ui-text-tertiary)" name={icon} size="0.875rem" />
|
||||
<Codicon className="text-(--ui-text-tertiary)" name={icon} size="0.875rem" />
|
||||
) : null
|
||||
|
||||
return node ? <span className={TOOL_HEADER_GLYPH_WRAP_CLASS}>{node}</span> : null
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import { ExportedMessageRepository } from '@assistant-ui/core/internal'
|
||||
// Clicking a user bubble must open the inline edit composer — through the
|
||||
// app's incremental external-store runtime (which reimplements capability
|
||||
// resolution, incl. `edit: onEdit !== undefined`) and the stock runtime.
|
||||
//
|
||||
// Note: this covers the React/runtime wiring only. The Electron-level failure
|
||||
// mode (titlebar -webkit-app-region:drag swallowing clicks on *stuck* sticky
|
||||
// bubbles) is not reproducible in jsdom — see USER_BUBBLE_BASE_CLASS's no-drag
|
||||
// carve-out in thread.tsx.
|
||||
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
|
||||
|
||||
import { Thread } from './thread'
|
||||
|
||||
const createdAt = new Date('2026-05-01T00:00:00.000Z')
|
||||
|
||||
class TestResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
vi.stubGlobal('ResizeObserver', TestResizeObserver)
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
window.setTimeout(() => callback(performance.now()), 0)
|
||||
)
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
|
||||
|
||||
Element.prototype.scrollTo = function scrollTo() {}
|
||||
|
||||
function stubOffsetDimension(
|
||||
prop: 'offsetHeight' | 'offsetWidth',
|
||||
clientProp: 'clientHeight' | 'clientWidth',
|
||||
fallback: number
|
||||
) {
|
||||
const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop)
|
||||
|
||||
Object.defineProperty(HTMLElement.prototype, prop, {
|
||||
configurable: true,
|
||||
get() {
|
||||
return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
stubOffsetDimension('offsetWidth', 'clientWidth', 800)
|
||||
stubOffsetDimension('offsetHeight', 'clientHeight', 600)
|
||||
|
||||
function userMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'edit me please' }],
|
||||
attachments: [],
|
||||
createdAt,
|
||||
metadata: { custom: {} }
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function assistantMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
status: { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
// Mirrors chat/index.tsx: incremental runtime + messageRepository + onEdit.
|
||||
function IncrementalHarness({ onEdit }: { onEdit: () => Promise<void> }) {
|
||||
const repository = ExportedMessageRepository.fromArray([userMessage(), assistantMessage()])
|
||||
|
||||
const runtime = useIncrementalExternalStoreRuntime<ThreadMessage>({
|
||||
messageRepository: repository,
|
||||
isRunning: false,
|
||||
setMessages: () => {},
|
||||
onNew: async () => {},
|
||||
onEdit,
|
||||
onCancel: async () => {},
|
||||
onReload: async () => {}
|
||||
})
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread />
|
||||
</AssistantRuntimeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Control: stock external store runtime.
|
||||
function StockHarness({ onEdit }: { onEdit: () => Promise<void> }) {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: [userMessage(), assistantMessage()],
|
||||
isRunning: false,
|
||||
onNew: async () => {},
|
||||
onEdit
|
||||
})
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread />
|
||||
</AssistantRuntimeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('click-to-edit user message', () => {
|
||||
it('opens the edit composer with the incremental runtime', async () => {
|
||||
const { container } = render(<IncrementalHarness onEdit={async () => {}} />)
|
||||
|
||||
const bubble = await screen.findByRole('button', { name: 'Edit message' })
|
||||
|
||||
fireEvent.click(bubble)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-slot="aui_edit-composer-root"]')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('opens the edit composer with the stock runtime', async () => {
|
||||
const { container } = render(<StockHarness onEdit={async () => {}} />)
|
||||
|
||||
const bubble = await screen.findByRole('button', { name: 'Edit message' })
|
||||
|
||||
fireEvent.click(bubble)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-slot="aui_edit-composer-root"]')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,65 +0,0 @@
|
||||
import type * as React from 'react'
|
||||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Solid (filled) glyphs for in-thread tool rows. Codicons are an outline icon
|
||||
// *font*, so an outline glyph has no separate fillable region — a filled look
|
||||
// can't be derived from it (stroke-thickening just bolds the outline). To get
|
||||
// the Cursor-style filled tool icons we render dedicated solid SVG paths,
|
||||
// keyed by the same names used in `TOOL_META` (tool-fallback-model.ts).
|
||||
//
|
||||
// Paths are Phosphor Icons (MIT) "fill" weight, 256×256 viewBox. Inlining the
|
||||
// path data mirrors the existing precedent in `directive-text.tsx`.
|
||||
const TOOL_ICON_PATHS: Record<string, string> = {
|
||||
diff: 'M118.18,213.08c-.11.14-.24.27-.36.4l-.16.18-.17.15a4.83,4.83,0,0,1-.42.37,3.92,3.92,0,0,1-.32.25l-.3.22-.38.23a2.91,2.91,0,0,1-.3.17l-.37.19-.34.15-.36.13a2.84,2.84,0,0,1-.38.13l-.36.1c-.14,0-.26.07-.4.09l-.42.07-.35.05a7,7,0,0,1-.79,0H64a8,8,0,0,1,0-16H92.69L55,162.34a23.85,23.85,0,0,1-7-17V95a32,32,0,1,1,16,0v50.38A8,8,0,0,0,66.34,151L104,188.69V160a8,8,0,0,1,16,0v48a7,7,0,0,1,0,.8c0,.11,0,.21,0,.32s0,.3-.07.46a2.83,2.83,0,0,1-.09.37c0,.13-.06.26-.1.39s-.08.23-.12.35l-.14.39-.15.31c-.06.13-.12.27-.19.4s-.11.18-.16.28l-.24.39-.21.28ZM208,161V110.63a23.85,23.85,0,0,0-7-17L163.31,56H192a8,8,0,0,0,0-16H143.82l-.6,0c-.14,0-.28,0-.41.06l-.37,0-.43.11-.33.08-.4.14-.34.13-.35.16-.36.18a3.14,3.14,0,0,0-.31.18c-.12.07-.25.14-.36.22a3.55,3.55,0,0,0-.31.23,3.81,3.81,0,0,0-.32.24c-.15.12-.28.24-.42.37l-.17.15-.16.18c-.12.13-.25.26-.36.4l-.26.35-.21.28-.24.39c-.05.1-.11.19-.16.28s-.13.27-.19.4l-.15.31-.14.39c0,.12-.09.23-.12.35s-.07.26-.1.39a2.83,2.83,0,0,0-.09.37c0,.16,0,.31-.07.46s0,.21-.05.32a7,7,0,0,0,0,.8V96a8,8,0,0,0,16,0V67.31L189.66,105a8,8,0,0,1,2.34,5.66V161a32,32,0,1,0,16,0Z',
|
||||
edit: 'M227.31,73.37,182.63,28.68a16,16,0,0,0-22.63,0L36.69,152A15.86,15.86,0,0,0,32,163.31V208a16,16,0,0,0,16,16H92.69A15.86,15.86,0,0,0,104,219.31L227.31,96a16,16,0,0,0,0-22.63ZM192,108.68,147.31,64l24-24L216,84.68Z',
|
||||
eye: 'M247.31,124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57,61.26,162.88,48,128,48S61.43,61.26,36.34,86.35C17.51,105.18,9,124,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208s66.57-13.26,91.66-38.34c18.83-18.83,27.3-37.61,27.65-38.4A8,8,0,0,0,247.31,124.76ZM128,168a40,40,0,1,1,40-40A40,40,0,0,1,128,168Z',
|
||||
file: 'M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM152,88V44l44,44Z',
|
||||
'file-media':
|
||||
'M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40ZM156,88a12,12,0,1,1-12,12A12,12,0,0,1,156,88Zm60,112H40V160.69l46.34-46.35a8,8,0,0,1,11.32,0h0L165,181.66a8,8,0,0,0,11.32-11.32l-17.66-17.65L173,138.34a8,8,0,0,1,11.31,0L216,170.07V200Z',
|
||||
files:
|
||||
'M213.66,66.34l-40-40A8,8,0,0,0,168,24H88A16,16,0,0,0,72,40V56H56A16,16,0,0,0,40,72V216a16,16,0,0,0,16,16H168a16,16,0,0,0,16-16V200h16a16,16,0,0,0,16-16V72A8,8,0,0,0,213.66,66.34ZM136,192H88a8,8,0,0,1,0-16h48a8,8,0,0,1,0,16Zm0-32H88a8,8,0,0,1,0-16h48a8,8,0,0,1,0,16Zm64,24H184V104a8,8,0,0,0-2.34-5.66l-40-40A8,8,0,0,0,136,56H88V40h76.69L200,75.31Z',
|
||||
globe:
|
||||
'M128,24h0A104,104,0,1,0,232,128,104.12,104.12,0,0,0,128,24Zm78.36,64H170.71a135.28,135.28,0,0,0-22.3-45.6A88.29,88.29,0,0,1,206.37,88ZM216,128a87.61,87.61,0,0,1-3.33,24H174.16a157.44,157.44,0,0,0,0-48h38.51A87.61,87.61,0,0,1,216,128ZM128,43a115.27,115.27,0,0,1,26,45H102A115.11,115.11,0,0,1,128,43ZM102,168H154a115.11,115.11,0,0,1-26,45A115.27,115.27,0,0,1,102,168Zm-3.9-16a140.84,140.84,0,0,1,0-48h59.88a140.84,140.84,0,0,1,0,48Zm50.35,61.6a135.28,135.28,0,0,0,22.3-45.6h35.66A88.29,88.29,0,0,1,148.41,213.6Z',
|
||||
question:
|
||||
'M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,168a12,12,0,1,1,12-12A12,12,0,0,1,128,192Zm8-48.72V144a8,8,0,0,1-16,0v-8a8,8,0,0,1,8-8c13.23,0,24-9,24-20s-10.77-20-24-20-24,9-24,20v4a8,8,0,0,1-16,0v-4c0-19.85,17.94-36,40-36s40,16.15,40,36C168,125.38,154.24,139.93,136,143.28Z',
|
||||
search:
|
||||
'M168,112a56,56,0,1,1-56-56A56,56,0,0,1,168,112Zm61.66,117.66a8,8,0,0,1-11.32,0l-50.06-50.07a88,88,0,1,1,11.32-11.31l50.06,50.06A8,8,0,0,1,229.66,229.66ZM112,184a72,72,0,1,0-72-72A72.08,72.08,0,0,0,112,184Z',
|
||||
terminal:
|
||||
'M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40Zm-91,94.25-40,32a8,8,0,1,1-10-12.5L107.19,128,75,102.25a8,8,0,1,1,10-12.5l40,32a8,8,0,0,1,0,12.5ZM176,168H136a8,8,0,0,1,0-16h40a8,8,0,0,1,0,16Z',
|
||||
tools:
|
||||
'M232,96a72,72,0,0,1-100.94,66L79,222.22c-.12.14-.26.29-.39.42a32,32,0,0,1-45.26-45.26c.14-.13.28-.27.43-.39L94,124.94a72.07,72.07,0,0,1,83.54-98.78,8,8,0,0,1,3.93,13.19L144,80l5.66,26.35L176,112l40.65-37.52a8,8,0,0,1,13.19,3.93A72.6,72.6,0,0,1,232,96Z',
|
||||
watch:
|
||||
'M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm56,112H128a8,8,0,0,1-8-8V72a8,8,0,0,1,16,0v48h48a8,8,0,0,1,0,16Z'
|
||||
}
|
||||
|
||||
export interface ToolIconProps {
|
||||
className?: string
|
||||
name: string
|
||||
size?: number | string
|
||||
}
|
||||
|
||||
/** Filled tool glyph. Falls back to the outline codicon font for any name not
|
||||
* covered by the solid set so new tools still render an icon. */
|
||||
export function ToolIcon({ className, name, size = '0.875rem' }: ToolIconProps) {
|
||||
const path = TOOL_ICON_PATHS[name]
|
||||
|
||||
if (!path) {
|
||||
return <Codicon className={className} name={name} size={size} />
|
||||
}
|
||||
|
||||
const dimension: React.CSSProperties = { height: size, width: size }
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={cn('shrink-0', className)}
|
||||
fill="currentColor"
|
||||
style={dimension}
|
||||
viewBox="0 0 256 256"
|
||||
>
|
||||
<path d={path} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { FIELD_DESCRIPTIONS, FIELD_LABELS } from '@/app/settings/constants'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
|
||||
import type { Translations } from './types'
|
||||
|
||||
@@ -519,7 +518,7 @@ export const en: Translations = {
|
||||
loading: 'Loading archived sessions…',
|
||||
archivedTitle: 'Archived sessions',
|
||||
archivedIntro:
|
||||
`Archived chats are hidden from the sidebar but keep all their messages. ${formatCombo('mod')}-click a chat in the sidebar to archive it.`,
|
||||
'Archived chats are hidden from the sidebar but keep all their messages. Ctrl/⌘-click a chat in the sidebar to archive it.',
|
||||
emptyArchivedTitle: 'Nothing archived',
|
||||
emptyArchivedDesc: 'Archive a chat to hide it here.',
|
||||
unarchive: 'Unarchive',
|
||||
@@ -530,7 +529,7 @@ export const en: Translations = {
|
||||
defaultDirTitle: 'Default project directory',
|
||||
defaultDirDesc:
|
||||
'New sessions start in this folder unless you pick another. Leave it unset to use your home directory.',
|
||||
defaultDirUpdated: `Default project directory updated — start a new chat (${formatCombo('mod+n')}) for it to take effect`,
|
||||
defaultDirUpdated: 'Default project directory updated — start a new chat (Ctrl/⌘+N) for it to take effect',
|
||||
defaultsTo: label => `Defaults to ${label}.`,
|
||||
change: 'Change',
|
||||
choose: 'Choose',
|
||||
@@ -1533,6 +1532,9 @@ export const en: Translations = {
|
||||
terminal: 'Terminal',
|
||||
noFolderSelected: 'No folder selected',
|
||||
changeCwdTitle: 'Change working directory',
|
||||
remotePickerTitle: 'Choose remote folder',
|
||||
remotePickerDescription: 'Browse folders on the connected backend.',
|
||||
remotePickerSelect: 'Select folder',
|
||||
folderTip: cwd => `${cwd} — click to change folder`,
|
||||
openFolder: 'Open folder',
|
||||
refreshTree: 'Refresh tree',
|
||||
@@ -1678,7 +1680,7 @@ export const en: Translations = {
|
||||
loadingQuestion: 'Loading question…',
|
||||
other: 'Other (type your answer)',
|
||||
placeholder: 'Type your answer…',
|
||||
shortcut: `${formatCombo('mod+enter')} to send`,
|
||||
shortcut: '⌘/Ctrl + Enter to send',
|
||||
back: 'Back',
|
||||
skip: 'Skip',
|
||||
send: 'Send'
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { defineFieldCopy } from '@/app/settings/field-copy'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
|
||||
import { defineLocale } from './define-locale'
|
||||
|
||||
@@ -643,7 +642,7 @@ export const ja = defineLocale({
|
||||
loading: 'アーカイブ済みセッションを読み込み中…',
|
||||
archivedTitle: 'アーカイブ済みセッション',
|
||||
archivedIntro:
|
||||
`アーカイブ済みチャットはサイドバーでは非表示になりますが、すべてのメッセージは保持されます。サイドバーのチャットを ${formatCombo('mod')} クリックするとアーカイブできます。`,
|
||||
'アーカイブ済みチャットはサイドバーでは非表示になりますが、すべてのメッセージは保持されます。サイドバーのチャットを Ctrl/⌘ クリックするとアーカイブできます。',
|
||||
emptyArchivedTitle: 'アーカイブがありません',
|
||||
emptyArchivedDesc: 'チャットをアーカイブするとここに表示されます。',
|
||||
unarchive: 'アーカイブを解除',
|
||||
@@ -1666,6 +1665,9 @@ export const ja = defineLocale({
|
||||
terminal: 'ターミナル',
|
||||
noFolderSelected: 'フォルダーが選択されていません',
|
||||
changeCwdTitle: '作業ディレクトリを変更',
|
||||
remotePickerTitle: 'リモートフォルダーを選択',
|
||||
remotePickerDescription: '接続中のバックエンド上のフォルダーを参照します。',
|
||||
remotePickerSelect: 'フォルダーを選択',
|
||||
folderTip: cwd => `${cwd} — クリックしてフォルダーを変更`,
|
||||
openFolder: 'フォルダーを開く',
|
||||
refreshTree: 'ツリーを更新',
|
||||
@@ -1812,7 +1814,7 @@ export const ja = defineLocale({
|
||||
loadingQuestion: '質問を読み込み中…',
|
||||
other: 'その他(回答を入力)',
|
||||
placeholder: '回答を入力…',
|
||||
shortcut: `${formatCombo('mod+enter')} で送信`,
|
||||
shortcut: '⌘/Ctrl + Enter で送信',
|
||||
back: '戻る',
|
||||
skip: 'スキップ',
|
||||
send: '送信'
|
||||
|
||||
@@ -1194,6 +1194,9 @@ export interface Translations {
|
||||
terminal: string
|
||||
noFolderSelected: string
|
||||
changeCwdTitle: string
|
||||
remotePickerTitle: string
|
||||
remotePickerDescription: string
|
||||
remotePickerSelect: string
|
||||
folderTip: (cwd: string) => string
|
||||
openFolder: string
|
||||
refreshTree: string
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { defineFieldCopy } from '@/app/settings/field-copy'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
|
||||
import { defineLocale } from './define-locale'
|
||||
|
||||
@@ -628,7 +627,7 @@ export const zhHant = defineLocale({
|
||||
loading: '正在載入已封存工作階段…',
|
||||
archivedTitle: '已封存工作階段',
|
||||
archivedIntro:
|
||||
`已封存的聊天會從側邊欄隱藏,但保留全部訊息。在側邊欄 ${formatCombo('mod')} 點擊聊天即可封存。`,
|
||||
'已封存的聊天會從側邊欄隱藏,但保留全部訊息。在側邊欄 Ctrl/⌘ 點擊聊天即可封存。',
|
||||
emptyArchivedTitle: '暫無封存',
|
||||
emptyArchivedDesc: '封存一個聊天後會顯示在這裡。',
|
||||
unarchive: '取消封存',
|
||||
@@ -1627,6 +1626,9 @@ export const zhHant = defineLocale({
|
||||
terminal: '終端機',
|
||||
noFolderSelected: '未選擇資料夾',
|
||||
changeCwdTitle: '變更工作目錄',
|
||||
remotePickerTitle: '選擇遠端資料夾',
|
||||
remotePickerDescription: '瀏覽已連線後端上的資料夾。',
|
||||
remotePickerSelect: '選擇資料夾',
|
||||
folderTip: cwd => `${cwd} — 點擊以變更資料夾`,
|
||||
openFolder: '開啟資料夾',
|
||||
refreshTree: '重新整理檔案樹',
|
||||
@@ -1773,7 +1775,7 @@ export const zhHant = defineLocale({
|
||||
loadingQuestion: '正在載入問題…',
|
||||
other: '其他(輸入您的答案)',
|
||||
placeholder: '輸入您的答案…',
|
||||
shortcut: `${formatCombo('mod+enter')} 傳送`,
|
||||
shortcut: '⌘/Ctrl + Enter 傳送',
|
||||
back: '返回',
|
||||
skip: '略過',
|
||||
send: '傳送'
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { defineFieldCopy } from '@/app/settings/field-copy'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
|
||||
import type { Translations } from './types'
|
||||
|
||||
@@ -713,7 +712,7 @@ export const zh: Translations = {
|
||||
sessions: {
|
||||
loading: '正在加载已归档会话…',
|
||||
archivedTitle: '已归档会话',
|
||||
archivedIntro: `已归档对话会从侧边栏隐藏,但会保留全部消息。在侧边栏 ${formatCombo('mod')} 点击对话即可归档。`,
|
||||
archivedIntro: '已归档对话会从侧边栏隐藏,但会保留全部消息。在侧边栏 Ctrl/⌘ 点击对话即可归档。',
|
||||
emptyArchivedTitle: '暂无归档',
|
||||
emptyArchivedDesc: '归档一个对话后会显示在这里。',
|
||||
unarchive: '取消归档',
|
||||
@@ -1713,6 +1712,9 @@ export const zh: Translations = {
|
||||
terminal: '终端',
|
||||
noFolderSelected: '未选择文件夹',
|
||||
changeCwdTitle: '更改工作目录',
|
||||
remotePickerTitle: '选择远程文件夹',
|
||||
remotePickerDescription: '浏览已连接后端上的文件夹。',
|
||||
remotePickerSelect: '选择文件夹',
|
||||
folderTip: cwd => `${cwd} — 点击更改文件夹`,
|
||||
openFolder: '打开文件夹',
|
||||
refreshTree: '刷新文件树',
|
||||
@@ -1857,7 +1859,7 @@ export const zh: Translations = {
|
||||
loadingQuestion: '正在加载问题…',
|
||||
other: '其他 (输入你的答案)',
|
||||
placeholder: '输入你的答案…',
|
||||
shortcut: `${formatCombo('mod+enter')} 发送`,
|
||||
shortcut: '⌘/Ctrl + Enter 发送',
|
||||
back: '返回',
|
||||
skip: '跳过',
|
||||
send: '发送'
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
import {
|
||||
desktopDefaultCwd,
|
||||
desktopGitRoot,
|
||||
readDesktopDir,
|
||||
readDesktopFileDataUrl,
|
||||
readDesktopFileText,
|
||||
selectDesktopPaths,
|
||||
setDesktopFsRemotePicker
|
||||
} from './desktop-fs'
|
||||
|
||||
const readDir = vi.fn(async () => ({ entries: [{ name: 'local', path: '/local', isDirectory: true }] }))
|
||||
const readFileText = vi.fn(async () => ({ path: '/local/file.txt', text: 'local', byteSize: 5 }))
|
||||
const readFileDataUrl = vi.fn(async () => 'data:text/plain;base64,bG9jYWw=')
|
||||
const gitRoot = vi.fn(async () => '/local')
|
||||
const selectPaths = vi.fn(async () => ['/local'])
|
||||
const api = vi.fn(async ({ path }: { path: string }) => {
|
||||
if (path.startsWith('/api/fs/list?')) return { entries: [{ name: 'remote', path: '/remote', isDirectory: true }] }
|
||||
if (path.startsWith('/api/fs/read-text?')) return { path: '/remote/file.txt', text: 'remote', byteSize: 6 }
|
||||
if (path.startsWith('/api/fs/read-data-url?')) return { dataUrl: 'data:text/plain;base64,cmVtb3Rl' }
|
||||
if (path.startsWith('/api/fs/git-root?')) return { root: '/remote' }
|
||||
if (path === '/api/fs/default-cwd') return { cwd: '/backend/project', branch: 'main' }
|
||||
throw new Error(`unexpected path ${path}`)
|
||||
})
|
||||
|
||||
function stubBridge() {
|
||||
vi.stubGlobal('window', {
|
||||
hermesDesktop: {
|
||||
api,
|
||||
gitRoot,
|
||||
readDir,
|
||||
readFileDataUrl,
|
||||
readFileText,
|
||||
selectPaths
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('desktop filesystem facade', () => {
|
||||
beforeEach(() => {
|
||||
stubBridge()
|
||||
$connection.set(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.clearAllMocks()
|
||||
$connection.set(null)
|
||||
setDesktopFsRemotePicker(null)
|
||||
})
|
||||
|
||||
it('uses local Electron filesystem methods in local mode', async () => {
|
||||
$connection.set({ mode: 'local' } as never)
|
||||
|
||||
await expect(readDesktopDir('/work')).resolves.toEqual({ entries: [{ name: 'local', path: '/local', isDirectory: true }] })
|
||||
await expect(readDesktopFileText('/work/file.txt')).resolves.toMatchObject({ text: 'local' })
|
||||
await expect(readDesktopFileDataUrl('/work/file.txt')).resolves.toBe('data:text/plain;base64,bG9jYWw=')
|
||||
await expect(desktopGitRoot('/work')).resolves.toBe('/local')
|
||||
await expect(selectDesktopPaths({ directories: true })).resolves.toEqual(['/local'])
|
||||
|
||||
expect(readDir).toHaveBeenCalledWith('/work')
|
||||
expect(readFileText).toHaveBeenCalledWith('/work/file.txt')
|
||||
expect(readFileDataUrl).toHaveBeenCalledWith('/work/file.txt')
|
||||
expect(gitRoot).toHaveBeenCalledWith('/work')
|
||||
expect(selectPaths).toHaveBeenCalledWith({ directories: true })
|
||||
expect(api).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes filesystem reads through authenticated backend REST in remote mode', async () => {
|
||||
$connection.set({ mode: 'remote' } as never)
|
||||
|
||||
await expect(readDesktopDir('/home/user/project')).resolves.toMatchObject({ entries: [{ name: 'remote' }] })
|
||||
await expect(readDesktopFileText('/home/user/project/a b.txt')).resolves.toMatchObject({ text: 'remote' })
|
||||
await expect(readDesktopFileDataUrl('/home/user/project/a b.txt')).resolves.toBe('data:text/plain;base64,cmVtb3Rl')
|
||||
await expect(desktopGitRoot('/home/user/project')).resolves.toBe('/remote')
|
||||
await expect(desktopDefaultCwd()).resolves.toEqual({ cwd: '/backend/project', branch: 'main' })
|
||||
|
||||
expect(api).toHaveBeenCalledWith({ path: '/api/fs/list?path=%2Fhome%2Fuser%2Fproject' })
|
||||
expect(api).toHaveBeenCalledWith({ path: '/api/fs/read-text?path=%2Fhome%2Fuser%2Fproject%2Fa%20b.txt' })
|
||||
expect(api).toHaveBeenCalledWith({ path: '/api/fs/read-data-url?path=%2Fhome%2Fuser%2Fproject%2Fa%20b.txt' })
|
||||
expect(api).toHaveBeenCalledWith({ path: '/api/fs/git-root?path=%2Fhome%2Fuser%2Fproject' })
|
||||
expect(api).toHaveBeenCalledWith({ path: '/api/fs/default-cwd' })
|
||||
expect(readDir).not.toHaveBeenCalled()
|
||||
expect(readFileText).not.toHaveBeenCalled()
|
||||
expect(readFileDataUrl).not.toHaveBeenCalled()
|
||||
expect(gitRoot).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the registered in-app directory picker in remote mode', async () => {
|
||||
const remoteSelect = vi.fn(async () => ['/remote/project'])
|
||||
$connection.set({ mode: 'remote' } as never)
|
||||
setDesktopFsRemotePicker({ selectPaths: remoteSelect })
|
||||
|
||||
await expect(selectDesktopPaths({ defaultPath: '/remote', directories: true, multiple: false })).resolves.toEqual([
|
||||
'/remote/project'
|
||||
])
|
||||
|
||||
expect(remoteSelect).toHaveBeenCalledWith({ defaultPath: '/remote', directories: true, multiple: false })
|
||||
expect(selectPaths).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not treat the remote directory picker as a general file picker', async () => {
|
||||
const remoteSelect = vi.fn(async () => ['/remote/project'])
|
||||
$connection.set({ mode: 'remote' } as never)
|
||||
setDesktopFsRemotePicker({ selectPaths: remoteSelect })
|
||||
|
||||
await expect(selectDesktopPaths({ directories: false, multiple: false })).resolves.toEqual([])
|
||||
await expect(selectDesktopPaths({ directories: true, multiple: true })).resolves.toEqual([])
|
||||
|
||||
expect(remoteSelect).not.toHaveBeenCalled()
|
||||
expect(selectPaths).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
import type { HermesConnection, HermesReadDirResult, HermesReadFileTextResult, HermesSelectPathsOptions } from '@/global'
|
||||
|
||||
export interface DesktopFsRemotePicker {
|
||||
selectPaths: (options?: HermesSelectPathsOptions) => Promise<string[]>
|
||||
}
|
||||
|
||||
let remotePicker: DesktopFsRemotePicker | null = null
|
||||
|
||||
export function setDesktopFsRemotePicker(next: DesktopFsRemotePicker | null) {
|
||||
remotePicker = next
|
||||
}
|
||||
|
||||
function connectionCacheKey(connection: HermesConnection | null) {
|
||||
if (!connection) {
|
||||
return 'local:'
|
||||
}
|
||||
return `${connection.mode || 'local'}:${connection.profile || ''}:${connection.baseUrl || ''}`
|
||||
}
|
||||
|
||||
export function desktopFsCacheKey() {
|
||||
return connectionCacheKey($connection.get())
|
||||
}
|
||||
|
||||
export function isDesktopFsRemoteMode() {
|
||||
return $connection.get()?.mode === 'remote'
|
||||
}
|
||||
|
||||
function fsPath(endpoint: string, filePath: string) {
|
||||
return `/api/fs/${endpoint}?path=${encodeURIComponent(filePath)}`
|
||||
}
|
||||
|
||||
function bridge() {
|
||||
const desktop = window.hermesDesktop
|
||||
if (!desktop) {
|
||||
throw new Error('Hermes Desktop bridge is unavailable')
|
||||
}
|
||||
return desktop
|
||||
}
|
||||
|
||||
export async function readDesktopDir(path: string): Promise<HermesReadDirResult> {
|
||||
const desktop = bridge()
|
||||
if (!isDesktopFsRemoteMode()) {
|
||||
return desktop.readDir(path)
|
||||
}
|
||||
return desktop.api<HermesReadDirResult>({ path: fsPath('list', path) })
|
||||
}
|
||||
|
||||
export async function readDesktopFileText(path: string): Promise<HermesReadFileTextResult> {
|
||||
const desktop = bridge()
|
||||
if (!isDesktopFsRemoteMode()) {
|
||||
return desktop.readFileText(path)
|
||||
}
|
||||
return desktop.api<HermesReadFileTextResult>({ path: fsPath('read-text', path) })
|
||||
}
|
||||
|
||||
export async function readDesktopFileDataUrl(path: string): Promise<string> {
|
||||
const desktop = bridge()
|
||||
if (!isDesktopFsRemoteMode()) {
|
||||
return desktop.readFileDataUrl(path)
|
||||
}
|
||||
|
||||
const result = await desktop.api<string | { dataUrl?: string }>({ path: fsPath('read-data-url', path) })
|
||||
return typeof result === 'string' ? result : result.dataUrl || ''
|
||||
}
|
||||
|
||||
export async function desktopGitRoot(path: string): Promise<string | null> {
|
||||
const desktop = bridge()
|
||||
if (!isDesktopFsRemoteMode()) {
|
||||
return desktop.gitRoot ? desktop.gitRoot(path) : null
|
||||
}
|
||||
|
||||
const result = await desktop.api<{ root: string | null }>({ path: fsPath('git-root', path) })
|
||||
return result.root
|
||||
}
|
||||
|
||||
export async function desktopDefaultCwd(): Promise<{ branch: string; cwd: string } | null> {
|
||||
if (!isDesktopFsRemoteMode()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return bridge().api<{ branch: string; cwd: string }>({ path: '/api/fs/default-cwd' })
|
||||
}
|
||||
|
||||
export async function selectDesktopPaths(options?: HermesSelectPathsOptions): Promise<string[]> {
|
||||
const desktop = bridge()
|
||||
if (!isDesktopFsRemoteMode()) {
|
||||
return desktop.selectPaths(options)
|
||||
}
|
||||
if (!options?.directories || options.multiple !== false) {
|
||||
return []
|
||||
}
|
||||
return remotePicker ? remotePicker.selectPaths(options) : []
|
||||
}
|
||||
@@ -5,9 +5,6 @@
|
||||
// like navigate / theme); labels come from i18n (`t.keybinds.actions[id]`). To
|
||||
// add a hotkey, add a row here and a handler there — nothing else.
|
||||
|
||||
import type { Combo, FakeCombo } from "./combo";
|
||||
|
||||
|
||||
export type KeybindCategory = 'composer' | 'profiles' | 'session' | 'navigation' | 'view'
|
||||
|
||||
// The self-referential opener — bound + dispatched like any action, but shown in
|
||||
@@ -30,16 +27,15 @@ export interface KeybindActionMeta {
|
||||
// `profile.default`) — ⌘` is macOS-reserved (window cycling) and ⌘0 is reset-zoom.
|
||||
export const PROFILE_SLOT_COUNT = 18
|
||||
|
||||
const PROFILE_SWITCH_ACTIONS: KeybindActionMeta[] = Array.from({ length: PROFILE_SLOT_COUNT }, (_, i) => {
|
||||
const slot = i+1
|
||||
const combo = (slot <= 9 ? `mod+${slot}` : `mod+alt+${slot - 9}`) as Combo
|
||||
function comboForSlot(slot: number): string {
|
||||
return slot <= 9 ? `mod+${slot}` : `mod+alt+${slot - 9}`
|
||||
}
|
||||
|
||||
return ({
|
||||
id: `profile.switch.${i + 1}`,
|
||||
category: 'profiles' as const,
|
||||
defaults: [combo]
|
||||
})
|
||||
})
|
||||
const PROFILE_SWITCH_ACTIONS: KeybindActionMeta[] = Array.from({ length: PROFILE_SLOT_COUNT }, (_, i) => ({
|
||||
id: `profile.switch.${i + 1}`,
|
||||
category: 'profiles' as const,
|
||||
defaults: [comboForSlot(i + 1)]
|
||||
}))
|
||||
|
||||
// ⌘` on macOS / Ctrl+` elsewhere (the `~` key), plus the Shift/tilde variant.
|
||||
// `mod` keeps one binding cross-platform; on macOS this shadows the system
|
||||
@@ -108,12 +104,10 @@ export function keybindAction(id: string): KeybindActionMeta | undefined {
|
||||
return ACTION_BY_ID.get(id)
|
||||
}
|
||||
|
||||
export type KeybindBindings = Record<string, Combo[]>
|
||||
export type KeybindBindings = Record<string, string[]>
|
||||
|
||||
export function defaultBindings(): KeybindBindings {
|
||||
return Object.fromEntries<string, Combo[]>(
|
||||
KEYBIND_ACTIONS.map(action => [action.id, [...action.defaults] as Combo[]])
|
||||
)
|
||||
return Object.fromEntries(KEYBIND_ACTIONS.map(action => [action.id, [...action.defaults]]))
|
||||
}
|
||||
|
||||
// Fixed, non-rebindable shortcuts surfaced read-only in the panel so the map is
|
||||
@@ -123,7 +117,7 @@ export function defaultBindings(): KeybindBindings {
|
||||
export interface KeybindReadonly {
|
||||
id: string
|
||||
category: KeybindCategory
|
||||
keys: readonly FakeCombo[]
|
||||
keys: readonly string[]
|
||||
}
|
||||
|
||||
export const KEYBIND_READONLY: readonly KeybindReadonly[] = [
|
||||
|
||||
@@ -10,13 +10,11 @@
|
||||
// Control+Tab. Off macOS, Control already *is* `mod`, so `canonicalizeCombo`
|
||||
// folds `ctrl` → `mod`.
|
||||
|
||||
const IS_MAC = typeof navigator !== 'undefined' && /mac/i.test(navigator.platform || navigator.userAgent || '')
|
||||
|
||||
export const modKey = IS_MAC ? 'metaKey' as const : 'ctrlKey' as const
|
||||
export const IS_MAC = typeof navigator !== 'undefined' && /mac/i.test(navigator.platform || navigator.userAgent || '')
|
||||
|
||||
// event.code → canonical base token. Letters/digits map to their lowercase
|
||||
// character; everything else uses an explicit name so combos read cleanly.
|
||||
const CODE_TO_KEY = {
|
||||
const CODE_TO_KEY: Record<string, string> = {
|
||||
Backquote: '`',
|
||||
Backslash: '\\',
|
||||
BracketLeft: '[',
|
||||
@@ -37,50 +35,8 @@ const CODE_TO_KEY = {
|
||||
ArrowDown: 'down',
|
||||
ArrowLeft: 'left',
|
||||
ArrowRight: 'right'
|
||||
} as const satisfies Record<Capitalize<string>, Lowercase<string>>
|
||||
|
||||
type SpecialKey = typeof CODE_TO_KEY[keyof typeof CODE_TO_KEY]
|
||||
|
||||
type Alpha = 'a'|'b'|'c'|'d'|'e'|'f'|'g'|'h'|'i'|'j'|'k'|'l'|'m'
|
||||
| 'n'|'o'|'p'|'q'|'r'|'s'|'t'|'u'|'v'|'w'|'x'|'y'|'z'
|
||||
|
||||
export type Digit = '0'|'1'|'2'|'3'|'4'|'5'|'6'|'7'|'8'|'9'
|
||||
|
||||
|
||||
type FKey =
|
||||
| 'f1' | 'f2' | 'f3' | 'f4' | 'f5' | 'f6'
|
||||
| 'f7' | 'f8' | 'f9' | 'f10' | 'f11' | 'f12'
|
||||
| 'f13' | 'f14' | 'f15' | 'f16' | 'f17' | 'f18'
|
||||
| 'f19' | 'f20' | 'f21' | 'f22' | 'f23' | 'f24'
|
||||
|
||||
type BaseKey = Alpha | Digit | FKey | SpecialKey
|
||||
|
||||
// subset of https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values
|
||||
type KeyCode = Uppercase<FKey> | `Digit${Digit}` | `Key${Uppercase<Alpha>}` | keyof typeof CODE_TO_KEY
|
||||
|
||||
function baseKeyFromCode(code: KeyCode): BaseKey | null {
|
||||
if (code.startsWith('Key')) {
|
||||
return code.slice(3).toLowerCase() as Alpha
|
||||
}
|
||||
|
||||
if (code.startsWith('Digit')) {
|
||||
return code.slice(5) as Digit
|
||||
}
|
||||
|
||||
if (code.startsWith('Numpad')) {
|
||||
const rest = code.slice(6)
|
||||
|
||||
return /^[0-9]$/.test(rest) ? rest as Digit : null
|
||||
}
|
||||
|
||||
if (code.startsWith('F') && /^F\d{1,2}$/.test(code)) {
|
||||
return code.toLowerCase() as FKey
|
||||
}
|
||||
|
||||
return CODE_TO_KEY[code as keyof typeof CODE_TO_KEY] ?? null
|
||||
}
|
||||
|
||||
|
||||
const MODIFIER_CODES = new Set([
|
||||
'AltLeft',
|
||||
'AltRight',
|
||||
@@ -92,20 +48,42 @@ const MODIFIER_CODES = new Set([
|
||||
'ShiftRight'
|
||||
])
|
||||
|
||||
function baseKeyFromCode(code: string): string | null {
|
||||
if (code.startsWith('Key')) {
|
||||
return code.slice(3).toLowerCase()
|
||||
}
|
||||
|
||||
if (code.startsWith('Digit')) {
|
||||
return code.slice(5)
|
||||
}
|
||||
|
||||
if (code.startsWith('Numpad')) {
|
||||
const rest = code.slice(6)
|
||||
|
||||
return /^[0-9]$/.test(rest) ? rest : null
|
||||
}
|
||||
|
||||
if (code.startsWith('F') && /^F\d{1,2}$/.test(code)) {
|
||||
return code.toLowerCase()
|
||||
}
|
||||
|
||||
return CODE_TO_KEY[code] ?? null
|
||||
}
|
||||
|
||||
// Returns the canonical combo for a keydown, or null while only modifiers are
|
||||
// held (so capture mode keeps waiting for a real key).
|
||||
export function comboFromEvent(event: KeyboardEvent): Combo | null {
|
||||
export function comboFromEvent(event: KeyboardEvent): string | null {
|
||||
if (MODIFIER_CODES.has(event.code)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const base = baseKeyFromCode(event.code as KeyCode)
|
||||
const base = baseKeyFromCode(event.code)
|
||||
|
||||
if (!base) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parts: Combo[] = []
|
||||
const parts: string[] = []
|
||||
|
||||
// macOS reports Cmd (`mod`) and Control (`ctrl`) separately; elsewhere
|
||||
// Control IS the accelerator, so it folds into `mod`.
|
||||
@@ -127,7 +105,7 @@ export function comboFromEvent(event: KeyboardEvent): Combo | null {
|
||||
|
||||
parts.push(base)
|
||||
|
||||
return parts.join('+') as Combo
|
||||
return parts.join('+')
|
||||
}
|
||||
|
||||
// Rewrites a binding to the form `comboFromEvent` emits, so it indexes under
|
||||
@@ -137,14 +115,7 @@ export function canonicalizeCombo(combo: string): string {
|
||||
return IS_MAC ? combo : combo.replace(/\bctrl\b/g, 'mod')
|
||||
}
|
||||
|
||||
const MOD_LABELS = {
|
||||
mod: IS_MAC ? '⌘' : 'Ctrl',
|
||||
ctrl: IS_MAC ? '⌃' : 'Ctrl',
|
||||
alt: IS_MAC ? '⌥' : 'Alt',
|
||||
shift: IS_MAC ? '⇧' : 'Shift'
|
||||
} as const
|
||||
|
||||
const FANCY_KEY_LABELS = {
|
||||
const TOKEN_LABELS: Record<string, string> = {
|
||||
enter: '↵',
|
||||
escape: 'Esc',
|
||||
backspace: '⌫',
|
||||
@@ -153,47 +124,39 @@ const FANCY_KEY_LABELS = {
|
||||
up: '↑',
|
||||
down: '↓',
|
||||
left: '←',
|
||||
right: '→',
|
||||
} as const
|
||||
|
||||
const TOKEN_LABELS: Record<string, string> = {
|
||||
...MOD_LABELS,
|
||||
...FANCY_KEY_LABELS
|
||||
right: '→'
|
||||
}
|
||||
|
||||
function labelForToken(token: string): string {
|
||||
if (TOKEN_LABELS[token]) {
|
||||
return TOKEN_LABELS[token]
|
||||
function labelForBase(base: string): string {
|
||||
if (TOKEN_LABELS[base]) {
|
||||
return TOKEN_LABELS[base]
|
||||
}
|
||||
|
||||
if (/^f\d{1,2}$/.test(token)) {
|
||||
return token.toUpperCase()
|
||||
if (/^f\d{1,2}$/.test(base)) {
|
||||
return base.toUpperCase()
|
||||
}
|
||||
|
||||
return token.length === 1 ? token.toUpperCase() : token
|
||||
return base.length === 1 ? base.toUpperCase() : base
|
||||
}
|
||||
|
||||
//
|
||||
function labelForMod(mod: string): string {
|
||||
if (mod === 'mod') {
|
||||
return IS_MAC ? '⌘' : 'Ctrl'
|
||||
}
|
||||
|
||||
type ModKey = keyof typeof MOD_LABELS
|
||||
if (mod === 'ctrl') {
|
||||
return IS_MAC ? '⌃' : 'Ctrl'
|
||||
}
|
||||
|
||||
type ModPrefix = `${'mod+'|''}${'alt+'|''}${'shift+'|''}`
|
||||
if (mod === 'alt') {
|
||||
return IS_MAC ? '⌥' : 'Alt'
|
||||
}
|
||||
|
||||
type ModPrefixedCombo<Suffix extends string> =
|
||||
| `${ModPrefix}${Suffix}`
|
||||
| ModKey
|
||||
| 'mod+alt' | 'mod+shift' | 'alt+shift' | 'mod+alt+shift'
|
||||
| 'ctrl+tab' | 'ctrl+shift+tab'
|
||||
| `ctrl+${Digit}`
|
||||
if (mod === 'shift') {
|
||||
return IS_MAC ? '⇧' : 'Shift'
|
||||
}
|
||||
|
||||
export type Combo = ModPrefixedCombo<BaseKey>
|
||||
export type FakeCombo = ModPrefixedCombo<BaseKey | '@' | '?'>
|
||||
|
||||
// Human-readable keys, e.g. "mod+shift+k" returns ["⌘","⇧","K"] on macos, ["Ctrl","Shift","K"] elsewhere.
|
||||
export function normalizeCombo(combo: Combo): string[] {
|
||||
const parts = combo.split('+')
|
||||
|
||||
return parts.map(p => labelForToken(p.trim()))
|
||||
return mod
|
||||
}
|
||||
|
||||
// Per-key display tokens, e.g. ["⌘", "K"] on macOS, ["Ctrl", "K"] elsewhere —
|
||||
@@ -202,18 +165,14 @@ export function comboTokens(combo: string): string[] {
|
||||
const parts = combo.split('+')
|
||||
const base = parts.pop() ?? ''
|
||||
|
||||
return [...parts.map(labelForToken), labelForToken(base)]
|
||||
return [...parts.map(labelForMod), labelForBase(base)]
|
||||
}
|
||||
|
||||
// Human-readable label, e.g. "mod+shift+k" returns "⌘⇧K" on macOS, "Ctrl+Shift+K" elsewhere.
|
||||
export function formatCombo(combo: Combo): string {
|
||||
return normalizeCombo(combo).join(IS_MAC ? '' : '+')
|
||||
}
|
||||
// Human-readable label, e.g. "⌘⇧K" on macOS, "Ctrl+Shift+K" elsewhere.
|
||||
export function formatCombo(combo: string): string {
|
||||
const tokens = comboTokens(combo)
|
||||
|
||||
|
||||
// like `formatCombo` but allows any input like `@`
|
||||
export function formatFakeCombo(combo: FakeCombo): string {
|
||||
return normalizeCombo(combo as Combo).join(IS_MAC ? '' : '+')
|
||||
return IS_MAC ? tokens.join('') : tokens.join('+')
|
||||
}
|
||||
|
||||
// True when focus is in a text-entry surface, so bare-key shortcuts don't fire
|
||||
@@ -231,6 +190,6 @@ export function isEditableTarget(target: EventTarget | null): boolean {
|
||||
|
||||
// A primary modifier (Cmd/Ctrl/Control) fires even while typing (e.g. ⌘K or
|
||||
// ⌃Tab from the composer); bare/Shift-only combos are suppressed in inputs.
|
||||
export function comboAllowedInInput(combo: Combo): boolean {
|
||||
export function comboAllowedInInput(combo: string): boolean {
|
||||
return /^(?:mod|ctrl)(?:\+|$)/.test(combo)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isDesktopFsRemoteMode, readDesktopFileText } from '@/lib/desktop-fs'
|
||||
import type { PreviewTarget } from '@/store/preview'
|
||||
|
||||
const HTML_EXTENSIONS = new Set(['.htm', '.html'])
|
||||
@@ -107,6 +108,26 @@ export function localPreviewTarget(rawTarget: string, cwd?: string | null): Prev
|
||||
}
|
||||
}
|
||||
|
||||
async function enrichPreviewTarget(target: PreviewTarget | null): Promise<PreviewTarget | null> {
|
||||
if (!isDesktopFsRemoteMode() || !target || target.kind !== 'file' || target.previewKind === 'image') {
|
||||
return target
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await readDesktopFileText(target.path || target.source)
|
||||
return {
|
||||
...target,
|
||||
binary: result.binary,
|
||||
byteSize: result.byteSize,
|
||||
language: result.language || target.language,
|
||||
large: (result.byteSize ?? 0) > 512 * 1024,
|
||||
mimeType: result.mimeType
|
||||
}
|
||||
} catch {
|
||||
return target
|
||||
}
|
||||
}
|
||||
|
||||
export async function normalizeOrLocalPreviewTarget(
|
||||
rawTarget: string,
|
||||
cwd?: string | null
|
||||
@@ -115,12 +136,12 @@ export async function normalizeOrLocalPreviewTarget(
|
||||
const normalized = await window.hermesDesktop?.normalizePreviewTarget?.(rawTarget, cwd || undefined)
|
||||
|
||||
if (normalized) {
|
||||
return normalized
|
||||
return enrichPreviewTarget(normalized)
|
||||
}
|
||||
} catch {
|
||||
// Running Electron may still have the old HTML-only preview IPC. Fall
|
||||
// through to renderer-side local classification so text/images still open.
|
||||
}
|
||||
|
||||
return localPreviewTarget(rawTarget, cwd)
|
||||
return enrichPreviewTarget(localPreviewTarget(rawTarget, cwd))
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
type KeybindBindings
|
||||
} from '@/lib/keybinds/actions'
|
||||
import { canonicalizeCombo } from '@/lib/keybinds/combo'
|
||||
import type { Combo } from '@/lib/keybinds/combo'
|
||||
import { arraysEqual, persistString, storedString } from '@/lib/storage'
|
||||
|
||||
const STORAGE_KEY = 'hermes.desktop.keybinds'
|
||||
@@ -29,7 +28,7 @@ function loadBindings(): KeybindBindings {
|
||||
const value = parsed[id]
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
base[id] = value.filter((combo): combo is string => typeof combo === 'string') as Combo[]
|
||||
base[id] = value.filter((combo): combo is string => typeof combo === 'string')
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -79,7 +78,7 @@ export const $comboIndex = computed($bindings, bindings => {
|
||||
return index
|
||||
})
|
||||
|
||||
export function setBinding(actionId: string, combos: Combo[]): void {
|
||||
export function setBinding(actionId: string, combos: string[]): void {
|
||||
if (!keybindAction(actionId)) {
|
||||
return
|
||||
}
|
||||
@@ -102,7 +101,7 @@ export function resetAllBindings(): void {
|
||||
}
|
||||
|
||||
// Other actions that already use `combo` (excluding `actionId` itself).
|
||||
export function conflictsFor(actionId: string, combo: Combo): string[] {
|
||||
export function conflictsFor(actionId: string, combo: string): string[] {
|
||||
const bindings = $bindings.get()
|
||||
|
||||
return KEYBIND_ACTION_IDS.filter(id => id !== actionId && (bindings[id] ?? []).includes(combo))
|
||||
|
||||
@@ -5,12 +5,14 @@ import type { SessionInfo } from '@/types/hermes'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$attentionSessionIds,
|
||||
$connection,
|
||||
$currentCwd,
|
||||
$workingSessionIds,
|
||||
applyConfiguredDefaultProjectDir,
|
||||
getRecentlySettledSessionIds,
|
||||
mergeSessionPage,
|
||||
sessionPinId,
|
||||
setCurrentCwd,
|
||||
setSessionAttention,
|
||||
setSessionWorking,
|
||||
workspaceCwdForNewSession
|
||||
@@ -145,9 +147,12 @@ describe('mergeSessionPage', () => {
|
||||
describe('workspaceCwdForNewSession', () => {
|
||||
afterEach(() => {
|
||||
applyConfiguredDefaultProjectDir(null)
|
||||
$connection.set(null)
|
||||
$currentCwd.set('')
|
||||
$activeSessionId.set(null)
|
||||
window.localStorage.removeItem('hermes.desktop.workspace-cwd')
|
||||
window.localStorage.removeItem('hermes.desktop.workspace-cwd.remote.http%3A%2F%2Fbackend-a.default')
|
||||
window.localStorage.removeItem('hermes.desktop.workspace-cwd.remote.http%3A%2F%2Fbackend-b.default')
|
||||
})
|
||||
|
||||
it('prefers the configured default over the sticky remembered workspace', () => {
|
||||
@@ -177,6 +182,26 @@ describe('workspaceCwdForNewSession', () => {
|
||||
expect($currentCwd.get()).toBe('/live/session/path')
|
||||
expect(workspaceCwdForNewSession()).toBe('/home/user/configured')
|
||||
})
|
||||
|
||||
it('keeps remote workspace memory separate from local and other remotes', () => {
|
||||
window.localStorage.setItem('hermes.desktop.workspace-cwd', '/local/project')
|
||||
$currentCwd.set('/live/session/path')
|
||||
$connection.set({ baseUrl: 'http://backend-a', mode: 'remote' } as never)
|
||||
|
||||
expect(workspaceCwdForNewSession()).toBe('')
|
||||
|
||||
setCurrentCwd('/backend/project-a')
|
||||
expect(workspaceCwdForNewSession()).toBe('/backend/project-a')
|
||||
|
||||
$connection.set({ baseUrl: 'http://backend-b', mode: 'remote' } as never)
|
||||
expect(workspaceCwdForNewSession()).toBe('')
|
||||
|
||||
setCurrentCwd('/backend/project-b')
|
||||
expect(workspaceCwdForNewSession()).toBe('/backend/project-b')
|
||||
|
||||
$connection.set(null)
|
||||
expect(workspaceCwdForNewSession()).toBe('/local/project')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRecentlySettledSessionIds', () => {
|
||||
|
||||
@@ -10,13 +10,19 @@ type Updater<T> = T | ((current: T) => T)
|
||||
|
||||
const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd'
|
||||
|
||||
// Cached copy of Settings → Sessions → Default project directory. The main
|
||||
// process persists this in project-dir.json, but the renderer must also honor it
|
||||
// when seeding $currentCwd — otherwise PR #37586's sticky localStorage home dir
|
||||
// wins and new sessions ignore the user's explicit picker choice.
|
||||
let configuredDefaultProjectDir = ''
|
||||
|
||||
export const getRememberedWorkspaceCwd = (): string => storedString(WORKSPACE_CWD_KEY)?.trim() || ''
|
||||
function workspaceCwdKey(connection: HermesConnection | null = $connection.get()): string {
|
||||
if (connection?.mode !== 'remote') {
|
||||
return WORKSPACE_CWD_KEY
|
||||
}
|
||||
|
||||
const base = encodeURIComponent(connection.baseUrl || 'remote')
|
||||
const profile = encodeURIComponent(connection.profile || 'default')
|
||||
return `${WORKSPACE_CWD_KEY}.remote.${base}.${profile}`
|
||||
}
|
||||
|
||||
export const getRememberedWorkspaceCwd = (): string => storedString(workspaceCwdKey())?.trim() || ''
|
||||
|
||||
export const getConfiguredDefaultProjectDir = (): string => configuredDefaultProjectDir
|
||||
|
||||
@@ -54,6 +60,13 @@ export async function ensureDefaultWorkspaceCwd(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const remembered = getRememberedWorkspaceCwd()
|
||||
|
||||
if ($connection.get()?.mode === 'remote') {
|
||||
seedLiveCwd(remembered)
|
||||
return
|
||||
}
|
||||
|
||||
if (configured) {
|
||||
const { cwd } = await sanitize(configured)
|
||||
seedLiveCwd(cwd)
|
||||
@@ -61,8 +74,10 @@ export async function ensureDefaultWorkspaceCwd(): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
const { cwd } = await sanitize(getRememberedWorkspaceCwd())
|
||||
seedLiveCwd(cwd)
|
||||
if (remembered) {
|
||||
const { cwd } = await sanitize(remembered)
|
||||
seedLiveCwd(cwd)
|
||||
}
|
||||
}
|
||||
|
||||
export function applyConfiguredDefaultProjectDir(dir: null | string | undefined): void {
|
||||
@@ -229,15 +244,16 @@ export const setYoloActive = (next: Updater<boolean>) => updateAtom($yoloActive,
|
||||
|
||||
export const setCurrentCwd = (next: Updater<string>) => {
|
||||
updateAtom($currentCwd, next)
|
||||
// Keep localStorage in sync with the atom: a real folder is remembered, an
|
||||
// empty cwd clears the key (|| null → removeItem).
|
||||
persistString(WORKSPACE_CWD_KEY, $currentCwd.get().trim() || null)
|
||||
persistString(workspaceCwdKey(), $currentCwd.get().trim() || null)
|
||||
}
|
||||
|
||||
/** Workspace for a brand-new chat. Explicit Settings override wins; otherwise
|
||||
* fall back to the sticky last-used folder, then whatever is already live. */
|
||||
export const workspaceCwdForNewSession = (): string =>
|
||||
getConfiguredDefaultProjectDir() || getRememberedWorkspaceCwd() || $currentCwd.get().trim()
|
||||
export const workspaceCwdForNewSession = (): string => {
|
||||
if ($connection.get()?.mode === 'remote') {
|
||||
return getRememberedWorkspaceCwd()
|
||||
}
|
||||
|
||||
return getConfiguredDefaultProjectDir() || getRememberedWorkspaceCwd() || $currentCwd.get().trim()
|
||||
}
|
||||
|
||||
export const setCurrentBranch = (next: Updater<string>) => updateAtom($currentBranch, next)
|
||||
export const setCurrentUsage = (next: Updater<UsageStats>) => updateAtom($currentUsage, next)
|
||||
|
||||
+1
-24
@@ -415,8 +415,7 @@ prompt_caching:
|
||||
# Auxiliary Models (Advanced — Experimental)
|
||||
# =============================================================================
|
||||
# Hermes uses lightweight "auxiliary" models for side tasks: image analysis,
|
||||
# browser screenshot analysis, web page summarization, TTS audio-tag insertion,
|
||||
# and context compression.
|
||||
# browser screenshot analysis, web page summarization, and context compression.
|
||||
#
|
||||
# By default these use Gemini Flash via OpenRouter or Nous Portal and are
|
||||
# auto-detected from your credentials. You do NOT need to change anything
|
||||
@@ -461,12 +460,6 @@ prompt_caching:
|
||||
# provider: "auto"
|
||||
# model: ""
|
||||
#
|
||||
# # Gemini 3.1 TTS hidden audio-tag insertion
|
||||
# tts_audio_tags:
|
||||
# provider: "auto" # empty model = your main chat model
|
||||
# model: ""
|
||||
# timeout: 30
|
||||
#
|
||||
# # Session search — summarizes matching past sessions
|
||||
# session_search:
|
||||
# provider: "auto"
|
||||
@@ -842,22 +835,6 @@ platform_toolsets:
|
||||
# max_tool_rounds: 5 # tool loop limit (0 = disable)
|
||||
# log_level: "info" # audit verbosity
|
||||
|
||||
# =============================================================================
|
||||
# Text-to-Speech
|
||||
# =============================================================================
|
||||
# TTS defaults to Edge TTS unless changed in ~/.hermes/config.yaml.
|
||||
# Gemini TTS supports persona/director prompt files, and Gemini 3.1 Flash TTS
|
||||
# can use a hidden auxiliary rewrite pass to insert expressive square-bracket
|
||||
# audio tags into the TTS script without showing tags in chat.
|
||||
#
|
||||
# tts:
|
||||
# provider: "gemini"
|
||||
# gemini:
|
||||
# model: "gemini-3.1-flash-tts-preview"
|
||||
# voice: "Kore"
|
||||
# audio_tags: false
|
||||
# persona_prompt_file: "" # e.g. ~/.hermes/tts/radio-host.md
|
||||
|
||||
# =============================================================================
|
||||
# Voice Transcription (Speech-to-Text)
|
||||
# =============================================================================
|
||||
|
||||
@@ -1804,18 +1804,6 @@ class BasePlatformAdapter(ABC):
|
||||
# preview (see gateway/run.py progress_callback).
|
||||
supports_code_blocks: bool = False
|
||||
|
||||
# The command prefix users can always TYPE on this platform to reach
|
||||
# Hermes commands. Default "/" (most platforms deliver "/approve" etc.
|
||||
# as plain message text). Platforms where typing a leading "/" is
|
||||
# intercepted or restricted by the client (Slack blocks native slash
|
||||
# commands inside threads; Matrix clients reserve "/" for client-local
|
||||
# commands) ship a "!" alias rewrite in their adapter and set this to
|
||||
# "!" so user-facing instruction text ("Reply `!approve` ...") tells
|
||||
# users the form that actually works everywhere. Capability flag —
|
||||
# shared prompt builders read it via getattr(adapter,
|
||||
# "typed_command_prefix", "/"); no per-platform branching at call sites.
|
||||
typed_command_prefix: str = "/"
|
||||
|
||||
def __init__(self, config: PlatformConfig, platform: Platform):
|
||||
self.config = config
|
||||
self.platform = platform
|
||||
|
||||
@@ -422,11 +422,6 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
|
||||
supports_code_blocks = True # Matrix renders fenced code blocks (HTML/markdown)
|
||||
|
||||
# Matrix clients commonly reserve typed "/" for client-local commands;
|
||||
# the adapter accepts "!command" as the alias that always reaches Hermes
|
||||
# (see _normalize_matrix_bang_command), so instruction text shows "!".
|
||||
typed_command_prefix = "!"
|
||||
|
||||
# Threshold for detecting Matrix client-side message splits.
|
||||
# When a chunk is near the ~4000-char practical limit, a continuation
|
||||
# is almost certain.
|
||||
@@ -1355,11 +1350,11 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
"⚠️ **Dangerous command requires approval**\n"
|
||||
f"```\n{cmd_preview}\n```\n"
|
||||
f"Reason: {description}\n\n"
|
||||
"Reply `!approve` to execute, `!approve session` to approve this pattern for the session, "
|
||||
"`!approve always` to approve permanently, or `!deny` to cancel.\n\n"
|
||||
"Reply `/approve` to execute, `/approve session` to approve this pattern for the session, "
|
||||
"`/approve always` to approve permanently, or `/deny` to cancel.\n\n"
|
||||
"You can also click the reaction to approve:\n"
|
||||
"✅ = approve\n"
|
||||
"❎ = deny"
|
||||
"✅ = /approve\n"
|
||||
"❎ = /deny"
|
||||
)
|
||||
|
||||
result = await self.send(chat_id, text, metadata=metadata)
|
||||
|
||||
@@ -318,11 +318,6 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
|
||||
MAX_MESSAGE_LENGTH = 39000 # Slack API allows 40,000 chars; leave margin
|
||||
supports_code_blocks = True # Slack mrkdwn renders fenced code blocks
|
||||
# Slack blocks typed native slash commands inside threads ("/approve is
|
||||
# not supported in threads. Sorry!"). The adapter rewrites a leading
|
||||
# "!" to "/" for known commands (see _handle_slack_message), so "!" is
|
||||
# the prefix that works everywhere — instruction text must show it.
|
||||
typed_command_prefix = "!"
|
||||
|
||||
def __init__(self, config: PlatformConfig):
|
||||
super().__init__(config, Platform.SLACK)
|
||||
@@ -2697,26 +2692,19 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
return SendResult(success=False, error="Not connected")
|
||||
|
||||
try:
|
||||
cmd_preview = command[:2900] + "..." if len(command) > 2900 else command
|
||||
thread_ts = self._resolve_thread_ts(None, metadata)
|
||||
|
||||
# Slack hard-caps a section block's text at 3000 chars; an
|
||||
# oversized block fails the whole send with ``invalid_blocks``
|
||||
# and the gateway falls back to the plain-text prompt (no
|
||||
# buttons). execute_code approvals embed the entire script in
|
||||
# ``command``, so budget the preview against the fixed parts
|
||||
# instead of a flat truncation that overflows once the header +
|
||||
# reason are added.
|
||||
header = ":warning: *Command Approval Required*\n"
|
||||
reason = f"Reason: {description[:500]}"
|
||||
budget = 3000 - len(header) - len(reason) - len("``````\n") - len("...")
|
||||
cmd_preview = command[:budget] + "..." if len(command) > budget else command
|
||||
|
||||
blocks = [
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": f"{header}```{cmd_preview}```\n{reason}",
|
||||
"text": (
|
||||
f":warning: *Command Approval Required*\n"
|
||||
f"```{cmd_preview}```\n"
|
||||
f"Reason: {description}"
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -2784,13 +2772,8 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
return SendResult(success=False, error="Not connected")
|
||||
|
||||
try:
|
||||
body = message[:2900] + "..." if len(message) > 2900 else message
|
||||
thread_ts = self._resolve_thread_ts(None, metadata)
|
||||
# Same 3000-char section-block cap as send_exec_approval: budget
|
||||
# the body against the rendered title so the wrapper never pushes
|
||||
# the block over the limit (overflow → invalid_blocks → no buttons).
|
||||
_title = (title or "Confirm")[:150]
|
||||
budget = 3000 - len(f"*{_title}*\n\n") - len("...")
|
||||
body = message[:budget] + "..." if len(message) > budget else message
|
||||
# Encode session_key and confirm_id into the button value so the
|
||||
# callback handler can resolve without extra bookkeeping.
|
||||
value = f"{session_key}|{confirm_id}"
|
||||
@@ -2800,7 +2783,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": f"*{_title}*\n\n{body}",
|
||||
"text": f"*{title or 'Confirm'}*\n\n{body}",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
+8
-23
@@ -6473,12 +6473,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
_tool_approval_live = False
|
||||
if _pending_confirm and not _tool_approval_live:
|
||||
_raw_reply = (event.text or "").strip()
|
||||
# Accept bang-prefixed replies (`!always`, `!cancel`) verbatim.
|
||||
# Slack/Matrix instruction text shows the `!` prefix (typed `/`
|
||||
# is blocked in Slack threads), but the adapters only rewrite
|
||||
# `!<known-command>` — `always`/`cancel` are confirm keywords,
|
||||
# not registered commands, so the `!` survives to here.
|
||||
_norm_reply = _raw_reply.lstrip("!/").lower()
|
||||
_cmd_reply = event.get_command()
|
||||
_confirm_choice = None
|
||||
if _cmd_reply in {"approve", "yes", "ok", "confirm"}:
|
||||
@@ -6487,11 +6481,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
_confirm_choice = "always"
|
||||
elif _cmd_reply in {"cancel", "no", "deny", "nevermind"}:
|
||||
_confirm_choice = "cancel"
|
||||
elif _norm_reply in {"approve", "approve once", "once"}:
|
||||
elif _raw_reply.lower() in {"approve", "approve once", "once"}:
|
||||
_confirm_choice = "once"
|
||||
elif _norm_reply in {"always", "always approve"}:
|
||||
elif _raw_reply.lower() in {"always", "always approve"}:
|
||||
_confirm_choice = "always"
|
||||
elif _norm_reply in {"cancel", "nevermind", "no"}:
|
||||
elif _raw_reply.lower() in {"cancel", "nevermind", "no"}:
|
||||
_confirm_choice = "cancel"
|
||||
if _confirm_choice is not None:
|
||||
_resolved = await _slash_confirm_mod.resolve(
|
||||
@@ -7065,9 +7059,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
if canonical == "memory":
|
||||
return await self._handle_memory_command(event)
|
||||
|
||||
if canonical == "skills":
|
||||
return await self._handle_skills_command(event)
|
||||
|
||||
if canonical == "fast":
|
||||
return await self._handle_fast_command(event)
|
||||
|
||||
@@ -10637,7 +10628,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
return result
|
||||
return result
|
||||
|
||||
_p = self._typed_command_prefix_for(event.source.platform)
|
||||
prompt_message = (
|
||||
f"⚠️ **Confirm /{command}**\n\n"
|
||||
f"{detail}\n\n"
|
||||
@@ -10645,7 +10635,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
"• **Approve Once** — proceed this time only\n"
|
||||
"• **Always Approve** — proceed and silence this prompt permanently\n"
|
||||
"• **Cancel** — keep current conversation\n\n"
|
||||
f"_Text fallback: reply `{_p}approve`, `{_p}always`, or `{_p}cancel`._"
|
||||
"_Text fallback: reply `/approve`, `/always`, or `/cancel`._"
|
||||
)
|
||||
return await self._request_slash_confirm(
|
||||
event=event,
|
||||
@@ -11040,12 +11030,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
logger.debug("Button-based update prompt failed: %s", btn_err)
|
||||
if not sent_buttons:
|
||||
default_hint = f" (default: {default})" if default else ""
|
||||
_p = getattr(adapter, "typed_command_prefix", "/")
|
||||
await adapter.send(
|
||||
chat_id,
|
||||
f"⚕ **Update needs your input:**\n\n"
|
||||
f"{prompt_text}{default_hint}\n\n"
|
||||
f"Reply `{_p}approve` (yes) or `{_p}deny` (no), "
|
||||
f"Reply `/approve` (yes) or `/deny` (no), "
|
||||
f"or type your answer directly.",
|
||||
metadata=metadata,
|
||||
)
|
||||
@@ -14112,18 +14101,14 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
"Button-based approval failed, falling back to text: %s", _e
|
||||
)
|
||||
|
||||
# Fallback: plain text approval prompt. Use the adapter's
|
||||
# typed prefix so Slack/Matrix users are told the form they
|
||||
# can actually type (`!approve`) — typed "/" is blocked in
|
||||
# Slack threads and reserved by Matrix clients.
|
||||
_p = getattr(_status_adapter, "typed_command_prefix", "/")
|
||||
# Fallback: plain text approval prompt
|
||||
cmd_preview = cmd[:200] + "..." if len(cmd) > 200 else cmd
|
||||
msg = (
|
||||
f"⚠️ **Dangerous command requires approval:**\n"
|
||||
f"```\n{cmd_preview}\n```\n"
|
||||
f"Reason: {desc}\n\n"
|
||||
f"Reply `{_p}approve` to execute, `{_p}approve session` to approve this pattern "
|
||||
f"for the session, `{_p}approve always` to approve permanently, or `{_p}deny` to cancel."
|
||||
f"Reply `/approve` to execute, `/approve session` to approve this pattern "
|
||||
f"for the session, `/approve always` to approve permanently, or `/deny` to cancel."
|
||||
)
|
||||
try:
|
||||
_approval_send_fut = safe_schedule_threadsafe(
|
||||
|
||||
@@ -47,19 +47,6 @@ logger = logging.getLogger("gateway.run")
|
||||
class GatewaySlashCommandsMixin:
|
||||
"""In-session slash-command handlers for GatewayRunner."""
|
||||
|
||||
def _typed_command_prefix_for(self, platform) -> str:
|
||||
"""Return the prefix users can always type to reach Hermes commands.
|
||||
|
||||
Reads the adapter's ``typed_command_prefix`` capability flag
|
||||
(default "/"). Slack and Matrix return "!" because typed "/"
|
||||
commands are blocked in Slack threads / reserved by Matrix clients;
|
||||
their adapters rewrite "!command" to "/command" on receive.
|
||||
Instruction text built for those platforms must show the prefix
|
||||
that actually works when typed.
|
||||
"""
|
||||
adapter = self.adapters.get(platform) if getattr(self, "adapters", None) else None
|
||||
return getattr(adapter, "typed_command_prefix", "/") if adapter is not None else "/"
|
||||
|
||||
async def _handle_reset_command(self, event: MessageEvent) -> Union[str, EphemeralReply]:
|
||||
"""Handle /new or /reset command."""
|
||||
source = event.source
|
||||
@@ -1337,14 +1324,13 @@ class GatewaySlashCommandsMixin:
|
||||
# an explicit decision).
|
||||
return await _finish_switch()
|
||||
|
||||
_p = self._typed_command_prefix_for(event.source.platform)
|
||||
return await self._request_slash_confirm(
|
||||
event=event,
|
||||
command="model",
|
||||
title="Expensive Model Warning",
|
||||
message=(
|
||||
f"⚠️ **Expensive Model Warning**\n\n{_cost_warning.message}\n\n"
|
||||
f"_Text fallback: reply `{_p}approve` to switch or `{_p}cancel` to keep "
|
||||
"_Text fallback: reply `/approve` to switch or `/cancel` to keep "
|
||||
"the current model._"
|
||||
),
|
||||
handler=_on_cost_confirm,
|
||||
@@ -2058,64 +2044,6 @@ class GatewaySlashCommandsMixin:
|
||||
"reject <id>, approval <on|off>.")
|
||||
return out
|
||||
|
||||
async def _handle_skills_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /skills on the gateway — pending skill-write review only.
|
||||
|
||||
The full skills hub (search/browse/install) stays CLI-only; this
|
||||
handler covers the write-approval review surface (pending / approve /
|
||||
reject / diff / approval) so a skill staged from a gateway session can
|
||||
be reviewed from that same session. Gated by ``skills.write_approval``
|
||||
via the CommandDef's ``gateway_config_gate``; also answers when staged
|
||||
writes still exist after the gate was turned off (so they are never
|
||||
stranded).
|
||||
|
||||
``diff`` output is truncated for chat bubbles — the full diff lives in
|
||||
the CLI (``/skills diff <id>``) and the pending JSON file.
|
||||
"""
|
||||
from gateway.run import _hermes_home
|
||||
from hermes_cli.write_approval_commands import handle_pending_subcommand
|
||||
from tools import write_approval as wa
|
||||
|
||||
raw_args = event.get_command_args().strip()
|
||||
args = raw_args.split() if raw_args else []
|
||||
session_key = self._session_key_for_source(event.source)
|
||||
config_path = _hermes_home / "config.yaml"
|
||||
|
||||
gate_on = wa.write_approval_enabled(wa.SKILLS)
|
||||
wants_toggle = bool(args) and args[0].lower() in {"approval", "mode"}
|
||||
if not gate_on and not wants_toggle and wa.pending_count(wa.SKILLS) == 0:
|
||||
return ("Skill write approval is off (skills.write_approval). "
|
||||
"Enable it with /skills approval on, then review staged "
|
||||
"writes here with /skills pending.")
|
||||
|
||||
def _set_approval(enabled: bool):
|
||||
import yaml
|
||||
user_config = {}
|
||||
if config_path.exists():
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
user_config = yaml.safe_load(f) or {}
|
||||
user_config.setdefault("skills", {})["write_approval"] = bool(enabled)
|
||||
atomic_yaml_write(config_path, user_config)
|
||||
# New setting must take effect next message → drop cached agent.
|
||||
self._evict_cached_agent(session_key)
|
||||
|
||||
out = handle_pending_subcommand(
|
||||
wa.SKILLS, args, set_mode_fn=_set_approval,
|
||||
)
|
||||
if out is None:
|
||||
return ("Unknown /skills subcommand on this platform. Use: pending, "
|
||||
"approve <id>, reject <id>, diff <id>, approval <on|off>. "
|
||||
"(Search/install are CLI-only.)")
|
||||
|
||||
# Chat bubbles can't hold a full skill diff — truncate and point at
|
||||
# the real review surfaces.
|
||||
if args and args[0].lower() == "diff" and len(out) > 3000:
|
||||
pending_id = args[1] if len(args) > 1 else "<id>"
|
||||
out = (out[:3000]
|
||||
+ f"\n… (truncated — full diff: `/skills diff {pending_id}` "
|
||||
f"on the CLI, or ~/.hermes/pending/skills/{pending_id}.json)")
|
||||
return out
|
||||
|
||||
async def _handle_fast_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /fast — mirror the CLI Priority Processing toggle in gateway chats."""
|
||||
from gateway.run import _hermes_home, _load_gateway_config, _resolve_gateway_model
|
||||
|
||||
+15
-60
@@ -19,74 +19,29 @@ __release_date__ = "2026.6.5"
|
||||
|
||||
|
||||
def _ensure_utf8():
|
||||
"""Force UTF-8 stdout/stderr to prevent UnicodeEncodeError crashes.
|
||||
"""Force UTF-8 stdout/stderr on Windows to prevent UnicodeEncodeError.
|
||||
|
||||
Several environments select a legacy, non-UTF-8 encoding for the standard
|
||||
streams:
|
||||
|
||||
- Windows services and terminals default to cp1252.
|
||||
- Linux hosts with a latin-1 / C / POSIX locale (common on minimal Debian
|
||||
installs and Raspberry Pi) select latin-1 or ASCII.
|
||||
|
||||
The CLI prints box-drawing characters (┌│├└─) and the ⚕ glyph in the setup
|
||||
wizard, doctor, and status banners. Encoding those under a non-UTF-8 codec
|
||||
raises an unhandled UnicodeEncodeError that crashes the command before it
|
||||
can even start — e.g. `hermes setup` on a fresh Pi.
|
||||
|
||||
This runs at import time so it protects every CLI subcommand, on any
|
||||
platform. It re-wraps stdout/stderr as UTF-8 when their encoding is not
|
||||
already UTF-8, preferring TextIOWrapper.reconfigure() so the existing
|
||||
stream object is fixed in place (cached `sys.stdout` references keep
|
||||
working) and falling back to reopening the file descriptor with
|
||||
closefd=False (the CPython-recommended safe variant).
|
||||
|
||||
No-op when the streams are already UTF-8: a healthy UTF-8 system sees no
|
||||
stream change and no environment mutation.
|
||||
|
||||
Note: this is intentionally the earliest, platform-agnostic guard.
|
||||
hermes_cli/stdio.py::configure_windows_stdio() runs later from the entry
|
||||
points and layers on the Windows-only extras (console code-page flip,
|
||||
EDITOR default, PATH augmentation); its stream reconfiguration is a
|
||||
harmless idempotent no-op once we have already repaired the streams here.
|
||||
Windows services and terminals default to cp1252, which cannot encode
|
||||
box-drawing characters used in CLI output. This causes unhandled
|
||||
UnicodeEncodeError crashes on gateway startup.
|
||||
"""
|
||||
repaired = False
|
||||
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
||||
for stream_name in ("stdout", "stderr"):
|
||||
stream = getattr(sys, stream_name, None)
|
||||
if stream is None:
|
||||
continue
|
||||
try:
|
||||
encoding = (getattr(stream, "encoding", "") or "").lower().replace("-", "")
|
||||
if encoding == "utf8":
|
||||
continue
|
||||
|
||||
# Preferred: reconfigure the existing TextIOWrapper in place. This
|
||||
# preserves object identity so any code already holding a reference
|
||||
# to the old sys.stdout benefits from the repair too.
|
||||
reconfigure = getattr(stream, "reconfigure", None)
|
||||
if callable(reconfigure):
|
||||
reconfigure(encoding="utf-8", errors="replace")
|
||||
repaired = True
|
||||
continue
|
||||
|
||||
# Fallback: reopen the underlying file descriptor as UTF-8. Used
|
||||
# for streams that don't expose reconfigure() (e.g. some wrapped
|
||||
# or replaced streams). closefd=False keeps the original fd open.
|
||||
new_stream = open(
|
||||
stream.fileno(), "w", encoding="utf-8",
|
||||
errors="replace", buffering=1, closefd=False,
|
||||
)
|
||||
setattr(sys, stream_name, new_stream)
|
||||
repaired = True
|
||||
except (AttributeError, OSError, ValueError):
|
||||
if getattr(stream, "encoding", "").lower().replace("-", "") != "utf8":
|
||||
new_stream = open(
|
||||
stream.fileno(), "w", encoding="utf-8",
|
||||
buffering=1, closefd=False,
|
||||
)
|
||||
setattr(sys, stream_name, new_stream)
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
|
||||
# Only nudge child processes toward UTF-8 when we actually detected a
|
||||
# non-UTF-8 locale. On a healthy UTF-8 host children inherit UTF-8 from the
|
||||
# locale already, so leave the environment untouched (minimal footprint).
|
||||
if repaired:
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
||||
|
||||
|
||||
_ensure_utf8()
|
||||
|
||||
@@ -167,7 +167,6 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
cli_only=True),
|
||||
CommandDef("skills", "Search, install, inspect, or manage skills",
|
||||
"Tools & Skills", cli_only=True,
|
||||
gateway_config_gate="skills.write_approval",
|
||||
subcommands=("search", "browse", "inspect", "install", "audit",
|
||||
"pending", "approve", "reject", "diff", "approval")),
|
||||
CommandDef("memory", "Review pending memory writes / toggle the approval gate",
|
||||
|
||||
+2
-27
@@ -1290,14 +1290,6 @@ DEFAULT_CONFIG = {
|
||||
"timeout": 30,
|
||||
"extra_body": {},
|
||||
},
|
||||
"tts_audio_tags": {
|
||||
"provider": "auto",
|
||||
"model": "",
|
||||
"base_url": "",
|
||||
"api_key": "",
|
||||
"timeout": 30,
|
||||
"extra_body": {},
|
||||
},
|
||||
# Triage specifier — flesh out a rough one-liner in the Kanban
|
||||
# Triage column into a concrete spec, then promote it to ``todo``.
|
||||
# Invoked by ``hermes kanban specify`` (single id or --all). Set a
|
||||
@@ -1564,7 +1556,7 @@ DEFAULT_CONFIG = {
|
||||
# Each provider supports an optional `max_text_length:` override for the
|
||||
# per-request input-character cap. Omit it to use the provider's documented
|
||||
# limit (OpenAI 4096, xAI 15000, MiniMax 10000, ElevenLabs 5k-40k model-aware,
|
||||
# Gemini 32000, Edge 5000, Mistral 4000, NeuTTS/KittenTTS 2000).
|
||||
# Gemini 5000, Edge 5000, Mistral 4000, NeuTTS/KittenTTS 2000).
|
||||
"tts": {
|
||||
"provider": "edge", # "edge" (free) | "elevenlabs" (premium) | "openai" | "xai" | "minimax" | "mistral" | "gemini" | "neutts" (local) | "kittentts" (local) | "piper" (local)
|
||||
"edge": {
|
||||
@@ -1580,19 +1572,6 @@ DEFAULT_CONFIG = {
|
||||
"voice": "alloy",
|
||||
# Voices: alloy, echo, fable, onyx, nova, shimmer
|
||||
},
|
||||
"gemini": {
|
||||
"model": "gemini-2.5-flash-preview-tts",
|
||||
"voice": "Kore",
|
||||
# When true, Gemini 3.1 TTS uses a hidden auxiliary-model rewrite
|
||||
# pass to insert freeform square-bracket audio tags into the TTS
|
||||
# script. Visible chat replies are unchanged.
|
||||
"audio_tags": False,
|
||||
# Optional local Markdown/text file with Gemini TTS performance
|
||||
# direction. It may include AUDIO PROFILE, SCENE, DIRECTOR'S NOTES,
|
||||
# SAMPLE CONTEXT, and either a `{transcript}` placeholder or no
|
||||
# transcript section; Hermes appends the live transcript when absent.
|
||||
"persona_prompt_file": "",
|
||||
},
|
||||
"xai": {
|
||||
"voice_id": "eve", # or custom voice ID — see https://docs.x.ai/developers/model-capabilities/audio/custom-voices
|
||||
"language": "en",
|
||||
@@ -5829,22 +5808,18 @@ def remove_env_value(key: str) -> bool:
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
atomic_replace(tmp_path, env_path)
|
||||
# Preserve the original file mode (e.g. 0640 for Docker volume
|
||||
# mounts) instead of letting _secure_file unconditionally tighten
|
||||
# to 0600. Mirrors save_env_value().
|
||||
if original_mode is not None:
|
||||
try:
|
||||
os.chmod(env_path, original_mode)
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
_secure_file(env_path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
_secure_file(env_path)
|
||||
|
||||
os.environ.pop(key, None)
|
||||
invalidate_env_cache()
|
||||
|
||||
@@ -2980,7 +2980,6 @@ _AUX_TASKS: list[tuple[str, str, str]] = [
|
||||
("approval", "Approval", "smart command approval"),
|
||||
("mcp", "MCP", "MCP tool reasoning"),
|
||||
("title_generation", "Title generation", "session titles"),
|
||||
("tts_audio_tags", "TTS audio tags", "Gemini TTS tag insertion"),
|
||||
("skills_hub", "Skills hub", "skills search/install"),
|
||||
("triage_specifier", "Triage specifier", "kanban spec fleshing"),
|
||||
("kanban_decomposer", "Kanban decomposer", "task decomposition"),
|
||||
@@ -6393,167 +6392,6 @@ def _load_installable_optional_extras(group: str = "all") -> list[str]:
|
||||
return referenced
|
||||
|
||||
|
||||
# Install-scoped breadcrumb dropped right before ``hermes update`` mutates the
|
||||
# venv and cleared only after the dependency install verifies clean. If a user
|
||||
# kills the update mid-install (Ctrl-C, terminal close, WSL OOM), the marker
|
||||
# survives and the next ``hermes`` launch finishes the install instead of
|
||||
# limping along on a half-built venv (e.g. pip wiped, a core dep like Pillow
|
||||
# never landed). Lives next to the venv (not under $HERMES_HOME) because the
|
||||
# venv is shared across all profiles, so a single marker covers every profile.
|
||||
def _update_marker_path() -> Path:
|
||||
return PROJECT_ROOT / ".update-incomplete"
|
||||
|
||||
|
||||
def _write_update_incomplete_marker() -> None:
|
||||
"""Drop the interrupted-install breadcrumb. Never raises."""
|
||||
try:
|
||||
_update_marker_path().write_text(
|
||||
f"started={_time.time()}\npid={os.getpid()}\n", encoding="utf-8"
|
||||
)
|
||||
except OSError as exc:
|
||||
logger.debug("Could not write update-incomplete marker: %s", exc)
|
||||
|
||||
|
||||
def _clear_update_incomplete_marker() -> None:
|
||||
"""Remove the interrupted-install breadcrumb. Never raises."""
|
||||
try:
|
||||
_update_marker_path().unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as exc:
|
||||
logger.debug("Could not clear update-incomplete marker: %s", exc)
|
||||
|
||||
|
||||
def _recover_from_interrupted_install() -> None:
|
||||
"""Finish a dependency install that a prior ``hermes update`` left half-done.
|
||||
|
||||
Triggered on launch when ``.update-incomplete`` is present — meaning the
|
||||
code was pulled but the dep install was killed before it verified clean.
|
||||
Unconditionally bootstraps pip via ``ensurepip`` (a killed ``pip install``
|
||||
can wipe pip from the venv entirely, which blocks the venv from recovering
|
||||
on its own), then re-runs the editable ``.[all]`` install + core-dependency
|
||||
verification, then clears the marker.
|
||||
|
||||
Never raises: a recovery failure must not block launch. If it can't
|
||||
self-heal it prints the one-line manual command and leaves the marker so
|
||||
the next launch tries again.
|
||||
|
||||
Concurrency: the marker lives next to the shared venv, so a gateway start
|
||||
plus a CLI launch (or two profiles starting at once) can both see it. An
|
||||
``O_EXCL`` lockfile ensures only one process runs the reinstall; the
|
||||
others skip and let the winner clear the marker.
|
||||
|
||||
Output: everything — our status lines AND the streamed pip/uv install
|
||||
(which inherits fd 1) — is routed to stderr. Launches whose stdout is a
|
||||
protocol stream (``hermes acp`` speaks JSON-RPC on stdout) must never get
|
||||
install noise on stdout.
|
||||
"""
|
||||
if not _update_marker_path().exists():
|
||||
return
|
||||
|
||||
# Skip in managed/Docker installs and on PyPI installs with no git checkout:
|
||||
# those don't run the source-tree update path, so a stray marker is not ours
|
||||
# to act on. Just clear it.
|
||||
if not (PROJECT_ROOT / "pyproject.toml").is_file():
|
||||
_clear_update_incomplete_marker()
|
||||
return
|
||||
|
||||
# Single-flight guard: atomically claim the recovery lock. If another
|
||||
# process holds it, skip — it is running the same reinstall into the same
|
||||
# shared venv right now. A crashed holder leaves a stale lock; break it
|
||||
# after an hour (well past any realistic install) so recovery can't be
|
||||
# wedged forever.
|
||||
lock_path = PROJECT_ROOT / ".update-incomplete.lock"
|
||||
try:
|
||||
fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
os.write(fd, f"{os.getpid()}\n".encode())
|
||||
os.close(fd)
|
||||
except FileExistsError:
|
||||
try:
|
||||
if _time.time() - lock_path.stat().st_mtime > 3600:
|
||||
lock_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return
|
||||
except OSError as exc:
|
||||
# Couldn't create the lock (read-only fs, perms). Proceed unlocked —
|
||||
# the install itself will surface the real problem.
|
||||
logger.debug("Could not create install-recovery lock: %s", exc)
|
||||
|
||||
saved_stdout_fd = None
|
||||
saved_sys_stdout = sys.stdout
|
||||
try:
|
||||
# Route Python-level prints AND subprocess-inherited fd 1 to stderr
|
||||
# for the duration of recovery (see docstring: ACP stdout safety).
|
||||
try:
|
||||
saved_stdout_fd = os.dup(1)
|
||||
os.dup2(2, 1)
|
||||
except OSError:
|
||||
saved_stdout_fd = None
|
||||
sys.stdout = sys.stderr
|
||||
|
||||
print(
|
||||
"⚠ A previous `hermes update` was interrupted mid-install — "
|
||||
"finishing dependency installation now..."
|
||||
)
|
||||
|
||||
try:
|
||||
from hermes_cli.managed_uv import ensure_uv
|
||||
|
||||
# Always bootstrap pip first: a killed install can leave the venv with
|
||||
# no pip module at all, and uv may also be gone. ensurepip restores a
|
||||
# known-good pip so at least the plain-pip path below can proceed.
|
||||
try:
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"],
|
||||
cwd=PROJECT_ROOT,
|
||||
capture_output=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("ensurepip during install recovery failed: %s", exc)
|
||||
|
||||
uv_bin = ensure_uv()
|
||||
if uv_bin:
|
||||
uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")}
|
||||
if _is_termux_env(uv_env):
|
||||
uv_env.pop("PYTHONPATH", None)
|
||||
uv_env.pop("PYTHONHOME", None)
|
||||
_install_python_dependencies_with_optional_fallback(
|
||||
[uv_bin, "pip"],
|
||||
env=uv_env,
|
||||
group="termux-all" if _is_termux_env(uv_env) else "all",
|
||||
)
|
||||
else:
|
||||
_install_python_dependencies_with_optional_fallback(
|
||||
[sys.executable, "-m", "pip"],
|
||||
group="termux-all" if _is_termux_env() else "all",
|
||||
)
|
||||
|
||||
_clear_update_incomplete_marker()
|
||||
print("✓ Dependency installation recovered — your install is healthy again.")
|
||||
except Exception as exc:
|
||||
# Leave the marker in place so the next launch retries. Give the user
|
||||
# the exact manual recovery command in the meantime.
|
||||
logger.debug("Interrupted-install recovery failed: %s", exc)
|
||||
print("✗ Could not auto-recover the interrupted install.")
|
||||
print(" Recover manually with:")
|
||||
print(f" cd {PROJECT_ROOT}")
|
||||
print(f" {sys.executable} -m ensurepip --upgrade")
|
||||
print(f" {sys.executable} -m pip install -e '.[all]'")
|
||||
finally:
|
||||
sys.stdout = saved_sys_stdout
|
||||
if saved_stdout_fd is not None:
|
||||
try:
|
||||
os.dup2(saved_stdout_fd, 1)
|
||||
os.close(saved_stdout_fd)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
lock_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _run_install_with_heartbeat(
|
||||
cmd: list[str],
|
||||
*,
|
||||
@@ -8485,13 +8323,6 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
# Reinstall Python dependencies. Prefer .[all], but if one optional extra
|
||||
# breaks on this machine, keep base deps and reinstall the remaining extras
|
||||
# individually so update does not silently strip working capabilities.
|
||||
#
|
||||
# Drop the interrupted-install breadcrumb BEFORE touching the venv. If
|
||||
# the install is killed mid-flight (Ctrl-C, terminal close, WSL OOM),
|
||||
# the marker survives and the next ``hermes`` launch finishes the
|
||||
# install via ``_recover_from_interrupted_install``. Cleared only after
|
||||
# the install + core-dependency verification completes below.
|
||||
_write_update_incomplete_marker()
|
||||
print("→ Updating Python dependencies...")
|
||||
from hermes_cli.managed_uv import ensure_uv, update_managed_uv
|
||||
|
||||
@@ -8545,12 +8376,6 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
_install_psutil_android_compat(pip_cmd)
|
||||
_install_python_dependencies_with_optional_fallback(pip_cmd, group=install_group)
|
||||
|
||||
# Core Python deps installed AND verified (the fallback helper runs
|
||||
# _verify_core_dependencies_installed). Clear the interrupted-install
|
||||
# breadcrumb now — the remaining steps (lazy refresh, node deps, web
|
||||
# UI, desktop rebuild) are non-core and can't brick the venv.
|
||||
_clear_update_incomplete_marker()
|
||||
|
||||
_refresh_active_lazy_features()
|
||||
|
||||
_update_node_dependencies()
|
||||
@@ -10865,22 +10690,6 @@ def main():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Self-heal a venv left half-built by an interrupted ``hermes update``
|
||||
# (Ctrl-C, terminal close, WSL OOM mid-install). Skip when the user is
|
||||
# *running* update — that flow writes and clears its own marker, and we
|
||||
# don't want a recovery install racing the real one. Never raises.
|
||||
#
|
||||
# The substring match is deliberately loose: argv isn't parsed yet at this
|
||||
# point, and the failure modes are asymmetric. Over-matching (e.g.
|
||||
# ``hermes skills install update``) merely defers recovery one launch;
|
||||
# under-matching (missing ``hermes -p work update``) would race a recovery
|
||||
# install against the real one. Loose wins.
|
||||
try:
|
||||
if "update" not in sys.argv[1:]:
|
||||
_recover_from_interrupted_install()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if _try_termux_fast_tui_launch():
|
||||
return
|
||||
if _try_termux_fast_cli_launch():
|
||||
|
||||
@@ -18,6 +18,7 @@ from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import hmac
|
||||
import importlib.util
|
||||
import mimetypes
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -820,6 +821,177 @@ _MEDIA_CONTENT_TYPES = {
|
||||
}
|
||||
_MEDIA_MAX_BYTES = 25 * 1024 * 1024
|
||||
|
||||
_FS_READDIR_HIDDEN = {
|
||||
".git",
|
||||
".hg",
|
||||
".svn",
|
||||
".cache",
|
||||
".next",
|
||||
".turbo",
|
||||
".venv",
|
||||
"__pycache__",
|
||||
"build",
|
||||
"dist",
|
||||
"node_modules",
|
||||
"target",
|
||||
"venv",
|
||||
}
|
||||
_FS_DATA_URL_MAX_BYTES = 16 * 1024 * 1024
|
||||
_FS_TEXT_SOURCE_MAX_BYTES = 64 * 1024 * 1024
|
||||
_FS_TEXT_PREVIEW_MAX_BYTES = 512 * 1024
|
||||
_FS_PREVIEW_LANGUAGE_BY_EXT = {
|
||||
".c": "c",
|
||||
".conf": "ini",
|
||||
".cpp": "cpp",
|
||||
".css": "css",
|
||||
".csv": "csv",
|
||||
".go": "go",
|
||||
".graphql": "graphql",
|
||||
".h": "c",
|
||||
".hpp": "cpp",
|
||||
".html": "html",
|
||||
".java": "java",
|
||||
".js": "javascript",
|
||||
".json": "json",
|
||||
".jsx": "jsx",
|
||||
".kt": "kotlin",
|
||||
".lua": "lua",
|
||||
".md": "markdown",
|
||||
".mjs": "javascript",
|
||||
".py": "python",
|
||||
".rb": "ruby",
|
||||
".rs": "rust",
|
||||
".sh": "shell",
|
||||
".sql": "sql",
|
||||
".svg": "xml",
|
||||
".toml": "toml",
|
||||
".ts": "typescript",
|
||||
".tsx": "tsx",
|
||||
".txt": "text",
|
||||
".xml": "xml",
|
||||
".yaml": "yaml",
|
||||
".yml": "yaml",
|
||||
".zsh": "shell",
|
||||
}
|
||||
_FS_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",
|
||||
}
|
||||
|
||||
|
||||
def _fs_path(raw_path: str) -> Path:
|
||||
raw = str(raw_path or "").strip()
|
||||
if not raw:
|
||||
raise HTTPException(status_code=400, detail="Path is required")
|
||||
if "\0" in raw:
|
||||
raise HTTPException(status_code=400, detail="Invalid path")
|
||||
try:
|
||||
if raw.lower().startswith("file:"):
|
||||
parsed = urllib.parse.urlparse(raw)
|
||||
if parsed.netloc and parsed.netloc not in {"", "localhost"}:
|
||||
raise ValueError
|
||||
raw = urllib.request.url2pathname(parsed.path)
|
||||
candidate = Path(raw).expanduser()
|
||||
if not candidate.is_absolute():
|
||||
candidate = Path.cwd() / candidate
|
||||
return candidate.resolve(strict=False)
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="Invalid path")
|
||||
|
||||
|
||||
def _fs_mime_type(path: Path) -> str:
|
||||
suffix = path.suffix.lower()
|
||||
if suffix in _FS_MIME_TYPES:
|
||||
return _FS_MIME_TYPES[suffix]
|
||||
guessed, _ = mimetypes.guess_type(str(path))
|
||||
return guessed or "application/octet-stream"
|
||||
|
||||
|
||||
def _fs_looks_binary(data: bytes) -> bool:
|
||||
if not data:
|
||||
return False
|
||||
if b"\0" in data:
|
||||
return True
|
||||
suspicious = sum(1 for byte in data if byte < 32 and byte not in {9, 10, 13})
|
||||
return suspicious / len(data) > 0.12
|
||||
|
||||
|
||||
def _fs_regular_file(path: Path) -> tuple[Path, os.stat_result]:
|
||||
target = _fs_path(str(path))
|
||||
try:
|
||||
st = target.stat()
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
except NotADirectoryError:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail="File is not readable")
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc) or "Invalid path")
|
||||
if stat.S_ISDIR(st.st_mode):
|
||||
raise HTTPException(status_code=400, detail="Path points to a directory")
|
||||
if not stat.S_ISREG(st.st_mode):
|
||||
raise HTTPException(status_code=400, detail="Only regular files can be read")
|
||||
return target, st
|
||||
|
||||
|
||||
def _fs_find_git_root(start: Path) -> str | None:
|
||||
directory = start
|
||||
for _ in range(50):
|
||||
try:
|
||||
if (directory / ".git").exists():
|
||||
return str(directory)
|
||||
except OSError:
|
||||
return None
|
||||
parent = directory.parent
|
||||
if parent == directory:
|
||||
return None
|
||||
directory = parent
|
||||
return None
|
||||
|
||||
|
||||
def _fs_default_cwd() -> str:
|
||||
cfg_terminal = load_config().get("terminal") or {}
|
||||
raw = str(cfg_terminal.get("cwd") or os.environ.get("TERMINAL_CWD") or "").strip()
|
||||
if raw and raw not in {".", "auto", "cwd"}:
|
||||
try:
|
||||
candidate = Path(raw).expanduser().resolve(strict=False)
|
||||
if candidate.is_dir():
|
||||
return str(candidate)
|
||||
except (OSError, RuntimeError):
|
||||
pass
|
||||
return str(Path.cwd())
|
||||
|
||||
|
||||
def _fs_git_branch(cwd: str) -> str:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "-C", cwd, "branch", "--show-current"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
check=False,
|
||||
)
|
||||
return result.stdout.strip() if result.returncode == 0 else ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _media_serve_roots() -> list[Path]:
|
||||
"""Directories ``GET /api/media`` is allowed to read from.
|
||||
@@ -874,6 +1046,87 @@ async def get_media(path: str):
|
||||
return {"data_url": f"data:{_MEDIA_CONTENT_TYPES[target.suffix.lower()]};base64,{encoded}"}
|
||||
|
||||
|
||||
@app.get("/api/fs/list")
|
||||
async def fs_list(path: str):
|
||||
target = _fs_path(path)
|
||||
try:
|
||||
entries = []
|
||||
with os.scandir(target) as scan:
|
||||
for entry in scan:
|
||||
if entry.name in _FS_READDIR_HIDDEN:
|
||||
continue
|
||||
entries.append({
|
||||
"name": entry.name,
|
||||
"path": str(target / entry.name),
|
||||
"isDirectory": entry.is_dir(follow_symlinks=False),
|
||||
})
|
||||
entries.sort(key=lambda item: (not item["isDirectory"], item["name"].lower(), item["name"]))
|
||||
return {"entries": entries}
|
||||
except FileNotFoundError:
|
||||
return {"entries": [], "error": "ENOENT"}
|
||||
except NotADirectoryError:
|
||||
return {"entries": [], "error": "ENOTDIR"}
|
||||
except PermissionError:
|
||||
return {"entries": [], "error": "EACCES"}
|
||||
except OSError as exc:
|
||||
return {"entries": [], "error": getattr(exc, "strerror", None) or "read-error"}
|
||||
|
||||
|
||||
@app.get("/api/fs/read-text")
|
||||
async def fs_read_text(path: str):
|
||||
target, st = _fs_regular_file(_fs_path(path))
|
||||
if st.st_size > _FS_TEXT_SOURCE_MAX_BYTES:
|
||||
raise HTTPException(status_code=413, detail="File too large")
|
||||
bytes_to_read = min(st.st_size, _FS_TEXT_PREVIEW_MAX_BYTES)
|
||||
try:
|
||||
with target.open("rb") as handle:
|
||||
data = handle.read(bytes_to_read)
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail="File is not readable")
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc) or "File read failed")
|
||||
return {
|
||||
"binary": _fs_looks_binary(data[:4096]),
|
||||
"byteSize": st.st_size,
|
||||
"language": _FS_PREVIEW_LANGUAGE_BY_EXT.get(target.suffix.lower(), "text"),
|
||||
"mimeType": _fs_mime_type(target),
|
||||
"path": str(target),
|
||||
"text": data.decode("utf-8", errors="replace"),
|
||||
"truncated": st.st_size > _FS_TEXT_PREVIEW_MAX_BYTES,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/fs/read-data-url")
|
||||
async def fs_read_data_url(path: str):
|
||||
target, st = _fs_regular_file(_fs_path(path))
|
||||
if st.st_size > _FS_DATA_URL_MAX_BYTES:
|
||||
raise HTTPException(status_code=413, detail="File too large")
|
||||
try:
|
||||
encoded = base64.b64encode(target.read_bytes()).decode("ascii")
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail="File is not readable")
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc) or "File read failed")
|
||||
return {"dataUrl": f"data:{_fs_mime_type(target)};base64,{encoded}"}
|
||||
|
||||
|
||||
@app.get("/api/fs/git-root")
|
||||
async def fs_git_root(path: str):
|
||||
target = _fs_path(path)
|
||||
try:
|
||||
st = target.stat()
|
||||
start = target if stat.S_ISDIR(st.st_mode) else target.parent
|
||||
except OSError:
|
||||
start = target
|
||||
return {"root": _fs_find_git_root(start)}
|
||||
|
||||
|
||||
@app.get("/api/fs/default-cwd")
|
||||
async def fs_default_cwd():
|
||||
cwd = _fs_default_cwd()
|
||||
return {"cwd": cwd, "branch": _fs_git_branch(cwd)}
|
||||
|
||||
|
||||
@app.get("/api/status")
|
||||
async def get_status():
|
||||
current_ver, latest_ver = check_config_version()
|
||||
|
||||
@@ -116,8 +116,6 @@ class OpenRouterProfile(ProviderProfile):
|
||||
the same backend server across turns.
|
||||
"""
|
||||
extra_body: dict[str, Any] = {}
|
||||
top_level: dict[str, Any] = {}
|
||||
extra_headers: dict[str, Any] = {}
|
||||
if supports_reasoning:
|
||||
# Reasoning-mandatory Anthropic models (Claude 4.6+ / fable /
|
||||
# future named models) use *adaptive* thinking: the model decides
|
||||
@@ -134,36 +132,18 @@ class OpenRouterProfile(ProviderProfile):
|
||||
# The only reliable behavior is to omit ``reasoning`` and let the
|
||||
# model default to adaptive. See hermes-agent#42991 (disable case)
|
||||
# and the tool-replay follow-up.
|
||||
#
|
||||
# ``reasoning.effort`` being ignored does NOT mean these models have
|
||||
# no effort lever — OpenRouter honors the requested effort on the
|
||||
# top-level ``verbosity`` field instead (it maps to Anthropic's
|
||||
# ``output_config.effort``; ``reasoning.effort`` is accepted but
|
||||
# ignored — confirmed by OpenRouter's Claude migration docs and a
|
||||
# live token-spend probe in hermes-agent#43432). Route the existing
|
||||
# ``reasoning_config["effort"]`` (sourced from
|
||||
# ``agent.reasoning_effort``) onto ``verbosity`` so the knob the user
|
||||
# already sets keeps working for these models. We still send NO
|
||||
# ``reasoning`` field, preserving the #42991 400 fix.
|
||||
if _anthropic_reasoning_is_mandatory(model):
|
||||
cfg = reasoning_config or {}
|
||||
effort = cfg.get("effort")
|
||||
# Only emit when effort is actually requested and reasoning
|
||||
# isn't explicitly disabled. Otherwise omit ``verbosity`` so the
|
||||
# model keeps its own adaptive default (``high``).
|
||||
if cfg.get("enabled", True) is not False and effort and effort != "none":
|
||||
top_level["verbosity"] = effort
|
||||
pass # omit reasoning entirely → adaptive default
|
||||
elif reasoning_config is not None:
|
||||
extra_body["reasoning"] = dict(reasoning_config)
|
||||
else:
|
||||
extra_body["reasoning"] = {"enabled": True, "effort": "medium"}
|
||||
|
||||
extra_headers: dict[str, Any] = {}
|
||||
if session_id and model and model.startswith(("x-ai/grok-", "xai/grok-")):
|
||||
extra_headers["x-grok-conv-id"] = session_id
|
||||
if extra_headers:
|
||||
top_level["extra_headers"] = extra_headers
|
||||
|
||||
return extra_body, top_level
|
||||
return extra_body, {"extra_headers": extra_headers} if extra_headers else {}
|
||||
|
||||
|
||||
openrouter = OpenRouterProfile(
|
||||
|
||||
@@ -45,7 +45,6 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
|
||||
|
||||
# Auto-extracted from noreply emails + manual overrides
|
||||
AUTHOR_MAP = {
|
||||
"barronlroth@gmail.com": "barronlroth",
|
||||
"ondrej.drapalik@gmail.com": "OndrejDrapalik",
|
||||
"tomasz.panek@gmail.com": "tomekpanek",
|
||||
"philipadsouza@gmail.com": "PhilipAD",
|
||||
|
||||
@@ -668,8 +668,8 @@ def test_state_atomic_write_no_tmp_leftovers(curator_env):
|
||||
c = curator_env["curator"]
|
||||
c.save_state({"paused": True})
|
||||
parent = c._state_file().parent
|
||||
tmp_files = [p.name for p in parent.iterdir() if p.name.endswith(".tmp")]
|
||||
assert tmp_files == []
|
||||
for p in parent.iterdir():
|
||||
assert not p.name.startswith(".curator_state_"), f"tmp leftover: {p.name}"
|
||||
|
||||
|
||||
def test_state_preserves_last_report_path(curator_env):
|
||||
|
||||
@@ -415,17 +415,14 @@ class TestSendVoiceReply:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_calls_tts_and_send_voice(self, runner):
|
||||
from gateway.config import Platform
|
||||
|
||||
mock_adapter = AsyncMock()
|
||||
mock_adapter.send_voice = AsyncMock()
|
||||
event = _make_event()
|
||||
event.source.platform = Platform.TELEGRAM
|
||||
runner.adapters[event.source.platform] = mock_adapter
|
||||
|
||||
tts_result = json.dumps({"success": True, "file_path": "/tmp/test.ogg"})
|
||||
|
||||
with patch("tools.tts_tool.text_to_speech_tool", return_value=tts_result) as mock_tts, \
|
||||
with patch("tools.tts_tool.text_to_speech_tool", return_value=tts_result), \
|
||||
patch("tools.tts_tool._strip_markdown_for_tts", side_effect=lambda t: t), \
|
||||
patch("os.path.isfile", return_value=True), \
|
||||
patch("os.unlink"), \
|
||||
@@ -433,32 +430,9 @@ class TestSendVoiceReply:
|
||||
await runner._send_voice_reply(event, "Hello world")
|
||||
|
||||
mock_adapter.send_voice.assert_called_once()
|
||||
assert mock_tts.call_args.kwargs["output_path"].endswith(".ogg")
|
||||
call_args = mock_adapter.send_voice.call_args
|
||||
assert call_args.kwargs.get("chat_id") == "123"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_telegram_auto_voice_reply_uses_mp3(self, runner):
|
||||
from gateway.config import Platform
|
||||
|
||||
mock_adapter = AsyncMock()
|
||||
mock_adapter.send_voice = AsyncMock()
|
||||
event = _make_event()
|
||||
event.source.platform = Platform.SLACK
|
||||
runner.adapters[event.source.platform] = mock_adapter
|
||||
|
||||
tts_result = json.dumps({"success": True, "file_path": "/tmp/test.mp3"})
|
||||
|
||||
with patch("tools.tts_tool.text_to_speech_tool", return_value=tts_result) as mock_tts, \
|
||||
patch("tools.tts_tool._strip_markdown_for_tts", side_effect=lambda t: t), \
|
||||
patch("os.path.isfile", return_value=True), \
|
||||
patch("os.unlink"), \
|
||||
patch("os.makedirs"):
|
||||
await runner._send_voice_reply(event, "Hello world")
|
||||
|
||||
mock_adapter.send_voice.assert_called_once()
|
||||
assert mock_tts.call_args.kwargs["output_path"].endswith(".mp3")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_voice_reply_uses_thread_metadata_helper(self, runner):
|
||||
from gateway.config import Platform
|
||||
|
||||
@@ -354,28 +354,6 @@ class TestRemoveEnvValue:
|
||||
remove_env_value("ORPHAN_KEY")
|
||||
assert "ORPHAN_KEY" not in os.environ
|
||||
|
||||
def test_remove_env_value_preserves_existing_file_mode_on_posix(self, tmp_path):
|
||||
"""Regression: pre-existing .env mode (e.g. 0640 for a Docker
|
||||
bind-mount the operator chose) survives a remove just as it does a
|
||||
save. Previously _secure_file ran unconditionally after the
|
||||
mode-restore branch and re-tightened to 0600 — the same bug fixed
|
||||
in save_env_value (#33699), in the sibling remove path.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
return
|
||||
|
||||
env_path = tmp_path / ".env"
|
||||
env_path.write_text("KEEP=value\nDROP=gone\n")
|
||||
os.chmod(env_path, 0o640)
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path), "DROP": "gone"}):
|
||||
removed = remove_env_value("DROP")
|
||||
|
||||
assert removed is True
|
||||
assert "DROP" not in env_path.read_text()
|
||||
env_mode = env_path.stat().st_mode & 0o777
|
||||
assert env_mode == 0o640, f"expected 0o640, got {oct(env_mode)}"
|
||||
|
||||
|
||||
class TestSaveConfigAtomicity:
|
||||
"""Verify save_config uses atomic writes (tempfile + os.replace)."""
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
"""Regression tests for hermes_cli._ensure_utf8().
|
||||
|
||||
Covers the crash class where the setup wizard (and other banner-printing
|
||||
commands) emit box-drawing characters and the ⚕ glyph, which raise
|
||||
UnicodeEncodeError when stdout/stderr are bound to a non-UTF-8 codec.
|
||||
|
||||
Historically the repair was gated on ``sys.platform == "win32"`` and only
|
||||
caught the Windows cp1252 case. Linux hosts with a latin-1 / C / POSIX locale
|
||||
(common on minimal Debian installs and Raspberry Pi) hit the identical crash
|
||||
in ``hermes setup`` because the repair returned early. See the Raspberry Pi
|
||||
report: latin-1 locale → UnicodeEncodeError before the wizard could start.
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
|
||||
import hermes_cli
|
||||
|
||||
|
||||
# The exact glyphs the setup wizard / banners print (setup.py ~line 2962+).
|
||||
_BANNER = "┌─────┐\n│ ⚕ Hermes │\n└─────┘"
|
||||
|
||||
|
||||
class _FakeStream:
|
||||
"""Minimal text stream backed by an in-memory byte buffer with a codec.
|
||||
|
||||
Mirrors how CPython binds sys.stdout to the locale encoding: writes that
|
||||
can't be encoded raise UnicodeEncodeError, just like a real latin-1 TTY.
|
||||
"""
|
||||
|
||||
def __init__(self, encoding, *, supports_reconfigure=True):
|
||||
self.encoding = encoding
|
||||
self._supports_reconfigure = supports_reconfigure
|
||||
self.errors = "strict"
|
||||
self._buf = io.BytesIO()
|
||||
|
||||
def write(self, s):
|
||||
self._buf.write(s.encode(self.encoding, self.errors))
|
||||
return len(s)
|
||||
|
||||
def flush(self):
|
||||
pass
|
||||
|
||||
def reconfigure(self, *, encoding=None, errors=None):
|
||||
if not self._supports_reconfigure:
|
||||
raise AttributeError("reconfigure")
|
||||
if encoding is not None:
|
||||
self.encoding = encoding
|
||||
if errors is not None:
|
||||
self.errors = errors
|
||||
|
||||
def getvalue(self):
|
||||
return self._buf.getvalue()
|
||||
|
||||
|
||||
def _run_with_streams(monkeypatch, out, err):
|
||||
monkeypatch.setattr(sys, "stdout", out, raising=False)
|
||||
monkeypatch.setattr(sys, "stderr", err, raising=False)
|
||||
hermes_cli._ensure_utf8()
|
||||
|
||||
|
||||
def test_latin1_stdout_is_repaired_to_utf8(monkeypatch):
|
||||
"""A latin-1 stdout (the Raspberry Pi case) becomes UTF-8 capable."""
|
||||
out = _FakeStream("latin-1")
|
||||
err = _FakeStream("latin-1")
|
||||
|
||||
# Sanity: before the fix, the banner cannot be encoded.
|
||||
try:
|
||||
out.write(_BANNER)
|
||||
pre_fix_crashes = False
|
||||
except UnicodeEncodeError:
|
||||
pre_fix_crashes = True
|
||||
assert pre_fix_crashes, "fixture should reproduce the original crash"
|
||||
|
||||
out = _FakeStream("latin-1")
|
||||
err = _FakeStream("latin-1")
|
||||
_run_with_streams(monkeypatch, out, err)
|
||||
|
||||
assert sys.stdout.encoding.lower().replace("-", "") == "utf8"
|
||||
assert sys.stderr.encoding.lower().replace("-", "") == "utf8"
|
||||
# The banner now encodes without raising.
|
||||
sys.stdout.write(_BANNER)
|
||||
assert "⚕".encode("utf-8") in sys.stdout.getvalue()
|
||||
|
||||
|
||||
def test_ascii_posix_locale_is_repaired(monkeypatch):
|
||||
"""C/POSIX locale resolves to ascii stdout — also must be repaired."""
|
||||
out = _FakeStream("ascii")
|
||||
err = _FakeStream("ascii")
|
||||
_run_with_streams(monkeypatch, out, err)
|
||||
assert sys.stdout.encoding.lower().replace("-", "") == "utf8"
|
||||
sys.stdout.write(_BANNER) # no raise
|
||||
|
||||
|
||||
def test_utf8_stream_left_untouched(monkeypatch):
|
||||
"""Already-UTF-8 streams are a no-op: object identity preserved AND the
|
||||
process environment is left untouched (no PYTHONUTF8/PYTHONIOENCODING
|
||||
burned in on a healthy UTF-8 host)."""
|
||||
out = _FakeStream("utf-8")
|
||||
err = _FakeStream("utf-8")
|
||||
sentinel_out, sentinel_err = out, err
|
||||
monkeypatch.delenv("PYTHONUTF8", raising=False)
|
||||
monkeypatch.delenv("PYTHONIOENCODING", raising=False)
|
||||
_run_with_streams(monkeypatch, out, err)
|
||||
assert sys.stdout is sentinel_out
|
||||
assert sys.stderr is sentinel_err
|
||||
# Healthy UTF-8 host: no environment mutation (minimal footprint).
|
||||
assert "PYTHONUTF8" not in os.environ
|
||||
assert "PYTHONIOENCODING" not in os.environ
|
||||
|
||||
|
||||
def test_repair_sets_child_process_env(monkeypatch):
|
||||
"""When a real repair happens, child-process UTF-8 hints are set."""
|
||||
monkeypatch.delenv("PYTHONUTF8", raising=False)
|
||||
monkeypatch.delenv("PYTHONIOENCODING", raising=False)
|
||||
_run_with_streams(monkeypatch, _FakeStream("latin-1"), _FakeStream("latin-1"))
|
||||
assert os.environ.get("PYTHONUTF8") == "1"
|
||||
assert os.environ.get("PYTHONIOENCODING") == "utf-8"
|
||||
|
||||
|
||||
def test_repair_does_not_override_explicit_env(monkeypatch):
|
||||
"""A user's explicit PYTHONIOENCODING is respected (setdefault, not set)."""
|
||||
monkeypatch.setenv("PYTHONIOENCODING", "utf-16")
|
||||
monkeypatch.delenv("PYTHONUTF8", raising=False)
|
||||
_run_with_streams(monkeypatch, _FakeStream("latin-1"), _FakeStream("latin-1"))
|
||||
assert os.environ["PYTHONIOENCODING"] == "utf-16"
|
||||
|
||||
|
||||
def test_fallback_when_reconfigure_unavailable(monkeypatch, tmp_path):
|
||||
"""Streams without reconfigure() fall back to reopening the fd as UTF-8."""
|
||||
real_path = tmp_path / "out.txt"
|
||||
fh = open(real_path, "w", encoding="latin-1")
|
||||
|
||||
class _NoReconfigure:
|
||||
"""latin-1 stream exposing a real fileno() but no reconfigure()."""
|
||||
|
||||
encoding = "latin-1"
|
||||
|
||||
def fileno(self):
|
||||
return fh.fileno()
|
||||
|
||||
stream = _NoReconfigure()
|
||||
monkeypatch.setattr(sys, "stdout", stream, raising=False)
|
||||
monkeypatch.setattr(sys, "stderr", stream, raising=False)
|
||||
hermes_cli._ensure_utf8()
|
||||
|
||||
# Replaced with a new UTF-8 stream object (not reconfigured in place).
|
||||
assert sys.stdout is not stream
|
||||
assert sys.stdout.encoding.lower().replace("-", "") == "utf8"
|
||||
sys.stdout.write(_BANNER)
|
||||
sys.stdout.flush()
|
||||
fh.close()
|
||||
assert "⚕".encode("utf-8") in real_path.read_bytes()
|
||||
|
||||
|
||||
def test_broken_stream_does_not_raise(monkeypatch):
|
||||
"""A stream whose repair raises must be swallowed, never crash import."""
|
||||
|
||||
class _Hostile:
|
||||
encoding = "latin-1"
|
||||
|
||||
def reconfigure(self, *a, **k):
|
||||
raise OSError("nope")
|
||||
|
||||
def fileno(self):
|
||||
raise OSError("no fd")
|
||||
|
||||
monkeypatch.setattr(sys, "stdout", _Hostile(), raising=False)
|
||||
monkeypatch.setattr(sys, "stderr", _Hostile(), raising=False)
|
||||
# Must not propagate.
|
||||
hermes_cli._ensure_utf8()
|
||||
|
||||
|
||||
def test_none_streams_do_not_raise(monkeypatch):
|
||||
"""pythonw / detached streams (sys.stdout is None) must be tolerated."""
|
||||
monkeypatch.setattr(sys, "stdout", None, raising=False)
|
||||
monkeypatch.setattr(sys, "stderr", None, raising=False)
|
||||
hermes_cli._ensure_utf8()
|
||||
@@ -1,218 +0,0 @@
|
||||
"""Tests for interrupted-install self-heal (the ``.update-incomplete`` marker).
|
||||
|
||||
Covers the breadcrumb lifecycle and the launch-time recovery guard added so a
|
||||
``hermes update`` killed mid-install (Ctrl-C, terminal close, WSL OOM) gets
|
||||
finished automatically on the next launch instead of leaving a half-built venv.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import hermes_cli.main as m
|
||||
|
||||
|
||||
def test_marker_round_trip(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
|
||||
marker = m._update_marker_path()
|
||||
assert marker == tmp_path / ".update-incomplete"
|
||||
assert not marker.exists()
|
||||
|
||||
m._write_update_incomplete_marker()
|
||||
assert marker.exists()
|
||||
body = marker.read_text()
|
||||
assert "started=" in body
|
||||
assert "pid=" in body
|
||||
|
||||
m._clear_update_incomplete_marker()
|
||||
assert not marker.exists()
|
||||
|
||||
|
||||
def test_clear_when_absent_is_noop(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
|
||||
# Must not raise when the marker was never written.
|
||||
m._clear_update_incomplete_marker()
|
||||
assert not m._update_marker_path().exists()
|
||||
|
||||
|
||||
def test_recovery_noop_without_marker(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
|
||||
called = {"install": False}
|
||||
monkeypatch.setattr(
|
||||
m,
|
||||
"_install_python_dependencies_with_optional_fallback",
|
||||
lambda *a, **k: called.__setitem__("install", True),
|
||||
)
|
||||
m._recover_from_interrupted_install()
|
||||
assert called["install"] is False, "recovery must not install when no marker"
|
||||
|
||||
|
||||
def test_recovery_clears_stray_marker_without_pyproject(tmp_path, monkeypatch):
|
||||
# No pyproject.toml (PyPI/Docker install) — a stray marker is not ours to
|
||||
# act on; recovery should just clear it without trying to install.
|
||||
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
|
||||
m._write_update_incomplete_marker()
|
||||
called = {"install": False}
|
||||
monkeypatch.setattr(
|
||||
m,
|
||||
"_install_python_dependencies_with_optional_fallback",
|
||||
lambda *a, **k: called.__setitem__("install", True),
|
||||
)
|
||||
m._recover_from_interrupted_install()
|
||||
assert called["install"] is False
|
||||
assert not m._update_marker_path().exists()
|
||||
|
||||
|
||||
def test_recovery_runs_install_and_clears_marker(tmp_path, monkeypatch):
|
||||
# Source-tree install (pyproject present) with marker set → recovery should
|
||||
# run the dep install and clear the marker on success.
|
||||
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")
|
||||
m._write_update_incomplete_marker()
|
||||
|
||||
seen = {"ensurepip": False, "install": False}
|
||||
|
||||
def fake_run(cmd, *a, **k):
|
||||
if "ensurepip" in cmd:
|
||||
seen["ensurepip"] = True
|
||||
|
||||
class R:
|
||||
returncode = 0
|
||||
|
||||
return R()
|
||||
|
||||
monkeypatch.setattr(m.subprocess, "run", fake_run)
|
||||
monkeypatch.setattr(m, "_is_termux_env", lambda *a, **k: False)
|
||||
monkeypatch.setattr("hermes_cli.managed_uv.ensure_uv", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
m,
|
||||
"_install_python_dependencies_with_optional_fallback",
|
||||
lambda *a, **k: seen.__setitem__("install", True),
|
||||
)
|
||||
|
||||
m._recover_from_interrupted_install()
|
||||
|
||||
assert seen["ensurepip"] is True, "ensurepip must run unconditionally first"
|
||||
assert seen["install"] is True, "dep install must run"
|
||||
assert not m._update_marker_path().exists(), "marker cleared on success"
|
||||
|
||||
|
||||
def test_recovery_keeps_marker_on_failure(tmp_path, monkeypatch):
|
||||
# If the install itself blows up, the marker must survive so the next
|
||||
# launch retries — and recovery must not raise.
|
||||
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")
|
||||
m._write_update_incomplete_marker()
|
||||
|
||||
class R:
|
||||
returncode = 0
|
||||
|
||||
monkeypatch.setattr(m.subprocess, "run", lambda *a, **k: R())
|
||||
monkeypatch.setattr(m, "_is_termux_env", lambda *a, **k: False)
|
||||
monkeypatch.setattr("hermes_cli.managed_uv.ensure_uv", lambda: None)
|
||||
|
||||
def boom(*a, **k):
|
||||
raise RuntimeError("install died")
|
||||
|
||||
monkeypatch.setattr(
|
||||
m, "_install_python_dependencies_with_optional_fallback", boom
|
||||
)
|
||||
|
||||
# Must not raise.
|
||||
m._recover_from_interrupted_install()
|
||||
assert m._update_marker_path().exists(), "marker preserved for retry on failure"
|
||||
|
||||
|
||||
def _stub_install_env(monkeypatch, m, seen):
|
||||
"""Common stubs so recovery's install path is inert and observable."""
|
||||
|
||||
class R:
|
||||
returncode = 0
|
||||
|
||||
monkeypatch.setattr(m.subprocess, "run", lambda *a, **k: R())
|
||||
monkeypatch.setattr(m, "_is_termux_env", lambda *a, **k: False)
|
||||
monkeypatch.setattr("hermes_cli.managed_uv.ensure_uv", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
m,
|
||||
"_install_python_dependencies_with_optional_fallback",
|
||||
lambda *a, **k: seen.__setitem__("install", True),
|
||||
)
|
||||
|
||||
|
||||
def test_recovery_skips_when_lock_held(tmp_path, monkeypatch):
|
||||
# Another process is mid-recovery (fresh lockfile) — this launch must skip
|
||||
# the install entirely and leave both marker and lock untouched.
|
||||
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")
|
||||
m._write_update_incomplete_marker()
|
||||
lock = tmp_path / ".update-incomplete.lock"
|
||||
lock.write_text("12345\n")
|
||||
|
||||
seen = {"install": False}
|
||||
_stub_install_env(monkeypatch, m, seen)
|
||||
|
||||
m._recover_from_interrupted_install()
|
||||
|
||||
assert seen["install"] is False, "must not install while another holds the lock"
|
||||
assert m._update_marker_path().exists(), "marker left for the lock holder"
|
||||
assert lock.exists(), "fresh lock must not be broken"
|
||||
|
||||
|
||||
def test_recovery_breaks_stale_lock(tmp_path, monkeypatch):
|
||||
# A lock older than an hour is from a crashed holder — it gets removed so
|
||||
# the NEXT launch can recover (this launch still skips).
|
||||
import os as _os
|
||||
|
||||
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")
|
||||
m._write_update_incomplete_marker()
|
||||
lock = tmp_path / ".update-incomplete.lock"
|
||||
lock.write_text("12345\n")
|
||||
stale = m._time.time() - 7200
|
||||
_os.utime(lock, (stale, stale))
|
||||
|
||||
seen = {"install": False}
|
||||
_stub_install_env(monkeypatch, m, seen)
|
||||
|
||||
m._recover_from_interrupted_install()
|
||||
|
||||
assert not lock.exists(), "stale lock must be broken"
|
||||
assert m._update_marker_path().exists()
|
||||
|
||||
# Next launch proceeds normally.
|
||||
m._recover_from_interrupted_install()
|
||||
assert seen["install"] is True
|
||||
assert not m._update_marker_path().exists()
|
||||
assert not lock.exists(), "lock released after recovery"
|
||||
|
||||
|
||||
def test_recovery_releases_lock_after_run(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")
|
||||
m._write_update_incomplete_marker()
|
||||
|
||||
seen = {"install": False}
|
||||
_stub_install_env(monkeypatch, m, seen)
|
||||
|
||||
m._recover_from_interrupted_install()
|
||||
|
||||
assert seen["install"] is True
|
||||
assert not (tmp_path / ".update-incomplete.lock").exists()
|
||||
|
||||
|
||||
def test_recovery_output_goes_to_stderr(tmp_path, monkeypatch, capfd):
|
||||
# ACP speaks JSON-RPC on stdout — recovery output (including the streamed
|
||||
# install, which inherits fd 1) must land on stderr only.
|
||||
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")
|
||||
m._write_update_incomplete_marker()
|
||||
|
||||
seen = {"install": False}
|
||||
_stub_install_env(monkeypatch, m, seen)
|
||||
|
||||
m._recover_from_interrupted_install()
|
||||
|
||||
out, err = capfd.readouterr()
|
||||
assert "interrupted mid-install" not in out
|
||||
assert "interrupted mid-install" in err
|
||||
assert "recovered" in err
|
||||
@@ -0,0 +1,188 @@
|
||||
import base64
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import web_server
|
||||
|
||||
pytest.importorskip("starlette.testclient")
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
previous_auth_required = getattr(web_server.app.state, "auth_required", None)
|
||||
web_server.app.state.auth_required = False
|
||||
test_client = TestClient(web_server.app)
|
||||
test_client.headers[web_server._SESSION_HEADER_NAME] = web_server._SESSION_TOKEN
|
||||
try:
|
||||
yield test_client
|
||||
finally:
|
||||
if previous_auth_required is None:
|
||||
try:
|
||||
delattr(web_server.app.state, "auth_required")
|
||||
except AttributeError:
|
||||
pass
|
||||
else:
|
||||
web_server.app.state.auth_required = previous_auth_required
|
||||
|
||||
|
||||
def test_fs_list_sorts_and_hides_noise(client, tmp_path):
|
||||
root = tmp_path / "project"
|
||||
root.mkdir()
|
||||
(root / "b.txt").write_text("b")
|
||||
(root / "a_dir").mkdir()
|
||||
(root / "a.txt").write_text("a")
|
||||
(root / "node_modules").mkdir()
|
||||
(root / ".git").mkdir()
|
||||
|
||||
response = client.get("/api/fs/list", params={"path": str(root)})
|
||||
|
||||
assert response.status_code == 200
|
||||
entries = response.json()["entries"]
|
||||
assert [entry["name"] for entry in entries] == ["a_dir", "a.txt", "b.txt"]
|
||||
assert entries[0] == {"name": "a_dir", "path": str(root / "a_dir"), "isDirectory": True}
|
||||
assert all(entry["name"] not in {".git", "node_modules"} for entry in entries)
|
||||
|
||||
|
||||
def test_fs_list_accepts_relative_paths(client, tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "rel").mkdir()
|
||||
(tmp_path / "rel" / "file.txt").write_text("ok")
|
||||
|
||||
response = client.get("/api/fs/list", params={"path": "rel"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["entries"] == [
|
||||
{"name": "file.txt", "path": str(tmp_path / "rel" / "file.txt"), "isDirectory": False}
|
||||
]
|
||||
|
||||
|
||||
def test_fs_list_missing_path_returns_structured_error(client, tmp_path):
|
||||
response = client.get("/api/fs/list", params={"path": str(tmp_path / "missing")})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"entries": [], "error": "ENOENT"}
|
||||
|
||||
|
||||
def test_fs_read_text_matches_preview_shape_and_truncates(client, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(web_server, "_FS_TEXT_SOURCE_MAX_BYTES", 32)
|
||||
monkeypatch.setattr(web_server, "_FS_TEXT_PREVIEW_MAX_BYTES", 5)
|
||||
target = tmp_path / "sample.py"
|
||||
target.write_text("print('hello')")
|
||||
|
||||
response = client.get("/api/fs/read-text", params={"path": str(target)})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"binary": False,
|
||||
"byteSize": 14,
|
||||
"language": "python",
|
||||
"mimeType": "text/x-python",
|
||||
"path": str(target),
|
||||
"text": "print",
|
||||
"truncated": True,
|
||||
}
|
||||
|
||||
|
||||
def test_fs_read_text_rejects_source_over_cap(client, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(web_server, "_FS_TEXT_SOURCE_MAX_BYTES", 4)
|
||||
target = tmp_path / "large.txt"
|
||||
target.write_text("12345")
|
||||
|
||||
response = client.get("/api/fs/read-text", params={"path": str(target)})
|
||||
|
||||
assert response.status_code == 413
|
||||
|
||||
|
||||
def test_fs_read_text_flags_binary(client, tmp_path):
|
||||
target = tmp_path / "blob.bin"
|
||||
target.write_bytes(b"hello\x00world")
|
||||
|
||||
response = client.get("/api/fs/read-text", params={"path": str(target)})
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["binary"] is True
|
||||
assert body["text"].startswith("hello")
|
||||
|
||||
|
||||
def test_fs_read_data_url_returns_capped_data_url(client, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(web_server, "_FS_DATA_URL_MAX_BYTES", 16)
|
||||
target = tmp_path / "image.png"
|
||||
target.write_bytes(b"pngbytes")
|
||||
|
||||
response = client.get("/api/fs/read-data-url", params={"path": str(target)})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"dataUrl": "data:image/png;base64," + base64.b64encode(b"pngbytes").decode("ascii")}
|
||||
|
||||
|
||||
def test_fs_read_data_url_rejects_over_cap(client, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(web_server, "_FS_DATA_URL_MAX_BYTES", 3)
|
||||
target = tmp_path / "image.png"
|
||||
target.write_bytes(b"1234")
|
||||
|
||||
response = client.get("/api/fs/read-data-url", params={"path": str(target)})
|
||||
|
||||
assert response.status_code == 413
|
||||
|
||||
|
||||
def test_fs_git_root_for_nested_file(client, tmp_path):
|
||||
(tmp_path / ".git").mkdir()
|
||||
nested = tmp_path / "pkg" / "mod"
|
||||
nested.mkdir(parents=True)
|
||||
target = nested / "file.py"
|
||||
target.write_text("x")
|
||||
|
||||
response = client.get("/api/fs/git-root", params={"path": str(target)})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"root": str(tmp_path)}
|
||||
|
||||
|
||||
def test_fs_git_root_returns_null_outside_repo(client, tmp_path):
|
||||
response = client.get("/api/fs/git-root", params={"path": str(tmp_path)})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"root": None}
|
||||
|
||||
|
||||
def test_fs_default_cwd_prefers_existing_terminal_cwd(client, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(web_server, "load_config", lambda: {"terminal": {"cwd": str(tmp_path)}})
|
||||
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path / "env"))
|
||||
monkeypatch.setattr(web_server.Path, "cwd", lambda: tmp_path / "process")
|
||||
monkeypatch.setattr(web_server, "_fs_git_branch", lambda cwd: "main")
|
||||
|
||||
response = client.get("/api/fs/default-cwd")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"cwd": str(tmp_path), "branch": "main"}
|
||||
|
||||
|
||||
def test_fs_default_cwd_falls_back_when_terminal_cwd_is_invalid(client, tmp_path, monkeypatch):
|
||||
fallback = tmp_path / "backend"
|
||||
fallback.mkdir()
|
||||
monkeypatch.setattr(web_server, "load_config", lambda: {"terminal": {"cwd": "/client/missing"}})
|
||||
monkeypatch.setenv("TERMINAL_CWD", "/client/missing")
|
||||
monkeypatch.setattr(web_server.Path, "cwd", lambda: fallback)
|
||||
monkeypatch.setattr(web_server, "_fs_git_branch", lambda cwd: "")
|
||||
|
||||
response = client.get("/api/fs/default-cwd")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"cwd": str(fallback), "branch": ""}
|
||||
|
||||
|
||||
def test_fs_endpoints_require_auth(tmp_path):
|
||||
client = TestClient(web_server.app)
|
||||
target = tmp_path / "secret.txt"
|
||||
target.write_text("secret")
|
||||
|
||||
list_response = client.get("/api/fs/list", params={"path": str(tmp_path)})
|
||||
read_response = client.get("/api/fs/read-text", params={"path": str(target)})
|
||||
default_response = client.get("/api/fs/default-cwd")
|
||||
|
||||
assert list_response.status_code == 401
|
||||
assert read_response.status_code == 401
|
||||
assert default_response.status_code == 401
|
||||
@@ -291,121 +291,6 @@ class TestOpenRouterProfile:
|
||||
assert eb["reasoning"] == {"enabled": True, "effort": "high"}
|
||||
assert tl["extra_headers"]["x-grok-conv-id"] == "sess-123"
|
||||
|
||||
# --- reasoning-mandatory Anthropic effort → top-level verbosity (#43432) ---
|
||||
#
|
||||
# These models (Claude 4.6+ / fable / mythos-class) ignore
|
||||
# ``reasoning.effort`` and use adaptive thinking. OpenRouter honors the
|
||||
# requested effort on the top-level ``verbosity`` field instead (maps to
|
||||
# Anthropic ``output_config.effort``). The profile must route the existing
|
||||
# ``reasoning_config["effort"]`` there while still NEVER emitting a
|
||||
# ``reasoning`` field (which would 400 — see #42991). Gate every fixture on
|
||||
# the real predicate so this stays a behavior contract, not a name snapshot.
|
||||
|
||||
@staticmethod
|
||||
def _is_mandatory(model):
|
||||
import inspect
|
||||
p = get_provider_profile("openrouter")
|
||||
mod = inspect.getmodule(type(p))
|
||||
return mod._anthropic_reasoning_is_mandatory(model)
|
||||
|
||||
def test_mandatory_anthropic_effort_routes_to_verbosity(self):
|
||||
"""effort set + reasoning enabled → top-level verbosity == effort,
|
||||
and NO reasoning field in extra_body.
|
||||
|
||||
Covers the full real config range produced by
|
||||
``hermes_constants.parse_reasoning_effort`` —
|
||||
``VALID_REASONING_EFFORTS = (minimal, low, medium, high, xhigh)``.
|
||||
"""
|
||||
p = get_provider_profile("openrouter")
|
||||
model = "anthropic/claude-fable-5"
|
||||
assert self._is_mandatory(model) # fixture really is mandatory
|
||||
for effort in ("minimal", "low", "medium", "high", "xhigh"):
|
||||
eb, tl = p.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": effort},
|
||||
supports_reasoning=True,
|
||||
model=model,
|
||||
)
|
||||
assert tl["verbosity"] == effort, (effort, tl)
|
||||
assert "reasoning" not in eb, (effort, eb)
|
||||
|
||||
def test_mandatory_anthropic_effort_without_enabled_key_routes(self):
|
||||
"""effort present without an explicit ``enabled`` key still routes to
|
||||
verbosity (enabled defaults to True)."""
|
||||
p = get_provider_profile("openrouter")
|
||||
eb, tl = p.build_api_kwargs_extras(
|
||||
reasoning_config={"effort": "xhigh"},
|
||||
supports_reasoning=True,
|
||||
model="anthropic/claude-fable-5",
|
||||
)
|
||||
assert tl["verbosity"] == "xhigh"
|
||||
assert "reasoning" not in eb
|
||||
|
||||
def test_mandatory_anthropic_verbosity_is_value_agnostic_passthrough(self):
|
||||
"""The mapping passes the effort value through verbatim — it must NOT
|
||||
clamp or whitelist. ``xhigh`` is a real config value; ``max`` is not
|
||||
producible by ``parse_reasoning_effort`` today but OpenRouter accepts it
|
||||
for Claude (live-proven in #43432), so a forward value must survive
|
||||
rather than be silently dropped. The OpenAI SDK type only literals
|
||||
``low|medium|high`` but it's a TypedDict (no runtime validation), so the
|
||||
extended scale reaches the wire untouched."""
|
||||
p = get_provider_profile("openrouter")
|
||||
for effort in ("xhigh", "max"):
|
||||
_, tl = p.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": effort},
|
||||
supports_reasoning=True,
|
||||
model="anthropic/claude-fable-5",
|
||||
)
|
||||
assert tl["verbosity"] == effort
|
||||
|
||||
def test_mandatory_anthropic_no_verbosity_when_effort_absent(self):
|
||||
"""No effort / none / disabled → no verbosity emitted, so the model
|
||||
keeps its own adaptive default. Still no reasoning field."""
|
||||
p = get_provider_profile("openrouter")
|
||||
model = "anthropic/claude-fable-5"
|
||||
for cfg in (
|
||||
None,
|
||||
{},
|
||||
{"enabled": True},
|
||||
{"effort": "none"},
|
||||
{"enabled": True, "effort": "none"},
|
||||
{"enabled": False, "effort": "high"}, # explicitly disabled wins
|
||||
):
|
||||
eb, tl = p.build_api_kwargs_extras(
|
||||
reasoning_config=cfg,
|
||||
supports_reasoning=True,
|
||||
model=model,
|
||||
)
|
||||
assert "verbosity" not in tl, (cfg, tl)
|
||||
assert "reasoning" not in eb, (cfg, eb)
|
||||
|
||||
def test_non_mandatory_reasoning_model_unchanged_no_verbosity(self):
|
||||
"""Non-mandatory reasoning models (DeepSeek, Qwen, GPT) keep getting
|
||||
``reasoning`` in extra_body and never get a ``verbosity`` field — the
|
||||
new path must not touch them."""
|
||||
p = get_provider_profile("openrouter")
|
||||
for model in ("deepseek/deepseek-chat", "qwen/qwen3-max", "openai/gpt-5.4"):
|
||||
assert not self._is_mandatory(model) # fixture really is non-mandatory
|
||||
eb, tl = p.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": "high"},
|
||||
supports_reasoning=True,
|
||||
model=model,
|
||||
)
|
||||
assert eb["reasoning"] == {"enabled": True, "effort": "high"}, (model, eb)
|
||||
assert "verbosity" not in tl, (model, tl)
|
||||
|
||||
def test_mandatory_anthropic_verbosity_coexists_with_grok_header(self):
|
||||
"""A reasoning-mandatory Anthropic model is never a Grok model, but the
|
||||
top-level dict must remain a single merged dict — verify the verbosity
|
||||
path doesn't clobber the extra_headers slot used by Grok affinity."""
|
||||
p = get_provider_profile("openrouter")
|
||||
# mandatory anthropic + effort → verbosity, no extra_headers
|
||||
_, tl = p.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": "high"},
|
||||
supports_reasoning=True,
|
||||
model="anthropic/claude-fable-5",
|
||||
)
|
||||
assert tl == {"verbosity": "high"}
|
||||
|
||||
|
||||
class TestNousProfile:
|
||||
def test_tags(self):
|
||||
|
||||
@@ -172,31 +172,6 @@ def test_dockerfile_preinstalls_gateway_messaging_dependencies(dockerfile_text):
|
||||
)
|
||||
|
||||
|
||||
def test_dockerfile_preinstalls_matrix_dependencies(dockerfile_text):
|
||||
sync_steps = [
|
||||
step for step in _run_steps(dockerfile_text)
|
||||
if "uv sync" in step and "--no-install-project" in step
|
||||
]
|
||||
|
||||
assert sync_steps, "Dockerfile must install Python dependencies with uv sync"
|
||||
assert any("--extra matrix" in step for step in sync_steps), (
|
||||
"Published Docker images must preload the [matrix] extra so the "
|
||||
"Matrix gateway has mautrix[encryption]/python-olm available at "
|
||||
"runtime instead of relying on first-boot lazy installation into "
|
||||
"the container venv (#30399)."
|
||||
)
|
||||
|
||||
|
||||
def test_dockerfile_installs_matrix_native_build_dependencies(dockerfile_text):
|
||||
instructions = _instruction_text(dockerfile_text)
|
||||
|
||||
for package in ("libolm-dev", "cmake", "g++", "make"):
|
||||
assert package in instructions, (
|
||||
"Docker image must include native build dependencies needed by "
|
||||
f"python-olm when preinstalling the [matrix] extra (#30399): {package}"
|
||||
)
|
||||
|
||||
|
||||
def test_dockerfile_preinstalls_hindsight_memory_dependency(dockerfile_text):
|
||||
sync_steps = [
|
||||
step for step in _run_steps(dockerfile_text)
|
||||
|
||||
@@ -321,19 +321,12 @@ class TestStdioPgroupReaping:
|
||||
|
||||
psutil = pytest.importorskip("psutil")
|
||||
|
||||
# Grandchild: sleep forever, write its pid then wait. The pid file
|
||||
# is written to a temp path and os.replace()d into place so the
|
||||
# polling reader below can never observe a created-but-empty file
|
||||
# (CI flake: int('') ValueError when the reader won the race between
|
||||
# open('w') creating the file and write() filling it).
|
||||
# Grandchild: sleep forever, write its pid then wait.
|
||||
grandchild_pid_file = tmp_path / "grandchild.pid"
|
||||
grandchild_script = tmp_path / "grandchild.py"
|
||||
grandchild_script.write_text(
|
||||
"import os, sys, time\n"
|
||||
f"tmp = {str(grandchild_pid_file)!r} + '.tmp'\n"
|
||||
"with open(tmp, 'w') as f:\n"
|
||||
" f.write(str(os.getpid()))\n"
|
||||
f"os.replace(tmp, {str(grandchild_pid_file)!r})\n"
|
||||
f"open({str(grandchild_pid_file)!r}, 'w').write(str(os.getpid()))\n"
|
||||
"while True:\n"
|
||||
" time.sleep(0.5)\n"
|
||||
)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import base64
|
||||
import struct
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -256,169 +255,6 @@ class TestGenerateGeminiTts:
|
||||
|
||||
assert mock_post.call_args[0][0].startswith("https://custom-gemini.example.com/v1beta/")
|
||||
|
||||
def test_persona_prompt_file_appends_labeled_transcript(
|
||||
self, tmp_path, monkeypatch, mock_gemini_response
|
||||
):
|
||||
from tools.tts_tool import _generate_gemini_tts
|
||||
|
||||
persona_file = tmp_path / "voice-persona.md"
|
||||
persona_file.write_text(
|
||||
"# AUDIO PROFILE: Dry Butler\n\n### DIRECTOR'S NOTES\nStyle: Understated.",
|
||||
encoding="utf-8",
|
||||
)
|
||||
config = {"gemini": {"persona_prompt_file": str(persona_file)}}
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
|
||||
|
||||
with patch("requests.post", return_value=mock_gemini_response) as mock_post:
|
||||
_generate_gemini_tts("Hi", str(tmp_path / "test.wav"), config)
|
||||
|
||||
prompt_text = mock_post.call_args[1]["json"]["contents"][0]["parts"][0]["text"]
|
||||
assert "Synthesize speech from the TRANSCRIPT only" in prompt_text
|
||||
assert "# AUDIO PROFILE: Dry Butler" in prompt_text
|
||||
assert "### DIRECTOR'S NOTES\nStyle: Understated." in prompt_text
|
||||
assert "#### TRANSCRIPT\nHi" in prompt_text
|
||||
|
||||
def test_persona_prompt_file_supports_transcript_placeholder(
|
||||
self, tmp_path, monkeypatch, mock_gemini_response
|
||||
):
|
||||
from tools.tts_tool import _generate_gemini_tts
|
||||
|
||||
persona_file = tmp_path / "voice-persona.md"
|
||||
persona_file.write_text(
|
||||
"### DIRECTOR'S NOTES\nPacing: Slow.\n\n#### TRANSCRIPT\n{{ transcript }}",
|
||||
encoding="utf-8",
|
||||
)
|
||||
config = {"gemini": {"persona_prompt_file": str(persona_file)}}
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
|
||||
|
||||
with patch("requests.post", return_value=mock_gemini_response) as mock_post:
|
||||
_generate_gemini_tts("Read this.", str(tmp_path / "test.wav"), config)
|
||||
|
||||
prompt_text = mock_post.call_args[1]["json"]["contents"][0]["parts"][0]["text"]
|
||||
assert "{{ transcript }}" not in prompt_text
|
||||
assert "#### TRANSCRIPT\nRead this." in prompt_text
|
||||
|
||||
def test_missing_persona_prompt_file_warns_and_continues(
|
||||
self, tmp_path, monkeypatch, caplog, mock_gemini_response
|
||||
):
|
||||
from tools.tts_tool import _generate_gemini_tts
|
||||
|
||||
config = {"gemini": {"persona_prompt_file": str(tmp_path / "missing.md")}}
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
|
||||
|
||||
with patch("requests.post", return_value=mock_gemini_response) as mock_post:
|
||||
_generate_gemini_tts("Hi", str(tmp_path / "test.wav"), config)
|
||||
|
||||
prompt_text = mock_post.call_args[1]["json"]["contents"][0]["parts"][0]["text"]
|
||||
assert prompt_text == "Hi"
|
||||
assert "persona prompt file unavailable" in caplog.text
|
||||
|
||||
def test_audio_tags_disabled_does_not_call_rewriter(
|
||||
self, tmp_path, monkeypatch, mock_gemini_response
|
||||
):
|
||||
from tools.tts_tool import _generate_gemini_tts
|
||||
|
||||
config = {
|
||||
"gemini": {
|
||||
"model": "gemini-3.1-flash-tts-preview",
|
||||
"audio_tags": False,
|
||||
}
|
||||
}
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
|
||||
|
||||
with patch("agent.auxiliary_client.call_llm") as mock_call_llm, \
|
||||
patch("requests.post", return_value=mock_gemini_response) as mock_post:
|
||||
_generate_gemini_tts("Hi there.", str(tmp_path / "test.wav"), config)
|
||||
|
||||
mock_call_llm.assert_not_called()
|
||||
prompt_text = mock_post.call_args[1]["json"]["contents"][0]["parts"][0]["text"]
|
||||
assert prompt_text == "Hi there."
|
||||
|
||||
def test_audio_tags_enabled_rewrites_hidden_tts_script(
|
||||
self, tmp_path, monkeypatch, mock_gemini_response
|
||||
):
|
||||
from tools.tts_tool import _generate_gemini_tts
|
||||
|
||||
persona_file = tmp_path / "voice-persona.md"
|
||||
persona_file.write_text(
|
||||
"### DIRECTOR'S NOTES\nStyle: Warm and amused.",
|
||||
encoding="utf-8",
|
||||
)
|
||||
response = SimpleNamespace(
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
message=SimpleNamespace(content="[warmly] Hi there. [soft laugh]")
|
||||
)
|
||||
]
|
||||
)
|
||||
config = {
|
||||
"gemini": {
|
||||
"model": "gemini-3.1-flash-tts-preview",
|
||||
"audio_tags": True,
|
||||
"persona_prompt_file": str(persona_file),
|
||||
}
|
||||
}
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
|
||||
|
||||
with patch("agent.auxiliary_client.call_llm", return_value=response) as mock_call_llm, \
|
||||
patch("requests.post", return_value=mock_gemini_response) as mock_post:
|
||||
_generate_gemini_tts("Hi there.", str(tmp_path / "test.wav"), config)
|
||||
|
||||
mock_call_llm.assert_called_once()
|
||||
call_kwargs = mock_call_llm.call_args.kwargs
|
||||
assert call_kwargs["task"] == "tts_audio_tags"
|
||||
assert "Audio tags are inline square-bracket modifiers" in call_kwargs["messages"][0]["content"]
|
||||
assert "Style: Warm and amused." in call_kwargs["messages"][1]["content"]
|
||||
assert "Hi there." in call_kwargs["messages"][1]["content"]
|
||||
|
||||
prompt_text = mock_post.call_args[1]["json"]["contents"][0]["parts"][0]["text"]
|
||||
assert "Synthesize speech from the TRANSCRIPT only" in prompt_text
|
||||
assert "### DIRECTOR'S NOTES\nStyle: Warm and amused." in prompt_text
|
||||
assert "#### TRANSCRIPT\n[warmly] Hi there. [soft laugh]" in prompt_text
|
||||
|
||||
def test_audio_tags_enabled_skips_non_tag_capable_model(
|
||||
self, tmp_path, monkeypatch, mock_gemini_response, caplog
|
||||
):
|
||||
from tools.tts_tool import _generate_gemini_tts
|
||||
|
||||
config = {
|
||||
"gemini": {
|
||||
"model": "gemini-2.5-flash-preview-tts",
|
||||
"audio_tags": True,
|
||||
}
|
||||
}
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
|
||||
|
||||
with patch("agent.auxiliary_client.call_llm") as mock_call_llm, \
|
||||
patch("requests.post", return_value=mock_gemini_response) as mock_post:
|
||||
_generate_gemini_tts("Hi there.", str(tmp_path / "test.wav"), config)
|
||||
|
||||
mock_call_llm.assert_not_called()
|
||||
prompt_text = mock_post.call_args[1]["json"]["contents"][0]["parts"][0]["text"]
|
||||
assert prompt_text == "Hi there."
|
||||
assert "not known to support Gemini audio tags" in caplog.text
|
||||
|
||||
def test_audio_tag_rewrite_failure_falls_back_to_original_text(
|
||||
self, tmp_path, monkeypatch, mock_gemini_response, caplog
|
||||
):
|
||||
from tools.tts_tool import _generate_gemini_tts
|
||||
|
||||
config = {
|
||||
"gemini": {
|
||||
"model": "gemini-3.1-flash-tts-preview",
|
||||
"audio_tags": True,
|
||||
}
|
||||
}
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
|
||||
|
||||
with patch("agent.auxiliary_client.call_llm", side_effect=RuntimeError("boom")), \
|
||||
patch("requests.post", return_value=mock_gemini_response) as mock_post:
|
||||
_generate_gemini_tts("Hi there.", str(tmp_path / "test.wav"), config)
|
||||
|
||||
prompt_text = mock_post.call_args[1]["json"]["contents"][0]["parts"][0]["text"]
|
||||
assert prompt_text == "Hi there."
|
||||
assert "audio tag rewrite failed" in caplog.text
|
||||
|
||||
|
||||
class TestGeminiInCheckRequirements:
|
||||
def test_gemini_api_key_satisfies_requirements(self, monkeypatch):
|
||||
|
||||
@@ -268,117 +268,3 @@ def test_handle_unknown_subcommand_returns_none(hermes_home):
|
||||
# the CLI falls through to the skills hub.
|
||||
out = handle_pending_subcommand(wa.SKILLS, ["search", "foo"])
|
||||
assert out is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inline (interactive CLI) approval path — regression for the bug where the
|
||||
# per-thread approval callback was never passed to prompt_dangerous_approval,
|
||||
# so every gated foreground memory write was silently denied.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def approval_callback_cleanup():
|
||||
yield
|
||||
from tools.terminal_tool import set_approval_callback
|
||||
set_approval_callback(None)
|
||||
|
||||
|
||||
def test_memory_inline_approve_writes(hermes_home, approval_callback_cleanup):
|
||||
from tools.memory_tool import memory_tool, MemoryStore
|
||||
from tools.terminal_tool import set_approval_callback
|
||||
from tools import write_approval as wa
|
||||
_set_approval("memory", True)
|
||||
|
||||
calls = []
|
||||
def approve_cb(command, description, **kw):
|
||||
calls.append((command, description))
|
||||
return "once"
|
||||
set_approval_callback(approve_cb)
|
||||
|
||||
store = MemoryStore(); store.load_from_disk()
|
||||
r = json.loads(memory_tool("add", "memory", "approved fact", store=store))
|
||||
assert r["success"] is True
|
||||
assert r.get("staged") is None # real write, not staged
|
||||
assert store.memory_entries == ["approved fact"]
|
||||
assert wa.pending_count("memory") == 0
|
||||
# The registered callback must actually be invoked (not the input() path).
|
||||
assert len(calls) == 1
|
||||
assert "approved fact" in calls[0][0]
|
||||
|
||||
|
||||
def test_memory_inline_deny_blocks(hermes_home, approval_callback_cleanup):
|
||||
from tools.memory_tool import memory_tool, MemoryStore
|
||||
from tools.terminal_tool import set_approval_callback
|
||||
from tools import write_approval as wa
|
||||
_set_approval("memory", True)
|
||||
set_approval_callback(lambda command, description, **kw: "deny")
|
||||
|
||||
store = MemoryStore(); store.load_from_disk()
|
||||
r = json.loads(memory_tool("add", "memory", "denied fact", store=store))
|
||||
assert r["success"] is False
|
||||
assert "denied" in r["error"].lower()
|
||||
assert store.memory_entries == []
|
||||
assert wa.pending_count("memory") == 0 # denied, not staged
|
||||
|
||||
|
||||
def test_memory_inline_callback_error_stages(hermes_home, approval_callback_cleanup):
|
||||
# If the prompt machinery fails, fall back to staging — never drop silently.
|
||||
from tools.memory_tool import memory_tool, MemoryStore
|
||||
from tools.terminal_tool import set_approval_callback
|
||||
from tools import write_approval as wa
|
||||
_set_approval("memory", True)
|
||||
def broken_cb(command, description, **kw):
|
||||
raise RuntimeError("boom")
|
||||
set_approval_callback(broken_cb)
|
||||
|
||||
store = MemoryStore(); store.load_from_disk()
|
||||
r = json.loads(memory_tool("add", "memory", "fallback fact", store=store))
|
||||
assert r.get("staged") is True
|
||||
assert wa.pending_count("memory") == 1
|
||||
|
||||
|
||||
def test_gateway_context_stages_not_prompts(hermes_home, monkeypatch):
|
||||
# A gateway session has no per-thread CLI callback; the dangerous-command
|
||||
# /approve round-trip lives in the pending-queue machinery which the gate
|
||||
# does not use. The gate must stage, never attempt an inline prompt
|
||||
# (which would hit the input() fallback and silently deny).
|
||||
from tools.memory_tool import memory_tool, MemoryStore
|
||||
from tools import write_approval as wa
|
||||
_set_approval("memory", True)
|
||||
monkeypatch.setenv("HERMES_GATEWAY_SESSION", "1")
|
||||
|
||||
store = MemoryStore(); store.load_from_disk()
|
||||
r = json.loads(memory_tool("add", "memory", "gateway fact", store=store))
|
||||
assert r.get("staged") is True
|
||||
assert store.memory_entries == []
|
||||
assert wa.pending_count("memory") == 1
|
||||
|
||||
|
||||
def test_skills_never_prompt_inline_even_with_callback(hermes_home, approval_callback_cleanup):
|
||||
# Skills always stage — even when an interactive callback is registered.
|
||||
from tools.skill_manager_tool import skill_manage
|
||||
from tools.terminal_tool import set_approval_callback
|
||||
from tools import write_approval as wa
|
||||
_set_approval("skills", True)
|
||||
|
||||
calls = []
|
||||
set_approval_callback(lambda c, d, **kw: calls.append(1) or "once")
|
||||
|
||||
r = json.loads(skill_manage(
|
||||
action="create", name="test-inline-skill",
|
||||
content="---\nname: test-inline-skill\ndescription: x\n---\nbody\n"))
|
||||
assert r.get("staged") is True
|
||||
assert calls == [] # never prompted
|
||||
assert wa.pending_count("skills") == 1
|
||||
|
||||
|
||||
def test_memory_invalid_params_rejected_before_staging(hermes_home):
|
||||
# Param validation must run BEFORE the gate so a broken write is rejected
|
||||
# immediately instead of staged and failing at approve time.
|
||||
from tools.memory_tool import memory_tool, MemoryStore
|
||||
from tools import write_approval as wa
|
||||
_set_approval("memory", True)
|
||||
store = MemoryStore(); store.load_from_disk()
|
||||
r = json.loads(memory_tool("add", "memory", None, store=store))
|
||||
assert r["success"] is False
|
||||
assert wa.pending_count("memory") == 0
|
||||
|
||||
+10
-12
@@ -681,29 +681,27 @@ def memory_tool(
|
||||
if target not in {"memory", "user"}:
|
||||
return tool_error(f"Invalid target '{target}'. Use 'memory' or 'user'.", success=False)
|
||||
|
||||
# Validate required params BEFORE the gate so an invalid write is rejected
|
||||
# immediately instead of being staged and only failing at approve time.
|
||||
if action == "add" and not content:
|
||||
return tool_error("Content is required for 'add' action.", success=False)
|
||||
if action == "replace" and (not old_text or not content):
|
||||
missing = "old_text" if not old_text else "content"
|
||||
return tool_error(f"{missing} is required for 'replace' action.", success=False)
|
||||
if action == "remove" and not old_text:
|
||||
return tool_error("old_text is required for 'remove' action.", success=False)
|
||||
|
||||
# Approval gate: when on, stages the write (background/gateway) or prompts
|
||||
# inline (interactive CLI); when off (default) passes straight through.
|
||||
# Approval gate: when on, stages the write (background) or prompts inline
|
||||
# (foreground); when off (default) passes straight through.
|
||||
gate_result = _apply_write_gate(action, target, content, old_text)
|
||||
if gate_result is not None:
|
||||
return gate_result
|
||||
|
||||
if action == "add":
|
||||
if not content:
|
||||
return tool_error("Content is required for 'add' action.", success=False)
|
||||
result = store.add(target, content)
|
||||
|
||||
elif action == "replace":
|
||||
if not old_text:
|
||||
return tool_error("old_text is required for 'replace' action.", success=False)
|
||||
if not content:
|
||||
return tool_error("content is required for 'replace' action.", success=False)
|
||||
result = store.replace(target, old_text, content)
|
||||
|
||||
elif action == "remove":
|
||||
if not old_text:
|
||||
return tool_error("old_text is required for 'remove' action.", success=False)
|
||||
result = store.remove(target, old_text)
|
||||
|
||||
else:
|
||||
|
||||
+19
-196
@@ -190,8 +190,6 @@ DEFAULT_XAI_BASE_URL = "https://api.x.ai/v1"
|
||||
DEFAULT_GEMINI_TTS_MODEL = "gemini-2.5-flash-preview-tts"
|
||||
DEFAULT_GEMINI_TTS_VOICE = "Kore"
|
||||
DEFAULT_GEMINI_TTS_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
||||
DEFAULT_GEMINI_AUDIO_TAGS = False
|
||||
GEMINI_AUDIO_TAG_REWRITE_TASK = "tts_audio_tags"
|
||||
# PCM output specs for Gemini TTS (fixed by the API)
|
||||
GEMINI_TTS_SAMPLE_RATE = 24000
|
||||
GEMINI_TTS_CHANNELS = 1
|
||||
@@ -206,8 +204,8 @@ DEFAULT_OUTPUT_DIR = _get_default_output_dir()
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-provider input-character limits (from official provider docs).
|
||||
# A single global cap was wrong: OpenAI is 4096, xAI is 15k, MiniMax is 10k,
|
||||
# ElevenLabs is model-dependent (5k / 10k / 30k / 40k), Gemini has a 32k-token
|
||||
# context window. Users can override any of these via
|
||||
# ElevenLabs is model-dependent (5k / 10k / 30k / 40k), Gemini caps at ~8k
|
||||
# input tokens. Users can override any of these via
|
||||
# ``tts.<provider>.max_text_length`` in config.yaml.
|
||||
# ---------------------------------------------------------------------------
|
||||
PROVIDER_MAX_TEXT_LENGTH: Dict[str, int] = {
|
||||
@@ -216,7 +214,7 @@ PROVIDER_MAX_TEXT_LENGTH: Dict[str, int] = {
|
||||
"xai": 15000, # https://docs.x.ai/developers/model-capabilities/audio/text-to-speech
|
||||
"minimax": 10000, # https://platform.minimax.io/docs/api-reference/speech-t2a-http (sync)
|
||||
"mistral": 4000, # conservative; no published per-request cap
|
||||
"gemini": 32000, # Gemini TTS has a 32k-token context window; char cap is conservative
|
||||
"gemini": 5000, # Gemini TTS caps at ~8k input tokens / ~655s audio
|
||||
"elevenlabs": 10000, # fallback when model-aware lookup can't resolve (multilingual_v2)
|
||||
"neutts": 2000, # local model, quality falls off on long text
|
||||
"kittentts": 2000, # local 25MB model
|
||||
@@ -235,23 +233,6 @@ ELEVENLABS_MODEL_MAX_TEXT_LENGTH: Dict[str, int] = {
|
||||
"eleven_flash_v2_5": 40000,
|
||||
}
|
||||
|
||||
|
||||
def _config_bool(value: Any, default: bool = False) -> bool:
|
||||
"""Coerce common YAML/env bool spellings without treating random strings as true."""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"1", "true", "yes", "on", "enabled"}:
|
||||
return True
|
||||
if normalized in {"0", "false", "no", "off", "disabled"}:
|
||||
return False
|
||||
return default
|
||||
|
||||
# Final fallback when provider isn't recognised at all.
|
||||
FALLBACK_MAX_TEXT_LENGTH = 4000
|
||||
|
||||
@@ -1088,7 +1069,20 @@ _XAI_FIRST_SENTENCE_RE = re.compile(r"^(.{12,120}?[.!?…])\s+(?=\S)", flags=re.
|
||||
|
||||
|
||||
def _xai_bool_config(value: Any, default: bool = False) -> bool:
|
||||
return _config_bool(value, default=default)
|
||||
"""Coerce common YAML/env bool spellings without treating random strings as true."""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"1", "true", "yes", "on", "enabled"}:
|
||||
return True
|
||||
if normalized in {"0", "false", "no", "off", "disabled"}:
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def _apply_xai_auto_speech_tags(text: str) -> str:
|
||||
@@ -1400,160 +1394,6 @@ def _wrap_pcm_as_wav(
|
||||
return riff_header + fmt_chunk + data_chunk_header + pcm_bytes
|
||||
|
||||
|
||||
def _resolve_gemini_persona_prompt_path(gemini_config: Dict[str, Any]) -> Optional[Path]:
|
||||
"""Return the configured persona prompt file path, if any."""
|
||||
raw = gemini_config.get("persona_prompt_file")
|
||||
if not isinstance(raw, str) or not raw.strip():
|
||||
return None
|
||||
|
||||
expanded = os.path.expandvars(raw.strip())
|
||||
path = Path(expanded).expanduser()
|
||||
if not path.is_absolute():
|
||||
try:
|
||||
from hermes_constants import get_hermes_home
|
||||
path = get_hermes_home() / path
|
||||
except Exception:
|
||||
path = Path.cwd() / path
|
||||
return path
|
||||
|
||||
|
||||
def _read_gemini_persona_prompt(gemini_config: Dict[str, Any]) -> str:
|
||||
"""Read the Gemini persona prompt file, failing soft on config mistakes."""
|
||||
path = _resolve_gemini_persona_prompt_path(gemini_config)
|
||||
if path is None:
|
||||
return ""
|
||||
try:
|
||||
return path.read_text(encoding="utf-8").strip()
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
logger.warning(
|
||||
"Gemini TTS persona prompt file unavailable at %s: %s",
|
||||
path,
|
||||
exc,
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
def _gemini_model_supports_audio_tags(model: str) -> bool:
|
||||
"""Return True for Gemini TTS models known to support expressive audio tags."""
|
||||
normalized = (model or "").strip().lower().rsplit("/", 1)[-1]
|
||||
return "gemini-3.1" in normalized and "tts" in normalized
|
||||
|
||||
|
||||
def _gemini_audio_tags_enabled(gemini_config: Dict[str, Any], model: str) -> bool:
|
||||
raw = gemini_config.get("audio_tags")
|
||||
if isinstance(raw, dict):
|
||||
raw = raw.get("enabled")
|
||||
enabled = _config_bool(raw, default=DEFAULT_GEMINI_AUDIO_TAGS)
|
||||
if not enabled:
|
||||
return False
|
||||
if not _gemini_model_supports_audio_tags(model):
|
||||
logger.warning(
|
||||
"Gemini TTS audio_tags enabled, but model %s is not known to support "
|
||||
"Gemini audio tags; skipping hidden tag rewrite",
|
||||
model,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _clean_gemini_audio_tag_rewrite(content: str) -> str:
|
||||
clean = (content or "").strip()
|
||||
fence = re.fullmatch(r"```(?:[A-Za-z0-9_-]+)?\s*(.*?)\s*```", clean, flags=re.DOTALL)
|
||||
if fence:
|
||||
clean = fence.group(1).strip()
|
||||
return clean
|
||||
|
||||
|
||||
def _extract_auxiliary_message_content(response: Any) -> str:
|
||||
try:
|
||||
choice = response.choices[0]
|
||||
message = getattr(choice, "message", None)
|
||||
if isinstance(message, dict):
|
||||
return str(message.get("content") or "")
|
||||
return str(getattr(message, "content", "") or "")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _rewrite_gemini_tts_audio_tags(text: str, persona_prompt: str = "") -> str:
|
||||
"""Use the configured auxiliary model to insert Gemini audio tags."""
|
||||
transcript = text.strip()
|
||||
if not transcript:
|
||||
return text
|
||||
|
||||
system_prompt = (
|
||||
"You rewrite transcripts for Gemini 3.1 Flash TTS by inserting expressive "
|
||||
"audio tags.\n\n"
|
||||
"Audio tags are inline square-bracket modifiers such as [whispers], "
|
||||
"[excitedly], [very slow], [sarcastically], [laughs], [sighs], or [gasp]. "
|
||||
"There is no fixed allowlist. Use creative freeform tags generously but "
|
||||
"naturally to control tone, pace, emotional vibe, emphasis, section-level "
|
||||
"delivery, and non-verbal sounds. Use English audio tags even when the "
|
||||
"spoken transcript is not English.\n\n"
|
||||
"Rules:\n"
|
||||
"- Preserve the spoken words, order, and meaning.\n"
|
||||
"- Do not add new spoken sentences or remove existing spoken words.\n"
|
||||
"- Use square brackets for every audio tag.\n"
|
||||
"- Do not use SSML or XML tags.\n"
|
||||
"- Do not explain or comment.\n"
|
||||
"- Return only the tagged TTS script."
|
||||
)
|
||||
context = persona_prompt.strip() or "(none)"
|
||||
user_prompt = (
|
||||
"PERSONA AND DIRECTOR CONTEXT:\n"
|
||||
f"{context}\n\n"
|
||||
"TRANSCRIPT TO TAG:\n"
|
||||
f"{transcript}"
|
||||
)
|
||||
try:
|
||||
from agent.auxiliary_client import call_llm
|
||||
|
||||
response = call_llm(
|
||||
task=GEMINI_AUDIO_TAG_REWRITE_TASK,
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
temperature=0.7,
|
||||
)
|
||||
tagged = _clean_gemini_audio_tag_rewrite(_extract_auxiliary_message_content(response))
|
||||
return tagged or text
|
||||
except Exception as exc:
|
||||
logger.warning("Gemini TTS audio tag rewrite failed; using untagged text: %s", exc)
|
||||
return text
|
||||
|
||||
|
||||
def _compose_gemini_tts_prompt(
|
||||
text: str,
|
||||
gemini_config: Dict[str, Any],
|
||||
persona_prompt: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Build the Gemini prompt from persona direction plus the live transcript."""
|
||||
transcript = text.strip()
|
||||
if persona_prompt is None:
|
||||
persona_prompt = _read_gemini_persona_prompt(gemini_config)
|
||||
if not persona_prompt:
|
||||
return transcript
|
||||
|
||||
preamble = (
|
||||
"Synthesize speech from the TRANSCRIPT only. Treat AUDIO PROFILE, "
|
||||
"SCENE, DIRECTOR'S NOTES, and SAMPLE CONTEXT as performance direction; "
|
||||
"do not speak those sections aloud."
|
||||
)
|
||||
|
||||
placeholder_patterns = (
|
||||
re.compile(r"\{\{\s*transcript\s*\}\}", flags=re.IGNORECASE),
|
||||
re.compile(r"\{\s*transcript\s*\}", flags=re.IGNORECASE),
|
||||
)
|
||||
prompt = persona_prompt
|
||||
for pattern in placeholder_patterns:
|
||||
if pattern.search(prompt):
|
||||
prompt = pattern.sub(transcript, prompt)
|
||||
return f"{preamble}\n\n{prompt}".strip()
|
||||
|
||||
return f"{preamble}\n\n{persona_prompt}\n\n#### TRANSCRIPT\n{transcript}".strip()
|
||||
|
||||
|
||||
def _generate_gemini_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -> str:
|
||||
"""Generate audio using Google Gemini TTS.
|
||||
|
||||
@@ -1579,8 +1419,7 @@ def _generate_gemini_tts(text: str, output_path: str, tts_config: Dict[str, Any]
|
||||
"GEMINI_API_KEY not set. Get one at https://aistudio.google.com/app/apikey"
|
||||
)
|
||||
|
||||
raw_gemini_config = tts_config.get("gemini", {})
|
||||
gemini_config = raw_gemini_config if isinstance(raw_gemini_config, dict) else {}
|
||||
gemini_config = tts_config.get("gemini", {})
|
||||
model = str(gemini_config.get("model", DEFAULT_GEMINI_TTS_MODEL)).strip() or DEFAULT_GEMINI_TTS_MODEL
|
||||
voice = str(gemini_config.get("voice", DEFAULT_GEMINI_TTS_VOICE)).strip() or DEFAULT_GEMINI_TTS_VOICE
|
||||
base_url = str(
|
||||
@@ -1588,25 +1427,9 @@ def _generate_gemini_tts(text: str, output_path: str, tts_config: Dict[str, Any]
|
||||
or get_env_value("GEMINI_BASE_URL")
|
||||
or DEFAULT_GEMINI_TTS_BASE_URL
|
||||
).strip().rstrip("/")
|
||||
persona_prompt = _read_gemini_persona_prompt(gemini_config)
|
||||
tts_script = text
|
||||
if _gemini_audio_tags_enabled(gemini_config, model):
|
||||
tts_script = _rewrite_gemini_tts_audio_tags(text, persona_prompt=persona_prompt)
|
||||
prompt_text = _compose_gemini_tts_prompt(
|
||||
tts_script,
|
||||
gemini_config,
|
||||
persona_prompt=persona_prompt,
|
||||
)
|
||||
max_len = _resolve_max_text_length("gemini", tts_config)
|
||||
if len(prompt_text) > max_len:
|
||||
logger.warning(
|
||||
"Gemini TTS composed prompt too long (%d chars), truncating to %d",
|
||||
len(prompt_text), max_len,
|
||||
)
|
||||
prompt_text = prompt_text[:max_len]
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"contents": [{"parts": [{"text": prompt_text}]}],
|
||||
"contents": [{"parts": [{"text": text}]}],
|
||||
"generationConfig": {
|
||||
"responseModalities": ["AUDIO"],
|
||||
"speechConfig": {
|
||||
|
||||
+46
-58
@@ -15,25 +15,24 @@ Both stores are written from two origins:
|
||||
turn and autonomously decides what to save (the source of the
|
||||
"wrong assumptions" users complained about)
|
||||
|
||||
This module lets the user gate those writes per-subsystem with a boolean
|
||||
``write_approval``:
|
||||
This module lets the user gate those writes per-subsystem with a tri-state
|
||||
``write_mode``:
|
||||
|
||||
* ``false`` (default) — write freely (the pre-gate behaviour)
|
||||
* ``true`` — require approval: do not commit the write; either
|
||||
prompt inline (memory, interactive CLI only) or **stage** it to a pending
|
||||
store and surface it for the user to approve or reject out-of-band
|
||||
* ``on`` — write freely (current behaviour, default)
|
||||
* ``off`` — never write; the tool returns a clean "disabled" result
|
||||
* ``approve`` — do not commit the write; **stage** it to a pending store and
|
||||
surface it for the user to approve or reject out-of-band
|
||||
|
||||
The size asymmetry between memory and skills is real and unavoidable: a memory
|
||||
entry can be reviewed inline in a chat bubble; a 100 KB SKILL.md cannot. So
|
||||
the gate stages BOTH to disk, but review affordances differ by subsystem
|
||||
``approve`` mode stages BOTH to disk, but review affordances differ by subsystem
|
||||
(see ``hermes_cli`` slash handlers): memory shows full content, skills show
|
||||
metadata + a one-line gist + a ``diff`` escape hatch (CLI/dashboard/file).
|
||||
|
||||
Staging is mandatory for background-origin writes (a daemon thread cannot
|
||||
block on an interactive prompt) and for gateway sessions (no inline prompt
|
||||
channel — review happens via ``/memory pending``). Foreground CLI memory
|
||||
writes prompt inline via the dangerous-command approval callback; skill
|
||||
writes always stage (too big to eyeball mid-loop).
|
||||
Staging is mandatory for background-origin writes under ``approve`` (a daemon
|
||||
thread cannot block on an interactive prompt). Foreground memory writes may
|
||||
additionally block inline via the dangerous-command approval gate; foreground
|
||||
skill writes always stage (too big to eyeball mid-loop).
|
||||
|
||||
Pending records live under ``<HERMES_HOME>/pending/{memory,skills}/<id>.json``
|
||||
so they survive process restarts and can be reviewed from CLI, gateway, or the
|
||||
@@ -231,14 +230,14 @@ class GateDecision:
|
||||
"""Result of evaluating the write gate for a single write attempt.
|
||||
|
||||
Exactly one of the boolean flags is True:
|
||||
* ``allow`` — proceed with the real write (gate off, or an inline
|
||||
* ``allow`` — proceed with the real write (mode ``on``, or an inline
|
||||
approval was granted).
|
||||
* ``blocked`` — refuse the write (the user denied an inline approval
|
||||
prompt). ``message`` explains why; surface it to the agent.
|
||||
* ``blocked`` — refuse the write (mode ``off``, or an inline approval was
|
||||
denied). ``message`` explains why; surface it to the agent.
|
||||
* ``stage`` — do not write; the caller should stage the payload via
|
||||
``stage_write`` (gate on, and no inline prompt is available — gateway,
|
||||
background review, script, or any skill write). ``message`` is the
|
||||
user-facing "staged for approval" note.
|
||||
``stage_write`` (mode ``approve`` for a background write, or a
|
||||
foreground write with no interactive prompt available). ``message`` is
|
||||
the user-facing "staged for approval" note.
|
||||
"""
|
||||
|
||||
__slots__ = ("allow", "blocked", "stage", "message")
|
||||
@@ -262,10 +261,10 @@ def evaluate_gate(subsystem: str, *, inline_summary: str = "",
|
||||
are small; skills never take the inline path).
|
||||
|
||||
Decision matrix:
|
||||
gate off (default) → allow (writes flow freely)
|
||||
gate on, memory + interactive CLI → inline approve/deny prompt
|
||||
gate on, memory + gateway/script/bg → stage
|
||||
gate on, skills (any origin) → stage (too big to review inline)
|
||||
gate off (default) → allow (writes flow freely)
|
||||
gate on, memory + foreground → inline approve/deny prompt
|
||||
gate on, memory + background → stage
|
||||
gate on, skills (any origin) → stage (too big to review inline)
|
||||
|
||||
Note: there is no config-driven "blocked" outcome — the gate only ever
|
||||
delays a write for approval, never silently refuses it. ``blocked`` is
|
||||
@@ -288,10 +287,10 @@ def evaluate_gate(subsystem: str, *, inline_summary: str = "",
|
||||
),
|
||||
)
|
||||
|
||||
# Memory + foreground: if an interactive approval channel exists (a CLI
|
||||
# approval callback registered on this thread), prompt inline — entries
|
||||
# are small enough to show in full. Otherwise (gateway, script, batch,
|
||||
# no listener) stage instead of forcing a blind deny.
|
||||
# Memory + foreground: if an interactive approval channel exists (CLI
|
||||
# prompt_toolkit callback, or a gateway approve/deny round-trip), prompt
|
||||
# inline — entries are small enough to show in full. Otherwise (script,
|
||||
# batch, no listener) stage instead of forcing a blind deny.
|
||||
if _interactive_approval_available():
|
||||
granted = _prompt_inline_memory_approval(inline_summary, inline_detail)
|
||||
if granted is True:
|
||||
@@ -315,21 +314,19 @@ def evaluate_gate(subsystem: str, *, inline_summary: str = "",
|
||||
def _interactive_approval_available() -> bool:
|
||||
"""True when a foreground memory write can be approved inline.
|
||||
|
||||
Inline prompting requires a per-thread approval callback registered by the
|
||||
interactive CLI (``tools.terminal_tool.set_approval_callback``). Every
|
||||
other surface stages instead:
|
||||
|
||||
* **Gateway/API sessions** — the dangerous-command ``/approve`` round-trip
|
||||
lives in the pending-approval queue (``submit_pending`` +
|
||||
``_await_gateway_decision``), which ``prompt_dangerous_approval`` never
|
||||
reaches; trying to prompt from a gateway session would hit the
|
||||
``input()`` fallback and silently deny. Staging gives the user a real
|
||||
review affordance (``/memory pending``) instead.
|
||||
* Scripts, cron, and background threads — no user present.
|
||||
Either a per-thread approval callback is registered (interactive CLI), or
|
||||
the call is inside a gateway/API session that supports the /approve //deny
|
||||
round-trip. Scripts, cron, and background threads have neither → stage.
|
||||
"""
|
||||
try:
|
||||
from tools.terminal_tool import _get_approval_callback
|
||||
return _get_approval_callback() is not None
|
||||
if _get_approval_callback() is not None:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from tools.approval import _is_gateway_approval_context
|
||||
return bool(_is_gateway_approval_context())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@@ -338,37 +335,28 @@ def _prompt_inline_memory_approval(summary: str, detail: str) -> Optional[bool]:
|
||||
"""Prompt the user inline to approve a memory write.
|
||||
|
||||
Returns True (approved), False (denied), or None (no interactive prompt
|
||||
available / prompt failed → caller should stage instead).
|
||||
available on this thread → caller should stage instead).
|
||||
|
||||
Reuses the per-thread CLI approval callback registered for dangerous
|
||||
commands (``tools.terminal_tool.set_approval_callback``). The callback is
|
||||
invoked directly — NOT via ``prompt_dangerous_approval`` — because that
|
||||
wrapper falls back to ``input()`` (deadlock-prone under prompt_toolkit,
|
||||
see #15216) and converts callback errors into a silent deny; here a
|
||||
failed prompt must stage the write instead.
|
||||
Reuses the dangerous-command approval machinery so the CLI prompt_toolkit
|
||||
callback and the gateway ``/approve`` ``/deny`` round-trip both work without
|
||||
duplicating that plumbing.
|
||||
"""
|
||||
try:
|
||||
from tools.terminal_tool import _get_approval_callback
|
||||
from tools.approval import prompt_dangerous_approval
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
callback = _get_approval_callback()
|
||||
if callback is None:
|
||||
# No interactive channel on this thread — stage rather than risk the
|
||||
# input() fallback (deadlock under prompt_toolkit, EOF-deny in tests).
|
||||
return None
|
||||
|
||||
header = summary.strip() or "Save to memory?"
|
||||
body = detail.strip()
|
||||
description = f"Save to memory: {header}"
|
||||
command = body if body else header
|
||||
# Invoke the callback directly instead of via prompt_dangerous_approval:
|
||||
# that wrapper swallows callback exceptions into "deny", which would
|
||||
# silently refuse the write. Direct invocation lets a crashed prompt fall
|
||||
# back to staging (the gate only ever delays a write, never drops it).
|
||||
try:
|
||||
choice = callback(command, description, allow_permanent=False)
|
||||
except Exception as e:
|
||||
choice = prompt_dangerous_approval(
|
||||
command,
|
||||
description,
|
||||
allow_permanent=False,
|
||||
)
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error("Inline memory approval prompt failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
@@ -131,9 +131,8 @@ class AcmeProfile(ProviderProfile):
|
||||
|
||||
def build_api_kwargs_extras(self, *, reasoning_config=None, **context):
|
||||
"""Returns (extra_body_additions, top_level_kwargs). Needed when some
|
||||
fields go top-level (Kimi's reasoning_effort, OpenRouter's verbosity for
|
||||
adaptive Anthropic models) and some go in extra_body (OpenRouter's
|
||||
reasoning dict). Default: ({}, {})."""
|
||||
fields go top-level (Kimi's reasoning_effort) and some go in extra_body
|
||||
(OpenRouter's reasoning dict). Default: ({}, {})."""
|
||||
return {}, {}
|
||||
|
||||
def fetch_models(self, *, api_key=None, timeout=8.0) -> list[str] | None:
|
||||
|
||||
@@ -835,7 +835,6 @@ $ hermes model
|
||||
[ ] vision currently: auto / main model
|
||||
[ ] web_extract currently: auto / main model
|
||||
[ ] title_generation currently: openrouter / google/gemini-3-flash-preview
|
||||
[ ] tts_audio_tags currently: auto / main model
|
||||
[ ] compression currently: auto / main model
|
||||
[ ] approval currently: auto / main model
|
||||
[ ] triage_specifier currently: auto / main model
|
||||
@@ -912,14 +911,6 @@ auxiliary:
|
||||
api_key: ""
|
||||
timeout: 30 # seconds
|
||||
|
||||
# Gemini 3.1 TTS hidden audio-tag insertion
|
||||
tts_audio_tags:
|
||||
provider: "auto"
|
||||
model: "" # empty = main chat model
|
||||
base_url: ""
|
||||
api_key: ""
|
||||
timeout: 30
|
||||
|
||||
# Context compression timeout (separate from compression.* config)
|
||||
compression:
|
||||
timeout: 120 # seconds — compression summarizes long conversations, needs more time
|
||||
@@ -1124,17 +1115,6 @@ agent:
|
||||
|
||||
When unset (default), reasoning effort defaults to "medium" — a balanced level that works well for most tasks. Setting a value overrides it — higher reasoning effort gives better results on complex tasks at the cost of more tokens and latency.
|
||||
|
||||
:::note Adaptive-thinking models (Claude 4.6+, Fable/Mythos-class) over OpenRouter
|
||||
These models use *adaptive* thinking and don't accept the usual `reasoning.effort`
|
||||
field — OpenRouter ignores it for them. Hermes transparently routes your
|
||||
`reasoning_effort` to OpenRouter's `verbosity` parameter instead (which maps to
|
||||
Anthropic's `output_config.effort`), so the same `low`/`medium`/`high`/`xhigh`
|
||||
knob keeps working — no extra configuration needed. `none` (or unset) leaves the
|
||||
model on its own adaptive default. (`max` is accepted on the wire but is not a
|
||||
selectable `reasoning_effort` value; `xhigh` is the configurable ceiling.) The
|
||||
native Anthropic provider already controls effort directly and is unaffected.
|
||||
:::
|
||||
|
||||
You can also change the reasoning effort at runtime with the `/reasoning` command:
|
||||
|
||||
```
|
||||
@@ -1206,10 +1186,8 @@ tts:
|
||||
model: "voxtral-mini-tts-2603"
|
||||
voice_id: "c69964a6-ab8b-4f8a-9465-ec0925096ec8" # Paul - Neutral (default)
|
||||
gemini:
|
||||
model: "gemini-2.5-flash-preview-tts" # or gemini-3.1-flash-tts-preview
|
||||
model: "gemini-2.5-flash-preview-tts" # or gemini-2.5-pro-preview-tts
|
||||
voice: "Kore" # 30 prebuilt voices: Zephyr, Puck, Kore, Enceladus, etc.
|
||||
audio_tags: false # Hidden Gemini 3.1 TTS audio-tag insertion
|
||||
persona_prompt_file: "" # Optional Markdown/text file with Gemini voice direction
|
||||
xai:
|
||||
voice_id: "eve" # xAI TTS voice
|
||||
language: "en" # ISO 639-1
|
||||
|
||||
@@ -222,7 +222,7 @@ first, set `memory.write_approval: true`. It's a simple on/off gate applied to
|
||||
| `write_approval` | Behaviour |
|
||||
|------------------|-----------|
|
||||
| `false` (default) | Write freely — the gate is off (the pre-gate behaviour). |
|
||||
| `true` | Require approval before anything is saved. In the interactive CLI, foreground writes prompt you inline (entries are small enough to read in full). Everywhere else — messaging platforms, scripts, and the background self-improvement review — writes are **staged** for review with `/memory pending`. |
|
||||
| `true` | Require approval before anything is saved. Foreground writes prompt you inline (entries are small enough to read in a chat bubble). Background-review writes are **staged** instead of committed (a background thread can't block on a prompt). |
|
||||
|
||||
> To turn memory off entirely (not just gate it), set `memory_enabled: false`.
|
||||
|
||||
|
||||
@@ -66,10 +66,8 @@ tts:
|
||||
model: "voxtral-mini-tts-2603"
|
||||
voice_id: "c69964a6-ab8b-4f8a-9465-ec0925096ec8" # Paul - Neutral (default)
|
||||
gemini:
|
||||
model: "gemini-2.5-flash-preview-tts" # or gemini-3.1-flash-tts-preview
|
||||
model: "gemini-2.5-flash-preview-tts" # or gemini-2.5-pro-preview-tts
|
||||
voice: "Kore" # 30 prebuilt voices: Zephyr, Puck, Kore, Enceladus, Gacrux, etc.
|
||||
audio_tags: false # Enable hidden Gemini 3.1 TTS audio-tag insertion
|
||||
persona_prompt_file: "" # Optional Markdown/text file with Gemini voice direction
|
||||
xai:
|
||||
voice_id: "eve" # or a custom voice ID — see docs below
|
||||
language: "en" # ISO 639-1 code
|
||||
@@ -99,34 +97,6 @@ tts:
|
||||
|
||||
**Speed control**: The global `tts.speed` value applies to all providers by default. Each provider can override it with its own `speed` setting (e.g., `tts.openai.speed: 1.5`). Provider-specific speed takes precedence over the global value. Default is `1.0` (normal speed).
|
||||
|
||||
### Gemini Persona Prompts
|
||||
|
||||
Gemini TTS can follow natural-language performance direction. Set `tts.gemini.persona_prompt_file` to a local Markdown or text file that describes the voice persona. The file can include Gemini-style sections such as `AUDIO PROFILE`, `SCENE`, `DIRECTOR'S NOTES`, `SAMPLE CONTEXT`, and `TRANSCRIPT`.
|
||||
|
||||
If the file contains `{transcript}` or `{{ transcript }}`, Hermes replaces that placeholder with the live TTS text. Otherwise, Hermes appends a labeled `TRANSCRIPT` section automatically. The persona prompt stays local and is not shown in the chat reply.
|
||||
|
||||
```yaml
|
||||
tts:
|
||||
provider: gemini
|
||||
gemini:
|
||||
voice: Algieba
|
||||
persona_prompt_file: ~/.hermes/tts/butler-voice.md
|
||||
```
|
||||
|
||||
### Gemini Audio Tags
|
||||
|
||||
Gemini 3.1 Flash TTS supports freeform square-bracket audio tags such as `[whispers]`, `[excitedly]`, `[very slow]`, `[laughs]`, and other expressive delivery notes. Enable `tts.gemini.audio_tags` to have Hermes run a hidden rewrite pass before Gemini TTS. The rewrite inserts inline tags into the TTS script only; the visible chat reply stays unchanged.
|
||||
|
||||
```yaml
|
||||
tts:
|
||||
provider: gemini
|
||||
gemini:
|
||||
model: gemini-3.1-flash-tts-preview
|
||||
audio_tags: true
|
||||
```
|
||||
|
||||
The rewrite uses `auxiliary.tts_audio_tags` and defaults to your main chat model. Override that auxiliary task if you want tag insertion handled by a cheaper or faster model.
|
||||
|
||||
|
||||
### Input length limits
|
||||
|
||||
@@ -139,7 +109,7 @@ Each provider has a documented per-request input-character cap. Hermes truncates
|
||||
| xAI | 15000 |
|
||||
| MiniMax | 10000 |
|
||||
| Mistral | 4000 |
|
||||
| Google Gemini | 32000 |
|
||||
| Google Gemini | 5000 |
|
||||
| ElevenLabs | Model-aware (see below) |
|
||||
| NeuTTS | 2000 |
|
||||
| KittenTTS | 2000 |
|
||||
|
||||
@@ -280,11 +280,6 @@ thread.
|
||||
Only the first token is checked against the known command list, so
|
||||
casual messages like `!nice work` pass through to the agent unchanged.
|
||||
|
||||
Approval prompts (dangerous command / `execute_code` approval) normally
|
||||
render as interactive buttons. When buttons can't be delivered and
|
||||
Hermes falls back to a text prompt, the prompt instructs you to reply
|
||||
with `!approve` / `!deny` — the form that works inside threads.
|
||||
|
||||
### Advanced: emit only the slash-commands array
|
||||
|
||||
If you maintain your Slack manifest by hand and just want the slash
|
||||
|
||||
Reference in New Issue
Block a user