feat: add install readme et al

This commit is contained in:
Brooklyn Nicholson
2026-05-01 22:20:05 -05:00
parent 935970898f
commit 420f68e4e2
46 changed files with 3462 additions and 327 deletions

2
.gitignore vendored
View File

@@ -54,7 +54,9 @@ environments/benchmarks/evals/
# Web UI build output
hermes_cli/web_dist/
apps/desktop/build/
apps/desktop/dist/
apps/desktop/release/
apps/desktop/*.tsbuildinfo
# Web UI assets — synced from @nous-research/ui at build time via

122
apps/desktop/README.md Normal file
View File

@@ -0,0 +1,122 @@
# Hermes Desktop
Native Electron shell for Hermes. It packages the desktop renderer, a bundled Hermes source payload, and installer targets for macOS and Windows.
## Development
```bash
npm install
npm run dev
```
`npm run dev` runs Vite plus Electron against the local repo checkout. This path is for UI iteration and may still show Electron/dev identities in OS prompts.
## Build
```bash
npm run pack # unpacked app at release/mac-<arch>/Hermes.app
npm run dist:mac # macOS DMG + zip
npm run dist:mac:dmg # DMG only
npm run dist:mac:zip # zip only
npm run dist:win # NSIS + MSI
```
Before packaging, `stage:hermes` copies the Python Hermes payload into `build/hermes-agent`. Electron Builder then ships it as `Contents/Resources/hermes-agent`.
## Icons
Desktop icons live in `assets/`:
- `assets/icon.icns`
- `assets/icon.ico`
- `assets/icon.png`
The builder config points at `assets/icon`. Replace these files directly if the app icon changes.
## Testing Install Paths
Use the package-local test scripts from this directory:
```bash
npm run test:desktop:all
npm run test:desktop:existing
npm run test:desktop:fresh
npm run test:desktop:dmg
```
`test:desktop:existing` builds the packaged app and opens it normally. It should use an existing `hermes` CLI if one is on `PATH`, preserving the users real `~/.hermes` config.
`test:desktop:fresh` builds the packaged app, deletes the bundled desktop runtime, sets `HERMES_DESKTOP_IGNORE_EXISTING=1`, and launches the app through the bundled payload path. Use this repeatedly to test first-run bootstrap.
`test:desktop:dmg` builds and opens the DMG.
For fast reruns without rebuilding:
```bash
HERMES_DESKTOP_SKIP_BUILD=1 npm run test:desktop:fresh
HERMES_DESKTOP_SKIP_BUILD=1 npm run test:desktop:existing
HERMES_DESKTOP_SKIP_BUILD=1 npm run test:desktop:dmg
```
## Installing Locally
```bash
npm run dist:mac:dmg
open release/Hermes-0.0.0-arm64.dmg
```
Drag `Hermes` to Applications. If testing repeated installs, replace the existing app.
## Runtime Bootstrap
Packaged desktop startup resolves Hermes in this order:
1. `HERMES_DESKTOP_HERMES_ROOT`
2. existing `hermes` CLI, unless `HERMES_DESKTOP_IGNORE_EXISTING=1`
3. bundled `Contents/Resources/hermes-agent`
4. dev repo source
5. installed `python -m hermes_cli.main`
When the bundled path is used, Electron creates or reuses:
```text
~/Library/Application Support/Hermes/hermes-runtime
```
The runtime is validated before use. If required dashboard imports are missing, it reinstalls the desktop runtime dependencies and retries.
## Debugging
Desktop boot logs are written to:
```text
~/Library/Application Support/Hermes/desktop.log
```
If the UI reports `Desktop boot failed`, check that log first. It includes the backend command output and recent Python traceback context.
To reset bundled runtime state:
```bash
rm -rf "$HOME/Library/Application Support/Hermes/hermes-runtime"
```
To reset stale macOS microphone permission prompts:
```bash
tccutil reset Microphone com.github.Electron
tccutil reset Microphone com.nousresearch.hermes
```
## Verification
Run before handing off installer changes:
```bash
npm run fix
npm run type-check
npm run lint
npm run test:desktop:all
```
Current lint may report existing warnings, but it should exit with no errors.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 674 KiB

View File

@@ -23,9 +23,40 @@ const { spawn } = require('node:child_process')
const PORT_FLOOR = 9120
const PORT_CEILING = 9199
const DEV_SERVER = process.env.HERMES_DESKTOP_DEV_SERVER
const REPO_ROOT = path.resolve(__dirname, '../../..')
const DESKTOP_ROOT = path.resolve(__dirname, '..')
const IS_PACKAGED = app.isPackaged
const IS_MAC = process.platform === 'darwin'
const IS_WINDOWS = process.platform === 'win32'
const APP_ROOT = app.getAppPath()
const SOURCE_REPO_ROOT = path.resolve(APP_ROOT, '../..')
const BUNDLED_HERMES_ROOT = path.join(process.resourcesPath, 'hermes-agent')
const BUNDLED_VENV_ROOT = path.join(app.getPath('userData'), 'hermes-runtime')
const BUNDLED_VENV_MARKER = path.join(BUNDLED_VENV_ROOT, '.hermes-desktop-runtime.json')
const DESKTOP_LOG_PATH = path.join(app.getPath('userData'), 'desktop.log')
const RUNTIME_SCHEMA_VERSION = 3
const BUNDLED_RUNTIME_REQUIREMENTS = [
'openai>=2.21.0,<3',
'anthropic>=0.39.0,<1',
'python-dotenv>=1.2.1,<2',
'fire>=0.7.1,<1',
'httpx[socks]>=0.28.1,<1',
'rich>=14.3.3,<15',
'tenacity>=9.1.4,<10',
'pyyaml>=6.0.2,<7',
'requests>=2.32.0,<3',
'jinja2>=3.1.5,<4',
'pydantic>=2.12.5,<3',
'prompt_toolkit>=3.0.52,<4',
'exa-py>=2.9.0,<3',
'firecrawl-py>=4.16.0,<5',
'parallel-web>=0.4.2,<1',
'fal-client>=0.13.1,<1',
'croniter>=6.0.0,<7',
'edge-tts>=7.2.7,<8',
'PyJWT[crypto]>=2.12.0,<3',
'fastapi>=0.104.0,<1',
'uvicorn[standard]>=0.24.0,<1',
IS_WINDOWS ? 'pywinpty>=2.0.0,<3' : 'ptyprocess>=0.7.0,<1'
]
const APP_NAME = 'Hermes'
const TITLEBAR_HEIGHT = 34
const MACOS_TRAFFIC_LIGHTS_HEIGHT = 14
@@ -33,9 +64,17 @@ const WINDOW_BUTTON_POSITION = {
x: 24,
y: TITLEBAR_HEIGHT / 2 - MACOS_TRAFFIC_LIGHTS_HEIGHT / 2
}
const APP_ICON_PATH = path.join(DESKTOP_ROOT, 'public', 'apple-touch-icon.png')
const APP_ICON_PATHS = [
path.join(APP_ROOT, 'public', 'apple-touch-icon.png'),
path.join(APP_ROOT, 'dist', 'apple-touch-icon.png'),
path.join(unpackedPathFor(APP_ROOT), 'dist', 'apple-touch-icon.png')
]
app.setName(APP_NAME)
app.setAboutPanelOptions({
applicationName: APP_NAME,
copyright: 'Copyright © 2026 Nous Research'
})
let mainWindow = null
let hermesProcess = null
@@ -45,15 +84,324 @@ const hermesLog = []
function rememberLog(chunk) {
const text = String(chunk || '').trim()
if (!text) return
hermesLog.push(...text.split(/\r?\n/).map(line => `[hermes] ${line}`))
const lines = text.split(/\r?\n/).map(line => `[hermes] ${line}`)
hermesLog.push(...lines)
if (hermesLog.length > 300) {
hermesLog.splice(0, hermesLog.length - 300)
}
try {
fs.mkdirSync(path.dirname(DESKTOP_LOG_PATH), { recursive: true })
fs.appendFileSync(DESKTOP_LOG_PATH, `${lines.join('\n')}\n`)
} catch {
// Logging must never block app startup.
}
}
function findPython() {
const local = [path.join(REPO_ROOT, '.venv', 'bin', 'python'), path.join(REPO_ROOT, 'venv', 'bin', 'python')]
return local.find(candidate => fs.existsSync(candidate)) || 'python3'
function fileExists(filePath) {
try {
return fs.statSync(filePath).isFile()
} catch {
return false
}
}
function directoryExists(filePath) {
try {
return fs.statSync(filePath).isDirectory()
} catch {
return false
}
}
function unpackedPathFor(filePath) {
return filePath.replace(/app\.asar(?=$|[\\/])/, 'app.asar.unpacked')
}
function findOnPath(command) {
if (!command) return null
if (path.isAbsolute(command) || command.includes(path.sep) || (IS_WINDOWS && command.includes('/'))) {
return fileExists(command) ? command : null
}
const pathEntries = String(process.env.PATH || '')
.split(path.delimiter)
.filter(Boolean)
const extensions = IS_WINDOWS
? ['', ...(process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)]
: ['']
for (const entry of pathEntries) {
for (const extension of extensions) {
const candidate = path.join(entry, `${command}${extension}`)
if (fileExists(candidate)) return candidate
}
}
return null
}
function isCommandScript(command) {
return IS_WINDOWS && /\.(cmd|bat)$/i.test(command || '')
}
function isHermesSourceRoot(root) {
return directoryExists(root) && fileExists(path.join(root, 'hermes_cli', 'main.py'))
}
function findPythonForRoot(root) {
const override = process.env.HERMES_DESKTOP_PYTHON
if (override && fileExists(override)) return override
const relativePaths = IS_WINDOWS
? [path.join('.venv', 'Scripts', 'python.exe'), path.join('venv', 'Scripts', 'python.exe')]
: [path.join('.venv', 'bin', 'python'), path.join('venv', 'bin', 'python')]
for (const relativePath of relativePaths) {
const candidate = path.join(root, relativePath)
if (fileExists(candidate)) return candidate
}
return findSystemPython()
}
function findSystemPython() {
const commands = IS_WINDOWS ? ['python.exe', 'py.exe', 'python'] : ['python3', 'python']
for (const command of commands) {
const candidate = findOnPath(command)
if (candidate) return candidate
}
return null
}
function getVenvPython(venvRoot) {
return path.join(venvRoot, IS_WINDOWS ? path.join('Scripts', 'python.exe') : path.join('bin', 'python'))
}
function runProcess(command, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: options.cwd,
env: options.env || process.env,
shell: Boolean(options.shell),
stdio: ['ignore', 'pipe', 'pipe']
})
child.stdout.on('data', rememberLog)
child.stderr.on('data', rememberLog)
child.once('error', reject)
child.once('exit', code => {
if (code === 0) {
resolve()
} else {
reject(new Error(`${path.basename(command)} exited with code ${code}: ${recentHermesLog()}`))
}
})
})
}
function recentHermesLog() {
return hermesLog.slice(-20).join('\n')
}
function readJson(filePath) {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
} catch {
return null
}
}
function resolveWebDist() {
const override = process.env.HERMES_DESKTOP_WEB_DIST
if (override && directoryExists(path.resolve(override))) return path.resolve(override)
const unpackedDist = path.join(unpackedPathFor(APP_ROOT), 'dist')
if (directoryExists(unpackedDist)) return unpackedDist
return path.join(APP_ROOT, 'dist')
}
function resolveRendererIndex() {
const candidates = [path.join(APP_ROOT, 'dist', 'index.html'), path.join(resolveWebDist(), 'index.html')]
return candidates.find(fileExists) || candidates[0]
}
function resolveHermesCwd() {
const candidates = [
process.env.HERMES_DESKTOP_CWD,
process.env.INIT_CWD,
process.cwd(),
!IS_PACKAGED ? SOURCE_REPO_ROOT : null,
app.getPath('home')
]
for (const candidate of candidates) {
if (!candidate) continue
const resolved = path.resolve(String(candidate))
if (directoryExists(resolved)) return resolved
}
return app.getPath('home')
}
function createPythonBackend(root, label, dashboardArgs, options = {}) {
const python = findPythonForRoot(root)
if (!python) return null
return {
kind: 'python',
label,
command: python,
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
env: {
PYTHONPATH: [root, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter)
},
root,
bootstrap: Boolean(options.bootstrap),
shell: false
}
}
function createBundledBackend(root, dashboardArgs) {
const python = getVenvPython(BUNDLED_VENV_ROOT)
return {
kind: 'python',
label: 'bundled Hermes',
command: fileExists(python) ? python : findSystemPython(),
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
env: {
PYTHONPATH: [root, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter)
},
root,
bootstrap: true,
shell: false
}
}
function resolveHermesBackend(dashboardArgs) {
const overrideRoot = process.env.HERMES_DESKTOP_HERMES_ROOT && path.resolve(process.env.HERMES_DESKTOP_HERMES_ROOT)
if (overrideRoot && isHermesSourceRoot(overrideRoot)) {
const backend = createPythonBackend(overrideRoot, `Hermes source at ${overrideRoot}`, dashboardArgs)
if (backend) return backend
}
if (process.env.HERMES_DESKTOP_IGNORE_EXISTING !== '1') {
const hermesCommand = process.env.HERMES_DESKTOP_HERMES
? findOnPath(process.env.HERMES_DESKTOP_HERMES) || process.env.HERMES_DESKTOP_HERMES
: findOnPath('hermes')
if (hermesCommand) {
return {
label: `existing Hermes CLI at ${hermesCommand}`,
command: hermesCommand,
args: dashboardArgs,
bootstrap: false,
env: {},
kind: 'command',
shell: isCommandScript(hermesCommand)
}
}
}
if (IS_PACKAGED && isHermesSourceRoot(BUNDLED_HERMES_ROOT)) {
const backend = createBundledBackend(BUNDLED_HERMES_ROOT, dashboardArgs)
if (backend.command) return backend
}
if (!IS_PACKAGED && isHermesSourceRoot(SOURCE_REPO_ROOT)) {
const backend = createPythonBackend(SOURCE_REPO_ROOT, `Hermes source at ${SOURCE_REPO_ROOT}`, dashboardArgs)
if (backend) return backend
}
const python = findSystemPython()
if (python) {
return {
kind: 'python',
label: `installed hermes_cli module via ${python}`,
command: python,
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
bootstrap: false,
env: {},
shell: false
}
}
throw new Error('Could not find Hermes. Install the Hermes CLI or set HERMES_DESKTOP_HERMES_ROOT.')
}
async function ensureBundledRuntime(backend) {
if (!backend.bootstrap) return backend
const sourceVersion = readJson(path.join(backend.root, 'package.json'))?.version || app.getVersion()
const marker = readJson(BUNDLED_VENV_MARKER)
const venvPython = getVenvPython(BUNDLED_VENV_ROOT)
const runtimeReady =
fileExists(venvPython) &&
marker?.sourceVersion === sourceVersion &&
marker?.runtimeSchemaVersion === RUNTIME_SCHEMA_VERSION &&
(await hasBundledRuntimeImports(venvPython))
if (runtimeReady) {
backend.command = venvPython
backend.label = `${backend.label} runtime at ${BUNDLED_VENV_ROOT}`
return backend
}
const systemPython = findSystemPython()
if (!systemPython) {
throw new Error('Python 3.11+ is required to bootstrap the bundled Hermes runtime.')
}
rememberLog(`Preparing bundled Hermes runtime in ${BUNDLED_VENV_ROOT}`)
fs.mkdirSync(BUNDLED_VENV_ROOT, { recursive: true })
if (!fileExists(venvPython)) {
await runProcess(systemPython, ['-m', 'venv', BUNDLED_VENV_ROOT])
}
await runProcess(venvPython, [
'-m',
'pip',
'install',
'--disable-pip-version-check',
'--no-warn-script-location',
'--upgrade',
...BUNDLED_RUNTIME_REQUIREMENTS
])
await runProcess(venvPython, ['-c', 'import fastapi, uvicorn, ptyprocess'])
fs.writeFileSync(
BUNDLED_VENV_MARKER,
JSON.stringify(
{
runtimeSchemaVersion: RUNTIME_SCHEMA_VERSION,
sourceVersion,
installedAt: new Date().toISOString()
},
null,
2
)
)
backend.command = venvPython
backend.label = `${backend.label} runtime at ${BUNDLED_VENV_ROOT}`
return backend
}
async function hasBundledRuntimeImports(python) {
try {
await runProcess(python, ['-c', 'import fastapi, uvicorn, ptyprocess'])
return true
} catch {
rememberLog('Bundled Hermes runtime is missing required dashboard dependencies; reinstalling.')
return false
}
}
function isPortAvailable(port) {
@@ -226,22 +574,15 @@ function getWindowButtonPosition() {
return mainWindow?.getWindowButtonPosition?.() || WINDOW_BUTTON_POSITION
}
function getAppIconPath() {
return fs.existsSync(APP_ICON_PATH) ? APP_ICON_PATH : undefined
function sendBackendExit(payload) {
if (!mainWindow || mainWindow.isDestroyed()) return
const { webContents } = mainWindow
if (!webContents || webContents.isDestroyed()) return
webContents.send('hermes:backend-exit', payload)
}
function resolveHermesCwd() {
const candidates = [process.env.HERMES_DESKTOP_CWD, process.env.INIT_CWD, process.cwd(), REPO_ROOT]
for (const candidate of candidates) {
if (!candidate) continue
const resolved = path.resolve(String(candidate))
try {
if (fs.statSync(resolved).isDirectory()) return resolved
} catch {
// Try the next candidate.
}
}
return REPO_ROOT
function getAppIconPath() {
return APP_ICON_PATHS.find(fileExists)
}
function buildApplicationMenu() {
@@ -421,43 +762,57 @@ async function startHermes() {
connectionPromise = (async () => {
const port = await pickPort()
const token = crypto.randomBytes(32).toString('base64url')
const python = findPython()
const args = [
'-m',
'hermes_cli.main',
'dashboard',
'--no-open',
'--tui',
'--host',
'127.0.0.1',
'--port',
String(port)
]
const dashboardArgs = ['dashboard', '--no-open', '--tui', '--host', '127.0.0.1', '--port', String(port)]
const backend = await ensureBundledRuntime(resolveHermesBackend(dashboardArgs))
const hermesCwd = resolveHermesCwd()
const webDist = resolveWebDist()
hermesProcess = spawn(python, args, {
rememberLog(`Starting Hermes backend via ${backend.label}`)
hermesProcess = spawn(backend.command, backend.args, {
cwd: hermesCwd,
env: {
...process.env,
PYTHONPATH: [REPO_ROOT, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter),
...backend.env,
HERMES_DASHBOARD_SESSION_TOKEN: token,
HERMES_DASHBOARD_TUI: '1',
HERMES_WEB_DIST: path.join(REPO_ROOT, 'apps', 'desktop', 'dist')
HERMES_WEB_DIST: webDist
},
shell: backend.shell,
stdio: ['ignore', 'pipe', 'pipe']
})
hermesProcess.stdout.on('data', rememberLog)
hermesProcess.stderr.on('data', rememberLog)
let backendReady = false
let rejectBackendStart = null
const backendStartFailed = new Promise((_resolve, reject) => {
rejectBackendStart = reject
})
hermesProcess.once('error', error => {
rememberLog(`Hermes backend failed to start: ${error.message}`)
hermesProcess = null
connectionPromise = null
sendBackendExit({ code: null, signal: null, error: error.message })
rejectBackendStart?.(error)
})
hermesProcess.once('exit', (code, signal) => {
rememberLog(`Hermes dashboard exited (${signal || code})`)
hermesProcess = null
connectionPromise = null
mainWindow?.webContents.send('hermes:backend-exit', { code, signal })
sendBackendExit({ code, signal })
if (!backendReady) {
rejectBackendStart?.(
new Error(
`Hermes dashboard exited before it became ready (${signal || code}). Log: ${DESKTOP_LOG_PATH}\n${recentHermesLog()}`
)
)
}
})
const baseUrl = `http://127.0.0.1:${port}`
await waitForHermes(baseUrl, token)
await Promise.race([waitForHermes(baseUrl, token), backendStartFailed])
backendReady = true
return {
baseUrl,
@@ -506,7 +861,7 @@ function createWindow() {
if (DEV_SERVER) {
mainWindow.loadURL(DEV_SERVER)
} else {
mainWindow.loadURL(pathToFileURL(path.join(__dirname, '..', 'dist', 'index.html')).toString())
mainWindow.loadURL(pathToFileURL(resolveRendererIndex()).toString())
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,10 @@
{
"name": "@hermes-agent/desktop",
"name": "hermes",
"productName": "Hermes",
"private": true,
"version": "0.0.0",
"description": "Native desktop shell for Hermes Agent.",
"author": "Nous Research",
"type": "module",
"main": "electron/main.cjs",
"scripts": {
@@ -12,6 +13,20 @@
"dev:electron": "wait-on http://127.0.0.1:5174 && cross-env HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
"start": "npm run build && electron .",
"build": "tsc -b && vite build",
"stage:hermes": "node scripts/stage-hermes-payload.mjs",
"pack": "npm run build && npm run stage:hermes && electron-builder --dir",
"dist": "npm run build && npm run stage:hermes && electron-builder",
"dist:mac": "npm run build && npm run stage:hermes && electron-builder --mac",
"dist:mac:dmg": "npm run build && npm run stage:hermes && electron-builder --mac dmg",
"dist:mac:zip": "npm run build && npm run stage:hermes && electron-builder --mac zip",
"dist:win": "npm run build && npm run stage:hermes && electron-builder --win",
"dist:win:msi": "npm run build && npm run stage:hermes && electron-builder --win msi",
"dist:win:nsis": "npm run build && npm run stage:hermes && electron-builder --win nsis",
"test:desktop": "node scripts/test-desktop.mjs",
"test:desktop:all": "node scripts/test-desktop.mjs all",
"test:desktop:dmg": "node scripts/test-desktop.mjs dmg",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"type-check": "tsc -b",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
@@ -59,6 +74,7 @@
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"electron": "^40.9.3",
"electron-builder": "^26.8.1",
"eslint": "^9.39.4",
"eslint-plugin-perfectionist": "^5.9.0",
"eslint-plugin-react": "^7.37.5",
@@ -72,5 +88,82 @@
"vite": "^8.0.10",
"vitest": "^4.1.5",
"wait-on": "^9.0.5"
},
"build": {
"appId": "com.nousresearch.hermes",
"productName": "Hermes",
"executableName": "Hermes",
"artifactName": "Hermes-${version}-${arch}.${ext}",
"icon": "assets/icon",
"directories": {
"output": "release"
},
"files": [
"dist/**",
"assets/**",
"electron/**",
"public/**",
"package.json"
],
"extraResources": [
{
"from": "build/hermes-agent",
"to": "hermes-agent"
}
],
"asar": true,
"asarUnpack": [
"dist/**"
],
"mac": {
"category": "public.app-category.developer-tools",
"extendInfo": {
"CFBundleDisplayName": "Hermes",
"CFBundleExecutable": "Hermes",
"CFBundleName": "Hermes",
"NSAudioCaptureUsageDescription": "Hermes uses audio capture for voice conversations.",
"NSMicrophoneUsageDescription": "Hermes uses the microphone for voice input and voice conversations."
},
"target": [
"dmg",
"zip"
]
},
"dmg": {
"title": "Install Hermes",
"backgroundColor": "#f5f5f7",
"iconSize": 96,
"window": {
"width": 560,
"height": 360
},
"contents": [
{
"x": 160,
"y": 170,
"type": "file"
},
{
"x": 400,
"y": 170,
"type": "link",
"path": "/Applications"
}
]
},
"win": {
"legalTrademarks": "Hermes",
"target": [
"nsis",
"msi"
]
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"perMachine": false,
"shortcutName": "Hermes",
"uninstallDisplayName": "Hermes"
}
}
}

View File

@@ -0,0 +1,109 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const DESKTOP_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '../..')
const OUT_ROOT = path.join(DESKTOP_ROOT, 'build', 'hermes-agent')
const ROOT_FILES = [
'README.md',
'LICENSE',
'pyproject.toml',
'run_agent.py',
'model_tools.py',
'toolsets.py',
'batch_runner.py',
'trajectory_compressor.py',
'toolset_distributions.py',
'cli.py',
'hermes_constants.py',
'hermes_logging.py',
'hermes_state.py',
'hermes_time.py',
'rl_cli.py',
'utils.py'
]
const ROOT_DIRS = [
'acp_adapter',
'agent',
'cron',
'gateway',
'hermes_cli',
'plugins',
'scripts',
'skills',
'tools',
'tui_gateway'
]
const TUI_FILES = ['package.json', 'package-lock.json']
const TUI_DIRS = ['dist', 'packages/hermes-ink/dist']
const EXCLUDED_NAMES = new Set([
'.DS_Store',
'.git',
'.mypy_cache',
'.pytest_cache',
'.ruff_cache',
'.venv',
'__pycache__',
'node_modules',
'release',
'venv'
])
function keep(entry) {
return !EXCLUDED_NAMES.has(entry.name) && !entry.name.endsWith('.pyc') && !entry.name.endsWith('.pyo')
}
async function exists(target) {
try {
await fs.access(target)
return true
} catch {
return false
}
}
async function copyFileIfPresent(relativePath) {
const from = path.join(REPO_ROOT, relativePath)
if (!(await exists(from))) return
const to = path.join(OUT_ROOT, relativePath)
await fs.mkdir(path.dirname(to), { recursive: true })
await fs.copyFile(from, to)
}
async function copyDirIfPresent(relativePath) {
const from = path.join(REPO_ROOT, relativePath)
if (!(await exists(from))) return
const to = path.join(OUT_ROOT, relativePath)
await fs.cp(from, to, {
recursive: true,
filter: source => keep({ name: path.basename(source) })
})
}
async function main() {
await fs.rm(OUT_ROOT, { force: true, recursive: true })
await fs.mkdir(OUT_ROOT, { recursive: true })
await Promise.all(ROOT_FILES.map(copyFileIfPresent))
for (const dir of ROOT_DIRS) {
await copyDirIfPresent(dir)
}
for (const file of TUI_FILES) {
await copyFileIfPresent(path.join('ui-tui', file))
}
for (const dir of TUI_DIRS) {
await copyDirIfPresent(path.join('ui-tui', dir))
}
}
await main()

View File

@@ -0,0 +1,171 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { spawn, spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
const DESKTOP_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const PACKAGE_JSON = JSON.parse(fs.readFileSync(path.join(DESKTOP_ROOT, 'package.json'), 'utf8'))
const MODE = process.argv[2] || 'help'
const ARCH = process.arch === 'arm64' ? 'arm64' : 'x64'
const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release')
const APP_PATH = path.join(RELEASE_ROOT, `mac-${ARCH}`, 'Hermes.app')
const APP_BIN = path.join(APP_PATH, 'Contents', 'MacOS', 'Hermes')
const DMG_PATH = path.join(RELEASE_ROOT, `Hermes-${PACKAGE_JSON.version}-${ARCH}.dmg`)
const USER_DATA = path.join(os.homedir(), 'Library', 'Application Support', 'Hermes')
const RUNTIME_ROOT = path.join(USER_DATA, 'hermes-runtime')
function die(message) {
console.error(`\n${message}`)
process.exit(1)
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd || DESKTOP_ROOT,
env: options.env || process.env,
shell: Boolean(options.shell),
stdio: 'inherit'
})
if (result.status !== 0) {
die(`${command} ${args.join(' ')} failed`)
}
}
function output(command, args) {
const result = spawnSync(command, args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
})
return result.status === 0 ? result.stdout.trim() : ''
}
function exists(target) {
return fs.existsSync(target)
}
function ensureMac() {
if (process.platform !== 'darwin') {
die('Desktop launch tests are macOS-only from this script.')
}
}
function ensurePackagedApp() {
if (process.env.HERMES_DESKTOP_SKIP_BUILD === '1' && exists(APP_BIN)) {
return
}
run('npm', ['run', 'pack'])
}
function ensureDmg() {
if (process.env.HERMES_DESKTOP_SKIP_BUILD === '1' && exists(DMG_PATH)) {
return
}
run('npm', ['run', 'dist:mac:dmg'])
}
function openApp() {
if (!exists(APP_PATH)) {
die(`Missing packaged app: ${APP_PATH}`)
}
run('open', ['-n', APP_PATH])
}
function openDmg() {
if (!exists(DMG_PATH)) {
die(`Missing DMG: ${DMG_PATH}`)
}
run('open', [DMG_PATH])
}
function launchFresh() {
if (!exists(APP_BIN)) {
die(`Missing app executable: ${APP_BIN}`)
}
fs.rmSync(RUNTIME_ROOT, { force: true, recursive: true })
const python = output('which', ['python3'])
if (!python) {
die('python3 is required for fresh bundled-runtime bootstrap.')
}
const env = {
...process.env,
HERMES_DESKTOP_IGNORE_EXISTING: '1',
HERMES_DESKTOP_TEST_MODE: 'fresh-bundled-runtime'
}
delete env.HERMES_DESKTOP_HERMES
delete env.HERMES_DESKTOP_HERMES_ROOT
const child = spawn(APP_BIN, [], {
cwd: os.homedir(),
detached: true,
env,
stdio: 'ignore'
})
child.unref()
}
function validateBundle() {
const required = [
APP_BIN,
path.join(APP_PATH, 'Contents', 'Resources', 'hermes-agent', 'hermes_cli', 'main.py'),
path.join(APP_PATH, 'Contents', 'Resources', 'app.asar.unpacked', 'dist', 'index.html')
]
for (const target of required) {
if (!exists(target)) {
die(`Missing packaged payload file: ${target}`)
}
}
}
function printArtifacts() {
console.log('\nDesktop artifacts:')
console.log(` app: ${APP_PATH}`)
console.log(` dmg: ${DMG_PATH}`)
console.log(` runtime: ${RUNTIME_ROOT}`)
}
function help() {
console.log(`Usage:
npm run test:desktop:existing # build packaged app, launch with normal PATH/existing Hermes
npm run test:desktop:fresh # build packaged app, delete bundled runtime, hide existing Hermes, launch
npm run test:desktop:dmg # build DMG and open it
npm run test:desktop:all # build DMG, validate app payload, print paths
Fast rerun:
HERMES_DESKTOP_SKIP_BUILD=1 npm run test:desktop:fresh
`)
}
ensureMac()
if (MODE === 'existing') {
ensurePackagedApp()
validateBundle()
openApp()
printArtifacts()
} else if (MODE === 'fresh') {
ensurePackagedApp()
validateBundle()
launchFresh()
printArtifacts()
} else if (MODE === 'dmg') {
ensureDmg()
openDmg()
printArtifacts()
} else if (MODE === 'all') {
ensureDmg()
validateBundle()
printArtifacts()
} else {
help()
}

View File

@@ -105,7 +105,13 @@ function artifactKind(value: string): ArtifactKind {
return 'image'
}
if (value.startsWith('/') || value.startsWith('./') || value.startsWith('../') || value.startsWith('~/') || value.startsWith('file://')) {
if (
value.startsWith('/') ||
value.startsWith('./') ||
value.startsWith('../') ||
value.startsWith('~/') ||
value.startsWith('file://')
) {
return 'file'
}
@@ -113,7 +119,12 @@ function artifactKind(value: string): ArtifactKind {
}
function artifactHref(value: string): string {
if (value.startsWith('http://') || value.startsWith('https://') || value.startsWith('file://') || value.startsWith('data:')) {
if (
value.startsWith('http://') ||
value.startsWith('https://') ||
value.startsWith('file://') ||
value.startsWith('data:')
) {
return value
}
@@ -153,7 +164,11 @@ function messageText(message: SessionMessage): string {
return ''
}
function collectStringValues(value: unknown, keyPath: string, collector: (value: string, keyPath: string) => void): void {
function collectStringValues(
value: unknown,
keyPath: string,
collector: (value: string, keyPath: string) => void
): void {
if (typeof value === 'string') {
collector(value, keyPath)

View File

@@ -46,13 +46,7 @@ export function ComposerCompletionDrawer({
)
}
export function CompletionDrawerEmpty({
children,
title
}: {
children?: ReactNode
title: string
}) {
export function CompletionDrawerEmpty({ children, title }: { children?: ReactNode; title: string }) {
return (
<div className="px-3 py-3 text-sm text-muted-foreground">
<p>{title}</p>

View File

@@ -1,4 +1,13 @@
import { Clipboard, FileText, FolderOpen, ImageIcon, Link, type LucideIcon, MessageSquareText, Plus } from 'lucide-react'
import {
Clipboard,
FileText,
FolderOpen,
ImageIcon,
Link,
type LucideIcon,
MessageSquareText,
Plus
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import {

View File

@@ -178,13 +178,7 @@ function ConversationIndicator({
{bars.map((weight, index) => {
const height = listening ? 0.3 + Math.min(0.7, normalized * weight) : 0.3
return (
<span
className="w-0.5 rounded-full bg-current"
key={index}
style={{ height: `${height * 100}%` }}
/>
)
return <span className="w-0.5 rounded-full bg-current" key={index} style={{ height: `${height * 100}%` }} />
})}
</span>
)
@@ -204,11 +198,7 @@ function DictationButton({
const active = state.active || status !== 'idle'
const aria =
status === 'recording'
? 'Stop dictation'
: status === 'transcribing'
? 'Transcribing dictation'
: 'Voice dictation'
status === 'recording' ? 'Stop dictation' : status === 'transcribing' ? 'Transcribing dictation' : 'Voice dictation'
return (
<Button

View File

@@ -1,11 +1,7 @@
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
import { ComposerPrimitive, type Unstable_MentionDirective } from '@assistant-ui/react'
import {
ComposerCompletionDrawer,
CompletionDrawerEmpty,
COMPLETION_DRAWER_ROW_CLASS
} from './completion-drawer'
import { COMPLETION_DRAWER_ROW_CLASS, CompletionDrawerEmpty, ComposerCompletionDrawer } from './completion-drawer'
export function DirectivePopover({
adapter,
@@ -44,11 +40,7 @@ function DirectiveRow({ index, item }: { index: number; item: Unstable_TriggerIt
const description = metadata?.meta || item.description
return (
<ComposerPrimitive.Unstable_TriggerPopoverItem
className={COMPLETION_DRAWER_ROW_CLASS}
index={index}
item={item}
>
<ComposerPrimitive.Unstable_TriggerPopoverItem className={COMPLETION_DRAWER_ROW_CLASS} index={index} item={item}>
<span className="shrink-0 truncate font-mono font-medium leading-5 text-foreground">{display}</span>
{description && <span className="min-w-0 truncate leading-5 text-muted-foreground/80">{description}</span>}
</ComposerPrimitive.Unstable_TriggerPopoverItem>

View File

@@ -59,7 +59,9 @@ function Row({ description, keyLabel, mono = false }: { description: string; key
return (
<div className="flex min-w-0 items-baseline gap-2 rounded-md px-2.5 py-1 text-xs">
<span
className={mono ? 'shrink-0 truncate font-mono font-medium text-foreground/85' : 'shrink-0 truncate text-foreground/85'}
className={
mono ? 'shrink-0 truncate font-mono font-medium text-foreground/85' : 'shrink-0 truncate text-foreground/85'
}
>
{keyLabel}
</span>

View File

@@ -181,6 +181,7 @@ export function useMicRecorder(): { handle: MicRecorderHandle; level: number; re
['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus', 'audio/ogg', 'audio/wav'].find(
type => MediaRecorder.isTypeSupported(type)
) ?? ''
let recorder: MediaRecorder
try {

View File

@@ -38,9 +38,10 @@ function commandText(value: string): string {
}
/** Live `/` completions backed by the gateway's `complete.slash` RPC. */
export function useSlashCompletions(options: {
gateway: HermesGateway | null
}): { adapter: Unstable_TriggerAdapter; loading: boolean } {
export function useSlashCompletions(options: { gateway: HermesGateway | null }): {
adapter: Unstable_TriggerAdapter
loading: boolean
} {
const { gateway } = options
const enabled = Boolean(gateway)

View File

@@ -104,7 +104,11 @@ export function useVoiceConversation({
}
if (!force && buffer.length > 220) {
const softBoundary = Math.max(buffer.lastIndexOf(', ', 180), buffer.lastIndexOf('; ', 180), buffer.lastIndexOf(': ', 180))
const softBoundary = Math.max(
buffer.lastIndexOf(', ', 180),
buffer.lastIndexOf('; ', 180),
buffer.lastIndexOf(': ', 180)
)
if (softBoundary > 80) {
const chunk = buffer.slice(0, softBoundary + 1).trim()
@@ -123,33 +127,21 @@ export function useVoiceConversation({
return buffer
}
const handleTurn = useCallback(async (forceTranscribe = false) => {
if (turnClosingRef.current) {
return
}
turnClosingRef.current = true
clearTurnTimeout()
setStatus('transcribing')
try {
const result = await handle.stop()
if (!result || (!result.heardSpeech && !forceTranscribe) || !onTranscribeAudio) {
if (enabledRef.current && !mutedRef.current && !busyRef.current && statusRef.current !== 'speaking') {
pendingStartRef.current = true
}
setStatus('idle')
const handleTurn = useCallback(
async (forceTranscribe = false) => {
if (turnClosingRef.current) {
return
}
try {
const transcript = (await onTranscribeAudio(result.audio)).trim()
turnClosingRef.current = true
clearTurnTimeout()
setStatus('transcribing')
if (!transcript) {
if (enabledRef.current) {
try {
const result = await handle.stop()
if (!result || (!result.heardSpeech && !forceTranscribe) || !onTranscribeAudio) {
if (enabledRef.current && !mutedRef.current && !busyRef.current && statusRef.current !== 'speaking') {
pendingStartRef.current = true
}
@@ -158,23 +150,38 @@ export function useVoiceConversation({
return
}
awaitingSpokenResponseRef.current = true
resetSpeechBuffer()
await onSubmit(transcript)
setStatus('thinking')
} catch (error) {
notifyError(error, 'Voice transcription failed')
try {
const transcript = (await onTranscribeAudio(result.audio)).trim()
if (enabledRef.current && !mutedRef.current && !busyRef.current) {
pendingStartRef.current = true
if (!transcript) {
if (enabledRef.current) {
pendingStartRef.current = true
}
setStatus('idle')
return
}
awaitingSpokenResponseRef.current = true
resetSpeechBuffer()
await onSubmit(transcript)
setStatus('thinking')
} catch (error) {
notifyError(error, 'Voice transcription failed')
if (enabledRef.current && !mutedRef.current && !busyRef.current) {
pendingStartRef.current = true
}
setStatus('idle')
}
setStatus('idle')
} finally {
turnClosingRef.current = false
}
} finally {
turnClosingRef.current = false
}
}, [handle, onSubmit, onTranscribeAudio])
},
[handle, onSubmit, onTranscribeAudio]
)
const startListening = useCallback(async () => {
pendingStartRef.current = false
@@ -210,25 +217,22 @@ export function useVoiceConversation({
}
}, [handle, handleTurn, onFatalError])
const speak = useCallback(
async (text: string) => {
setStatus('speaking')
const speak = useCallback(async (text: string) => {
setStatus('speaking')
try {
await playSpeechText(text, { source: 'voice-conversation' })
} catch (error) {
notifyError(error, 'Voice playback failed')
} finally {
if (enabledRef.current) {
pendingStartRef.current = true
setStatus('idle')
} else {
setStatus('idle')
}
try {
await playSpeechText(text, { source: 'voice-conversation' })
} catch (error) {
notifyError(error, 'Voice playback failed')
} finally {
if (enabledRef.current) {
pendingStartRef.current = true
setStatus('idle')
} else {
setStatus('idle')
}
},
[]
)
}
}, [])
const start = useCallback(async () => {
if (!onTranscribeAudio) {

View File

@@ -86,10 +86,7 @@ export function useVoiceRecorder({
startedAtRef.current = Date.now()
setElapsedSeconds(0)
setVoiceStatus('recording')
intervalRef.current = window.setInterval(
() => setElapsedSeconds((Date.now() - startedAtRef.current) / 1000),
250
)
intervalRef.current = window.setInterval(() => setElapsedSeconds((Date.now() - startedAtRef.current) / 1000), 250)
const cap = Math.max(1, Math.min(Math.trunc(maxRecordingSeconds), 600))
timeoutRef.current = window.setTimeout(() => void stop(), cap * 1000)
} catch (error) {

View File

@@ -378,10 +378,7 @@ export function ChatBar({
loading={at.loading}
/>
<SlashPopover adapter={slash.adapter} loading={slash.loading} />
<div
className="pointer-events-none absolute inset-0"
style={{ background: glassTweaks.fadeBackground }}
/>
<div className="pointer-events-none absolute inset-0" style={{ background: glassTweaks.fadeBackground }} />
<div className="relative w-full">
<div
className={cn(
@@ -430,9 +427,7 @@ export function ChatBar({
>
<VoiceActivity state={voiceActivityState} />
<VoicePlaybackActivity />
{attachments.length > 0 && (
<AttachmentList attachments={attachments} onRemove={onRemoveAttachment} />
)}
{attachments.length > 0 && <AttachmentList attachments={attachments} onRemove={onRemoveAttachment} />}
{stacked ? (
<>
{input}

View File

@@ -1,11 +1,7 @@
import type { Unstable_DirectiveFormatter, Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
import { ComposerPrimitive } from '@assistant-ui/react'
import {
ComposerCompletionDrawer,
CompletionDrawerEmpty,
COMPLETION_DRAWER_ROW_CLASS
} from './completion-drawer'
import { COMPLETION_DRAWER_ROW_CLASS, CompletionDrawerEmpty, ComposerCompletionDrawer } from './completion-drawer'
const slashFormatter: Unstable_DirectiveFormatter = {
serialize(item: Unstable_TriggerItem): string {

View File

@@ -1,3 +1,5 @@
import type { HermesGateway } from '@/hermes'
export interface ContextSuggestion {
text: string
display: string
@@ -28,7 +30,7 @@ export interface ChatBarProps {
focusKey?: string | null
maxRecordingSeconds?: number
state: ChatBarState
gateway?: import('@/hermes').HermesGateway | null
gateway?: HermesGateway | null
sessionId?: string | null
cwd?: string | null
onCancel: () => void

View File

@@ -2,7 +2,14 @@ import { Globe } from 'lucide-react'
import type * as React from 'react'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
const URL_HINT = /^https?:\/\//i

View File

@@ -162,11 +162,7 @@ function PlaybackWaveform({ audioElement }: { audioElement: HTMLAudioElement | n
return <canvas aria-hidden="true" className="block h-4 w-[88px]" ref={canvasRef} />
}
export function VoiceActivity({
state
}: {
state: VoiceActivityState
}) {
export function VoiceActivity({ state }: { state: VoiceActivityState }) {
if (state.status === 'idle') {
return null
}
@@ -194,7 +190,9 @@ export function VoiceActivity({
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate font-medium text-foreground/85">{title}</span>
<span className="font-mono text-[0.6875rem] text-muted-foreground/85">{formatElapsed(state.elapsedSeconds)}</span>
<span className="font-mono text-[0.6875rem] text-muted-foreground/85">
{formatElapsed(state.elapsedSeconds)}
</span>
</div>
<VoiceLevelBars active={recording} level={state.level} />

View File

@@ -2,11 +2,7 @@ import { useCallback } from 'react'
import { formatRefValue } from '@/components/assistant-ui/directive-text'
import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime'
import {
addComposerAttachment,
type ComposerAttachment,
removeComposerAttachment
} from '@/store/composer'
import { addComposerAttachment, type ComposerAttachment, removeComposerAttachment } from '@/store/composer'
import { notify, notifyError } from '@/store/notifications'
import type { ImageAttachResponse, ImageDetachResponse } from '../../types'
@@ -92,6 +88,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
session_id: activeSessionId,
path
})
const attachedPath = result.path || path
if (result.attached) {

View File

@@ -14,8 +14,10 @@ import {
$gatewayState
} from '@/store/session'
interface ChatRightRailProps
extends Pick<React.ComponentProps<typeof SessionInspector>, 'onBrowseCwd' | 'onChangeCwd'> {
interface ChatRightRailProps extends Pick<
React.ComponentProps<typeof SessionInspector>,
'onBrowseCwd' | 'onChangeCwd'
> {
onOpenModelPicker: () => void
onSelectPersonality: (name: string) => void
}

View File

@@ -74,12 +74,16 @@ export function ChatSidebar({
const sessionsLoading = useStore($sessionsLoading)
const workingSessionIds = useStore($workingSessionIds)
const sortedSessions = useMemo(() => [...sessions].sort((a, b) => {
const aTime = a.last_active || a.started_at || 0
const bTime = b.last_active || b.started_at || 0
const sortedSessions = useMemo(
() =>
[...sessions].sort((a, b) => {
const aTime = a.last_active || a.started_at || 0
const bTime = b.last_active || b.started_at || 0
return bTime - aTime
}), [sessions])
return bTime - aTime
}),
[sessions]
)
const sessionsById = useMemo(() => new Map(sessions.map(session => [session.id, session])), [sessions])
const workingSessionIdSet = useMemo(() => new Set(workingSessionIds), [workingSessionIds])

View File

@@ -5,6 +5,7 @@ import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from 're
import type { ModelOptionsResponse, SessionRuntimeInfo } from '@/types/hermes'
import { formatRefValue } from '../components/assistant-ui/directive-text'
import {
getGlobalModelInfo,
getHermesConfig,
@@ -14,7 +15,6 @@ import {
listSessions,
setGlobalModel
} from '../hermes'
import { formatRefValue } from '../components/assistant-ui/directive-text'
import { toChatMessages } from '../lib/chat-messages'
import { BUILTIN_PERSONALITIES, normalizePersonalityValue, personalityNamesFromConfig } from '../lib/chat-runtime'
import { $pinnedSessionIds, pinSession, unpinSession } from '../store/layout'
@@ -48,13 +48,7 @@ import { ChatSidebar } from './chat/sidebar'
import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
import { ModelPickerOverlay } from './model-picker-overlay'
import {
appViewForPath,
isNewChatRoute,
NEW_CHAT_ROUTE,
routeSessionId,
sessionRoute
} from './routes'
import { appViewForPath, isNewChatRoute, NEW_CHAT_ROUTE, routeSessionId, sessionRoute } from './routes'
import { useMessageStream } from './session/hooks/use-message-stream'
import { usePromptActions } from './session/hooks/use-prompt-actions'
import { useSessionActions } from './session/hooks/use-session-actions'

View File

@@ -7,7 +7,9 @@ import type { RpcEvent } from '@/types/hermes'
interface GatewayBootOptions {
handleGatewayEvent: (event: RpcEvent) => void
onConnectionReady: (connection: Awaited<ReturnType<NonNullable<typeof window.hermesDesktop>['getConnection']>> | null) => void
onConnectionReady: (
connection: Awaited<ReturnType<NonNullable<typeof window.hermesDesktop>['getConnection']>> | null
) => void
onGatewayReady: (gateway: HermesGateway | null) => void
refreshHermesConfig: () => Promise<void>
refreshSessions: () => Promise<void>
@@ -87,11 +89,5 @@ export function useGatewayBoot({
onConnectionReady(null)
onGatewayReady(null)
}
}, [
handleGatewayEvent,
onConnectionReady,
onGatewayReady,
refreshHermesConfig,
refreshSessions
])
}, [handleGatewayEvent, onConnectionReady, onGatewayReady, refreshHermesConfig, refreshSessions])
}

View File

@@ -1,15 +1,17 @@
import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useRef } from 'react'
import { HermesGateway } from '@/hermes'
import type { HermesGateway } from '@/hermes'
import { $gatewayState, setConnection } from '@/store/session'
export function useGatewayRequest() {
const gatewayState = useStore($gatewayState)
const gatewayRef = useRef<HermesGateway | null>(null)
const connectionRef = useRef<Awaited<ReturnType<NonNullable<typeof window.hermesDesktop>['getConnection']>> | null>(
null
)
const gatewayStateRef = useRef(gatewayState)
const reconnectingRef = useRef<Promise<HermesGateway | null> | null>(null)

View File

@@ -56,9 +56,7 @@ interface CommandsCatalogResponse {
}
function renderCommandsCatalog(catalog: CommandsCatalogResponse): string {
const sections = catalog.categories?.length
? catalog.categories
: [{ name: 'Commands', pairs: catalog.pairs ?? [] }]
const sections = catalog.categories?.length ? catalog.categories : [{ name: 'Commands', pairs: catalog.pairs ?? [] }]
const body = sections
.filter(section => section.pairs.length > 0)
@@ -120,12 +118,15 @@ export function usePromptActions({
async (rawText: string) => {
const visibleText = rawText.trim()
const attachments = $composerAttachments.get()
const contextRefs = attachments
.map(attachment => attachment.refText)
.filter(Boolean)
.join('\n')
const hasImageAttachment = attachments.some(attachment => attachment.kind === 'image')
const displayRefs = attachments.map(attachmentDisplayText).filter(Boolean).join('\n')
const text =
[contextRefs, visibleText].filter(Boolean).join('\n\n') ||
(hasImageAttachment ? 'What do you see in this image?' : '')
@@ -241,6 +242,7 @@ export function usePromptActions({
session_id: sessionId,
command: command.replace(/^\/+/, '')
})
const body = result?.output || `/${name}: no output`
renderSlashOutput(result?.warning ? `warning: ${result.warning}\n${body}` : body)
@@ -407,6 +409,7 @@ export function usePromptActions({
const messages = $messages.get()
const parentIndex = parentId ? messages.findIndex(message => message.id === parentId) : messages.length - 1
const userIndex =
parentIndex >= 0
? [...messages.slice(0, parentIndex + 1)].reverse().findIndex(message => message.role === 'user')
@@ -428,6 +431,7 @@ export function usePromptActions({
parentId && messages[parentIndex]?.role === 'assistant'
? messages[parentIndex]
: messages.slice(absoluteUserIndex + 1).find(message => message.role === 'assistant')
const branchGroupId = targetAssistant?.branchGroupId ?? branchGroupForUser(userMessage)
clearNotifications()
@@ -435,6 +439,7 @@ export function usePromptActions({
const nextUserIndex = state.messages.findIndex(
(message, index) => index > absoluteUserIndex && message.role === 'user'
)
const end = nextUserIndex < 0 ? state.messages.length : nextUserIndex
return {

View File

@@ -280,110 +280,121 @@ export function useSessionActions({
]
)
const branchCurrentSession = useCallback(async (messageId?: string): Promise<boolean> => {
const sourceSessionId = activeSessionIdRef.current
const branchCurrentSession = useCallback(
async (messageId?: string): Promise<boolean> => {
const sourceSessionId = activeSessionIdRef.current
if (!sourceSessionId) {
notify({
kind: 'warning',
title: 'Nothing to branch',
message: 'Start or resume a chat before branching.'
})
return false
}
if (busyRef.current) {
notify({
kind: 'warning',
title: 'Session busy',
message: 'Stop the current turn before branching this chat.'
})
return false
}
try {
const currentMessages = $messages.get()
const targetIndex = messageId ? currentMessages.findIndex(message => message.id === messageId) : -1
const branchStart = targetIndex >= 0 ? targetIndex : Math.max(currentMessages.length - 1, 0)
const branchEnd = targetIndex >= 0 ? targetIndex + 1 : currentMessages.length
const branchMessages = currentMessages
.slice(branchStart, branchEnd)
.map(message => ({
content: chatMessageText(message),
source: message,
role: message.role
}))
.filter(message => message.content.trim() && ['assistant', 'system', 'user'].includes(message.role))
if (!branchMessages.length) {
if (!sourceSessionId) {
notify({
kind: 'warning',
title: 'Nothing to branch',
message: 'This message has no text to branch from.'
message: 'Start or resume a chat before branching.'
})
return false
}
clearNotifications()
if (busyRef.current) {
notify({
kind: 'warning',
title: 'Session busy',
message: 'Stop the current turn before branching this chat.'
})
const branched = await requestGateway<SessionCreateResponse>('session.create', {
cols: 96,
messages: branchMessages.map(({ content, role }) => ({ content, role })),
title: 'Branch'
})
const routedSessionId = branched.stored_session_id ?? branched.session_id
setFreshDraftReady(false)
ensureSessionState(branched.session_id, routedSessionId)
setActiveSessionId(branched.session_id)
activeSessionIdRef.current = branched.session_id
updateSessionState(
branched.session_id,
state => ({
...state,
messages: branchMessages.map(({ source }) => source),
busy: false,
awaitingResponse: false
}),
routedSessionId
)
setSelectedStoredSessionId(routedSessionId)
selectedStoredSessionIdRef.current = routedSessionId
navigate(sessionRoute(routedSessionId))
clearComposerDraft()
clearComposerAttachments()
if (branched.info?.model) {
setCurrentModel(branched.info.model)
return false
}
if (branched.info?.provider) {
setCurrentProvider(branched.info.provider)
try {
const currentMessages = $messages.get()
const targetIndex = messageId ? currentMessages.findIndex(message => message.id === messageId) : -1
const branchStart = targetIndex >= 0 ? targetIndex : Math.max(currentMessages.length - 1, 0)
const branchEnd = targetIndex >= 0 ? targetIndex + 1 : currentMessages.length
const branchMessages = currentMessages
.slice(branchStart, branchEnd)
.map(message => ({
content: chatMessageText(message),
source: message,
role: message.role
}))
.filter(message => message.content.trim() && ['assistant', 'system', 'user'].includes(message.role))
if (!branchMessages.length) {
notify({
kind: 'warning',
title: 'Nothing to branch',
message: 'This message has no text to branch from.'
})
return false
}
clearNotifications()
const branched = await requestGateway<SessionCreateResponse>('session.create', {
cols: 96,
messages: branchMessages.map(({ content, role }) => ({ content, role })),
title: 'Branch'
})
const routedSessionId = branched.stored_session_id ?? branched.session_id
setFreshDraftReady(false)
ensureSessionState(branched.session_id, routedSessionId)
setActiveSessionId(branched.session_id)
activeSessionIdRef.current = branched.session_id
updateSessionState(
branched.session_id,
state => ({
...state,
messages: branchMessages.map(({ source }) => source),
busy: false,
awaitingResponse: false
}),
routedSessionId
)
setSelectedStoredSessionId(routedSessionId)
selectedStoredSessionIdRef.current = routedSessionId
navigate(sessionRoute(routedSessionId))
clearComposerDraft()
clearComposerAttachments()
if (branched.info?.model) {
setCurrentModel(branched.info.model)
}
if (branched.info?.provider) {
setCurrentProvider(branched.info.provider)
}
if (branched.info?.cwd) {
setCurrentCwd(branched.info.cwd)
}
setCurrentBranch(branched.info?.branch || '')
if (typeof branched.info?.personality === 'string') {
setCurrentPersonality(normalizePersonalityValue(branched.info.personality))
}
return true
} catch (err) {
notifyError(err, 'Branch failed')
return false
}
if (branched.info?.cwd) {
setCurrentCwd(branched.info.cwd)
}
setCurrentBranch(branched.info?.branch || '')
if (typeof branched.info?.personality === 'string') {
setCurrentPersonality(normalizePersonalityValue(branched.info.personality))
}
return true
} catch (err) {
notifyError(err, 'Branch failed')
return false
}
}, [activeSessionIdRef, busyRef, ensureSessionState, navigate, requestGateway, selectedStoredSessionIdRef, updateSessionState])
},
[
activeSessionIdRef,
busyRef,
ensureSessionState,
navigate,
requestGateway,
selectedStoredSessionIdRef,
updateSessionState
]
)
const removeSession = useCallback(
async (storedSessionId: string) => {

View File

@@ -73,7 +73,11 @@ function ConfigField({
<SelectContent>
{selectOptions.map(option => (
<SelectItem key={option || EMPTY_SELECT_VALUE} value={option || EMPTY_SELECT_VALUE}>
{option ? (optionLabels?.[option] ?? prettyName(option)) : schemaKey === 'display.personality' ? 'None' : '(none)'}
{option
? (optionLabels?.[option] ?? prettyName(option))
: schemaKey === 'display.personality'
? 'None'
: '(none)'}
</SelectItem>
))}
</SelectContent>
@@ -227,6 +231,7 @@ export function ConfigSettings({
void (async () => {
try {
await saveHermesConfig(config)
if (saveVersionRef.current === v) {
onConfigSaved?.()
}

View File

@@ -1,4 +1,16 @@
import { Brain, Lock, type LucideIcon, MessageCircle, Mic, Monitor, Moon, Palette, Sparkles, Sun, Wrench } from 'lucide-react'
import {
Brain,
Lock,
type LucideIcon,
MessageCircle,
Mic,
Monitor,
Moon,
Palette,
Sparkles,
Sun,
Wrench
} from 'lucide-react'
import type { ThemeMode } from '@/themes/context'

View File

@@ -10,7 +10,7 @@ export const prettyName = (v: string) => v.replace(/_/g, ' ').replace(/\b\w/g, c
export const toolNames = (t: ToolsetInfo) => (Array.isArray(t.tools) ? t.tools.map(asText).filter(Boolean) : [])
export const withoutKey = <T,>(record: Record<string, T>, key: string) => {
export const withoutKey = <T>(record: Record<string, T>, key: string) => {
const next = { ...record }
delete next[key]

View File

@@ -9,7 +9,15 @@ import { notify, notifyError } from '@/store/notifications'
import type { EnvVarInfo } from '@/types/hermes'
import { CONTROL_TEXT } from './constants'
import { asText, includesQuery, prettyName, providerGroup, providerPriority, redactedValue, withoutKey } from './helpers'
import {
asText,
includesQuery,
prettyName,
providerGroup,
providerPriority,
redactedValue,
withoutKey
} from './helpers'
import { LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
import type { EnvPatch, EnvRowProps, ProviderGroup, SearchProps } from './types'
@@ -218,6 +226,7 @@ export function KeysSettings({ query }: SearchProps) {
void (async () => {
try {
const next = await getEnvVars()
if (!cancelled) {
setVars(next)
}

View File

@@ -34,11 +34,7 @@ function filteredSkills(skills: SkillInfo[], query: string, category: string | n
return true
}
return (
includesQuery(skill.name, q) ||
includesQuery(skill.description, q) ||
includesQuery(skill.category, q)
)
return includesQuery(skill.name, q) || includesQuery(skill.description, q) || includesQuery(skill.category, q)
})
.sort((a, b) => asText(a.name).localeCompare(asText(b.name)))
}
@@ -177,14 +173,21 @@ export function SkillsView({ setTitlebarActions, ...props }: SkillsViewProps) {
>
<header className={titlebarHeaderBaseClass}>
<h2 className="text-base font-semibold leading-none tracking-tight">Skills</h2>
<span className="text-xs text-muted-foreground">{enabledSkills}/{totalSkills} enabled</span>
<span className="text-xs text-muted-foreground">
{enabledSkills}/{totalSkills} enabled
</span>
</header>
<div className="min-h-0 flex-1 overflow-hidden rounded-[1.0625rem] border border-border/50 bg-background/85">
<div className="border-b border-border/50 px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<ModeButton active={mode === 'skills'} icon={Brain} onClick={() => setMode('skills')} text="Skills" />
<ModeButton active={mode === 'toolsets'} icon={Wrench} onClick={() => setMode('toolsets')} text="Toolsets" />
<ModeButton
active={mode === 'toolsets'}
icon={Wrench}
onClick={() => setMode('toolsets')}
text="Toolsets"
/>
<div className="ml-auto w-full max-w-sm min-w-64">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
@@ -246,7 +249,10 @@ export function SkillsView({ setTitlebarActions, ...props }: SkillsViewProps) {
</div>
<div className="divide-y divide-border/40 rounded-lg border border-border/40 bg-background/70">
{list.map(skill => (
<div className="grid gap-3 px-3 py-2.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center" key={skill.name}>
<div
className="grid gap-3 px-3 py-2.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center"
key={skill.name}
>
<div className="min-w-0">
<div className="truncate text-sm font-medium">{skill.name}</div>
<p className="mt-0.5 text-xs text-muted-foreground">
@@ -272,7 +278,9 @@ export function SkillsView({ setTitlebarActions, ...props }: SkillsViewProps) {
<EmptyState description="Try a broader search query." title="No toolsets found" />
) : (
<div className="space-y-2">
<div className="text-xs text-muted-foreground">{enabledToolsets}/{toolsets.length} toolsets enabled</div>
<div className="text-xs text-muted-foreground">
{enabledToolsets}/{toolsets.length} toolsets enabled
</div>
<div className="divide-y divide-border/40 rounded-lg border border-border/40 bg-background/70">
{visibleToolsets.map(toolset => {
const tools = toolNames(toolset)
@@ -289,7 +297,9 @@ export function SkillsView({ setTitlebarActions, ...props }: SkillsViewProps) {
</StatusPill>
</div>
</div>
<p className="mt-1 text-xs text-muted-foreground">{asText(toolset.description) || 'No description.'}</p>
<p className="mt-1 text-xs text-muted-foreground">
{asText(toolset.description) || 'No description.'}
</p>
{tools.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{tools.map(name => (

View File

@@ -32,12 +32,7 @@ const ICONS: Record<HermesRefType, ComponentType<{ className?: string }>> = {
const CANONICAL_DIRECTIVE_RE = /:([\w-]{1,64})\[([^\]\n]{1,1024})\](?:\{name=([^}\n]{1,1024})\})?/g
const HERMES_DIRECTIVE_RE = new RegExp(
'@(file|folder|url|image|tool):(' +
'`[^`\\n]+`' +
'|"[^"\\n]+"' +
"|'[^'\\n]+'" +
'|\\S+' +
')',
'@(file|folder|url|image|tool):(' + '`[^`\\n]+`' + '|"[^"\\n]+"' + "|'[^'\\n]+'" + '|\\S+' + ')',
'g'
)
@@ -59,7 +54,7 @@ function unwrapRefValue(raw: string): string {
}
function needsQuoting(value: string): boolean {
return /[\s()\[\]{}<>"'`]/.test(value)
return /[\s()[\]{}<>"'`]/.test(value)
}
export function formatRefValue(value: string): string {

View File

@@ -21,15 +21,7 @@ import {
Volume2Icon,
VolumeXIcon
} from 'lucide-react'
import {
type FC,
type ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState
} from 'react'
import { type FC, type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
import { ActivityTimerText } from '@/components/assistant-ui/activity-timer-text'
@@ -256,7 +248,9 @@ export const Thread: FC<{
scrollToBottomOnThreadSwitch
>
<div className="flex w-full flex-col gap-3" ref={contentRef}>
<ThreadPrimitive.Messages>{() => <ThreadMessage onBranchInNewChat={onBranchInNewChat} />}</ThreadPrimitive.Messages>
<ThreadPrimitive.Messages>
{() => <ThreadMessage onBranchInNewChat={onBranchInNewChat} />}
</ThreadPrimitive.Messages>
{loading === 'response' && <ResponseLoadingIndicator />}
{loading === 'working' && <WorkingIndicator />}
</div>
@@ -384,7 +378,12 @@ const WorkingIndicator: FC = () => {
return (
<StatusRow label="Hermes is still working">
<Loader className="size-4 text-muted-foreground/60" label="Still working" strokeScale={0.65} type="spiral-search" />
<Loader
className="size-4 text-muted-foreground/60"
label="Still working"
strokeScale={0.65}
type="spiral-search"
/>
<span className="shimmer min-w-0 truncate text-muted-foreground/60">Still working</span>
<ActivityTimerText seconds={elapsed} />
</StatusRow>

View File

@@ -176,4 +176,3 @@ export const ToolFallback = ({ toolName, args, result }: ToolCallMessagePartProp
</div>
)
}

View File

@@ -134,7 +134,9 @@ function NotificationDetail({ detail }: { detail: string }) {
Details
</summary>
<div className="mt-1 rounded-md border border-border/70 bg-background/65 p-2">
<pre className="max-h-32 whitespace-pre-wrap wrap-break-word font-mono text-[0.6875rem] leading-relaxed">{detail}</pre>
<pre className="max-h-32 whitespace-pre-wrap wrap-break-word font-mono text-[0.6875rem] leading-relaxed">
{detail}
</pre>
<button
className="mt-1 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[0.6875rem] text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={copyDetail}

View File

@@ -52,7 +52,10 @@ interface LoaderProps extends Omit<ComponentProps<'div'>, 'children'> {
type?: LoaderType
}
interface BaseCurveOptions extends Pick<LoaderCurve, 'durationMs' | 'particleCount' | 'pulseDurationMs' | 'strokeWidth' | 'trailSpan'> {
interface BaseCurveOptions extends Pick<
LoaderCurve,
'durationMs' | 'particleCount' | 'pulseDurationMs' | 'strokeWidth' | 'trailSpan'
> {
point?: LoaderCurve['point']
rotate?: boolean
rotationDurationMs?: number
@@ -206,8 +209,7 @@ const LOADER_CURVES: Record<LoaderType, LoaderCurve> = {
point(progress, detailScale) {
const t = progress * Math.PI * 12
const butterfly =
Math.exp(Math.cos(t)) - 2 * Math.cos(4 * t) - Math.sin(t / 12) ** 5
const butterfly = Math.exp(Math.cos(t)) - 2 * Math.cos(4 * t) - Math.sin(t / 12) ** 5
const scale = 4.6 + detailScale * 0.45
@@ -520,7 +522,9 @@ function buildPath(config: LoaderCurve, detailScale: number, steps: number) {
}
function detailScaleFor(time: number, config: LoaderCurve, phaseOffset: number) {
const pulseProgress = ((time + phaseOffset * config.pulseDurationMs) % config.pulseDurationMs) / config.pulseDurationMs
const pulseProgress =
((time + phaseOffset * config.pulseDurationMs) % config.pulseDurationMs) / config.pulseDurationMs
const pulseAngle = pulseProgress * TWO_PI
return 0.52 + ((Math.sin(pulseAngle + 0.55) + 1) / 2) * 0.48
@@ -548,5 +552,7 @@ function rotationFor(time: number, config: LoaderCurve, phaseOffset: number) {
return 0
}
return -(((time + phaseOffset * config.rotationDurationMs) % config.rotationDurationMs) / config.rotationDurationMs) * 360
return (
-(((time + phaseOffset * config.rotationDurationMs) % config.rotationDurationMs) / config.rotationDurationMs) * 360
)
}

View File

@@ -437,7 +437,9 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
})
flushPendingTools(messages.length)
return withUniqueToolCallIds(result.filter(m => chatMessageText(m).trim() || m.parts.some(part => part.type !== 'text')))
return withUniqueToolCallIds(
result.filter(m => chatMessageText(m).trim() || m.parts.some(part => part.type !== 'text'))
)
}
export function branchGroupForUser(userMessage: ChatMessage): string {

View File

@@ -1,5 +1,4 @@
const EMOJI_RE =
/(?:[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}]|[\u{FE0F}\u{200D}]|[\u{E0020}-\u{E007F}])+/gu
const EMOJI_RE = /(?:[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}]|[\u{FE0F}\u{200D}]|[\u{E0020}-\u{E007F}])+/gu
const FENCED_CODE_RE = /```[\s\S]*?(?:```|$)/g
const INLINE_CODE_RE = /`([^`]+)`/g

View File

@@ -38,10 +38,7 @@ const DENSITY_MULTIPLIERS: Record<ThemeDensity, string> = {
const INJECTED_FONT_URLS = new Set<string>()
const SKIN_THEME_LIST = BUILTIN_THEME_LIST.filter(t => t.name !== 'nous-light')
function effectiveMode(
mode: ThemeMode,
systemDark = matchesQuery('(prefers-color-scheme: dark)')
): 'light' | 'dark' {
function effectiveMode(mode: ThemeMode, systemDark = matchesQuery('(prefers-color-scheme: dark)')): 'light' | 'dark' {
return mode === 'system' ? (systemDark ? 'dark' : 'light') : mode
}